diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eea639..edbe723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,5 +96,7 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium working-directory: packages/petstore-hono + # Generated code is not committed. test:e2e runs `pnpm generate` first, which + # rebuilds generated/ from spec/api.json using the workspace generators built above. - name: Run E2E tests run: pnpm --filter @codewithagents/petstore-hono run test:e2e diff --git a/packages/petstore-express/.gitignore b/packages/petstore-express/.gitignore new file mode 100644 index 0000000..b2bad0d --- /dev/null +++ b/packages/petstore-express/.gitignore @@ -0,0 +1,6 @@ +# Generated output — regenerated on demand, never committed. +# Run `pnpm generate` (or `pnpm test`) to recreate. +generated/ + +# Test artifacts +test-results/ diff --git a/packages/petstore-express/generated/client-config.ts b/packages/petstore-express/generated/client-config.ts deleted file mode 100644 index 34704fa..0000000 --- a/packages/petstore-express/generated/client-config.ts +++ /dev/null @@ -1,74 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -export interface ClientConfig { - /** Base URL for all API requests (e.g. 'https://api.example.com') */ - baseUrl: string - /** - * Bearer token or a function that returns one (supports async refresh). - * When provided, adds `Authorization: Bearer ` to every request. - */ - token?: string | (() => string | Promise) - /** - * Fetch credentials mode. Use 'include' for cookie-based auth. - * Defaults to 'same-origin'. - */ - credentials?: RequestCredentials - /** Additional headers sent with every request */ - headers?: Record - /** - * Global error hook called with every non-2xx response error before it is - * thrown. Use for logging, monitoring, or triggering auth refresh flows. - * - * @example - * configureClient({ - * onError: (err) => Sentry.captureException(err), - * }) - */ - onError?: (err: Error) => void - /** - * Default AbortSignal for every request. Useful for cancelling in-flight - * requests (e.g. on route change). Overridable per call via the config param. - */ - signal?: AbortSignal - /** - * Default timeout in milliseconds. When set, every request is aborted with a - * TimeoutError after the specified duration. Overridable per call. - */ - timeout?: number - /** - * Request interceptor called before every fetch. Receives the resolved URL and - * RequestInit; may return an object with optional url/init overrides. - * - * @example - * configureClient({ - * onRequest: ({ url, init }) => ({ init: { ...init, headers: { ...init.headers, 'X-Request-Id': crypto.randomUUID() } } }), - * }) - */ - onRequest?: (req: { - url: string - init: RequestInit - }) => - | void - | { url?: string; init?: RequestInit } - | Promise - /** - * Custom fetch implementation. Defaults to globalThis.fetch, resolved at - * call time so test mocks of globalThis.fetch are always honoured. - */ - fetch?: typeof globalThis.fetch -} - -let _config: ClientConfig = { - baseUrl: '', - credentials: 'same-origin', -} - -/** Configure the API client. Call once at app startup before making any requests. */ -export function configureClient(config: ClientConfig): void { - _config = { ..._config, ...config } -} - -/** @internal — used by generated fetch functions */ -export function getConfig(): Readonly { - return _config -} diff --git a/packages/petstore-express/generated/client.ts b/packages/petstore-express/generated/client.ts deleted file mode 100644 index 8647866..0000000 --- a/packages/petstore-express/generated/client.ts +++ /dev/null @@ -1,113 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -import type { CreatePetRequest, Pet } from './models.js' -import { getConfig, type ClientConfig } from './client-config.js' - -export class ApiError extends Error { - constructor( - public readonly status: Status, - public readonly body: Body - ) { - super(`API error ${status}`) - this.name = 'ApiError' - } -} - -type _FetchResponse = Awaited> - -function _buildSignal( - signal: AbortSignal | undefined, - timeout: number | undefined -): AbortSignal | undefined { - if (timeout === undefined) return signal - const _ts = AbortSignal.timeout(timeout) - if (signal === undefined) return _ts - if (typeof AbortSignal.any === 'function') return AbortSignal.any([signal, _ts]) - const _ctrl = new AbortController() - const _abort = (s: AbortSignal) => () => { - if (!_ctrl.signal.aborted) _ctrl.abort(s.reason) - } - signal.addEventListener('abort', _abort(signal), { once: true }) - _ts.addEventListener('abort', _abort(_ts), { once: true }) - return _ctrl.signal -} - -async function _request( - method: string, - path: string, - opts: { - searchParams?: URLSearchParams - body?: unknown - signal?: AbortSignal - }, - config?: Partial -): Promise<_FetchResponse> { - const { - baseUrl, - headers, - onError, - signal: _cfgSignal, - timeout, - onRequest, - fetch: _configFetch, - } = { ...getConfig(), ...config } - const base = baseUrl ? baseUrl.replace(/\/$/, '') : '' - const qs = opts.searchParams?.toString() ?? '' - const url = qs ? `${base}${path}?${qs}` : `${base}${path}` - const fetch = _configFetch ?? globalThis.fetch - let _url = url - let _init: RequestInit = { - method, - headers: { - ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }, - ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), - } - if (onRequest) { - const _or = await onRequest({ url: _url, init: _init }) - if (_or) { - if (_or.url !== undefined) _url = _or.url - if (_or.init !== undefined) _init = { ..._init, ..._or.init } - } - } - const _rawSignal = opts.signal ?? _cfgSignal - const _resolvedSignal = _buildSignal(_rawSignal, timeout) - if (_resolvedSignal !== undefined) _init = { ..._init, signal: _resolvedSignal } - const res = await fetch(_url, _init) - if (!res.ok) { - const err = new ApiError(res.status, await res.json().catch(() => null)) - onError?.(err) - throw err - } - return res -} - -export async function listPets( - params?: { - species?: string - }, - config?: Partial -): Promise { - const searchParams = new URLSearchParams() - if (params?.species != null) searchParams.set('species', String(params.species)) - const res = await _request('GET', '/pets', { searchParams }, config) - return res.json() -} - -export async function createPet( - body: CreatePetRequest, - config?: Partial -): Promise { - const res = await _request('POST', '/pets', { body }, config) - return res.json() -} - -export async function getPet(id: string, config?: Partial): Promise { - const res = await _request('GET', `/pets/${encodeURIComponent(id)}`, {}, config) - return res.json() -} - -export async function deletePet(id: string, config?: Partial): Promise { - await _request('DELETE', `/pets/${encodeURIComponent(id)}`, {}, config) -} diff --git a/packages/petstore-express/generated/index.ts b/packages/petstore-express/generated/index.ts deleted file mode 100644 index 41689a6..0000000 --- a/packages/petstore-express/generated/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -export * from './models.js' -export * from './client-config.js' -export * from './client.js' diff --git a/packages/petstore-express/generated/models.ts b/packages/petstore-express/generated/models.ts deleted file mode 100644 index 953707b..0000000 --- a/packages/petstore-express/generated/models.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file is auto-generated by openapi-zod-ts - do not edit - -export interface Pet { - id: string - name: string - species: string -} - -export interface CreatePetRequest { - name: string - species: string -} diff --git a/packages/petstore-express/generated/router.ts b/packages/petstore-express/generated/router.ts deleted file mode 100644 index 0ff60b2..0000000 --- a/packages/petstore-express/generated/router.ts +++ /dev/null @@ -1,43 +0,0 @@ -// This file is auto-generated. Do not edit manually. -// Express: apply express.json() middleware before mounting this router so req.body is populated. - -import { Router } from 'express' -import type { Request, Response } from 'express' -import type { CreatePetRequest } from './models.js' -import type { PetstoreService } from './service.js' -import { z } from 'zod' -import { CreatePetRequestSchema } from '../../petstore-hono/generated/schemas.js' - -export function createRouter(service: PetstoreService): Router { - const router = Router() - - router.get('/pets', async (req: Request, res: Response) => { - const params = { - species: req.query['species'] as string | undefined, - } - res.json(await service.listPets(params)) - }) - - router.post('/pets', async (req: Request, res: Response) => { - // Validate request body: returns 422 with Zod issues on failure - const parseResult = CreatePetRequestSchema.safeParse(req.body) - if (!parseResult.success) { - return void res - .status(422) - .json({ error: 'Invalid request body', issues: parseResult.error.issues }) - } - const validatedBody = parseResult.data - res.status(201).json(await service.createPet(validatedBody)) - }) - - router.get('/pets/:id', async (req: Request, res: Response) => { - res.json(await service.getPet(req.params['id']!)) - }) - - router.delete('/pets/:id', async (req: Request, res: Response) => { - await service.deletePet(req.params['id']!) - res.status(204).end() - }) - - return router -} diff --git a/packages/petstore-express/generated/service.ts b/packages/petstore-express/generated/service.ts deleted file mode 100644 index 89af88e..0000000 --- a/packages/petstore-express/generated/service.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file is auto-generated. Do not edit manually. - -import type { CreatePetRequest, Pet } from './models.js' - -export interface PetstoreService { - /** GET /pets */ - listPets(params?: { species?: string }): Promise - /** POST /pets */ - createPet(body: CreatePetRequest): Promise - /** GET /pets/{id} */ - getPet(id: string): Promise - /** DELETE /pets/{id} */ - deletePet(id: string): Promise -} diff --git a/packages/petstore-express/openapi-server.config.json b/packages/petstore-express/openapi-server.config.json index f5fa2bb..37dd981 100644 --- a/packages/petstore-express/openapi-server.config.json +++ b/packages/petstore-express/openapi-server.config.json @@ -2,5 +2,5 @@ "input_openapi": "../petstore-hono/spec/api.json", "output": "generated/", "framework": "express", - "input_schema": "../petstore-hono/generated/schemas.ts" + "input_schema": "../petstore-hono/src/schemas.ts" } diff --git a/packages/petstore-express/package.json b/packages/petstore-express/package.json index c69e1fe..2c448eb 100644 --- a/packages/petstore-express/package.json +++ b/packages/petstore-express/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "generate": "openapi-zod-ts && openapi-server", - "start": "tsx src/server/index.ts", - "test": "vitest run" + "generate": "node node_modules/openapi-zod-ts/dist/cli.cjs && node node_modules/@codewithagents/openapi-server/dist/cli.cjs", + "start": "pnpm generate && tsx src/server/index.ts", + "test": "pnpm generate && vitest run" }, "dependencies": { "express": "^5.0.0", diff --git a/packages/petstore-fastify/.gitignore b/packages/petstore-fastify/.gitignore new file mode 100644 index 0000000..b2bad0d --- /dev/null +++ b/packages/petstore-fastify/.gitignore @@ -0,0 +1,6 @@ +# Generated output — regenerated on demand, never committed. +# Run `pnpm generate` (or `pnpm test`) to recreate. +generated/ + +# Test artifacts +test-results/ diff --git a/packages/petstore-fastify/generated/client-config.ts b/packages/petstore-fastify/generated/client-config.ts deleted file mode 100644 index 34704fa..0000000 --- a/packages/petstore-fastify/generated/client-config.ts +++ /dev/null @@ -1,74 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -export interface ClientConfig { - /** Base URL for all API requests (e.g. 'https://api.example.com') */ - baseUrl: string - /** - * Bearer token or a function that returns one (supports async refresh). - * When provided, adds `Authorization: Bearer ` to every request. - */ - token?: string | (() => string | Promise) - /** - * Fetch credentials mode. Use 'include' for cookie-based auth. - * Defaults to 'same-origin'. - */ - credentials?: RequestCredentials - /** Additional headers sent with every request */ - headers?: Record - /** - * Global error hook called with every non-2xx response error before it is - * thrown. Use for logging, monitoring, or triggering auth refresh flows. - * - * @example - * configureClient({ - * onError: (err) => Sentry.captureException(err), - * }) - */ - onError?: (err: Error) => void - /** - * Default AbortSignal for every request. Useful for cancelling in-flight - * requests (e.g. on route change). Overridable per call via the config param. - */ - signal?: AbortSignal - /** - * Default timeout in milliseconds. When set, every request is aborted with a - * TimeoutError after the specified duration. Overridable per call. - */ - timeout?: number - /** - * Request interceptor called before every fetch. Receives the resolved URL and - * RequestInit; may return an object with optional url/init overrides. - * - * @example - * configureClient({ - * onRequest: ({ url, init }) => ({ init: { ...init, headers: { ...init.headers, 'X-Request-Id': crypto.randomUUID() } } }), - * }) - */ - onRequest?: (req: { - url: string - init: RequestInit - }) => - | void - | { url?: string; init?: RequestInit } - | Promise - /** - * Custom fetch implementation. Defaults to globalThis.fetch, resolved at - * call time so test mocks of globalThis.fetch are always honoured. - */ - fetch?: typeof globalThis.fetch -} - -let _config: ClientConfig = { - baseUrl: '', - credentials: 'same-origin', -} - -/** Configure the API client. Call once at app startup before making any requests. */ -export function configureClient(config: ClientConfig): void { - _config = { ..._config, ...config } -} - -/** @internal — used by generated fetch functions */ -export function getConfig(): Readonly { - return _config -} diff --git a/packages/petstore-fastify/generated/client.ts b/packages/petstore-fastify/generated/client.ts deleted file mode 100644 index 8647866..0000000 --- a/packages/petstore-fastify/generated/client.ts +++ /dev/null @@ -1,113 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -import type { CreatePetRequest, Pet } from './models.js' -import { getConfig, type ClientConfig } from './client-config.js' - -export class ApiError extends Error { - constructor( - public readonly status: Status, - public readonly body: Body - ) { - super(`API error ${status}`) - this.name = 'ApiError' - } -} - -type _FetchResponse = Awaited> - -function _buildSignal( - signal: AbortSignal | undefined, - timeout: number | undefined -): AbortSignal | undefined { - if (timeout === undefined) return signal - const _ts = AbortSignal.timeout(timeout) - if (signal === undefined) return _ts - if (typeof AbortSignal.any === 'function') return AbortSignal.any([signal, _ts]) - const _ctrl = new AbortController() - const _abort = (s: AbortSignal) => () => { - if (!_ctrl.signal.aborted) _ctrl.abort(s.reason) - } - signal.addEventListener('abort', _abort(signal), { once: true }) - _ts.addEventListener('abort', _abort(_ts), { once: true }) - return _ctrl.signal -} - -async function _request( - method: string, - path: string, - opts: { - searchParams?: URLSearchParams - body?: unknown - signal?: AbortSignal - }, - config?: Partial -): Promise<_FetchResponse> { - const { - baseUrl, - headers, - onError, - signal: _cfgSignal, - timeout, - onRequest, - fetch: _configFetch, - } = { ...getConfig(), ...config } - const base = baseUrl ? baseUrl.replace(/\/$/, '') : '' - const qs = opts.searchParams?.toString() ?? '' - const url = qs ? `${base}${path}?${qs}` : `${base}${path}` - const fetch = _configFetch ?? globalThis.fetch - let _url = url - let _init: RequestInit = { - method, - headers: { - ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }, - ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), - } - if (onRequest) { - const _or = await onRequest({ url: _url, init: _init }) - if (_or) { - if (_or.url !== undefined) _url = _or.url - if (_or.init !== undefined) _init = { ..._init, ..._or.init } - } - } - const _rawSignal = opts.signal ?? _cfgSignal - const _resolvedSignal = _buildSignal(_rawSignal, timeout) - if (_resolvedSignal !== undefined) _init = { ..._init, signal: _resolvedSignal } - const res = await fetch(_url, _init) - if (!res.ok) { - const err = new ApiError(res.status, await res.json().catch(() => null)) - onError?.(err) - throw err - } - return res -} - -export async function listPets( - params?: { - species?: string - }, - config?: Partial -): Promise { - const searchParams = new URLSearchParams() - if (params?.species != null) searchParams.set('species', String(params.species)) - const res = await _request('GET', '/pets', { searchParams }, config) - return res.json() -} - -export async function createPet( - body: CreatePetRequest, - config?: Partial -): Promise { - const res = await _request('POST', '/pets', { body }, config) - return res.json() -} - -export async function getPet(id: string, config?: Partial): Promise { - const res = await _request('GET', `/pets/${encodeURIComponent(id)}`, {}, config) - return res.json() -} - -export async function deletePet(id: string, config?: Partial): Promise { - await _request('DELETE', `/pets/${encodeURIComponent(id)}`, {}, config) -} diff --git a/packages/petstore-fastify/generated/index.ts b/packages/petstore-fastify/generated/index.ts deleted file mode 100644 index 41689a6..0000000 --- a/packages/petstore-fastify/generated/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -export * from './models.js' -export * from './client-config.js' -export * from './client.js' diff --git a/packages/petstore-fastify/generated/models.ts b/packages/petstore-fastify/generated/models.ts deleted file mode 100644 index 953707b..0000000 --- a/packages/petstore-fastify/generated/models.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file is auto-generated by openapi-zod-ts - do not edit - -export interface Pet { - id: string - name: string - species: string -} - -export interface CreatePetRequest { - name: string - species: string -} diff --git a/packages/petstore-fastify/generated/router.ts b/packages/petstore-fastify/generated/router.ts deleted file mode 100644 index fa07002..0000000 --- a/packages/petstore-fastify/generated/router.ts +++ /dev/null @@ -1,37 +0,0 @@ -// This file is auto-generated. Do not edit manually. - -import type { FastifyInstance } from 'fastify' -import type { CreatePetRequest } from './models.js' -import type { PetstoreService } from './service.js' -import { z } from 'zod' -import { CreatePetRequestSchema } from '../../petstore-hono/generated/schemas.js' - -export function createRouter(app: FastifyInstance, service: PetstoreService): void { - app.get<{ Querystring: { species?: string } }>('/pets', async (req, reply) => { - const params = { - species: req.query.species, - } - return service.listPets(params) - }) - - app.post<{ Body: CreatePetRequest }>('/pets', async (req, reply) => { - // Validate request body: returns 422 with Zod issues on failure - const parseResult = CreatePetRequestSchema.safeParse(req.body) - if (!parseResult.success) { - return reply - .status(422) - .send({ error: 'Invalid request body', issues: parseResult.error.issues }) - } - reply.status(201) - return service.createPet(parseResult.data) - }) - - app.get<{ Params: { id: string } }>('/pets/:id', async (req, reply) => { - return service.getPet(req.params.id) - }) - - app.delete<{ Params: { id: string } }>('/pets/:id', async (req, reply) => { - await service.deletePet(req.params.id) - reply.status(204).send() - }) -} diff --git a/packages/petstore-fastify/generated/service.ts b/packages/petstore-fastify/generated/service.ts deleted file mode 100644 index 89af88e..0000000 --- a/packages/petstore-fastify/generated/service.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file is auto-generated. Do not edit manually. - -import type { CreatePetRequest, Pet } from './models.js' - -export interface PetstoreService { - /** GET /pets */ - listPets(params?: { species?: string }): Promise - /** POST /pets */ - createPet(body: CreatePetRequest): Promise - /** GET /pets/{id} */ - getPet(id: string): Promise - /** DELETE /pets/{id} */ - deletePet(id: string): Promise -} diff --git a/packages/petstore-fastify/openapi-server.config.json b/packages/petstore-fastify/openapi-server.config.json index 161b28e..e248481 100644 --- a/packages/petstore-fastify/openapi-server.config.json +++ b/packages/petstore-fastify/openapi-server.config.json @@ -2,5 +2,5 @@ "input_openapi": "../petstore-hono/spec/api.json", "output": "generated/", "framework": "fastify", - "input_schema": "../petstore-hono/generated/schemas.ts" + "input_schema": "../petstore-hono/src/schemas.ts" } diff --git a/packages/petstore-fastify/package.json b/packages/petstore-fastify/package.json index 302bf6f..ec60df0 100644 --- a/packages/petstore-fastify/package.json +++ b/packages/petstore-fastify/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "generate": "openapi-zod-ts && openapi-server", - "start": "tsx src/server/index.ts", - "test": "vitest run" + "generate": "node node_modules/openapi-zod-ts/dist/cli.cjs && node node_modules/@codewithagents/openapi-server/dist/cli.cjs", + "start": "pnpm generate && tsx src/server/index.ts", + "test": "pnpm generate && vitest run" }, "dependencies": { "fastify": "^5.0.0", diff --git a/packages/petstore-hono/.gitignore b/packages/petstore-hono/.gitignore new file mode 100644 index 0000000..74bc44e --- /dev/null +++ b/packages/petstore-hono/.gitignore @@ -0,0 +1,7 @@ +# Generated output — regenerated on demand, never committed. +# Run `pnpm generate` (or `pnpm build` / `pnpm test:e2e`) to recreate. +generated/ + +# Playwright artifacts +test-results/ +playwright-report/ diff --git a/packages/petstore-hono/CLAUDE.md b/packages/petstore-hono/CLAUDE.md index a881b3c..abe5565 100644 --- a/packages/petstore-hono/CLAUDE.md +++ b/packages/petstore-hono/CLAUDE.md @@ -5,9 +5,9 @@ Full-stack demo and e2e test harness for the `@codewithagents` OpenAPI toolchain ## Purpose - **Demo**: shows a real production-shaped project built on top of the generated code -- **E2E validation**: Playwright tests cover the full round-trip — browser form → Hono server → Zod validation → 422/201 response → React UI update +- **E2E validation**: Playwright tests cover the full round-trip: browser form to Hono server to Zod validation to 422/201 response to React UI update. -Not published to npm (`private: true`). No unit tests — integration-level testing lives in `packages/integration/`. +Not published to npm (`private: true`). No unit tests; integration-level testing lives in `packages/integration/`. ## Generators used @@ -17,18 +17,18 @@ Not published to npm (`private: true`). No unit tests — integration-level test | `openapi-server.config.json` | `service.ts`, `router.ts` (Hono + Zod validation) | | `openapi-react-query.config.json` | `hooks.ts`, `test-utils.ts` | -All three share `spec/api.json` and `generated/schemas.ts`. +All three share `spec/api.json`. The `input_schema` for `openapi-zod-ts` and `openapi-server` points at `src/schemas.ts`. -## `generated/schemas.ts` — user-owned +## `src/schemas.ts` — user-owned -Written by hand with real business rules (`.min(1, 'Name is required')`). Generators never overwrite it. Regenerating `generated/` is safe. +Written by hand with real business rules (`.min(1, 'Name is required')`). Generators never overwrite it. The `generated/` directory is gitignored and regenerated on demand. ## Dev / generate / test ```bash -pnpm run generate # re-run all three generators (does NOT touch schemas.ts) -pnpm run dev # Vite + Hono server in watch mode (concurrently) -pnpm run test:e2e # vite build → playwright test (Chromium) +pnpm run generate # re-run all three generators (does NOT touch src/schemas.ts) +pnpm run dev # generate + Vite + Hono server in watch mode (concurrently) +pnpm run test:e2e # generate + vite build + playwright test (Chromium) ``` ## CI diff --git a/packages/petstore-hono/README.md b/packages/petstore-hono/README.md index 323361e..0cb989c 100644 --- a/packages/petstore-hono/README.md +++ b/packages/petstore-hono/README.md @@ -17,11 +17,11 @@ A complete, runnable full-stack application that shows the entire `@codewithagen | React Query hooks | `hooks.ts` | ✅ `@codewithagents/openapi-react-query` | | Server interface (framework-agnostic) | `service.ts` | ✅ `@codewithagents/openapi-server` | | Router + Zod validation (Hono — demo choice) | `router.ts` | ✅ `@codewithagents/openapi-server` | -| Zod schemas | `schemas.ts` | ⚠️ Bootstrapped once, then **yours to own** | +| Zod schemas | `src/schemas.ts` | ⚠️ Bootstrapped once, then **yours to own** | | Business logic | `src/server/petService.ts` | ❌ You write this | | React UI | `src/client/App.tsx` | ❌ You write this | -**The key insight:** everything in `generated/` is disposable. Change `spec/api.json`, run `pnpm generate`, and the types, client, hooks, and router update automatically. Your business logic in `src/` is untouched because it implements a stable TypeScript interface. +**The key insight:** everything in `generated/` is disposable and not committed to git. Change `spec/api.json`, run `pnpm generate`, and the types, client, hooks, and router update automatically. Your business logic in `src/` is untouched because it implements a stable TypeScript interface. --- @@ -60,7 +60,7 @@ Open `http://localhost:5173` and you'll see a pet management UI. Add a pet, dele ## The Zod validation story -This is the part that ties everything together. Open `generated/schemas.ts`: +This is the part that ties everything together. Open `src/schemas.ts`: ```ts // Bootstrapped by openapi-zod-ts — this file is yours. Never overwritten. @@ -78,7 +78,7 @@ export const CreatePetRequestSchema = z.object({ }) ``` -The `.min(1, ...)` rules are custom — they weren't in the spec. This is business logic you own. +The `.min(1, ...)` rules are custom, they weren't in the spec. This is business logic you own. Now look at the generated `router.ts`: @@ -93,7 +93,7 @@ app.post('/pets', async (c) => { }) ``` -The router was regenerated (second pass) because `openapi-server.config.json` points at `input_schema: "generated/schemas.ts"`. The generator found `CreatePetRequestSchema`, wired it into the route, and now invalid requests return a structured `422` before they ever reach your service implementation. +The router was regenerated (second pass) because `openapi-server.config.json` points at `input_schema: "src/schemas.ts"`. The generator found `CreatePetRequestSchema`, wired it into the route, and now invalid requests return a structured `422` before they ever reach your service implementation. **The full round-trip:** ``` @@ -119,7 +119,7 @@ This runs all three generators in order: 2. `openapi-server` → `service.ts`, `router.ts` (with Zod validation wired in) 3. `openapi-react-query` → `hooks.ts`, `test-utils.ts` -**`generated/schemas.ts` is never overwritten.** It's bootstrapped on the very first run (if it doesn't exist), then left entirely to you. Add validation rules, refinements, and business logic freely. +**`src/schemas.ts` is never overwritten.** It's bootstrapped on the very first run (if it doesn't exist), then left entirely to you. Add validation rules, refinements, and business logic freely. --- @@ -210,7 +210,7 @@ function ItemList() { spec/ api.json OpenAPI 3.1 — single source of truth -generated/ Auto-generated — safe to delete and re-run +generated/ Auto-generated, gitignored — safe to delete and re-run models.ts TypeScript types (Pet, CreatePetRequest) client.ts Typed fetch functions (zero runtime deps) client-config.ts configureClient() — base URL + auth setup @@ -219,9 +219,9 @@ generated/ Auto-generated — safe to delete and re-run router.ts createRouter(service) — Hono routes + Zod validation hooks.ts useListPets, useCreatePet, useDeletePet (React Query) test-utils.ts MSW handlers for testing hooks - schemas.ts ⚠️ User-owned — bootstrapped once, never overwritten src/ + schemas.ts ⚠️ User-owned — bootstrapped once, never overwritten server/ petService.ts Implements PetstoreService (in-memory Map) index.ts Hono app — mounts router, serves React build @@ -245,7 +245,7 @@ openapi-react-query.config.json Generator config (React Query hooks) { "input_openapi": "spec/api.json", "output": "generated/", - "input_schema": "generated/schemas.ts" + "input_schema": "src/schemas.ts" } ``` @@ -255,7 +255,7 @@ openapi-react-query.config.json Generator config (React Query hooks) "input_openapi": "spec/api.json", "output": "generated/", "framework": "hono", - "input_schema": "generated/schemas.ts" + "input_schema": "src/schemas.ts" } ``` @@ -267,4 +267,4 @@ openapi-react-query.config.json Generator config (React Query hooks) } ``` -All three share the same spec and output directory. The `input_schema` points both `openapi-zod-ts` and `openapi-server` at the same `schemas.ts`, so client-side and server-side validation use identical rules. +All three share the same spec and output directory. The `input_schema` points both `openapi-zod-ts` and `openapi-server` at `src/schemas.ts`, so client-side and server-side validation use identical rules. The `generated/` directory is gitignored and recreated on every build. diff --git a/packages/petstore-hono/generated/client-config.ts b/packages/petstore-hono/generated/client-config.ts deleted file mode 100644 index 34704fa..0000000 --- a/packages/petstore-hono/generated/client-config.ts +++ /dev/null @@ -1,74 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -export interface ClientConfig { - /** Base URL for all API requests (e.g. 'https://api.example.com') */ - baseUrl: string - /** - * Bearer token or a function that returns one (supports async refresh). - * When provided, adds `Authorization: Bearer ` to every request. - */ - token?: string | (() => string | Promise) - /** - * Fetch credentials mode. Use 'include' for cookie-based auth. - * Defaults to 'same-origin'. - */ - credentials?: RequestCredentials - /** Additional headers sent with every request */ - headers?: Record - /** - * Global error hook called with every non-2xx response error before it is - * thrown. Use for logging, monitoring, or triggering auth refresh flows. - * - * @example - * configureClient({ - * onError: (err) => Sentry.captureException(err), - * }) - */ - onError?: (err: Error) => void - /** - * Default AbortSignal for every request. Useful for cancelling in-flight - * requests (e.g. on route change). Overridable per call via the config param. - */ - signal?: AbortSignal - /** - * Default timeout in milliseconds. When set, every request is aborted with a - * TimeoutError after the specified duration. Overridable per call. - */ - timeout?: number - /** - * Request interceptor called before every fetch. Receives the resolved URL and - * RequestInit; may return an object with optional url/init overrides. - * - * @example - * configureClient({ - * onRequest: ({ url, init }) => ({ init: { ...init, headers: { ...init.headers, 'X-Request-Id': crypto.randomUUID() } } }), - * }) - */ - onRequest?: (req: { - url: string - init: RequestInit - }) => - | void - | { url?: string; init?: RequestInit } - | Promise - /** - * Custom fetch implementation. Defaults to globalThis.fetch, resolved at - * call time so test mocks of globalThis.fetch are always honoured. - */ - fetch?: typeof globalThis.fetch -} - -let _config: ClientConfig = { - baseUrl: '', - credentials: 'same-origin', -} - -/** Configure the API client. Call once at app startup before making any requests. */ -export function configureClient(config: ClientConfig): void { - _config = { ..._config, ...config } -} - -/** @internal — used by generated fetch functions */ -export function getConfig(): Readonly { - return _config -} diff --git a/packages/petstore-hono/generated/client.ts b/packages/petstore-hono/generated/client.ts deleted file mode 100644 index 143f483..0000000 --- a/packages/petstore-hono/generated/client.ts +++ /dev/null @@ -1,116 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -import type { CreatePetRequest, Pet } from './models.js' -import { getConfig, type ClientConfig } from './client-config.js' -import { z } from 'zod' -import { CreatePetRequestSchema, PetSchema } from './schemas.js' - -export class ApiError extends Error { - constructor( - public readonly status: Status, - public readonly body: Body - ) { - super(`API error ${status}`) - this.name = 'ApiError' - } -} - -type _FetchResponse = Awaited> - -function _buildSignal( - signal: AbortSignal | undefined, - timeout: number | undefined -): AbortSignal | undefined { - if (timeout === undefined) return signal - const _ts = AbortSignal.timeout(timeout) - if (signal === undefined) return _ts - if (typeof AbortSignal.any === 'function') return AbortSignal.any([signal, _ts]) - const _ctrl = new AbortController() - const _abort = (s: AbortSignal) => () => { - if (!_ctrl.signal.aborted) _ctrl.abort(s.reason) - } - signal.addEventListener('abort', _abort(signal), { once: true }) - _ts.addEventListener('abort', _abort(_ts), { once: true }) - return _ctrl.signal -} - -async function _request( - method: string, - path: string, - opts: { - searchParams?: URLSearchParams - body?: unknown - signal?: AbortSignal - }, - config?: Partial -): Promise<_FetchResponse> { - const { - baseUrl, - headers, - onError, - signal: _cfgSignal, - timeout, - onRequest, - fetch: _configFetch, - } = { ...getConfig(), ...config } - const base = baseUrl ? baseUrl.replace(/\/$/, '') : '' - const qs = opts.searchParams?.toString() ?? '' - const url = qs ? `${base}${path}?${qs}` : `${base}${path}` - const fetch = _configFetch ?? globalThis.fetch - let _url = url - let _init: RequestInit = { - method, - headers: { - ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), - ...headers, - }, - ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), - } - if (onRequest) { - const _or = await onRequest({ url: _url, init: _init }) - if (_or) { - if (_or.url !== undefined) _url = _or.url - if (_or.init !== undefined) _init = { ..._init, ..._or.init } - } - } - const _rawSignal = opts.signal ?? _cfgSignal - const _resolvedSignal = _buildSignal(_rawSignal, timeout) - if (_resolvedSignal !== undefined) _init = { ..._init, signal: _resolvedSignal } - const res = await fetch(_url, _init) - if (!res.ok) { - const err = new ApiError(res.status, await res.json().catch(() => null)) - onError?.(err) - throw err - } - return res -} - -export async function listPets( - params?: { - species?: string - }, - config?: Partial -): Promise { - const searchParams = new URLSearchParams() - if (params?.species != null) searchParams.set('species', String(params.species)) - const res = await _request('GET', '/pets', { searchParams }, config) - return z.array(PetSchema).parse(await res.json()) -} - -export async function createPet( - body: CreatePetRequest, - config?: Partial -): Promise { - CreatePetRequestSchema.parse(body) - const res = await _request('POST', '/pets', { body }, config) - return PetSchema.parse(await res.json()) -} - -export async function getPet(id: string, config?: Partial): Promise { - const res = await _request('GET', `/pets/${encodeURIComponent(id)}`, {}, config) - return PetSchema.parse(await res.json()) -} - -export async function deletePet(id: string, config?: Partial): Promise { - await _request('DELETE', `/pets/${encodeURIComponent(id)}`, {}, config) -} diff --git a/packages/petstore-hono/generated/hooks.ts b/packages/petstore-hono/generated/hooks.ts deleted file mode 100644 index 5597d5d..0000000 --- a/packages/petstore-hono/generated/hooks.ts +++ /dev/null @@ -1,121 +0,0 @@ -// This file is auto-generated by @codewithagents/openapi-react-query — do not edit - -import { - queryOptions, - useQuery, - type UseQueryOptions, - useMutation, - type UseMutationOptions, -} from '@tanstack/react-query' -import { createPet, deletePet, getPet, listPets, type ApiError } from './client.js' - -// ── Query key factories ────────────────────────────────────── - -export const petKeys = { - all: () => ['pets'] as const, - list: (params?: Parameters[0]) => ['pets', 'list', params] as const, - detail: (id: string) => ['pets', id] as const, -} - -// ── Query options factories ────────────────────────────────── - -export function listPetsQueryOptions( - params?: Parameters[0], - options?: Omit< - UseQueryOptions>, ApiError>, - 'queryKey' | 'queryFn' - > -) { - return queryOptions>, ApiError>({ - queryKey: petKeys.list(params), - queryFn: () => listPets(params), - staleTime: 0, - gcTime: 300000, - ...options, - }) -} - -export function getPetQueryOptions( - id: string, - options?: Omit< - UseQueryOptions>, ApiError>, - 'queryKey' | 'queryFn' - > -) { - return queryOptions>, ApiError>({ - queryKey: petKeys.detail(id), - queryFn: () => getPet(id), - staleTime: 0, - gcTime: 300000, - ...options, - }) -} - -// ── Queries ────────────────────────────────────────────────── - -export function useListPets( - params?: Parameters[0], - options?: Omit< - UseQueryOptions>, ApiError>, - 'queryKey' | 'queryFn' - > -) { - return useQuery>, ApiError>({ - queryKey: petKeys.list(params), - queryFn: () => listPets(params), - staleTime: 0, - gcTime: 300000, - ...options, - }) -} - -export function useGetPet( - id: string | undefined | null, - options?: Omit< - UseQueryOptions>, ApiError>, - 'queryKey' | 'queryFn' - > -) { - return useQuery>, ApiError>({ - queryKey: petKeys.detail(id!), - queryFn: () => getPet(id!), - staleTime: 0, - gcTime: 300000, - enabled: id != null && (options?.enabled ?? true), - ...options, - }) -} - -// ── Mutations ──────────────────────────────────────────────── - -export function useCreatePet( - options?: Omit< - UseMutationOptions< - Awaited>, - ApiError, - Parameters[0] - >, - 'mutationFn' - > -) { - return useMutation< - Awaited>, - ApiError, - Parameters[0] - >({ - mutationFn: (vars) => createPet(vars), - ...options, - }) -} - -export function useDeletePet( - options?: Omit< - UseMutationOptions>, ApiError, string>, - 'mutationFn' - > -) { - return useMutation>, ApiError, string>({ - mutationFn: (id) => deletePet(id), - ...options, - }) -} diff --git a/packages/petstore-hono/generated/index.ts b/packages/petstore-hono/generated/index.ts deleted file mode 100644 index 41689a6..0000000 --- a/packages/petstore-hono/generated/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file is auto-generated by openapi-zod-ts — do not edit - -export * from './models.js' -export * from './client-config.js' -export * from './client.js' diff --git a/packages/petstore-hono/generated/models.ts b/packages/petstore-hono/generated/models.ts deleted file mode 100644 index bffd133..0000000 --- a/packages/petstore-hono/generated/models.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file is auto-generated by openapi-zod-ts - do not edit -import type { z } from 'zod' -import type { PetSchema, CreatePetRequestSchema } from './schemas.js' - -export type Pet = z.infer - -export type CreatePetRequest = z.infer diff --git a/packages/petstore-hono/generated/router.ts b/packages/petstore-hono/generated/router.ts deleted file mode 100644 index ad8b9c2..0000000 --- a/packages/petstore-hono/generated/router.ts +++ /dev/null @@ -1,40 +0,0 @@ -// This file is auto-generated. Do not edit manually. - -import { Hono } from 'hono' -import type { CreatePetRequest } from './models.js' -import type { PetstoreService } from './service.js' -import { z } from 'zod' -import { CreatePetRequestSchema } from './schemas.js' - -export function createRouter(service: PetstoreService): Hono { - const app = new Hono() - - app.get('/pets', async (c) => { - const params = { - species: c.req.query('species') ?? undefined, - } - return c.json(await service.listPets(params)) - }) - - app.post('/pets', async (c) => { - const body = await c.req.json() - // Validate request body: returns 422 with Zod issues on failure - const parseResult = CreatePetRequestSchema.safeParse(body) - if (!parseResult.success) { - return c.json({ error: 'Invalid request body', issues: parseResult.error.issues }, 422) - } - const validatedBody = parseResult.data - return c.json(await service.createPet(validatedBody), 201) - }) - - app.get('/pets/:id', async (c) => { - return c.json(await service.getPet(c.req.param('id'))) - }) - - app.delete('/pets/:id', async (c) => { - await service.deletePet(c.req.param('id')) - return new Response(null, { status: 204 }) - }) - - return app -} diff --git a/packages/petstore-hono/generated/service.ts b/packages/petstore-hono/generated/service.ts deleted file mode 100644 index 89af88e..0000000 --- a/packages/petstore-hono/generated/service.ts +++ /dev/null @@ -1,14 +0,0 @@ -// This file is auto-generated. Do not edit manually. - -import type { CreatePetRequest, Pet } from './models.js' - -export interface PetstoreService { - /** GET /pets */ - listPets(params?: { species?: string }): Promise - /** POST /pets */ - createPet(body: CreatePetRequest): Promise - /** GET /pets/{id} */ - getPet(id: string): Promise - /** DELETE /pets/{id} */ - deletePet(id: string): Promise -} diff --git a/packages/petstore-hono/generated/test-utils.ts b/packages/petstore-hono/generated/test-utils.ts deleted file mode 100644 index 7be9aac..0000000 --- a/packages/petstore-hono/generated/test-utils.ts +++ /dev/null @@ -1,44 +0,0 @@ -// This file is auto-generated by @codewithagents/openapi-react-query — do not edit - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { createElement, type ReactNode } from 'react' - -/** - * Creates a QueryClient pre-configured for testing. - * - * Differences from the production default: - * - `retry: false` — tests should not retry on failure - * - `gcTime: 0` — no garbage-collection delay between tests - */ -export function createTestQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - mutations: { - retry: false, - }, - }, - }) -} - -/** - * Returns a wrapper component that provides a `QueryClientProvider`. - * Pass it as the `wrapper` option to `renderHook` from `@testing-library/react`. - * - * @example - * ```ts - * const queryClient = createTestQueryClient() - * const wrapper = createWrapper(queryClient) - * const { result } = renderHook(() => useListTasks(), { wrapper }) - * ``` - */ -export function createWrapper( - queryClient: QueryClient -): ({ children }: { children: ReactNode }) => ReactNode { - return function Wrapper({ children }: { children: ReactNode }): ReactNode { - return createElement(QueryClientProvider, { client: queryClient }, children) - } -} diff --git a/packages/petstore-hono/openapi-server.config.json b/packages/petstore-hono/openapi-server.config.json index 34f24f0..ac66784 100644 --- a/packages/petstore-hono/openapi-server.config.json +++ b/packages/petstore-hono/openapi-server.config.json @@ -2,5 +2,5 @@ "input_openapi": "spec/api.json", "output": "generated/", "framework": "hono", - "input_schema": "generated/schemas.ts" + "input_schema": "src/schemas.ts" } diff --git a/packages/petstore-hono/openapi-zod-ts.config.json b/packages/petstore-hono/openapi-zod-ts.config.json index 534fddd..ffa5736 100644 --- a/packages/petstore-hono/openapi-zod-ts.config.json +++ b/packages/petstore-hono/openapi-zod-ts.config.json @@ -1,5 +1,5 @@ { "input_openapi": "spec/api.json", "output": "generated/", - "input_schema": "generated/schemas.ts" + "input_schema": "src/schemas.ts" } diff --git a/packages/petstore-hono/package.json b/packages/petstore-hono/package.json index 1a3cd6b..8695069 100644 --- a/packages/petstore-hono/package.json +++ b/packages/petstore-hono/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "scripts": { - "generate": "openapi-zod-ts && openapi-server && openapi-react-query", - "dev": "concurrently \"vite\" \"tsx watch src/server/index.ts\"", - "build": "vite build", + "generate": "node node_modules/openapi-zod-ts/dist/cli.cjs && node node_modules/@codewithagents/openapi-server/dist/cli.cjs && node node_modules/@codewithagents/openapi-react-query/dist/cli.js", + "dev": "pnpm generate && concurrently \"vite\" \"tsx watch src/server/index.ts\"", + "build": "pnpm generate && vite build", "start": "tsx src/server/index.ts", - "test:e2e": "vite build && playwright test" + "test:e2e": "pnpm generate && vite build && playwright test" }, "dependencies": { "openapi-zod-ts": "workspace:*", diff --git a/packages/petstore-hono/generated/schemas.ts b/packages/petstore-hono/src/schemas.ts similarity index 100% rename from packages/petstore-hono/generated/schemas.ts rename to packages/petstore-hono/src/schemas.ts