Skip to content

Commit cf90725

Browse files
authored
feat(oazapfts#52): support Blobs
* Allow to return blob for binary data * Generic param not needed * Deduplicate * Improve return type heuristic * Assume text for content type "text/*" * Assume text for empty responses (backwards-compatbility) * Rest will map to Blob * Consolidate functions to determine response type * Ensure proper typing for optimistic API * Improve result type detection * Allow to customize FormData constructor Inspired by * OpenAPITools/openapi-generator#7455 * OpenAPITools/openapi-generator#2939 * Fix defaults.formDataConstructor * || instead of ??
1 parent 4fb5720 commit cf90725

4 files changed

Lines changed: 135 additions & 27 deletions

File tree

demo/binary.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"info": {
3+
"title": "example with binary data download",
4+
"version": "0.0.1"
5+
},
6+
"openapi": "3.0.0",
7+
"servers": [
8+
{
9+
"url": "https://api.example.com"
10+
}
11+
],
12+
"paths": {
13+
"/file/{fileId}/download": {
14+
"get": {
15+
"operationId": "DownloadFile",
16+
"responses": {
17+
"200": {
18+
"description": "Ok",
19+
"content": {
20+
"application/octet-stream": {
21+
"schema": {
22+
"type": "string",
23+
"format": "binary"
24+
}
25+
}
26+
}
27+
}
28+
},
29+
"description": "Get the content of a file.",
30+
"parameters": [
31+
{
32+
"in": "path",
33+
"name": "fileId",
34+
"required": true,
35+
"schema": {
36+
"type": "integer"
37+
}
38+
}
39+
]
40+
}
41+
}
42+
}
43+
}

src/codegen/generate.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,19 @@ describe("generate", () => {
3838
expect(printAst(generate(spec))).toBe(artefact);
3939
});
4040
});
41+
42+
describe('generate with blob download', () => {
43+
let spec: OpenAPIV3.Document;
44+
45+
beforeAll(async () => {
46+
spec = (await SwaggerParser.bundle(
47+
__dirname + "/../../demo/binary.json"
48+
)) as any;
49+
});
50+
51+
it('should generate an api using fetchBlob', async () => {
52+
const artefact = printAst(generate(spec));
53+
const oneLine = artefact.replace(/\s+/g, ' ');
54+
expect(oneLine).toContain('return oazapfts.fetchBlob<{ status: 200; data: Blob; }>(`/file/${fileId}/download`, { ...opts });');
55+
});
56+
});

src/codegen/generate.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -450,15 +450,33 @@ export default function generateApi(spec: OpenAPIV3.Document, opts?: Opts) {
450450
return getTypeFromSchema(getSchemaFromContent(res.content));
451451
}
452452

