- remove the events/outages module, ArcGIS adapter, portal config and event statuses - add a configurable city dictionary/parser and make City part of StreetKey - seed: read city/city_name, namespace IDs by city, keep structures distinct - fix reliability: schema v2 with fail-closed load, deterministic ordering, input limits, config validation, atomic DB publish, LLM response caps - add read-only shadow mode comparing the old ES path with addrkit - breaking: registry.db must be re-seeded; config has no portal section
175 lines
5.3 KiB
Go
175 lines
5.3 KiB
Go
// Package httpapi exposes the addrkit domain over a small, versioned JSON API.
|
||
//
|
||
// It contains no business logic: handlers decode requests, call the resolver
|
||
// and encode responses. Cross-cutting concerns (timeouts, recovery, request
|
||
// ids, logging) live in middleware.
|
||
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"log/slog"
|
||
|
||
"git.totmin.ru/en2zmax/addrkit/internal/domain/match"
|
||
)
|
||
|
||
// Resolver is the read side of the domain (implemented by *match.Resolver).
|
||
type Resolver interface {
|
||
Resolve(ctx context.Context, raw string) match.Result
|
||
ResolveInCity(ctx context.Context, raw, city string) match.Result
|
||
MatchCity(query string) (string, bool)
|
||
Version() string
|
||
Ready() bool
|
||
Cities() []string
|
||
}
|
||
|
||
// maxAddressRunes bounds user input. A real address is far shorter; the limit
|
||
// keeps a pathological string from turning into a CPU-bound fuzzy scan.
|
||
const maxAddressRunes = 512
|
||
|
||
// maxCityQueryRunes bounds the separate city-only endpoint.
|
||
const maxCityQueryRunes = 128
|
||
|
||
// Server wires the domain to HTTP.
|
||
type Server struct {
|
||
resolver Resolver
|
||
log *slog.Logger
|
||
timeout time.Duration
|
||
}
|
||
|
||
// New builds a server.
|
||
func New(resolver Resolver, log *slog.Logger) *Server {
|
||
if log == nil {
|
||
log = slog.Default()
|
||
}
|
||
return &Server{
|
||
resolver: resolver,
|
||
log: log,
|
||
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/city", s.handleCity)
|
||
mux.HandleFunc("GET /v1/cities", s.handleCities)
|
||
mux.HandleFunc("GET /v1/healthz", s.handleHealthz)
|
||
mux.HandleFunc("GET /v1/readyz", s.handleReadyz)
|
||
mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||
|
||
// Logging sits outside recovery so a panic is still recorded as a 500.
|
||
return requestIDMW(loggingMW(s.log)(recoverMW(s.log)(timeoutMW(s.timeout)(mux))))
|
||
}
|
||
|
||
type resolveRequest struct {
|
||
Address string `json:"address"`
|
||
// City is an optional hint for clients that keep the city separate from
|
||
// the street line. It overrides a city found in the address text.
|
||
City string `json:"city"`
|
||
}
|
||
|
||
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.ResolveInCity(r.Context(), req.Address, req.City))
|
||
}
|
||
|
||
type cityRequest struct {
|
||
Query string `json:"query"`
|
||
}
|
||
|
||
type cityResponse struct {
|
||
Status string `json:"status"`
|
||
City string `json:"city,omitempty"`
|
||
}
|
||
|
||
// handleCity normalizes a city-only query ("нск", "г. Новосибирск",
|
||
// "Новосибирская область"). It is the small, separate use case for projects
|
||
// that need city names but not streets.
|
||
func (s *Server) handleCity(w http.ResponseWriter, r *http.Request) {
|
||
var req cityRequest
|
||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid json body")
|
||
return
|
||
}
|
||
if req.Query == "" {
|
||
writeError(w, http.StatusBadRequest, "query is required")
|
||
return
|
||
}
|
||
if utf8.RuneCountInString(req.Query) > maxCityQueryRunes {
|
||
writeError(w, http.StatusBadRequest, "query is too long")
|
||
return
|
||
}
|
||
if city, ok := s.resolver.MatchCity(req.Query); ok {
|
||
writeJSON(w, http.StatusOK, cityResponse{Status: "found", City: city})
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, cityResponse{Status: "not_found"})
|
||
}
|
||
|
||
func (s *Server) handleCities(w http.ResponseWriter, _ *http.Request) {
|
||
cities := s.resolver.Cities()
|
||
if cities == nil {
|
||
cities = []string{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string][]string{"cities": cities})
|
||
}
|
||
|
||
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
|
||
}
|
||
if utf8.RuneCountInString(req.Address) > maxAddressRunes {
|
||
writeError(w, http.StatusBadRequest, "address is too long")
|
||
return resolveRequest{}, false
|
||
}
|
||
if utf8.RuneCountInString(req.City) > 128 {
|
||
writeError(w, http.StatusBadRequest, "city is too long")
|
||
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})
|
||
}
|