Skip to content

Commit 6f0af5e

Browse files
authored
feat(openapi): async json schema converter support (#407)
This will expend the supports of converter for dynamic cases, or lazy cases <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Documentation** - Added a new section in the OpenAPI specification providing guidance on extending JSON schema conversion using custom converters. - **New Features / Refactor** - Improved asynchronous processing for API generation and schema conversion, ensuring smoother and more reliable handling of contract procedures. - **Tests** - Updated test scenarios to validate asynchronous behavior, reinforcing the stability of the new improvements. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 060b589 commit 6f0af5e

File tree

4 files changed

+55
-23
lines changed

4 files changed

+55
-23
lines changed

apps/content/docs/openapi/openapi-specification.md

+25
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,31 @@ oRPC supports OpenAPI 3.1.1 and integrates seamlessly with popular schema librar
4141
Interested in support for additional schema libraries? [Let us know](https://github.com/unnoq/orpc/discussions/categories/ideas)!
4242
:::
4343

44+
::: details Want to create your own JSON schema converter?
45+
You can use any existing `X to JSON Schema` converter to add support for additional schema libraries. For example, if you want to use [Valibot](https://valibot.dev) with oRPC (if not supported), you can create a custom converter to convert Valibot schemas into JSON Schema.
46+
47+
```ts
48+
import type { AnySchema } from '@orpc/contract'
49+
import type { ConditionalSchemaConverter, JSONSchema, SchemaConvertOptions } from '@orpc/openapi'
50+
import type { ConversionConfig } from '@valibot/to-json-schema'
51+
import { toJsonSchema } from '@valibot/to-json-schema'
52+
53+
export class ValibotToJsonSchemaConverter implements ConditionalSchemaConverter {
54+
condition(schema: AnySchema | undefined): boolean {
55+
return schema !== undefined && schema['~standard'].vendor === 'valibot'
56+
}
57+
58+
convert(schema: AnySchema | undefined, _options: SchemaConvertOptions): [required: boolean, jsonSchema: Exclude<JSONSchema, boolean>] {
59+
// Most JSON schema converters do not convert the `required` property separately, so returning `true` is acceptable here.
60+
return [true, toJsonSchema(schema as any)]
61+
}
62+
}
63+
```
64+
65+
:::info
66+
It's recommended to use the built-in converters because the oRPC implementations handle many edge cases and supports every type that oRPC offers.
67+
:::
68+
4469
```ts twoslash
4570
import { contract, router } from './shared/planet'
4671
// ---cut---

packages/openapi/src/openapi-generator.ts

+21-15
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,15 @@ export class OpenAPIGenerator {
3434
const doc: OpenAPI.Document = clone(base) as OpenAPI.Document
3535
doc.openapi = '3.1.1'
3636

37-
const errors: string[] = []
37+
const contracts: { contract: AnyContractProcedure, path: readonly string[] }[] = []
3838

3939
await resolveContractProcedures({ path: [], router }, ({ contract, path }) => {
40+
contracts.push({ contract, path })
41+
})
42+
43+
const errors: string[] = []
44+
45+
for (const { contract, path } of contracts) {
4046
const operationId = path.join('.')
4147

4248
try {
@@ -53,9 +59,9 @@ export class OpenAPIGenerator {
5359
tags: def.route.tags?.map(tag => tag),
5460
}
5561

56-
this.#request(operationObjectRef, def)
57-
this.#successResponse(operationObjectRef, def)
58-
this.#errorResponse(operationObjectRef, def)
62+
await this.#request(operationObjectRef, def)
63+
await this.#successResponse(operationObjectRef, def)
64+
await this.#errorResponse(operationObjectRef, def)
5965

6066
doc.paths ??= {}
6167
doc.paths[httpPath] ??= {}
@@ -70,7 +76,7 @@ export class OpenAPIGenerator {
7076
`[OpenAPIGenerator] Error occurred while generating OpenAPI for procedure at path: ${operationId}\n${e.message}`,
7177
)
7278
}
73-
})
79+
}
7480

7581
if (errors.length) {
7682
throw new OpenAPIGeneratorError(
@@ -81,16 +87,16 @@ export class OpenAPIGenerator {
8187
return this.serializer.serialize(doc)[0] as OpenAPI.Document
8288
}
8389

84-
#request(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): void {
90+
async #request(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): Promise<void> {
8591
const method = fallbackContractConfig('defaultMethod', def.route.method)
8692
const details = getEventIteratorSchemaDetails(def.inputSchema)
8793

8894
if (details) {
8995
ref.requestBody = {
9096
required: true,
9197
content: toOpenAPIEventIteratorContent(
92-
this.converter.convert(details.yields, { strategy: 'input' }),
93-
this.converter.convert(details.returns, { strategy: 'input' }),
98+
await this.converter.convert(details.yields, { strategy: 'input' }),
99+
await this.converter.convert(details.returns, { strategy: 'input' }),
94100
),
95101
}
96102

@@ -99,7 +105,7 @@ export class OpenAPIGenerator {
99105

100106
const dynamicParams = getDynamicParams(def.route.path)?.map(v => v.name)
101107
const inputStructure = fallbackContractConfig('defaultInputStructure', def.route.inputStructure)
102-
let [required, schema] = this.converter.convert(def.inputSchema, { strategy: 'input' })
108+
let [required, schema] = await this.converter.convert(def.inputSchema, { strategy: 'input' })
103109

104110
if (isAnySchema(schema) && !dynamicParams?.length) {
105111
return
@@ -195,7 +201,7 @@ export class OpenAPIGenerator {
195201
}
196202
}
197203

198-
#successResponse(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): void {
204+
async #successResponse(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): Promise<void> {
199205
const outputSchema = def.outputSchema
200206
const status = fallbackContractConfig('defaultSuccessStatus', def.route.successStatus)
201207
const description = fallbackContractConfig('defaultSuccessDescription', def.route?.successDescription)
@@ -207,15 +213,15 @@ export class OpenAPIGenerator {
207213
ref.responses[status] = {
208214
description,
209215
content: toOpenAPIEventIteratorContent(
210-
this.converter.convert(eventIteratorSchemaDetails.yields, { strategy: 'output' }),
211-
this.converter.convert(eventIteratorSchemaDetails.returns, { strategy: 'output' }),
216+
await this.converter.convert(eventIteratorSchemaDetails.yields, { strategy: 'output' }),
217+
await this.converter.convert(eventIteratorSchemaDetails.returns, { strategy: 'output' }),
212218
),
213219
}
214220

215221
return
216222
}
217223

218-
const [required, json] = this.converter.convert(outputSchema, { strategy: 'output' })
224+
const [required, json] = await this.converter.convert(outputSchema, { strategy: 'output' })
219225

220226
ref.responses ??= {}
221227
ref.responses[status] = {
@@ -258,7 +264,7 @@ export class OpenAPIGenerator {
258264
}
259265
}
260266

261-
#errorResponse(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): void {
267+
async #errorResponse(ref: OpenAPI.OperationObject, def: AnyContractProcedure['~orpc']): Promise<void> {
262268
const errorMap = def.errorMap as ErrorMap
263269

264270
const errors: Record<string, JSONSchema[]> = {}
@@ -273,7 +279,7 @@ export class OpenAPIGenerator {
273279
const status = fallbackORPCErrorStatus(code, config.status)
274280
const message = fallbackORPCErrorMessage(code, config.message)
275281

276-
const [dataRequired, dataSchema] = this.converter.convert(config.data, { strategy: 'output' })
282+
const [dataRequired, dataSchema] = await this.converter.convert(config.data, { strategy: 'output' })
277283

278284
errors[status] ??= []
279285
errors[status].push({

packages/openapi/src/schema-converter.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ describe('compositeSchemaConverter', () => {
2323

2424
const schema = z.object({})
2525

26-
it('fallback to any if no condition is matched', () => {
27-
expect(converter.convert(schema, { strategy: 'input' })).toEqual([false, {}])
26+
it('fallback to any if no condition is matched', async () => {
27+
await expect(converter.convert(schema, { strategy: 'input' })).resolves.toEqual([false, {}])
2828

2929
expect(converter1.condition).toBeCalledTimes(1)
3030
expect(converter1.condition).toBeCalledWith(schema, { strategy: 'input' })
@@ -34,11 +34,11 @@ describe('compositeSchemaConverter', () => {
3434
expect(converter2.convert).not.toHaveBeenCalled()
3535
})
3636

37-
it('return result of first converter if condition is matched', () => {
37+
it('return result of first converter if condition is matched', async () => {
3838
converter1.condition.mockReturnValue(true)
3939
converter1.convert.mockReturnValue('__MATCHED__')
4040

41-
expect(converter.convert(schema, { strategy: 'input' })).toEqual('__MATCHED__')
41+
await expect(converter.convert(schema, { strategy: 'input' })).resolves.toEqual('__MATCHED__')
4242

4343
expect(converter1.condition).toBeCalledTimes(1)
4444
expect(converter1.convert).toHaveBeenCalled()

packages/openapi/src/schema-converter.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { AnySchema } from '@orpc/contract'
2+
import type { Promisable } from '@orpc/shared'
23
import type { JSONSchema } from './schema'
34

45
export interface SchemaConvertOptions {
56
strategy: 'input' | 'output'
67
}
78

89
export interface SchemaConverter {
9-
convert(schema: AnySchema | undefined, options: SchemaConvertOptions): [required: boolean, jsonSchema: JSONSchema]
10+
convert(schema: AnySchema | undefined, options: SchemaConvertOptions): Promisable<[required: boolean, jsonSchema: JSONSchema]>
1011
}
1112

1213
export interface ConditionalSchemaConverter extends SchemaConverter {
13-
condition(schema: AnySchema | undefined, options: SchemaConvertOptions): boolean
14+
condition(schema: AnySchema | undefined, options: SchemaConvertOptions): Promisable<boolean>
1415
}
1516

1617
export class CompositeSchemaConverter implements SchemaConverter {
@@ -20,9 +21,9 @@ export class CompositeSchemaConverter implements SchemaConverter {
2021
this.converters = converters
2122
}
2223

23-
convert(schema: AnySchema | undefined, options: SchemaConvertOptions): [required: boolean, jsonSchema: JSONSchema] {
24+
async convert(schema: AnySchema | undefined, options: SchemaConvertOptions): Promise<[required: boolean, jsonSchema: JSONSchema]> {
2425
for (const converter of this.converters) {
25-
if (converter.condition(schema, options)) {
26+
if (await converter.condition(schema, options)) {
2627
return converter.convert(schema, options)
2728
}
2829
}

0 commit comments

Comments
 (0)