Skip to content

Commit d42addb

Browse files
committed
feat: support application/octet-stream req/res bodies
1 parent f4a378b commit d42addb

14 files changed

Lines changed: 195 additions & 43 deletions

File tree

packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios-client-builder.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder {
1616
"application/scim+json",
1717
"application/merge-patch+json",
1818
"application/x-www-form-urlencoded",
19+
"application/octet-stream",
1920
"text/json",
2021
"text/plain",
2122
"text/x-markdown",
@@ -69,6 +70,9 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder {
6970
const axiosFragment = `this._request({${[
7071
`url: url ${query ? "+ query" : ""}`,
7172
`method: "${method}"`,
73+
responseSchema?.type === "Blob"
74+
? "responseType: 'arraybuffer'"
75+
: undefined,
7276
requestBody?.parameter
7377
? requestBody.isSupported
7478
? "data: body"
@@ -106,7 +110,9 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder {
106110
107111
return {...res, data: ${this.schemaBuilder.parse(
108112
responseSchema.schema,
109-
"res.data",
113+
responseSchema.type === "Blob"
114+
? "new Blob([res.data], {type: res.headers['content-type'] || 'application/octet-stream'})"
115+
: "res.data",
110116
)}}
111117
`
112118
: `return ${axiosFragment}`
@@ -207,6 +213,16 @@ ${this.legacyExports(clientName)}
207213
return `${param} !== undefined ? ${serialize} : null`
208214
}
209215

216+
case "Blob": {
217+
const serialize = param
218+
219+
if (requestBody.parameter.required) {
220+
return serialize
221+
}
222+
223+
return `${param} !== undefined ? ${serialize} : null`
224+
}
225+
210226
default: {
211227
throw new Error(
212228
`typescript-axios does not support request bodies of content-type '${requestBody.contentType}' using serializer '${requestBody.serializer satisfies never}'`,

packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch-client-builder.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class TypescriptFetchClientBuilder extends AbstractClientBuilder {
1616
"application/scim+json",
1717
"application/merge-patch+json",
1818
"application/x-www-form-urlencoded",
19+
"application/octet-stream",
1920
"text/json",
2021
"text/plain",
2122
"text/x-markdown",
@@ -205,6 +206,16 @@ export class TypescriptFetchClientBuilder extends AbstractClientBuilder {
205206
return `${param} !== undefined ? ${serialize} : null`
206207
}
207208

209+
case "Blob": {
210+
const serialize = param
211+
212+
if (requestBody.parameter.required) {
213+
return serialize
214+
}
215+
216+
return `${param} !== undefined ? ${serialize} : null`
217+
}
218+
208219
default: {
209220
throw new Error(
210221
`typescript-fetch does not support request bodies of content-type '${requestBody.contentType}' using serializer '${requestBody.serializer satisfies never}'`,

packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,15 @@ export abstract class AbstractSchemaBuilder<
202202
const model = maybeModel
203203

204204
switch (model.type) {
205-
case "string":
206-
result = this.string(model)
205+
case "string": {
206+
if (model.format === "byte" || model.format === "binary") {
207+
result = this.any()
208+
} else {
209+
result = this.string(model)
210+
}
211+
207212
break
213+
}
208214
case "number":
209215
result = this.number(model)
210216
break

packages/openapi-code-generator/src/typescript/common/type-builder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ export class TypeBuilder implements ICompilable {
178178
if (schemaObject["x-enum-extensibility"] === "open") {
179179
result.push(this.addStaticType("UnknownEnumStringValue"))
180180
}
181+
} else if (
182+
schemaObject.format === "binary" ||
183+
schemaObject.format === "byte"
184+
) {
185+
result.push("Blob")
181186
} else {
182187
result.push("string")
183188
}

packages/openapi-code-generator/src/typescript/common/typescript-common.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,12 @@ export function buildExport(args: ExportDefinition) {
141141
}
142142
}
143143

144-
export type Serializer = "JSON.stringify" | "String" | "URLSearchParams"
144+
export type Serializer =
145+
| "JSON.stringify"
146+
| "String"
147+
| "URLSearchParams"
148+
| "Blob"
145149
// TODO: support more serializations
146-
// | "Blob"
147150
// | "FormData"
148151

149152
export type RequestBodyAsParameter = {
@@ -169,10 +172,10 @@ function serializerForNormalizedContentType(contentType: string): Serializer {
169172
case "application/x-www-form-urlencoded":
170173
return "URLSearchParams"
171174

175+
case "application/octet-stream":
176+
return "Blob"
177+
172178
// TODO: support more serializations
173-
// case "application/octet-stream":
174-
// return "Blob"
175-
//
176179
// case "multipart/form-data":
177180
// return "FormData"
178181

packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export abstract class AbstractRouterBuilder implements ICompilable {
1717
"application/scim+json",
1818
"application/merge-patch+json",
1919
"application/x-www-form-urlencoded",
20+
"application/octet-stream",
2021
"text/json",
2122
"text/plain",
2223
"text/x-markdown",

packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ export class ExpressRouterBuilder extends AbstractRouterBuilder {
4040

4141
this.imports
4242
.from("@nahkies/typescript-express-runtime/server")
43-
.add("ExpressRuntimeResponse", "SkipResponse", "parseQueryParameters")
43+
.add(
44+
"ExpressRuntimeResponse",
45+
"SkipResponse",
46+
"parseQueryParameters",
47+
"parseOctetStream",
48+
"sendResponse",
49+
)
4450
.addType(
4551
"ExpressRuntimeResponder",
4652
"Params",
@@ -139,7 +145,7 @@ router.${builder.method.toLowerCase()}(\`${builder.route}\`, async (req: Request
139145
const input = {
140146
params: ${params.path.schema ? `parseRequestInput(${params.path.name}, req.params, RequestInputType.RouteParam)` : "undefined"},
141147
query: ${params.query.schema ? `parseRequestInput(${params.query.name}, ${params.query.isSimpleQuery ? `req.query` : `parseQueryParameters(new URL(\`http://localhost\${req.originalUrl}\`).search, ${JSON.stringify(params.query.parameters)})`}, RequestInputType.QueryString)` : "undefined"},
142-
${params.body.schema && !params.body.isSupported ? `// todo: request bodies with content-type '${params.body.contentType}' not yet supported\n` : ""}body: ${params.body.schema ? `parseRequestInput(${params.body.schema}, req.body, RequestInputType.RequestBody)${!params.body.isSupported ? " as never" : ""}` : "undefined"},
148+
${params.body.schema && !params.body.isSupported ? `// todo: request bodies with content-type '${params.body.contentType}' not yet supported\n` : ""}body: ${params.body.schema ? (params.body.contentType === "application/octet-stream" ? `parseRequestInput(${symbols.requestBodySchema}, await parseOctetStream(req), RequestInputType.RequestBody)` : `parseRequestInput(${params.body.schema}, req.body, RequestInputType.RequestBody)${!params.body.isSupported ? " as never" : ""}`) : "undefined"},
143149
headers: ${params.header.schema ? `parseRequestInput(${params.header.name}, req.headers, RequestInputType.RequestHeader)` : "undefined"}
144150
}
145151
@@ -155,13 +161,7 @@ router.${builder.method.toLowerCase()}(\`${builder.route}\`, async (req: Request
155161
156162
const { status, body } = response instanceof ExpressRuntimeResponse ? response.unpack() : response
157163
158-
res.status(status)
159-
160-
if (body !== undefined) {
161-
res.json(${symbols.responseBodyValidator}(status, body))
162-
} else {
163-
res.end()
164-
}
164+
await sendResponse(res, status, body, ${symbols.responseBodyValidator})
165165
} catch (error) {
166166
next(error)
167167
}

packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class KoaRouterBuilder extends AbstractRouterBuilder {
3838
"SkipResponse",
3939
"startServer",
4040
"parseQueryParameters",
41+
"parseOctetStream",
4142
)
4243
.addType(
4344
"KoaRuntimeResponder",
@@ -147,7 +148,7 @@ router.${builder.method.toLowerCase()}('${symbols.implPropName}','${builder.rout
147148
const input = {
148149
params: ${params.path.schema ? `parseRequestInput(${params.path.name}, ctx.params, RequestInputType.RouteParam)` : "undefined"},
149150
query: ${params.query.schema ? `parseRequestInput(${params.query.name}, ${params.query.isSimpleQuery ? "ctx.query" : `parseQueryParameters(ctx.querystring, ${JSON.stringify(params.query.parameters)})`}, RequestInputType.QueryString)` : "undefined"},
150-
${params.body.schema && !params.body.isSupported ? `// todo: request bodies with content-type '${params.body.contentType}' not yet supported\n` : ""}body: ${params.body.schema ? `parseRequestInput(${params.body.schema}, Reflect.get(ctx.request, "body"), RequestInputType.RequestBody)${!params.body.isSupported ? " as never" : ""}` : "undefined"},
151+
${params.body.schema && !params.body.isSupported ? `// todo: request bodies with content-type '${params.body.contentType}' not yet supported\n` : ""}body: ${params.body.schema ? (params.body.contentType === "application/octet-stream" ? `parseRequestInput(${symbols.requestBodySchema}, await parseOctetStream(ctx), RequestInputType.RequestBody)` : `parseRequestInput(${params.body.schema}, Reflect.get(ctx.request, "body"), RequestInputType.RequestBody)${!params.body.isSupported ? " as never" : ""}`) : "undefined"},
151152
headers: ${params.header.schema ? `parseRequestInput(${params.header.name}, Reflect.get(ctx.request, "headers"), RequestInputType.RequestHeader)` : "undefined"}
152153
}
153154

packages/typescript-express-runtime/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
},
5252
"dependencies": {
5353
"@nahkies/typescript-common-runtime": "workspace:^",
54+
"raw-body": "^3.0.0",
5455
"tslib": "^2.8.1"
5556
},
5657
"peerDependencies": {

packages/typescript-express-runtime/src/server.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import Cors, {type CorsOptions, type CorsOptionsDelegate} from "cors"
1010
import express, {
1111
type ErrorRequestHandler,
1212
type Express,
13+
type Response as ExpressResponse,
14+
type Request,
1315
type RequestHandler,
1416
type Router,
1517
} from "express"
18+
import getRawBody from "raw-body"
1619

1720
export {parseQueryParameters} from "@nahkies/typescript-common-runtime/query-parser"
1821
export type {
@@ -25,6 +28,8 @@ export type {
2528
StatusCode5xx,
2629
} from "@nahkies/typescript-common-runtime/types"
2730

31+
export type ResponseValidator = (status: number, value: unknown) => any
32+
2833
export const SkipResponse = Symbol("skip response processing")
2934

3035
export class ExpressRuntimeResponse<Type> {
@@ -188,3 +193,64 @@ export async function startServer({
188193
}
189194
})
190195
}
196+
197+
export async function parseOctetStream(
198+
req: Request,
199+
): Promise<Blob | undefined> {
200+
const contentLength = req.headers["content-length"]
201+
? parseInt(req.headers["content-length"], 10)
202+
: undefined
203+
204+
if (!contentLength) {
205+
throw new Error("No content length provided")
206+
}
207+
208+
const body = await getRawBody(req, {
209+
length: contentLength,
210+
limit: "1mb",
211+
})
212+
213+
if (!body) {
214+
return undefined
215+
}
216+
217+
if (!Buffer.isBuffer(body)) {
218+
throw new Error("body must be a buffer")
219+
}
220+
221+
const blob = new Blob([new Uint8Array(body)], {
222+
type: "application/octet-stream",
223+
})
224+
225+
return blob
226+
}
227+
228+
export async function sendResponse(
229+
res: ExpressResponse,
230+
status: number,
231+
body: unknown,
232+
validator: ResponseValidator,
233+
) {
234+
res.status(status)
235+
236+
if (body === undefined) {
237+
res.end()
238+
return
239+
}
240+
241+
if (body instanceof Blob) {
242+
await sendBlob(res, body)
243+
} else {
244+
res.json(validator(status, body))
245+
}
246+
}
247+
248+
export async function sendBlob(res: ExpressResponse, body: Blob) {
249+
const arrayBuffer = await body.arrayBuffer()
250+
const buffer = Buffer.from(arrayBuffer)
251+
252+
res.setHeader("Content-Type", body.type ?? "application/octet-stream")
253+
res.setHeader("Content-Length", buffer.length)
254+
255+
res.send(buffer)
256+
}

0 commit comments

Comments
 (0)