Skip to content

Commit deb9e75

Browse files
authored
feat(openapi): modularize generator and hoist error schemas into components (#1721)
Resolves: #1355 ## Summary Rewrites the OpenAPI generator into small focused modules, hoists typed error schemas into `components.schemas`, and brings every generator module to 100% test coverage. The generator stays schema-library agnostic: tests cover zod, arktype, and plain JSON schemas through custom converters. Error responses now reference named components derived from the error code: ```yaml "400": content: application/json: schema: oneOf: - $ref: '#/components/schemas/BadRequest' - $ref: '#/components/schemas/BadRequest2' - $ref: '#/components/schemas/UndefinedError' ``` ## Changes - Defined errors are hoisted into `#/components/schemas`, named after their code (`BAD_REQUEST` -> `BadRequest`), with numbered postfixes on conflicts and reuse of equal schemas across procedures. - Fixed dangling `$defs` refs when local defs are referenced through keywords like `patternProperties` or `contains`. - Fixed component reuse when regenerating with a previously generated document passed as `base`. - Split the generator into `openapi-generator`, `openapi-generator-operation`, and `openapi-generator-components`. - Added `visitJsonSchemaRefs` to `@orpc/json-schema`, sharing traversal rules with `mapJsonSchemaRefs` so ref discovery and rewriting cannot drift. ## Tests - One unit test file per module in `src/`, reaching 100% statement, branch, function, and line coverage of all three generator modules from unit tests alone. - E2E tests in `packages/openapi/tests/openapi-generator/` organized around end-user use cases: CRUD API, webhook with full HTTP control, file upload and download, SSE streaming, typed errors, reusable components, composed schemas, and schema library agnosticism (zod, arktype, plain JSON schemas).
1 parent 427926b commit deb9e75

23 files changed

Lines changed: 4606 additions & 4921 deletions

packages/json-schema/src/ref-utils.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,66 @@
11
import type { JsonSchema } from './types'
2-
import { decodeJsonPointerSegment, encodeJsonPointerSegment, hoistRecursiveRefToDef, mapJsonSchemaRefs, resolveJsonSchemaRootLocalRef } from './ref-utils'
2+
import { decodeJsonPointerSegment, encodeJsonPointerSegment, hoistRecursiveRefToDef, mapJsonSchemaRefs, resolveJsonSchemaRootLocalRef, visitJsonSchemaRefs } from './ref-utils'
3+
4+
describe('visitJsonSchemaRefs', () => {
5+
it('visits refs under the same keywords mapJsonSchemaRefs rewrites, and skips non-schema keywords', () => {
6+
const schema: JsonSchema = {
7+
type: 'object',
8+
properties: { child: { $ref: '#/$defs/A' } },
9+
patternProperties: { '^x-': { $ref: '#/$defs/B' } },
10+
propertyNames: { $ref: '#/$defs/C' },
11+
dependentSchemas: { other: { $ref: '#/$defs/D' } },
12+
contains: { $ref: '#/$defs/E' },
13+
prefixItems: [{ $ref: '#/$defs/F' }],
14+
not: { $ref: '#/$defs/G' },
15+
if: { $ref: '#/$defs/H' },
16+
then: { $ref: '#/$defs/I' },
17+
else: { $ref: '#/$defs/J' },
18+
items: { $ref: '#/$defs/K' },
19+
additionalProperties: { $ref: '#/$defs/L' },
20+
anyOf: [{ $ref: '#/$defs/M' }],
21+
$defs: { Nested: { $ref: '#/$defs/N' } },
22+
examples: [{ $ref: '#/$defs/Ignored' }],
23+
}
24+
25+
const mapped: string[] = []
26+
mapJsonSchemaRefs(schema, (ref) => {
27+
mapped.push(ref)
28+
return ref
29+
})
30+
31+
const visited: string[] = []
32+
visitJsonSchemaRefs(schema, ref => visited.push(ref))
33+
34+
expect(visited.sort()).toEqual(mapped.sort())
35+
expect(visited).not.toContain('#/$defs/Ignored')
36+
expect(visited.length).toBe(14)
37+
})
38+
39+
it('visits shared and cyclic object instances once', () => {
40+
const shared: JsonSchema = { $ref: '#/$defs/Shared' }
41+
const cyclic: Record<string, unknown> = { type: 'object' }
42+
cyclic.items = cyclic
43+
44+
const visited: string[] = []
45+
visitJsonSchemaRefs({
46+
type: 'object',
47+
properties: {
48+
a: shared,
49+
b: shared,
50+
c: cyclic as JsonSchema,
51+
},
52+
}, ref => visited.push(ref))
53+
54+
expect(visited).toEqual(['#/$defs/Shared'])
55+
})
56+
57+
it('ignores non-object schemas', () => {
58+
const visited: string[] = []
59+
visitJsonSchemaRefs(true, ref => visited.push(ref))
60+
visitJsonSchemaRefs(false, ref => visited.push(ref))
61+
expect(visited).toEqual([])
62+
})
63+
})
364

465
describe('json pointer utils', () => {
566
it('encodes and decodes JSON pointer segments', () => {

packages/json-schema/src/ref-utils.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,42 @@ export function decodeJsonPointerSegment(segment: string): string {
2525
return segment.replaceAll('~1', '/').replaceAll('~0', '~')
2626
}
2727

28+
/**
29+
* Visits every `$ref` in a schema, traversing the same keywords as {@link mapJsonSchemaRefs}.
30+
* Shared or cyclic object instances are visited once.
31+
*/
32+
export function visitJsonSchemaRefs(
33+
value: JsonSchema,
34+
visit: (ref: string) => void,
35+
schemaLevel = true,
36+
seen = new Set<object>(),
37+
): void {
38+
if (!value || typeof value !== 'object' || seen.has(value)) {
39+
return
40+
}
41+
42+
seen.add(value)
43+
44+
if (Array.isArray(value)) {
45+
for (const item of value) {
46+
visitJsonSchemaRefs(item, visit, schemaLevel, seen)
47+
}
48+
return
49+
}
50+
51+
for (const [key, val] of Object.entries(value)) {
52+
if (key === '$ref' && typeof val === 'string') {
53+
visit(val)
54+
}
55+
else if (!schemaLevel) {
56+
visitJsonSchemaRefs(val as JsonSchema, visit, true, seen)
57+
}
58+
else if (JSON_SCHEMA_LOGIC_KEYWORDS.has(key) || JSON_SCHEMA_RECORD_KEYWORDS.has(key)) {
59+
visitJsonSchemaRefs(val as JsonSchema, visit, !JSON_SCHEMA_RECORD_KEYWORDS.has(key), seen)
60+
}
61+
}
62+
}
63+
2864
export function mapJsonSchemaRefs(
2965
value: JsonSchema,
3066
map: (ref: string, path: Array<string | number>) => string,

packages/openapi/src/adapters/standard/openapi-handler-codec.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,29 @@ describe('openAPIHandlerCodec', () => {
8787
expect(resolveBody).not.toHaveBeenCalled()
8888
})
8989

90+
it('treats HEAD like GET, merging path params and query without reading the body', async () => {
91+
const procedure = os
92+
.meta(openapi({ method: 'HEAD', path: '/{id}' }))
93+
.handler(vi.fn())
94+
const codec = new OpenAPIHandlerCodec(procedure)
95+
const resolveBody = vi.fn()
96+
97+
const result = await codec.resolveProcedure(createRequest({
98+
method: 'HEAD',
99+
url: '/42?verbose=true',
100+
resolveBody,
101+
}), options as any)
102+
103+
expect(result).toBeDefined()
104+
105+
await expect(result!.decodeInput()).resolves.toEqual({
106+
id: '42',
107+
verbose: 'true',
108+
})
109+
110+
expect(resolveBody).not.toHaveBeenCalled()
111+
})
112+
90113
it('returns query directly when there are no path params', async () => {
91114
const procedure = os
92115
.meta(openapi({ method: 'GET', path: '/status' }))

packages/openapi/src/adapters/standard/openapi-handler-codec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../../constants'
1616
import { getOpenAPIMeta } from '../../meta'
1717
import { OpenAPISerializer } from '../../openapi-serializer'
18+
import { isBodylessMethod } from '../../utils'
1819
import { OpenAPIMatcher } from './openapi-matcher'
1920

2021
export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> {
@@ -27,7 +28,7 @@ export interface OpenAPIHandlerCodecCoreOptions<_T extends Context> {
2728
* Mapping ORPCError Code -> HTTP Status Code
2829
* The status code should be in the `4xx` or `5xx` range (must be greater than or equal to `400`).
2930
*
30-
* @default COMMON_ERROR_STATUS_MAP, DEFAULT_ERROR_STATUS
31+
* @default COMMON_ERROR_STATUS_MAP
3132
*/
3233
errorStatusMap?: Record<string, number> | undefined
3334

@@ -66,7 +67,7 @@ export class OpenAPIHandlerCodecCore<T extends Context> {
6667
const query = this.deserializeQuery(search, meta?.queryStyles)
6768

6869
if (inputStructure === 'compact') {
69-
const data = request.method === 'GET'
70+
const data = isBodylessMethod(request.method)
7071
? query
7172
: this.serializer.deserialize(await request.resolveBody(meta?.requestBodyHint))
7273

packages/openapi/src/adapters/standard/openapi-link-codec.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,24 @@ describe('openAPILinkCodec', () => {
5050
})
5151
})
5252

53+
it('builds a HEAD request with query parameters and no body', async () => {
54+
const codec = new OpenAPILinkCodec({
55+
check: oc.meta(openapi({ method: 'HEAD', path: '/items/{id}' })),
56+
}, {
57+
url: '/api',
58+
serializer,
59+
})
60+
61+
const request = await codec.encodeInput({ id: '42', verbose: true }, ['check'], { context: {} })
62+
63+
expect(request.method).toBe('HEAD')
64+
expect(request.body).toBeUndefined()
65+
66+
const url = new URL(request.url, 'http://localhost')
67+
expect(url.pathname).toBe('/api/items/42')
68+
expect(url.searchParams.get('verbose')).toBe('true')
69+
})
70+
5371
it('builds a GET request with a prefixed path and mixed query styles', async () => {
5472
const codec = new OpenAPILinkCodec({
5573
item: oc.meta(openapi({

packages/openapi/src/adapters/standard/openapi-link-codec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '../../constants'
1818
import { getOpenAPIMeta } from '../../meta'
1919
import { OpenAPISerializer } from '../../openapi-serializer'
20-
import { getDynamicPathParams } from '../../utils'
20+
import { getDynamicPathParams, isBodylessMethod } from '../../utils'
2121

2222
export class OpenAPILinkCodecError extends TypeError {}
2323

@@ -112,7 +112,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
112112

113113
pathname = `${basePathname.replace(END_SLASH_REGEX, '')}${pathname}` as `/${string}`
114114

115-
if (method === 'GET') {
115+
if (isBodylessMethod(method)) {
116116
const queryString = this.serializeQueryString(data, meta?.queryStyles)
117117
const search = combineSearch(baseSearch, queryString)
118118
const url = `${pathname}${search ?? ''}${baseHash ?? ''}` as StandardUrl
@@ -175,7 +175,7 @@ export class OpenAPILinkCodec<T extends ClientContext> implements StandardLinkCo
175175
const search = combineSearch(baseSearch, queryString)
176176
const url = `${pathname}${search ?? ''}${baseHash ?? ''}` as StandardUrl
177177

178-
if (method === 'GET') {
178+
if (isBodylessMethod(method)) {
179179
return {
180180
body: undefined,
181181
method,

0 commit comments

Comments
 (0)