From d42488d288a70db92fe7a525d08339a7c2081aa1 Mon Sep 17 00:00:00 2001 From: unnoq Date: Sat, 28 Dec 2024 20:12:12 +0700 Subject: [PATCH] feat(openapi)!: spec generator rewrite and support multiple schema (#61) * wip * improve * path parser * todo * fixed * sync --- .../content/docs/openapi/generator.mdx | 12 +- apps/content/examples/open-api.ts | 15 +- packages/next/src/action-form.ts | 3 +- packages/openapi/src/fetch/openapi-handler.ts | 6 +- .../src/fetch/openapi-payload-codec.test.ts | 3 +- .../src/fetch/openapi-payload-codec.ts | 38 +- packages/openapi/src/generator.test.ts | 761 ------------------ packages/openapi/src/generator.ts | 372 --------- packages/openapi/src/index.ts | 11 +- packages/openapi/src/json-serializer.ts | 36 + .../openapi/src/openapi-content-builder.ts | 44 + packages/openapi/src/openapi-generator.ts | 202 +++++ .../openapi/src/openapi-parameters-builder.ts | 49 ++ packages/openapi/src/openapi-path-parser.ts | 12 + packages/openapi/src/openapi.ts | 2 + packages/openapi/src/schema-converter.ts | 34 + packages/openapi/src/schema-utils.ts | 138 ++++ packages/openapi/src/schema.ts | 38 + packages/openapi/src/utils.ts | 25 +- .../src/converter.test.ts} | 2 +- .../src/converter.ts} | 88 +- packages/zod/src/index.ts | 1 + playgrounds/contract-openapi/src/main.ts | 13 +- playgrounds/expressjs/src/main.ts | 12 +- playgrounds/nextjs/src/app/spec/route.ts | 13 +- playgrounds/nuxt/server/routes/spec.ts | 12 +- playgrounds/openapi/src/main.ts | 13 +- 27 files changed, 717 insertions(+), 1238 deletions(-) delete mode 100644 packages/openapi/src/generator.test.ts delete mode 100644 packages/openapi/src/generator.ts create mode 100644 packages/openapi/src/json-serializer.ts create mode 100644 packages/openapi/src/openapi-content-builder.ts create mode 100644 packages/openapi/src/openapi-generator.ts create mode 100644 packages/openapi/src/openapi-parameters-builder.ts create mode 100644 packages/openapi/src/openapi-path-parser.ts create mode 100644 packages/openapi/src/openapi.ts create mode 100644 packages/openapi/src/schema-converter.ts create mode 100644 packages/openapi/src/schema-utils.ts create mode 100644 packages/openapi/src/schema.ts rename packages/{openapi/src/zod-to-json-schema.test.ts => zod/src/converter.test.ts} (99%) rename packages/{openapi/src/zod-to-json-schema.ts => zod/src/converter.ts} (87%) diff --git a/apps/content/content/docs/openapi/generator.mdx b/apps/content/content/docs/openapi/generator.mdx index 7608fd3d..5f5c8b07 100644 --- a/apps/content/content/docs/openapi/generator.mdx +++ b/apps/content/content/docs/openapi/generator.mdx @@ -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', diff --git a/apps/content/examples/open-api.ts b/apps/content/examples/open-api.ts index 30e551c4..033c7103 100644 --- a/apps/content/examples/open-api.ts +++ b/apps/content/examples/open-api.ts @@ -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', diff --git a/packages/next/src/action-form.ts b/packages/next/src/action-form.ts index 1e526786..7aeea4e2 100644 --- a/packages/next/src/action-form.ts +++ b/packages/next/src/action-form.ts @@ -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' @@ -21,7 +22,7 @@ export function createFormAction< const formAction = async (input: FormData): Promise => { 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) diff --git a/packages/openapi/src/fetch/openapi-handler.ts b/packages/openapi/src/fetch/openapi-handler.ts index c8ed8e40..47363147 100644 --- a/packages/openapi/src/fetch/openapi-handler.ts +++ b/packages/openapi/src/fetch/openapi-handler.ts @@ -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' @@ -11,6 +12,7 @@ import { CompositeSchemaCoercer, type SchemaCoercer } from './schema-coercer' export type OpenAPIHandlerOptions = & Hooks & { + jsonSerializer?: PublicJSONSerializer procedureMatcher?: PublicOpenAPIProcedureMatcher payloadCodec?: PublicOpenAPIPayloadCodec inputBuilderSimple?: PublicInputBuilderSimple @@ -30,8 +32,10 @@ export class OpenAPIHandler implements ConditionalFetchHandle router: Router, private readonly options?: NoInfer>, ) { + 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 ?? []) diff --git a/packages/openapi/src/fetch/openapi-payload-codec.test.ts b/packages/openapi/src/fetch/openapi-payload-codec.test.ts index 9e4d6ff1..012f69b6 100644 --- a/packages/openapi/src/fetch/openapi-payload-codec.test.ts +++ b/packages/openapi/src/fetch/openapi-payload-codec.test.ts @@ -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 () => { diff --git a/packages/openapi/src/fetch/openapi-payload-codec.ts b/packages/openapi/src/fetch/openapi-payload-codec.ts index ed682247..71f4e4c3 100644 --- a/packages/openapi/src/fetch/openapi-payload-codec.ts +++ b/packages/openapi/src/fetch/openapi-payload-codec.ts @@ -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: '*/*' }] @@ -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 => @@ -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, - ) - } - async decode(re: Request | Response | Headers | URLSearchParams | FormData): Promise { if ( re instanceof Headers diff --git a/packages/openapi/src/generator.test.ts b/packages/openapi/src/generator.test.ts deleted file mode 100644 index 552f8488..00000000 --- a/packages/openapi/src/generator.test.ts +++ /dev/null @@ -1,761 +0,0 @@ -import type { OpenAPIObject } from 'openapi3-ts/oas31' -import { oc } from '@orpc/contract' -import { os } from '@orpc/server' -import { oz } from '@orpc/zod' -import OpenAPIParser from '@readme/openapi-parser' -import { z } from 'zod' -import { generateOpenAPI } from './generator' - -it('works', async () => { - const o = oc - - const router = o.router({ - ping: o.output(z.string()), - - user: { - find: o - .route({ method: 'GET', path: '/users/{id}', tags: ['user'] }) - .input(z.object({ id: z.string() })) - .output(z.object({ name: z.string() })), - }, - }) - - const spec = await generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toMatchObject({ - openapi: '3.1.0', - info: { - title: 'test', - version: '1.0.0', - }, - paths: { - '/ping': { - post: { - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - type: 'string', - }, - }, - }, - }, - }, - }, - }, - '/users/{id}': { - get: { - tags: ['user'], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }, - ], - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - name: { - type: 'string', - }, - }, - required: ['name'], - }, - }, - }, - }, - }, - }, - }, - }, - } satisfies OpenAPIObject) -}) - -it('throwOnMissingTagDefinition option', async () => { - const o = oc - - const router = o.router({ - ping: o.output(z.string()), - - user: { - find: o - .route({ method: 'GET', path: '/users/{id}', tags: ['user'] }) - .input(z.object({ id: z.string() })) - .output(z.object({ name: z.string() })), - }, - }) - - const spec = await generateOpenAPI( - { - router, - info: { - title: 'test', - version: '1.0.0', - }, - tags: [ - { - name: 'user', - description: 'User related apis', - }, - ], - }, - { throwOnMissingTagDefinition: true }, - ) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toMatchObject({ - openapi: '3.1.0', - info: { - title: 'test', - version: '1.0.0', - }, - tags: [ - { - name: 'user', - description: 'User related apis', - }, - ], - paths: { - '/ping': { - post: { - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - type: 'string', - }, - }, - }, - }, - }, - }, - }, - '/users/{id}': { - get: { - tags: ['user'], - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }, - ], - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - name: { - type: 'string', - }, - }, - required: ['name'], - }, - }, - }, - }, - }, - }, - }, - }, - } satisfies OpenAPIObject) - - expect(generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }, { throwOnMissingTagDefinition: true })) - .rejects - .toThrowError('Tag "user" is missing definition. Please define it in OpenAPI root tags object. [user.find]') -}) - -it('support single file upload', async () => { - const o = oc - - const router = o.router({ - upload: o - .input(z.union([z.string(), oz.file()])) - .output( - z.union([oz.file().type('image/jpg'), oz.file().type('image/png')]), - ), - }) - - const spec = await generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toMatchObject({ - paths: { - '/upload': { - post: { - requestBody: { - content: { - '*/*': { - schema: { - type: 'string', - contentMediaType: '*/*', - }, - }, - 'application/json': { - schema: { - type: 'string', - }, - }, - }, - }, - responses: { - 200: { - content: { - 'image/jpg': { - schema: { - type: 'string', - contentMediaType: 'image/jpg', - }, - }, - 'image/png': { - schema: { - type: 'string', - contentMediaType: 'image/png', - }, - }, - }, - }, - }, - }, - }, - }, - }) -}) - -it('support multipart/form-data', async () => { - const o = oc - - const router = o.router({ - resize: o - .input( - z.object({ - file: oz.file().type('image/*'), - height: z.number(), - width: z.number(), - }), - ) - .output(oz.file().type('image/*')), - }) - - const spec = await generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - expect(spec).toMatchObject({ - paths: { - '/resize': { - post: { - requestBody: { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - file: { - type: 'string', - contentMediaType: 'image/*', - }, - height: { - type: 'number', - }, - width: { - type: 'number', - }, - }, - required: ['file', 'height', 'width'], - }, - }, - }, - }, - responses: { - 200: { - content: { - 'image/*': { - schema: { - type: 'string', - contentMediaType: 'image/*', - }, - }, - }, - }, - }, - }, - }, - }, - }) -}) - -it('work with example', async () => { - const router = oc.router({ - upload: oc - .input( - z.object({ - set: z.set(z.string()), - map: z.map(z.string(), z.number()), - }), - { - set: new Set(['a', 'b', 'c']), - map: new Map([ - ['a', 1], - ['b', 2], - ['c', 3], - ]), - }, - ) - .output( - z.object({ - set: z.set(z.string()), - map: z.map(z.string(), z.number()), - }), - { - set: new Set(['a', 'b', 'c']), - map: new Map([ - ['a', 1], - ['b', 2], - ['c', 3], - ]), - }, - ), - }) - - const spec = await generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toMatchObject({ - paths: { - '/upload': { - post: { - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - set: { - type: 'array', - items: { - type: 'string', - }, - }, - map: { - type: 'array', - items: { - type: 'array', - prefixItems: [ - { - type: 'string', - }, - { - type: 'number', - }, - ], - maxItems: 2, - minItems: 2, - }, - }, - }, - required: ['set', 'map'], - }, - example: { - set: ['a', 'b', 'c'], - map: [ - ['a', 1], - ['b', 2], - ['c', 3], - ], - }, - }, - }, - }, - responses: { - 200: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - set: { - type: 'array', - items: { - type: 'string', - }, - }, - map: { - type: 'array', - items: { - type: 'array', - prefixItems: [ - { - type: 'string', - }, - { - type: 'number', - }, - ], - maxItems: 2, - minItems: 2, - }, - }, - }, - required: ['set', 'map'], - }, - example: { - set: ['a', 'b', 'c'], - map: [ - ['a', 1], - ['b', 2], - ['c', 3], - ], - }, - }, - }, - }, - }, - }, - }, - }, - }) -}) - -it('should remove params on body', async () => { - const router = oc.router({ - upload: oc.route({ method: 'POST', path: '/upload/{id}' }).input( - oz.openapi( - z.object({ - id: z.number(), - file: z.string().url(), - }), - { - examples: [ - { - id: 123, - file: 'https://example.com/file.png', - }, - ], - }, - ), - ), - }) - - const spec = await generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toEqual({ - info: { title: 'test', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/upload/{id}': { - post: { - summary: undefined, - description: undefined, - deprecated: undefined, - tags: undefined, - operationId: 'upload', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { examples: [123], type: 'number' }, - example: undefined, - }, - ], - requestBody: { - required: false, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { file: { type: 'string', format: 'uri' } }, - required: ['file'], - examples: [{ file: 'https://example.com/file.png' }], - }, - example: undefined, - }, - }, - }, - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { schema: {}, example: undefined }, - }, - }, - }, - }, - }, - }, - }) -}) - -it('should remove params on query', async () => { - const router = oc.router({ - upload: oc.route({ method: 'GET', path: '/upload/{id}' }).input( - oz.openapi( - z.object({ - id: z.number(), - file: z.string().url(), - object: z - .object({ - name: z.string(), - }) - .optional(), - }), - { - examples: [ - { - id: 123, - file: 'https://example.com/file.png', - object: { name: 'test' }, - }, - { - id: 456, - file: 'https://example.com/file2.png', - }, - ], - }, - ), - ), - }) - - const spec = await generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toEqual({ - info: { title: 'test', version: '1.0.0' }, - openapi: '3.1.0', - paths: { - '/upload/{id}': { - get: { - summary: undefined, - description: undefined, - deprecated: undefined, - tags: undefined, - operationId: 'upload', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { examples: [123, 456], type: 'number' }, - example: undefined, - }, - { - name: 'file', - in: 'query', - style: 'deepObject', - required: true, - schema: { - examples: [ - 'https://example.com/file.png', - 'https://example.com/file2.png', - ], - type: 'string', - format: 'uri', - }, - example: undefined, - }, - { - name: 'object', - in: 'query', - style: 'deepObject', - required: false, - schema: { - examples: [{ name: 'test' }], - anyOf: undefined, - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'], - }, - example: undefined, - }, - ], - requestBody: undefined, - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { schema: {}, example: undefined }, - }, - }, - }, - }, - }, - }, - }) -}) - -it('works with lazy', async () => { - const ping = os.input(z.string()).output(z.string()).func(() => 'pong') - - const lazyRouter = os.lazy(() => Promise.resolve({ default: { - ping, - } })) - - const router = os.router({ - ping, - lazyRouter, - }) - - const spec = await generateOpenAPI({ - router, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toMatchObject({ - openapi: '3.1.0', - info: { - title: 'test', - version: '1.0.0', - }, - paths: { - '/ping': { - post: { - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - type: 'string', - }, - }, - }, - }, - }, - }, - }, - '/lazyRouter/ping': { - post: { - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: { - type: 'string', - }, - }, - }, - }, - }, - }, - }, - }, - } satisfies OpenAPIObject) -}) - -it('works will use contract instead of implemented', async () => { - const contract = oc.router({ - ping: oc.route({ path: '/contract' }), - }) - - const implemented = os.contract(contract).router({ - ping: os.route({ path: '/implemented' }).func(() => 'pong'), - }) - - const spec = await generateOpenAPI({ - router: implemented, - info: { - title: 'test', - version: '1.0.0', - }, - }) - - await OpenAPIParser.validate(spec as any) - - expect(spec).toMatchObject({ - openapi: '3.1.0', - info: { - title: 'test', - version: '1.0.0', - }, - paths: { - '/contract': { - post: { - responses: { - 200: { - description: 'OK', - content: { - 'application/json': { - schema: {}, - }, - }, - }, - }, - }, - }, - }, - } satisfies OpenAPIObject) -}) diff --git a/packages/openapi/src/generator.ts b/packages/openapi/src/generator.ts deleted file mode 100644 index 83b011c7..00000000 --- a/packages/openapi/src/generator.ts +++ /dev/null @@ -1,372 +0,0 @@ -import type { JSONSchema } from 'json-schema-typed/draft-2020-12' -import type { PublicOpenAPIPayloadCodec } from './fetch' -import type { EachLeafOptions } from './utils' -import { type ContractRouter, isContractProcedure } from '@orpc/contract' -import { type ANY_ROUTER, unlazy } from '@orpc/server' -import { findDeepMatches, isPlainObject, omit } from '@orpc/shared' -import { - type MediaTypeObject, - OpenApiBuilder, - type OpenAPIObject, - type OperationObject, - type ParameterObject, - type RequestBodyObject, - type ResponseObject, -} from 'openapi3-ts/oas31' -import { OpenAPIPayloadCodec } from './fetch' -import { forEachContractProcedure, standardizeHTTPPath } from './utils' -import { - extractJSONSchema, - UNSUPPORTED_JSON_SCHEMA, - zodToJsonSchema, -} from './zod-to-json-schema' - -// Reference: https://spec.openapis.org/oas/v3.1.0.html#style-values - -export interface GenerateOpenAPIOptions { - /** - * Throw error when you missing define tag definition on OpenAPI root tags - * - * Example: if procedure has tags ['foo', 'bar'], and OpenAPI root tags is ['foo'], then error will be thrown - * Because OpenAPI root tags is missing 'bar' tag - * - * @default false - */ - throwOnMissingTagDefinition?: boolean - - /** - * Weather ignore procedures that has no path defined. - * - * @default false - */ - ignoreUndefinedPathProcedures?: boolean - - payloadCodec?: PublicOpenAPIPayloadCodec -} - -export async function generateOpenAPI( - opts: { - router: ContractRouter | ANY_ROUTER - } & Omit, - options?: GenerateOpenAPIOptions, -): Promise { - const throwOnMissingTagDefinition - = options?.throwOnMissingTagDefinition ?? false - const ignoreUndefinedPathProcedures - = options?.ignoreUndefinedPathProcedures ?? false - const payloadCodec = options?.payloadCodec ?? new OpenAPIPayloadCodec() - - const builder = new OpenApiBuilder({ - ...omit(opts, ['router']), - openapi: '3.1.0', - }) - - const rootTags = opts.tags?.map(tag => tag.name) ?? [] - - const pending: EachLeafOptions[] = [{ - path: [], - router: opts.router, - }] - - for (const item of pending) { - const lazies = forEachContractProcedure(item, ({ contract, path }) => { - if (!isContractProcedure(contract)) { - return - } - - const internal = contract['~orpc'] - - if (ignoreUndefinedPathProcedures && internal.route?.path === undefined) { - return - } - - const httpPath = internal.route?.path - ? standardizeHTTPPath(internal.route?.path) - : `/${path.map(encodeURIComponent).join('/')}` - - const method = internal.route?.method ?? 'POST' - - let inputSchema = internal.InputSchema - ? zodToJsonSchema(internal.InputSchema, { mode: 'input' }) - : {} - const outputSchema = internal.OutputSchema - ? zodToJsonSchema(internal.OutputSchema, { mode: 'output' }) - : {} - - const params: ParameterObject[] | undefined = (() => { - const names = httpPath.match(/\{([^}]+)\}/g) - - if (!names || !names.length) { - return undefined - } - - if (typeof inputSchema !== 'object' || inputSchema.type !== 'object') { - throw new Error( - `When path has parameters, input schema must be an object [${path.join('.')}]`, - ) - } - - return names - .map(raw => raw.slice(1, -1)) - .map((name) => { - let schema = inputSchema.properties?.[name] - const required = inputSchema.required?.includes(name) - - if (schema === undefined) { - throw new Error( - `Parameter ${name} is missing in input schema [${path.join('.')}]`, - ) - } - - if (!required) { - throw new Error( - `Parameter ${name} must be required in input schema [${path.join('.')}]`, - ) - } - - const examples = inputSchema.examples - ?.filter((example) => { - return isPlainObject(example) && name in example - }) - .map((example) => { - return example[name] - }) - - schema = { - examples: examples?.length ? examples : undefined, - ...(schema === true - ? {} - : schema === false - ? UNSUPPORTED_JSON_SCHEMA - : schema), - } - - inputSchema = { - ...inputSchema, - properties: inputSchema.properties - ? Object.entries(inputSchema.properties).reduce( - (acc, [key, value]) => { - if (key !== name) { - acc[key] = value - } - - return acc - }, - {} as Record, - ) - : undefined, - required: inputSchema.required?.filter(v => v !== name), - examples: inputSchema.examples?.map((example) => { - if (!isPlainObject(example)) - return example - - return Object.entries(example).reduce( - (acc, [key, value]) => { - if (key !== name) { - acc[key] = value - } - - return acc - }, - {} as Record, - ) - }), - } - - return { - name, - in: 'path', - required: true, - schema: schema as any, - example: (internal.inputExample as any)?.[name], - } - }) - })() - - const query: ParameterObject[] | undefined = (() => { - if (method !== 'GET' || Object.keys(inputSchema).length === 0) { - return undefined - } - - if (typeof inputSchema !== 'object' || inputSchema.type !== 'object') { - throw new Error( - `When method is GET, input schema must be an object [${path.join('.')}]`, - ) - } - - return Object.entries(inputSchema.properties ?? {}).map( - ([name, schema]) => { - const examples = inputSchema.examples - ?.filter((example) => { - return isPlainObject(example) && name in example - }) - .map((example) => { - return example[name] - }) - - const schema_ = { - examples: examples?.length ? examples : undefined, - ...(schema === true - ? {} - : schema === false - ? UNSUPPORTED_JSON_SCHEMA - : schema), - } - - return { - name, - in: 'query', - style: 'deepObject', - required: inputSchema?.required?.includes(name) ?? false, - schema: schema_ as any, - example: (internal.inputExample as any)?.[name], - } - }, - ) - })() - - const parameters = [...(params ?? []), ...(query ?? [])] - - const requestBody: RequestBodyObject | undefined = (() => { - if (method === 'GET') { - return undefined - } - - const { schema, matches } = extractJSONSchema(inputSchema, isFileSchema) - - const files = matches as (JSONSchema & { - type: 'string' - contentMediaType: string - })[] - - const isStillHasFileSchema - = findDeepMatches(isFileSchema, schema).values.length > 0 - - if (files.length) { - parameters.push({ - name: 'content-disposition', - in: 'header', - required: schema === undefined, - schema: { - type: 'string', - pattern: 'filename', - example: 'filename="file.png"', - description: - 'To define the file name. Required when the request body is a file.', - }, - }) - } - - const content: Record = {} - - for (const file of files) { - content[file.contentMediaType] = { - schema: file as any, - } - } - - if (schema !== undefined) { - content[ - isStillHasFileSchema ? 'multipart/form-data' : 'application/json' - ] = { - schema: schema as any, - example: (internal.inputExample as any), - } - } - - return { - required: Boolean( - internal.InputSchema - && 'isOptional' in internal.InputSchema - && typeof internal.InputSchema.isOptional === 'function' - ? internal.InputSchema.isOptional() - : false, - ), - content, - } - })() - - const successResponse: ResponseObject = (() => { - const { schema, matches } = extractJSONSchema(outputSchema, isFileSchema) - const files = matches as (JSONSchema & { - type: 'string' - contentMediaType: string - })[] - - const isStillHasFileSchema - = findDeepMatches(isFileSchema, schema).values.length > 0 - - const content: Record = {} - - for (const file of files) { - content[file.contentMediaType] = { - schema: file as any, - } - } - - if (schema !== undefined) { - content[ - isStillHasFileSchema ? 'multipart/form-data' : 'application/json' - ] = { - schema: schema as any, - example: internal.outputExample, - } - } - - return { - description: 'OK', - content, - } - })() - - if (throwOnMissingTagDefinition && internal.route?.tags) { - const missingTag = internal.route?.tags.find(tag => !rootTags.includes(tag)) - - if (missingTag !== undefined) { - throw new Error( - `Tag "${missingTag}" is missing definition. Please define it in OpenAPI root tags object. [${path.join('.')}]`, - ) - } - } - - const operation: OperationObject = { - summary: internal.route?.summary, - description: internal.route?.description, - deprecated: internal.route?.deprecated, - tags: internal.route?.tags ? [...internal.route.tags] : undefined, - operationId: path.join('.'), - parameters: parameters.length ? parameters : undefined, - requestBody, - responses: { - 200: successResponse, - }, - } - - builder.addPath(httpPath, { - [method.toLocaleLowerCase()]: operation, - }) - }) - - for (const lazy of lazies) { - const { default: router } = await unlazy(lazy.router) - - pending.push({ - path: lazy.path, - router, - }) - } - } - - return payloadCodec.serialize(builder.getSpec()) as OpenAPIObject -} - -function isFileSchema(schema: unknown) { - if (typeof schema !== 'object' || schema === null) - return false - return ( - 'type' in schema - && 'contentMediaType' in schema - && typeof schema.type === 'string' - && typeof schema.contentMediaType === 'string' - ) -} diff --git a/packages/openapi/src/index.ts b/packages/openapi/src/index.ts index e8fb1df1..f8c24bd5 100644 --- a/packages/openapi/src/index.ts +++ b/packages/openapi/src/index.ts @@ -1,3 +1,12 @@ /** unnoq */ -export * from './generator' +export * from './json-serializer' +export * from './openapi' +export * from './openapi-content-builder' +export * from './openapi-generator' +export * from './openapi-parameters-builder' +export * from './openapi-path-parser' +export * from './schema' +export * from './schema-converter' +export * from './schema-utils' +export * from './utils' diff --git a/packages/openapi/src/json-serializer.ts b/packages/openapi/src/json-serializer.ts new file mode 100644 index 00000000..2e8a558b --- /dev/null +++ b/packages/openapi/src/json-serializer.ts @@ -0,0 +1,36 @@ +import { isPlainObject } from '@orpc/shared' + +export class JSONSerializer { + 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, + ) + } +} + +export type PublicJSONSerializer = Pick diff --git a/packages/openapi/src/openapi-content-builder.ts b/packages/openapi/src/openapi-content-builder.ts new file mode 100644 index 00000000..41f30510 --- /dev/null +++ b/packages/openapi/src/openapi-content-builder.ts @@ -0,0 +1,44 @@ +import type { OpenAPI } from './openapi' +import type { JSONSchema } from './schema' +import type { PublicSchemaUtils } from './schema-utils' +import { findDeepMatches } from '@orpc/shared' + +export class OpenAPIContentBuilder { + constructor( + private readonly schemaUtils: PublicSchemaUtils, + ) {} + + build(jsonSchema: JSONSchema.JSONSchema, options?: Partial): OpenAPI.ContentObject { + const isFileSchema = this.schemaUtils.isFileSchema.bind(this.schemaUtils) + + const [matches, schema] = this.schemaUtils.filterSchemaBranches(jsonSchema, isFileSchema) + + const files = matches as (JSONSchema.JSONSchema & { + type: 'string' + contentMediaType: string + })[] + + const content: OpenAPI.ContentObject = {} + + for (const file of files) { + content[file.contentMediaType] = { + schema: file as any, + } + } + + const isStillHasFileSchema = findDeepMatches(isFileSchema as any /** TODO */, schema).values.length > 0 + + if (schema !== undefined) { + content[ + isStillHasFileSchema ? 'multipart/form-data' : 'application/json' + ] = { + schema: schema as any, + ...options, + } + } + + return content + } +} + +export type PublicOpenAPIContentBuilder = Pick diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts new file mode 100644 index 00000000..4a7038c5 --- /dev/null +++ b/packages/openapi/src/openapi-generator.ts @@ -0,0 +1,202 @@ +import type { ContractRouter } from '@orpc/contract' +import type { ANY_ROUTER } from '@orpc/server' +import type { PublicOpenAPIPathParser } from './openapi-path-parser' +import type { SchemaConverter } from './schema-converter' +import { JSONSerializer, type PublicJSONSerializer } from './json-serializer' +import { type OpenAPI, OpenApiBuilder } from './openapi' +import { OpenAPIContentBuilder, type PublicOpenAPIContentBuilder } from './openapi-content-builder' +import { OpenAPIParametersBuilder, type PublicOpenAPIParametersBuilder } from './openapi-parameters-builder' +import { OpenAPIPathParser } from './openapi-path-parser' +import { CompositeSchemaConverter } from './schema-converter' +import { type PublicSchemaUtils, SchemaUtils } from './schema-utils' +import { forEachAllContractProcedure, standardizeHTTPPath } from './utils' + +export interface OpenAPIGeneratorOptions { + contentBuilder?: PublicOpenAPIContentBuilder + parametersBuilder?: PublicOpenAPIParametersBuilder + schemaConverters?: SchemaConverter[] + schemaUtils?: PublicSchemaUtils + jsonSerializer?: PublicJSONSerializer + pathParser?: PublicOpenAPIPathParser + + /** + * Throw error when you missing define tag definition on OpenAPI root tags + * + * Example: if procedure has tags ['foo', 'bar'], and OpenAPI root tags is ['foo'], then error will be thrown + * Because OpenAPI root tags is missing 'bar' tag + * + * @default false + */ + considerMissingTagDefinitionAsError?: boolean + + /** + * Weather ignore procedures that has no path defined. + * + * @default false + */ + ignoreUndefinedPathProcedures?: boolean + + /** + * Throw error when you have error in OpenAPI generator + * + * @default false + */ + throwOnError?: boolean +} + +export class OpenAPIGenerator { + private readonly contentBuilder: PublicOpenAPIContentBuilder + private readonly parametersBuilder: PublicOpenAPIParametersBuilder + private readonly schemaConverter: CompositeSchemaConverter + private readonly schemaUtils: PublicSchemaUtils + private readonly jsonSerializer: PublicJSONSerializer + private readonly pathParser: PublicOpenAPIPathParser + + constructor(private readonly options?: OpenAPIGeneratorOptions) { + this.parametersBuilder = options?.parametersBuilder ?? new OpenAPIParametersBuilder() + this.schemaConverter = new CompositeSchemaConverter(options?.schemaConverters ?? []) + this.schemaUtils = options?.schemaUtils ?? new SchemaUtils() + this.jsonSerializer = options?.jsonSerializer ?? new JSONSerializer() + this.contentBuilder = options?.contentBuilder ?? new OpenAPIContentBuilder(this.schemaUtils) + this.pathParser = new OpenAPIPathParser() + } + + async generate(router: ContractRouter | ANY_ROUTER, doc: Omit): Promise { + const builder = new OpenApiBuilder({ + ...doc, + openapi: '3.1.1', + }) + + const rootTags = doc.tags?.map(tag => tag.name) ?? [] + + await forEachAllContractProcedure(router, ({ contract, path }) => { + const def = contract['~orpc'] + + if (this.options?.ignoreUndefinedPathProcedures && def.route?.path === undefined) { + return + } + + const method = def.route?.method ?? 'POST' + const httpPath = def.route?.path ? standardizeHTTPPath(def.route?.path) : `/${path.map(encodeURIComponent).join('/')}` + + let inputSchema = this.schemaConverter.convert(def.InputSchema, { strategy: 'input' }) + const outputSchema = this.schemaConverter.convert(def.OutputSchema, { strategy: 'output' }) + + const params: OpenAPI.ParameterObject[] | undefined = (() => { + const dynamic = this.pathParser.parseDynamicParams(httpPath) + + if (!dynamic.length) { + return undefined + } + + if (this.schemaUtils.isAnySchema(inputSchema)) { + return undefined + } + + if (!this.schemaUtils.isObjectSchema(inputSchema)) { + this.handleError( + new Error( + `When path has parameters, input schema must be an object [${path.join('.')}]`, + ), + ) + + return undefined + } + + const [matched, rest] = this.schemaUtils.separateObjectSchema(inputSchema, dynamic.map(v => v.name)) + + inputSchema = rest + + return this.parametersBuilder.build('path', matched, { + example: def.inputExample, + required: true, + }) + })() + + const query: OpenAPI.ParameterObject[] | undefined = (() => { + if (method !== 'GET' || Object.keys(inputSchema).length === 0) { + return undefined + } + + if (this.schemaUtils.isAnySchema(inputSchema)) { + return undefined + } + + if (!this.schemaUtils.isObjectSchema(inputSchema)) { + this.handleError( + new Error( + `When method is GET, input schema must be an object [${path.join('.')}]`, + ), + ) + + return undefined + } + + return this.parametersBuilder.build('query', inputSchema, { + example: def.inputExample, + }) + })() + + const parameters = [...(params ?? []), ...(query ?? [])] + + const requestBody: OpenAPI.RequestBodyObject | undefined = (() => { + if (method === 'GET') { + return undefined + } + + return { + required: this.schemaUtils.isUndefinableSchema(inputSchema), + content: this.contentBuilder.build(inputSchema, { + example: def.inputExample, + }), + } + })() + + const successResponse: OpenAPI.ResponseObject = { + description: 'OK', + content: this.contentBuilder.build(outputSchema, { + example: def.outputExample, + }), + } + + if (this.options?.considerMissingTagDefinitionAsError && def.route?.tags) { + const missingTag = def.route?.tags.find(tag => !rootTags.includes(tag)) + + if (missingTag !== undefined) { + this.handleError( + new Error( + `Tag "${missingTag}" is missing definition. Please define it in OpenAPI root tags object. [${path.join('.')}]`, + ), + ) + } + } + + const operation: OpenAPI.OperationObject = { + summary: def.route?.summary, + description: def.route?.description, + deprecated: def.route?.deprecated, + tags: def.route?.tags ? [...def.route.tags] : undefined, + operationId: path.join('.'), + parameters: parameters.length ? parameters : undefined, + requestBody, + responses: { + 200: successResponse, + }, + } + + builder.addPath(httpPath, { + [method.toLocaleLowerCase()]: operation, + }) + }) + + return this.jsonSerializer.serialize(builder.getSpec()) as OpenAPI.OpenAPIObject + } + + private handleError(error: Error): void { + if (this.options?.throwOnError) { + throw error + } + + console.error(error) + } +} diff --git a/packages/openapi/src/openapi-parameters-builder.ts b/packages/openapi/src/openapi-parameters-builder.ts new file mode 100644 index 00000000..a2c2fb3b --- /dev/null +++ b/packages/openapi/src/openapi-parameters-builder.ts @@ -0,0 +1,49 @@ +import type { OpenAPI } from './openapi' +import type { JSONSchema } from './schema' +import { get, isPlainObject } from '@orpc/shared' + +export class OpenAPIParametersBuilder { + build( + paramIn: OpenAPI.ParameterObject['in'], + jsonSchema: JSONSchema.JSONSchema & { type: 'object' } & object, + options?: Pick, + ): OpenAPI.ParameterObject[] { + const parameters: OpenAPI.ParameterObject[] = [] + + for (const name in jsonSchema.properties) { + const schema = jsonSchema.properties[name]! + + const paramExamples = jsonSchema.examples + ?.filter((example) => { + return isPlainObject(example) && name in example + }) + .map((example) => { + return example[name] + }) + + const paramSchema = { + examples: paramExamples?.length ? paramExamples : undefined, + ...(schema === true + ? {} + : schema === false + ? { not: {} } + : schema), + } + + const paramExample = get(options?.example, [name]) + + parameters.push({ + name, + in: paramIn, + required: typeof options?.required === 'boolean' ? options.required : jsonSchema.required?.includes(name) ?? false, + schema: paramSchema as any, + example: paramExample, + style: options?.style, + }) + } + + return parameters + } +} + +export type PublicOpenAPIParametersBuilder = Pick diff --git a/packages/openapi/src/openapi-path-parser.ts b/packages/openapi/src/openapi-path-parser.ts new file mode 100644 index 00000000..91e31b55 --- /dev/null +++ b/packages/openapi/src/openapi-path-parser.ts @@ -0,0 +1,12 @@ +export class OpenAPIPathParser { + parseDynamicParams(path: string): { name: string, raw: string }[] { + const raws = path.match(/\{([^}]+)\}/g) ?? [] + + return raws.map((raw) => { + const name = raw.slice(1, -1).split(':')[0]! // : can be used in the future for more complex routing + return { name, raw } + }) + } +} + +export type PublicOpenAPIPathParser = Pick diff --git a/packages/openapi/src/openapi.ts b/packages/openapi/src/openapi.ts new file mode 100644 index 00000000..732f3502 --- /dev/null +++ b/packages/openapi/src/openapi.ts @@ -0,0 +1,2 @@ +export { OpenApiBuilder } from 'openapi3-ts/oas31' +export type * as OpenAPI from 'openapi3-ts/oas31' diff --git a/packages/openapi/src/schema-converter.ts b/packages/openapi/src/schema-converter.ts new file mode 100644 index 00000000..9b21dbec --- /dev/null +++ b/packages/openapi/src/schema-converter.ts @@ -0,0 +1,34 @@ +import type { Schema } from '@orpc/contract' +import type { JSONSchema } from './schema' + +export interface SchemaConvertOptions { + strategy: 'input' | 'output' +} + +export interface SchemaConverter { + condition: (schema: Schema, options: SchemaConvertOptions) => boolean + + convert: (schema: Schema, options: SchemaConvertOptions) => JSONSchema.JSONSchema +} + +export class CompositeSchemaConverter implements SchemaConverter { + private readonly converters: SchemaConverter[] + + constructor(converters: SchemaConverter[]) { + this.converters = converters + } + + condition(): boolean { + return true + } + + convert(schema: Schema, options: SchemaConvertOptions): JSONSchema.JSONSchema { + for (const converter of this.converters) { + if (converter.condition(schema, options)) { + return converter.convert(schema, options) + } + } + + return {} // ANY SCHEMA + } +} diff --git a/packages/openapi/src/schema-utils.ts b/packages/openapi/src/schema-utils.ts new file mode 100644 index 00000000..44483004 --- /dev/null +++ b/packages/openapi/src/schema-utils.ts @@ -0,0 +1,138 @@ +import { isPlainObject } from '@orpc/shared' +import { type FileSchema, type JSONSchema, NON_LOGIC_KEYWORDS, type ObjectSchema } from './schema' + +export class SchemaUtils { + isFileSchema(schema: JSONSchema.JSONSchema): schema is FileSchema { + return typeof schema === 'object' && schema.type === 'string' && typeof schema.contentMediaType === 'string' + } + + isObjectSchema(schema: JSONSchema.JSONSchema): schema is ObjectSchema { + return typeof schema === 'object' && schema.type === 'object' + } + + isAnySchema(schema: JSONSchema.JSONSchema): boolean { + return schema === true || Object.keys(schema).length === 0 + } + + isUndefinableSchema(schema: JSONSchema.JSONSchema): boolean { + const [matches] = this.filterSchemaBranches(schema, (schema) => { + if (typeof schema === 'boolean') { + return schema + } + + return Object.keys(schema).length === 0 + }) + + return matches.length > 0 + } + + separateObjectSchema(schema: ObjectSchema, separatedProperties: string[]): [matched: ObjectSchema, rest: ObjectSchema] { + const matched = { ...schema } + const rest = { ...schema } + + matched.properties = Object.entries(schema.properties ?? {}) + .filter(([key]) => separatedProperties.includes(key)) + .reduce((acc, [key, value]) => { + acc[key] = value + return acc + }, {} as Record) + + matched.required = schema.required?.filter(key => separatedProperties.includes(key)) + + matched.examples = schema.examples?.map((example) => { + if (!isPlainObject(example)) { + return example + } + + return Object.entries(example).reduce((acc, [key, value]) => { + if (separatedProperties.includes(key)) { + acc[key] = value + } + + return acc + }, {} as Record) + }) + + rest.properties = Object.entries(schema.properties ?? {}) + .filter(([key]) => !separatedProperties.includes(key)) + .reduce((acc, [key, value]) => { + acc[key] = value + return acc + }, {} as Record) + + rest.required = schema.required?.filter(key => !separatedProperties.includes(key)) + + rest.examples = schema.examples?.map((example) => { + if (!isPlainObject(example)) { + return example + } + + return Object.entries(example).reduce((acc, [key, value]) => { + if (!separatedProperties.includes(key)) { + acc[key] = value + } + + return acc + }, {} as Record) + }) + + return [matched, rest] + } + + filterSchemaBranches( + schema: JSONSchema.JSONSchema, + check: (schema: JSONSchema.JSONSchema) => boolean, + matches: JSONSchema.JSONSchema[] = [], + ): [matches: JSONSchema.JSONSchema[], rest: JSONSchema.JSONSchema | undefined] { + if (check(schema)) { + matches.push(schema) + return [matches, undefined] + } + + if (typeof schema === 'boolean') { + return [matches, schema] + } + + // TODO: $ref + + if ( + schema.anyOf + && Object.keys(schema).every( + k => k === 'anyOf' || NON_LOGIC_KEYWORDS.includes(k as any), + ) + ) { + const anyOf = schema.anyOf + .map(s => this.filterSchemaBranches(s, check, matches)[1]) + .filter(v => !!v) + + if (anyOf.length === 1 && typeof anyOf[0] === 'object') { + return [matches, { ...schema, anyOf: undefined, ...anyOf[0] }] + } + + return [matches, { ...schema, anyOf }] + } + + // TODO: $ref + + if ( + schema.oneOf + && Object.keys(schema).every( + k => k === 'oneOf' || NON_LOGIC_KEYWORDS.includes(k as any), + ) + ) { + const oneOf = schema.oneOf + .map(s => this.filterSchemaBranches(s, check, matches)[1]) + .filter(v => !!v) + + if (oneOf.length === 1 && typeof oneOf[0] === 'object') { + return [matches, { ...schema, oneOf: undefined, ...oneOf[0] }] + } + + return [matches, { ...schema, oneOf }] + } + + return [matches, schema] + } +} + +export type PublicSchemaUtils = Pick diff --git a/packages/openapi/src/schema.ts b/packages/openapi/src/schema.ts new file mode 100644 index 00000000..dddf5370 --- /dev/null +++ b/packages/openapi/src/schema.ts @@ -0,0 +1,38 @@ +import * as JSONSchema from 'json-schema-typed/draft-2020-12' + +export { Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12' +export { JSONSchema } + +export type ObjectSchema = JSONSchema.JSONSchema & { type: 'object' } & object +export type FileSchema = JSONSchema.JSONSchema & { type: 'string', contentMediaType: string } & object + +export const NON_LOGIC_KEYWORDS = [ + // Core Documentation Keywords + '$anchor', + '$comment', + '$defs', + '$id', + 'title', + 'description', + + // Value Keywords + 'default', + 'deprecated', + 'examples', + + // Metadata Keywords + '$schema', + 'definitions', // Legacy, but still used + 'readOnly', + 'writeOnly', + + // Display and UI Hints + 'contentMediaType', + 'contentEncoding', + 'format', + + // Custom Extensions + '$vocabulary', + '$dynamicAnchor', + '$dynamicRef', +] satisfies (typeof JSONSchema.keywords)[number][] diff --git a/packages/openapi/src/utils.ts b/packages/openapi/src/utils.ts index 44efc0bd..8a6ec3f6 100644 --- a/packages/openapi/src/utils.ts +++ b/packages/openapi/src/utils.ts @@ -1,7 +1,7 @@ import type { ContractRouter, HTTPPath, WELL_CONTRACT_PROCEDURE } from '@orpc/contract' import type { ANY_PROCEDURE, ANY_ROUTER, Lazy } from '@orpc/server' import { isContractProcedure } from '@orpc/contract' -import { getRouterContract, isLazy, isProcedure } from '@orpc/server' +import { getRouterContract, isLazy, isProcedure, unlazy } from '@orpc/server' export interface EachLeafOptions { router: ContractRouter | ANY_ROUTER @@ -78,6 +78,29 @@ export function forEachContractProcedure( return result } +export async function forEachAllContractProcedure( + router: ContractRouter | ANY_ROUTER, + callback: (options: EachLeafCallbackOptions) => void, +) { + const pending: EachLeafOptions[] = [{ + path: [], + router, + }] + + for (const item of pending) { + const lazies = forEachContractProcedure(item, callback) + + for (const lazy of lazies) { + const { default: router } = await unlazy(lazy.router) + + pending.push({ + path: lazy.path, + router, + }) + } + } +} + export function standardizeHTTPPath(path: HTTPPath): HTTPPath { return `/${path.replace(/\/{2,}/g, '/').replace(/^\/|\/$/g, '')}` } diff --git a/packages/openapi/src/zod-to-json-schema.test.ts b/packages/zod/src/converter.test.ts similarity index 99% rename from packages/openapi/src/zod-to-json-schema.test.ts rename to packages/zod/src/converter.test.ts index 415ff2ac..20754cc8 100644 --- a/packages/openapi/src/zod-to-json-schema.test.ts +++ b/packages/zod/src/converter.test.ts @@ -2,7 +2,7 @@ import { oz } from '@orpc/zod' import { Format } from 'json-schema-typed/draft-2020-12' import { describe, expect, it } from 'vitest' import { z } from 'zod' -import { zodToJsonSchema } from './zod-to-json-schema' +import { zodToJsonSchema } from './converter' describe('primitive types', () => { it('should convert string schema', () => { diff --git a/packages/openapi/src/zod-to-json-schema.ts b/packages/zod/src/converter.ts similarity index 87% rename from packages/openapi/src/zod-to-json-schema.ts rename to packages/zod/src/converter.ts index 7dfc34ae..aa5b97b1 100644 --- a/packages/openapi/src/zod-to-json-schema.ts +++ b/packages/zod/src/converter.ts @@ -1,16 +1,8 @@ +import type { Schema } from '@orpc/contract' +import type { JSONSchema, SchemaConverter, SchemaConvertOptions } from '@orpc/openapi' import type { StandardSchemaV1 } from '@standard-schema/spec' -import { - getCustomJSONSchema, - getCustomZodFileMimeType, - getCustomZodType, -} from '@orpc/zod' +import { JSONSchemaFormat } from '@orpc/openapi' import escapeStringRegexp from 'escape-string-regexp' -import { - Format, - type JSONSchema, - type keywords, -} from 'json-schema-typed/draft-2020-12' - import { type EnumLike, type KeySchema, @@ -43,6 +35,11 @@ import { type ZodUnion, type ZodUnionOptions, } from 'zod' +import { + getCustomJSONSchema, + getCustomZodFileMimeType, + getCustomZodType, +} from './schemas' export const NON_LOGIC_KEYWORDS = [ // Core Documentation Keywords @@ -73,7 +70,7 @@ export const NON_LOGIC_KEYWORDS = [ '$vocabulary', '$dynamicAnchor', '$dynamicRef', -] satisfies (typeof keywords)[number][] +] satisfies (typeof JSONSchema.keywords)[number][] export const UNSUPPORTED_JSON_SCHEMA = { not: {} } export const UNDEFINED_JSON_SCHEMA = { const: 'undefined' } @@ -113,7 +110,7 @@ export interface ZodToJsonSchemaOptions { export function zodToJsonSchema( schema: StandardSchemaV1, options?: ZodToJsonSchemaOptions, -): Exclude { +): Exclude { if (schema['~standard'].vendor !== 'zod') { console.warn(`Generate JSON schema not support ${schema['~standard'].vendor} yet`) return {} @@ -164,7 +161,7 @@ export function zodToJsonSchema( } case 'URL': { - return { type: 'string', format: Format.URI } + return { type: 'string', format: JSONSchemaFormat.URI } } } @@ -176,7 +173,7 @@ export function zodToJsonSchema( case ZodFirstPartyTypeKind.ZodString: { const schema_ = schema__ as ZodString - const json: JSONSchema = { type: 'string' } + const json: JSONSchema.JSONSchema = { type: 'string' } for (const check of schema_._def.checks) { switch (check.kind) { @@ -187,13 +184,13 @@ export function zodToJsonSchema( json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$' break case 'email': - json.format = Format.Email + json.format = JSONSchemaFormat.Email break case 'url': - json.format = Format.URI + json.format = JSONSchemaFormat.URI break case 'uuid': - json.format = Format.UUID + json.format = JSONSchemaFormat.UUID break case 'regex': json.pattern = check.regex.source @@ -231,19 +228,19 @@ export function zodToJsonSchema( json.pattern = '^[0-9A-HJKMNP-TV-Z]{26}$' break case 'datetime': - json.format = Format.DateTime + json.format = JSONSchemaFormat.DateTime break case 'date': - json.format = Format.Date + json.format = JSONSchemaFormat.Date break case 'time': - json.format = Format.Time + json.format = JSONSchemaFormat.Time break case 'duration': - json.format = Format.Duration + json.format = JSONSchemaFormat.Duration break case 'ip': - json.format = Format.IPv4 + json.format = JSONSchemaFormat.IPv4 break case 'jwt': json.pattern = '^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]*$' @@ -263,7 +260,7 @@ export function zodToJsonSchema( case ZodFirstPartyTypeKind.ZodNumber: { const schema_ = schema__ as ZodNumber - const json: JSONSchema = { type: 'number' } + const json: JSONSchema.JSONSchema = { type: 'number' } for (const check of schema_._def.checks) { switch (check.kind) { @@ -293,7 +290,7 @@ export function zodToJsonSchema( } case ZodFirstPartyTypeKind.ZodBigInt: { - const json: JSONSchema = { type: 'string', pattern: '^-?[0-9]+$' } + const json: JSONSchema.JSONSchema = { type: 'string', pattern: '^-?[0-9]+$' } // WARN: ignore checks @@ -305,11 +302,11 @@ export function zodToJsonSchema( } case ZodFirstPartyTypeKind.ZodDate: { - const jsonSchema: JSONSchema = { type: 'string', format: Format.Date } + const schema: JSONSchema.JSONSchema = { type: 'string', format: JSONSchemaFormat.Date } // WARN: ignore checks - return jsonSchema + return schema } case ZodFirstPartyTypeKind.ZodNull: { @@ -346,7 +343,7 @@ export function zodToJsonSchema( const schema_ = schema__ as ZodArray const def = schema_._def - const json: JSONSchema = { type: 'array' } + const json: JSONSchema.JSONSchema = { type: 'array' } if (def.exactLength) { json.maxItems = def.exactLength.value @@ -370,8 +367,8 @@ export function zodToJsonSchema( ZodTypeAny | null > - const prefixItems: JSONSchema[] = [] - const json: JSONSchema = { type: 'array' } + const prefixItems: JSONSchema.JSONSchema[] = [] + const json: JSONSchema.JSONSchema = { type: 'array' } for (const item of schema_._def.items) { prefixItems.push(zodToJsonSchema(item, childOptions)) @@ -394,8 +391,8 @@ export function zodToJsonSchema( case ZodFirstPartyTypeKind.ZodObject: { const schema_ = schema__ as ZodObject - const json: JSONSchema = { type: 'object' } - const properties: Record = {} + const json: JSONSchema.JSONSchema = { type: 'object' } + const properties: Record = {} const required: string[] = [] for (const [key, value] of Object.entries(schema_.shape)) { @@ -446,7 +443,7 @@ export function zodToJsonSchema( case ZodFirstPartyTypeKind.ZodRecord: { const schema_ = schema__ as ZodRecord - const json: JSONSchema = { type: 'object' } + const json: JSONSchema.JSONSchema = { type: 'object' } json.additionalProperties = zodToJsonSchema( schema_._def.valueType, @@ -488,7 +485,7 @@ export function zodToJsonSchema( | ZodUnion | ZodDiscriminatedUnion, ...ZodObject[]]> - const anyOf: JSONSchema[] = [] + const anyOf: JSONSchema.JSONSchema[] = [] for (const s of schema_._def.options) { anyOf.push(zodToJsonSchema(s, childOptions)) @@ -500,7 +497,7 @@ export function zodToJsonSchema( case ZodFirstPartyTypeKind.ZodIntersection: { const schema_ = schema__ as ZodIntersection - const allOf: JSONSchema[] = [] + const allOf: JSONSchema.JSONSchema[] = [] for (const s of [schema_._def.left, schema_._def.right]) { allOf.push(zodToJsonSchema(s, childOptions)) @@ -602,11 +599,11 @@ export function zodToJsonSchema( return UNSUPPORTED_JSON_SCHEMA } -export function extractJSONSchema( - schema: JSONSchema, - check: (schema: JSONSchema) => boolean, - matches: JSONSchema[] = [], -): { schema: JSONSchema | undefined, matches: JSONSchema[] } { +function extractJSONSchema( + schema: JSONSchema.JSONSchema, + check: (schema: JSONSchema.JSONSchema) => boolean, + matches: JSONSchema.JSONSchema[] = [], +): { schema: JSONSchema.JSONSchema | undefined, matches: JSONSchema.JSONSchema[] } { if (check(schema)) { matches.push(schema) return { schema: undefined, matches } @@ -668,3 +665,14 @@ export function extractJSONSchema( return { schema, matches } } + +export class ZodToJsonSchemaConverter implements SchemaConverter { + condition(schema: Schema): boolean { + return Boolean(schema && schema['~standard'].vendor === 'zod') + } + + convert(schema: Schema, options: SchemaConvertOptions): JSONSchema.JSONSchema { // TODO + const jsonSchema = schema as ZodTypeAny + return zodToJsonSchema(jsonSchema, { mode: options.strategy }) + } +} diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index b0f75c87..3c56341d 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,2 +1,3 @@ export * from './coercer' +export * from './converter' export * from './schemas' diff --git a/playgrounds/contract-openapi/src/main.ts b/playgrounds/contract-openapi/src/main.ts index f0f3c84d..237d284d 100644 --- a/playgrounds/contract-openapi/src/main.ts +++ b/playgrounds/contract-openapi/src/main.ts @@ -1,8 +1,8 @@ import { createServer } from 'node:http' -import { generateOpenAPI } from '@orpc/openapi' +import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIServerHandler } from '@orpc/openapi/fetch' import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' -import { ZodCoercer } from '@orpc/zod' +import { ZodCoercer, ZodToJsonSchemaConverter } from '@orpc/zod' import { createServerAdapter } from '@whatwg-node/server' import { contract } from './contract' import { router } from './router' @@ -23,6 +23,12 @@ const orpcHandler = new ORPCHandler(router, { }) const compositeHandler = new CompositeHandler([openAPIHandler, orpcHandler]) +const openAPIGenerator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], +}) + const server = createServer( createServerAdapter(async (request: Request) => { const url = new URL(request.url) @@ -39,8 +45,7 @@ const server = createServer( } if (url.pathname === '/spec.json') { - const spec = await generateOpenAPI({ - router: contract, + const spec = await openAPIGenerator.generate(contract, { info: { title: 'ORPC Playground', version: '1.0.0', diff --git a/playgrounds/expressjs/src/main.ts b/playgrounds/expressjs/src/main.ts index 53dd322e..fb4f631a 100644 --- a/playgrounds/expressjs/src/main.ts +++ b/playgrounds/expressjs/src/main.ts @@ -1,7 +1,7 @@ -import { generateOpenAPI } from '@orpc/openapi' +import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIServerHandler } from '@orpc/openapi/fetch' import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' -import { ZodCoercer } from '@orpc/zod' +import { ZodCoercer, ZodToJsonSchemaConverter } from '@orpc/zod' import { createServerAdapter } from '@whatwg-node/server' import express from 'express' import { router } from './router' @@ -37,10 +37,14 @@ app.all( }) }), ) +const openAPIGenerator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], +}) app.get('/spec.json', async (req, res) => { - const spec = await generateOpenAPI({ - router, + const spec = await openAPIGenerator.generate(router, { info: { title: 'ORPC Playground', version: '1.0.0', diff --git a/playgrounds/nextjs/src/app/spec/route.ts b/playgrounds/nextjs/src/app/spec/route.ts index 5d403eac..168c07e3 100644 --- a/playgrounds/nextjs/src/app/spec/route.ts +++ b/playgrounds/nextjs/src/app/spec/route.ts @@ -1,9 +1,16 @@ -import { generateOpenAPI } from '@orpc/openapi' +import { OpenAPIGenerator } from '@orpc/openapi' + +import { ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from '../api/[...rest]/router' +const openAPIGenerator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], +}) + export async function GET(request: Request) { - const spec = await generateOpenAPI({ - router, + const spec = await openAPIGenerator.generate(router, { info: { title: 'ORPC Playground', version: '1.0.0', diff --git a/playgrounds/nuxt/server/routes/spec.ts b/playgrounds/nuxt/server/routes/spec.ts index 102c9224..20242702 100644 --- a/playgrounds/nuxt/server/routes/spec.ts +++ b/playgrounds/nuxt/server/routes/spec.ts @@ -1,9 +1,15 @@ -import { generateOpenAPI } from '@orpc/openapi' +import { OpenAPIGenerator } from '@orpc/openapi' +import { ZodToJsonSchemaConverter } from '@orpc/zod' import { router } from '~/server/router' +const openAPIGenerator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], +}) + export default defineEventHandler(async (event) => { - const spec = await generateOpenAPI({ - router, + const spec = await openAPIGenerator.generate(router, { info: { title: 'ORPC Playground', version: '1.0.0', diff --git a/playgrounds/openapi/src/main.ts b/playgrounds/openapi/src/main.ts index 29c2a4da..92ba0692 100644 --- a/playgrounds/openapi/src/main.ts +++ b/playgrounds/openapi/src/main.ts @@ -1,8 +1,8 @@ import { createServer } from 'node:http' -import { generateOpenAPI } from '@orpc/openapi' +import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIServerHandler } from '@orpc/openapi/fetch' import { CompositeHandler, ORPCHandler } from '@orpc/server/fetch' -import { ZodCoercer } from '@orpc/zod' +import { ZodCoercer, ZodToJsonSchemaConverter } from '@orpc/zod' import { createServerAdapter } from '@whatwg-node/server' import { router } from './router' import './polyfill' @@ -22,6 +22,12 @@ const orpcHandler = new ORPCHandler(router, { }) const compositeHandler = new CompositeHandler([openAPIHandler, orpcHandler]) +const openAPIGenerator = new OpenAPIGenerator({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], +}) + const server = createServer( createServerAdapter(async (request: Request) => { const url = new URL(request.url) @@ -38,8 +44,7 @@ const server = createServer( } if (url.pathname === '/spec.json') { - const spec = await generateOpenAPI({ - router, + const spec = await openAPIGenerator.generate(router, { info: { title: 'ORPC Playground', version: '1.0.0',