From 857f504d36a5ced7c1bf0d99aaaed9b1b230042c Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 12:03:56 +0200 Subject: [PATCH] feat(api): /update, /healthz, /version handlers --- internal/api/handlers.go | 133 +++++++++++++++++++++++++++ internal/api/handlers_test.go | 168 ++++++++++++++++++++++++++++++++++ internal/api/types.go | 39 ++++++++ 3 files changed, 340 insertions(+) create mode 100644 internal/api/handlers.go create mode 100644 internal/api/handlers_test.go create mode 100644 internal/api/types.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..b3392b8 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,133 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/docker/docker/api/types" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/shcizo/package-updater/internal/logging" + "github.com/shcizo/package-updater/internal/updater" +) + +// Finder is the discovery interface used by the update handler. +type Finder interface { + FindJobs(ctx context.Context, image string) ([]discovery.Job, error) +} + +// Submitter is the queue interface used by the update handler. +type Submitter interface { + Submit(ctx context.Context, jobs []discovery.Job) []updater.Result +} + +// Pinger pings the Docker daemon for the healthcheck. +type Pinger interface { + Ping(ctx context.Context) (types.Ping, error) +} + +// Handlers wires the HTTP endpoints to the rest of the service. +type Handlers struct { + finder Finder + submitter Submitter + pinger Pinger + version string + commit string + buildTime string +} + +// NewHandlers constructs a Handlers value with all dependencies injected. +func NewHandlers(f Finder, s Submitter, p Pinger, version, commit, buildTime string) *Handlers { + return &Handlers{finder: f, submitter: s, pinger: p, version: version, commit: commit, buildTime: buildTime} +} + +// Update implements POST /update. +func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) { + var req UpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid JSON body") + return + } + if req.Image == "" { + writeJSONError(w, http.StatusBadRequest, "image is required") + return + } + + jobs, err := h.finder.FindJobs(r.Context(), req.Image) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "discovery failed: "+err.Error()) + return + } + + resp := UpdateResponse{ + RequestID: logging.RequestIDFrom(r.Context()), + Image: req.Image, + Tag: req.Tag, + Matched: len(jobs), + Results: []ResultDTO{}, + } + + if len(jobs) == 0 { + writeJSON(w, http.StatusOK, resp) + return + } + + results := h.submitter.Submit(r.Context(), jobs) + updated, failed := 0, 0 + for _, res := range results { + composeFile := "" + if len(res.Job.ConfigFiles) > 0 { + composeFile = res.Job.ConfigFiles[0] + } + resp.Results = append(resp.Results, ResultDTO{ + Project: res.Job.Project, + Service: res.Job.Service, + ComposeFile: composeFile, + Status: string(res.Status), + Error: res.Error, + DurationMs: res.DurationMs, + }) + if res.Status == updater.StatusUpdated { + updated++ + } else { + failed++ + } + } + + switch { + case failed == 0: + writeJSON(w, http.StatusOK, resp) + case updated == 0: + writeJSON(w, http.StatusInternalServerError, resp) + default: + writeJSON(w, http.StatusMultiStatus, resp) + } +} + +// Healthz implements GET /healthz. +func (h *Handlers) Healthz(w http.ResponseWriter, r *http.Request) { + if _, err := h.pinger.Ping(r.Context()); err != nil { + writeJSON(w, http.StatusServiceUnavailable, HealthResponse{Status: "unhealthy", Docker: "unreachable"}) + return + } + writeJSON(w, http.StatusOK, HealthResponse{Status: "ok", Docker: "ok"}) +} + +// Version implements GET /version. +func (h *Handlers) Version(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, VersionResponse{ + Version: h.version, + Commit: h.commit, + BuildTime: h.buildTime, + }) +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +func writeJSONError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..3de30e7 --- /dev/null +++ b/internal/api/handlers_test.go @@ -0,0 +1,168 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/docker/docker/api/types" + "github.com/shcizo/package-updater/internal/api" + "github.com/shcizo/package-updater/internal/discovery" + "github.com/shcizo/package-updater/internal/updater" + "github.com/stretchr/testify/require" +) + +type fakeFinder struct { + jobs []discovery.Job + err error +} + +func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) { + return f.jobs, f.err +} + +type fakeSubmitter struct { + results []updater.Result +} + +func (f *fakeSubmitter) Submit(_ context.Context, jobs []discovery.Job) []updater.Result { + if f.results != nil { + return f.results + } + out := make([]updater.Result, len(jobs)) + for i, j := range jobs { + out[i] = updater.Result{Job: j, Status: updater.StatusUpdated} + } + return out +} + +type fakePinger struct{ err error } + +func (f *fakePinger) Ping(_ context.Context) (types.Ping, error) { + return types.Ping{}, f.err +} + +func decode[T any](t *testing.T, body io.Reader) T { + t.Helper() + var v T + require.NoError(t, json.NewDecoder(body).Decode(&v)) + return v +} + +func TestUpdate_ValidationError(t *testing.T) { + h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now") + req := httptest.NewRequest(http.MethodPost, "/update", + strings.NewReader(`{"tag":"v1.2.3"}`)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdate_BadJSON(t *testing.T) { + h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now") + req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`not json`)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestUpdate_DiscoveryFailureReturns500(t *testing.T) { + finder := &fakeFinder{err: errors.New("daemon unreachable")} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now") + body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestUpdate_ZeroMatchesReturns200(t *testing.T) { + h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now") + body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusOK, w.Code) + resp := decode[api.UpdateResponse](t, w.Body) + require.Equal(t, 0, resp.Matched) +} + +func TestUpdate_AllSucceeded200(t *testing.T) { + finder := &fakeFinder{jobs: []discovery.Job{ + {Project: "p", Service: "s", WorkingDir: "/x", ConfigFiles: []string{"/x/c.yml"}}, + }} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now") + body, _ := json.Marshal(api.UpdateRequest{Image: "r/x", Tag: "v1"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusOK, w.Code) + resp := decode[api.UpdateResponse](t, w.Body) + require.Equal(t, 1, resp.Matched) + require.Equal(t, "updated", resp.Results[0].Status) + require.Equal(t, "/x/c.yml", resp.Results[0].ComposeFile) +} + +func TestUpdate_MixedReturns207(t *testing.T) { + jobs := []discovery.Job{ + {Project: "p1", Service: "s", ConfigFiles: []string{"/x/c.yml"}}, + {Project: "p2", Service: "s", ConfigFiles: []string{"/y/c.yml"}}, + } + finder := &fakeFinder{jobs: jobs} + submitter := &fakeSubmitter{results: []updater.Result{ + {Job: jobs[0], Status: updater.StatusUpdated}, + {Job: jobs[1], Status: updater.StatusFailed, Error: "boom"}, + }} + h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now") + body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusMultiStatus, w.Code) +} + +func TestUpdate_AllFailedReturns500(t *testing.T) { + jobs := []discovery.Job{{Project: "p", Service: "s", ConfigFiles: []string{"/x/c.yml"}}} + finder := &fakeFinder{jobs: jobs} + submitter := &fakeSubmitter{results: []updater.Result{ + {Job: jobs[0], Status: updater.StatusFailed, Error: "boom"}, + }} + h := api.NewHandlers(finder, submitter, &fakePinger{}, "v0.0.0", "abc", "now") + body, _ := json.Marshal(api.UpdateRequest{Image: "r/x"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestHealthz_OKWhenDockerUp(t *testing.T) { + h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now") + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + h.Healthz(w, req) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestHealthz_503WhenDockerDown(t *testing.T) { + h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{err: errors.New("ping fail")}, "v0.0.0", "abc", "now") + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + h.Healthz(w, req) + require.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestVersion(t *testing.T) { + h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v1.2.3", "abcdef", "2026-05-22T00:00:00Z") + req := httptest.NewRequest(http.MethodGet, "/version", nil) + w := httptest.NewRecorder() + h.Version(w, req) + require.Equal(t, http.StatusOK, w.Code) + resp := decode[api.VersionResponse](t, w.Body) + require.Equal(t, "v1.2.3", resp.Version) +} diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..fc3cf51 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,39 @@ +package api + +// UpdateRequest is the body of POST /update. +type UpdateRequest struct { + Image string `json:"image"` + Tag string `json:"tag,omitempty"` +} + +// UpdateResponse is the body of POST /update. +type UpdateResponse struct { + RequestID string `json:"request_id"` + Image string `json:"image"` + Tag string `json:"tag,omitempty"` + Matched int `json:"matched"` + Results []ResultDTO `json:"results"` +} + +// ResultDTO is one row in UpdateResponse.Results. +type ResultDTO struct { + Project string `json:"project"` + Service string `json:"service"` + ComposeFile string `json:"compose_file"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` +} + +// HealthResponse is the body of GET /healthz. +type HealthResponse struct { + Status string `json:"status"` + Docker string `json:"docker"` +} + +// VersionResponse is the body of GET /version. +type VersionResponse struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildTime string `json:"build_time"` +}