Skip to content

Commit d42488d

Browse files
authored
feat(openapi)!: spec generator rewrite and support multiple schema (#61)
* wip * improve * path parser * todo * fixed * sync
1 parent 9588d75 commit d42488d

27 files changed

Lines changed: 717 additions & 1238 deletions

apps/content/content/docs/openapi/generator.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,18 @@ export const router = pub.router({
8787
To generate an OpenAPI specification, you need either the type of the [router](/docs/server/router) you intend to use or the [contract](/docs/contract/builder).
8888

8989
```ts twoslash
90-
import { generateOpenAPI } from '@orpc/openapi'
90+
import { OpenAPIGenerator } from '@orpc/openapi'
91+
import { ZodToJsonSchemaConverter } from '@orpc/zod'
9192
import { router } from 'examples/server'
9293
import { contract } from 'examples/contract'
9394

94-
const spec = generateOpenAPI({
95-
router: contract, // both router and contract are supported
95+
const openAPIGenerator = new OpenAPIGenerator({
96+
schemaConverters: [
97+
new ZodToJsonSchemaConverter(),
98+
],
99+
})
100+
101+
const spec = await openAPIGenerator.generate(contract /* or router */, {
96102
info: {
97103
title: 'My App',
98104
version: '0.0.0',

apps/content/examples/open-api.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1-
import { generateOpenAPI } from '@orpc/openapi'
1+
import { OpenAPIGenerator } from '@orpc/openapi'
2+
import { ZodToJsonSchemaConverter } from '@orpc/zod'
23
import { contract } from 'examples/contract'
34
import { router } from 'examples/server'
45

5-
export const specFromServerRouter = generateOpenAPI({
6-
router,
6+
const openAPIGenerator = new OpenAPIGenerator({
7+
schemaConverters: [
8+
new ZodToJsonSchemaConverter(),
9+
],
10+
})
11+
12+
export const specFromServerRouter = await openAPIGenerator.generate(router, {
713
info: {
814
title: 'My App',
915
version: '0.0.0',
1016
},
1117
})
1218

13-
export const specFromContractRouter = generateOpenAPI({
14-
router: contract,
19+
export const specFromContractRouter = await openAPIGenerator.generate(contract, {
1520
info: {
1621
title: 'My App',
1722
version: '0.0.0',

packages/next/src/action-form.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Schema, SchemaInput } from '@orpc/contract'
22
import type { Context, CreateProcedureClientOptions } from '@orpc/server'
3+
import { JSONSerializer } from '@orpc/openapi'
34
import { CompositeSchemaCoercer, OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec, type SchemaCoercer } from '@orpc/openapi/fetch'
45
import { createProcedureClient, ORPCError, unlazy } from '@orpc/server'
56
import { forbidden, notFound, unauthorized } from 'next/navigation'
@@ -21,7 +22,7 @@ export function createFormAction<
2122

2223
const formAction = async (input: FormData): Promise<void> => {
2324
try {
24-
const codec = opt.payloadCodec ?? new OpenAPIPayloadCodec()
25+
const codec = opt.payloadCodec ?? new OpenAPIPayloadCodec(new JSONSerializer())
2526
const coercer = new CompositeSchemaCoercer(opt.schemaCoercers ?? [])
2627

2728
const { default: procedure } = await unlazy(opt.procedure)

packages/openapi/src/fetch/openapi-handler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ConditionalFetchHandler, FetchOptions } from '@orpc/server/fetch'
22
import type { PublicInputBuilderSimple } from './input-builder-simple'
33
import { type Context, createProcedureClient, ORPCError, type Router, type WithSignal } from '@orpc/server'
44
import { executeWithHooks, type Hooks, ORPC_HANDLER_HEADER, trim } from '@orpc/shared'
5+
import { JSONSerializer, type PublicJSONSerializer } from '../json-serializer'
56
import { InputBuilderFull, type PublicInputBuilderFull } from './input-builder-full'
67
import { InputBuilderSimple } from './input-builder-simple'
78
import { OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec } from './openapi-payload-codec'
@@ -11,6 +12,7 @@ import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer'
1112
export type OpenAPIHandlerOptions<T extends Context> =
1213
& Hooks<Request, Response, T, WithSignal>
1314
& {
15+
jsonSerializer?: PublicJSONSerializer
1416
procedureMatcher?: PublicOpenAPIProcedureMatcher
1517
payloadCodec?: PublicOpenAPIPayloadCodec
1618
inputBuilderSimple?: PublicInputBuilderSimple
@@ -30,8 +32,10 @@ export class OpenAPIHandler<T extends Context> implements ConditionalFetchHandle
3032
router: Router<T, any>,
3133
private readonly options?: NoInfer<OpenAPIHandlerOptions<T>>,
3234
) {
35+
const jsonSerializer = options?.jsonSerializer ?? new JSONSerializer()
36+
3337
this.procedureMatcher = options?.procedureMatcher ?? new OpenAPIProcedureMatcher(hono, router)
34-
this.payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec()
38+
this.payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec(jsonSerializer)
3539
this.inputBuilderSimple = options?.inputBuilderSimple ?? new InputBuilderSimple()
3640
this.inputBuilderFull = options?.inputBuilderFull ?? new InputBuilderFull()
3741
this.compositeSchemaCoercer = new CompositeSchemaCoercer(options?.schemaCoercers ?? [])

packages/openapi/src/fetch/openapi-payload-codec.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { JSONSerializer } from '../json-serializer'
12
import { OpenAPIPayloadCodec } from './openapi-payload-codec'
23

34
describe('openAPIPayloadCodec', () => {
4-
const codec = new OpenAPIPayloadCodec()
5+
const codec = new OpenAPIPayloadCodec(new JSONSerializer())
56

67
describe('encode', () => {
78
it('should encode JSON data when accept header is application/json', async () => {

packages/openapi/src/fetch/openapi-payload-codec.ts

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import type { PublicJSONSerializer } from '../json-serializer'
12
import { ORPCError } from '@orpc/server'
2-
import { findDeepMatches, isPlainObject } from '@orpc/shared'
3+
import { findDeepMatches } from '@orpc/shared'
34
import cd from 'content-disposition'
45
import { safeParse } from 'fast-content-type-parse'
56
import wcmatch from 'wildcard-match'
67
import * as BracketNotation from './bracket-notation'
78

89
export class OpenAPIPayloadCodec {
10+
constructor(private readonly jsonSerializer: PublicJSONSerializer) {}
11+
912
encode(payload: unknown, accept?: string): { body: FormData | Blob | string | undefined, headers?: Headers } {
1013
const typeMatchers = (
1114
accept?.split(',').map(safeParse) ?? [{ type: '*/*' }]
@@ -30,7 +33,7 @@ export class OpenAPIPayloadCodec {
3033
}
3134
}
3235

33-
const handledPayload = this.serialize(payload)
36+
const handledPayload = this.jsonSerializer.serialize(payload)
3437
const hasBlobs = findDeepMatches(v => v instanceof Blob, handledPayload).values.length > 0
3538

3639
const isExpectedMultipartFormData = typeMatchers.some(isMatch =>
@@ -141,37 +144,6 @@ export class OpenAPIPayloadCodec {
141144
}
142145
}
143146

144-
serialize(payload: unknown): unknown {
145-
if (payload instanceof Set)
146-
return this.serialize([...payload])
147-
if (payload instanceof Map)
148-
return this.serialize([...payload.entries()])
149-
if (Array.isArray(payload)) {
150-
return payload.map(v => (v === undefined ? 'undefined' : this.serialize(v)))
151-
}
152-
if (Number.isNaN(payload))
153-
return 'NaN'
154-
if (typeof payload === 'bigint')
155-
return payload.toString()
156-
if (payload instanceof Date && Number.isNaN(payload.getTime())) {
157-
return 'Invalid Date'
158-
}
159-
if (payload instanceof RegExp)
160-
return payload.toString()
161-
if (payload instanceof URL)
162-
return payload.toString()
163-
if (!isPlainObject(payload))
164-
return payload
165-
return Object.keys(payload).reduce(
166-
(carry, key) => {
167-
const val = payload[key]
168-
carry[key] = this.serialize(val)
169-
return carry
170-
},
171-
{} as Record<string, unknown>,
172-
)
173-
}
174-
175147
async decode(re: Request | Response | Headers | URLSearchParams | FormData): Promise<unknown> {
176148
if (
177149
re instanceof Headers

0 commit comments

Comments
 (0)