@metaobjectsdev/runtime-ts 1.0.9-rc.1 → 1.0.9-rc.3
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/dist/drizzle-fastify/filter-allowlist.d.ts +23 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-allowlist.js +13 -1
- package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
- package/dist/drizzle-fastify/filter-parser.d.ts +1 -6
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +91 -42
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.d.ts +21 -0
- package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -0
- package/dist/drizzle-fastify/filter-value-format.js +54 -0
- package/dist/drizzle-fastify/filter-value-format.js.map +1 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +10 -5
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/list-params.d.ts +27 -0
- package/dist/drizzle-fastify/list-params.d.ts.map +1 -0
- package/dist/drizzle-fastify/list-params.js +43 -0
- package/dist/drizzle-fastify/list-params.js.map +1 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +3 -1
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +8 -4
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/drizzle-fastify/route-error-handler.d.ts +27 -0
- package/dist/drizzle-fastify/route-error-handler.d.ts.map +1 -0
- package/dist/drizzle-fastify/route-error-handler.js +41 -0
- package/dist/drizzle-fastify/route-error-handler.js.map +1 -0
- package/dist/fastify/index.d.ts.map +1 -1
- package/dist/fastify/index.js +28 -11
- package/dist/fastify/index.js.map +1 -1
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +30 -21
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +10 -7
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/hono/route-guard.d.ts +34 -0
- package/dist/hono/route-guard.d.ts.map +1 -0
- package/dist/hono/route-guard.js +46 -0
- package/dist/hono/route-guard.js.map +1 -0
- package/dist/route-errors.d.ts +50 -0
- package/dist/route-errors.d.ts.map +1 -0
- package/dist/route-errors.js +67 -0
- package/dist/route-errors.js.map +1 -0
- package/package.json +3 -3
- package/src/drizzle-fastify/filter-allowlist.ts +33 -0
- package/src/drizzle-fastify/filter-parser.ts +117 -39
- package/src/drizzle-fastify/filter-value-format.ts +68 -0
- package/src/drizzle-fastify/index.ts +10 -5
- package/src/drizzle-fastify/list-params.ts +50 -0
- package/src/drizzle-fastify/mount-m2m.ts +3 -1
- package/src/drizzle-fastify/mount-read-only.ts +8 -4
- package/src/drizzle-fastify/route-error-handler.ts +54 -0
- package/src/fastify/index.ts +24 -9
- package/src/hono/index.ts +27 -19
- package/src/hono/mount-read-only.ts +10 -13
- package/src/hono/route-guard.ts +58 -0
- package/src/route-errors.ts +77 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Well-formedness checks for filter values, keyed by the wire format a field's values
|
|
3
|
+
* take (docs/features/api-contract.md, "Type encodings"). The filter parser runs these
|
|
4
|
+
* before a value reaches SQL, so a malformed value is a 400 naming the format rather than
|
|
5
|
+
* a silent empty result (SQLite compares the text and matches nothing) or a driver error
|
|
6
|
+
* (Postgres refuses the cast).
|
|
7
|
+
*
|
|
8
|
+
* Deliberately a little more lenient than the RESPONSE encoding, because a filter bound
|
|
9
|
+
* is written by a person: a time may omit seconds, a timestamp may omit its time part
|
|
10
|
+
* (a day bound) or carry any UTC offset, and fractional seconds may have any precision.
|
|
11
|
+
* What is refused is a value that is not that kind of thing at all.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
FIELD_SUBTYPE_DATE,
|
|
16
|
+
FIELD_SUBTYPE_TIME,
|
|
17
|
+
FIELD_SUBTYPE_TIMESTAMP,
|
|
18
|
+
FIELD_SUBTYPE_UUID,
|
|
19
|
+
} from "@metaobjectsdev/metadata";
|
|
20
|
+
import type { FilterValueFormat } from "./filter-allowlist.js";
|
|
21
|
+
|
|
22
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
23
|
+
const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/;
|
|
24
|
+
// Date, then optionally a `T`/space time, then optionally `Z` or a ±HH[:]MM offset.
|
|
25
|
+
const TIMESTAMP_RE =
|
|
26
|
+
/^(\d{4}-\d{2}-\d{2})(?:[T ]((?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?)(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)?)?$/i;
|
|
27
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
|
|
29
|
+
/** A `YYYY-MM-DD` that names a real calendar day (2026-02-30 does not). */
|
|
30
|
+
function isCalendarDate(s: string): boolean {
|
|
31
|
+
const m = DATE_RE.exec(s);
|
|
32
|
+
if (!m) return false;
|
|
33
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
34
|
+
const probe = new Date(Date.UTC(y, mo - 1, d));
|
|
35
|
+
return probe.getUTCFullYear() === y && probe.getUTCMonth() === mo - 1 && probe.getUTCDate() === d;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isTimestamp(s: string): boolean {
|
|
39
|
+
const m = TIMESTAMP_RE.exec(s);
|
|
40
|
+
return m !== null && isCalendarDate(m[1] ?? "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CHECKS: Readonly<Record<FilterValueFormat, (s: string) => boolean>> = {
|
|
44
|
+
[FIELD_SUBTYPE_DATE]: isCalendarDate,
|
|
45
|
+
[FIELD_SUBTYPE_TIME]: (s) => TIME_RE.test(s),
|
|
46
|
+
[FIELD_SUBTYPE_TIMESTAMP]: isTimestamp,
|
|
47
|
+
[FIELD_SUBTYPE_UUID]: (s) => UUID_RE.test(s),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The `expected` member of the `invalid_filter_value` envelope, per format. */
|
|
51
|
+
export const FORMAT_EXPECTED: Readonly<Record<FilterValueFormat, string>> = {
|
|
52
|
+
[FIELD_SUBTYPE_DATE]: "date (YYYY-MM-DD)",
|
|
53
|
+
[FIELD_SUBTYPE_TIME]: "time (HH:MM[:SS[.fff]])",
|
|
54
|
+
[FIELD_SUBTYPE_TIMESTAMP]: "timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])",
|
|
55
|
+
[FIELD_SUBTYPE_UUID]: "uuid (8-4-4-4-12 hex)",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** `expected` for a temporal rule that does not say WHICH temporal format it is. */
|
|
59
|
+
export const ANY_TEMPORAL_EXPECTED = "date, time or timestamp (ISO 8601)";
|
|
60
|
+
|
|
61
|
+
export function matchesFormat(format: FilterValueFormat, s: string): boolean {
|
|
62
|
+
return CHECKS[format](s);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** For a `datetime` rule with no `format` (generated before it existed): any of the three. */
|
|
66
|
+
export function matchesAnyTemporal(s: string): boolean {
|
|
67
|
+
return isCalendarDate(s) || CHECKS[FIELD_SUBTYPE_TIME](s) || isTimestamp(s);
|
|
68
|
+
}
|
|
@@ -26,9 +26,10 @@ import { eq, count, and, asc } from "drizzle-orm";
|
|
|
26
26
|
import qs from "qs";
|
|
27
27
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
28
28
|
export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
29
|
-
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
29
|
+
import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js";
|
|
30
30
|
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow } from "./util.js";
|
|
31
31
|
import { timestampWire } from "../timestamp-wire.js";
|
|
32
|
+
import { withContractErrorHandler } from "./route-error-handler.js";
|
|
32
33
|
export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
|
|
33
34
|
export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
|
|
34
35
|
|
|
@@ -161,8 +162,11 @@ export function mountCrudRoutes(opts: CrudRoutesOptions): void {
|
|
|
161
162
|
|
|
162
163
|
type VerbOptions = Omit<CrudRoutesOptions, "expose">;
|
|
163
164
|
|
|
165
|
+
/** The adopter's route options plus the route-scoped contract error handler (an
|
|
166
|
+
* unexpected error answers `500 { error: "internal" }`, a malformed JSON body
|
|
167
|
+
* `400 { error: "invalid_json" }`) — see route-error-handler.ts. */
|
|
164
168
|
function routeOpts(opts: VerbOptions): RouteShorthandOptions {
|
|
165
|
-
return opts.routeOptions
|
|
169
|
+
return withContractErrorHandler(opts.routeOptions);
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
export function mountListRoute(opts: VerbOptions): void {
|
|
@@ -211,11 +215,12 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
211
215
|
} else {
|
|
212
216
|
// Legacy path — no allowlists configured. Only limit/offset (+ the TPH
|
|
213
217
|
// discriminator predicate when subtype-scoped).
|
|
214
|
-
const
|
|
218
|
+
const limit = parsePageBound(qsParsed, "limit");
|
|
219
|
+
const offset = parsePageBound(qsParsed, "offset");
|
|
215
220
|
if (discCond) { q = q.where(discCond); where = discCond; }
|
|
216
221
|
if (src.id !== undefined) q = q.orderBy(asc(src.id));
|
|
217
|
-
if (limit !== undefined) q = q.limit(
|
|
218
|
-
if (offset !== undefined) q = q.offset(
|
|
222
|
+
if (limit !== undefined) q = q.limit(limit);
|
|
223
|
+
if (offset !== undefined) q = q.offset(offset);
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
// Await the query directly rather than calling `.all()`: the
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List-query parameter checks shared by every mount — the Drizzle mounts (Fastify and
|
|
3
|
+
* Hono) and the ObjectManager Fastify mount. Deliberately free of drizzle-orm (an
|
|
4
|
+
* OPTIONAL peer): the ObjectManager mount must not pull it in.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class FilterParseError extends Error {
|
|
8
|
+
constructor(public readonly code: string, message: string, public readonly details?: Record<string, unknown>) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "FilterParseError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The page-size ceiling of a raw-SQL (column-less `.existing()`) view list, which has
|
|
15
|
+
* no allowlist to page through and returns at most this many rows. */
|
|
16
|
+
export const RAW_VIEW_MAX_LIMIT = 1000;
|
|
17
|
+
|
|
18
|
+
/** The `expected` member of a `pagination.invalid_value` answer with no upper bound. */
|
|
19
|
+
export const PAGINATION_EXPECTED = "a non-negative integer (0 or more)";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read `?limit=` / `?offset=` as a non-negative integer, or refuse it. Before, a value
|
|
23
|
+
* that was not a finite number (`?limit=abc`) was dropped — every row came back — and a
|
|
24
|
+
* negative or fractional one reached SQL. `max` is the ceiling a mount clamps to, when it
|
|
25
|
+
* has one: a bound above it is refused rather than silently lowered. TS-only (no corpus
|
|
26
|
+
* scenario sends a malformed page bound): see docs/features/api-contract.md, "TS-only
|
|
27
|
+
* error responses".
|
|
28
|
+
*/
|
|
29
|
+
export function parsePageBound(
|
|
30
|
+
query: Record<string, unknown>,
|
|
31
|
+
param: "limit" | "offset",
|
|
32
|
+
max?: number,
|
|
33
|
+
): number | undefined {
|
|
34
|
+
const raw = query[param];
|
|
35
|
+
if (raw === undefined) return undefined;
|
|
36
|
+
const s = typeof raw === "string" ? raw : String(raw);
|
|
37
|
+
const n = /^\d+$/.test(s) ? Number(s) : Number.NaN;
|
|
38
|
+
if (!Number.isSafeInteger(n) || (max !== undefined && n > max)) {
|
|
39
|
+
const expected = max === undefined ? PAGINATION_EXPECTED : `an integer from 0 to ${max}`;
|
|
40
|
+
throw new FilterParseError(
|
|
41
|
+
"pagination.invalid_value",
|
|
42
|
+
`?${param}= must be ${expected}, got "${s}".`,
|
|
43
|
+
{ param, value: s, expected },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The `expected` member of an `invalid_sort` answer — the one spelling `?sort` takes. */
|
|
50
|
+
export const SORT_EXPECTED = "sort=<field>:asc|desc";
|
|
@@ -34,6 +34,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
|
34
34
|
import { and, eq, or, inArray } from "drizzle-orm";
|
|
35
35
|
import { coerceIdForColumn } from "./util.js";
|
|
36
36
|
import { timestampWire } from "../timestamp-wire.js";
|
|
37
|
+
import { withContractErrorHandler } from "./route-error-handler.js";
|
|
37
38
|
|
|
38
39
|
// Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
|
|
39
40
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
|
|
@@ -84,7 +85,8 @@ export interface M2mRouteOptions {
|
|
|
84
85
|
export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
85
86
|
const targetPk = opts.targetPkColumn ?? "id";
|
|
86
87
|
const route = `${opts.path}/:id/${opts.relationName}`;
|
|
87
|
-
|
|
88
|
+
// Route-scoped contract error handler — see route-error-handler.ts.
|
|
89
|
+
const ro = withContractErrorHandler(opts.routeOptions);
|
|
88
90
|
const toWire = timestampWire(opts.targetTable);
|
|
89
91
|
|
|
90
92
|
opts.fastify.get(route, ro, async (req, reply) => {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
2
2
|
import { sql, eq, and, count } from "drizzle-orm";
|
|
3
3
|
import qs from "qs";
|
|
4
|
-
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
|
|
4
|
+
import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "./filter-parser.js";
|
|
5
5
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
6
6
|
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
|
|
7
7
|
import { timestampWire } from "../timestamp-wire.js";
|
|
8
|
+
import { withContractErrorHandler } from "./route-error-handler.js";
|
|
8
9
|
|
|
9
10
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
|
|
10
11
|
type AnyView = any;
|
|
@@ -118,7 +119,9 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
|
|
|
118
119
|
export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
119
120
|
const { fastify, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts;
|
|
120
121
|
const idCol = opts.idColumn ?? "id";
|
|
121
|
-
|
|
122
|
+
// Route-scoped contract error handler: an unexpected error answers
|
|
123
|
+
// `500 { error: "internal" }` rather than Fastify's default (which echoes the SQL).
|
|
124
|
+
const ro = withContractErrorHandler(opts.routeOptions);
|
|
122
125
|
|
|
123
126
|
const viewName = resolveViewName(view);
|
|
124
127
|
const useRawSql = isEmptyColumnView(view) && !!viewName;
|
|
@@ -137,8 +140,9 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
137
140
|
const qIdx = url.indexOf("?");
|
|
138
141
|
const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
139
142
|
const parsed = qs.parse(queryString) as Record<string, unknown>;
|
|
140
|
-
|
|
141
|
-
const
|
|
143
|
+
// A bound that is not an integer in range is a 400, not silently clamped.
|
|
144
|
+
const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
|
|
145
|
+
const offsetVal = parsePageBound(parsed, "offset") ?? 0;
|
|
142
146
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
143
147
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
144
148
|
const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`)) as any[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-level Fastify error handler every mount helper installs on the routes IT
|
|
3
|
+
* mounts (drizzle-fastify and the ObjectManager fastify adapter share it).
|
|
4
|
+
*
|
|
5
|
+
* Why route-level: Fastify's `errorHandler` route option is scoped to exactly that route
|
|
6
|
+
* (it overrides the scope's `setErrorHandler` for requests to it, and nothing else). A
|
|
7
|
+
* `setErrorHandler` call from inside a mount would instead change how every OTHER route
|
|
8
|
+
* in the adopter's scope answers — and the generated route files hand the mounts a plain
|
|
9
|
+
* plugin instance, so the helpers cannot know how wide that scope is. A scoped content-type
|
|
10
|
+
* parser was the other option; it would fix only the malformed-body half and would replace
|
|
11
|
+
* the adopter's own JSON parser for the whole scope.
|
|
12
|
+
*
|
|
13
|
+
* What it answers:
|
|
14
|
+
* - a body Fastify's JSON parser refused → `400 { "error": "invalid_json" }`;
|
|
15
|
+
* - any other deliberate 4xx (an auth hook's 401, schema validation, 413, 415) → rethrown,
|
|
16
|
+
* so the enclosing scope's handler — the adopter's, or Fastify's default — answers it
|
|
17
|
+
* exactly as it did before the mount existed;
|
|
18
|
+
* - anything else → logged server-side, then `500 { "error": "internal" }`, with no SQL,
|
|
19
|
+
* table, column or parameter in the body.
|
|
20
|
+
*
|
|
21
|
+
* An adopter who passes `routeOptions.errorHandler` keeps it: theirs wins, untouched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { FastifyReply, FastifyRequest, RouteShorthandOptions } from "fastify";
|
|
25
|
+
import {
|
|
26
|
+
HTTP_STATUS_BAD_REQUEST,
|
|
27
|
+
HTTP_STATUS_INTERNAL,
|
|
28
|
+
INTERNAL_ERROR_BODY,
|
|
29
|
+
INVALID_JSON_BODY,
|
|
30
|
+
isDeliberateClientError,
|
|
31
|
+
isMalformedJsonBodyError,
|
|
32
|
+
logUnexpectedError,
|
|
33
|
+
} from "../route-errors.js";
|
|
34
|
+
|
|
35
|
+
export function contractRouteErrorHandler(
|
|
36
|
+
error: unknown,
|
|
37
|
+
_request: FastifyRequest,
|
|
38
|
+
reply: FastifyReply,
|
|
39
|
+
): FastifyReply {
|
|
40
|
+
if (isMalformedJsonBodyError(error)) {
|
|
41
|
+
return reply.code(HTTP_STATUS_BAD_REQUEST).send(INVALID_JSON_BODY);
|
|
42
|
+
}
|
|
43
|
+
// Throwing from a route-level handler hands the error to the enclosing scope's handler.
|
|
44
|
+
if (isDeliberateClientError(error)) throw error;
|
|
45
|
+
logUnexpectedError(error);
|
|
46
|
+
return reply.code(HTTP_STATUS_INTERNAL).send(INTERNAL_ERROR_BODY);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The adopter's route options plus the contract error handler — unless they set their own. */
|
|
50
|
+
export function withContractErrorHandler(ro: RouteShorthandOptions | undefined): RouteShorthandOptions {
|
|
51
|
+
const base = ro ?? {};
|
|
52
|
+
if (base.errorHandler !== undefined) return base;
|
|
53
|
+
return { ...base, errorHandler: contractRouteErrorHandler };
|
|
54
|
+
}
|
package/src/fastify/index.ts
CHANGED
|
@@ -26,6 +26,8 @@ import type { RouteShorthandOptions } from "fastify";
|
|
|
26
26
|
import type { SortAllowlist } from "../drizzle-fastify/filter-allowlist.js";
|
|
27
27
|
import { sortOrderSpec } from "../drizzle-fastify/filter-allowlist.js";
|
|
28
28
|
import { isTruthyFlag, contractErrorCode } from "../drizzle-fastify/util.js";
|
|
29
|
+
import { parsePageBound, FilterParseError, SORT_EXPECTED } from "../drizzle-fastify/list-params.js";
|
|
30
|
+
import { withContractErrorHandler } from "../drizzle-fastify/route-error-handler.js";
|
|
29
31
|
|
|
30
32
|
// ---------------------------------------------------------------------------
|
|
31
33
|
// Public surface
|
|
@@ -107,12 +109,14 @@ export function mountCrudRoutes(opts: CrudRoutesOptions): void {
|
|
|
107
109
|
type SingleVerbOptions = Omit<CrudRoutesOptions, "expose">;
|
|
108
110
|
|
|
109
111
|
/**
|
|
110
|
-
* Build the route options Fastify wants
|
|
111
|
-
*
|
|
112
|
-
*
|
|
112
|
+
* Build the route options Fastify wants: the consumer's routeOptions plus the
|
|
113
|
+
* route-scoped contract error handler shared with drizzle-fastify (an unexpected
|
|
114
|
+
* error answers `500 { error: "internal" }` instead of Fastify's default, which
|
|
115
|
+
* echoes the driver message; a malformed JSON body answers
|
|
116
|
+
* `400 { error: "invalid_json" }`). A consumer-supplied `errorHandler` wins.
|
|
113
117
|
*/
|
|
114
118
|
function routeOpts(opts: SingleVerbOptions): RouteShorthandOptions {
|
|
115
|
-
return opts.routeOptions
|
|
119
|
+
return withContractErrorHandler(opts.routeOptions);
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
export function mountListRoute(opts: SingleVerbOptions): void {
|
|
@@ -126,9 +130,15 @@ export function mountListRoute(opts: SingleVerbOptions): void {
|
|
|
126
130
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
127
131
|
|
|
128
132
|
const readOpts: { limit?: number; offset?: number; orderBy?: [string, "asc" | "desc"] } = {};
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
try {
|
|
134
|
+
const limit = parsePageBound(parsed, "limit");
|
|
135
|
+
const offset = parsePageBound(parsed, "offset");
|
|
136
|
+
if (limit !== undefined) readOpts.limit = limit;
|
|
137
|
+
if (offset !== undefined) readOpts.offset = offset;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof FilterParseError) return reply.code(400).send({ error: err.code, ...(err.details ?? {}) });
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
132
142
|
|
|
133
143
|
// Sort allowlist gate (cross-port REST contract). Only enforced when a
|
|
134
144
|
// sortAllowlist is configured; absent → ?sort is ignored (back-compat).
|
|
@@ -137,7 +147,12 @@ export function mountListRoute(opts: SingleVerbOptions): void {
|
|
|
137
147
|
if (sortParse.error) {
|
|
138
148
|
// `field` is REQUIRED on the invalid_sort envelope cross-port (F20 ruling) —
|
|
139
149
|
// it names the offending sort field instead of making the caller guess.
|
|
140
|
-
return reply.code(400).send({
|
|
150
|
+
return reply.code(400).send({
|
|
151
|
+
error: contractErrorCode(sortParse.error),
|
|
152
|
+
field: sortParse.field,
|
|
153
|
+
expected: SORT_EXPECTED,
|
|
154
|
+
allowed: Object.keys(opts.sortAllowlist),
|
|
155
|
+
});
|
|
141
156
|
}
|
|
142
157
|
if (sortParse.orderBy) readOpts.orderBy = sortParse.orderBy;
|
|
143
158
|
}
|
|
@@ -304,7 +319,7 @@ export interface MetaRouteOptions {
|
|
|
304
319
|
*/
|
|
305
320
|
export function mountMetaRoute(opts: MetaRouteOptions): void {
|
|
306
321
|
const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
|
|
307
|
-
opts.fastify.get(path, async (_req, reply) => {
|
|
322
|
+
opts.fastify.get(path, withContractErrorHandler(undefined), async (_req, reply) => {
|
|
308
323
|
return reply
|
|
309
324
|
.code(200)
|
|
310
325
|
.header("content-type", META_CONTENT_TYPE)
|
package/src/hono/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import qs from "qs";
|
|
|
29
29
|
import { contractErrorCode } from "../drizzle-fastify/util.js";
|
|
30
30
|
import {
|
|
31
31
|
parseFilterParams,
|
|
32
|
+
parsePageBound,
|
|
32
33
|
FilterParseError,
|
|
33
34
|
type ParseFilterResult,
|
|
34
35
|
} from "../drizzle-fastify/filter-parser.js";
|
|
@@ -42,6 +43,10 @@ export type {
|
|
|
42
43
|
} from "../drizzle-fastify/filter-allowlist.js";
|
|
43
44
|
import { isTruthyFlag, coerceIdForColumn, firstRow } from "../drizzle-fastify/util.js";
|
|
44
45
|
import { timestampWire } from "../timestamp-wire.js";
|
|
46
|
+
// Every handler below is wrapped in guardRoute (an unexpected error answers
|
|
47
|
+
// `500 { error: "internal" }`, logged server-side) and reads its body through
|
|
48
|
+
// readJsonBody (a malformed body answers `400 { error: "invalid_json" }`).
|
|
49
|
+
import { guardRoute, readJsonBody } from "./route-guard.js";
|
|
45
50
|
// Back-compat re-export — the unsafe local copy was consolidated onto the one
|
|
46
51
|
// shared (deprecated) helper so the three adapters can't silently diverge.
|
|
47
52
|
export { parseId } from "../drizzle-fastify/util.js";
|
|
@@ -175,7 +180,7 @@ async function reReadThroughView(
|
|
|
175
180
|
|
|
176
181
|
export function mountListRoute(opts: VerbOptions): void {
|
|
177
182
|
const toWire = readWire(opts);
|
|
178
|
-
opts.app.get(opts.path, async (c) => {
|
|
183
|
+
opts.app.get(opts.path, guardRoute(async (c) => {
|
|
179
184
|
try {
|
|
180
185
|
const listSrc = readSource(opts);
|
|
181
186
|
let q = opts.db.select().from(listSrc).$dynamic();
|
|
@@ -200,9 +205,10 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
200
205
|
if (parsed.offset !== undefined) q = q.offset(parsed.offset);
|
|
201
206
|
} else {
|
|
202
207
|
// No allowlists configured. Only limit/offset.
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
if (
|
|
208
|
+
const limit = parsePageBound(qsParsed, "limit");
|
|
209
|
+
const offset = parsePageBound(qsParsed, "offset");
|
|
210
|
+
if (limit !== undefined) q = q.limit(limit);
|
|
211
|
+
if (offset !== undefined) q = q.offset(offset);
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
// Await the query directly rather than calling `.all()`: the drizzle-orm
|
|
@@ -230,12 +236,12 @@ export function mountListRoute(opts: VerbOptions): void {
|
|
|
230
236
|
}
|
|
231
237
|
throw err;
|
|
232
238
|
}
|
|
233
|
-
});
|
|
239
|
+
}));
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
export function mountGetRoute(opts: VerbOptions): void {
|
|
237
243
|
const toWire = readWire(opts);
|
|
238
|
-
opts.app.get(`${opts.path}/:id`, async (c) => {
|
|
244
|
+
opts.app.get(`${opts.path}/:id`, guardRoute(async (c) => {
|
|
239
245
|
const id = c.req.param("id") ?? "";
|
|
240
246
|
// Compare against the PK's real type — a numeric-LOOKING id on a TEXT pk
|
|
241
247
|
// must stay a string ('0123' ≠ '123'), or affinity matches the WRONG row.
|
|
@@ -244,14 +250,15 @@ export function mountGetRoute(opts: VerbOptions): void {
|
|
|
244
250
|
if (idValue === undefined) return c.json({ error: "invalid_id" }, 400);
|
|
245
251
|
const row = await firstRow(opts.db, getSrc, eq(getSrc.id, idValue));
|
|
246
252
|
return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
|
|
247
|
-
});
|
|
253
|
+
}));
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
export function mountCreateRoute(opts: VerbOptions): void {
|
|
251
257
|
const toWire = echoWire(opts);
|
|
252
|
-
opts.app.post(opts.path, async (c) => {
|
|
253
|
-
const
|
|
254
|
-
|
|
258
|
+
opts.app.post(opts.path, guardRoute(async (c) => {
|
|
259
|
+
const read = await readJsonBody(c);
|
|
260
|
+
if (!read.ok) return read.response;
|
|
261
|
+
const parsed = opts.insertSchema.safeParse(read.body);
|
|
255
262
|
if (!parsed.success) {
|
|
256
263
|
return c.json({ error: "validation", issues: parsed.error.issues }, 400);
|
|
257
264
|
}
|
|
@@ -269,15 +276,16 @@ export function mountCreateRoute(opts: VerbOptions): void {
|
|
|
269
276
|
const row = (result as unknown[])[0];
|
|
270
277
|
// Echo the row through the replica view so derived columns are present (#214).
|
|
271
278
|
return c.json(toWire(await reReadThroughView(opts, row)), 201);
|
|
272
|
-
});
|
|
279
|
+
}));
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
export function mountUpdateRoute(opts: VerbOptions): void {
|
|
276
283
|
const toWire = echoWire(opts);
|
|
277
|
-
const handler = async (c: Context) => {
|
|
284
|
+
const handler = guardRoute(async (c: Context) => {
|
|
278
285
|
const id = c.req.param("id") ?? "";
|
|
279
|
-
const
|
|
280
|
-
|
|
286
|
+
const read = await readJsonBody(c);
|
|
287
|
+
if (!read.ok) return read.response;
|
|
288
|
+
const parsed = opts.updateSchema.safeParse(read.body);
|
|
281
289
|
if (!parsed.success) {
|
|
282
290
|
return c.json({ error: "validation", issues: parsed.error.issues }, 400);
|
|
283
291
|
}
|
|
@@ -308,7 +316,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
308
316
|
}
|
|
309
317
|
const row = (result as unknown[])[0];
|
|
310
318
|
return row ? c.json(toWire(await reReadThroughView(opts, row))) : c.json({ error: "not_found" }, 404);
|
|
311
|
-
};
|
|
319
|
+
});
|
|
312
320
|
const path = `${opts.path}/:id`;
|
|
313
321
|
// Cross-port REST contract (FR-008): the update verb is reachable via BOTH PATCH
|
|
314
322
|
// and PUT, each routed to the same handler — as the drizzle-fastify flavor and
|
|
@@ -325,7 +333,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
|
|
|
325
333
|
}
|
|
326
334
|
|
|
327
335
|
export function mountDeleteRoute(opts: VerbOptions): void {
|
|
328
|
-
opts.app.delete(`${opts.path}/:id`, async (c) => {
|
|
336
|
+
opts.app.delete(`${opts.path}/:id`, guardRoute(async (c) => {
|
|
329
337
|
const id = c.req.param("id") ?? "";
|
|
330
338
|
// Compare against the PK's real type (see mountGetRoute) — a numeric-
|
|
331
339
|
// LOOKING id on a TEXT pk would otherwise DELETE the wrong row (data loss).
|
|
@@ -346,7 +354,7 @@ export function mountDeleteRoute(opts: VerbOptions): void {
|
|
|
346
354
|
return c.body(null, 204);
|
|
347
355
|
}
|
|
348
356
|
return c.json({ error: "not_found" }, 404);
|
|
349
|
-
});
|
|
357
|
+
}));
|
|
350
358
|
}
|
|
351
359
|
|
|
352
360
|
function extractRowCount(result: unknown): number {
|
|
@@ -387,7 +395,7 @@ export interface HonoMetaRouteOptions {
|
|
|
387
395
|
*/
|
|
388
396
|
export function mountMetaRouteHono(opts: HonoMetaRouteOptions): void {
|
|
389
397
|
const path = `${opts.prefix ?? ""}${META_ROUTE_PATH}`;
|
|
390
|
-
opts.app.get(path, (c) =>
|
|
398
|
+
opts.app.get(path, guardRoute((c) =>
|
|
391
399
|
c.body(metaJson(opts.root), 200, { "content-type": META_CONTENT_TYPE }),
|
|
392
|
-
);
|
|
400
|
+
));
|
|
393
401
|
}
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
import type { Hono } from "hono";
|
|
6
6
|
import { sql, eq, and, count } from "drizzle-orm";
|
|
7
7
|
import qs from "qs";
|
|
8
|
-
import { parseFilterParams, FilterParseError } from "../drizzle-fastify/filter-parser.js";
|
|
8
|
+
import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError } from "../drizzle-fastify/filter-parser.js";
|
|
9
9
|
import type {
|
|
10
10
|
FilterAllowlist,
|
|
11
11
|
SortAllowlist,
|
|
12
12
|
} from "../drizzle-fastify/filter-allowlist.js";
|
|
13
13
|
import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js";
|
|
14
14
|
import { timestampWire } from "../timestamp-wire.js";
|
|
15
|
+
// An unexpected error on a mounted route answers `500 { error: "internal" }`.
|
|
16
|
+
import { guardRoute } from "./route-guard.js";
|
|
15
17
|
|
|
16
18
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
|
|
17
19
|
type AnyView = any;
|
|
@@ -107,21 +109,16 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
107
109
|
const toWire = timestampWire(view);
|
|
108
110
|
|
|
109
111
|
// ── List ──────────────────────────────────────────────────────────────────
|
|
110
|
-
app.get(path, async (c) => {
|
|
112
|
+
app.get(path, guardRoute(async (c) => {
|
|
111
113
|
try {
|
|
112
114
|
if (useRawSql) {
|
|
113
115
|
const url = c.req.url;
|
|
114
116
|
const qIdx = url.indexOf("?");
|
|
115
117
|
const queryString = qIdx >= 0 ? url.slice(qIdx + 1) : "";
|
|
116
118
|
const parsed = qs.parse(queryString) as Record<string, unknown>;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
const offsetVal = Math.max(
|
|
122
|
-
0,
|
|
123
|
-
Number(typeof parsed["offset"] === "string" ? parsed["offset"] : 0),
|
|
124
|
-
);
|
|
119
|
+
// A bound that is not an integer in range is a 400, not silently clamped.
|
|
120
|
+
const limitVal = parsePageBound(parsed, "limit", RAW_VIEW_MAX_LIMIT) ?? RAW_VIEW_MAX_LIMIT;
|
|
121
|
+
const offsetVal = parsePageBound(parsed, "offset") ?? 0;
|
|
125
122
|
const withCount = isTruthyFlag(parsed["withCount"]);
|
|
126
123
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
127
124
|
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM "${viewName}" LIMIT ${limitVal} OFFSET ${offsetVal}`))) as any[];
|
|
@@ -175,10 +172,10 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
175
172
|
}
|
|
176
173
|
throw err;
|
|
177
174
|
}
|
|
178
|
-
});
|
|
175
|
+
}));
|
|
179
176
|
|
|
180
177
|
// ── Get by ID ─────────────────────────────────────────────────────────────
|
|
181
|
-
app.get(`${path}/:id`, async (c) => {
|
|
178
|
+
app.get(`${path}/:id`, guardRoute(async (c) => {
|
|
182
179
|
const id = c.req.param("id") ?? "";
|
|
183
180
|
if (useRawSql) {
|
|
184
181
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
@@ -202,7 +199,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
202
199
|
.limit(1);
|
|
203
200
|
const row = (rows as unknown[])[0];
|
|
204
201
|
return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
|
|
205
|
-
});
|
|
202
|
+
}));
|
|
206
203
|
|
|
207
204
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
208
205
|
const reject = (c: { req: { method: string }; json: (body: unknown, status: number) => unknown }) =>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-handler error guard for the Hono mount helpers — the Hono twin of
|
|
3
|
+
* drizzle-fastify's route-scoped error handler (route-error-handler.ts).
|
|
4
|
+
*
|
|
5
|
+
* Hono has no per-route error handler: `app.onError` is app-wide, so installing one from
|
|
6
|
+
* a mount would change how the adopter's OTHER routes answer. Wrapping each handler the
|
|
7
|
+
* mount registers keeps the change to exactly the routes it owns.
|
|
8
|
+
*
|
|
9
|
+
* An unexpected error is logged server-side and answered `500 { "error": "internal" }`.
|
|
10
|
+
* Hono's default would answer a bare-text `Internal Server Error` — no leak, but not the
|
|
11
|
+
* contract's JSON envelope, and different from the Fastify flavor this adapter promises
|
|
12
|
+
* to match on the wire. An `HTTPException` (anything carrying `getResponse`) was thrown
|
|
13
|
+
* on purpose and is rethrown to the adopter's `onError` / Hono's default untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Context } from "hono";
|
|
17
|
+
import {
|
|
18
|
+
HTTP_STATUS_BAD_REQUEST,
|
|
19
|
+
HTTP_STATUS_INTERNAL,
|
|
20
|
+
INTERNAL_ERROR_BODY,
|
|
21
|
+
INVALID_JSON_BODY,
|
|
22
|
+
logUnexpectedError,
|
|
23
|
+
} from "../route-errors.js";
|
|
24
|
+
|
|
25
|
+
type RouteHandler = (c: Context) => Promise<Response> | Response;
|
|
26
|
+
|
|
27
|
+
function isHttpException(err: unknown): boolean {
|
|
28
|
+
return typeof (err as { getResponse?: unknown } | null)?.getResponse === "function";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function guardRoute(handler: RouteHandler): RouteHandler {
|
|
32
|
+
return async (c: Context) => {
|
|
33
|
+
try {
|
|
34
|
+
return await handler(c);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (isHttpException(err)) throw err;
|
|
37
|
+
logUnexpectedError(err);
|
|
38
|
+
return c.json(INTERNAL_ERROR_BODY, HTTP_STATUS_INTERNAL);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The outcome of reading a JSON request body: the value, or the 400 to answer. */
|
|
44
|
+
export type JsonBodyRead = { ok: true; body: unknown } | { ok: false; response: Response };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the request body as JSON. A body that does not parse — including an empty one —
|
|
48
|
+
* answers `400 { "error": "invalid_json" }`, matching the Fastify flavor. Before, the
|
|
49
|
+
* parse failure was swallowed into `undefined` and surfaced as a Zod `validation` error
|
|
50
|
+
* complaining that the body was not an object, which named the wrong problem.
|
|
51
|
+
*/
|
|
52
|
+
export async function readJsonBody(c: Context): Promise<JsonBodyRead> {
|
|
53
|
+
try {
|
|
54
|
+
return { ok: true, body: await c.req.json() };
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, response: c.json(INVALID_JSON_BODY, HTTP_STATUS_BAD_REQUEST) };
|
|
57
|
+
}
|
|
58
|
+
}
|