Skip to content
1 change: 1 addition & 0 deletions apps/content/docs/openapi/openapi-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\<Uint8Array\>** (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).
Expand Down
38 changes: 38 additions & 0 deletions packages/openapi/src/adapters/standard/openapi-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>()

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'],
Expand Down Expand Up @@ -251,6 +271,24 @@ describe('standardOpenAPICodec', () => {
expect(serializer.serialize).toHaveBeenCalledWith('__output__')
})

it('works with ReadableStream body', () => {
const stream = new ReadableStream<Uint8Array>()
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' },
Expand Down
17 changes: 17 additions & 0 deletions packages/openapi/src/adapters/standard/openapi-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Comment thread
ghdoergeloh marked this conversation as resolved.
return {
status: successStatus,
headers: {},
Expand All @@ -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 ?? {},
Expand Down
11 changes: 9 additions & 2 deletions packages/openapi/src/openapi-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -1428,7 +1437,6 @@ describe('openAPIGenerator', () => {
properties: {
parent: { $ref: '#/components/schemas/User' },
},
required: [],
},
},
},
Expand Down Expand Up @@ -1707,7 +1715,6 @@ describe('openAPIGenerator', () => {
a: { type: 'string' },
b: { type: 'number' },
},
required: [],
},
},
},
Expand Down
5 changes: 4 additions & 1 deletion packages/openapi/src/openapi-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ export class OpenAPIGenerator {
},
)

let omitResponseBody = false

if (isAnySchema(schema) && !dynamicParams?.length) {
return
}
Expand All @@ -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
Expand All @@ -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),
Expand Down
28 changes: 28 additions & 0 deletions packages/openapi/src/openapi-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/openapi/src/openapi-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,7 +33,7 @@ export function toOpenAPIContent(schema: JSONSchema): Record<string, OpenAPI.Med
}
}

if (restSchema !== undefined) {
if (restSchema !== undefined && !isAnySchema(restSchema) && !isNeverSchema(restSchema)) {
content['application/json'] = {
schema: toOpenAPISchema(restSchema),
}
Expand Down
78 changes: 74 additions & 4 deletions packages/openapi/src/schema-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
filterSchemaBranches,
isAnySchema,
isFileSchema,
isNeverSchema,
isObjectSchema,
isPrimitiveSchema,
separateObjectSchema,
Expand Down Expand Up @@ -36,6 +37,59 @@ it('isAnySchema', () => {
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', () => {
Expand Down Expand Up @@ -121,7 +175,6 @@ describe('separateObjectSchema', () => {
properties: {
b: { type: 'string' },
},
required: [],
additionalProperties: true,
})
})
Expand Down Expand Up @@ -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)
})
})

Expand Down
44 changes: 41 additions & 3 deletions packages/openapi/src/schema-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand All @@ -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<string, JSONSchema> = {}, [key, value]) => {
acc[key] = value
return acc
}, {} as Record<string, JSONSchema>)
}, 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
Expand Down
Loading
Loading