From 83643f84abbb88c0f1c83eedc18dcb3488e50393 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 1 Mar 2026 16:07:07 -0800 Subject: [PATCH] feat: implement GET /api/v1/domains and /api/v1/hostnames endpoints Implement the two API endpoints documented in the README that were previously returning 404: - GET /api/v1/domains: Returns configured domains with their discovered nameservers, last check time, and status (ok/pending). - GET /api/v1/hostnames: Returns configured hostnames with per-nameserver DNS records, status, and last check time. Both endpoints read from the existing state store and config, requiring no new dependencies. Nameserver results in the hostnames endpoint are sorted for deterministic output. Closes #67 --- internal/handlers/domains.go | 60 +++++++++++++++++ internal/handlers/handlers.go | 10 ++- internal/handlers/hostnames.go | 120 +++++++++++++++++++++++++++++++++ internal/server/routes.go | 2 + 4 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 internal/handlers/domains.go create mode 100644 internal/handlers/hostnames.go diff --git a/internal/handlers/domains.go b/internal/handlers/domains.go new file mode 100644 index 0000000..9c56a88 --- /dev/null +++ b/internal/handlers/domains.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "net/http" + "time" +) + +// domainResponse represents a single domain in the API response. +type domainResponse struct { + Domain string `json:"domain"` + Nameservers []string `json:"nameservers,omitempty"` + LastChecked string `json:"lastChecked,omitempty"` + Status string `json:"status"` +} + +// domainsResponse is the top-level response for GET /api/v1/domains. +type domainsResponse struct { + Domains []domainResponse `json:"domains"` +} + +// HandleDomains returns the configured domains and their status. +func (h *Handlers) HandleDomains() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + configured := h.config.Domains + snapshot := h.state.GetSnapshot() + + domains := make( + []domainResponse, 0, len(configured), + ) + + for _, domain := range configured { + dr := domainResponse{ + Domain: domain, + Status: "pending", + } + + ds, ok := snapshot.Domains[domain] + if ok { + dr.Nameservers = ds.Nameservers + dr.Status = "ok" + + if !ds.LastChecked.IsZero() { + dr.LastChecked = ds.LastChecked. + Format(time.RFC3339) + } + } + + domains = append(domains, dr) + } + + h.respondJSON( + writer, request, + &domainsResponse{Domains: domains}, + http.StatusOK, + ) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 474c1bc..a791d9e 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -8,9 +8,11 @@ import ( "go.uber.org/fx" + "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/healthcheck" "sneak.berlin/go/dnswatcher/internal/logger" + "sneak.berlin/go/dnswatcher/internal/state" ) // Params contains dependencies for Handlers. @@ -20,6 +22,8 @@ type Params struct { Logger *logger.Logger Globals *globals.Globals Healthcheck *healthcheck.Healthcheck + State *state.State + Config *config.Config } // Handlers provides HTTP request handlers. @@ -28,6 +32,8 @@ type Handlers struct { params *Params globals *globals.Globals hc *healthcheck.Healthcheck + state *state.State + config *config.Config } // New creates a new Handlers instance. @@ -37,6 +43,8 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) { params: ¶ms, globals: params.Globals, hc: params.Healthcheck, + state: params.State, + config: params.Config, }, nil } @@ -44,7 +52,7 @@ func (h *Handlers) respondJSON( writer http.ResponseWriter, _ *http.Request, data any, - status int, + status int, //nolint:unparam // general-purpose utility; status varies in future use ) { writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(status) diff --git a/internal/handlers/hostnames.go b/internal/handlers/hostnames.go new file mode 100644 index 0000000..58a8402 --- /dev/null +++ b/internal/handlers/hostnames.go @@ -0,0 +1,120 @@ +package handlers + +import ( + "net/http" + "sort" + "time" + + "sneak.berlin/go/dnswatcher/internal/state" +) + +// nameserverRecordResponse represents one nameserver's records +// in the API response. +type nameserverRecordResponse struct { + Nameserver string `json:"nameserver"` + Records map[string][]string `json:"records"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + LastChecked string `json:"lastChecked,omitempty"` +} + +// hostnameResponse represents a single hostname in the API response. +type hostnameResponse struct { + Hostname string `json:"hostname"` + Nameservers []nameserverRecordResponse `json:"nameservers,omitempty"` + LastChecked string `json:"lastChecked,omitempty"` + Status string `json:"status"` +} + +// hostnamesResponse is the top-level response for +// GET /api/v1/hostnames. +type hostnamesResponse struct { + Hostnames []hostnameResponse `json:"hostnames"` +} + +// HandleHostnames returns the configured hostnames and their status. +func (h *Handlers) HandleHostnames() http.HandlerFunc { + return func( + writer http.ResponseWriter, + request *http.Request, + ) { + configured := h.config.Hostnames + snapshot := h.state.GetSnapshot() + + hostnames := make( + []hostnameResponse, 0, len(configured), + ) + + for _, hostname := range configured { + hr := hostnameResponse{ + Hostname: hostname, + Status: "pending", + } + + hs, ok := snapshot.Hostnames[hostname] + if ok { + hr.Status = "ok" + + if !hs.LastChecked.IsZero() { + hr.LastChecked = hs.LastChecked. + Format(time.RFC3339) + } + + hr.Nameservers = buildNameserverRecords( + hs, + ) + } + + hostnames = append(hostnames, hr) + } + + h.respondJSON( + writer, request, + &hostnamesResponse{Hostnames: hostnames}, + http.StatusOK, + ) + } +} + +// buildNameserverRecords converts the per-nameserver state map +// into a sorted slice for deterministic JSON output. +func buildNameserverRecords( + hs *state.HostnameState, +) []nameserverRecordResponse { + if hs.RecordsByNameserver == nil { + return nil + } + + nsNames := make( + []string, 0, len(hs.RecordsByNameserver), + ) + for ns := range hs.RecordsByNameserver { + nsNames = append(nsNames, ns) + } + + sort.Strings(nsNames) + + records := make( + []nameserverRecordResponse, 0, len(nsNames), + ) + + for _, ns := range nsNames { + nsr := hs.RecordsByNameserver[ns] + + entry := nameserverRecordResponse{ + Nameserver: ns, + Records: nsr.Records, + Status: nsr.Status, + Error: nsr.Error, + } + + if !nsr.LastChecked.IsZero() { + entry.LastChecked = nsr.LastChecked. + Format(time.RFC3339) + } + + records = append(records, entry) + } + + return records +} diff --git a/internal/server/routes.go b/internal/server/routes.go index cd07ba1..df5d5c9 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -28,6 +28,8 @@ func (s *Server) SetupRoutes() { // API v1 routes s.router.Route("/api/v1", func(r chi.Router) { r.Get("/status", s.handlers.HandleStatus()) + r.Get("/domains", s.handlers.HandleDomains()) + r.Get("/hostnames", s.handlers.HandleHostnames()) }) // Metrics endpoint (optional, with basic auth) -- 2.49.1