// 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}) }