feat(api): chi-роутер, check/apply хендлеры, changeset DTO
This commit is contained in:
@@ -3,6 +3,7 @@ module github.com/vasyakrg/dns-autoresolver
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/pressly/goose/v3 v3.27.2
|
github.com/pressly/goose/v3 v3.27.2
|
||||||
|
|||||||
@@ -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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user