Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-openapi-response-representations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/openapi-generator": patch
---

Preserve JSON-compatible response representations per status and decode successful binary responses in generated HTTP clients.
81 changes: 69 additions & 12 deletions packages/tools/openapi-generator/src/OpenApiGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,22 +493,31 @@ const parseOpenApi = (
}
const representable: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []

const jsonResponseEntries = findContentEntriesByMediaType(content, isJsonMediaType)
let jsonSchemaName: string | undefined
const jsonResponseSchema = content?.["application/json"]?.schema
if (Predicate.isNotUndefined(jsonResponseSchema)) {
if (jsonResponseEntries.length > 0) {
const jsonResponseSchema = combineJsonResponseSchemas(jsonResponseEntries)
jsonSchemaName = addSchema(`${schemaId}${status}`, jsonResponseSchema, op)

if (isHttpApi) {
representable.push({
contentType: "application/json",
encoding: "json",
schema: jsonSchemaName
})
const multipleJsonRepresentations = jsonResponseEntries.length > 1
for (const [contentType, mediaType] of jsonResponseEntries) {
const schema = mediaType.schema as JsonSchema.JsonSchema
const schemaName = multipleJsonRepresentations
? addSchema(`${schemaId}${status}${mediaTypeToSuffix(contentType)}`, schema, op)
: jsonSchemaName
representable.push({
contentType,
encoding: "json",
schema: schemaName
})
}
}
}

if (isHttpApi) {
for (const [contentType, mediaType] of Object.entries(content ?? {})) {
if (contentType === "application/json") {
if (isJsonMediaType(contentType.toLowerCase())) {
continue
}
if (!Predicate.isObject(mediaType)) {
Expand Down Expand Up @@ -612,10 +621,20 @@ const parseOpenApi = (
}
}

if (Predicate.isNotUndefined(content?.["application/octet-stream"])) {
const statusMajorNumber = Number(parsedStatus[0])
if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
op.binaryResponse = true
if (Predicate.isNotUndefined(content)) {
for (const [contentType, mediaType] of Object.entries(content)) {
const schema = Predicate.isObject(mediaType) ? mediaType.schema : undefined
const isBinary = isBinaryMediaType(contentType.toLowerCase()) ||
(Predicate.isObject(schema) && schema.type === "string" && schema.format === "binary")
if (!isBinary) {
continue
}
const statusMajorNumber = Number(parsedStatus[0])
if (!Number.isNaN(statusMajorNumber) && statusMajorNumber < 4) {
op.binaryResponse = true
op.binarySuccessStatuses.add(parsedStatus.toLowerCase())
}
break
}
}

Expand Down Expand Up @@ -912,6 +931,38 @@ const isMultipartBinaryFiles = (value: Record<string, unknown>, singleFileRef: s
return isMultipartBinaryFile(items) || (Predicate.isObject(items) && items.$ref === singleFileRef)
}

const combineJsonResponseSchemas = (
entries: ReadonlyArray<readonly [string, { readonly schema: unknown }]>
): JsonSchema.JsonSchema => {
const schemas = entries.map(([, mediaType]) => mediaType.schema as JsonSchema.JsonSchema)
return schemas.length === 1
? schemas[0]
: {
anyOf: schemas
}
}

const findContentEntriesByMediaType = (
content: Record<string, unknown> | undefined,
predicate: (contentType: string) => boolean
): Array<readonly [string, { readonly schema: unknown }]> => {
if (Predicate.isUndefined(content)) {
return []
}

const matches: Array<readonly [string, { readonly schema: unknown }]> = []
for (const [contentType, mediaType] of Object.entries(content)) {
if (
predicate(contentType.toLowerCase()) &&
Predicate.isObject(mediaType) &&
Predicate.isNotUndefined(mediaType.schema)
) {
matches.push([contentType, mediaType as { readonly schema: unknown }])
}
}
return matches
}

const isJsonMediaType = (contentType: string): boolean =>
contentType === "application/json" ||
(contentType.startsWith("application/") && contentType.endsWith("+json"))
Expand All @@ -920,6 +971,12 @@ const isTextMediaType = (contentType: string): boolean => contentType.startsWith

const isBinaryMediaType = (contentType: string): boolean =>
contentType === "application/octet-stream" ||
contentType === "application/zip" ||
contentType === "application/gzip" ||
contentType === "application/pdf" ||
contentType.startsWith("image/") ||
contentType.startsWith("audio/") ||
contentType.startsWith("video/") ||
(contentType.startsWith("application/") && (contentType.includes("binary") || contentType.endsWith("+octet-stream")))

const getRequestMediaTypeEncoding = (
Expand Down
91 changes: 74 additions & 17 deletions packages/tools/openapi-generator/src/OpenApiTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,25 @@ export class OpenApiTransformer extends Context.Service<
interface ImportRequirements {
readonly eventStream: boolean
readonly octetStream: boolean
readonly binarySuccess: boolean
}

const computeImportRequirements = (operations: ReadonlyArray<ParsedOperation>): ImportRequirements => {
let eventStream = false
let octetStream = false
let binarySuccess = false
for (const op of operations) {
if (op.sseSchema) {
eventStream = true
}
if (op.binaryResponse) {
octetStream = true
}
if (op.binarySuccessStatuses.size > 0) {
binarySuccess = true
}
}
return { eventStream, octetStream }
return { eventStream, octetStream, binarySuccess }
}

const requiresStreaming = (requirements: ImportRequirements): boolean =>
Expand Down Expand Up @@ -124,10 +129,13 @@ ${clientErrorSource(name)}`
}

let success = "void"
if (operation.successSchemas.size > 0) {
success = Array.from(operation.successSchemas.values())
.map((schema) => `typeof ${schema}.Type`)
.join(" | ")
const successTypes = Array.from(operation.successSchemas.values())
.map((schema) => `typeof ${schema}.Type`)
if (operation.binarySuccessStatuses.size > 0) {
successTypes.push("Uint8Array")
}
if (successTypes.length > 0) {
success = successTypes.join(" | ")
}
const errors = ["HttpClientError.HttpClientError", "SchemaError"]
if (operation.errorSchemas.size > 0) {
Expand Down Expand Up @@ -232,6 +240,9 @@ ${clientErrorSource(name)}`
if (requirements.octetStream) {
helpers.push(binaryRequestSource)
}
if (requirements.binarySuccess) {
helpers.push(decodeBinarySource)
}

return `export interface OperationConfig {
/**
Expand Down Expand Up @@ -318,6 +329,11 @@ export const make = (
const statusCode = singleSuccessCode && status.startsWith("2") ? "2xx" : status
decodes.push(`"${statusCode}": decodeSuccess(${schema})`)
})
const singleBinaryCode = operation.binarySuccessStatuses.size === 1 && operation.successSchemas.size === 0
operation.binarySuccessStatuses.forEach((status) => {
const statusCode = singleBinaryCode && status.startsWith("2") ? "2xx" : status
decodes.push(`"${statusCode}": decodeBinary`)
})
operation.errorSchemas.forEach((schema, status) => {
decodes.push(`"${status}": decodeError("${schema}", ${schema})`)
})
Expand Down Expand Up @@ -533,8 +549,12 @@ ${clientErrorSource(name)}`
}

let success = "void"
if (operation.successSchemas.size > 0) {
success = Array.from(operation.successSchemas.values()).join(" | ")
const successTypes = Array.from(operation.successSchemas.values())
if (operation.binarySuccessStatuses.size > 0) {
successTypes.push("Uint8Array")
}
if (successTypes.length > 0) {
success = successTypes.join(" | ")
}

const errors = ["HttpClientError.HttpClientError"]
Expand Down Expand Up @@ -620,7 +640,7 @@ ${clientErrorSource(name)}`
const requirements = computeImportRequirements(operations)
const implMethods: Array<string> = []
for (const op of operations) {
implMethods.push(operationToImpl(op))
implMethods.push(operationToImpl(op, requirements.binarySuccess))
if (op.sseSchema) {
implMethods.push(operationToSseImpl(op))
}
Expand All @@ -636,6 +656,9 @@ ${clientErrorSource(name)}`
if (requirements.octetStream) {
helpers.push(binaryRequestSourceTs)
}
if (requirements.binarySuccess) {
helpers.push(decodeBinarySourceTs)
}

return `export interface OperationConfig {
/**
Expand Down Expand Up @@ -683,19 +706,33 @@ export const make = (
(cause) => Effect.fail(${name}Error(tag, cause, response)),
)
const onRequest = <Config extends OperationConfig>(config: Config | undefined) => (
successCodes: ReadonlyArray<string>,
successCodes: ReadonlyArray<string>,${
requirements.binarySuccess
? `
binarySuccessCodes?: ReadonlyArray<string>,`
: ""
}
errorCodes?: Record<string, string>,
) => {
const cases: any = { orElse: unexpectedStatus }
for (const code of successCodes) {
cases[code] = decodeSuccess
}${
requirements.binarySuccess
? `
if (binarySuccessCodes) {
for (const code of binarySuccessCodes) {
cases[code] = decodeBinary
}
}`
: ""
}
if (errorCodes) {
for (const [code, tag] of Object.entries(errorCodes)) {
cases[code] = decodeError(tag)
}
}
if (successCodes.length === 0) {
if (successCodes.length === 0${requirements.binarySuccess ? " && (binarySuccessCodes?.length ?? 0) === 0" : ""}) {
cases["2xx"] = decodeVoid
}
return withResponse(config)(HttpClientResponse.matchStatus(cases) as any)
Expand All @@ -707,7 +744,7 @@ export const make = (
}`
}

const operationToImpl = (operation: ParsedOperation) => {
const operationToImpl = (operation: ParsedOperation, includeBinaryArgument: boolean) => {
const args: Array<string> = [...operation.pathIds, "options"]
const params = `${args.join(", ")}`

Expand Down Expand Up @@ -738,18 +775,27 @@ export const make = (
}

const successCodesRaw = Array.from(operation.successSchemas.keys())
const binarySuccessCodesRaw = Array.from(operation.binarySuccessStatuses)
const successCodes = successCodesRaw
.map((_) => JSON.stringify(_))
.join(", ")
const singleSuccessCode = successCodesRaw.length === 1 && successCodesRaw[0].startsWith("2")
const binarySuccessCodes = binarySuccessCodesRaw
.map((_) => JSON.stringify(_))
.join(", ")
const singleSuccessCode = successCodesRaw.length === 1 &&
binarySuccessCodesRaw.length === 0 &&
successCodesRaw[0].startsWith("2")
const errorCodes = operation.errorSchemas.size > 0 &&
Object.fromEntries(operation.errorSchemas.entries())
const configAccessor = resolveConfigAccessor(operation, "options", "config")
pipeline.push(
`onRequest(${configAccessor})([${singleSuccessCode ? `"2xx"` : successCodes}]${
errorCodes ? `, ${JSON.stringify(errorCodes)}` : ""
})`
)
const onRequestArgs = [`[${singleSuccessCode ? `"2xx"` : successCodes}]`]
if (includeBinaryArgument) {
onRequestArgs.push(binarySuccessCodes.length > 0 ? `[${binarySuccessCodes}]` : "undefined")
}
if (errorCodes) {
onRequestArgs.push(JSON.stringify(errorCodes))
}
pipeline.push(`onRequest(${configAccessor})(${onRequestArgs.join(", ")})`)

return (
`"${operation.id}": (${params}) => ` +
Expand Down Expand Up @@ -940,6 +986,11 @@ const binaryRequestSource =
Stream.unwrap
)`

const decodeBinarySource = `const decodeBinary = (response: HttpClientResponse.HttpClientResponse) =>
response.arrayBuffer.pipe(
Effect.map((buffer) => new Uint8Array(buffer))
)`

// Type-only mode helpers (no schema decoding)
const sseRequestSourceTs =
`const sseRequest = (request: HttpClientRequest.HttpClientRequest): Stream.Stream<unknown, HttpClientError.HttpClientError> =>
Expand All @@ -959,6 +1010,12 @@ const binaryRequestSourceTs =
Stream.unwrap
)`

const decodeBinarySourceTs =
`const decodeBinary = (response: HttpClientResponse.HttpClientResponse): Effect.Effect<Uint8Array, HttpClientError.HttpClientError> =>
response.arrayBuffer.pipe(
Effect.map((buffer) => new Uint8Array(buffer))
)`

const clientErrorSource = (
name: string
) =>
Expand Down
7 changes: 5 additions & 2 deletions packages/tools/openapi-generator/src/ParsedOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,10 @@ export interface ParsedOperation {
readonly voidSchemas: ReadonlySet<string>
// SSE streaming response schema (text/event-stream)
readonly sseSchema?: string
// Binary stream response (application/octet-stream)
// Binary stream response for any successful binary media type.
readonly binaryResponse: boolean
// Successful statuses that should decode the response body as binary.
readonly binarySuccessStatuses: ReadonlySet<string>
}

/**
Expand Down Expand Up @@ -273,5 +275,6 @@ export const makeDeepMutable = (options: {
errorSchemas: new Map(),
voidSchemas: new Set(),
paramsOptional: true,
binaryResponse: false
binaryResponse: false,
binarySuccessStatuses: new Set()
})
Loading