453-
function hasJsonContent(responses?: OpenAPIV3.ResponsesObject) {
454-
if (!responses) return false;
455-
return Object.values(responses)
456-
.map(resolve)
457-
.some(
458-
(res) =>
459-
!!_.get(res, ["content", "application/json"]) ||
460-
!!_.get(res, ["content", "*/*"])
461-
);
453+
function getResponseType(responses?: OpenAPIV3.ResponsesObject): 'json' | 'text' | 'blob' {
454+
// backwards-compatibility
455+
if (!responses) return 'text';
456+
457+
const resolvedResponses = Object.values(responses).map(resolve);
458+
459+
// if no content is specified, assume `text` (backwards-compatibility)
460+
if (!resolvedResponses.some((res) =>
461+
Object.keys(res.content ?? []).length > 0)) {
462+
return 'text';
463+
}
464+
465+
// if there’s `application/json` or `*/*`, assume `json`
466+
if (resolvedResponses.some((res) =>
467+
!!_.get(res, ["content", "application/json"]) ||
468+
!!_.get(res, ["content", "*/*"]))) {
469+
return 'json';
470+
}
471+
472+
// if there’s `text/*`, assume `text`
473+
if (resolvedResponses.some((res) =>
474+
Object.keys(res.content ?? []).some((type) => type.startsWith("text/")))) {
475+
return 'text';
476+
}
477+
478+
// for the rest, assume `blob`
479+
return 'blob';
462480
}
463481

464482
function getSchemaFromContent(content: any) {
@@ -467,11 +485,20 @@ export default function generateApi(spec: OpenAPIV3.Document, opts?: Opts) {
467485
if (contentType) {
468486
schema = _.get(content, [contentType, "schema"]);
469487
}
470-
return (
471-
schema || {
472-
type: "string",
473-
}
474-
);
488+
if (schema) {
489+
return schema;
490+
}
491+
492+
// if no content is specified -> string
493+
// `text/*` -> string
494+
if (
495+
Object.keys(content).length === 0 ||
496+
Object.keys(content).some(type => type.startsWith("text/"))) {
497+
return { type: "string" };
498+
}
499+
500+
// rest (e.g. `application/octet-stream`, `application/gzip`, …) -> binary
501+
return { type: "string", format: "binary" };
475502
}
476503

477504
function wrapResult(ex: ts.Expression) {
@@ -618,7 +645,7 @@ export default function generateApi(spec: OpenAPIV3.Document, opts?: Opts) {
618645

619646
// Next, build the method body...
620647

621-
const returnsJson = hasJsonContent(responses);
648+
const returnType = getResponseType(responses);
622649
const query = parameters.filter((p) => p.in === "query");
623650
const header = parameters
624651
.filter((p) => p.in === "header")
@@ -706,9 +733,9 @@ export default function generateApi(spec: OpenAPIV3.Document, opts?: Opts) {
706733
ts.createReturn(
707734
wrapResult(
708735
callOazapftsFunction(
709-
returnsJson ? "fetchJson" : "fetchText",
736+
{ json: 'fetchJson', text: 'fetchText', blob: 'fetchBlob' }[returnType],
710737
args,
711-
returnsJson
738+
returnType === 'json' || returnType === 'blob'
712739
? [
713740
getTypeFromResponses(responses!) ||
714741
ts.SyntaxKind.AnyKeyword,

src/runtime/index.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ok } from "../";
55
export type RequestOpts = {
66
baseUrl?: string;
77
fetch?: typeof fetch;
8+
formDataConstructor?: new () => FormData;
89
headers?: Record<string, string | undefined>;
910
} & Omit<RequestInit, "body" | "headers">;
1011

@@ -24,15 +25,7 @@ type MultipartRequestOpts = RequestOpts & {
2425

2526
export function runtime(defaults: RequestOpts) {
2627
async function fetchText(url: string, req?: FetchRequestOpts) {
27-
const { baseUrl, headers, fetch: customFetch, ...init } = {
28-
...defaults,
29-
...req,
30-
};
31-
const href = joinUrl(baseUrl, url);
32-
const res = await (customFetch || fetch)(href, {
33-
...init,
34-
headers: stripUndefined({ ...defaults.headers, ...headers }),
35-
});
28+
const res = await doFetch(url, req);
3629
let data;
3730
try {
3831
data = await res.text();
@@ -62,10 +55,39 @@ export function runtime(defaults: RequestOpts) {
6255
return { status, data } as T;
6356
}
6457

58+
async function fetchBlob<T extends ApiResponse>(
59+
url: string,
60+
req: FetchRequestOpts = {}
61+
) {
62+
const res = await doFetch(url, req);
63+
let data;
64+
try {
65+
data = await res.blob();
66+
} catch (err) {}
67+
return { status: res.status, data } as T;
68+
}
69+
70+
async function doFetch(
71+
url: string,
72+
req: FetchRequestOpts = {}
73+
) {
74+
const { baseUrl, headers, fetch: customFetch, ...init } = {
75+
...defaults,
76+
...req,
77+
};
78+
const href = joinUrl(baseUrl, url);
79+
const res = await (customFetch || fetch)(href, {
80+
...init,
81+
headers: stripUndefined({ ...defaults.headers, ...headers }),
82+
});
83+
return res;
84+
}
85+
6586
return {
6687
ok,
6788
fetchText,
6889
fetchJson,
90+
fetchBlob,
6991

7092
json({ body, headers, ...req }: JsonRequestOpts) {
7193
return {
@@ -91,7 +113,7 @@ export function runtime(defaults: RequestOpts) {
91113

92114
multipart({ body, ...req }: MultipartRequestOpts) {
93115
if (!body) return req;
94-
const data = new FormData();
116+
const data = new (defaults.formDataConstructor || req.formDataConstructor || FormData)();
95117
Object.entries(body).forEach(([name, value]) => {
96118
data.append(name, value);
97119
});

0 commit comments

Comments
 (0)