@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 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.0",
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
- "node": "./index.ts",
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.0",
33
- "@mailwoman/core": "7.2.0",
34
- "@mailwoman/formatter": "7.2.0",
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")