Skip to content

Commit fb2b8d2

Browse files
authored
refactor(json-schema,openapi)!: make JsonSchemaConverter sync (#1716)
## Summary `JsonSchemaConverter.condition`/`.convert` and `DelegatingJsonSchemaConverter.convert` are now synchronous, so JSON schema conversion can be used in sync contexts. ```ts const converter = new DelegatingJsonSchemaConverter([new ZodToJsonSchemaConverter()]) const [jsonSchema, optional] = converter.convert(schema, 'input') // no await needed ``` - All built-in converters (zod, valibot, arktype, effect, standard) were already sync — only the interface and delegating wrapper changed. - Smart coercion plugins now coerce without an extra microtask per call. - `OpenAPIGenerator.generate` stays async (lazy router walking) but converts schemas synchronously. - BREAKING: custom converters with async `condition`/`convert` are no longer supported; schemas whose fallback `validate(undefined)` is async are now treated as required.
1 parent a192493 commit fb2b8d2

6 files changed

Lines changed: 67 additions & 53 deletions

File tree

packages/json-schema/src/convert.test.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,67 @@ import z from 'zod'
55
import { DelegatingJsonSchemaConverter } from './convert'
66

77
describe('delegatingJsonSchemaConverter', () => {
8-
it('uses the first matching custom converter', async () => {
8+
it('uses the first matching custom converter', () => {
99
const schema = z.object({ value: z.string() })
1010

1111
const firstConverter: JsonSchemaConverter = {
12-
condition: vi.fn().mockResolvedValue(true),
13-
convert: vi.fn().mockResolvedValue([{ type: 'string' }, false]),
12+
condition: vi.fn().mockReturnValue(true),
13+
convert: vi.fn().mockReturnValue([{ type: 'string' }, false]),
1414
}
1515

1616
const secondConverter: JsonSchemaConverter = {
17-
condition: vi.fn().mockResolvedValue(true),
18-
convert: vi.fn().mockResolvedValue([{ type: 'number' }, true]),
17+
condition: vi.fn().mockReturnValue(true),
18+
convert: vi.fn().mockReturnValue([{ type: 'number' }, true]),
1919
}
2020

2121
const converter = new DelegatingJsonSchemaConverter([firstConverter, secondConverter])
2222

23-
await expect(converter.convert(schema, 'input')).resolves.toEqual([{ type: 'string' }, false])
23+
expect(converter.convert(schema, 'input')).toEqual([{ type: 'string' }, false])
2424
expect(firstConverter.condition).toHaveBeenCalledWith(schema, 'input')
2525
expect(firstConverter.convert).toHaveBeenCalledWith(schema, 'input')
2626
expect(secondConverter.condition).not.toHaveBeenCalled()
2727
expect(secondConverter.convert).not.toHaveBeenCalled()
2828
})
2929

30-
it('converts schemas using the standard json schema fallback behavior', async () => {
30+
it('converts schemas using the standard json schema fallback behavior', () => {
3131
const converter = new DelegatingJsonSchemaConverter([])
3232

3333
const schema = z.number().transform(String).pipe(z.string())
34-
await expect(converter.convert(schema, 'input')).resolves.toEqual([
34+
expect(converter.convert(schema, 'input')).toEqual([
3535
expect.objectContaining({ type: 'number' }),
3636
false,
3737
])
38-
await expect(converter.convert(schema, 'output')).resolves.toEqual([
38+
expect(converter.convert(schema, 'output')).toEqual([
3939
expect.objectContaining({ type: 'string' }),
4040
false,
4141
])
4242

4343
const optionalSchema = v.optional(v.string())
44-
await expect(converter.convert(optionalSchema, 'input')).resolves.toEqual([
44+
expect(converter.convert(optionalSchema, 'input')).toEqual([
4545
expect.objectContaining({ }),
4646
true,
4747
])
48-
await expect(converter.convert(optionalSchema, 'output')).resolves.toEqual([
48+
expect(converter.convert(optionalSchema, 'output')).toEqual([
4949
expect.objectContaining({ }),
5050
true,
5151
])
5252
})
5353

54-
it('returns an empty schema when ~standard does not expose jsonSchema', async () => {
54+
it('returns an empty schema when ~standard does not expose jsonSchema', () => {
55+
const schema: AnySchema = {
56+
'~standard': {
57+
vendor: 'custom',
58+
version: 1,
59+
validate: vi.fn().mockReturnValue({}),
60+
},
61+
}
62+
63+
const converter = new DelegatingJsonSchemaConverter([])
64+
65+
expect(converter.convert(schema, 'input')).toEqual([{}, true])
66+
})
67+
68+
it('treats schemas with async validation as required', () => {
5569
const schema: AnySchema = {
5670
'~standard': {
5771
vendor: 'custom',
@@ -62,6 +76,6 @@ describe('delegatingJsonSchemaConverter', () => {
6276

6377
const converter = new DelegatingJsonSchemaConverter([])
6478

65-
await expect(converter.convert(schema, 'input')).resolves.toEqual([{}, true])
79+
expect(converter.convert(schema, 'input')).toEqual([{}, false])
6680
})
6781
})

packages/json-schema/src/convert.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { AnySchema } from '@orpc/contract'
2-
import type { Promisable } from '@orpc/shared'
32
import type { JsonSchema } from './types'
43

54
export type JsonSchemaConverterDirection = 'input' | 'output'
@@ -8,27 +7,28 @@ export interface JsonSchemaConverter {
87
/**
98
* Determines whether this converter can handle the given schema.
109
*/
11-
condition(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promisable<boolean>
10+
condition(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): boolean
1211

1312
/**
1413
* Converts an ORPC schema to a JSON Schema representation.
1514
*/
16-
convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promisable<[jsonSchema: JsonSchema, optional: boolean]>
15+
convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean]
1716
}
1817

1918
export class DelegatingJsonSchemaConverter implements Pick<JsonSchemaConverter, 'convert'> {
2019
constructor(
2120
private readonly converters: JsonSchemaConverter[] = [],
2221
) {}
2322

24-
async convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promise<[jsonSchema: JsonSchema, optional: boolean]> {
23+
convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] {
2524
for (const converter of this.converters) {
26-
if (await converter.condition(schema, direction)) {
25+
if (converter.condition(schema, direction)) {
2726
return converter.convert(schema, direction)
2827
}
2928
}
3029

31-
const optional = !(await schema?.['~standard'].validate(undefined))?.issues?.length
30+
const result = schema?.['~standard'].validate(undefined)
31+
const optional = result instanceof Promise ? false : !result?.issues?.length
3232

3333
if (schema && 'jsonSchema' in schema['~standard'] && schema['~standard'].jsonSchema) {
3434
try {

packages/json-schema/src/smart-coercion-handler-plugin.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,27 @@ export class SmartCoercionHandlerPlugin<T extends Context> implements StandardHa
3131
return {
3232
...options,
3333
clientInterceptors: [
34-
async ({ next, input, ...interceptorOptions }) => {
34+
({ next, input, ...interceptorOptions }) => {
3535
const inputSchemas = interceptorOptions.procedure['~orpc'].inputSchemas
3636

3737
if (!inputSchemas) {
3838
return next()
3939
}
4040

41-
const coercedInput = await this.coerceValue(inputSchemas, input)
41+
const coercedInput = this.coerceValue(inputSchemas, input)
4242
return next({ ...interceptorOptions, input: coercedInput })
4343
},
4444
...toArray(options.clientInterceptors),
4545
],
4646
}
4747
}
4848

49-
private async coerceValue(schemas: AnySchema[], value: unknown): Promise<unknown> {
49+
private coerceValue(schemas: AnySchema[], value: unknown): unknown {
5050
for (const schema of schemas) {
5151
let converted = this.cache.get(schema)
5252

5353
if (!converted) {
54-
converted = await this.converter.convert(schema, 'input')
54+
converted = this.converter.convert(schema, 'input')
5555
this.cache.set(schema, converted)
5656
}
5757

packages/json-schema/src/smart-coercion-link-plugin.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export class SmartCoercionLinkPlugin<T extends ClientContext> implements Standar
5353
return output
5454
}
5555

56-
const coercedOutput = await this.coerceValue(outputSchemas, output)
56+
const coercedOutput = this.coerceValue(outputSchemas, output)
5757
return coercedOutput
5858
}
5959
catch (error) {
@@ -69,20 +69,20 @@ export class SmartCoercionLinkPlugin<T extends ClientContext> implements Standar
6969
}
7070

7171
const cloned = cloneORPCError(error)
72-
cloned.data = await this.coerceValue([dataSchema], cloned.data)
72+
cloned.data = this.coerceValue([dataSchema], cloned.data)
7373
throw cloned
7474
}
7575
},
7676
],
7777
}
7878
}
7979

80-
private async coerceValue(schemas: AnySchema[], value: unknown): Promise<unknown> {
80+
private coerceValue(schemas: AnySchema[], value: unknown): unknown {
8181
for (const schema of schemas) {
8282
let converted = this.cache.get(schema)
8383

8484
if (!converted) {
85-
converted = await this.converter.convert(schema, 'output')
85+
converted = this.converter.convert(schema, 'output')
8686
this.cache.set(schema, converted)
8787
}
8888

packages/openapi/src/openapi-generator.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import { OpenAPIGenerator } from './openapi-generator'
88
describe('openAPIGenerator', () => {
99
const zodJsonSchemaConverter: JsonSchemaConverter = {
1010
condition: schema => schema?.['~standard'].vendor === 'zod',
11-
async convert(schema, direction) {
11+
convert(schema, direction) {
1212
const jsonSchema = z.toJSONSchema(schema as any, { io: direction })
13-
const output = await schema?.['~standard'].validate(undefined)
14-
return [jsonSchema as any, !output?.issues]
13+
const output = schema?.['~standard'].validate(undefined)
14+
return [jsonSchema as any, !(output instanceof Promise) && !output?.issues]
1515
},
1616
}
1717

@@ -3086,7 +3086,7 @@ describe('openAPIGenerator', () => {
30863086
converters: [
30873087
{
30883088
condition: schema => schema === planetSchema,
3089-
async convert(_schema, _direction) {
3089+
convert(_schema, _direction) {
30903090
return [{
30913091
type: 'object',
30923092
properties: {
@@ -3150,7 +3150,7 @@ describe('openAPIGenerator', () => {
31503150
converters: [
31513151
{
31523152
condition: schema => schema === planetSchema,
3153-
async convert(_schema, _direction) {
3153+
convert(_schema, _direction) {
31543154
return [{
31553155
type: 'object',
31563156
properties: {

packages/openapi/src/openapi-generator.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export class OpenAPIGenerator {
111111

112112
const errors: string[] = []
113113

114-
await walkProcedureContractsAsync(router, async (contract, path) => {
114+
await walkProcedureContractsAsync(router, (contract, path) => {
115115
if (value(options.filter, contract, path) === false) {
116116
return
117117
}
@@ -140,9 +140,9 @@ export class OpenAPIGenerator {
140140
tags: meta?.tags?.map(tag => tag),
141141
}
142142

143-
await this.request(doc, operationRef, def, meta, dynamicPathParams, options, path)
144-
await this.successResponse(doc, operationRef, def, meta, options, path)
145-
await this.errorResponse(doc, operationRef, def, meta, options)
143+
this.request(doc, operationRef, def, meta, dynamicPathParams, options, path)
144+
this.successResponse(doc, operationRef, def, meta, options, path)
145+
this.errorResponse(doc, operationRef, def, meta, options)
146146
}
147147

148148
if (typeof meta?.spec === 'function') {
@@ -172,17 +172,17 @@ export class OpenAPIGenerator {
172172
return this.serializer.serialize(doc, { asFormData: false, useFormDataForBlobFields: false }) as OpenAPIDocument
173173
}
174174

175-
private async convertSchema(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): Promise<[JsonSchema, boolean]> {
176-
const [jsonSchema, optional] = await this.converter.convert(schema as any, direction)
175+
private convertSchema(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [JsonSchema, boolean] {
176+
const [jsonSchema, optional] = this.converter.convert(schema as any, direction)
177177
return [strip$schemaField(jsonSchema), optional]
178178
}
179179

180-
private async convertSchemas(schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection): Promise<[JsonSchema, boolean]> {
180+
private convertSchemas(schemas: AnySchema[] | undefined, direction: JsonSchemaConverterDirection): [JsonSchema, boolean] {
181181
if (!schemas || schemas.length <= 1) {
182182
return this.convertSchema(schemas?.[0], direction)
183183
}
184184

185-
const results = await Promise.all(schemas.map(s => this.convertSchema(s, direction)))
185+
const results = schemas.map(s => this.convertSchema(s, direction))
186186
const allOfSchemas: JsonSchema[] = []
187187
let optional = true
188188

@@ -196,15 +196,15 @@ export class OpenAPIGenerator {
196196
return [combineJsonSchemasWithComposition('allOf', allOfSchemas), optional]
197197
}
198198

199-
private async request(
199+
private request(
200200
doc: OpenAPIDocument,
201201
ref: OpenAPIOperationObject,
202202
def: AnyProcedureContract['~orpc'],
203203
meta: OpenAPIMeta | undefined,
204204
dynamicPathParams: DynamicPathParam[] | undefined,
205205
options: OpenAPIGeneratorGenerateOptions,
206206
path: string[],
207-
): Promise<void> {
207+
): void {
208208
const method = meta?.method ?? DEFAULT_OPENAPI_METHOD
209209
const inputStructure = meta?.inputStructure ?? DEFAULT_OPENAPI_INPUT_STRUCTURE
210210
const inputSchemas = def.inputSchemas
@@ -214,8 +214,8 @@ export class OpenAPIGenerator {
214214

215215
if (asyncIteratorObjectDetails) {
216216
const [yieldSchemas, returnSchemas] = asyncIteratorObjectDetails
217-
const yieldResult = await this.convertSchemas(yieldSchemas, 'input')
218-
const returnResult = await this.convertSchemas(returnSchemas, 'input')
217+
const yieldResult = this.convertSchemas(yieldSchemas, 'input')
218+
const returnResult = this.convertSchemas(returnSchemas, 'input')
219219

220220
ref.requestBody = {
221221
required: true,
@@ -228,7 +228,7 @@ export class OpenAPIGenerator {
228228

229229
const dynamicParams = dynamicPathParams?.map(v => v.parameterName)
230230

231-
const [schema, optional] = await this.convertSchemas(inputSchemas, 'input')
231+
const [schema, optional] = this.convertSchemas(inputSchemas, 'input')
232232

233233
if (isUnconstrainedSchema(schema) && !dynamicParams?.length) {
234234
return
@@ -374,14 +374,14 @@ export class OpenAPIGenerator {
374374
}
375375
}
376376

377-
private async successResponse(
377+
private successResponse(
378378
doc: OpenAPIDocument,
379379
ref: OpenAPIOperationObject,
380380
def: AnyProcedureContract['~orpc'],
381381
meta: OpenAPIMeta | undefined,
382382
options: OpenAPIGeneratorGenerateOptions,
383383
path: string[],
384-
): Promise<void> {
384+
): void {
385385
const outputSchemas = def.outputSchemas
386386
const status = meta?.successStatus ?? DEFAULT_SUCCESS_STATUS
387387
const description = meta?.successDescription ?? DEFAULT_OPENAPI_SUCCESS_DESCRIPTION
@@ -392,8 +392,8 @@ export class OpenAPIGenerator {
392392

393393
if (iteratorDetails) {
394394
const [yieldSchemas, returnSchemas] = iteratorDetails
395-
const yieldResult = await this.convertSchemas(yieldSchemas, 'output')
396-
const returnResult = await this.convertSchemas(returnSchemas, 'output')
395+
const yieldResult = this.convertSchemas(yieldSchemas, 'output')
396+
const returnResult = this.convertSchemas(returnSchemas, 'output')
397397

398398
ref.responses ??= {}
399399
ref.responses[status] = {
@@ -405,7 +405,7 @@ export class OpenAPIGenerator {
405405
}
406406
}
407407

408-
const [schema] = await this.convertSchemas(outputSchemas, 'output')
408+
const [schema] = this.convertSchemas(outputSchemas, 'output')
409409

410410
if (isUnconstrainedSchema(schema) || outputStructure === 'compact') {
411411
ref.responses ??= {}
@@ -481,13 +481,13 @@ export class OpenAPIGenerator {
481481
}
482482
}
483483

484-
private async errorResponse(
484+
private errorResponse(
485485
doc: OpenAPIDocument,
486486
ref: OpenAPIOperationObject,
487487
def: AnyProcedureContract['~orpc'],
488488
meta: OpenAPIMeta | undefined,
489489
options: OpenAPIGeneratorGenerateOptions,
490-
): Promise<void> {
490+
): void {
491491
const errorStatusMap: Record<string, number> = options.errorStatusMap ?? COMMON_ERROR_STATUS_MAP
492492
const errorMap: ErrorMap = def.errorMap
493493

@@ -504,7 +504,7 @@ export class OpenAPIGenerator {
504504

505505
const status = errorStatusMap[code] ?? DEFAULT_ERROR_STATUS
506506
const defaultMessage = config.message
507-
const [dataJsonSchema, dataOptional] = await this.convertSchema(config.data, 'output')
507+
const [dataJsonSchema, dataOptional] = this.convertSchema(config.data, 'output')
508508

509509
const definitions = errorDefinitionsByStatus.get(status)
510510
if (definitions) {

0 commit comments

Comments
 (0)