@mailwoman/api 9.1.0 → 9.3.0
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/README.md +1 -1
- package/{app.ts → lib/app.ts} +39 -11
- package/{engine.ts → lib/engine.ts} +24 -12
- package/{index.ts → lib/index.ts} +4 -4
- package/{routes.ts → lib/routes.ts} +78 -54
- package/lib/schema.ts +502 -0
- package/out/app.d.ts +10 -2
- package/out/app.d.ts.map +1 -1
- package/out/app.js +21 -9
- package/out/app.js.map +1 -1
- package/out/engine.d.ts +19 -23
- package/out/engine.d.ts.map +1 -1
- package/out/engine.js.map +1 -1
- package/out/index.d.ts +4 -4
- package/out/index.d.ts.map +1 -1
- package/out/index.js +4 -4
- package/out/index.js.map +1 -1
- package/out/routes.d.ts +9 -3
- package/out/routes.d.ts.map +1 -1
- package/out/routes.js +47 -50
- package/out/routes.js.map +1 -1
- package/out/schema.d.ts +616 -9
- package/out/schema.d.ts.map +1 -1
- package/out/schema.js +177 -21
- package/out/schema.js.map +1 -1
- package/package.json +38 -9
- package/schema.ts +0 -328
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mailwoman/api
|
|
2
2
|
|
|
3
|
-
The **native [Mailwoman](https://mailwoman.
|
|
3
|
+
The **native [Mailwoman](https://mailwoman.ai) HTTP API** — an engine-agnostic `/v1` surface
|
|
4
4
|
(parse, geocode, batch, resolve, format) plus health, metrics, and an emitted OpenAPI document. Unlike its
|
|
5
5
|
drop-in siblings ([`@mailwoman/nominatim`](../nominatim), [`@mailwoman/photon`](../photon),
|
|
6
6
|
[`@mailwoman/libpostal`](../libpostal)), nothing here mimics a third-party API — this is Mailwoman's own
|
package/{app.ts → lib/app.ts}
RENAMED
|
@@ -9,13 +9,24 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { OpenAPIHono } from "@hono/zod-openapi"
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
12
|
+
import { attachOpenAPIDocs, engineHeaders, errorResponse, type OpenAPIDocInfo } from "@mailwoman/api-kit"
|
|
13
|
+
import { readLocalJSONFile } from "@mailwoman/core/fs/readers"
|
|
14
|
+
import type { EngineStamp } from "@mailwoman/core/license"
|
|
15
|
+
import { resolvePackagePath } from "@mailwoman/core/module/resolvers"
|
|
14
16
|
import { bodyLimit } from "hono/body-limit"
|
|
15
17
|
import { cors } from "hono/cors"
|
|
16
18
|
|
|
17
|
-
import type { MailwomanAPIEngine } from "
|
|
18
|
-
import { DEFAULT_BATCH_MAX, registerMailwomanAPIRoutes } from "
|
|
19
|
+
import type { MailwomanAPIEngine } from "#engine"
|
|
20
|
+
import { DEFAULT_BATCH_MAX, registerMailwomanAPIRoutes } from "#routes"
|
|
21
|
+
import type { GeocodeOutcomeLike } from "#schema"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* This package's own manifest, read at load rather than imported as a module: a JSON import makes `tsc` copy the file
|
|
25
|
+
* into `out/`, where it becomes the package scope for the compiled tree and breaks every `#` import in it.
|
|
26
|
+
*/
|
|
27
|
+
const packageJson = await readLocalJSONFile<{ name: string; version: string; description: string }>(
|
|
28
|
+
resolvePackagePath("@mailwoman/api", "package.json")
|
|
29
|
+
)
|
|
19
30
|
|
|
20
31
|
/**
|
|
21
32
|
* 2 MiB — carried from the express server's `express.json({ limit: "2mb" })` (`mailwoman/server/index.ts`).
|
|
@@ -43,6 +54,13 @@ export interface MailwomanAPIOptions {
|
|
|
43
54
|
* Max `addresses` rows accepted by `POST /v1/batch`. Default 500 (see `routes.ts`'s `DEFAULT_BATCH_MAX`).
|
|
44
55
|
*/
|
|
45
56
|
batchMax?: number
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The engine stamp to carry on every response: `engine` in each `/v1` body and the `Server` + `Link: rel="license"`
|
|
60
|
+
* headers everywhere. Absent when an embedding application builds the app without the `mailwoman` package; the
|
|
61
|
+
* `mailwoman serve` command always passes one.
|
|
62
|
+
*/
|
|
63
|
+
engine?: EngineStamp
|
|
46
64
|
}
|
|
47
65
|
|
|
48
66
|
/**
|
|
@@ -63,7 +81,7 @@ export const MAILWOMAN_API_DOC_INFO: OpenAPIDocInfo = {
|
|
|
63
81
|
version: packageJson.version,
|
|
64
82
|
description: packageJson.description,
|
|
65
83
|
license: { name: "AGPL-3.0-only OR LicenseRef-Commercial", identifier: "AGPL-3.0-only" },
|
|
66
|
-
contact: { name: "Sister Software", url: "https://mailwoman.
|
|
84
|
+
contact: { name: "Sister Software", url: "https://mailwoman.ai" },
|
|
67
85
|
servers: [
|
|
68
86
|
{
|
|
69
87
|
url: "http://{host}:{port}",
|
|
@@ -83,7 +101,10 @@ export const MAILWOMAN_API_DOC_INFO: OpenAPIDocInfo = {
|
|
|
83
101
|
/**
|
|
84
102
|
* Build the native Mailwoman app around an injected {@link MailwomanAPIEngine}.
|
|
85
103
|
*/
|
|
86
|
-
export function createMailwomanAPI
|
|
104
|
+
export function createMailwomanAPI<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike>(
|
|
105
|
+
engine: MailwomanAPIEngine<T>,
|
|
106
|
+
options: MailwomanAPIOptions = {}
|
|
107
|
+
): OpenAPIHono {
|
|
87
108
|
const app = new OpenAPIHono({
|
|
88
109
|
// This surface is ours (no vendor contract to preserve): every declared body/query schema is
|
|
89
110
|
// validator-enforced, and a failure maps through the shared api-kit envelope — never the raw zod
|
|
@@ -92,7 +113,7 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
|
|
|
92
113
|
// just `/v1/format`).
|
|
93
114
|
defaultHook: (result, c) => {
|
|
94
115
|
if (!result.success) {
|
|
95
|
-
return
|
|
116
|
+
return errorResponse(c, 400, "invalid request body", summarizeValidationError(result.error))
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
return undefined
|
|
@@ -105,6 +126,10 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
|
|
|
105
126
|
app.use(cors({ origin: "*", allowMethods: ["GET", "POST", "OPTIONS"], allowHeaders: ["*"], maxAge: 86_400 }))
|
|
106
127
|
}
|
|
107
128
|
|
|
129
|
+
if (options.engine) {
|
|
130
|
+
app.use(engineHeaders(options.engine))
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
// Safety net: an engine fault answers the native envelope, never a crash. `detail` carries the raw message —
|
|
109
134
|
// this surface is ours to design, so (unlike the vendor-constrained drop-in envelopes) we can be helpful.
|
|
110
135
|
app.onError((error, c) => {
|
|
@@ -112,10 +137,10 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
|
|
|
112
137
|
// validator throws before a route's own hook ever sees the body, so it lands here instead of the
|
|
113
138
|
// per-route 400s in routes.ts. Answer 400, not the 500 net (which stays reserved for engine faults).
|
|
114
139
|
if (error instanceof Error && error.message.includes("Malformed JSON")) {
|
|
115
|
-
return
|
|
140
|
+
return errorResponse(c, 400, "invalid request body", "malformed JSON")
|
|
116
141
|
}
|
|
117
142
|
|
|
118
|
-
return
|
|
143
|
+
return errorResponse(c, 500, "internal error", error instanceof Error ? error.message : String(error))
|
|
119
144
|
})
|
|
120
145
|
|
|
121
146
|
// Ahead of the handlers (which buffer the body into memory) so an oversized POST is rejected before that
|
|
@@ -124,11 +149,14 @@ export function createMailwomanAPI(engine: MailwomanAPIEngine, options: Mailwoma
|
|
|
124
149
|
"/v1/*",
|
|
125
150
|
bodyLimit({
|
|
126
151
|
maxSize: options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES,
|
|
127
|
-
onError: (c) =>
|
|
152
|
+
onError: (c) => errorResponse(c, 413, "request body too large"),
|
|
128
153
|
})
|
|
129
154
|
)
|
|
130
155
|
|
|
131
|
-
registerMailwomanAPIRoutes(app, engine, {
|
|
156
|
+
registerMailwomanAPIRoutes(app, engine, {
|
|
157
|
+
batchMax: options.batchMax ?? DEFAULT_BATCH_MAX,
|
|
158
|
+
engine: options.engine,
|
|
159
|
+
})
|
|
132
160
|
|
|
133
161
|
attachOpenAPIDocs(app, MAILWOMAN_API_DOC_INFO)
|
|
134
162
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import type { AddressTree } from "@mailwoman/core"
|
|
12
12
|
|
|
13
|
+
import type { GeocodeOutcomeLike } from "#schema"
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* One parsed component in reading order (a `ComponentTag` + the covered text).
|
|
15
17
|
*/
|
|
@@ -21,22 +23,22 @@ export interface ParseComponent {
|
|
|
21
23
|
/**
|
|
22
24
|
* One parse outcome: ordered components + the full decoded tree (the same language `/v1/resolve` speaks).
|
|
23
25
|
*/
|
|
24
|
-
export interface
|
|
26
|
+
export interface ParsedAddressResult {
|
|
25
27
|
input: string
|
|
26
28
|
components: ParseComponent[]
|
|
27
29
|
tree: AddressTree
|
|
28
30
|
debug?: string
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
export interface BatchResultFailure {
|
|
34
|
+
input: string
|
|
35
|
+
error: string
|
|
36
|
+
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
|
-
* A batch row
|
|
39
|
+
* A batch row slot (per-row isolation).
|
|
38
40
|
*/
|
|
39
|
-
export type
|
|
41
|
+
export type BatchResultEntry<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike> = T | BatchResultFailure
|
|
40
42
|
|
|
41
43
|
export interface ResolveTreeOutcome {
|
|
42
44
|
tree: AddressTree
|
|
@@ -53,11 +55,21 @@ export type HealthData = Record<string, unknown>
|
|
|
53
55
|
*/
|
|
54
56
|
export type WireInputMode = "fragmented" | "formatted"
|
|
55
57
|
|
|
56
|
-
export interface
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
export interface ParseInit {
|
|
59
|
+
inputMode?: WireInputMode
|
|
60
|
+
debug?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type GeocodeCallback<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike> = (
|
|
64
|
+
address: string,
|
|
65
|
+
opts?: ParseInit
|
|
66
|
+
) => Promise<T>
|
|
67
|
+
|
|
68
|
+
export interface MailwomanAPIEngine<T extends Partial<GeocodeOutcomeLike> = GeocodeOutcomeLike> {
|
|
69
|
+
parse?(address: string, opts: ParseInit): Promise<ParsedAddressResult>
|
|
70
|
+
geocode?: GeocodeCallback<T>
|
|
71
|
+
batch?(addresses: string[], opts?: ParseInit): Promise<{ results: BatchResultEntry<T>[] }>
|
|
60
72
|
resolveTree?(tree: AddressTree, opts: Record<string, unknown>): Promise<ResolveTreeOutcome>
|
|
61
73
|
reload?(): Promise<{ reloaded: boolean; versions: unknown }>
|
|
62
|
-
health?(): HealthData
|
|
74
|
+
health?(): Promise<HealthData>
|
|
63
75
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* engine contract in `engine.ts`; the zod wire schemas in `schema.ts`.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
export * from "
|
|
20
|
-
export * from "
|
|
21
|
-
export * from "
|
|
22
|
-
export * from "
|
|
19
|
+
export * from "#app"
|
|
20
|
+
export * from "#engine"
|
|
21
|
+
export * from "#routes"
|
|
22
|
+
export * from "#schema"
|
|
@@ -20,12 +20,19 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi"
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
geocoderUnavailableError,
|
|
25
|
+
metricsSnapshot,
|
|
26
|
+
recordTimed,
|
|
27
|
+
stampedResponseSchema,
|
|
28
|
+
withEngineStamp,
|
|
29
|
+
} from "@mailwoman/api-kit"
|
|
24
30
|
import type { AddressTree } from "@mailwoman/core/decoder"
|
|
31
|
+
import type { EngineStamp } from "@mailwoman/core/license"
|
|
25
32
|
import type { ComponentTag } from "@mailwoman/core/types"
|
|
26
33
|
import { canonicalKey, type ComponentDict, formatAddress, type FormatAddressOptions } from "@mailwoman/formatter"
|
|
27
34
|
|
|
28
|
-
import type { MailwomanAPIEngine } from "
|
|
35
|
+
import type { MailwomanAPIEngine } from "#engine"
|
|
29
36
|
import {
|
|
30
37
|
APIErrorSchema,
|
|
31
38
|
BatchRequestSchema,
|
|
@@ -39,26 +46,18 @@ import {
|
|
|
39
46
|
ParseRequestSchema,
|
|
40
47
|
ResolveRequestSchema,
|
|
41
48
|
ResolveResponseSchema,
|
|
42
|
-
|
|
49
|
+
type GeocodeOutcome,
|
|
50
|
+
} from "#schema"
|
|
43
51
|
|
|
44
52
|
/**
|
|
45
53
|
* Default `POST /v1/batch` row cap when {@link RegisterMailwomanAPIRoutesOptions.batchMax} is omitted. This is the
|
|
46
54
|
* standalone-engine default, not derived from env — `mailwoman serve` always passes the env-derived value explicitly
|
|
47
|
-
* (`$public.MAILWOMAN_BATCH_MAX`, default 1000; see `
|
|
55
|
+
* (`$public.MAILWOMAN_BATCH_MAX`, default 1000; see `mailwoman/lib/env/schema.ts`).
|
|
48
56
|
*/
|
|
49
57
|
export const DEFAULT_BATCH_MAX = 500
|
|
50
58
|
|
|
51
59
|
const startedAt = Date.now()
|
|
52
60
|
|
|
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
61
|
/**
|
|
63
62
|
* Options for {@link registerMailwomanAPIRoutes}.
|
|
64
63
|
*/
|
|
@@ -67,6 +66,11 @@ export interface RegisterMailwomanAPIRoutesOptions {
|
|
|
67
66
|
* Max `addresses` rows accepted by `POST /v1/batch`. Default {@link DEFAULT_BATCH_MAX}.
|
|
68
67
|
*/
|
|
69
68
|
batchMax?: number
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The engine stamp attached as `engine` to every `/v1` success body. Absent: no field is added.
|
|
72
|
+
*/
|
|
73
|
+
engine?: EngineStamp
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
const errorContent = (description: string) => ({
|
|
@@ -86,7 +90,7 @@ const parseQueryParams = z.object({
|
|
|
86
90
|
const parseResponses = {
|
|
87
91
|
200: {
|
|
88
92
|
description: "The tokenized input span + ranked solutions.",
|
|
89
|
-
content: { "application/json": { schema: ParseOutcomeSchema } },
|
|
93
|
+
content: { "application/json": { schema: stampedResponseSchema(ParseOutcomeSchema) } },
|
|
90
94
|
},
|
|
91
95
|
400: errorContent("`address` is required."),
|
|
92
96
|
501: errorContent("The backing engine method is not wired for this deployment."),
|
|
@@ -95,7 +99,7 @@ const parseResponses = {
|
|
|
95
99
|
const geocodeResponses = {
|
|
96
100
|
200: {
|
|
97
101
|
description: "One geocode result (parse → resolve cascade), passed through from the engine verbatim.",
|
|
98
|
-
content: { "application/json": { schema: GeocodeOutcomeSchema } },
|
|
102
|
+
content: { "application/json": { schema: stampedResponseSchema(GeocodeOutcomeSchema) } },
|
|
99
103
|
},
|
|
100
104
|
400: errorContent("`address` is required."),
|
|
101
105
|
503: errorContent("The geocoding engine is not wired for this deployment (dependencies missing)."),
|
|
@@ -104,7 +108,7 @@ const geocodeResponses = {
|
|
|
104
108
|
const batchResponses = {
|
|
105
109
|
200: {
|
|
106
110
|
description: "One result per input address, in input order (per-row error isolation).",
|
|
107
|
-
content: { "application/json": { schema: BatchResponseSchema } },
|
|
111
|
+
content: { "application/json": { schema: stampedResponseSchema(BatchResponseSchema) } },
|
|
108
112
|
},
|
|
109
113
|
400: errorContent("Body must be `{ addresses: string[] }`."),
|
|
110
114
|
413: errorContent("`addresses.length` exceeds the configured batch cap."),
|
|
@@ -114,7 +118,7 @@ const batchResponses = {
|
|
|
114
118
|
const resolveResponses = {
|
|
115
119
|
200: {
|
|
116
120
|
description: "The same tree, decorated in place with gazetteer coordinates + attribution.",
|
|
117
|
-
content: { "application/json": { schema: ResolveResponseSchema } },
|
|
121
|
+
content: { "application/json": { schema: stampedResponseSchema(ResolveResponseSchema) } },
|
|
118
122
|
},
|
|
119
123
|
400: errorContent("Body must be `{ tree: AddressTree, opts? }`."),
|
|
120
124
|
503: errorContent("The resolver is not wired for this deployment (dependencies missing)."),
|
|
@@ -122,7 +126,7 @@ const resolveResponses = {
|
|
|
122
126
|
|
|
123
127
|
const reloadResponses = {
|
|
124
128
|
200: {
|
|
125
|
-
description: "Versioned data switchover result — the new per-
|
|
129
|
+
description: "Versioned data switchover result — the new per-extract version map.",
|
|
126
130
|
content: {
|
|
127
131
|
"application/json": { schema: z.looseObject({ reloaded: z.boolean(), versions: z.unknown() }) },
|
|
128
132
|
},
|
|
@@ -133,7 +137,7 @@ const reloadResponses = {
|
|
|
133
137
|
const formatResponses = {
|
|
134
138
|
200: {
|
|
135
139
|
description: "The rendered address string + the deterministic canonical match key.",
|
|
136
|
-
content: { "application/json": { schema: FormatResponseSchema } },
|
|
140
|
+
content: { "application/json": { schema: stampedResponseSchema(FormatResponseSchema) } },
|
|
137
141
|
},
|
|
138
142
|
400: errorContent("Invalid request body."),
|
|
139
143
|
}
|
|
@@ -206,7 +210,7 @@ const reloadRoute = createRoute({
|
|
|
206
210
|
method: "post",
|
|
207
211
|
path: "/v1/reload",
|
|
208
212
|
operationId: "reload",
|
|
209
|
-
summary: "Reload versioned data
|
|
213
|
+
summary: "Reload versioned data extracts (deploy-only; check at ingress)",
|
|
210
214
|
tags: ["meta"],
|
|
211
215
|
responses: reloadResponses,
|
|
212
216
|
})
|
|
@@ -261,15 +265,17 @@ function toComponentDict(components: Record<string, string | string[]>): Compone
|
|
|
261
265
|
/**
|
|
262
266
|
* Register the native `/v1` routes + `/health` + `/metrics` against an injected engine.
|
|
263
267
|
*/
|
|
264
|
-
export function registerMailwomanAPIRoutes(
|
|
268
|
+
export function registerMailwomanAPIRoutes<T extends Partial<GeocodeOutcome> = GeocodeOutcome>(
|
|
265
269
|
app: OpenAPIHono,
|
|
266
|
-
engine: MailwomanAPIEngine
|
|
270
|
+
engine: MailwomanAPIEngine<T>,
|
|
267
271
|
options: RegisterMailwomanAPIRoutesOptions = {}
|
|
268
272
|
): void {
|
|
269
273
|
const batchMax = options.batchMax ?? DEFAULT_BATCH_MAX
|
|
274
|
+
const stamp = options.engine
|
|
270
275
|
|
|
271
276
|
app.openapi(parseGetRoute, async (c) => {
|
|
272
277
|
if (!engine.parse) return c.json({ error: "parse not implemented" }, 501)
|
|
278
|
+
|
|
273
279
|
const address = c.req.query("address")?.trim()
|
|
274
280
|
|
|
275
281
|
if (!address) return c.json({ error: "address is required" }, 400)
|
|
@@ -278,20 +284,26 @@ export function registerMailwomanAPIRoutes(
|
|
|
278
284
|
const inputMode = inputModeRaw === "fragmented" || inputModeRaw === "formatted" ? inputModeRaw : undefined
|
|
279
285
|
const outcome = await engine.parse(address, { debug, inputMode })
|
|
280
286
|
|
|
281
|
-
return c.json(outcome, 200)
|
|
287
|
+
return c.json(withEngineStamp(outcome, stamp), 200)
|
|
282
288
|
})
|
|
283
289
|
|
|
284
290
|
app.openapi(
|
|
285
291
|
parsePostRoute,
|
|
286
292
|
async (c) => {
|
|
287
|
-
if (!engine.parse)
|
|
293
|
+
if (!engine.parse) {
|
|
294
|
+
return c.json({ error: "parse not implemented" }, 501)
|
|
295
|
+
}
|
|
296
|
+
|
|
288
297
|
const { address, debug, input_mode } = c.req.valid("json")
|
|
289
298
|
const trimmed = address.trim()
|
|
290
299
|
|
|
291
|
-
if (!trimmed)
|
|
300
|
+
if (!trimmed) {
|
|
301
|
+
return c.json({ error: "address is required" }, 400)
|
|
302
|
+
}
|
|
303
|
+
|
|
292
304
|
const outcome = await engine.parse(trimmed, { debug: debug ?? false, inputMode: input_mode })
|
|
293
305
|
|
|
294
|
-
return c.json(outcome, 200)
|
|
306
|
+
return c.json(withEngineStamp(outcome, stamp), 200)
|
|
295
307
|
},
|
|
296
308
|
(result, c) => {
|
|
297
309
|
if (!result.success) return c.json({ error: "address is required" }, 400)
|
|
@@ -303,29 +315,33 @@ export function registerMailwomanAPIRoutes(
|
|
|
303
315
|
app.openapi(
|
|
304
316
|
geocodeRoute,
|
|
305
317
|
async (c) => {
|
|
306
|
-
if (!engine.geocode)
|
|
318
|
+
if (!engine.geocode) {
|
|
319
|
+
return geocoderUnavailableError(c)
|
|
320
|
+
}
|
|
321
|
+
|
|
307
322
|
const { address, input_mode } = c.req.valid("json")
|
|
308
323
|
const trimmed = address.trim()
|
|
309
324
|
|
|
310
325
|
if (!trimmed) return c.json({ error: "address is required" }, 400)
|
|
311
326
|
const t0 = performance.now()
|
|
312
327
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
328
|
+
return engine
|
|
329
|
+
.geocode(trimmed, { inputMode: input_mode })
|
|
330
|
+
.then((outcome) => {
|
|
331
|
+
recordTimed(performance.now() - t0, String(outcome.resolution_tier ?? "admin"))
|
|
332
|
+
|
|
333
|
+
return c.json(withEngineStamp(outcome as GeocodeOutcome, stamp), 200)
|
|
334
|
+
})
|
|
335
|
+
.catch((error) => {
|
|
336
|
+
recordTimed(performance.now() - t0, "error")
|
|
337
|
+
|
|
338
|
+
throw error
|
|
339
|
+
})
|
|
326
340
|
},
|
|
327
341
|
(result, c) => {
|
|
328
|
-
if (!result.success)
|
|
342
|
+
if (!result.success) {
|
|
343
|
+
return c.json({ error: "address is required" }, 400)
|
|
344
|
+
}
|
|
329
345
|
|
|
330
346
|
return undefined
|
|
331
347
|
}
|
|
@@ -336,13 +352,15 @@ export function registerMailwomanAPIRoutes(
|
|
|
336
352
|
async (c) => {
|
|
337
353
|
const { addresses, input_mode } = c.req.valid("json")
|
|
338
354
|
|
|
339
|
-
if (!addresses.length) return c.json({ results: [] }, 200)
|
|
355
|
+
if (!addresses.length) return c.json(withEngineStamp({ results: [] }, stamp), 200)
|
|
340
356
|
|
|
341
357
|
if (addresses.length > batchMax) {
|
|
342
358
|
return c.json({ error: `batch too large: ${addresses.length} > ${batchMax}` }, 413)
|
|
343
359
|
}
|
|
344
360
|
|
|
345
|
-
if (!engine.batch)
|
|
361
|
+
if (!engine.batch) {
|
|
362
|
+
return geocoderUnavailableError(c)
|
|
363
|
+
}
|
|
346
364
|
|
|
347
365
|
// Whole-call latency, recorded under the "batch" tier. Per-row tier metrics are the ENGINE's
|
|
348
366
|
// responsibility (phase 4b) — this app only times the call as a unit.
|
|
@@ -356,7 +374,7 @@ export function registerMailwomanAPIRoutes(
|
|
|
356
374
|
// Same wire-vs-domain cast as `/v1/geocode` above — `BatchRow`'s `GeocodeOutcome` half is a
|
|
357
375
|
// `Record<string, unknown>` passthrough; `BatchResponseSchema` now types its `GeocodeOutcome` union
|
|
358
376
|
// member as the real shape.
|
|
359
|
-
return c.json(outcome as
|
|
377
|
+
return c.json(withEngineStamp(outcome as z.infer<typeof BatchResponseSchema>, stamp), 200)
|
|
360
378
|
} catch (error) {
|
|
361
379
|
recordTimed(performance.now() - t0, "error")
|
|
362
380
|
throw error
|
|
@@ -375,14 +393,17 @@ export function registerMailwomanAPIRoutes(
|
|
|
375
393
|
// street node's stamped resolution tier per call — the wired engine must carry that over, and
|
|
376
394
|
// must trim batch rows the same way (the route passes raw input through).
|
|
377
395
|
async (c) => {
|
|
378
|
-
|
|
396
|
+
// `resolver`, not `geocoder` — the missing method is `engine.resolveTree`, and the 503's `error`
|
|
397
|
+
// value is what a caller branches on.
|
|
398
|
+
if (!engine.resolveTree) {
|
|
399
|
+
return geocoderUnavailableError(c, "resolver")
|
|
400
|
+
}
|
|
401
|
+
|
|
379
402
|
const { tree, opts } = c.req.valid("json")
|
|
380
|
-
// The wire schema keeps `tree` loose (`{ roots: unknown[] }`, forward-compat) — a local cast at the
|
|
381
|
-
// boundary onto the engine's `AddressTree` contract, matching the established idiom (api-kit's
|
|
382
|
-
// `openapi.ts`, the drop-ins' response casts) for "documented wire shape looser than the domain type".
|
|
383
|
-
const outcome = await engine.resolveTree(tree as unknown as AddressTree, opts ?? {})
|
|
384
403
|
|
|
385
|
-
|
|
404
|
+
const outcome = await engine.resolveTree(tree as AddressTree, opts ?? {})
|
|
405
|
+
|
|
406
|
+
return c.json(withEngineStamp(outcome, stamp), 200)
|
|
386
407
|
},
|
|
387
408
|
(result, c) => {
|
|
388
409
|
if (!result.success) return c.json({ error: "body must be { tree: AddressTree, opts? }" }, 400)
|
|
@@ -392,7 +413,10 @@ export function registerMailwomanAPIRoutes(
|
|
|
392
413
|
)
|
|
393
414
|
|
|
394
415
|
app.openapi(reloadRoute, async (c) => {
|
|
395
|
-
if (!engine.reload)
|
|
416
|
+
if (!engine.reload) {
|
|
417
|
+
return geocoderUnavailableError(c)
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
const outcome = await engine.reload()
|
|
397
421
|
|
|
398
422
|
return c.json(outcome, 200)
|
|
@@ -403,13 +427,13 @@ export function registerMailwomanAPIRoutes(
|
|
|
403
427
|
const dict = toComponentDict(components)
|
|
404
428
|
const formatted = formatAddress(dict, country, formatOptions as FormatAddressOptions | undefined)
|
|
405
429
|
|
|
406
|
-
return c.json({ formatted, canonicalKey: canonicalKey(dict) }, 200)
|
|
430
|
+
return c.json(withEngineStamp({ formatted, canonicalKey: canonicalKey(dict) }, stamp), 200)
|
|
407
431
|
})
|
|
408
432
|
|
|
409
|
-
app.openapi(healthRoute, (c) => {
|
|
433
|
+
app.openapi(healthRoute, async (c) => {
|
|
410
434
|
const uptimeSeconds = Math.round((Date.now() - startedAt) / 1000)
|
|
411
435
|
|
|
412
|
-
return c.json({ status: "ok", uptime_s: uptimeSeconds, ...engine.health?.() }, 200)
|
|
436
|
+
return c.json({ status: "ok", uptime_s: uptimeSeconds, ...(await engine.health?.()) }, 200)
|
|
413
437
|
})
|
|
414
438
|
|
|
415
439
|
app.openapi(metricsRoute, (c) => c.json(metricsSnapshot(), 200))
|