addrkit: deterministic address resolution service (Go)
- domain: address normalization/parsing, matching with explicit statuses - adapters: SQLite registry store, ArcGIS outages client, optional LLM rewriter, HTTP API - seed pipeline with duplicate handling and unparsed reporting - CLI (seed/verify/resolve), systemd unit, OpenAPI contract, tests
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
// Package arcgis is the adapter for the external outages portal exposed as an
|
||||
// ArcGIS MapServer /query endpoint.
|
||||
//
|
||||
// The portal only supports substring (LIKE) filtering, so precision is
|
||||
// guaranteed locally: whatever the portal returns is re-filtered by the
|
||||
// outages domain using the parsed house unit. The client adds the reliability
|
||||
// the portal lacks: a hard timeout, bounded retries with backoff and context
|
||||
// cancellation.
|
||||
package arcgis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/outages"
|
||||
)
|
||||
|
||||
// Client is a bounded HTTP client for the portal.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
retries int
|
||||
}
|
||||
|
||||
// New builds a client. timeout applies to a single attempt; retries is the
|
||||
// number of *additional* attempts after the first.
|
||||
func New(baseURL string, timeout time.Duration, retries int) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
http: &http.Client{Timeout: timeout},
|
||||
retries: retries,
|
||||
}
|
||||
}
|
||||
|
||||
// Query asks the portal for outages matching the canonical address. The
|
||||
// returned records are raw portal rows; callers must still filter by house.
|
||||
func (c *Client) Query(ctx context.Context, canonical string) ([]outages.Outage, error) {
|
||||
params := url.Values{}
|
||||
params.Set("where", "address like '"+likePattern(canonical)+"'")
|
||||
params.Set("outFields", "")
|
||||
params.Set("returnGeometry", "false")
|
||||
params.Set("f", "json")
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(attempt) * 150 * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
rows, err := c.do(ctx, params)
|
||||
if err == nil {
|
||||
return rows, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("portal query failed after %d attempts: %w", c.retries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, params url.Values) ([]outages.Outage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("portal status %d", resp.StatusCode)
|
||||
}
|
||||
// Bound the response size to protect the service from a misbehaving portal.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseResponse(body)
|
||||
}
|
||||
|
||||
// portalResponse mirrors the subset of the ArcGIS /query envelope we use.
|
||||
type portalResponse struct {
|
||||
Features []struct {
|
||||
Value string `json:"value"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
} `json:"features"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Domain struct {
|
||||
CodedValues []struct {
|
||||
Code any `json:"code"`
|
||||
Name string `json:"name"`
|
||||
} `json:"codedValues"`
|
||||
} `json:"domain"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
|
||||
func parseResponse(body []byte) ([]outages.Outage, error) {
|
||||
var pr portalResponse
|
||||
if err := json.Unmarshal(body, &pr); err != nil {
|
||||
return nil, fmt.Errorf("decode portal response: %w", err)
|
||||
}
|
||||
|
||||
// Build code -> name maps from the coded-value domains.
|
||||
maps := map[string]map[string]string{}
|
||||
for _, f := range pr.Fields {
|
||||
if len(f.Domain.CodedValues) == 0 {
|
||||
continue
|
||||
}
|
||||
m := make(map[string]string, len(f.Domain.CodedValues))
|
||||
for _, cv := range f.Domain.CodedValues {
|
||||
m[codeKey(cv.Code)] = cv.Name
|
||||
}
|
||||
maps[f.Name] = m
|
||||
}
|
||||
|
||||
out := make([]outages.Outage, 0, len(pr.Features))
|
||||
for _, feat := range pr.Features {
|
||||
o := outages.Outage{
|
||||
Address: addressOf(feat.Attributes, feat.Value),
|
||||
Type: lookup(maps, "type_id", feat.Attributes),
|
||||
System: lookup(maps, "system_id", feat.Attributes),
|
||||
Time: lookup(maps, "time_category", feat.Attributes),
|
||||
StartDate: stringOf(feat.Attributes["start_date"]),
|
||||
EndDate: stringOf(feat.Attributes["end_date"]),
|
||||
}
|
||||
o.Action = actionFor(o.System)
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func addressOf(attrs map[string]any, fallback string) string {
|
||||
if v, ok := attrs["address"]; ok {
|
||||
if s := stringOf(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func lookup(maps map[string]map[string]string, field string, attrs map[string]any) string {
|
||||
m, ok := maps[field]
|
||||
if !ok {
|
||||
return stringOf(attrs[field])
|
||||
}
|
||||
return m[codeKey(attrs[field])]
|
||||
}
|
||||
|
||||
// codeKey normalises a coded value coming from JSON (float64) or a string.
|
||||
func codeKey(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func stringOf(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// actionFor maps a portal system name to the canonical action code used by the
|
||||
// scenario (kept for parity with the existing integration).
|
||||
func actionFor(system string) string {
|
||||
switch system {
|
||||
case "Горячее водоснабжение":
|
||||
return "waterWarm"
|
||||
case "Холодное водоснабжение и канализация", "Холодное водоснабжение":
|
||||
return "waterCold"
|
||||
case "Электроснабжение":
|
||||
return "electricitySupply"
|
||||
case "Газоснабжение":
|
||||
return "gasSupply"
|
||||
case "Теплоснабжение":
|
||||
return "heating"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// likePattern builds the substring pattern the portal supports. Numeric tokens
|
||||
// are padded with spaces, matching the historical integration, so that "1" does
|
||||
// not incidentally match "10".
|
||||
func likePattern(canonical string) string {
|
||||
parts := strings.Fields(canonical)
|
||||
for i, part := range parts {
|
||||
if containsDigit(part) {
|
||||
parts[i] = " " + part + " "
|
||||
}
|
||||
}
|
||||
return "%" + strings.Join(parts, "%") + "%"
|
||||
}
|
||||
|
||||
func containsDigit(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= '0' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user