Skip to content

Commit

Permalink
feat(openapi)!: spec generator rewrite and support multiple schema (#61)
Browse files Browse the repository at this point in the history
* wip

* improve

* path parser

* todo

* fixed

* sync
  • Loading branch information
unnoq authored Dec 28, 2024
1 parent 9588d75 commit d42488d
Show file tree
Hide file tree
Showing 27 changed files with 717 additions and 1,238 deletions.
12 changes: 9 additions & 3 deletions apps/content/content/docs/openapi/generator.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,18 @@ export const router = pub.router({
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).

```ts twoslash
import { generateOpenAPI } from '@orpc/openapi'
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { router } from 'examples/server'
import { contract } from 'examples/contract'

const spec = generateOpenAPI({
router: contract, // both router and contract are supported
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter(),
],
})

const spec = await openAPIGenerator.generate(contract /* or router */, {
info: {
title: 'My App',
version: '0.0.0',
Expand Down
15 changes: 10 additions & 5 deletions apps/content/examples/open-api.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { generateOpenAPI } from '@orpc/openapi'
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { contract } from 'examples/contract'
import { router } from 'examples/server'

export const specFromServerRouter = generateOpenAPI({
router,
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter(),
],
})

export const specFromServerRouter = await openAPIGenerator.generate(router, {
info: {
title: 'My App',
version: '0.0.0',
},
})

export const specFromContractRouter = generateOpenAPI({
router: contract,
export const specFromContractRouter = await openAPIGenerator.generate(contract, {
info: {
title: 'My App',
version: '0.0.0',
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/action-form.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Schema, SchemaInput } from '@orpc/contract'
import type { Context, CreateProcedureClientOptions } from '@orpc/server'
import { JSONSerializer } from '@orpc/openapi'
import { CompositeSchemaCoercer, OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec, type SchemaCoercer } from '@orpc/openapi/fetch'
import { createProcedureClient, ORPCError, unlazy } from '@orpc/server'
import { forbidden, notFound, unauthorized } from 'next/navigation'
Expand All @@ -21,7 +22,7 @@ export function createFormAction<

const formAction = async (input: FormData): Promise<void> => {
try {
const codec = opt.payloadCodec ?? new OpenAPIPayloadCodec()
const codec = opt.payloadCodec ?? new OpenAPIPayloadCodec(new JSONSerializer())
const coercer = new CompositeSchemaCoercer(opt.schemaCoercers ?? [])

const { default: procedure } = await unlazy(opt.procedure)
Expand Down
6 changes: 5 additions & 1 deletion packages/openapi/src/fetch/openapi-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ConditionalFetchHandler, FetchOptions } from '@orpc/server/fetch'
import type { PublicInputBuilderSimple } from './input-builder-simple'
import { type Context, createProcedureClient, ORPCError, type Router, type WithSignal } from '@orpc/server'
import { executeWithHooks, type Hooks, ORPC_HANDLER_HEADER, trim } from '@orpc/shared'
import { JSONSerializer, type PublicJSONSerializer } from '../json-serializer'
import { InputBuilderFull, type PublicInputBuilderFull } from './input-builder-full'
import { InputBuilderSimple } from './input-builder-simple'
import { OpenAPIPayloadCodec, type PublicOpenAPIPayloadCodec } from './openapi-payload-codec'
Expand All @@ -11,6 +12,7 @@ import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer'
export type OpenAPIHandlerOptions<T extends Context> =
& Hooks<Request, Response, T, WithSignal>
& {
jsonSerializer?: PublicJSONSerializer
procedureMatcher?: PublicOpenAPIProcedureMatcher
payloadCodec?: PublicOpenAPIPayloadCodec
inputBuilderSimple?: PublicInputBuilderSimple
Expand All @@ -30,8 +32,10 @@ export class OpenAPIHandler<T extends Context> implements ConditionalFetchHandle
router: Router<T, any>,
private readonly options?: NoInfer<OpenAPIHandlerOptions<T>>,
) {
const jsonSerializer = options?.jsonSerializer ?? new JSONSerializer()

this.procedureMatcher = options?.procedureMatcher ?? new OpenAPIProcedureMatcher(hono, router)
this.payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec()
this.payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec(jsonSerializer)
this.inputBuilderSimple = options?.inputBuilderSimple ?? new InputBuilderSimple()
this.inputBuilderFull = options?.inputBuilderFull ?? new InputBuilderFull()
this.compositeSchemaCoercer = new CompositeSchemaCoercer(options?.schemaCoercers ?? [])
Expand Down
3 changes: 2 additions & 1 deletion packages/openapi/src/fetch/openapi-payload-codec.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { JSONSerializer } from '../json-serializer'
import { OpenAPIPayloadCodec } from './openapi-payload-codec'

describe('openAPIPayloadCodec', () => {
const codec = new OpenAPIPayloadCodec()
const codec = new OpenAPIPayloadCodec(new JSONSerializer())

describe('encode', () => {
it('should encode JSON data when accept header is application/json', async () => {
Expand Down
38 changes: 5 additions & 33 deletions packages/openapi/src/fetch/openapi-payload-codec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { PublicJSONSerializer } from '../json-serializer'
import { ORPCError } from '@orpc/server'
import { findDeepMatches, isPlainObject } from '@orpc/shared'
import { findDeepMatches } from '@orpc/shared'
import cd from 'content-disposition'
import { safeParse } from 'fast-content-type-parse'
import wcmatch from 'wildcard-match'
import * as BracketNotation from './bracket-notation'

export class OpenAPIPayloadCodec {
constructor(private readonly jsonSerializer: PublicJSONSerializer) {}

encode(payload: unknown, accept?: string): { body: FormData | Blob | string | undefined, headers?: Headers } {
const typeMatchers = (
accept?.split(',').map(safeParse) ?? [{ type: '*/*' }]
Expand All @@ -30,7 +33,7 @@ export class OpenAPIPayloadCodec {
}
}

const handledPayload = this.serialize(payload)
const handledPayload = this.jsonSerializer.serialize(payload)
const hasBlobs = findDeepMatches(v => v instanceof Blob, handledPayload).values.length > 0

const isExpectedMultipartFormData = typeMatchers.some(isMatch =>
Expand Down Expand Up @@ -141,37 +144,6 @@ export class OpenAPIPayloadCodec {
}
}

serialize(payload: unknown): unknown {
if (payload instanceof Set)
return this.serialize([...payload])
if (payload instanceof Map)
return this.serialize([...payload.entries()])
if (Array.isArray(payload)) {
return payload.map(v => (v === undefined ? 'undefined' : this.serialize(v)))
}
if (Number.isNaN(payload))
return 'NaN'
if (typeof payload === 'bigint')
return payload.toString()
if (payload instanceof Date && Number.isNaN(payload.getTime())) {
return 'Invalid Date'
}
if (payload instanceof RegExp)
return payload.toString()
if (payload instanceof URL)
return payload.toString()
if (!isPlainObject(payload))
return payload
return Object.keys(payload).reduce(
(carry, key) => {
const val = payload[key]
carry[key] = this.serialize(val)
return carry
},
{} as Record<string, unknown>,
)
}

async decode(re: Request | Response | Headers | URLSearchParams | FormData): Promise<unknown> {
if (
re instanceof Headers
Expand Down
Loading

0 comments on commit d42488d

Please sign in to comment.