Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/core-owns-route.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions .changeset/hono-server-only.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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',
Expand Down Expand Up @@ -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()`.
Expand Down
3 changes: 0 additions & 3 deletions examples/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hono/zod-openapi": "catalog:",
"@zodapi/core": "workspace:*",
"@zodapi/hono": "workspace:*",
"hono": "catalog:",
"zod": "catalog:"
}
}
12 changes: 6 additions & 6 deletions examples/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<typeof User>

export const NewUser = User.omit({ id: true }).openapi('NewUser')
export const NewUser = User.omit({ id: true }).meta({ id: 'NewUser' })
export type NewUser = z.infer<typeof NewUser>

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<typeof NotFound>

export const Conflict = z
Expand All @@ -29,7 +29,7 @@ export const Conflict = z
existingId: z.number(),
}),
})
.openapi('Conflict')
.meta({ id: 'Conflict' })
export type Conflict = z.infer<typeof Conflict>

export const listUsers = route({
Expand Down
4 changes: 3 additions & 1 deletion packages/client/test/contract.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/codegen/test/fixture/build-doc.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
3 changes: 2 additions & 1 deletion packages/codegen/test/fixture/contract.ts
Original file line number Diff line number Diff line change
@@ -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' })

Expand Down
102 changes: 92 additions & 10 deletions packages/hono/src/route.ts → packages/core/src/define-route.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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<string, unknown> | undefined
encoding?: Record<string, unknown> | undefined
itemSchema?: unknown
}

interface RequestBodyConfig {
description?: string | undefined
content: Record<string, MediaTypeConfig | undefined>
required?: boolean | undefined
}

interface ResponseConfig {
description?: string | undefined
summary?: string | undefined
headers?: z.ZodObject | Record<string, unknown> | undefined
links?: Record<string, unknown> | undefined
content?: Record<string, MediaTypeConfig | undefined> | 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<string | number, ResponseConfig>
/**
* 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<string, readonly string[]>[] | undefined
servers?: readonly Record<string, unknown>[] | undefined
externalDocs?: Record<string, unknown> | undefined
parameters?: readonly Record<string, unknown>[] | undefined
callbacks?: Record<string, unknown> | undefined
[extension: `x-${string}`]: unknown
}

type ConvertPathType<T extends string> = T extends `${infer Start}/{${infer Param}}${infer Rest}`
? `${Start}/:${Param}${ConvertPathType<Rest>}`
: T
Expand All @@ -30,8 +98,6 @@ type With400<Responses> = 400 extends keyof Responses
? Omit<Responses, '400'> & { '400': Merge400<Responses['400']> }
: Responses & { 400: ValidationErrorResponse }

export type ZodapiRouteConfig = RouteConfig & { alias?: string }

type PathParamNames<T extends string> = T extends `${string}{${infer P}}${infer Rest}`
? P | PathParamNames<Rest>
: never
Expand Down Expand Up @@ -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<string, unknown>): 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
Expand All @@ -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<const R extends ZodapiRouteConfig>(
config: ValidatedRouteConfig<R>,
Expand All @@ -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 })
}
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
3 changes: 2 additions & 1 deletion packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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' } }

Expand Down
Loading