peta-docs 0.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 ADDED
@@ -0,0 +1,350 @@
1
+ # peta-hono
2
+
3
+ OpenAPI + [Scalar](https://scalar.com) docs for [Hono](https://hono.dev), powered by [ArkType](https://arktype.io).
4
+
5
+ Define routes with ArkType schemas — get an OpenAPI 3.1 spec, auto-generated request validation, and an interactive API reference UI.
6
+
7
+ ## Features
8
+
9
+ - **ArkType-first** — schemas are ArkType types. Validation + docs from one definition.
10
+ - **Auto-validation** — schemas in the `route()` chain generate runtime validators. Invalid requests return `{ error: 'Validation failed', issues }` with status 400 before the handler runs.
11
+ - **Auto-load routes** — `loadRoutes()` discovers and mounts route modules from the filesystem.
12
+ - **Shorthand responses** — `200: Pet` instead of `200: { description, content: { 'application/json': { schema: Pet } } }`.
13
+ - **Status code autocomplete** — `StatusCode` type provides autocomplete for `200`, `201`, `400`, `404`, `500`, etc.
14
+ - **OpenAPI 3.1** — full JSON Schema 2020-12 compatibility.
15
+ - **Scalar UI** — one-liner to serve the Scalar API reference.
16
+ - **`c.req.valid('json')`** — typed body access with no extra imports or casts.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ bun add peta-hono hono arktype
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import { Hono } from "hono";
28
+ import { type } from "arktype";
29
+ import { getOpenAPISpec, route, serveScalarUI } from "peta-hono";
30
+
31
+ const app = new Hono();
32
+
33
+ const Pet = type({ id: "number", name: "string>0", species: "'cat'|'dog'" });
34
+
35
+ // route() generates docs + runtime validation from the same schema
36
+ app.get("/pets/:id", route()
37
+ .summary("Get a pet by ID")
38
+ .params(type({ id: "string" }))
39
+ .response(200, Pet)
40
+ .response(404, "Not found")
41
+ .handle((c) => c.json({ id: 1, name: "Fido", species: "dog" })),
42
+ );
43
+
44
+ app.post("/pets", route()
45
+ .summary("Create a pet")
46
+ .requestBody(type({ name: "string>0", species: "'cat'|'dog'" }))
47
+ .response(201, Pet)
48
+ .response(400, "Invalid input")
49
+ .handle((c) => {
50
+ const body = c.req.valid("json");
51
+ return c.json(body, 201);
52
+ }),
53
+ );
54
+
55
+ const info = { title: "Pet Store API", version: "1.0.0" };
56
+ app.get("/openapi.json", (c) => c.json(getOpenAPISpec(app, info)));
57
+ app.get("/docs", serveScalarUI({ specUrl: "/openapi.json", title: "Pet Store API" }));
58
+
59
+ export default app;
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### `route()` — chain API
65
+
66
+ Returns a `RouteBuilder` with chain methods for declaring route metadata and schemas. Terminal `.handle()` returns a Hono handler with validation composed in. Pass directly to `app.get()` / `app.post()`.
67
+
68
+ ```ts
69
+ route()
70
+ .summary(string) // optional
71
+ .description(string) // optional
72
+ .operationId(string) // optional
73
+ .tags(...string[]) // optional
74
+ .deprecated(boolean?) // optional
75
+ .paginated(options?) // optional: adds page/limit/offset to query + spec
76
+ .filter(name, schema, opts?) // optional: adds a filterable query param (supports operators)
77
+ .sort(fields) // optional: configures ?sort with ±field enum
78
+ .include(relations) // optional: configures ?include with related resource enum
79
+ .fieldsets(resources) // optional: configures ?fields[type] sparse fieldsets
80
+ .auth(scheme?) // optional: marks route as requiring auth (default: 'bearerAuth')
81
+ .query(ArkType type) // validates + documents query params
82
+ .params(ArkType type) // validates + documents path params
83
+ .headers(ArkType type) // validates + documents headers
84
+ .requestBody(ArkType type) // validates + documents request body
85
+ .response(status, value) // call: value is ArkType type, string, or full config
86
+ .onValidationError(handler) // per-route override for validation failure response
87
+ .handle(handler) // terminal → returns Hono handler
88
+ ```
89
+
90
+ Schema methods (`.requestBody`, `.query`, `.params`, `.headers`) enforce `ArkTypeSchema` at the type level — only ArkType types accepted.
91
+
92
+ `.response()` accepts:
93
+ | Value | Behavior |
94
+ |---|---|
95
+ | `ArkType type` | Auto "OK" description + `application/json` content |
96
+ | `string` | Description only, no content schema |
97
+ | `{ description?, content? }` | Full OpenAPI response object |
98
+
99
+ The handler callback receives a `TypedContext` with typed `.valid()` overloads matching your declared schemas:
100
+
101
+ ```ts
102
+ .handle((c) => {
103
+ const body = c.req.valid("json"); // typed as body schema's infer
104
+ const query = c.req.valid("query"); // typed as query schema's infer
105
+ // ...
106
+ })
107
+ ```
108
+
109
+ On validation failure, the route returns `{ error: 'Validation failed', issues: [...] }` with status 400.
110
+
111
+ #### `.paginated(options?)`
112
+
113
+ Adds `page`, `limit`, `offset` query parameters to validation and OpenAPI docs. `page` and `limit` are coerced from strings and clamped to valid ranges. `offset` is computed as `(page - 1) * limit`.
114
+
115
+ ```ts
116
+ route()
117
+ .paginated({ maxLimit: 100, defaultLimit: 20 })
118
+ .handle((c) => {
119
+ const { page, limit, offset } = c.req.valid("query"); // all numbers
120
+ const items = db.query(`SELECT * FROM items LIMIT $1 OFFSET $2`, [limit, offset]);
121
+ return c.json({ data: items, page, total });
122
+ })
123
+ ```
124
+
125
+ Options:
126
+
127
+ | Option | Default | Description |
128
+ |---|---|---|
129
+ | `maxLimit` | `100` | Maximum allowed value for `limit` |
130
+ | `defaultLimit` | `20` | Default `limit` when not specified |
131
+
132
+ Can be combined with `.query()` — pagination fields are merged into the same validated object.
133
+
134
+ #### `.auth(scheme?)`
135
+
136
+ Marks the route as requiring authentication in the OpenAPI spec. Adds `security: [{ [scheme]: [] }]` to the operation. The actual auth middleware is applied separately (e.g., via `app.use("/*", requireAuth)` or inline).
137
+
138
+ ```ts
139
+ route()
140
+ .auth() // security: [{ bearerAuth: [] }]
141
+ .auth("apiKey") // security: [{ apiKey: [] }]
142
+ .auth("bearerAuth")
143
+ .auth("oauth2") // multiple: [{ bearerAuth: [] }, { oauth2: [] }]
144
+ .handle((c) => c.json({ ok: true }));
145
+ ```
146
+
147
+ Define the security scheme in `components` via `getOpenAPISpec` options:
148
+
149
+ ```ts
150
+ getOpenAPISpec(app, info, undefined, {
151
+ components: {
152
+ securitySchemes: {
153
+ bearerAuth: { type: "http", scheme: "bearer" },
154
+ apiKey: { type: "apiKey", in: "header", name: "X-API-Key" },
155
+ },
156
+ },
157
+ });
158
+ ```
159
+
160
+ #### `.filter(name, schema, options?)`
161
+
162
+ Adds a filterable query parameter to the OpenAPI spec and runtime validation. Supports operator-based filters with `__` suffix convention.
163
+
164
+ ```ts
165
+ // Simple exact match — adds ?status= query param
166
+ .filter("status", type("'active'|'inactive'"))
167
+
168
+ // With operators — adds ?price__gte= and ?price__lte= query params
169
+ .filter("price", type("number"), { operators: ["gte", "lte"] })
170
+ ```
171
+
172
+ Available operators:
173
+
174
+ | Operator | Param example | Behavior |
175
+ |---|---|---|
176
+ | `eq` (default) | `?status=active` | Exact match |
177
+ | `ne` | `?name__ne=foo` | Not equal |
178
+ | `gte` | `?price__gte=10` | Greater than or equal |
179
+ | `gt` | `?price__gt=10` | Greater than |
180
+ | `lte` | `?price__lte=50` | Less than or equal |
181
+ | `lt` | `?price__lt=50` | Less than |
182
+ | `contains` | `?name__contains=foo` | Contains substring |
183
+ | `startsWith` | `?name__startsWith=foo` | Starts with |
184
+ | `endsWith` | `?name__endsWith=foo` | Ends with |
185
+ | `in` | `?status__in=active,pending` | Comma-separated set |
186
+
187
+ Filter values are validated against the provided schema and merged into `c.req.valid("query")` alongside `.query()` and `.paginated()` values.
188
+
189
+ #### `.sort(fields)`
190
+
191
+ Declares sortable fields. Adds a `?sort` query param with an enum of `±field` values.
192
+
193
+ ```ts
194
+ .sort(["name", "price", "createdAt"])
195
+ // → ?sort enum: name, -name, price, -price, createdAt, -createdAt
196
+ // → ?sort=-price,name (comma-separated, prefix - for descending)
197
+ ```
198
+
199
+ Invalid sort fields are rejected with a 400 response. Validated value is available at `c.req.valid("query").sort` as a `string[]`.
200
+
201
+ #### `.include(relations)`
202
+
203
+ Declares sideloadable related resources. Adds a `?include` query param with an enum of the allowed relation names.
204
+
205
+ ```ts
206
+ .include(["author", "comments", "tags"])
207
+ // → ?include enum: author, comments, tags
208
+ // → ?include=author,comments (comma-separated)
209
+ ```
210
+
211
+ Invalid relation names are rejected with a 400 response. Validated value is available at `c.req.valid("query").include` as a `string[]`.
212
+
213
+ #### `.fieldsets(resources)`
214
+
215
+ Declares sparse fieldset resources per the JSON:API spec. Adds `?fields[type]` query params for each resource.
216
+
217
+ ```ts
218
+ .fieldsets(["articles", "people"])
219
+ // → ?fields[articles]=title,body&fields[people]=name
220
+ // → c.req.valid("query")["fields[articles]"] → "title,body"
221
+ ```
222
+
223
+ Each resource generates a `?fields[type]` string parameter in the OpenAPI spec. Values are passed through as strings.
224
+
225
+ ### `getOpenAPISpec(app, info, scanner?, options?)`
226
+
227
+ Scans `app.routes` for handlers with OpenAPI metadata and builds the OpenAPI 3.1 document.
228
+
229
+ ```ts
230
+ getOpenAPISpec(
231
+ app: Hono,
232
+ info: InfoObject,
233
+ scanner?: RouteScanner,
234
+ options?: { basePath?: string; components?: Record<string, unknown> },
235
+ ): OpenAPIObject
236
+ ```
237
+
238
+ `scanner` is optional — defaults to `honoScanner`. The `basePath` option (default `"/api"`) strips a URL prefix before deriving auto-tags, so routes under `/api/pets` get tag `"pets"` instead of `"api"`. Pass `basePath: ""` to disable prefix stripping. The `components` option is forwarded directly into the spec — use it to define `securitySchemes`, `schemas`, etc.
239
+
240
+ ### `serveScalarUI(options)`
241
+
242
+ Returns a handler that serves the Scalar API reference page.
243
+
244
+ ```ts
245
+ serveScalarUI({
246
+ specUrl: string
247
+ title?: string // default: 'API Reference'
248
+ theme?: string // default: 'purple'
249
+ showSidebar?: boolean // default: true
250
+ cdnUrl?: string // default: 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'
251
+ })
252
+ ```
253
+
254
+ Uses the `<scalar-api-reference>` web component. The CDN URL is configurable for pinning versions or self-hosting.
255
+
256
+ ### `loadRoutes(app, dir, options?)`
257
+
258
+ Discovers and mounts route modules from a directory tree. Each subdirectory with an `index.ts` exporting a Hono instance (default export) is mounted as a sub-router at `${basePath}/${name}` where `basePath` defaults to `"/api"`.
259
+
260
+ Directories named `[param]` are converted to `:param` path segments for dynamic routing. Directories without `index.ts` (gaps) accumulate their path until a child directory with `index.ts` is found.
261
+
262
+ ```ts
263
+ import { loadRoutes } from "peta-hono";
264
+
265
+ await loadRoutes(app, "./routes"); // mounts at /api/pets, /api/species
266
+ await loadRoutes(app, "./routes", { basePath: "/v2" }); // mounts at /v2/pets, /v2/species
267
+ await loadRoutes(app, "./routes", { basePath: "" }); // mounts at /pets, /species
268
+ ```
269
+
270
+ Convention:
271
+
272
+ ```
273
+ routes/
274
+ pets/
275
+ index.ts → /api/pets
276
+ [id]/
277
+ index.ts → /api/pets/:id
278
+ comments/
279
+ index.ts → /api/pets/:id/comments
280
+ species/
281
+ index.ts → /api/species
282
+ admin/ ← no index.ts (gap)
283
+ [id]/
284
+ settings/
285
+ index.ts → /api/admin/:id/settings ← mounted on app, not sub-router
286
+ ```
287
+
288
+ Each level can have its own `index.ts` — nesting is recursive and mirrors the URL structure. `[param]` directories become `:param` path segments. Gap directories (no `index.ts`) accumulate their path; the next `index.ts` found deeper mounts on the original parent at the full accumulated path.
289
+
290
+ Also accepts factory functions. Errors in individual route files are logged — a bad route doesn't crash the app.
291
+
292
+ Since both `loadRoutes` and `getOpenAPISpec` default to `"/api"`, auto-tags are derived correctly without extra config in most cases. For a custom `basePath`, pass the same value to both.
293
+
294
+ ### `setOnValidationError(handler)`
295
+
296
+ > **Deprecated.** Use `.onValidationError()` on the route chain instead. Per-route handlers take precedence over the global handler.
297
+
298
+ Customize the response returned when request validation fails. Returns a restore function.
299
+
300
+ ```ts
301
+ const restore = setOnValidationError((issues, c) => {
302
+ return c.json({ error: "Invalid", details: issues }, 422);
303
+ });
304
+
305
+ // later: restore() // resets to default handler
306
+ ```
307
+
308
+ Default returns `{ error: 'Validation failed', issues }` with status 400.
309
+
310
+ Per-route override via the chain method:
311
+
312
+ ```ts
313
+ route()
314
+ .onValidationError((issues, c) => c.json({ error: "Invalid" }, 422))
315
+ .requestBody(type({ name: "string>0" }))
316
+ .handle((c) => c.json({ ok: true }, 201));
317
+ ```
318
+
319
+ The per-route handler takes precedence over the global `setOnValidationError()`.
320
+
321
+ ### `StatusCode`
322
+
323
+ Union of common HTTP status codes for use in response definitions. Provides autocomplete while accepting any string.
324
+
325
+ ```ts
326
+ import type { StatusCode } from "peta-hono";
327
+
328
+ type Code = StatusCode; // '200' | '201' | '400' | '404' | '500' | (string & {})
329
+ ```
330
+
331
+ Built into `RouteConfig.responses`.
332
+
333
+ ## How it works
334
+
335
+ 1. **`route()`** returns a `RouteBuilder`. Chain methods accumulate route metadata (summary, schemas, responses). Terminal `.handle()` attaches the config to the handler via a `Symbol` property. If schemas are present, it generates Hono validator middleware that runs before the handler and converts schemas via `toJsonSchema()` for OpenAPI docs.
336
+ 2. **`getOpenAPISpec()`** iterates `app.routes[]` (via `RouteScanner`), extracts the Symbol metadata, converts ArkType schemas to JSON Schema, and builds an OpenAPI 3.1 document.
337
+ 3. **`serveScalarUI()`** returns a handler that serves an HTML page loading the Scalar web component from CDN, pointed at the OpenAPI spec URL.
338
+
339
+ No Hono subclass, no monkey-patching — works with vanilla `new Hono()`.
340
+
341
+ ## Build
342
+
343
+ ```bash
344
+ bun run build # tsdown → dist/index.mjs + dist/index.d.mts
345
+ bun run test # 88 tests
346
+ ```
347
+
348
+ ## License
349
+
350
+ MIT
@@ -0,0 +1,102 @@
1
+ import { a as FilterFields, f as RouteConfig, g as TypedContext, l as Pagination, n as ArkTypeSchema, o as FilterOperator, r as FieldsetParams, t as RouteScanner } from "../scanner-CU4MsJ2G.mjs";
2
+ import { Context, Hono, MiddlewareHandler } from "hono";
3
+
4
+ //#region src/hono/loader.d.ts
5
+ /**
6
+ * Recursively walk a directory tree, mounting Hono sub-routers as they are
7
+ * discovered. Directories named `[param]` become `:param` path segments.
8
+ *
9
+ * When a directory has no `index.ts` the accumulated path builds up,
10
+ * and any router found deeper is mounted at the full accumulated prefix
11
+ * on the original parent router (the "gap" pattern).
12
+ *
13
+ * @internal
14
+ *
15
+ * // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ * `Hono<any, any, any>` is required because filesystem discovery can't
17
+ * know the type parameters of the Hono app at build time.
18
+ */
19
+ type AnyHono = Hono<any, any, any>;
20
+ /**
21
+ * Load routes from a directory tree. Each subdirectory with an `index.ts`
22
+ * exporting a Hono instance (default export) is mounted as a sub-router.
23
+ *
24
+ * Directories named `[param]` are converted to `:param` path segments for
25
+ * dynamic routing. Directories without `index.ts` accumulate their path
26
+ * until a child directory with `index.ts` is found (gap pattern).
27
+ *
28
+ * Convention:
29
+ * routes/
30
+ * pets/
31
+ * index.ts mounted at {basePath}/pets
32
+ * [id]/
33
+ * index.ts mounted at {basePath}/pets/:id
34
+ * comments/
35
+ * index.ts mounted at {basePath}/pets/:id/comments
36
+ * species/
37
+ * index.ts mounted at {basePath}/species
38
+ *
39
+ * @param app Hono application to mount routes on
40
+ * @param dir Path to the routes directory
41
+ * @param options.basePath URL prefix (default "/api")
42
+ */
43
+ declare function loadRoutes(app: AnyHono, dir: string, options?: {
44
+ basePath?: string;
45
+ }): Promise<void>;
46
+ //#endregion
47
+ //#region src/hono/route.d.ts
48
+ interface PaginationOptions {
49
+ maxLimit?: number;
50
+ defaultLimit?: number;
51
+ }
52
+ type ValidationErrorHandler = (issues: unknown[], c: Context) => Response | Promise<Response>;
53
+ /** @deprecated Use {@link RouteBuilder.onValidationError} on the route chain instead. Per-route handlers take precedence over the global handler. */
54
+ declare function setOnValidationError(handler: ValidationErrorHandler): () => void;
55
+ declare class RouteBuilder<B = undefined, Q = undefined, P = undefined, Hd = undefined, Pg extends Pagination | undefined = undefined, F = Record<string, unknown>, Sr = Record<string, unknown>, Ir = Record<string, unknown>, Fs = Record<string, unknown>> {
56
+ private _config;
57
+ private static readonly VALIDATOR_MAP;
58
+ summary(s: string): this;
59
+ description(s: string): this;
60
+ operationId(s: string): this;
61
+ tags(...t: string[]): this;
62
+ deprecated(d?: boolean): this;
63
+ requestBody<S extends ArkTypeSchema>(schema: S): RouteBuilder<S, Q, P, Hd, Pg, F, Sr, Ir, Fs>;
64
+ query<S extends ArkTypeSchema>(schema: S): RouteBuilder<B, S, P, Hd, Pg, F, Sr, Ir, Fs>;
65
+ params<S extends ArkTypeSchema>(schema: S): RouteBuilder<B, Q, S, Hd, Pg, F, Sr, Ir, Fs>;
66
+ headers<S extends ArkTypeSchema>(schema: S): RouteBuilder<B, Q, P, S, Pg, F, Sr, Ir, Fs>;
67
+ response(status: number | string, value: ArkTypeSchema | string | Record<string, unknown>): this;
68
+ auth(scheme?: string): this;
69
+ filter<N extends string, S extends ArkTypeSchema, O extends FilterOperator[] = ["eq"]>(name: N, schema: S, options?: {
70
+ operators?: O;
71
+ }): RouteBuilder<B, Q, P, Hd, Pg, F & FilterFields<N, S, O>, Sr, Ir, Fs>;
72
+ sort(fields: string[]): RouteBuilder<B, Q, P, Hd, Pg, F, {
73
+ sort?: string[];
74
+ }, Ir, Fs>;
75
+ include(relations: string[]): RouteBuilder<B, Q, P, Hd, Pg, F, Sr, {
76
+ include?: string[];
77
+ }, Fs>;
78
+ fieldsets<R extends string[]>(resources: R): RouteBuilder<B, Q, P, Hd, Pg, F, Sr, Ir, FieldsetParams<R>>;
79
+ onValidationError(handler: ValidationErrorHandler): this;
80
+ onResponseValidationError(handler: ValidationErrorHandler): this;
81
+ paginated(options?: PaginationOptions): RouteBuilder<B, Q, P, Hd, Pagination, F, Sr, Ir, Fs>;
82
+ handle(handler: (c: TypedContext<B, Q, P, Hd, Pg, F, Sr, Ir, Fs>) => Response | Promise<Response>): MiddlewareHandler;
83
+ private buildValidators;
84
+ private buildRouteConfig;
85
+ private composeHandler;
86
+ private validateResponse;
87
+ private attachRouteMeta;
88
+ }
89
+ declare function route<B = undefined, Q = undefined, P = undefined, Hd = undefined, Pg extends Pagination | undefined = undefined, F = Record<string, unknown>, Sr = Record<string, unknown>, Ir = Record<string, unknown>, Fs = Record<string, unknown>>(): RouteBuilder<B, Q, P, Hd, Pg, F, Sr, Ir, Fs>;
90
+ declare function getRouteMeta(handler: unknown): RouteConfig | undefined;
91
+ //#endregion
92
+ //#region src/hono/scanner.d.ts
93
+ /**
94
+ * Scans a Hono app instance and extracts route metadata.
95
+ *
96
+ * Relies on Hono's internal `app.routes` array. If that structure
97
+ * changes in a future Hono version, this scanner will warn and
98
+ * return an empty array.
99
+ */
100
+ declare const honoScanner: RouteScanner;
101
+ //#endregion
102
+ export { type PaginationOptions, RouteBuilder, type ValidationErrorHandler, getRouteMeta, honoScanner, loadRoutes, route, setOnValidationError };