From fdf90a7c23e5784f602b41076ab4e8576962d7f3 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:28:06 +0700 Subject: [PATCH] =?UTF-8?q?feat(api):=20chi-=D1=80=D0=BE=D1=83=D1=82=D0=B5?= =?UTF-8?q?=D1=80,=20check/apply=20=D1=85=D0=B5=D0=BD=D0=B4=D0=BB=D0=B5?= =?UTF-8?q?=D1=80=D1=8B,=20changeset=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + go.sum | 2 + internal/api/api.go | 40 ++++++++++++++++++ internal/api/api_test.go | 90 ++++++++++++++++++++++++++++++++++++++++ internal/api/dto.go | 54 ++++++++++++++++++++++++ internal/api/handlers.go | 56 +++++++++++++++++++++++++ 6 files changed, 243 insertions(+) create mode 100644 internal/api/api.go create mode 100644 internal/api/api_test.go create mode 100644 internal/api/dto.go create mode 100644 internal/api/handlers.go diff --git a/go.mod b/go.mod index ac55f81..986f736 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/vasyakrg/dns-autoresolver go 1.26.4 require ( + github.com/go-chi/chi/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/pressly/goose/v3 v3.27.2 diff --git a/go.sum b/go.sum index c4c01fb..23b3e13 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..d856cf5 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,40 @@ +package api + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/diff" + "github.com/vasyakrg/dns-autoresolver/internal/service" +) + +// CheckApplier is the service surface the API depends on. +type CheckApplier interface { + Check(ctx context.Context, domainID uuid.UUID) (diff.Changeset, error) + Apply(ctx context.Context, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) +} + +// API holds handler dependencies. Store/Cipher are used by CRUD handlers +// (added by the implementer following the accounts pattern). +type API struct { + Svc CheckApplier +} + +func NewRouter(a *API) http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.Recoverer) + + r.Route("/api/v1/projects/{pid}", func(r chi.Router) { + r.Route("/domains/{did}", func(r chi.Router) { + r.Get("/check", a.handleCheck) + r.Post("/apply", a.handleApply) + }) + // accounts/templates/domains CRUD маунтятся тем же паттерном (Task 4 sqlc-методы) + }) + return r +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..c7bf992 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/diff" + "github.com/vasyakrg/dns-autoresolver/internal/model" + "github.com/vasyakrg/dns-autoresolver/internal/service" +) + +type mockCheckApplier struct { + lastReq service.ApplyRequest +} + +func (m *mockCheckApplier) Check(context.Context, uuid.UUID) (diff.Changeset, error) { + d := model.Record{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}} + return diff.Changeset{Diffs: []diff.RecordDiff{{Kind: diff.Add, Type: d.Type, Name: d.Name, Desired: &d}}}, nil +} +func (m *mockCheckApplier) Apply(_ context.Context, _ uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) { + m.lastReq = req + return diff.Changeset{}, nil +} + +func newTestAPI() (*API, *mockCheckApplier) { + m := &mockCheckApplier{} + return &API{Svc: m}, m // остальные зависимости (store/cipher) nil — CRUD-тесты добавит реализатор +} + +func TestCheckEndpoint(t *testing.T) { + a, _ := newTestAPI() + router := NewRouter(a) + + did := uuid.New().String() + req := httptest.NewRequest(http.MethodGet, + "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/check", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d, body %s", w.Code, w.Body.String()) + } + var resp changesetResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Updates) != 1 { + t.Fatalf("expected 1 update in response, got %+v", resp) + } +} + +func TestApplyDefaultsPruneFalse(t *testing.T) { + a, m := newTestAPI() + router := NewRouter(a) + + did := uuid.New().String() + body := `{"applyUpdates":true}` // applyPrunes отсутствует → false + req := httptest.NewRequest(http.MethodPost, + "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/"+did+"/apply", + strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d body %s", w.Code, w.Body.String()) + } + if m.lastReq.ApplyPrunes != false || m.lastReq.ApplyUpdates != true { + t.Fatalf("apply request mismatch: %+v", m.lastReq) + } +} + +func TestApplyBadUUID(t *testing.T) { + a, _ := newTestAPI() + router := NewRouter(a) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/projects/00000000-0000-0000-0000-000000000002/domains/not-a-uuid/apply", + bytes.NewReader([]byte(`{}`))) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for bad uuid, got %d", w.Code) + } +} diff --git a/internal/api/dto.go b/internal/api/dto.go new file mode 100644 index 0000000..fdc5a13 --- /dev/null +++ b/internal/api/dto.go @@ -0,0 +1,54 @@ +package api + +import "github.com/vasyakrg/dns-autoresolver/internal/diff" + +type applyRequest struct { + ApplyUpdates bool `json:"applyUpdates"` + ApplyPrunes bool `json:"applyPrunes"` +} + +type recordView struct { + Kind string `json:"kind"` + Type string `json:"type"` + Name string `json:"name"` + Desired []string `json:"desired,omitempty"` + Actual []string `json:"actual,omitempty"` + ReadOnly bool `json:"readOnly"` +} + +type changesetResponse struct { + Updates []recordView `json:"updates"` + Prunes []recordView `json:"prunes"` + ReadOnly []recordView `json:"readOnly"` + InSync int `json:"inSyncCount"` +} + +func toRecordView(d diff.RecordDiff) recordView { + rv := recordView{Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly} + if d.Desired != nil { + rv.Desired = d.Desired.Values + } + if d.Actual != nil { + rv.Actual = d.Actual.Values + } + return rv +} + +func toChangesetResponse(cs diff.Changeset) changesetResponse { + resp := changesetResponse{} + for _, d := range cs.Updates() { + resp.Updates = append(resp.Updates, toRecordView(d)) + } + for _, d := range cs.Prunes() { + resp.Prunes = append(resp.Prunes, toRecordView(d)) + } + for _, d := range cs.Diffs { + if d.ReadOnly { + resp.ReadOnly = append(resp.ReadOnly, toRecordView(d)) + } + if d.Kind == diff.InSync { + resp.InSync++ + } + } + return resp +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..4890618 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,56 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/service" +) + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func (a *API) handleCheck(w http.ResponseWriter, r *http.Request) { + did, err := uuid.Parse(chi.URLParam(r, "did")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid domain id") + return + } + cs, err := a.Svc.Check(r.Context(), did) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, toChangesetResponse(cs)) +} + +func (a *API) handleApply(w http.ResponseWriter, r *http.Request) { + did, err := uuid.Parse(chi.URLParam(r, "did")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid domain id") + return + } + var req applyRequest + if r.Body != nil { + // пустое тело допустимо → значения по умолчанию (prune=false) + _ = json.NewDecoder(r.Body).Decode(&req) + } + cs, err := a.Svc.Apply(r.Context(), did, service.ApplyRequest{ + ApplyUpdates: req.ApplyUpdates, ApplyPrunes: req.ApplyPrunes, + }) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, toChangesetResponse(cs)) +}