// Package httpapi exposes the addrkit domain over a small, versioned JSON API. // // It contains no business logic: handlers decode requests, call the resolver // and the outages source, and encode responses. Cross-cutting concerns // (timeouts, recovery, request ids, logging) live in middleware. package httpapi import ( "context" "encoding/json" "errors" "log/slog" "net/http" "time" "git.totmin.ru/en2zmax/addrkit/internal/domain/address" "git.totmin.ru/en2zmax/addrkit/internal/domain/match" "git.totmin.ru/en2zmax/addrkit/internal/domain/outages" ) // Resolver is the read side of the domain (implemented by *match.Resolver). type Resolver interface { Resolve(ctx context.Context, raw string) match.Result Version() string Ready() bool } // Server wires the domain to HTTP. type Server struct { resolver Resolver outages outages.Source log *slog.Logger now func() time.Time loc *time.Location timeout time.Duration } // New builds a server. loc controls how outage dates are interpreted; it // should be the operational timezone (Asia/Novosibirsk). func New(resolver Resolver, src outages.Source, log *slog.Logger, loc *time.Location) *Server { if loc == nil { loc = time.UTC } return &Server{ resolver: resolver, outages: src, log: log, now: time.Now, loc: loc, timeout: 10 * time.Second, } } // Handler returns the fully wrapped HTTP handler. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /v1/resolve", s.handleResolve) mux.HandleFunc("POST /v1/outages", s.handleOutages) mux.HandleFunc("GET /v1/healthz", s.handleHealthz) mux.HandleFunc("GET /v1/readyz", s.handleReadyz) mux.HandleFunc("GET /v1/version", s.handleVersion) return recoverMW(s.log)(requestIDMW(loggingMW(s.log)(timeoutMW(s.timeout)(mux)))) } type resolveRequest struct { Address string `json:"address"` } func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) { req, ok := s.decode(w, r) if !ok { return } writeJSON(w, http.StatusOK, s.resolver.Resolve(r.Context(), req.Address)) } type outagesResponse struct { Status address.Status `json:"status"` Address string `json:"address,omitempty"` District string `json:"district,omitempty"` Outages []outages.Outage `json:"outages,omitempty"` AsOf string `json:"as_of,omitempty"` DataVersion string `json:"data_version,omitempty"` } func (s *Server) handleOutages(w http.ResponseWriter, r *http.Request) { req, ok := s.decode(w, r) if !ok { return } ctx := r.Context() res := s.resolver.Resolve(ctx, req.Address) out := outagesResponse{ Status: res.Status, Address: res.Address, District: res.District, DataVersion: res.DataVersion, } if res.Status != address.StatusFound { // No canonical address => nothing to query. Return the resolver's // verdict so the caller can ask for clarification. writeJSON(w, http.StatusOK, out) return } raw, err := s.outages.Query(ctx, res.Address) if err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { out.Status = address.StatusPortalTimeout } else { out.Status = address.StatusInfra } writeJSON(w, http.StatusOK, out) return } parsed := address.Parse(res.Address) current := outages.Current(raw, s.now(), s.loc) out.Outages = outages.MatchingHouse(current, parsed) out.AsOf = s.now().In(s.loc).Format(time.RFC3339) writeJSON(w, http.StatusOK, out) } func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleReadyz(w http.ResponseWriter, _ *http.Request) { if !s.resolver.Ready() { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"}) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } func (s *Server) handleVersion(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"data_version": s.resolver.Version()}) } // decode reads the JSON body into a resolveRequest, writing a 400 on failure. func (s *Server) decode(w http.ResponseWriter, r *http.Request) (resolveRequest, bool) { var req resolveRequest r.Body = http.MaxBytesReader(w, r.Body, 64*1024) if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json body") return resolveRequest{}, false } if req.Address == "" { writeError(w, http.StatusBadRequest, "address is required") return resolveRequest{}, false } return req, true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]any{"error": 1, "message": msg}) }