Skip to content

Commit e1af8d2

Browse files
author
Andy Nguyen
committed
fix(openapi): infer binary responses from media types
1 parent cc2f39a commit e1af8d2

10 files changed

Lines changed: 2199 additions & 8 deletions

File tree

packages/cli/api-importers/openapi-to-ir/src/3.1/paths/ResponseBodyConverter.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ export class ResponseBodyConverter extends Converters.AbstractConverters.Abstrac
166166
if (mediaTypeObject == null) {
167167
continue;
168168
}
169+
if (this.isBinaryContentType(contentType)) {
170+
return this.shouldReturnBytesResponse()
171+
? this.returnBytesResponse({
172+
mediaTypeObject
173+
})
174+
: this.returnFileDownloadResponse({
175+
mediaTypeObject
176+
});
177+
}
169178
const convertedSchema = this.parseMediaTypeObject({
170179
mediaTypeObject,
171180
schemaId,
@@ -454,6 +463,14 @@ export class ResponseBodyConverter extends Converters.AbstractConverters.Abstrac
454463
return contentType.includes("json");
455464
}
456465

466+
private isBinaryContentType(contentType: string): boolean {
467+
const mediaType = MediaType.parse(contentType);
468+
if (mediaType == null) {
469+
return false;
470+
}
471+
return mediaType.isBinary() && !mediaType.isXML();
472+
}
473+
457474
private shouldReturnBytesResponse(): boolean {
458475
return this.context.settings.useBytesForBinaryResponse && this.streamingExtension == null;
459476
}

packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertResponse.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -341,14 +341,7 @@ function convertResolvedResponse({
341341
continue;
342342
}
343343

344-
if (
345-
mimeType.isOctetStream() ||
346-
mimeType.isPDF() ||
347-
mimeType.isAudio() ||
348-
mimeType.isImage() ||
349-
mimeType.isVideo() ||
350-
mimeType.isMultiPartMixed()
351-
) {
344+
if (mimeType.isBinary() || mimeType.isMultiPartMixed()) {
352345
return ResponseWithExample.file({ description: resolvedResponse.description, source, statusCode });
353346
}
354347

packages/cli/api-importers/openapi/openapi-ir-parser/src/schema/convertSchemas.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MediaType } from "@fern-api/core-utils";
12
import type { Logger } from "@fern-api/logger";
23
import {
34
type Availability,
@@ -57,6 +58,43 @@ export const SCHEMA_INLINE_REFERENCE_PREFIX = "#/components/responses/";
5758
// Module-level collision tracker for title-based name overrides
5859
const globalTitleCollisionTracker = createSchemaCollisionTracker();
5960

61+
const RAW_BINARY_SCHEMA_ALLOWED_KEYS = new Set([
62+
"$anchor",
63+
"$comment",
64+
"$id",
65+
"$schema",
66+
"contentMediaType",
67+
"default",
68+
"deprecated",
69+
"description",
70+
"example",
71+
"examples",
72+
"externalDocs",
73+
"format",
74+
"maxLength",
75+
"minLength",
76+
"pattern",
77+
"readOnly",
78+
"title",
79+
"writeOnly"
80+
]);
81+
82+
function isRawBinarySchema(schema: OpenAPIV3.SchemaObject): boolean {
83+
const { contentEncoding, contentMediaType } = schema as Record<string, unknown>;
84+
if (schema.type != null || contentEncoding != null || typeof contentMediaType !== "string") {
85+
return false;
86+
}
87+
if (
88+
Object.keys(schema).some(
89+
(key) => !RAW_BINARY_SCHEMA_ALLOWED_KEYS.has(key) && !key.toLowerCase().startsWith("x-")
90+
)
91+
) {
92+
return false;
93+
}
94+
const mediaType = MediaType.parse(contentMediaType);
95+
return mediaType?.isBinary() === true && !mediaType.isXML();
96+
}
97+
6098
// Reset the global collision tracker (called at the start of document processing)
6199
export function resetTitleCollisionTracker(): void {
62100
globalTitleCollisionTracker.reset();
@@ -1429,6 +1467,29 @@ export function convertSchemaObject(
14291467
});
14301468
}
14311469

1470+
// OpenAPI 3.1 raw binary data has no JSON type or content encoding.
1471+
if (isRawBinarySchema(schema)) {
1472+
return wrapPrimitive({
1473+
nameOverride,
1474+
generatedName,
1475+
title,
1476+
primitive: PrimitiveSchemaValueWithExample.string({
1477+
default: getDefaultAsString(schema),
1478+
pattern: schema.pattern,
1479+
format: "binary",
1480+
minLength: schema.minLength,
1481+
maxLength: schema.maxLength,
1482+
example: getExamplesString({ schema, logger: context.logger, fallback })
1483+
}),
1484+
namespace,
1485+
groupName,
1486+
wrapAsOptional,
1487+
wrapAsNullable,
1488+
description,
1489+
availability
1490+
});
1491+
}
1492+
14321493
// handle null type (OpenAPI 3.1)
14331494
// `type: "null"` means the value is always null.
14341495
// Represent as nullable wrapping an unknown inner type.

packages/cli/api-importers/v3-importer-commons/src/converters/schema/PrimitiveSchemaConverter.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MediaType } from "@fern-api/core-utils";
12
import {
23
ContainerType,
34
IntegerValidationRules,
@@ -11,6 +12,27 @@ import { OpenAPIV3_1 } from "openapi-types";
1112

1213
import { AbstractConverter, AbstractConverterContext } from "../../index.js";
1314

15+
const RAW_BINARY_SCHEMA_ALLOWED_KEYS = new Set([
16+
"$anchor",
17+
"$comment",
18+
"$id",
19+
"$schema",
20+
"contentMediaType",
21+
"default",
22+
"deprecated",
23+
"description",
24+
"example",
25+
"examples",
26+
"externalDocs",
27+
"format",
28+
"maxLength",
29+
"minLength",
30+
"pattern",
31+
"readOnly",
32+
"title",
33+
"writeOnly"
34+
]);
35+
1436
export declare namespace PrimitiveSchemaConverter {
1537
export interface Args extends AbstractConverter.AbstractArgs {
1638
schema: OpenAPIV3_1.SchemaObject;
@@ -26,6 +48,16 @@ export class PrimitiveSchemaConverter extends AbstractConverter<AbstractConverte
2648
}
2749

2850
public convert(): TypeReference | undefined {
51+
if (this.isRawBinarySchema(this.schema)) {
52+
return TypeReference.primitive({
53+
v1: PrimitiveTypeV1.String,
54+
v2: PrimitiveTypeV2.string({
55+
default: this.context.getAsString(this.schema.default),
56+
validation: this.getStringValidation({ ...this.schema, format: "binary" })
57+
})
58+
});
59+
}
60+
2961
switch (this.schema.type) {
3062
case "string": {
3163
const stringConst = this.context.getAsString(this.schema.const);
@@ -201,6 +233,23 @@ export class PrimitiveSchemaConverter extends AbstractConverter<AbstractConverte
201233
}
202234
}
203235

236+
private isRawBinarySchema(schema: OpenAPIV3_1.SchemaObject): boolean {
237+
// OpenAPI 3.1 raw binary data has no JSON type or content encoding.
238+
const { contentEncoding, contentMediaType } = schema as Record<string, unknown>;
239+
if (schema.type != null || contentEncoding != null || typeof contentMediaType !== "string") {
240+
return false;
241+
}
242+
if (
243+
Object.keys(schema).some(
244+
(key) => !RAW_BINARY_SCHEMA_ALLOWED_KEYS.has(key) && !key.toLowerCase().startsWith("x-")
245+
)
246+
) {
247+
return false;
248+
}
249+
const mediaType = MediaType.parse(contentMediaType);
250+
return mediaType?.isBinary() === true && !mediaType.isXML();
251+
}
252+
204253
private getNumberValidation(schema: OpenAPIV3_1.SchemaObject): IntegerValidationRules | undefined {
205254
// Handle both OpenAPI 3.0 (boolean) and OpenAPI 3.1 (number) formats for exclusive bounds
206255
// OpenAPI 3.0: exclusiveMinimum/exclusiveMaximum are booleans that modify minimum/maximum

0 commit comments

Comments
 (0)