refactor!: make addrkit a multi-city address normalizer
- 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
This commit is contained in:
@@ -1,52 +1,54 @@
|
||||
// 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.
|
||||
// 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"
|
||||
"unicode/utf8"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"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
|
||||
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
|
||||
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
|
||||
// New builds a server.
|
||||
func New(resolver Resolver, log *slog.Logger) *Server {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Server{
|
||||
resolver: resolver,
|
||||
outages: src,
|
||||
log: log,
|
||||
now: time.Now,
|
||||
loc: loc,
|
||||
timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -55,16 +57,21 @@ func New(resolver Resolver, src outages.Source, log *slog.Logger, loc *time.Loca
|
||||
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("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)
|
||||
|
||||
return recoverMW(s.log)(requestIDMW(loggingMW(s.log)(timeoutMW(s.timeout)(mux))))
|
||||
// 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) {
|
||||
@@ -72,64 +79,49 @@ func (s *Server) handleResolve(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.resolver.Resolve(r.Context(), req.Address))
|
||||
writeJSON(w, http.StatusOK, s.resolver.ResolveInCity(r.Context(), req.Address, req.City))
|
||||
}
|
||||
|
||||
type outagesResponse struct {
|
||||
Status address.Status `json:"status"`
|
||||
Address string `json:"address,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
Outages []outages.Outage `json:"outages"`
|
||||
AsOf string `json:"as_of,omitempty"`
|
||||
DataVersion string `json:"data_version,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
type cityRequest struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
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)
|
||||
type cityResponse struct {
|
||||
Status string `json:"status"`
|
||||
City string `json:"city,omitempty"`
|
||||
}
|
||||
|
||||
out := outagesResponse{
|
||||
Status: res.Status,
|
||||
Address: res.Address,
|
||||
District: res.District,
|
||||
DataVersion: res.DataVersion,
|
||||
Outages: []outages.Outage{},
|
||||
}
|
||||
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)
|
||||
// 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 s.outages == nil {
|
||||
// The events module is optional and off when portal.url is empty.
|
||||
out.Status = address.StatusInfra
|
||||
out.Detail = "outages source is not configured"
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
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"})
|
||||
}
|
||||
|
||||
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
|
||||
func (s *Server) handleCities(w http.ResponseWriter, _ *http.Request) {
|
||||
cities := s.resolver.Cities()
|
||||
if cities == nil {
|
||||
cities = []string{}
|
||||
}
|
||||
|
||||
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)
|
||||
writeJSON(w, http.StatusOK, map[string][]string{"cities": cities})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -160,6 +152,14 @@ func (s *Server) decode(w http.ResponseWriter, r *http.Request) (resolveRequest,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user