diff --git a/apps/content/docs/openapi/openapi-handler.md b/apps/content/docs/openapi/openapi-handler.md index 62b47ccad..9849cc174 100644 --- a/apps/content/docs/openapi/openapi-handler.md +++ b/apps/content/docs/openapi/openapi-handler.md @@ -27,6 +27,7 @@ The `OpenAPIHandler` enables communication with clients over RESTful APIs, adher - **Blob** (unsupported in `AsyncIteratorObject`) - **File** (unsupported in `AsyncIteratorObject`) - **AsyncIteratorObject** (only at the root level; powers the [Event Iterator](/docs/event-iterator)) +- **ReadableStream\** (supported only at the root level in `OpenAPIHandler`; not supported by client-side `OpenAPILink` until v2) ::: warning If a payload contains `Blob` or `File` outside the root level, it must use `multipart/form-data`. In such cases, oRPC applies [Bracket Notation](/docs/openapi/bracket-notation) and converts other types to strings (exclude `null` and `undefined` will not be represented). diff --git a/packages/openapi/src/adapters/standard/openapi-codec.test.ts b/packages/openapi/src/adapters/standard/openapi-codec.test.ts index 575c8e319..f85040977 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.test.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.test.ts @@ -182,6 +182,26 @@ describe('standardOpenAPICodec', () => { expect(serializer.serialize).toHaveBeenCalledWith('__output__') }) + it('with ReadableStream bypasses serialization and respects successStatus', () => { + const procedure = new Procedure({ + ...ping['~orpc'], + route: { + successStatus: 202, + }, + }) + const stream = new ReadableStream() + + const response = codec.encode(stream, procedure) + + expect(response).toEqual({ + status: 202, + headers: {}, + body: stream, + }) + + expect(serializer.serialize).not.toHaveBeenCalled() + }) + describe('with detailed structure', async () => { const procedure = new Procedure({ ...ping['~orpc'], @@ -251,6 +271,24 @@ describe('standardOpenAPICodec', () => { expect(serializer.serialize).toHaveBeenCalledWith('__output__') }) + it('works with ReadableStream body', () => { + const stream = new ReadableStream() + const output = { + body: stream, + headers: { 'content-type': 'application/zip' }, + } + + const response = codec.encode(output, procedure) + + expect(response).toEqual({ + status: 298, + headers: { 'content-type': 'application/zip' }, + body: stream, + }) + + expect(serializer.serialize).not.toHaveBeenCalled() + }) + it.each([ 'invalid', { status: 'invalid' }, diff --git a/packages/openapi/src/adapters/standard/openapi-codec.ts b/packages/openapi/src/adapters/standard/openapi-codec.ts index 1bd20169b..9b719f557 100644 --- a/packages/openapi/src/adapters/standard/openapi-codec.ts +++ b/packages/openapi/src/adapters/standard/openapi-codec.ts @@ -73,9 +73,18 @@ export class StandardOpenAPICodec implements StandardCodec { encode(output: unknown, procedure: AnyProcedure): StandardResponse { const successStatus = fallbackContractConfig('defaultSuccessStatus', procedure['~orpc'].route.successStatus) + const outputStructure = fallbackContractConfig('defaultOutputStructure', procedure['~orpc'].route.outputStructure) if (outputStructure === 'compact') { + if (output instanceof ReadableStream) { + return { + status: successStatus, + headers: {}, + body: output, + } + } + return { status: successStatus, headers: {}, @@ -97,6 +106,14 @@ export class StandardOpenAPICodec implements StandardCodec { `) } + if (output.body instanceof ReadableStream) { + return { + status: output.status ?? successStatus, + headers: output.headers ?? {}, + body: output.body, + } + } + return { status: output.status ?? successStatus, headers: output.headers ?? {}, diff --git a/packages/openapi/src/openapi-generator.test.ts b/packages/openapi/src/openapi-generator.test.ts index 61d590a59..665307715 100644 --- a/packages/openapi/src/openapi-generator.test.ts +++ b/packages/openapi/src/openapi-generator.test.ts @@ -136,6 +136,15 @@ const inputTests: TestCase[] = [ }, }, }, + { + name: 'dynamic params only (no body)', + contract: oc.route({ method: 'POST', path: '/planets/{id}' }).input(z.object({ id: z.string() })), + expected: { + '/planets/{id}': { + post: expect.toSatisfy((v: any) => v.parameters?.length === 1 && !v.requestBody), + }, + }, + }, { name: 'query + params', contract: oc.route({ path: '/planets/{id}', method: 'GET' }).input( @@ -1428,7 +1437,6 @@ describe('openAPIGenerator', () => { properties: { parent: { $ref: '#/components/schemas/User' }, }, - required: [], }, }, }, @@ -1707,7 +1715,6 @@ describe('openAPIGenerator', () => { a: { type: 'string' }, b: { type: 'number' }, }, - required: [], }, }, }, diff --git a/packages/openapi/src/openapi-generator.ts b/packages/openapi/src/openapi-generator.ts index e2718e2e8..e8b15366d 100644 --- a/packages/openapi/src/openapi-generator.ts +++ b/packages/openapi/src/openapi-generator.ts @@ -307,6 +307,8 @@ export class OpenAPIGenerator { }, ) + let omitResponseBody = false + if (isAnySchema(schema) && !dynamicParams?.length) { return } @@ -329,6 +331,7 @@ export class OpenAPIGenerator { schema = rest required = rest.required ? rest.required.length !== 0 : false + omitResponseBody = !required && !rest.properties if (!checkParamsSchema(paramsSchema, dynamicParams)) { throw error @@ -348,7 +351,7 @@ export class OpenAPIGenerator { ref.parameters ??= [] ref.parameters.push(...toOpenAPIParameters(schema, 'query')) } - else { + else if (!omitResponseBody) { ref.requestBody = { required, content: toOpenAPIContent(schema), diff --git a/packages/openapi/src/openapi-utils.test.ts b/packages/openapi/src/openapi-utils.test.ts index aa5c6e8a3..0024f2f0f 100644 --- a/packages/openapi/src/openapi-utils.test.ts +++ b/packages/openapi/src/openapi-utils.test.ts @@ -69,6 +69,34 @@ describe('toOpenAPIContent', () => { }) }) + it('omits unconstrained non-file branches', () => { + expect(toOpenAPIContent({ + anyOf: [ + fileSchema, + {}, + ], + })).toEqual({ + 'image/png': { + schema: fileSchema, + }, + }) + + expect(toOpenAPIContent({ properties: undefined })).toEqual({}) + }) + + it('omits never non-file branches', () => { + expect(toOpenAPIContent({ + anyOf: [ + fileSchema, + { not: {} }, + ], + })).toEqual({ + 'image/png': { + schema: fileSchema, + }, + }) + }) + it('body contain file schema', () => { const schema: JSONSchema = { type: 'object', diff --git a/packages/openapi/src/openapi-utils.ts b/packages/openapi/src/openapi-utils.ts index 9887e9359..e0394c824 100644 --- a/packages/openapi/src/openapi-utils.ts +++ b/packages/openapi/src/openapi-utils.ts @@ -3,7 +3,7 @@ import type { OpenAPI } from '@orpc/contract' import type { FileSchema, JSONSchema, ObjectSchema } from './schema' import { standardizeHTTPPath } from '@orpc/openapi-client/standard' import { findDeepMatches, isObject, stringifyJSON, toArray } from '@orpc/shared' -import { expandArrayableSchema, filterSchemaBranches, isFileSchema, isObjectSchema, isPrimitiveSchema } from './schema-utils' +import { expandArrayableSchema, filterSchemaBranches, isAnySchema, isFileSchema, isNeverSchema, isObjectSchema, isPrimitiveSchema } from './schema-utils' /** * @internal @@ -33,7 +33,7 @@ export function toOpenAPIContent(schema: JSONSchema): Record { expect(isAnySchema({})).toBe(true) expect(isAnySchema({ type: 'string' })).toBe(false) expect(isAnySchema({ description: 'description' })).toBe(true) + expect(isAnySchema({ properties: undefined, required: undefined })).toBe(true) +}) + +describe('isNeverSchema', () => { + describe('returns true for never schemas', () => { + it('returns true for boolean false', () => { + expect(isNeverSchema(false)).toBe(true) + }) + + it('returns true for { not: true }', () => { + expect(isNeverSchema({ not: true })).toBe(true) + }) + + it('returns true for { not: {} }', () => { + expect(isNeverSchema({ not: {} })).toBe(true) + }) + }) + + describe('returns false for non-never schemas', () => { + it('returns false for boolean true', () => { + expect(isNeverSchema(true)).toBe(false) + }) + + it('returns false for an empty schema {}', () => { + expect(isNeverSchema({})).toBe(false) + }) + + it('returns false for a type constraint', () => { + expect(isNeverSchema({ type: 'string' })).toBe(false) + }) + + it('returns false for { not: false } (double negation = always true)', () => { + expect(isNeverSchema({ not: false })).toBe(false) + }) + + it('returns false for { not: { type: \'string\' } } (only rejects strings)', () => { + expect(isNeverSchema({ not: { type: 'string' } })).toBe(false) + }) + + it('returns false for a schema with only additionalProperties', () => { + expect(isNeverSchema({ additionalProperties: false })).toBe(false) + }) + + it('returns false for a complex schema', () => { + expect( + isNeverSchema({ + type: 'object', + properties: { id: { type: 'number' } }, + required: ['id'], + }), + ).toBe(false) + }) + }) }) describe('separateObjectSchema', () => { @@ -121,7 +175,6 @@ describe('separateObjectSchema', () => { properties: { b: { type: 'string' }, }, - required: [], additionalProperties: true, }) }) @@ -152,11 +205,28 @@ describe('separateObjectSchema', () => { const [matched, rest] = separateObjectSchema(schema, ['a']) - expect(matched).toEqual({ - ...schema, + expect(matched).toEqual(schema) + expect(rest).toEqual(schema) + }) + + it('with empty properties & required', () => { + const schema: ObjectSchema = { + type: 'object', + description: 'description', properties: {}, + required: [], + } + + const [matched, rest] = separateObjectSchema(schema, ['a']) + + expect(matched).toEqual({ + type: 'object', + description: 'description', + }) + expect(rest).toEqual({ + type: 'object', + description: 'description', }) - expect(rest).toEqual(schema) }) }) diff --git a/packages/openapi/src/schema-utils.ts b/packages/openapi/src/schema-utils.ts index a63f7690f..b0aa23cb2 100644 --- a/packages/openapi/src/schema-utils.ts +++ b/packages/openapi/src/schema-utils.ts @@ -24,13 +24,39 @@ export function isAnySchema(schema: JSONSchema): boolean { return true } - if (Object.keys(schema).every(k => !LOGIC_KEYWORDS.includes(k))) { + if ( + Object + .keys(schema) + .filter(v => schema[v as keyof typeof schema] !== undefined) + .every(k => !LOGIC_KEYWORDS.includes(k)) + ) { return true } return false } +export function isNeverSchema(schema: JSONSchema): boolean { + // Boolean `false` is the shorthand never-schema + if (schema === false) { + return true + } + + if (typeof schema === 'object' && schema.not !== undefined) { + // `{ not: true }` — negation of the boolean catch-all + if (schema.not === true) { + return true + } + + // `{ not: {} }` — negation of the empty object, which accepts everything + if (typeof schema.not === 'object' && Object.keys(schema.not).length === 0) { + return true + } + } + + return false +} + /** * @internal */ @@ -57,8 +83,16 @@ export function separateObjectSchema(schema: ObjectSchema, separatedProperties: return acc }, {}) + if (Object.keys(matched.properties).length === 0) { + matched.properties = undefined + } + matched.required = schema.required?.filter(key => separatedProperties.includes(key)) + if (matched.required?.length === 0) { + matched.required = undefined + } + matched.examples = schema.examples?.map((example) => { if (!isObject(example)) { return example @@ -75,13 +109,17 @@ export function separateObjectSchema(schema: ObjectSchema, separatedProperties: rest.properties = schema.properties && Object.entries(schema.properties) .filter(([key]) => !separatedProperties.includes(key)) - .reduce((acc, [key, value]) => { + .reduce((acc: Record = {}, [key, value]) => { acc[key] = value return acc - }, {} as Record) + }, undefined) rest.required = schema.required?.filter(key => !separatedProperties.includes(key)) + if (rest.required?.length === 0) { + rest.required = undefined + } + rest.examples = schema.examples?.map((example) => { if (!isObject(example)) { return example diff --git a/packages/server/src/adapters/standard/rpc-codec.test.ts b/packages/server/src/adapters/standard/rpc-codec.test.ts index 61e2a240b..9bc46c9bb 100644 --- a/packages/server/src/adapters/standard/rpc-codec.test.ts +++ b/packages/server/src/adapters/standard/rpc-codec.test.ts @@ -76,6 +76,20 @@ describe('standardRPCCodec', () => { expect(serializer.serialize).toHaveBeenCalledWith('__output__') }) + it('.encode with ReadableStream bypasses serialization', () => { + const stream = new ReadableStream() + + const response = codec.encode(stream, ping) + + expect(response).toEqual({ + status: 200, + headers: {}, + body: stream, + }) + + expect(serializer.serialize).not.toHaveBeenCalled() + }) + it('.encodeError', async () => { serializer.serialize.mockReturnValueOnce('__serialized__') diff --git a/packages/server/src/adapters/standard/rpc-codec.ts b/packages/server/src/adapters/standard/rpc-codec.ts index fb399e217..fba1b85a5 100644 --- a/packages/server/src/adapters/standard/rpc-codec.ts +++ b/packages/server/src/adapters/standard/rpc-codec.ts @@ -20,6 +20,14 @@ export class StandardRPCCodec implements StandardCodec { } encode(output: unknown, _procedure: AnyProcedure): StandardResponse { + if (output instanceof ReadableStream) { + return { + status: 200, + headers: {}, + body: output, + } + } + return { status: 200, headers: {}, diff --git a/packages/standard-server-fetch/src/body.test.ts b/packages/standard-server-fetch/src/body.test.ts index 378c8df78..7c45b7196 100644 --- a/packages/standard-server-fetch/src/body.test.ts +++ b/packages/standard-server-fetch/src/body.test.ts @@ -277,6 +277,30 @@ describe('toFetchBody', () => { expect(generateContentDispositionSpy).toHaveBeenCalledTimes(0) }) + it('readable stream', async () => { + const headers = new Headers(baseHeaders) + headers.set('content-type', 'application/zip') + headers.set('content-disposition', 'attachment; filename="archive.zip"') + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hello')) + controller.close() + }, + }) + + const body = toFetchBody(stream, headers, {}) + + expect(body).toBe(stream) + expect([...headers]).toEqual([ + ['content-disposition', 'attachment; filename="archive.zip"'], + ['content-type', 'application/zip'], + ['x-custom-header', 'custom-value'], + ]) + + const text = await new Response(body).text() + expect(text).toBe('hello') + }) + it('async generator', async () => { async function* gen() { yield 123 diff --git a/packages/standard-server-fetch/src/body.ts b/packages/standard-server-fetch/src/body.ts index b7e212a79..430220956 100644 --- a/packages/standard-server-fetch/src/body.ts +++ b/packages/standard-server-fetch/src/body.ts @@ -67,6 +67,10 @@ export function toFetchBody( headers: Headers, options: ToFetchBodyOptions = {}, ): string | Blob | FormData | URLSearchParams | undefined | ReadableStream { + if (body instanceof ReadableStream) { + return body + } + const currentContentDisposition = headers.get('content-disposition') headers.delete('content-type') diff --git a/packages/standard-server-node/src/body.test.ts b/packages/standard-server-node/src/body.test.ts index 59d45971c..299d9f4fa 100644 --- a/packages/standard-server-node/src/body.test.ts +++ b/packages/standard-server-node/src/body.test.ts @@ -405,6 +405,32 @@ describe('toNodeHttpBody', () => { expect(await resBlob.text()).toBe('foo') }) + it('readable stream', async () => { + const headers = { + ...baseHeaders, + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="archive.zip"', + } + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hello')) + controller.close() + }, + }) + + const body = toNodeHttpBody(stream, headers, {}) + + expect(body).toBeInstanceOf(Readable) + expect(headers).toEqual({ + 'content-disposition': 'attachment; filename="archive.zip"', + 'content-type': 'application/zip', + 'x-custom-header': 'custom-value', + }) + + const text = await new Response(body).text() + expect(text).toBe('hello') + }) + it('async generator', async () => { async function* gen() { yield 123 diff --git a/packages/standard-server-node/src/body.ts b/packages/standard-server-node/src/body.ts index 2cb2582ff..179ded13c 100644 --- a/packages/standard-server-node/src/body.ts +++ b/packages/standard-server-node/src/body.ts @@ -62,6 +62,10 @@ export function toNodeHttpBody( headers: StandardHeaders, options: ToNodeHttpBodyOptions = {}, ): Readable | undefined | string { + if (body instanceof ReadableStream) { + return Readable.fromWeb(body) + } + const currentContentDisposition = flattenHeader(headers['content-disposition']) delete headers['content-type'] diff --git a/packages/standard-server/src/types.ts b/packages/standard-server/src/types.ts index 26d0886fa..1e49aeeff 100644 --- a/packages/standard-server/src/types.ts +++ b/packages/standard-server/src/types.ts @@ -9,6 +9,7 @@ export type StandardBody | URLSearchParams | FormData | AsyncIterator + | ReadableStream export interface StandardRequest { method: string diff --git a/packages/standard-server/src/utils.ts b/packages/standard-server/src/utils.ts index 3d092e256..b98dfc875 100644 --- a/packages/standard-server/src/utils.ts +++ b/packages/standard-server/src/utils.ts @@ -1,7 +1,7 @@ import type { StandardHeaders, StandardLazyResponse } from './types' import { isAsyncIteratorObject, once, replicateAsyncIterator, toArray, tryDecodeURIComponent } from '@orpc/shared' -export function generateContentDisposition(filename: string): string { +export function generateContentDisposition(filename: string, disposition: 'inline' | 'attachment' = 'inline'): string { const encodedFileName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '\\"') // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers @@ -9,7 +9,7 @@ export function generateContentDisposition(filename: string): string { .replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) .replace(/%(7C|60|5E)/g, (str, hex) => String.fromCharCode(Number.parseInt(hex, 16))) - return `inline; filename="${encodedFileName}"; filename*=utf-8\'\'${encodedFilenameStar}` + return `${disposition}; filename="${encodedFileName}"; filename*=utf-8\'\'${encodedFilenameStar}` } export function getFilenameFromContentDisposition(contentDisposition: string): string | undefined {