diff --git a/docs/superpowers/plans/2026-06-11-openapi-cookie-parameters.md b/docs/superpowers/plans/2026-06-11-openapi-cookie-parameters.md new file mode 100644 index 000000000..f3ae71eef --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-openapi-cookie-parameters.md @@ -0,0 +1,436 @@ +# OpenAPI Cookie Parameters Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `cookies` as a supported key in `inputStructure: 'detailed'` so oRPC generates correct `in: cookie` OpenAPI parameters and validates cookie values server-side. + +**Architecture:** Two targeted changes in `packages/openapi`: (1) extend the generator loop to include `'cookies'` → `'cookie'` mapping, (2) parse the `Cookie` request header in the server codec and expose it as `cookies` using the same lazy-getter pattern as `query`. No changes to other packages. + +**Tech Stack:** TypeScript, Vitest, `@orpc/openapi` package internals. + +--- + +## File Map + +| File | Change | +|---|---| +| `packages/openapi/src/openapi-generator.ts` | Extend loop + update error message | +| `packages/openapi/src/adapters/standard/openapi-codec.ts` | Add `parseCookieHeader` + lazy `cookies` getter in `decode()` | +| `packages/openapi/src/openapi-utils.test.ts` | Add `toOpenAPIParameters` test for `'cookie'` | +| `packages/openapi/src/openapi-generator.test.ts` | Add generator tests for `cookies` key | +| `packages/openapi/src/adapters/standard/openapi-codec.test.ts` | Add codec `decode()` tests for cookies | + +--- + +## Task 1: `toOpenAPIParameters` — test that cookie parameters have no `style`/`explode`/`allowEmptyValue`/`allowReserved` + +**Files:** +- Modify: `packages/openapi/src/openapi-utils.test.ts` (after line 303) + +The `toOpenAPIParameters` function already accepts `'cookie'` as a `parameterIn`. This task verifies it behaves correctly: no `deepObject` style, no `allowEmptyValue`, no `allowReserved`. + +- [ ] **Step 1: Write the failing test** + +Open `packages/openapi/src/openapi-utils.test.ts`. After the existing `'query'` test block (around line 302), add a new `'cookie'` test inside the `describe('toOpenAPIParameters')` block: + +```ts +it('cookie', () => { + expect(toOpenAPIParameters(schema, 'cookie')).toEqual([{ + name: 'a', + in: 'cookie', + required: true, + schema: { + type: 'string', + }, + }, { + name: 'b', + in: 'cookie', + required: false, + schema: { + type: 'object', + properties: { + b1: { type: 'number' }, + b2: { type: 'string' }, + }, + required: ['b1'], + }, + }, { + name: 'c', + in: 'cookie', + required: true, + schema: { + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }]) +}) +``` + +The `schema` variable is already defined at line 207 of the test file — use the same one. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/openapi && pnpm vitest run src/openapi-utils.test.ts +``` + +Expected: this new test **passes immediately** (the function already handles `'cookie'` correctly — `parameterIn !== 'query'` sets `isDeepObjectStyle = false`). If it passes, skip to Step 3. + +- [ ] **Step 3: Commit** + +```bash +git add packages/openapi/src/openapi-utils.test.ts +git commit -m "test(openapi): verify toOpenAPIParameters handles cookie parameterIn" +``` + +--- + +## Task 2: Generator — add `cookies` to `inputStructure: 'detailed'` loop + +**Files:** +- Modify: `packages/openapi/src/openapi-generator.ts` (lines 364–407) + +- [ ] **Step 1: Write the failing generator test** + +Open `packages/openapi/src/openapi-generator.test.ts`. Find the `inputTests` array. After the existing `'inputStructure=detailed'` test case (around line 391), add two new test cases inside the `inputTests` array: + +```ts +{ + name: 'inputStructure=detailed with cookies', + contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ + cookies: z.object({ session_id: z.string(), theme: z.string().optional() }), + })), + expected: { + '/': { + post: expect.objectContaining({ + parameters: [ + { + name: 'session_id', + in: 'cookie', + required: true, + schema: { type: 'string' }, + }, + { + name: 'theme', + in: 'cookie', + required: false, + schema: { type: 'string' }, + }, + ], + }), + }, + }, +}, +{ + name: 'inputStructure=detailed with cookies + headers + query + body', + contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ + cookies: z.object({ session_id: z.string() }), + headers: z.object({ 'x-request-id': z.string() }), + query: z.object({ page: z.number().optional() }), + body: z.string(), + })), + expected: { + '/': { + post: expect.objectContaining({ + parameters: expect.arrayContaining([ + expect.objectContaining({ name: 'session_id', in: 'cookie' }), + expect.objectContaining({ name: 'x-request-id', in: 'header' }), + expect.objectContaining({ name: 'page', in: 'query' }), + ]), + requestBody: expect.objectContaining({ + content: expect.objectContaining({ + 'application/json': expect.objectContaining({ + schema: { type: 'string' }, + }), + }), + }), + }), + }, + }, +}, +{ + name: 'inputStructure=detailed + invalid cookies (not an object)', + contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ cookies: z.string() })), + error: 'When input structure is "detailed", input schema must satisfy', +}, +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd packages/openapi && pnpm vitest run src/openapi-generator.test.ts --reporter=verbose 2>&1 | grep -A5 "cookies" +``` + +Expected: the `'inputStructure=detailed with cookies'` test fails because cookies are not yet handled. + +- [ ] **Step 3: Implement the generator change** + +Open `packages/openapi/src/openapi-generator.ts`. + +**Change 1** — update error message at line 365–366: + +```ts +// BEFORE: + const error = new OpenAPIGeneratorError( + 'When input structure is "detailed", input schema must satisfy: ' + + '{ params?: Record, query?: Record, headers?: Record, body?: unknown }', + ) + +// AFTER: + const error = new OpenAPIGeneratorError( + 'When input structure is "detailed", input schema must satisfy: ' + + '{ params?: Record, query?: Record, headers?: Record, cookies?: Record, body?: unknown }', + ) +``` + +**Change 2** — extend the loop at lines 389–407: + +```ts +// BEFORE: + for (const from of ['params', 'query', 'headers']) { + const fromSchema = schema.properties?.[from] + if (fromSchema !== undefined) { + const resolvedSchema = simplifyComposedObjectJsonSchemasAndRefs(fromSchema, doc) + + if (!isObjectSchema(resolvedSchema)) { + throw error + } + + const parameterIn: 'path' | 'query' | 'header' = from === 'params' + ? 'path' + : from === 'headers' + ? 'header' + : 'query' + + ref.parameters ??= [] + ref.parameters.push(...toOpenAPIParameters(resolvedSchema, parameterIn)) + } + } + +// AFTER: + for (const from of ['params', 'query', 'headers', 'cookies']) { + const fromSchema = schema.properties?.[from] + if (fromSchema !== undefined) { + const resolvedSchema = simplifyComposedObjectJsonSchemasAndRefs(fromSchema, doc) + + if (!isObjectSchema(resolvedSchema)) { + throw error + } + + const parameterIn: 'path' | 'query' | 'header' | 'cookie' = from === 'params' + ? 'path' + : from === 'headers' + ? 'header' + : from === 'cookies' + ? 'cookie' + : 'query' + + ref.parameters ??= [] + ref.parameters.push(...toOpenAPIParameters(resolvedSchema, parameterIn)) + } + } +``` + +- [ ] **Step 4: Run generator tests to verify they pass** + +```bash +cd packages/openapi && pnpm vitest run src/openapi-generator.test.ts +``` + +Expected: all tests pass, including the new cookie tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/openapi/src/openapi-generator.ts packages/openapi/src/openapi-generator.test.ts +git commit -m "feat(openapi): support cookies in inputStructure detailed generator" +``` + +--- + +## Task 3: Codec — parse Cookie header and expose as `cookies` in `decode()` + +**Files:** +- Modify: `packages/openapi/src/adapters/standard/openapi-codec.ts` + +- [ ] **Step 1: Write the failing codec tests** + +Open `packages/openapi/src/adapters/standard/openapi-codec.test.ts`. Inside the `describe('with detailed structure')` block (around line 79), after the existing `'can set query'` test, add: + +```ts +it('cookies are parsed from Cookie header', async () => { + serializer.deserialize.mockReturnValue(undefined) + + const url = new URL('http://localhost/api/v1') + + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { + 'cookie': 'session_id=abc123; theme=dark', + }, + signal: undefined, + }, undefined, procedure) as any + + expect(input.cookies).toEqual({ session_id: 'abc123', theme: 'dark' }) +}) + +it('cookies is empty object when no Cookie header', async () => { + serializer.deserialize.mockReturnValue(undefined) + + const url = new URL('http://localhost/api/v1') + + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: {}, + signal: undefined, + }, undefined, procedure) as any + + expect(input.cookies).toEqual({}) +}) + +it('can set cookies', async () => { + serializer.deserialize.mockReturnValue(undefined) + + const url = new URL('http://localhost/api/v1') + + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { + 'cookie': 'session_id=abc123', + }, + signal: undefined, + }, undefined, procedure) as any + + input.cookies = { session_id: 'override' } + expect(input.cookies).toEqual({ session_id: 'override' }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd packages/openapi && pnpm vitest run src/adapters/standard/openapi-codec.test.ts +``` + +Expected: the three new cookie tests fail because `input.cookies` is `undefined`. + +- [ ] **Step 3: Implement the codec change** + +Open `packages/openapi/src/adapters/standard/openapi-codec.ts`. + +**Add `parseCookieHeader` as a module-level private function** before the `StandardOpenAPICodec` class definition (around line 23): + +```ts +function parseCookieHeader(cookieHeader: string | undefined): Record { + if (!cookieHeader) + return {} + return Object.fromEntries( + cookieHeader.split(';').map((pair) => { + const idx = pair.indexOf('=') + return [pair.slice(0, idx).trim(), pair.slice(idx + 1).trim()] + }), + ) +} +``` + +**Extend the `decode()` return object** for `inputStructure: 'detailed'` (lines 59–72). Replace the current return statement: + +```ts +// BEFORE: + return { + params, + get query() { + const value = deserializeSearchParams() + Object.defineProperty(this, 'query', { value, writable: true }) + return value + }, + set query(value) { + Object.defineProperty(this, 'query', { value, writable: true }) + }, + headers: request.headers, + body: this.serializer.deserialize(await request.body()), + } + +// AFTER: + return { + params, + get query() { + const value = deserializeSearchParams() + Object.defineProperty(this, 'query', { value, writable: true }) + return value + }, + set query(value) { + Object.defineProperty(this, 'query', { value, writable: true }) + }, + headers: request.headers, + get cookies() { + const value = parseCookieHeader(request.headers['cookie'] as string | undefined) + Object.defineProperty(this, 'cookies', { value, writable: true }) + return value + }, + set cookies(value) { + Object.defineProperty(this, 'cookies', { value, writable: true }) + }, + body: this.serializer.deserialize(await request.body()), + } +``` + +- [ ] **Step 4: Run codec tests to verify they pass** + +```bash +cd packages/openapi && pnpm vitest run src/adapters/standard/openapi-codec.test.ts +``` + +Expected: all tests pass, including the three new cookie tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/openapi/src/adapters/standard/openapi-codec.ts packages/openapi/src/adapters/standard/openapi-codec.test.ts +git commit -m "feat(openapi): parse Cookie header into cookies in detailed input structure decode" +``` + +--- + +## Task 4: Full test suite verification + +- [ ] **Step 1: Run the full openapi package test suite** + +```bash +cd packages/openapi && pnpm vitest run +``` + +Expected: all tests pass with no regressions. + +- [ ] **Step 2: Run the full monorepo test suite** + +```bash +cd /Users/me/Projects/github/orpc && pnpm test --filter @orpc/openapi +``` + +Expected: all tests pass. + +- [ ] **Step 3: TypeScript type check** + +```bash +cd packages/openapi && pnpm tsc --noEmit +``` + +Expected: no type errors. + +- [ ] **Step 4: Commit if needed** + +If any fixes were required in steps 1–3, commit them now. + +```bash +git add -A +git commit -m "fix(openapi): address type/test issues in cookie parameter support" +``` diff --git a/docs/superpowers/specs/2026-06-11-openapi-cookie-parameters-design.md b/docs/superpowers/specs/2026-06-11-openapi-cookie-parameters-design.md new file mode 100644 index 000000000..8a0af5ad3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-openapi-cookie-parameters-design.md @@ -0,0 +1,192 @@ +# OpenAPI Cookie Parameters Support + +**Date:** 2026-06-11 +**Status:** Approved + +## Problem + +oRPC's `inputStructure: 'detailed'` mode supports `params`, `query`, `headers`, and `body` as input keys. It does not support `cookies`. This means: + +1. **OpenAPI spec is incorrect** — no `in: cookie` parameter objects are generated, even when the user reads cookies in their handler. +2. **Runtime validation is missing** — the `Cookie` request header is never parsed or exposed to Zod validation. + +The OpenAPI spec supports cookie parameters as defined in [Swagger 3.0 — Cookie Parameters](https://swagger.io/docs/specification/v3_0/describing-parameters/#cookie-parameters). + +## Scope + +- `inputStructure: 'detailed'` only (not `compact`) +- Server-side: spec generation + runtime decode +- No client-side changes (browsers manage cookies automatically; can be added later) + +## Architecture + +``` +User Schema (Zod) + z.object({ session_id: z.string() }) + ↓ +inputStructure: 'detailed' input shape + { cookies: z.object({ session_id: z.string() }), ... } + ↓ +OpenAPI Generator + in: cookie → ParameterObject[] + ↓ +StandardOpenAPICodec.decode() + Cookie header → parsed key/value object → exposed as `cookies` + ↓ +Zod validation + validates cookies like any other input field +``` + +All changes are confined to `packages/openapi`. No changes to `packages/contract` or `packages/server`. + +## Changes + +### 1. OpenAPI Generator — `packages/openapi/src/openapi-generator.ts` + +**Line 389 — extend the loop over input structure keys:** + +```ts +// Before: +for (const from of ['params', 'query', 'headers']) { + const parameterIn: 'path' | 'query' | 'header' = from === 'params' + ? 'path' + : from === 'headers' ? 'header' : 'query' + // ... +} + +// After: +for (const from of ['params', 'query', 'headers', 'cookies']) { + const parameterIn: 'path' | 'query' | 'header' | 'cookie' = from === 'params' + ? 'path' + : from === 'headers' ? 'header' + : from === 'cookies' ? 'cookie' + : 'query' + // ... +} +``` + +**Line 366 — update the error message:** + +```ts +// Before: +'When input structure is "detailed", input schema must satisfy: ' ++ '{ params?: Record, query?: Record, headers?: Record, body?: unknown }' + +// After: +'When input structure is "detailed", input schema must satisfy: ' ++ '{ params?: Record, query?: Record, headers?: Record, cookies?: Record, body?: unknown }' +``` + +No changes needed to `toOpenAPIParameters()` — it already accepts `'cookie'` as a valid `parameterIn` value. + +### 2. Server Codec — `packages/openapi/src/adapters/standard/openapi-codec.ts` + +**Add a cookie header parser** (private helper function or inline): + +```ts +function parseCookieHeader(cookieHeader: string | undefined): Record { + if (!cookieHeader) return {} + return Object.fromEntries( + cookieHeader.split(';').map((pair) => { + const idx = pair.indexOf('=') + return [pair.slice(0, idx).trim(), pair.slice(idx + 1).trim()] + }), + ) +} +``` + +**Extend the `decode()` return object** with a lazy `cookies` getter, consistent with the existing `query` lazy getter pattern: + +```ts +return { + params, + get query() { + const value = deserializeSearchParams() + Object.defineProperty(this, 'query', { value, writable: true }) + return value + }, + set query(value) { + Object.defineProperty(this, 'query', { value, writable: true }) + }, + headers: request.headers, + get cookies() { + const value = parseCookieHeader(request.headers['cookie'] as string | undefined) + Object.defineProperty(this, 'cookies', { value, writable: true }) + return value + }, + set cookies(value) { + Object.defineProperty(this, 'cookies', { value, writable: true }) + }, + body: this.serializer.deserialize(await request.body()), +} +``` + +Cookie values are always `string` — the existing Zod/JSON Schema smart coercion will convert them to the required types (numbers, booleans, etc.) during validation. + +### 3. No changes required + +- `packages/openapi/src/openapi-utils.ts` — `toOpenAPIParameters()` already handles `'cookie'` +- `packages/contract/src/route.ts` — `inputStructure` is a string enum; cookie support is defined by user's schema shape +- `packages/server/src/helpers/cookie.ts` — not used here; we parse the `Cookie` header directly + +## Tests + +### `packages/openapi/src/openapi-generator.test.ts` + +- `inputStructure: 'detailed'` with `cookies` → generates `in: cookie` parameters correctly +- Error when `cookies` schema is not an object +- Combination: `cookies` + `headers` + `query` + `body` together + +### `packages/openapi/src/openapi-utils.test.ts` + +- `toOpenAPIParameters` with `parameterIn: 'cookie'` — verify `style`/`explode` are NOT added (cookie parameters do not support `deepObject` style) + +### `packages/openapi/src/adapters/standard/openapi-codec.test.ts` + +- `decode()` with `inputStructure: 'detailed'` and a `Cookie` request header → `cookies` key contains parsed key/value object +- `decode()` with no `Cookie` header → `cookies` is an empty object `{}` +- `decode()` with malformed `Cookie` header → graceful handling + +## Usage Example + +```ts +import { os } from '@orpc/server' +import { z } from 'zod' + +const getProfile = os + .route({ + method: 'GET', + path: '/profile', + inputStructure: 'detailed', + }) + .input( + z.object({ + cookies: z.object({ + session_id: z.string(), + }), + }), + ) + .handler(({ input }) => { + const { session_id } = input.cookies + // session_id is validated and typed + }) +``` + +Generated OpenAPI spec: + +```yaml +/profile: + get: + parameters: + - name: session_id + in: cookie + required: true + schema: + type: string +``` + +## Out of Scope + +- `inputStructure: 'compact'` cookie support +- Client-side cookie encoding (`openapi-link-codec.ts`) +- `Set-Cookie` response header in output structure diff --git a/packages/openapi/package.json b/packages/openapi/package.json index aabbcb593..de0213e5d 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -77,6 +77,7 @@ "@orpc/server": "workspace:*", "@orpc/shared": "workspace:*", "@orpc/standard-server": "workspace:*", + "cookie": "^1.1.1", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" }, diff --git a/packages/openapi/src/adapters/standard/openapi-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-codec.test.ts index f85040977..392fff08f 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.test.ts @@ -106,6 +106,7 @@ describe('standardOpenAPICodec', () => { headers: { 'content-type': 'application/json', }, + cookies: {}, body: '__deserialized__', }) @@ -136,6 +137,7 @@ describe('standardOpenAPICodec', () => { headers: { 'content-type': 'application/json', }, + cookies: {}, body: '__deserialized__', }) @@ -163,6 +165,111 @@ describe('standardOpenAPICodec', () => { input.query = { name: 'John Doe' } expect(input.query).toEqual({ name: 'John Doe' }) }) + + it('cookies are parsed from Cookie header', async () => { + serializer.deserialize.mockReturnValue(undefined) + + const url = new URL('http://localhost/api/v1') + + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { + cookie: 'session_id=abc123; theme=dark', + }, + signal: undefined, + }, undefined, procedure) as any + + expect(input.cookies).toEqual({ session_id: 'abc123', theme: 'dark' }) + }) + + it('cookies is empty object when no Cookie header', async () => { + serializer.deserialize.mockReturnValue(undefined) + + const url = new URL('http://localhost/api/v1') + + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: {}, + signal: undefined, + }, undefined, procedure) as any + + expect(input.cookies).toEqual({}) + }) + + it('can set cookies', async () => { + serializer.deserialize.mockReturnValue(undefined) + + const url = new URL('http://localhost/api/v1') + + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { + cookie: 'session_id=abc123', + }, + signal: undefined, + }, undefined, procedure) as any + + input.cookies = { session_id: 'override' } + expect(input.cookies).toEqual({ session_id: 'override' }) + }) + + it('cookies: pair without = is skipped', async () => { + serializer.deserialize.mockReturnValue(undefined) + const url = new URL('http://localhost/api/v1') + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { cookie: 'sessionid; valid=yes' }, + signal: undefined, + }, undefined, procedure) as any + expect(input.cookies).toEqual({ valid: 'yes' }) + }) + + it('cookies: value containing = is handled correctly', async () => { + serializer.deserialize.mockReturnValue(undefined) + const url = new URL('http://localhost/api/v1') + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { cookie: 'token=abc=def' }, + signal: undefined, + }, undefined, procedure) as any + expect(input.cookies).toEqual({ token: 'abc=def' }) + }) + + it('cookies: URL-encoded values are decoded', async () => { + serializer.deserialize.mockReturnValue(undefined) + const url = new URL('http://localhost/api/v1') + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { cookie: 'name=hello%20world; tag=%40user' }, + signal: undefined, + }, undefined, procedure) as any + expect(input.cookies).toEqual({ name: 'hello world', tag: '@user' }) + }) + + it('cookies: base64 values with = padding are handled correctly', async () => { + serializer.deserialize.mockReturnValue(undefined) + const url = new URL('http://localhost/api/v1') + const input = await codec.decode({ + method: 'POST', + url, + body: vi.fn(async () => undefined), + headers: { cookie: 'token=eyJhbGc=; session=base64data==' }, + signal: undefined, + }, undefined, procedure) as any + expect(input.cookies).toEqual({ token: 'eyJhbGc=', session: 'base64data==' }) + }) }) }) diff --git a/packages/openapi/src/adapters/standard/openapi-codec.ts b/packages/openapi/src/adapters/standard/openapi-codec.ts index 9b719f557..7eb6d3ca2 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.ts @@ -6,6 +6,8 @@ import type { StandardHeaders, StandardLazyRequest, StandardResponse } from '@or import { isORPCErrorStatus } from '@orpc/client' import { fallbackContractConfig } from '@orpc/contract' import { isObject, stringifyJSON } from '@orpc/shared' +import { flattenHeader } from '@orpc/standard-server' +import { parse as parseCookie } from 'cookie' export interface StandardOpenAPICodecOptions { /** @@ -67,6 +69,14 @@ export class StandardOpenAPICodec implements StandardCodec { Object.defineProperty(this, 'query', { value, writable: true }) }, headers: request.headers, + get cookies() { + const value = parseCookie(flattenHeader(request.headers.cookie) ?? '') + Object.defineProperty(this, 'cookies', { value, writable: true }) + return value + }, + set cookies(value) { + Object.defineProperty(this, 'cookies', { value, writable: true }) + }, body: this.serializer.deserialize(await request.body()), } } diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index 665307715..6a738d12e 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -423,6 +423,64 @@ const inputTests: TestCase[] = [ contract: oc.route({ inputStructure: 'detailed', path: '/{id}' }).input(z.object({ params: z.object({ id: z.string().optional() }) })), error: 'When input structure is "detailed" and path has dynamic params, the "params" schema must be an object with all dynamic params as required.', }, + { + name: 'inputStructure=detailed with cookies', + contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ + cookies: z.object({ session_id: z.string(), theme: z.string().optional() }), + })), + expected: { + '/': { + post: expect.objectContaining({ + parameters: [ + { + name: 'session_id', + in: 'cookie', + required: true, + schema: { type: 'string' }, + }, + { + name: 'theme', + in: 'cookie', + required: false, + schema: { type: 'string' }, + }, + ], + }), + }, + }, + }, + { + name: 'inputStructure=detailed with cookies + headers + query + body', + contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ + cookies: z.object({ session_id: z.string() }), + headers: z.object({ 'x-request-id': z.string() }), + query: z.object({ page: z.number().optional() }), + body: z.string(), + })), + expected: { + '/': { + post: expect.objectContaining({ + parameters: expect.arrayContaining([ + expect.objectContaining({ name: 'session_id', in: 'cookie' }), + expect.objectContaining({ name: 'x-request-id', in: 'header' }), + expect.objectContaining({ name: 'page', in: 'query' }), + ]), + requestBody: expect.objectContaining({ + content: expect.objectContaining({ + 'application/json': expect.objectContaining({ + schema: { type: 'string' }, + }), + }), + }), + }), + }, + }, + }, + { + name: 'inputStructure=detailed + invalid cookies (not an object)', + contract: oc.route({ inputStructure: 'detailed' }).input(z.object({ cookies: z.string() })), + error: 'When input structure is "detailed", input schema must satisfy', + }, ] const successResponseTests: TestCase[] = [ diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index e8b15366d..eb48f78dd 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -363,7 +363,7 @@ export class OpenAPIGenerator { const error = new OpenAPIGeneratorError( 'When input structure is "detailed", input schema must satisfy: ' - + '{ params?: Record, query?: Record, headers?: Record, body?: unknown }', + + '{ params?: Record, query?: Record, headers?: Record, cookies?: Record, body?: unknown }', ) if (!isObjectSchema(schema)) { @@ -386,7 +386,7 @@ export class OpenAPIGenerator { ) } - for (const from of ['params', 'query', 'headers']) { + for (const from of ['params', 'query', 'headers', 'cookies']) { const fromSchema = schema.properties?.[from] if (fromSchema !== undefined) { const resolvedSchema = simplifyComposedObjectJsonSchemasAndRefs(fromSchema, doc) @@ -395,11 +395,13 @@ export class OpenAPIGenerator { throw error } - const parameterIn: 'path' | 'query' | 'header' = from === 'params' + const parameterIn: 'path' | 'query' | 'header' | 'cookie' = from === 'params' ? 'path' : from === 'headers' ? 'header' - : 'query' + : from === 'cookies' + ? 'cookie' + : 'query' ref.parameters ??= [] ref.parameters.push(...toOpenAPIParameters(resolvedSchema, parameterIn)) diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts index 0024f2f0f..bada81b8d 100644 --- a/packages/openapi/src/openapi-utils.test.ts +++ b/packages/openapi/src/openapi-utils.test.ts @@ -299,6 +299,39 @@ describe('toOpenAPIParameters', () => { allowReserved: true, }]) }) + + it('cookie', () => { + expect(toOpenAPIParameters(schema, 'cookie')).toEqual([{ + name: 'a', + in: 'cookie', + required: true, + schema: { + type: 'string', + }, + }, { + name: 'b', + in: 'cookie', + required: false, + schema: { + type: 'object', + properties: { + b1: { type: 'number' }, + b2: { type: 'string' }, + }, + required: ['b1'], + }, + }, { + name: 'c', + in: 'cookie', + required: true, + schema: { + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }]) + }) }) describe('checkParamsSchema', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 835e61b9d..f0e8fa0bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,6 +509,9 @@ importers: '@orpc/standard-server': specifier: workspace:* version: link:../standard-server + cookie: + specifier: ^1.1.1 + version: 1.1.1 json-schema-typed: specifier: ^8.0.2 version: 8.0.2 @@ -750,7 +753,7 @@ importers: version: 5.8.3 next: specifier: ^16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) supertest: specifier: ^7.1.4 version: 7.2.2 @@ -1531,7 +1534,7 @@ importers: version: 19.2.3(@types/react@19.2.14) next: specifier: ^16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -28332,7 +28335,7 @@ snapshots: dependencies: typescript: 5.8.3 - next@16.2.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.3(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.3 '@swc/helpers': 0.5.15 @@ -28341,7 +28344,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.2.3 '@next/swc-darwin-x64': 16.2.3 @@ -30885,12 +30888,12 @@ snapshots: style-mod@4.1.3: {} - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 optionalDependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.28.6 stylehacks@7.0.7(postcss@8.5.9): dependencies: