From a8071640af9a32fd18ac843d9c421261a21013b5 Mon Sep 17 00:00:00 2001 From: Alan Christensen Date: Tue, 1 Sep 2026 07:48:59 +1200 Subject: [PATCH] refactor!: move route() into @zodapi/core so contracts carry no HTTP deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit route() came from @zodapi/hono, whose createRoute import pulled the @hono/zod-openapi barrel — hono, zod-to-openapi, openapi3-ts, yaml — into every package holding a contract. Bundlers that do not tree-shake within modules shipped all of it to clients that never call it, and sideEffects: false could not help: the barrel runs extendZodWithOpenApi(z) at import time. The eight lines of createRoute that route() actually used are inlined, keeping getRoutingPath non-enumerable so it stays out of spreads and the generated document. ZodapiRouteConfig is now expressed in zod types rather than re-exporting hono's RouteConfig. Request and response shapes are exact — they drive every inference; the OpenAPI documentation fields are typed loosely, and anything they let through app.openapi() still catches. method drops OAS 3.2's 'query' to match RouteDef['method'], which drives the client. BREAKING CHANGE: @zodapi/hono is now server-only, exporting createApp() plus OpenAPIHono and createRoute. Import route, validationErrorResponse, ZodapiRoute, ZodapiRouteConfig, queryArray, ValidationError, PROBLEM_JSON_CONTENT_TYPE and ZODAPI_VALIDATION_TYPE from @zodapi/core, and z from zod. The dropped z re-export also carried the .openapi() prototype method — use zod's .meta({ id: 'User' }), which produces the same component and $ref. The generated OpenAPI document is byte-identical before and after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P4oNyS84DzZeNFzLGcw7rQ --- .changeset/core-owns-route.md | 27 ++++ .changeset/hono-server-only.md | 25 ++++ README.md | 20 +-- examples/api/package.json | 3 - examples/api/src/index.ts | 12 +- packages/client/test/contract.ts | 4 +- packages/codegen/test/fixture/build-doc.ts | 2 +- packages/codegen/test/fixture/contract.ts | 3 +- .../src/route.ts => core/src/define-route.ts} | 102 +++++++++++++-- packages/core/src/index.ts | 6 + packages/core/src/query.ts | 3 +- packages/core/src/route.ts | 5 +- packages/{hono => core}/test/route.test-d.ts | 3 +- packages/{hono => core}/test/route.test.ts | 117 ++---------------- packages/hono/src/index.ts | 14 +-- packages/hono/test/create-app.test.ts | 109 ++++++++++++++++ pnpm-lock.yaml | 9 -- pnpm-workspace.yaml | 2 +- 18 files changed, 305 insertions(+), 161 deletions(-) create mode 100644 .changeset/core-owns-route.md create mode 100644 .changeset/hono-server-only.md rename packages/{hono/src/route.ts => core/src/define-route.ts} (70%) rename packages/{hono => core}/test/route.test-d.ts (98%) rename packages/{hono => core}/test/route.test.ts (54%) create mode 100644 packages/hono/test/create-app.test.ts diff --git a/.changeset/core-owns-route.md b/.changeset/core-owns-route.md new file mode 100644 index 0000000..f076333 --- /dev/null +++ b/.changeset/core-owns-route.md @@ -0,0 +1,27 @@ +--- +'@zodapi/core': minor +--- + +`route()` now lives here, so a contract depends on `zod` and `@zodapi/core` only. + +It previously came from `@zodapi/hono`, whose `createRoute` import pulled the `@hono/zod-openapi` +barrel — `hono`, `@asteasolutions/zod-to-openapi`, `openapi3-ts` and `yaml` — into every package +holding a contract. Bundlers that do not tree-shake within modules shipped all of it to clients +that never call it, and `sideEffects: false` could not help: the barrel runs +`extendZodWithOpenApi(z)` at import time. The eight lines of `createRoute` that `route()` actually +used are inlined instead. + +Behaviour is unchanged — the injected `400`, the `body.required` default, the path-param and +wire-string checks, and `alias` all work as before, and the emitted OpenAPI document is +byte-identical. The result still carries a non-enumerable `getRoutingPath()`, so it drops straight +into `app.openapi(...)`. + +Name OpenAPI components with zod's own `.meta({ id: 'User' })`. `.openapi('User')` is a prototype +method patched on by importing `@hono/zod-openapi`, so a contract using it stays coupled to hono; +both produce the same `$ref`. + +`ZodapiRouteConfig` is now defined in terms of zod types rather than re-exporting hono's +`RouteConfig`. The request and response shapes are exact; the OpenAPI documentation fields are +typed loosely, and anything they let through is still caught by `app.openapi(...)`. One deliberate +narrowing: `method` does not accept OAS 3.2's `'query'`, matching `RouteDef['method']`, which +drives the client. diff --git a/.changeset/hono-server-only.md b/.changeset/hono-server-only.md new file mode 100644 index 0000000..af39532 --- /dev/null +++ b/.changeset/hono-server-only.md @@ -0,0 +1,25 @@ +--- +'@zodapi/hono': minor +--- + +`@zodapi/hono` is now server-only: it exports `createApp()` (plus `OpenAPIHono` and `createRoute` +from `@hono/zod-openapi`) and nothing else. Contracts no longer import it, so they no longer pull +hono into client bundles. + +Everything else it re-exported moved to, or was already in, `@zodapi/core` — import from there +instead: `route`, `validationErrorResponse`, `ZodapiRoute`, `ZodapiRouteConfig`, `queryArray`, +`ValidationError`, `PROBLEM_JSON_CONTENT_TYPE`, `ZODAPI_VALIDATION_TYPE`. + +The `z` re-export is gone; use `import { z } from 'zod'`. It was `@hono/zod-openapi`'s re-export of +the same zod instance, and importing it was enough to drag the whole hono chain into a contract. +It also carried the `.openapi()` prototype method — replace `.openapi('User')` with zod's +`.meta({ id: 'User' })`, which produces the same component and `$ref`. + +```diff +- import { queryArray, route, z } from '@zodapi/hono' ++ import { queryArray, route } from '@zodapi/core' ++ import { z } from 'zod' +``` + +The `hono` peer range is now `^4.10.0` rather than `>=4.10.0`: `@zodapi/core` models hono's +`RouteConfig` structurally, and an unbounded range let a future major widen it with no signal. diff --git a/README.md b/README.md index f4260fb..04a55af 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ fetch/axios client with optional runtime validation and zodios-style error guard ## Packages -| Package | What it is | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@zodapi/core` | Contract types, the fixed `ValidationError` 400 problem-details shape, `ApiError` + typed error guards (`isErrorFromRoute`, `matchErrorByStatus`, `isAxiosErrorFromRoute`, ...), pluggable error decoders (`problemDetails`, `decodersFor`). No HTTP deps. | -| `@zodapi/hono` | Thin preset over `@hono/zod-openapi`: `createApp()` (fixed 400 shape via `defaultHook`, `a[]=` query normalization) and `route()` (`createRoute` + injected 400 + `body.required` default + `alias`). | -| `@zodapi/client` | `createClient(routes, ...)`: path- or alias-addressed typed calls over fetch (default) or axios (`@zodapi/client/axios`), with `validate: 'none' \| 'request' \| 'response' \| 'both'`. | -| `@zodapi/codegen` | `zodapi-codegen openapi.json -o contract.ts`: generates a zodapi contract (zod schemas + route objects) from an OpenAPI 3.1 document, for backends not written in TypeScript. | +| Package | What it is | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@zodapi/core` | `route()` (injected 400 + `body.required` default + path/wire-string checks + `alias`), contract types, the fixed `ValidationError` 400 problem-details shape, `ApiError` + typed error guards (`isErrorFromRoute`, `matchErrorByStatus`, `isAxiosErrorFromRoute`, ...), pluggable error decoders (`problemDetails`, `decodersFor`). No HTTP deps — a contract needs only this and `zod`. | +| `@zodapi/hono` | Thin preset over `@hono/zod-openapi`: `createApp()` — an `OpenAPIHono` with the fixed 400 shape via `defaultHook` and `a[]=` query normalization. Server-side only; contracts do not import it. | +| `@zodapi/client` | `createClient(routes, ...)`: path- or alias-addressed typed calls over fetch (default) or axios (`@zodapi/client/axios`), with `validate: 'none' \| 'request' \| 'response' \| 'both'`. | +| `@zodapi/codegen` | `zodapi-codegen openapi.json -o contract.ts`: generates a zodapi contract (zod schemas + route objects) from an OpenAPI 3.1 document, for backends not written in TypeScript. | `examples/api` is a shared contract, `examples/app` a runnable server + client demo. @@ -21,7 +21,8 @@ fetch/axios client with optional runtime validation and zodios-style error guard Contract (shared): ```ts -import { route, z } from '@zodapi/hono' +import { route } from '@zodapi/core' +import { z } from 'zod' export const getUser = route({ alias: 'getUser', @@ -126,6 +127,11 @@ OpenAPI 3.0 documents (3.1 only). ## Conventions - **OpenAPI 3.1 only** (`app.doc31`). +- **Contracts are HTTP-dependency-free.** `route()` lives in `@zodapi/core`, so a package + holding the contract depends on `zod` and `@zodapi/core` only — no `hono`, no + `@hono/zod-openapi`, and nothing of theirs in a client bundle. Name OpenAPI components + with zod's own `.meta({ id: 'User' })`; `.openapi('User')` is a prototype method patched + on by importing `@hono/zod-openapi`, which re-couples the contract to hono. - **Query arrays** use `a[]=1&a[]=2`. Declare them with `queryArray(item)`; `createApp()` strips the `[]` suffix at the edge (its `fetch`), so plain repeated keys work too. The normalization does not apply when the app is mounted under another Hono app via `.route()`. diff --git a/examples/api/package.json b/examples/api/package.json index a73d567..bcd753a 100644 --- a/examples/api/package.json +++ b/examples/api/package.json @@ -18,10 +18,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@hono/zod-openapi": "catalog:", "@zodapi/core": "workspace:*", - "@zodapi/hono": "workspace:*", - "hono": "catalog:", "zod": "catalog:" } } diff --git a/examples/api/src/index.ts b/examples/api/src/index.ts index 5d6de32..75ec73c 100644 --- a/examples/api/src/index.ts +++ b/examples/api/src/index.ts @@ -1,5 +1,5 @@ -import { z } from '@hono/zod-openapi' -import { queryArray, route } from '@zodapi/hono' +import { queryArray, route } from '@zodapi/core' +import { z } from 'zod' export const User = z .object({ @@ -8,17 +8,17 @@ export const User = z email: z.email(), tags: z.array(z.string()), }) - .openapi('User') + .meta({ id: 'User' }) export type User = z.infer -export const NewUser = User.omit({ id: true }).openapi('NewUser') +export const NewUser = User.omit({ id: true }).meta({ id: 'NewUser' }) export type NewUser = z.infer export const NotFound = z .object({ error: z.object({ code: z.literal('NOT_FOUND'), message: z.string() }), }) - .openapi('NotFound') + .meta({ id: 'NotFound' }) export type NotFound = z.infer export const Conflict = z @@ -29,7 +29,7 @@ export const Conflict = z existingId: z.number(), }), }) - .openapi('Conflict') + .meta({ id: 'Conflict' }) export type Conflict = z.infer export const listUsers = route({ diff --git a/packages/client/test/contract.ts b/packages/client/test/contract.ts index 7232c1f..bb08785 100644 --- a/packages/client/test/contract.ts +++ b/packages/client/test/contract.ts @@ -1,5 +1,7 @@ import type { RouteDef } from '@zodapi/core' -import { createApp, queryArray, route, z } from '@zodapi/hono' +import { queryArray, route } from '@zodapi/core' +import { createApp } from '@zodapi/hono' +import { z } from 'zod' export const Thing = z.object({ id: z.string(), name: z.string() }) export const NotFound = z.object({ diff --git a/packages/codegen/test/fixture/build-doc.ts b/packages/codegen/test/fixture/build-doc.ts index 3941225..9b7332a 100644 --- a/packages/codegen/test/fixture/build-doc.ts +++ b/packages/codegen/test/fixture/build-doc.ts @@ -1,5 +1,5 @@ // Mounts routes (hand-written or generated) and emits the OpenAPI 3.1 doc. -// createRoute() only adds getRoutingPath — unlike @zodapi/hono's route() it +// createRoute() only adds getRoutingPath — unlike route() from @zodapi/core it // injects nothing, so declared responses pass through verbatim. import { createRoute } from '@hono/zod-openapi' import type { RouteDef } from '@zodapi/core' diff --git a/packages/codegen/test/fixture/contract.ts b/packages/codegen/test/fixture/contract.ts index e0f7565..e4c3511 100644 --- a/packages/codegen/test/fixture/contract.ts +++ b/packages/codegen/test/fixture/contract.ts @@ -1,6 +1,7 @@ // The hand-written comprehensive contract the round-trip test starts from. // Every converter feature should appear here at least once. -import { queryArray, route, z } from '@zodapi/hono' +import { queryArray, route } from '@zodapi/core' +import { z } from 'zod' export const Role = z.enum(['admin', 'member', 'guest']).meta({ id: 'Role' }) diff --git a/packages/hono/src/route.ts b/packages/core/src/define-route.ts similarity index 70% rename from packages/hono/src/route.ts rename to packages/core/src/define-route.ts index d00bea8..4d6044c 100644 --- a/packages/hono/src/route.ts +++ b/packages/core/src/define-route.ts @@ -1,7 +1,8 @@ -import { createRoute, type RouteConfig } from '@hono/zod-openapi' -import { PROBLEM_JSON_CONTENT_TYPE, ValidationError } from '@zodapi/core' import type { z } from 'zod' +import type { Method } from './route.js' +import { PROBLEM_JSON_CONTENT_TYPE, ValidationError } from './validation-error.js' + /** The 400 response definition `route()` adds to every route. */ export const validationErrorResponse = { description: 'Request validation failed', @@ -10,6 +11,73 @@ export const validationErrorResponse = { type ValidationErrorResponse = typeof validationErrorResponse +/** A parameter group (path/query/cookie): a zod object, or one behind a transform. */ +type RouteParameter = z.ZodObject | z.ZodPipe + +interface MediaTypeConfig { + schema?: unknown + example?: unknown + examples?: Record | undefined + encoding?: Record | undefined + itemSchema?: unknown +} + +interface RequestBodyConfig { + description?: string | undefined + content: Record + required?: boolean | undefined +} + +interface ResponseConfig { + description?: string | undefined + summary?: string | undefined + headers?: z.ZodObject | Record | undefined + links?: Record | undefined + content?: Record | undefined +} + +/** + * The config `route()` accepts. Mirrors `RouteConfig` from + * `@hono/zod-openapi`/`@asteasolutions/zod-to-openapi` closely enough that the + * result drops straight into `app.openapi(...)`, but is expressed in zod types + * alone so contracts need no HTTP dependency. The request/response shapes are + * exact (they drive every inference); the OpenAPI documentation fields are + * typed loosely, and anything they reject is still caught by `app.openapi()`. + */ +export interface ZodapiRouteConfig { + method: Method + path: string + /** zodios-style client method name. Stripped from the OpenAPI document. */ + alias?: string | undefined + request?: + | { + body?: RequestBodyConfig | undefined + params?: RouteParameter | undefined + query?: RouteParameter | undefined + cookies?: RouteParameter | undefined + headers?: RouteParameter | z.ZodType[] | undefined + } + | undefined + responses: Record + /** + * Hono middleware. Typed as `unknown` to keep hono out of core — the `const` + * type parameter on `route()` captures the real type, so hono still infers + * the handler's `Env` from it. + */ + middleware?: unknown + summary?: string | undefined + description?: string | undefined + operationId?: string | undefined + tags?: readonly string[] | undefined + deprecated?: boolean | undefined + security?: readonly Record[] | undefined + servers?: readonly Record[] | undefined + externalDocs?: Record | undefined + parameters?: readonly Record[] | undefined + callbacks?: Record | undefined + [extension: `x-${string}`]: unknown +} + type ConvertPathType = T extends `${infer Start}/{${infer Param}}${infer Rest}` ? `${Start}/:${Param}${ConvertPathType}` : T @@ -30,8 +98,6 @@ type With400 = 400 extends keyof Responses ? Omit & { '400': Merge400 } : Responses & { 400: ValidationErrorResponse } -export type ZodapiRouteConfig = RouteConfig & { alias?: string } - type PathParamNames = T extends `${string}{${infer P}}${infer Rest}` ? P | PathParamNames : never @@ -163,7 +229,22 @@ function assertParamsMatchPath(config: ZodapiRouteConfig): void { } /** - * `createRoute` from `@hono/zod-openapi` plus zodapi conventions: + * Inlined `createRoute` from `@hono/zod-openapi`, so contracts carry no HTTP + * dependency. `getRoutingPath` must stay non-enumerable: an enumerable one + * leaks the function into `{...route}` spreads and the generated document. + */ +function withRoutingPath(config: { path: string } & Record): unknown { + const built = { + ...config, + getRoutingPath() { + return config.path.replaceAll(/\/{(.+?)}/g, '/:$1') + }, + } + return Object.defineProperty(built, 'getRoutingPath', { enumerable: false }) +} + +/** + * A route definition carrying the zodapi conventions: * - merges a `400` `ValidationError` response into `responses`, so docs and * client error types include it; a route declaring its own 400 gets the * problem+json content merged into it instead (kept verbatim when it already @@ -180,8 +261,9 @@ function assertParamsMatchPath(config: ZodapiRouteConfig): void { * input is indistinguishable from `z.number()`) * - carries an optional `alias` for zodios-style client method names * - * The result is a plain route object: pass it to `app.openapi(...)` on the - * server and into `createClient([...])` on the client. + * The result is a plain object with a `getRoutingPath()`: pass it to + * `app.openapi(...)` on the server and into `createClient([...])` on the + * client. Only `zod` is needed to define one. */ export function route( config: ValidatedRouteConfig, @@ -203,10 +285,10 @@ export function route(config: ZodapiRouteConfig): unknown { if (request?.body && request.body.required === undefined) { request = { ...request, body: { ...request.body, required: true } } } - const built = createRoute({ + const built = withRoutingPath({ ...rest, ...(request ? { request } : {}), - responses: responses as RouteConfig['responses'], + responses: responses as ZodapiRouteConfig['responses'], }) - return alias === undefined ? built : Object.assign(built, { alias }) + return alias === undefined ? built : Object.assign(built as object, { alias }) } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12db64e..5f60dbd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,5 +53,11 @@ export { problemDetails, zodapiValidationDecoder, } from './decoders.js' +export { + type ZodapiRoute, + type ZodapiRouteConfig, + route, + validationErrorResponse, +} from './define-route.js' export { queryArray } from './query.js' export { schemaContainsCodec } from './codec.js' diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 9ce61e9..b76354d 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -6,7 +6,8 @@ import { z } from 'zod' * Hono's validator hands single-occurrence keys to the schema as a bare string, * so this wraps a lone value into a one-element array before `z.array(item)`. * The client serialises array values with a `[]` key suffix; `createApp()` from - * `@zodapi/hono` strips the suffix at the edge so both `a[]=x` and repeated + * `createApp()` from `@zodapi/hono` strips the suffix at the edge so both `a[]=x` and + * repeated * `a=x` forms validate. * * Item values are raw strings on the wire, so the item schema must accept a diff --git a/packages/core/src/route.ts b/packages/core/src/route.ts index 9421b6d..9e3ee88 100644 --- a/packages/core/src/route.ts +++ b/packages/core/src/route.ts @@ -3,9 +3,8 @@ import type { z } from 'zod' export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace' /** - * Structural shape of a route definition. `createRoute()` from `@hono/zod-openapi` - * (and the `route()` helper from `@zodapi/hono`) produce objects satisfying this, - * so contracts can be shared with `@zodapi/client` without importing hono. + * Structural shape of a route definition. The `route()` helper produces objects + * satisfying this, as does `createRoute()` from `@hono/zod-openapi`. */ export interface RouteRequestDef { params?: z.ZodType | undefined diff --git a/packages/hono/test/route.test-d.ts b/packages/core/test/route.test-d.ts similarity index 98% rename from packages/hono/test/route.test-d.ts rename to packages/core/test/route.test-d.ts index 0b662b2..3693c75 100644 --- a/packages/hono/test/route.test-d.ts +++ b/packages/core/test/route.test-d.ts @@ -1,5 +1,6 @@ -import { route, z } from '@zodapi/hono' +import { route } from '@zodapi/core' import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' const ok = { 200: { description: 'ok' } } diff --git a/packages/hono/test/route.test.ts b/packages/core/test/route.test.ts similarity index 54% rename from packages/hono/test/route.test.ts rename to packages/core/test/route.test.ts index de38162..47c049a 100644 --- a/packages/hono/test/route.test.ts +++ b/packages/core/test/route.test.ts @@ -1,5 +1,6 @@ -import { createApp, route, validationErrorResponse, z } from '@zodapi/hono' +import { route, validationErrorResponse } from '@zodapi/core' import { describe, expect, it } from 'vitest' +import { z } from 'zod' const Item = z.object({ id: z.string() }) @@ -123,6 +124,17 @@ describe('route()', () => { expect(() => route(bad as never)).toThrow('missing from params schema: id') }) + it('keeps getRoutingPath non-enumerable, so it stays out of spreads and the document', () => { + const r = route({ + method: 'get', + path: '/items/{id}', + request: { params: z.object({ id: z.string() }) }, + responses: { 200: { description: 'ok' } }, + }) + expect(Object.keys(r)).not.toContain('getRoutingPath') + expect({ ...r }).not.toHaveProperty('getRoutingPath') + }) + it('carries the alias through', () => { const r = route({ alias: 'listItems', @@ -133,106 +145,3 @@ describe('route()', () => { expect(r.alias).toBe('listItems') }) }) - -describe('createApp()', () => { - it('responds 400 with problem+json and documents ValidationError as a component', async () => { - const app = createApp() - .openapi( - route({ - method: 'get', - path: '/items/{id}', - request: { params: z.object({ id: z.string().min(2) }) }, - responses: { - 200: { description: 'ok', content: { 'application/json': { schema: Item } } }, - }, - }), - (c) => c.json({ id: c.req.valid('param').id }, 200), - ) - .doc31('/openapi.json', { openapi: '3.1.0', info: { title: 't', version: '1' } }) - - const bad = await app.request('/items/x') - expect(bad.status).toBe(400) - expect(bad.headers.get('content-type')).toContain('application/problem+json') - const body = await bad.json() - expect(body.type).toBe('urn:zodapi:validation') - expect(body.status).toBe(400) - expect(body.target).toBe('param') - expect(body.issues[0]?.path).toEqual(['id']) - - const doc = await (await app.request('/openapi.json')).json() - expect(doc.components.schemas.ValidationError).toBeDefined() - expect( - doc.paths['/items/{id}'].get.responses['400'].content['application/problem+json'].schema, - ).toEqual({ $ref: '#/components/schemas/ValidationError' }) - }) - - it('documents a discriminated union as oneOf with a discriminator object', async () => { - const Circle = z.object({ shape: z.literal('circle'), radius: z.number() }).meta({ - id: 'Circle', - }) - const Square = z.object({ shape: z.literal('square'), side: z.number() }).meta({ - id: 'Square', - }) - const Shape = z.discriminatedUnion('shape', [Circle, Square]).meta({ id: 'Shape' }) - const app = createApp() - .openapi( - route({ - method: 'get', - path: '/shape', - responses: { - 200: { description: 'ok', content: { 'application/json': { schema: Shape } } }, - }, - }), - (c) => c.json({ shape: 'circle' as const, radius: 1 }, 200), - ) - .doc31('/openapi.json', { openapi: '3.1.0', info: { title: 't', version: '1' } }) - - const doc = await (await app.request('/openapi.json')).json() - expect(doc.components.schemas.Shape).toEqual({ - oneOf: [{ $ref: '#/components/schemas/Circle' }, { $ref: '#/components/schemas/Square' }], - discriminator: { - propertyName: 'shape', - mapping: { - circle: '#/components/schemas/Circle', - square: '#/components/schemas/Square', - }, - }, - }) - }) - - it('parses the wire-string idioms: coerced number and stringbool', async () => { - const app = createApp().openapi( - route({ - method: 'get', - path: '/w', - request: { - query: z.object({ n: z.coerce.number(), b: z.stringbool() }), - }, - responses: { 200: { description: 'ok' } }, - }), - (c) => c.json(c.req.valid('query'), 200), - ) - const res = await app.request('/w?n=5&b=false') - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ n: 5, b: false }) - }) - - it('lets a user-supplied defaultHook win', async () => { - const app = createApp({ - defaultHook: (result, c) => { - if (!result.success) return c.json({ custom: true }, 400) - }, - }).openapi( - route({ - method: 'get', - path: '/x', - request: { query: z.object({ n: z.coerce.number() }) }, - responses: { 200: { description: 'ok' } }, - }), - (c) => c.json({}, 200), - ) - const res = await app.request('/x?n=abc') - expect(res.status).toBe(400) - expect(await res.json()).toEqual({ custom: true }) - }) -}) diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts index 97e45a5..4986c66 100644 --- a/packages/hono/src/index.ts +++ b/packages/hono/src/index.ts @@ -1,14 +1,2 @@ export { createApp, type CreateAppInit } from './create-app.js' -export { - route, - validationErrorResponse, - type ZodapiRoute, - type ZodapiRouteConfig, -} from './route.js' -export { - PROBLEM_JSON_CONTENT_TYPE, - ValidationError, - ZODAPI_VALIDATION_TYPE, - queryArray, -} from '@zodapi/core' -export { OpenAPIHono, createRoute, z } from '@hono/zod-openapi' +export { OpenAPIHono, createRoute } from '@hono/zod-openapi' diff --git a/packages/hono/test/create-app.test.ts b/packages/hono/test/create-app.test.ts new file mode 100644 index 0000000..c4ee133 --- /dev/null +++ b/packages/hono/test/create-app.test.ts @@ -0,0 +1,109 @@ +import { route } from '@zodapi/core' +import { createApp } from '@zodapi/hono' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' + +const Item = z.object({ id: z.string() }) + +describe('createApp()', () => { + it('responds 400 with problem+json and documents ValidationError as a component', async () => { + const app = createApp() + .openapi( + route({ + method: 'get', + path: '/items/{id}', + request: { params: z.object({ id: z.string().min(2) }) }, + responses: { + 200: { description: 'ok', content: { 'application/json': { schema: Item } } }, + }, + }), + (c) => c.json({ id: c.req.valid('param').id }, 200), + ) + .doc31('/openapi.json', { openapi: '3.1.0', info: { title: 't', version: '1' } }) + + const bad = await app.request('/items/x') + expect(bad.status).toBe(400) + expect(bad.headers.get('content-type')).toContain('application/problem+json') + const body = await bad.json() + expect(body.type).toBe('urn:zodapi:validation') + expect(body.status).toBe(400) + expect(body.target).toBe('param') + expect(body.issues[0]?.path).toEqual(['id']) + + const doc = await (await app.request('/openapi.json')).json() + expect(doc.components.schemas.ValidationError).toBeDefined() + expect( + doc.paths['/items/{id}'].get.responses['400'].content['application/problem+json'].schema, + ).toEqual({ $ref: '#/components/schemas/ValidationError' }) + }) + + it('documents a discriminated union as oneOf with a discriminator object', async () => { + const Circle = z.object({ shape: z.literal('circle'), radius: z.number() }).meta({ + id: 'Circle', + }) + const Square = z.object({ shape: z.literal('square'), side: z.number() }).meta({ + id: 'Square', + }) + const Shape = z.discriminatedUnion('shape', [Circle, Square]).meta({ id: 'Shape' }) + const app = createApp() + .openapi( + route({ + method: 'get', + path: '/shape', + responses: { + 200: { description: 'ok', content: { 'application/json': { schema: Shape } } }, + }, + }), + (c) => c.json({ shape: 'circle' as const, radius: 1 }, 200), + ) + .doc31('/openapi.json', { openapi: '3.1.0', info: { title: 't', version: '1' } }) + + const doc = await (await app.request('/openapi.json')).json() + expect(doc.components.schemas.Shape).toEqual({ + oneOf: [{ $ref: '#/components/schemas/Circle' }, { $ref: '#/components/schemas/Square' }], + discriminator: { + propertyName: 'shape', + mapping: { + circle: '#/components/schemas/Circle', + square: '#/components/schemas/Square', + }, + }, + }) + }) + + it('parses the wire-string idioms: coerced number and stringbool', async () => { + const app = createApp().openapi( + route({ + method: 'get', + path: '/w', + request: { + query: z.object({ n: z.coerce.number(), b: z.stringbool() }), + }, + responses: { 200: { description: 'ok' } }, + }), + (c) => c.json(c.req.valid('query'), 200), + ) + const res = await app.request('/w?n=5&b=false') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ n: 5, b: false }) + }) + + it('lets a user-supplied defaultHook win', async () => { + const app = createApp({ + defaultHook: (result, c) => { + if (!result.success) return c.json({ custom: true }, 400) + }, + }).openapi( + route({ + method: 'get', + path: '/x', + request: { query: z.object({ n: z.coerce.number() }) }, + responses: { 200: { description: 'ok' } }, + }), + (c) => c.json({}, 200), + ) + const res = await app.request('/x?n=abc') + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ custom: true }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e010449..9f6195d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,18 +56,9 @@ importers: examples/api: dependencies: - '@hono/zod-openapi': - specifier: 'catalog:' - version: 1.6.1(hono@4.13.5)(zod@4.4.3) '@zodapi/core': specifier: workspace:* version: link:../../packages/core - '@zodapi/hono': - specifier: workspace:* - version: link:../../packages/hono - hono: - specifier: 'catalog:' - version: 4.13.5 zod: specifier: 'catalog:' version: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 71874d4..94bac31 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,5 +22,5 @@ catalogs: peers: '@hono/zod-openapi': ^1.6.0 axios: ^1.0.0 - hono: '>=4.10.0' + hono: ^4.10.0 zod: ^4.0.0