@mailwoman/api 7.2.0 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.ts +126 -0
- package/engine.ts +47 -0
- package/index.ts +22 -0
- package/package.json +23 -9
- package/routes.ts +403 -0
- package/schema.ts +181 -0
package/app.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The native Mailwoman Hono app: CORS + a request-body-size guard + the strict-validation error
|
|
7
|
+
* envelope + the `/v1` routes + the emitted OpenAPI document. Engine-agnostic — the `mailwoman`
|
|
8
|
+
* CLI wires the real parse/geocode/resolve stack (phase 4b); tests inject fixtures.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { OpenAPIHono } from "@hono/zod-openapi"
|
|
12
|
+
import { apiError, attachOpenAPIDocs, type OpenAPIDocInfo } from "@mailwoman/api-kit"
|
|
13
|
+
import packageJson from "@mailwoman/api/package.json" with { type: "json" }
|
|
14
|
+
import { bodyLimit } from "hono/body-limit"
|
|
15
|
+
import { cors } from "hono/cors"
|
|
16
|
+
|
|
17
|
+
import type { MailwomanAPIEngine } from "./engine.ts"
|
|
18
|
+
import { DEFAULT_BATCH_MAX, registerMailwomanAPIRoutes } from "./routes.ts"
|
|
19
|
+
|
|
20
|
+
/** 2 MiB — carried from the express server's `express.json({ limit: "2mb" })` (`mailwoman/server/index.ts`). */
|
|
21
|
+
const DEFAULT_BODY_LIMIT_BYTES = 2 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
/** Options for {@link createMailwomanAPI}. */
|
|
24
|
+
export interface MailwomanAPIOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Emit permissive CORS headers (`Access-Control-Allow-Origin: *`) on every response and answer preflight `OPTIONS`
|
|
27
|
+
* with `204`. Default `true` — browser-embedded clients (the demo, a map widget) need it: a cross-origin XHR
|
|
28
|
+
* (including the `POST` preflight) is blocked without it (#1017). Set `false` when a reverse proxy already owns the
|
|
29
|
+
* CORS headers.
|
|
30
|
+
*/
|
|
31
|
+
cors?: boolean
|
|
32
|
+
|
|
33
|
+
/** Max request body size in bytes, enforced ahead of every `/v1/*` handler. Default 2 MiB. */
|
|
34
|
+
bodyLimitBytes?: number
|
|
35
|
+
|
|
36
|
+
/** Max `addresses` rows accepted by `POST /v1/batch`. Default 500 (see `routes.ts`'s `DEFAULT_BATCH_MAX`). */
|
|
37
|
+
batchMax?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Short, single-line summary of a zod validation failure for the envelope's `detail` field — not the full `ZodError`,
|
|
42
|
+
* which is multi-line and carries internal path/code detail not meant for a wire response.
|
|
43
|
+
*/
|
|
44
|
+
function summarizeValidationError(error: { issues: Array<{ path: PropertyKey[]; message: string }> }): string {
|
|
45
|
+
return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The document info stamped into the emitted OpenAPI document. Exported (not inlined) so the `mailwoman openapi`
|
|
50
|
+
* command can call `emitOpenAPIDocuments` with the SAME info the mounted `/openapi.json` route (below, via
|
|
51
|
+
* {@link attachOpenAPIDocs}) uses — one source of truth, no risk of the two drifting.
|
|
52
|
+
*/
|
|
53
|
+
export const MAILWOMAN_API_DOC_INFO: OpenAPIDocInfo = {
|
|
54
|
+
title: packageJson.name,
|
|
55
|
+
version: packageJson.version,
|
|
56
|
+
description: packageJson.description,
|
|
57
|
+
license: { name: "AGPL-3.0-only OR LicenseRef-Commercial", identifier: "AGPL-3.0-only" },
|
|
58
|
+
contact: { name: "Sister Software", url: "https://mailwoman.sister.software" },
|
|
59
|
+
servers: [
|
|
60
|
+
{
|
|
61
|
+
url: "http://{host}:{port}",
|
|
62
|
+
variables: { host: { default: "127.0.0.1" }, port: { default: "3000" } },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
security: [],
|
|
66
|
+
tags: [
|
|
67
|
+
{ name: "parsing", description: "Free-text address parsing." },
|
|
68
|
+
{ name: "geocoding", description: "Address-to-coordinate resolution." },
|
|
69
|
+
{ name: "resolving", description: "Gazetteer resolution over an already-decoded address tree." },
|
|
70
|
+
{ name: "formatting", description: "Component-dict rendering — the inverse of parsing." },
|
|
71
|
+
{ name: "meta", description: "Health, metrics, and deploy-time operations." },
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build the native Mailwoman app around an injected {@link MailwomanAPIEngine}. */
|
|
76
|
+
export function createMailwomanAPI(engine: MailwomanAPIEngine, options: MailwomanAPIOptions = {}): OpenAPIHono {
|
|
77
|
+
const app = new OpenAPIHono({
|
|
78
|
+
// This surface is ours (no vendor contract to preserve): every declared body/query schema is
|
|
79
|
+
// validator-enforced, and a failure maps through the shared api-kit envelope — never the raw zod
|
|
80
|
+
// `{success, error}` shape. Individual routes (routes.ts) override this per-call to answer their OWN
|
|
81
|
+
// friendly business message (e.g. "address is required"); this is the fallback for the rest (currently
|
|
82
|
+
// just `/v1/format`).
|
|
83
|
+
defaultHook: (result, c) => {
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
return apiError(c, 400, "invalid request body", summarizeValidationError(result.error))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Browser-embedded clients need CORS or their cross-origin XHR (including the mutating `/v1/*` preflight) is
|
|
93
|
+
// blocked before it completes (#1017). GET+POST, unlike the read-only drop-ins (photon, nominatim).
|
|
94
|
+
if (options.cors !== false) {
|
|
95
|
+
app.use(cors({ origin: "*", allowMethods: ["GET", "POST", "OPTIONS"], allowHeaders: ["*"], maxAge: 86400 }))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Safety net: an engine fault answers the native envelope, never a crash. `detail` carries the raw message —
|
|
99
|
+
// this surface is ours to design, so (unlike the vendor-constrained drop-in envelopes) we can be helpful.
|
|
100
|
+
app.onError((error, c) => {
|
|
101
|
+
// A malformed request body is a client-side syntax error, not a server fault — Hono's zod-openapi
|
|
102
|
+
// validator throws before a route's own hook ever sees the body, so it lands here instead of the
|
|
103
|
+
// per-route 400s in routes.ts. Answer 400, not the 500 net (which stays reserved for engine faults).
|
|
104
|
+
if (error instanceof Error && error.message.includes("Malformed JSON")) {
|
|
105
|
+
return apiError(c, 400, "invalid request body", "malformed JSON")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return apiError(c, 500, "internal error", error instanceof Error ? error.message : String(error))
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// Ahead of the handlers (which buffer the body into memory) so an oversized POST is rejected before that
|
|
112
|
+
// buffering happens, not after — mirrors the libpostal precedent.
|
|
113
|
+
app.use(
|
|
114
|
+
"/v1/*",
|
|
115
|
+
bodyLimit({
|
|
116
|
+
maxSize: options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES,
|
|
117
|
+
onError: (c) => apiError(c, 413, "request body too large"),
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
registerMailwomanAPIRoutes(app, engine, { batchMax: options.batchMax ?? DEFAULT_BATCH_MAX })
|
|
122
|
+
|
|
123
|
+
attachOpenAPIDocs(app, MAILWOMAN_API_DOC_INFO)
|
|
124
|
+
|
|
125
|
+
return app
|
|
126
|
+
}
|
package/engine.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* The native-surface engine contract. Engine-agnostic like the drop-ins: the `mailwoman` CLI
|
|
7
|
+
* wires the real parse/geocode/resolve stack (phase 4b); tests inject fixtures. `format` is the
|
|
8
|
+
* exception — it's wired in-package from `@mailwoman/formatter` (the surface exists to expose it).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AddressTree } from "@mailwoman/core"
|
|
12
|
+
|
|
13
|
+
/** One parsed component in reading order (a `ComponentTag` + the covered text). */
|
|
14
|
+
export interface ParseComponent {
|
|
15
|
+
tag: string
|
|
16
|
+
value: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One parse outcome: ordered components + the full decoded tree (the same language `/v1/resolve` speaks). */
|
|
20
|
+
export interface ParseOutcome {
|
|
21
|
+
input: string
|
|
22
|
+
components: ParseComponent[]
|
|
23
|
+
tree: AddressTree
|
|
24
|
+
debug?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A geocode outcome — the engine returns the geocode-core `GeocodeResult` shape verbatim (passthrough). */
|
|
28
|
+
export type GeocodeOutcome = Record<string, unknown>
|
|
29
|
+
|
|
30
|
+
/** A batch row: a GeocodeOutcome, or an `{ input, error }` slot (per-row isolation). */
|
|
31
|
+
export type BatchRow = GeocodeOutcome | { input: string; error: string }
|
|
32
|
+
|
|
33
|
+
export interface ResolveTreeOutcome {
|
|
34
|
+
tree: AddressTree
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `/health` data block the engine contributes (model card, data-root inventory). */
|
|
38
|
+
export type HealthData = Record<string, unknown>
|
|
39
|
+
|
|
40
|
+
export interface MailwomanAPIEngine {
|
|
41
|
+
parse?(address: string, opts: { debug: boolean }): Promise<ParseOutcome>
|
|
42
|
+
geocode?(address: string): Promise<GeocodeOutcome>
|
|
43
|
+
batch?(addresses: string[]): Promise<{ results: BatchRow[] }>
|
|
44
|
+
resolveTree?(tree: AddressTree, opts: Record<string, unknown>): Promise<ResolveTreeOutcome>
|
|
45
|
+
reload?(): Promise<{ reloaded: boolean; versions: unknown }>
|
|
46
|
+
health?(): HealthData
|
|
47
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* `@mailwoman/api` — the native Mailwoman HTTP API: an engine-agnostic `/v1` surface (parse,
|
|
7
|
+
* geocode, batch, resolve, format) alongside health, metrics, and an emitted OpenAPI document.
|
|
8
|
+
* Unlike its drop-in siblings (`@mailwoman/nominatim`, `@mailwoman/photon`,
|
|
9
|
+
* `@mailwoman/libpostal`), nothing here mimics a third-party API — this is Mailwoman's own wire
|
|
10
|
+
* contract, so schemas are strict and validator-enforced rather than tolerant of legacy quirks.
|
|
11
|
+
*
|
|
12
|
+
* Like its siblings, the package is engine-agnostic: routes take a {@link MailwomanAPIEngine}; the
|
|
13
|
+
* `mailwoman` CLI wires the real parse/geocode/resolve stack (phase 4b). The Hono app (CORS +
|
|
14
|
+
* body-size guard + the strict-validation error envelope + the emitted OpenAPI document) lives in
|
|
15
|
+
* `app.ts`; route definitions + handlers (incl. `registerMailwomanAPIRoutes`) in `routes.ts`; the
|
|
16
|
+
* engine contract in `engine.ts`; the zod wire schemas in `schema.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export * from "./app.ts"
|
|
20
|
+
export * from "./engine.ts"
|
|
21
|
+
export * from "./routes.ts"
|
|
22
|
+
export * from "./schema.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailwoman/api",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.1",
|
|
4
4
|
"description": "The native Mailwoman HTTP API — engine-agnostic /v1 surface (parse, geocode, batch, resolve, format) with health, metrics, and an emitted OpenAPI document.",
|
|
5
5
|
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
6
6
|
"repository": {
|
|
@@ -13,25 +13,39 @@
|
|
|
13
13
|
"out/**/*.js.map",
|
|
14
14
|
"out/**/*.d.ts",
|
|
15
15
|
"out/**/*.d.ts.map",
|
|
16
|
-
"README.md"
|
|
16
|
+
"README.md",
|
|
17
|
+
"*.ts",
|
|
18
|
+
"*.tsx",
|
|
19
|
+
"**/*.ts",
|
|
20
|
+
"**/*.tsx",
|
|
21
|
+
"!*.test.ts",
|
|
22
|
+
"!*.test.tsx",
|
|
23
|
+
"!**/*.test.ts",
|
|
24
|
+
"!**/*.test.tsx"
|
|
17
25
|
],
|
|
18
26
|
"type": "module",
|
|
19
27
|
"exports": {
|
|
20
28
|
"./package.json": "./package.json",
|
|
21
29
|
".": {
|
|
22
|
-
"
|
|
23
|
-
"default": "./out/index.js"
|
|
24
|
-
"types": "./out/index.d.ts"
|
|
30
|
+
"types": "./out/index.d.ts",
|
|
31
|
+
"default": "./out/index.js"
|
|
25
32
|
}
|
|
26
33
|
},
|
|
27
34
|
"publishConfig": {
|
|
28
|
-
"access": "public"
|
|
35
|
+
"access": "public",
|
|
36
|
+
"exports": {
|
|
37
|
+
"./package.json": "./package.json",
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./out/index.d.ts",
|
|
40
|
+
"default": "./out/index.js"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
29
43
|
},
|
|
30
44
|
"dependencies": {
|
|
31
45
|
"@hono/zod-openapi": "^1.4.0",
|
|
32
|
-
"@mailwoman/api-kit": "7.2.
|
|
33
|
-
"@mailwoman/core": "7.2.
|
|
34
|
-
"@mailwoman/formatter": "7.2.
|
|
46
|
+
"@mailwoman/api-kit": "7.2.1",
|
|
47
|
+
"@mailwoman/core": "7.2.1",
|
|
48
|
+
"@mailwoman/formatter": "7.2.1",
|
|
35
49
|
"hono": "^4.12.29",
|
|
36
50
|
"zod": "^4.4.3"
|
|
37
51
|
}
|
package/routes.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Route definitions + handlers for the native `/v1` surface. The OpenAPI document is emitted from
|
|
7
|
+
* these definitions — there is no handwritten spec. Unlike the drop-ins (photon, nominatim,
|
|
8
|
+
* libpostal), nothing here mimics a vendor's legacy query-parsing tolerance: request bodies are
|
|
9
|
+
* validator-enforced, and a validation failure always answers through the shared api-kit envelope
|
|
10
|
+
* (`apiError`), never the raw zod shape. `GET /v1/parse` is the one query-string route, and it
|
|
11
|
+
* reads `c.req.query()` directly — a query string has no repeated-value contract worth preserving
|
|
12
|
+
* here (contrast the drop-ins' `legacyQuery` adapter), so there's nothing to tolerate.
|
|
13
|
+
*
|
|
14
|
+
* Per-route validation hooks (the 3rd arg to `app.openapi(route, handler, hook)`) override the
|
|
15
|
+
* app-level `defaultHook` (wired in `app.ts`) so each route can answer its OWN friendly business
|
|
16
|
+
* message — `"address is required"`, `"body must be { addresses: string[] }"`, etc. — matching the
|
|
17
|
+
* express `mailwoman/server` precedent this surface carries forward. Routes with no friendly
|
|
18
|
+
* carry-forward message (currently just `/v1/format`) fall through to the app-level hook's generic
|
|
19
|
+
* `"invalid request body"`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"
|
|
23
|
+
import { apiError, metricsSnapshot, recordTimed } from "@mailwoman/api-kit"
|
|
24
|
+
import type { AddressTree } from "@mailwoman/core/decoder"
|
|
25
|
+
import type { ComponentTag } from "@mailwoman/core/types"
|
|
26
|
+
import { canonicalKey, type ComponentDict, formatAddress, type FormatAddressOptions } from "@mailwoman/formatter"
|
|
27
|
+
|
|
28
|
+
import type { MailwomanAPIEngine } from "./engine.ts"
|
|
29
|
+
import {
|
|
30
|
+
APIErrorSchema,
|
|
31
|
+
BatchRequestSchema,
|
|
32
|
+
BatchResponseSchema,
|
|
33
|
+
FormatRequestSchema,
|
|
34
|
+
FormatResponseSchema,
|
|
35
|
+
GeocodeOutcomeSchema,
|
|
36
|
+
GeocodeRequestSchema,
|
|
37
|
+
HealthResponseSchema,
|
|
38
|
+
ParseOutcomeSchema,
|
|
39
|
+
ParseRequestSchema,
|
|
40
|
+
ResolveRequestSchema,
|
|
41
|
+
ResolveResponseSchema,
|
|
42
|
+
} from "./schema.ts"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Default `POST /v1/batch` row cap when {@link RegisterMailwomanAPIRoutesOptions.batchMax} is omitted. This is the
|
|
46
|
+
* standalone-engine default, not derived from env — `mailwoman serve` always passes the env-derived value explicitly
|
|
47
|
+
* (`$public.MAILWOMAN_BATCH_MAX`, default 1000; see `core/env/schema.ts`).
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_BATCH_MAX = 500
|
|
50
|
+
|
|
51
|
+
const startedAt = Date.now()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `detail` text for every 503 "engine method absent" response (`/v1/geocode`, `/v1/batch`, `/v1/resolve`, `/v1/reload`)
|
|
55
|
+
* — the express-era remediation carried forward: a stranger hitting a 503 must see the exact fix, not just "not
|
|
56
|
+
* available". Matches `mailwoman/api-engine.ts`'s `buildPreflightMessage()` boot-time banner in spirit (same two
|
|
57
|
+
* missing pieces — the packages, and the gazetteer data), condensed to one line for a JSON error body.
|
|
58
|
+
*/
|
|
59
|
+
const GEOCODER_UNAVAILABLE_DETAIL =
|
|
60
|
+
"install @mailwoman/neural + @mailwoman/resolver-wof-sqlite and provide gazetteer data (MAILWOMAN_WOF_DB / MAILWOMAN_CANDIDATE_DB)"
|
|
61
|
+
|
|
62
|
+
/** Options for {@link registerMailwomanAPIRoutes}. */
|
|
63
|
+
export interface RegisterMailwomanAPIRoutesOptions {
|
|
64
|
+
/** Max `addresses` rows accepted by `POST /v1/batch`. Default {@link DEFAULT_BATCH_MAX}. */
|
|
65
|
+
batchMax?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const errorContent = (description: string) => ({
|
|
69
|
+
description,
|
|
70
|
+
content: { "application/json": { schema: APIErrorSchema } },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const parseQueryParams = z.object({
|
|
74
|
+
address: z.string().optional().openapi({ description: "The address to parse." }),
|
|
75
|
+
debug: z.string().optional().openapi({ description: '`"true"` to include a diagnostic report.' }),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const parseResponses = {
|
|
79
|
+
200: {
|
|
80
|
+
description: "The tokenized input span + ranked solutions.",
|
|
81
|
+
content: { "application/json": { schema: ParseOutcomeSchema } },
|
|
82
|
+
},
|
|
83
|
+
400: errorContent("`address` is required."),
|
|
84
|
+
501: errorContent("The backing engine method is not wired for this deployment."),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const geocodeResponses = {
|
|
88
|
+
200: {
|
|
89
|
+
description: "One geocode result (parse → resolve cascade), passed through from the engine verbatim.",
|
|
90
|
+
content: { "application/json": { schema: GeocodeOutcomeSchema } },
|
|
91
|
+
},
|
|
92
|
+
400: errorContent("`address` is required."),
|
|
93
|
+
503: errorContent("The geocoding engine is not wired for this deployment (dependencies missing)."),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const batchResponses = {
|
|
97
|
+
200: {
|
|
98
|
+
description: "One result per input address, in input order (per-row error isolation).",
|
|
99
|
+
content: { "application/json": { schema: BatchResponseSchema } },
|
|
100
|
+
},
|
|
101
|
+
400: errorContent("Body must be `{ addresses: string[] }`."),
|
|
102
|
+
413: errorContent("`addresses.length` exceeds the configured batch cap."),
|
|
103
|
+
503: errorContent("The geocoding engine is not wired for this deployment (dependencies missing)."),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const resolveResponses = {
|
|
107
|
+
200: {
|
|
108
|
+
description: "The same tree, decorated in place with gazetteer coordinates + attribution.",
|
|
109
|
+
content: { "application/json": { schema: ResolveResponseSchema } },
|
|
110
|
+
},
|
|
111
|
+
400: errorContent("Body must be `{ tree: AddressTree, opts? }`."),
|
|
112
|
+
503: errorContent("The resolver is not wired for this deployment (dependencies missing)."),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const reloadResponses = {
|
|
116
|
+
200: {
|
|
117
|
+
description: "Versioned data switchover result — the new per-shard version map.",
|
|
118
|
+
content: {
|
|
119
|
+
"application/json": { schema: z.looseObject({ reloaded: z.boolean(), versions: z.unknown() }) },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
503: errorContent("The geocoding engine is not wired for this deployment (dependencies missing)."),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const formatResponses = {
|
|
126
|
+
200: {
|
|
127
|
+
description: "The rendered address string + the deterministic canonical match key.",
|
|
128
|
+
content: { "application/json": { schema: FormatResponseSchema } },
|
|
129
|
+
},
|
|
130
|
+
400: errorContent("Invalid request body."),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const healthResponses = {
|
|
134
|
+
200: {
|
|
135
|
+
description: "Liveness + engine health block. Answers 200 even when the engine is absent or broken.",
|
|
136
|
+
content: { "application/json": { schema: HealthResponseSchema } },
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const metricsResponses = {
|
|
141
|
+
200: {
|
|
142
|
+
description: "The live in-process timing metrics snapshot (latency percentiles + per-tier counts).",
|
|
143
|
+
content: { "application/json": { schema: z.looseObject({}) } },
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const parseGetRoute = createRoute({
|
|
148
|
+
method: "get",
|
|
149
|
+
path: "/v1/parse",
|
|
150
|
+
operationId: "parseGet",
|
|
151
|
+
summary: "Parse an address (query string)",
|
|
152
|
+
tags: ["parsing"],
|
|
153
|
+
request: { query: parseQueryParams },
|
|
154
|
+
responses: parseResponses,
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const parsePostRoute = createRoute({
|
|
158
|
+
method: "post",
|
|
159
|
+
path: "/v1/parse",
|
|
160
|
+
operationId: "parsePost",
|
|
161
|
+
summary: "Parse an address (JSON body)",
|
|
162
|
+
tags: ["parsing"],
|
|
163
|
+
request: { body: { content: { "application/json": { schema: ParseRequestSchema } }, required: true } },
|
|
164
|
+
responses: parseResponses,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const geocodeRoute = createRoute({
|
|
168
|
+
method: "post",
|
|
169
|
+
path: "/v1/geocode",
|
|
170
|
+
operationId: "geocode",
|
|
171
|
+
summary: "Geocode an address to coordinates",
|
|
172
|
+
tags: ["geocoding"],
|
|
173
|
+
request: { body: { content: { "application/json": { schema: GeocodeRequestSchema } }, required: true } },
|
|
174
|
+
responses: geocodeResponses,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const batchRoute = createRoute({
|
|
178
|
+
method: "post",
|
|
179
|
+
path: "/v1/batch",
|
|
180
|
+
operationId: "batch",
|
|
181
|
+
summary: "Geocode a batch of addresses",
|
|
182
|
+
tags: ["geocoding"],
|
|
183
|
+
request: { body: { content: { "application/json": { schema: BatchRequestSchema } }, required: true } },
|
|
184
|
+
responses: batchResponses,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const resolveRoute = createRoute({
|
|
188
|
+
method: "post",
|
|
189
|
+
path: "/v1/resolve",
|
|
190
|
+
operationId: "resolve",
|
|
191
|
+
summary: "Resolve an already-decoded address tree against the gazetteer",
|
|
192
|
+
tags: ["resolving"],
|
|
193
|
+
request: { body: { content: { "application/json": { schema: ResolveRequestSchema } }, required: true } },
|
|
194
|
+
responses: resolveResponses,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const reloadRoute = createRoute({
|
|
198
|
+
method: "post",
|
|
199
|
+
path: "/v1/reload",
|
|
200
|
+
operationId: "reload",
|
|
201
|
+
summary: "Reload versioned data shards (deploy-only; gate at ingress)",
|
|
202
|
+
tags: ["meta"],
|
|
203
|
+
responses: reloadResponses,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const formatRoute = createRoute({
|
|
207
|
+
method: "post",
|
|
208
|
+
path: "/v1/format",
|
|
209
|
+
operationId: "format",
|
|
210
|
+
summary: "Render address components to a string + canonical match key",
|
|
211
|
+
tags: ["formatting"],
|
|
212
|
+
request: { body: { content: { "application/json": { schema: FormatRequestSchema } }, required: true } },
|
|
213
|
+
responses: formatResponses,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const healthRoute = createRoute({
|
|
217
|
+
method: "get",
|
|
218
|
+
path: "/health",
|
|
219
|
+
operationId: "health",
|
|
220
|
+
summary: "Liveness + engine health",
|
|
221
|
+
tags: ["meta"],
|
|
222
|
+
responses: healthResponses,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const metricsRoute = createRoute({
|
|
226
|
+
method: "get",
|
|
227
|
+
path: "/metrics",
|
|
228
|
+
operationId: "metrics",
|
|
229
|
+
summary: "In-process timing metrics snapshot",
|
|
230
|
+
tags: ["meta"],
|
|
231
|
+
responses: metricsResponses,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* `components` accepts `string | string[]` per key on the wire (a caller may pass every span a multi-span match
|
|
236
|
+
* covered); `formatAddress`/`canonicalKey` want a single string per `ComponentTag`. Multi-span values collapse to their
|
|
237
|
+
* FIRST span here — the formatter template owns joining semantics, not this route.
|
|
238
|
+
*/
|
|
239
|
+
function toComponentDict(components: Record<string, string | string[]>): ComponentDict {
|
|
240
|
+
const out: ComponentDict = {}
|
|
241
|
+
|
|
242
|
+
for (const [key, value] of Object.entries(components)) {
|
|
243
|
+
const first = Array.isArray(value) ? value[0] : value
|
|
244
|
+
|
|
245
|
+
if (first !== undefined) {
|
|
246
|
+
out[key as ComponentTag] = first
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Register the native `/v1` routes + `/health` + `/metrics` against an injected engine. */
|
|
254
|
+
export function registerMailwomanAPIRoutes(
|
|
255
|
+
app: OpenAPIHono,
|
|
256
|
+
engine: MailwomanAPIEngine,
|
|
257
|
+
options: RegisterMailwomanAPIRoutesOptions = {}
|
|
258
|
+
): void {
|
|
259
|
+
const batchMax = options.batchMax ?? DEFAULT_BATCH_MAX
|
|
260
|
+
|
|
261
|
+
app.openapi(parseGetRoute, async (c) => {
|
|
262
|
+
if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
|
|
263
|
+
const address = c.req.query("address")?.trim()
|
|
264
|
+
|
|
265
|
+
if (!address) return c.json({ error: "address is required" }, 400)
|
|
266
|
+
const debug = c.req.query("debug") === "true"
|
|
267
|
+
const outcome = await engine.parse(address, { debug })
|
|
268
|
+
|
|
269
|
+
return c.json(outcome, 200)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
app.openapi(
|
|
273
|
+
parsePostRoute,
|
|
274
|
+
async (c) => {
|
|
275
|
+
if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
|
|
276
|
+
const { address, debug } = c.req.valid("json")
|
|
277
|
+
const trimmed = address.trim()
|
|
278
|
+
|
|
279
|
+
if (!trimmed) return c.json({ error: "address is required" }, 400)
|
|
280
|
+
const outcome = await engine.parse(trimmed, { debug: debug ?? false })
|
|
281
|
+
|
|
282
|
+
return c.json(outcome, 200)
|
|
283
|
+
},
|
|
284
|
+
(result, c) => {
|
|
285
|
+
if (!result.success) return c.json({ error: "address is required" }, 400)
|
|
286
|
+
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
app.openapi(
|
|
292
|
+
geocodeRoute,
|
|
293
|
+
async (c) => {
|
|
294
|
+
if (!engine.geocode) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
|
|
295
|
+
const { address } = c.req.valid("json")
|
|
296
|
+
const trimmed = address.trim()
|
|
297
|
+
|
|
298
|
+
if (!trimmed) return c.json({ error: "address is required" }, 400)
|
|
299
|
+
const t0 = performance.now()
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const outcome = await engine.geocode(trimmed)
|
|
303
|
+
recordTimed(performance.now() - t0, String(outcome["resolution_tier"] ?? "admin"))
|
|
304
|
+
|
|
305
|
+
// `GeocodeOutcome` (the engine contract) is a deliberate `Record<string, unknown>` passthrough —
|
|
306
|
+
// `GeocodeOutcomeSchema` is now a REAL typed shape (doc-accuracy only, per its own docstring), so a
|
|
307
|
+
// local cast at this wire boundary is needed, matching the established idiom below (`/v1/resolve`'s
|
|
308
|
+
// `tree as unknown as AddressTree`) for "documented wire shape looser than the domain type".
|
|
309
|
+
return c.json(outcome as unknown as z.infer<typeof GeocodeOutcomeSchema>, 200)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
recordTimed(performance.now() - t0, "error")
|
|
312
|
+
throw error
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
(result, c) => {
|
|
316
|
+
if (!result.success) return c.json({ error: "address is required" }, 400)
|
|
317
|
+
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
app.openapi(
|
|
323
|
+
batchRoute,
|
|
324
|
+
async (c) => {
|
|
325
|
+
const { addresses } = c.req.valid("json")
|
|
326
|
+
|
|
327
|
+
if (addresses.length === 0) return c.json({ results: [] }, 200)
|
|
328
|
+
|
|
329
|
+
if (addresses.length > batchMax) {
|
|
330
|
+
return c.json({ error: `batch too large: ${addresses.length} > ${batchMax}` }, 413)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!engine.batch) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
|
|
334
|
+
|
|
335
|
+
// Whole-call latency, recorded under the "batch" tier. Per-row tier metrics are the ENGINE's
|
|
336
|
+
// responsibility (phase 4b) — this app only times the call as a unit.
|
|
337
|
+
const t0 = performance.now()
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const outcome = await engine.batch(addresses)
|
|
341
|
+
recordTimed(performance.now() - t0, "batch")
|
|
342
|
+
|
|
343
|
+
// Same wire-vs-domain cast as `/v1/geocode` above — `BatchRow`'s `GeocodeOutcome` half is a
|
|
344
|
+
// `Record<string, unknown>` passthrough; `BatchResponseSchema` now types its `GeocodeOutcome` union
|
|
345
|
+
// member as the real shape.
|
|
346
|
+
return c.json(outcome as unknown as z.infer<typeof BatchResponseSchema>, 200)
|
|
347
|
+
} catch (error) {
|
|
348
|
+
recordTimed(performance.now() - t0, "error")
|
|
349
|
+
throw error
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
(result, c) => {
|
|
353
|
+
if (!result.success) return c.json({ error: "body must be { addresses: string[] }" }, 400)
|
|
354
|
+
|
|
355
|
+
return undefined
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
app.openapi(
|
|
360
|
+
resolveRoute,
|
|
361
|
+
// Metrics are the ENGINE's responsibility here (phase 4b): the express predecessor recorded the
|
|
362
|
+
// street node's stamped resolution tier per call — the wired engine must carry that over, and
|
|
363
|
+
// must trim batch rows the same way (the route passes raw input through).
|
|
364
|
+
async (c) => {
|
|
365
|
+
if (!engine.resolveTree) return apiError(c, 503, "resolver not available", GEOCODER_UNAVAILABLE_DETAIL)
|
|
366
|
+
const { tree, opts } = c.req.valid("json")
|
|
367
|
+
// The wire schema keeps `tree` loose (`{ roots: unknown[] }`, forward-compat) — a local cast at the
|
|
368
|
+
// boundary onto the engine's `AddressTree` contract, matching the established idiom (api-kit's
|
|
369
|
+
// `openapi.ts`, the drop-ins' response casts) for "documented wire shape looser than the domain type".
|
|
370
|
+
const outcome = await engine.resolveTree(tree as unknown as AddressTree, opts ?? {})
|
|
371
|
+
|
|
372
|
+
return c.json(outcome, 200)
|
|
373
|
+
},
|
|
374
|
+
(result, c) => {
|
|
375
|
+
if (!result.success) return c.json({ error: "body must be { tree: AddressTree, opts? }" }, 400)
|
|
376
|
+
|
|
377
|
+
return undefined
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
app.openapi(reloadRoute, async (c) => {
|
|
382
|
+
if (!engine.reload) return apiError(c, 503, "geocoder not available", GEOCODER_UNAVAILABLE_DETAIL)
|
|
383
|
+
const outcome = await engine.reload()
|
|
384
|
+
|
|
385
|
+
return c.json(outcome, 200)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
app.openapi(formatRoute, (c) => {
|
|
389
|
+
const { components, country, options: formatOptions } = c.req.valid("json")
|
|
390
|
+
const dict = toComponentDict(components)
|
|
391
|
+
const formatted = formatAddress(dict, country, formatOptions as FormatAddressOptions | undefined)
|
|
392
|
+
|
|
393
|
+
return c.json({ formatted, canonicalKey: canonicalKey(dict) }, 200)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
app.openapi(healthRoute, (c) => {
|
|
397
|
+
const uptimeSeconds = Math.round((Date.now() - startedAt) / 1000)
|
|
398
|
+
|
|
399
|
+
return c.json({ status: "ok", uptime_s: uptimeSeconds, ...engine.health?.() }, 200)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
app.openapi(metricsRoute, (c) => c.json(metricsSnapshot(), 200))
|
|
403
|
+
}
|
package/schema.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @copyright Sister Software
|
|
3
|
+
* @license AGPL-3.0
|
|
4
|
+
* @author Teffen Ellis, et al.
|
|
5
|
+
*
|
|
6
|
+
* Zod wire schemas for the native `/v1` surface. Unlike the drop-ins (photon, nominatim,
|
|
7
|
+
* libpostal), nothing here is a vendor contract — this surface is ours to design, so request
|
|
8
|
+
* bodies are REQUIRED and validator-enforced (no legacy tolerance to preserve). A `defaultHook`
|
|
9
|
+
* on the app (wired in Task 3) maps validation failures through the shared `APIErrorSchema`
|
|
10
|
+
* envelope (`apiError(c, 400, "invalid request body", <zod summary>)`) — the documented pattern
|
|
11
|
+
* boundary from phase 2: where no legacy contract exists, the validator MAY speak, but only in
|
|
12
|
+
* our envelope.
|
|
13
|
+
*
|
|
14
|
+
* `APIErrorSchema` itself is owned by `@mailwoman/api-kit` (plumbing shared by every native
|
|
15
|
+
* surface) — it's re-exported here so route modules can import every schema they need, request
|
|
16
|
+
* and error alike, from this one file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { z } from "@hono/zod-openapi"
|
|
20
|
+
|
|
21
|
+
export { APIErrorSchema } from "@mailwoman/api-kit"
|
|
22
|
+
|
|
23
|
+
/** `POST /v1/parse` request body. */
|
|
24
|
+
export const ParseRequestSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
address: z.string(),
|
|
27
|
+
debug: z.boolean().optional(),
|
|
28
|
+
})
|
|
29
|
+
.openapi("ParseRequest")
|
|
30
|
+
|
|
31
|
+
/** One `ParseOutcome.components` entry — mirrors {@linkcode ParseComponent} (`engine.ts`). */
|
|
32
|
+
export const ParseComponentSchema = z.object({ tag: z.string(), value: z.string() }).openapi("ParseComponent")
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `POST /v1/parse` response — mirrors {@linkcode ParseOutcome} (`engine.ts`): the ordered components plus the full
|
|
36
|
+
* decoded tree. `tree` is the same loose-tree idiom {@link ResolveResponseSchema} uses (`api/schema.ts:134-146`) — the
|
|
37
|
+
* decoder's `AddressTree` is the engine's contract, not this wire schema's.
|
|
38
|
+
*/
|
|
39
|
+
export const ParseOutcomeSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
input: z.string(),
|
|
42
|
+
components: z.array(ParseComponentSchema),
|
|
43
|
+
tree: z.looseObject({ roots: z.array(z.unknown()) }),
|
|
44
|
+
debug: z.string().optional(),
|
|
45
|
+
})
|
|
46
|
+
.openapi("ParseOutcome")
|
|
47
|
+
|
|
48
|
+
/** `POST /v1/geocode` request body. */
|
|
49
|
+
export const GeocodeRequestSchema = z
|
|
50
|
+
.object({
|
|
51
|
+
address: z.string(),
|
|
52
|
+
})
|
|
53
|
+
.openapi("GeocodeRequest")
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One `GeocodeOutcome.hierarchy` entry — locality → country, most specific first. `name` is the resolved gazetteer name
|
|
57
|
+
* (proper-cased canonical); `value` is the raw parsed span. Mirrors `GeocodeResult["hierarchy"]` entries
|
|
58
|
+
* (`mailwoman/geocode-core.ts`), hand-modeled — see {@link GeocodeOutcomeSchema} for the no-import rationale.
|
|
59
|
+
*/
|
|
60
|
+
const GeocodeHierarchyEntrySchema = z
|
|
61
|
+
.object({
|
|
62
|
+
tag: z.string(),
|
|
63
|
+
value: z.string(),
|
|
64
|
+
name: z.string(),
|
|
65
|
+
lat: z.number().optional(),
|
|
66
|
+
lon: z.number().optional(),
|
|
67
|
+
placeID: z.string().optional(),
|
|
68
|
+
})
|
|
69
|
+
.openapi("GeocodeHierarchyEntry")
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One `GeocodeOutcome.candidates` entry — a ranked alternative place for the query's primary result (the winning place
|
|
73
|
+
* first, then same-query runner-ups). Mirrors `GeocodeResult["candidates"]` entries.
|
|
74
|
+
*/
|
|
75
|
+
const GeocodeCandidateSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
name: z.string(),
|
|
78
|
+
tag: z.string(),
|
|
79
|
+
lat: z.number(),
|
|
80
|
+
lon: z.number(),
|
|
81
|
+
countryCode: z.string().nullable(),
|
|
82
|
+
placeID: z.string().optional(),
|
|
83
|
+
})
|
|
84
|
+
.openapi("GeocodeCandidate")
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `POST /v1/geocode` response — a hand-modeled mirror of `GeocodeResult`'s wire shape (`mailwoman/geocode-core.ts`),
|
|
88
|
+
* `.loose()` so a field the engine adds that this schema doesn't yet know about still rides through undocumented rather
|
|
89
|
+
* than being stripped or rejected. DOC-ACCURACY ONLY: the route passes `engine.geocode()`'s outcome through verbatim
|
|
90
|
+
* (`GeocodeOutcome = Record<string, unknown>`, `api/engine.ts`) — nothing here validates a real response, so a
|
|
91
|
+
* schema/engine mismatch can never reject or mutate a result at runtime. Deliberately carries NO import from
|
|
92
|
+
* `mailwoman` (the engine-agnosticism boundary — `mailwoman` is the one workspace allowed to depend on
|
|
93
|
+
* `@mailwoman/api`, never the reverse). `mailwoman/test/api-schema-drift.test.ts` is the compile-time tripwire that
|
|
94
|
+
* catches this shape drifting from the real `GeocodeResult` interface.
|
|
95
|
+
*/
|
|
96
|
+
export const GeocodeOutcomeSchema = z
|
|
97
|
+
.object({
|
|
98
|
+
input: z.string(),
|
|
99
|
+
lat: z.number().nullable(),
|
|
100
|
+
lon: z.number().nullable(),
|
|
101
|
+
resolution_tier: z.enum(["address_point", "interpolated", "street", "admin"]),
|
|
102
|
+
uncertainty_m: z.number().nullable(),
|
|
103
|
+
locality: z.string().nullable(),
|
|
104
|
+
region: z.string().nullable(),
|
|
105
|
+
postcode: z.string().nullable(),
|
|
106
|
+
house_number: z.string().nullable(),
|
|
107
|
+
street: z.string().nullable(),
|
|
108
|
+
countryCode: z.string().nullable(),
|
|
109
|
+
hierarchy: z.array(GeocodeHierarchyEntrySchema),
|
|
110
|
+
candidates: z.array(GeocodeCandidateSchema),
|
|
111
|
+
})
|
|
112
|
+
.loose()
|
|
113
|
+
.openapi("GeocodeOutcome")
|
|
114
|
+
|
|
115
|
+
/** `POST /v1/batch` request body. */
|
|
116
|
+
export const BatchRequestSchema = z
|
|
117
|
+
.object({
|
|
118
|
+
addresses: z.array(z.string()),
|
|
119
|
+
})
|
|
120
|
+
.openapi("BatchRequest")
|
|
121
|
+
|
|
122
|
+
/** `POST /v1/batch` response — one `GeocodeOutcome`, or an `{ input, error }` slot, per row (per-row isolation). */
|
|
123
|
+
export const BatchResponseSchema = z
|
|
124
|
+
.object({
|
|
125
|
+
results: z.array(z.union([GeocodeOutcomeSchema, z.object({ input: z.string(), error: z.string() })])),
|
|
126
|
+
})
|
|
127
|
+
.openapi("BatchResponse")
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `POST /v1/resolve` request body — an already-decoded `AddressTree` (the parser's output) to resolve against the
|
|
131
|
+
* gazetteer.
|
|
132
|
+
*/
|
|
133
|
+
export const ResolveRequestSchema = z
|
|
134
|
+
.object({
|
|
135
|
+
tree: z.looseObject({ roots: z.array(z.unknown()) }),
|
|
136
|
+
opts: z.looseObject({}).optional(),
|
|
137
|
+
})
|
|
138
|
+
.openapi("ResolveRequest")
|
|
139
|
+
|
|
140
|
+
/** `POST /v1/resolve` response — the same tree, decorated in place with gazetteer coords + attribution. */
|
|
141
|
+
export const ResolveResponseSchema = z
|
|
142
|
+
.object({
|
|
143
|
+
tree: z.looseObject({ roots: z.array(z.unknown()) }),
|
|
144
|
+
})
|
|
145
|
+
.openapi("ResolveResponse")
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `POST /v1/format` request body. `components` accepts `string | string[]` per key on the wire — a handler-side
|
|
149
|
+
* concern, not this schema's: `@mailwoman/formatter`'s `ComponentDict` (`format.ts`) is `Partial<Record<ComponentTag,
|
|
150
|
+
* string>>`, single-string only, so a route handler must join array values before calling
|
|
151
|
+
* `formatAddress`/`canonicalKey`.
|
|
152
|
+
*/
|
|
153
|
+
export const FormatRequestSchema = z
|
|
154
|
+
.object({
|
|
155
|
+
components: z.record(z.string(), z.union([z.string(), z.array(z.string())])),
|
|
156
|
+
country: z.string(),
|
|
157
|
+
options: z.looseObject({}).optional(),
|
|
158
|
+
})
|
|
159
|
+
.openapi("FormatRequest")
|
|
160
|
+
|
|
161
|
+
/** `POST /v1/format` response — the rendered string plus the deterministic canonical match key. */
|
|
162
|
+
export const FormatResponseSchema = z
|
|
163
|
+
.object({
|
|
164
|
+
formatted: z.string(),
|
|
165
|
+
canonicalKey: z.string(),
|
|
166
|
+
})
|
|
167
|
+
.openapi("FormatResponse")
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `GET /health` response — `status`/`uptime_s` are stamped by the ROUTE itself, unconditionally, regardless of engine
|
|
171
|
+
* (`api/routes.ts`'s `healthRoute` handler: `{ status: "ok", uptime_s, ...engine.health?.() }`), so those two are cheap
|
|
172
|
+
* + accurate to pin. Everything else is `HealthData` (`api/engine.ts`) — an engine-defined block (model card, data-root
|
|
173
|
+
* inventory for `mailwoman serve`; something else entirely for another engine) — stays loose.
|
|
174
|
+
*/
|
|
175
|
+
export const HealthResponseSchema = z
|
|
176
|
+
.object({
|
|
177
|
+
status: z.literal("ok"),
|
|
178
|
+
uptime_s: z.number(),
|
|
179
|
+
})
|
|
180
|
+
.loose()
|
|
181
|
+
.openapi("HealthResponse")
|