diff --git a/e2e/openapi.yaml b/e2e/openapi.yaml index e177869e2..f9c27191a 100644 --- a/e2e/openapi.yaml +++ b/e2e/openapi.yaml @@ -323,6 +323,25 @@ paths: application/json: schema: $ref: '#/components/schemas/ProductOrder' + /media-types/octet-stream: + post: + tags: + - media types + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + maxLength: 20971520 + responses: + 200: + description: ok + content: + application/octet-stream: + schema: + type: string + format: binary /escape-hatches/plain-text: get: diff --git a/e2e/src/generated/client/axios/client.ts b/e2e/src/generated/client/axios/client.ts index ec75cfd3d..777cb22fc 100644 --- a/e2e/src/generated/client/axios/client.ts +++ b/e2e/src/generated/client/axios/client.ts @@ -444,6 +444,33 @@ export class E2ETestClient extends AbstractAxiosClient { return {...res, data: s_ProductOrder.parse(res.data)} } + async postMediaTypesOctetStream( + p: { + requestBody: Blob + }, + timeout?: number, + opts: AxiosRequestConfig = {}, + ): Promise> { + const url = `/media-types/octet-stream` + const headers = this._headers( + {Accept: "application/json", "Content-Type": "application/octet-stream"}, + opts.headers, + ) + const body = p.requestBody + + const res = await this._request({ + url: url, + method: "POST", + responseType: "arraybuffer", + data: body, + ...(timeout ? {timeout} : {}), + ...opts, + headers, + }) + + return {...res, data: z.any().parse(this._parseBlobResponse(res))} + } + async getEscapeHatchesPlainText( timeout?: number, opts: AxiosRequestConfig = {}, diff --git a/e2e/src/generated/client/fetch/client.ts b/e2e/src/generated/client/fetch/client.ts index a6fac2605..62fcae4e8 100644 --- a/e2e/src/generated/client/fetch/client.ts +++ b/e2e/src/generated/client/fetch/client.ts @@ -416,6 +416,29 @@ export class E2ETestClient extends AbstractFetchClient { return responseValidationFactory([["200", s_ProductOrder]], undefined)(res) } + async postMediaTypesOctetStream( + p: { + requestBody: Blob + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/media-types/octet-stream` + const headers = this._headers( + {Accept: "application/json", "Content-Type": "application/octet-stream"}, + opts.headers, + ) + const body = p.requestBody + + const res = this._fetch( + url, + {method: "POST", body, ...opts, headers}, + timeout, + ) + + return responseValidationFactory([["200", z.any()]], undefined)(res) + } + async getEscapeHatchesPlainText( timeout?: number, opts: RequestInit = {}, diff --git a/e2e/src/generated/server/express/routes/media-types.ts b/e2e/src/generated/server/express/routes/media-types.ts index 7f59f6083..d2bf5a95f 100644 --- a/e2e/src/generated/server/express/routes/media-types.ts +++ b/e2e/src/generated/server/express/routes/media-types.ts @@ -9,6 +9,7 @@ import { handleImplementationError, handleResponse, type Params, + parseOctetStream, type SkipResponse, type StatusCode, } from "@nahkies/typescript-express-runtime/server" @@ -45,9 +46,22 @@ export type PostMediaTypesXWwwFormUrlencoded = ( next: NextFunction, ) => Promise | typeof SkipResponse> +export type PostMediaTypesOctetStreamResponder = { + with200(): ExpressRuntimeResponse +} & ExpressRuntimeResponder + +export type PostMediaTypesOctetStream = ( + params: Params, + respond: PostMediaTypesOctetStreamResponder, + req: Request, + res: Response, + next: NextFunction, +) => Promise | typeof SkipResponse> + export type MediaTypesImplementation = { postMediaTypesText: PostMediaTypesText postMediaTypesXWwwFormUrlencoded: PostMediaTypesXWwwFormUrlencoded + postMediaTypesOctetStream: PostMediaTypesOctetStream } export function createMediaTypesRouter( @@ -138,6 +152,46 @@ export function createMediaTypesRouter( }, ) + const postMediaTypesOctetStreamResponseBodyValidator = + responseValidationFactory([["200", z.any()]], undefined) + + // postMediaTypesOctetStream + router.post( + `/media-types/octet-stream`, + async (req: Request, res: Response, next: NextFunction) => { + try { + const input = { + params: undefined, + query: undefined, + body: parseRequestInput( + z.any(), + await parseOctetStream(req, "20mb"), + RequestInputType.RequestBody, + ), + headers: undefined, + } + + const responder = { + with200() { + return new ExpressRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new ExpressRuntimeResponse(status) + }, + } + + await implementation + .postMediaTypesOctetStream(input, responder, req, res, next) + .catch(handleImplementationError) + .then( + handleResponse(res, postMediaTypesOctetStreamResponseBodyValidator), + ) + } catch (error) { + next(error) + } + }, + ) + return router } diff --git a/e2e/src/generated/server/koa/routes/media-types.ts b/e2e/src/generated/server/koa/routes/media-types.ts index 37eed7e98..fed2f9e4f 100644 --- a/e2e/src/generated/server/koa/routes/media-types.ts +++ b/e2e/src/generated/server/koa/routes/media-types.ts @@ -10,6 +10,7 @@ import { type KoaRuntimeResponder, KoaRuntimeResponse, type Params, + parseOctetStream, type Res, type SkipResponse, type StatusCode, @@ -49,9 +50,21 @@ export type PostMediaTypesXWwwFormUrlencoded = ( KoaRuntimeResponse | Res<200, t_ProductOrder> | typeof SkipResponse > +export type PostMediaTypesOctetStreamResponder = { + with200(): KoaRuntimeResponse +} & KoaRuntimeResponder + +export type PostMediaTypesOctetStream = ( + params: Params, + respond: PostMediaTypesOctetStreamResponder, + ctx: RouterContext, + next: Next, +) => Promise | Res<200, Blob> | typeof SkipResponse> + export type MediaTypesImplementation = { postMediaTypesText: PostMediaTypesText postMediaTypesXWwwFormUrlencoded: PostMediaTypesXWwwFormUrlencoded + postMediaTypesOctetStream: PostMediaTypesOctetStream } export function createMediaTypesRouter( @@ -131,6 +144,44 @@ export function createMediaTypesRouter( }, ) + const postMediaTypesOctetStreamResponseValidator = responseValidationFactory( + [["200", z.any()]], + undefined, + ) + + router.post( + "postMediaTypesOctetStream", + "/media-types/octet-stream", + async (ctx, next) => { + const input = { + params: undefined, + query: undefined, + body: parseRequestInput( + z.any(), + await parseOctetStream(ctx, "20mb"), + RequestInputType.RequestBody, + ), + headers: undefined, + } + + const responder = { + with200() { + return new KoaRuntimeResponse(200) + }, + withStatus(status: StatusCode) { + return new KoaRuntimeResponse(status) + }, + } + + await implementation + .postMediaTypesOctetStream(input, responder, ctx, next) + .catch(handleImplementationError) + .then( + handleResponse(ctx, next, postMediaTypesOctetStreamResponseValidator), + ) + }, + ) + return router } diff --git a/e2e/src/index.axios.spec.ts b/e2e/src/index.axios.spec.ts index 813a25814..3e84e0bab 100644 --- a/e2e/src/index.axios.spec.ts +++ b/e2e/src/index.axios.spec.ts @@ -430,6 +430,20 @@ describe.each( }) }) + describe("POST /media-types/octet-stream", () => { + it("can send and parse application/octet-stream request bodies", async () => { + const blob = new Blob([new Uint8Array([0xde, 0xad, 0xbe, 0xef])], { + type: "application/octet-stream", + }) + const res = await client.postMediaTypesOctetStream({ + requestBody: blob, + }) + expect(res.status).toBe(200) + + await expect(res.data).toEqualBlob(blob) + }) + }) + describe("query parameters", () => { it("GET /params/simple-query", async () => { const {status, data} = await client.getParamsSimpleQuery({ diff --git a/e2e/src/index.fetch.spec.ts b/e2e/src/index.fetch.spec.ts index d5517a2b6..ed52b44c1 100644 --- a/e2e/src/index.fetch.spec.ts +++ b/e2e/src/index.fetch.spec.ts @@ -431,6 +431,20 @@ describe.each( }) }) + describe("POST /media-types/octet-stream", () => { + it("can send and parse application/octet-stream request bodies", async () => { + const blob = new Blob([new Uint8Array([0xde, 0xad, 0xbe, 0xef])], { + type: "application/octet-stream", + }) + const res = await client.postMediaTypesOctetStream({ + requestBody: blob, + }) + expect(res.status).toBe(200) + + await expect(await res.blob()).toEqualBlob(blob) + }) + }) + describe("query parameters", () => { it("GET /params/simple-query", async () => { const res = await client.getParamsSimpleQuery({ diff --git a/e2e/src/jest.d.ts b/e2e/src/jest.d.ts new file mode 100644 index 000000000..c076b5168 --- /dev/null +++ b/e2e/src/jest.d.ts @@ -0,0 +1,10 @@ +import type {CustomMatcherResult} from "expect" + +declare module "expect" { + interface AsymmetricMatchers { + toEqualBlob(this: R, expected: Blob): Promise + } + interface Matchers { + toEqualBlob(this: R, expected: Blob): Promise + } +} diff --git a/e2e/src/routes/express/media-types.ts b/e2e/src/routes/express/media-types.ts index 8211a4438..11e8ce7a1 100644 --- a/e2e/src/routes/express/media-types.ts +++ b/e2e/src/routes/express/media-types.ts @@ -1,6 +1,7 @@ import {SkipResponse} from "@nahkies/typescript-express-runtime/server" import { createRouter, + type PostMediaTypesOctetStream, type PostMediaTypesText, type PostMediaTypesXWwwFormUrlencoded, } from "../../generated/server/express/routes/media-types.ts" @@ -23,9 +24,17 @@ const postMediaTypesXWwwFormUrlencoded: PostMediaTypesXWwwFormUrlencoded = return respond.with200().body(body) } +const postMediaTypesOctetStream: PostMediaTypesOctetStream = async ( + {body}, + respond, +) => { + return respond.with200().body(body) +} + export function createMediaTypesRouter() { return createRouter({ postMediaTypesText, postMediaTypesXWwwFormUrlencoded, + postMediaTypesOctetStream, }) } diff --git a/e2e/src/routes/koa/media-types.ts b/e2e/src/routes/koa/media-types.ts index 557e7df4b..9956ba0b4 100644 --- a/e2e/src/routes/koa/media-types.ts +++ b/e2e/src/routes/koa/media-types.ts @@ -1,6 +1,7 @@ import {SkipResponse} from "@nahkies/typescript-koa-runtime/server" import { createRouter, + type PostMediaTypesOctetStream, type PostMediaTypesText, type PostMediaTypesXWwwFormUrlencoded, } from "../../generated/server/koa/routes/media-types.ts" @@ -24,9 +25,17 @@ const postMediaTypesXWwwFormUrlencoded: PostMediaTypesXWwwFormUrlencoded = return respond.with200().body(body) } +const postMediaTypesOctetStream: PostMediaTypesOctetStream = async ( + {body}, + respond, +) => { + return respond.with200().body(body) +} + export function createMediaTypesRouter() { return createRouter({ postMediaTypesText, postMediaTypesXWwwFormUrlencoded, + postMediaTypesOctetStream, }) } diff --git a/e2e/src/test-utils.ts b/e2e/src/test-utils.ts index 5cdf233b3..b81eaf6d7 100644 --- a/e2e/src/test-utils.ts +++ b/e2e/src/test-utils.ts @@ -1,3 +1,5 @@ +import {Blob} from "node:buffer" +import {expect} from "@jest/globals" import {AsymmetricMatcher} from "expect" class NumberInRange extends AsymmetricMatcher { @@ -23,3 +25,42 @@ class NumberInRange extends AsymmetricMatcher { export const numberBetween = (min: number, max: number) => new NumberInRange(min, max) + +expect.extend({ + async toEqualBlob(received: Blob, expected: Blob) { + if (!(received instanceof Blob)) { + return { + pass: false, + message: () => + "received is not a blob:\n" + + `received: '${this.utils.printReceived(received)}'` + + `expected: '${this.utils.printExpected(expected)}'`, + } + } + + const [bufA, bufB] = await Promise.all([ + received.arrayBuffer(), + expected.arrayBuffer(), + ]) + + const a = new Uint8Array(bufA) + const b = new Uint8Array(bufB) + + const pass = a.length === b.length && a.every((v, i) => v === b[i]) + + if (pass) { + return { + pass: true, + message: () => "expected blobs not to be equal", + } + } else { + return { + pass: false, + message: () => + `expected blobs to be equal, but got:\n` + + `received: ${[...a].map((x) => x.toString(16).padStart(2, "0")).join(" ")}\n` + + `expected: ${[...b].map((x) => x.toString(16).padStart(2, "0")).join(" ")}`, + } + } + }, +}) diff --git a/integration-tests-definitions/todo-lists.yaml b/integration-tests-definitions/todo-lists.yaml index 1a77111df..06282e883 100644 --- a/integration-tests-definitions/todo-lists.yaml +++ b/integration-tests-definitions/todo-lists.yaml @@ -190,6 +190,23 @@ paths: responses: 202: description: accepted + /attachments/{id}: + parameters: + - name: id + in: path + schema: + type: string + put: + operationId: replaceAttachment + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + 202: + description: accepted components: schemas: CreateUpdateTodoList: diff --git a/integration-tests/typescript-angular/src/generated/api.github.com.yaml/client.service.ts b/integration-tests/typescript-angular/src/generated/api.github.com.yaml/client.service.ts index a80ba5ca0..e7b95c726 100644 --- a/integration-tests/typescript-angular/src/generated/api.github.com.yaml/client.service.ts +++ b/integration-tests/typescript-angular/src/generated/api.github.com.yaml/client.service.ts @@ -7870,13 +7870,14 @@ export class GitHubV3RestApiService { > { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/orgs/${p["org"]}/migrations/${p["migrationId"]}/archive`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -11673,13 +11674,14 @@ export class GitHubV3RestApiService { > { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/repos/${p["owner"]}/${p["repo"]}/actions/artifacts/${p["artifactId"]}/${p["archiveFormat"]}`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -11814,13 +11816,14 @@ export class GitHubV3RestApiService { }): Observable<(HttpResponse & {status: 302}) | HttpResponse> { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/repos/${p["owner"]}/${p["repo"]}/actions/jobs/${p["jobId"]}/logs`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -12720,13 +12723,14 @@ export class GitHubV3RestApiService { }): Observable<(HttpResponse & {status: 302}) | HttpResponse> { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/repos/${p["owner"]}/${p["repo"]}/actions/runs/${p["runId"]}/attempts/${p["attemptNumber"]}/logs`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -12844,13 +12848,14 @@ export class GitHubV3RestApiService { }): Observable<(HttpResponse & {status: 302}) | HttpResponse> { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/repos/${p["owner"]}/${p["repo"]}/actions/runs/${p["runId"]}/logs`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -22428,7 +22433,7 @@ export class GitHubV3RestApiService { releaseId: number name: string label?: string - requestBody?: never + requestBody?: Blob }, basePath: | Server<"reposUploadReleaseAsset_GitHubV3RestApiService"> @@ -22440,8 +22445,13 @@ export class GitHubV3RestApiService { | (HttpResponse & {status: 422}) | HttpResponse > { - const headers = this._headers({Accept: "application/json"}) + const headers = this._headers({ + Accept: "application/json", + "Content-Type": + p.requestBody !== undefined ? "application/octet-stream" : undefined, + }) const params = this._query({name: p["name"], label: p["label"]}) + const body = p["requestBody"] return this.httpClient.request( "POST", @@ -22450,7 +22460,7 @@ export class GitHubV3RestApiService { { params, headers, - // todo: request bodies with content-type 'application/octet-stream' not yet supported, + body, observe: "response", reportProgress: false, }, @@ -23615,13 +23625,14 @@ export class GitHubV3RestApiService { }): Observable<(HttpResponse & {status: 302}) | HttpResponse> { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/repos/${p["owner"]}/${p["repo"]}/tarball/${p["ref"]}`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -23887,13 +23898,14 @@ export class GitHubV3RestApiService { }): Observable<(HttpResponse & {status: 302}) | HttpResponse> { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/repos/${p["owner"]}/${p["repo"]}/zipball/${p["ref"]}`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -26634,12 +26646,13 @@ export class GitHubV3RestApiService { > { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/user/migrations/${p["migrationId"]}/archive`, { headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) diff --git a/integration-tests/typescript-angular/src/generated/okta.oauth.yaml/client.service.ts b/integration-tests/typescript-angular/src/generated/okta.oauth.yaml/client.service.ts index 027777932..d3a8dd28c 100644 --- a/integration-tests/typescript-angular/src/generated/okta.oauth.yaml/client.service.ts +++ b/integration-tests/typescript-angular/src/generated/okta.oauth.yaml/client.service.ts @@ -225,13 +225,14 @@ export class OktaOpenIdConnectOAuth20Service { state: p["state"], }) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/oauth2/v1/authorize`, { params, headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -244,13 +245,14 @@ export class OktaOpenIdConnectOAuth20Service { > { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "POST", this.config.basePath + `/oauth2/v1/authorize`, { headers, // todo: request bodies with content-type 'application/x-www-form-urlencoded' not yet supported, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -864,7 +866,7 @@ export class OktaOpenIdConnectOAuth20Service { state: p["state"], }) - return this.httpClient.request( + return this.httpClient.request( "GET", this.config.basePath + `/oauth2/${p["authorizationServerId"]}/v1/authorize`, @@ -872,6 +874,7 @@ export class OktaOpenIdConnectOAuth20Service { params, headers, observe: "response", + responseType: "blob", reportProgress: false, }, ) @@ -885,7 +888,7 @@ export class OktaOpenIdConnectOAuth20Service { > { const headers = this._headers({Accept: "application/json"}) - return this.httpClient.request( + return this.httpClient.request( "POST", this.config.basePath + `/oauth2/${p["authorizationServerId"]}/v1/authorize`, @@ -893,6 +896,7 @@ export class OktaOpenIdConnectOAuth20Service { headers, // todo: request bodies with content-type 'application/x-www-form-urlencoded' not yet supported, observe: "response", + responseType: "blob", reportProgress: false, }, ) diff --git a/integration-tests/typescript-angular/src/generated/stripe.yaml/client.service.ts b/integration-tests/typescript-angular/src/generated/stripe.yaml/client.service.ts index c1ef22a4a..76ba90d0c 100644 --- a/integration-tests/typescript-angular/src/generated/stripe.yaml/client.service.ts +++ b/integration-tests/typescript-angular/src/generated/stripe.yaml/client.service.ts @@ -12482,7 +12482,7 @@ export class StripeApiService { | Server<"getQuotesQuotePdf_StripeApiService"> | string = StripeApiServiceServers.operations.getQuotesQuotePdf().build(), ): Observable< - | (HttpResponse & {status: 200}) + | (HttpResponse & {status: 200}) | (HttpResponse & {status: StatusCode}) | HttpResponse > { @@ -12497,7 +12497,7 @@ export class StripeApiService { }, ) - return this.httpClient.request( + return this.httpClient.request( "GET", basePath + `/v1/quotes/${p["quote"]}/pdf`, { @@ -12505,6 +12505,7 @@ export class StripeApiService { headers, // todo: request bodies with content-type 'application/x-www-form-urlencoded' not yet supported, observe: "response", + responseType: "blob", reportProgress: false, }, ) diff --git a/integration-tests/typescript-angular/src/generated/todo-lists.yaml/client.service.ts b/integration-tests/typescript-angular/src/generated/todo-lists.yaml/client.service.ts index 47bcb79ff..bb67adbe0 100644 --- a/integration-tests/typescript-angular/src/generated/todo-lists.yaml/client.service.ts +++ b/integration-tests/typescript-angular/src/generated/todo-lists.yaml/client.service.ts @@ -423,6 +423,28 @@ export class TodoListsExampleApiService { reportProgress: false, }) } + + replaceAttachment(p: { + id: string + requestBody: Blob + }): Observable<(HttpResponse & {status: 202}) | HttpResponse> { + const headers = this._headers({ + Accept: "application/json", + "Content-Type": "application/octet-stream", + }) + const body = p["requestBody"] + + return this.httpClient.request( + "PUT", + this.config.basePath + `/attachments/${p["id"]}`, + { + headers, + body, + observe: "response", + reportProgress: false, + }, + ) + } } export {TodoListsExampleApiService as ApiClient} diff --git a/integration-tests/typescript-axios/src/generated/api.github.com.yaml/client.ts b/integration-tests/typescript-axios/src/generated/api.github.com.yaml/client.ts index fe8a5a3e6..58d6961cb 100644 --- a/integration-tests/typescript-axios/src/generated/api.github.com.yaml/client.ts +++ b/integration-tests/typescript-axios/src/generated/api.github.com.yaml/client.ts @@ -21024,7 +21024,7 @@ export class GitHubV3RestApi extends AbstractAxiosClient { releaseId: number name: string label?: string - requestBody?: never + requestBody?: Blob }, basePath: | Server<"reposUploadReleaseAsset_GitHubV3RestApi"> @@ -21035,13 +21035,21 @@ export class GitHubV3RestApi extends AbstractAxiosClient { opts: AxiosRequestConfig = {}, ): Promise> { const url = `/repos/${p["owner"]}/${p["repo"]}/releases/${p["releaseId"]}/assets` - const headers = this._headers({Accept: "application/json"}, opts.headers) + const headers = this._headers( + { + Accept: "application/json", + "Content-Type": + p.requestBody !== undefined ? "application/octet-stream" : false, + }, + opts.headers, + ) const query = this._query({name: p["name"], label: p["label"]}) + const body = p.requestBody !== undefined ? p.requestBody : null return this._request({ url: url + query, method: "POST", - // todo: request bodies with content-type 'application/octet-stream' not yet supported, + data: body, baseURL: basePath, ...(timeout ? {timeout} : {}), ...opts, diff --git a/integration-tests/typescript-axios/src/generated/stripe.yaml/client.ts b/integration-tests/typescript-axios/src/generated/stripe.yaml/client.ts index 25b643ab6..b7d5a3eef 100644 --- a/integration-tests/typescript-axios/src/generated/stripe.yaml/client.ts +++ b/integration-tests/typescript-axios/src/generated/stripe.yaml/client.ts @@ -14717,7 +14717,7 @@ export class StripeApi extends AbstractAxiosClient { | string = StripeApiServers.operations.getQuotesQuotePdf().build(), timeout?: number, opts: AxiosRequestConfig = {}, - ): Promise> { + ): Promise> { const url = `/v1/quotes/${p["quote"]}/pdf` const headers = this._headers({Accept: "application/json"}, opts.headers) const query = this._query( diff --git a/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts b/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts index 626cdeea6..4cedb328f 100644 --- a/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts +++ b/integration-tests/typescript-axios/src/generated/todo-lists.yaml/client.ts @@ -349,6 +349,31 @@ export class TodoListsExampleApi extends AbstractAxiosClient { headers, }) } + + async replaceAttachment( + p: { + id: string + requestBody: Blob + }, + timeout?: number, + opts: AxiosRequestConfig = {}, + ): Promise> { + const url = `/attachments/${p["id"]}` + const headers = this._headers( + {Accept: "application/json", "Content-Type": "application/octet-stream"}, + opts.headers, + ) + const body = p.requestBody + + return this._request({ + url: url, + method: "PUT", + data: body, + ...(timeout ? {timeout} : {}), + ...opts, + headers, + }) + } } export {TodoListsExampleApi as ApiClient} diff --git a/integration-tests/typescript-express/src/generated/api.github.com.yaml/generated.ts b/integration-tests/typescript-express/src/generated/api.github.com.yaml/generated.ts index cc30fc53e..9eccc1dd7 100644 --- a/integration-tests/typescript-express/src/generated/api.github.com.yaml/generated.ts +++ b/integration-tests/typescript-express/src/generated/api.github.com.yaml/generated.ts @@ -9,6 +9,7 @@ import { handleImplementationError, handleResponse, type Params, + parseOctetStream, type ServerConfig, type SkipResponse, type StatusCode, @@ -16609,7 +16610,7 @@ export type ReposUploadReleaseAsset = ( params: Params< t_ReposUploadReleaseAssetParamSchema, t_ReposUploadReleaseAssetQuerySchema, - never | undefined, + Blob | undefined, void >, respond: ReposUploadReleaseAssetResponder, @@ -72459,12 +72460,11 @@ export function createRouter(implementation: Implementation): Router { req.query, RequestInputType.QueryString, ), - // todo: request bodies with content-type 'application/octet-stream' not yet supported body: parseRequestInput( - z.never().optional(), - req.body, + z.any().optional(), + await parseOctetStream(req, "10mb"), RequestInputType.RequestBody, - ) as never, + ), headers: undefined, } diff --git a/integration-tests/typescript-express/src/generated/stripe.yaml/generated.ts b/integration-tests/typescript-express/src/generated/stripe.yaml/generated.ts index ddb5f2d72..f63f21ff2 100644 --- a/integration-tests/typescript-express/src/generated/stripe.yaml/generated.ts +++ b/integration-tests/typescript-express/src/generated/stripe.yaml/generated.ts @@ -7937,7 +7937,7 @@ export type GetQuotesQuoteLineItems = ( ) => Promise | typeof SkipResponse> export type GetQuotesQuotePdfResponder = { - with200(): ExpressRuntimeResponse + with200(): ExpressRuntimeResponse withDefault(status: StatusCode): ExpressRuntimeResponse } & ExpressRuntimeResponder @@ -39750,7 +39750,7 @@ export function createRouter(implementation: Implementation): Router { }) const getQuotesQuotePdfResponseBodyValidator = responseValidationFactory( - [["200", z.string()]], + [["200", z.any()]], s_error, ) @@ -39786,7 +39786,7 @@ export function createRouter(implementation: Implementation): Router { const responder = { with200() { - return new ExpressRuntimeResponse(200) + return new ExpressRuntimeResponse(200) }, withDefault(status: StatusCode) { return new ExpressRuntimeResponse(status) diff --git a/integration-tests/typescript-express/src/generated/todo-lists.yaml/generated.ts b/integration-tests/typescript-express/src/generated/todo-lists.yaml/generated.ts index bd197b600..f03f369ec 100644 --- a/integration-tests/typescript-express/src/generated/todo-lists.yaml/generated.ts +++ b/integration-tests/typescript-express/src/generated/todo-lists.yaml/generated.ts @@ -9,6 +9,7 @@ import { handleImplementationError, handleResponse, type Params, + parseOctetStream, type ServerConfig, type SkipResponse, type StatusCode, @@ -31,6 +32,7 @@ import type { t_GetTodoListByIdParamSchema, t_GetTodoListItemsParamSchema, t_GetTodoListsQuerySchema, + t_ReplaceAttachmentParamSchema, t_TodoList, t_UnknownObject, t_UpdateTodoListByIdParamSchema, @@ -165,6 +167,18 @@ export type UploadAttachment = ( next: NextFunction, ) => Promise | typeof SkipResponse> +export type ReplaceAttachmentResponder = { + with202(): ExpressRuntimeResponse +} & ExpressRuntimeResponder + +export type ReplaceAttachment = ( + params: Params, + respond: ReplaceAttachmentResponder, + req: Request, + res: Response, + next: NextFunction, +) => Promise | typeof SkipResponse> + export type Implementation = { getTodoLists: GetTodoLists getTodoListById: GetTodoListById @@ -174,6 +188,7 @@ export type Implementation = { createTodoListItem: CreateTodoListItem listAttachments: ListAttachments uploadAttachment: UploadAttachment + replaceAttachment: ReplaceAttachment } export function createRouter(implementation: Implementation): Router { @@ -579,6 +594,52 @@ export function createRouter(implementation: Implementation): Router { }, ) + const replaceAttachmentParamSchema = z.object({id: z.string().optional()}) + + const replaceAttachmentResponseBodyValidator = responseValidationFactory( + [["202", z.undefined()]], + undefined, + ) + + // replaceAttachment + router.put( + `/attachments/:id`, + async (req: Request, res: Response, next: NextFunction) => { + try { + const input = { + params: parseRequestInput( + replaceAttachmentParamSchema, + req.params, + RequestInputType.RouteParam, + ), + query: undefined, + body: parseRequestInput( + z.any(), + await parseOctetStream(req, "10mb"), + RequestInputType.RequestBody, + ), + headers: undefined, + } + + const responder = { + with202() { + return new ExpressRuntimeResponse(202) + }, + withStatus(status: StatusCode) { + return new ExpressRuntimeResponse(status) + }, + } + + await implementation + .replaceAttachment(input, responder, req, res, next) + .catch(handleImplementationError) + .then(handleResponse(res, replaceAttachmentResponseBodyValidator)) + } catch (error) { + next(error) + } + }, + ) + return router } diff --git a/integration-tests/typescript-express/src/generated/todo-lists.yaml/models.ts b/integration-tests/typescript-express/src/generated/todo-lists.yaml/models.ts index c67a8a24c..7450c8fb4 100644 --- a/integration-tests/typescript-express/src/generated/todo-lists.yaml/models.ts +++ b/integration-tests/typescript-express/src/generated/todo-lists.yaml/models.ts @@ -54,6 +54,10 @@ export type t_GetTodoListsQuerySchema = { tags?: string[] | undefined } +export type t_ReplaceAttachmentParamSchema = { + id?: string | undefined +} + export type t_UpdateTodoListByIdParamSchema = { listId: string } diff --git a/integration-tests/typescript-fetch/src/generated/api.github.com.yaml/client.ts b/integration-tests/typescript-fetch/src/generated/api.github.com.yaml/client.ts index f4c462b9a..781a741f2 100644 --- a/integration-tests/typescript-fetch/src/generated/api.github.com.yaml/client.ts +++ b/integration-tests/typescript-fetch/src/generated/api.github.com.yaml/client.ts @@ -19061,7 +19061,7 @@ export class GitHubV3RestApi extends AbstractFetchClient { releaseId: number name: string label?: string - requestBody?: never + requestBody?: Blob }, basePath: | Server<"reposUploadReleaseAsset_GitHubV3RestApi"> @@ -19074,17 +19074,20 @@ export class GitHubV3RestApi extends AbstractFetchClient { const url = basePath + `/repos/${p["owner"]}/${p["repo"]}/releases/${p["releaseId"]}/assets` - const headers = this._headers({Accept: "application/json"}, opts.headers) + const headers = this._headers( + { + Accept: "application/json", + "Content-Type": + p.requestBody !== undefined ? "application/octet-stream" : undefined, + }, + opts.headers, + ) const query = this._query({name: p["name"], label: p["label"]}) + const body = p.requestBody !== undefined ? p.requestBody : null return this._fetch( url + query, - { - method: "POST", - // todo: request bodies with content-type 'application/octet-stream' not yet supported, - ...opts, - headers, - }, + {method: "POST", body, ...opts, headers}, timeout, ) } diff --git a/integration-tests/typescript-fetch/src/generated/stripe.yaml/client.ts b/integration-tests/typescript-fetch/src/generated/stripe.yaml/client.ts index 52e40e0ce..c91dde84c 100644 --- a/integration-tests/typescript-fetch/src/generated/stripe.yaml/client.ts +++ b/integration-tests/typescript-fetch/src/generated/stripe.yaml/client.ts @@ -12949,7 +12949,7 @@ export class StripeApi extends AbstractFetchClient { | string = StripeApiServers.operations.getQuotesQuotePdf().build(), timeout?: number, opts: RequestInit = {}, - ): Promise | Res> { + ): Promise | Res> { const url = basePath + `/v1/quotes/${p["quote"]}/pdf` const headers = this._headers({Accept: "application/json"}, opts.headers) const query = this._query( diff --git a/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts b/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts index 0afea2d36..ab198270d 100644 --- a/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts +++ b/integration-tests/typescript-fetch/src/generated/todo-lists.yaml/client.ts @@ -325,6 +325,24 @@ export class TodoListsExampleApi extends AbstractFetchClient { timeout, ) } + + async replaceAttachment( + p: { + id: string + requestBody: Blob + }, + timeout?: number, + opts: RequestInit = {}, + ): Promise> { + const url = this.basePath + `/attachments/${p["id"]}` + const headers = this._headers( + {Accept: "application/json", "Content-Type": "application/octet-stream"}, + opts.headers, + ) + const body = p.requestBody + + return this._fetch(url, {method: "PUT", body, ...opts, headers}, timeout) + } } export {TodoListsExampleApi as ApiClient} diff --git a/integration-tests/typescript-koa/src/generated/api.github.com.yaml/generated.ts b/integration-tests/typescript-koa/src/generated/api.github.com.yaml/generated.ts index 2135617f2..a64282a25 100644 --- a/integration-tests/typescript-koa/src/generated/api.github.com.yaml/generated.ts +++ b/integration-tests/typescript-koa/src/generated/api.github.com.yaml/generated.ts @@ -10,6 +10,7 @@ import { type KoaRuntimeResponder, KoaRuntimeResponse, type Params, + parseOctetStream, type Res, type ServerConfig, type SkipResponse, @@ -20756,7 +20757,7 @@ export type ReposUploadReleaseAsset = ( params: Params< t_ReposUploadReleaseAssetParamSchema, t_ReposUploadReleaseAssetQuerySchema, - never | undefined, + Blob | undefined, void >, respond: ReposUploadReleaseAssetResponder, @@ -74109,12 +74110,11 @@ export function createRouter(implementation: Implementation): KoaRouter { ctx.query, RequestInputType.QueryString, ), - // todo: request bodies with content-type 'application/octet-stream' not yet supported body: parseRequestInput( - z.never().optional(), - Reflect.get(ctx.request, "body"), + z.any().optional(), + await parseOctetStream(ctx, "10mb"), RequestInputType.RequestBody, - ) as never, + ), headers: undefined, } diff --git a/integration-tests/typescript-koa/src/generated/stripe.yaml/generated.ts b/integration-tests/typescript-koa/src/generated/stripe.yaml/generated.ts index 2790aed42..6f71586b3 100644 --- a/integration-tests/typescript-koa/src/generated/stripe.yaml/generated.ts +++ b/integration-tests/typescript-koa/src/generated/stripe.yaml/generated.ts @@ -10083,7 +10083,7 @@ export type GetQuotesQuoteLineItems = ( > export type GetQuotesQuotePdfResponder = { - with200(): KoaRuntimeResponse + with200(): KoaRuntimeResponse withDefault(status: StatusCode): KoaRuntimeResponse } & KoaRuntimeResponder @@ -10099,7 +10099,7 @@ export type GetQuotesQuotePdf = ( next: Next, ) => Promise< | KoaRuntimeResponse - | Res<200, string> + | Res<200, Blob> | Res | typeof SkipResponse > @@ -40745,7 +40745,7 @@ export function createRouter(implementation: Implementation): KoaRouter { }) const getQuotesQuotePdfResponseValidator = responseValidationFactory( - [["200", z.string()]], + [["200", z.any()]], s_error, ) @@ -40777,7 +40777,7 @@ export function createRouter(implementation: Implementation): KoaRouter { const responder = { with200() { - return new KoaRuntimeResponse(200) + return new KoaRuntimeResponse(200) }, withDefault(status: StatusCode) { return new KoaRuntimeResponse(status) diff --git a/integration-tests/typescript-koa/src/generated/todo-lists.yaml/generated.ts b/integration-tests/typescript-koa/src/generated/todo-lists.yaml/generated.ts index a168b2d77..98a0ef92c 100644 --- a/integration-tests/typescript-koa/src/generated/todo-lists.yaml/generated.ts +++ b/integration-tests/typescript-koa/src/generated/todo-lists.yaml/generated.ts @@ -10,6 +10,7 @@ import { type KoaRuntimeResponder, KoaRuntimeResponse, type Params, + parseOctetStream, type Res, type ServerConfig, type SkipResponse, @@ -33,6 +34,7 @@ import type { t_GetTodoListByIdParamSchema, t_GetTodoListItemsParamSchema, t_GetTodoListsQuerySchema, + t_ReplaceAttachmentParamSchema, t_TodoList, t_UnknownObject, t_UpdateTodoListByIdParamSchema, @@ -202,6 +204,17 @@ export type UploadAttachment = ( next: Next, ) => Promise | Res<202, void> | typeof SkipResponse> +export type ReplaceAttachmentResponder = { + with202(): KoaRuntimeResponse +} & KoaRuntimeResponder + +export type ReplaceAttachment = ( + params: Params, + respond: ReplaceAttachmentResponder, + ctx: RouterContext, + next: Next, +) => Promise | Res<202, void> | typeof SkipResponse> + export type Implementation = { getTodoLists: GetTodoLists getTodoListById: GetTodoListById @@ -211,6 +224,7 @@ export type Implementation = { createTodoListItem: CreateTodoListItem listAttachments: ListAttachments uploadAttachment: UploadAttachment + replaceAttachment: ReplaceAttachment } export function createRouter(implementation: Implementation): KoaRouter { @@ -556,6 +570,44 @@ export function createRouter(implementation: Implementation): KoaRouter { .then(handleResponse(ctx, next, uploadAttachmentResponseValidator)) }) + const replaceAttachmentParamSchema = z.object({id: z.string().optional()}) + + const replaceAttachmentResponseValidator = responseValidationFactory( + [["202", z.undefined()]], + undefined, + ) + + router.put("replaceAttachment", "/attachments/:id", async (ctx, next) => { + const input = { + params: parseRequestInput( + replaceAttachmentParamSchema, + ctx.params, + RequestInputType.RouteParam, + ), + query: undefined, + body: parseRequestInput( + z.any(), + await parseOctetStream(ctx, "10mb"), + RequestInputType.RequestBody, + ), + headers: undefined, + } + + const responder = { + with202() { + return new KoaRuntimeResponse(202) + }, + withStatus(status: StatusCode) { + return new KoaRuntimeResponse(status) + }, + } + + await implementation + .replaceAttachment(input, responder, ctx, next) + .catch(handleImplementationError) + .then(handleResponse(ctx, next, replaceAttachmentResponseValidator)) + }) + return router } diff --git a/integration-tests/typescript-koa/src/generated/todo-lists.yaml/models.ts b/integration-tests/typescript-koa/src/generated/todo-lists.yaml/models.ts index 57c6728ba..ae4b163b1 100644 --- a/integration-tests/typescript-koa/src/generated/todo-lists.yaml/models.ts +++ b/integration-tests/typescript-koa/src/generated/todo-lists.yaml/models.ts @@ -54,6 +54,10 @@ export type t_GetTodoListsQuerySchema = { tags?: string[] } +export type t_ReplaceAttachmentParamSchema = { + id?: string +} + export type t_UpdateTodoListByIdParamSchema = { listId: string } diff --git a/integration-tests/typescript-koa/src/todo-lists.yaml.ts b/integration-tests/typescript-koa/src/todo-lists.yaml.ts index 3609bac91..09888d59d 100644 --- a/integration-tests/typescript-koa/src/todo-lists.yaml.ts +++ b/integration-tests/typescript-koa/src/todo-lists.yaml.ts @@ -22,6 +22,7 @@ async function main() { createTodoListItem: notImplemented, listAttachments: notImplemented, uploadAttachment: notImplemented, + replaceAttachment: notImplemented, }), middleware: [genericErrorMiddleware], port: {port: 3000, host: "127.0.0.1"}, diff --git a/package.json b/package.json index e30a498f9..826eeda1a 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "prepare": "husky" }, "devDependencies": { - "@biomejs/biome": "2.3.8", + "@biomejs/biome": "2.3.10", "@biomejs/js-api": "4.0.0", - "@biomejs/wasm-nodejs": "2.3.8", + "@biomejs/wasm-nodejs": "2.3.10", "@commander-js/extra-typings": "^14.0.0", "@jest/reporters": "^30.2.0", "@swc/core": "^1.15.5", diff --git a/packages/documentation/src/app/guides/concepts/content-type/page.mdx b/packages/documentation/src/app/guides/concepts/content-type/page.mdx index b0e897e3a..9be18f31c 100644 --- a/packages/documentation/src/app/guides/concepts/content-type/page.mdx +++ b/packages/documentation/src/app/guides/concepts/content-type/page.mdx @@ -80,7 +80,30 @@ by the server templates. Full support is expected to land soon. ## `application/octet-stream` -🚫 Not yet supported. Coming soon. +| Template | Supported | Notes | +|:-------------------|:---------:|--------------------------------------------------------------------------------:| +| typescript-angular | ✅ | Uses `Blob` for request bodies and response data | +| typescript-axios | ✅ | Uses `Blob` for request bodies, and response `res.data` | +| typescript-express | ✅ | Parses request bodies to `Blob`, serializes `Blob` | +| typescript-fetch | ✅ | Pass a `Blob` for request bodies, access using `res.blob()` for response bodies | +| typescript-koa | ✅ | Parses request bodies to `Blob`, serializes `Blob` | + +### Controlling Maximum Request Body size (server templates) +By default, a maximum of `10mb` is used as the maximum request body size for binary data. You can override this for +an individual schema by using the `maxLength` field, which will be interpreted as `bytes`, eg: + +```yaml +post: + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + maxLength: 20971520 # 20mb in bytes +``` + +This only applies to server templates. ## Selecting between multiple possible `Content-Type` diff --git a/packages/openapi-code-generator/package.json b/packages/openapi-code-generator/package.json index 60da8ffc7..b4c32cc26 100644 --- a/packages/openapi-code-generator/package.json +++ b/packages/openapi-code-generator/package.json @@ -62,9 +62,9 @@ "tsx": "^4.21.0" }, "dependencies": { - "@biomejs/biome": "2.3.8", + "@biomejs/biome": "2.3.10", "@biomejs/js-api": "4.0.0", - "@biomejs/wasm-nodejs": "2.3.8", + "@biomejs/wasm-nodejs": "2.3.10", "@commander-js/extra-typings": "^14.0.0", "@nahkies/typescript-common-runtime": "workspace:^", "ajv": "^8.17.1", diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index 200437dee..e3a032de1 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -700,7 +700,7 @@ export class SchemaNormalizer { constructor(readonly config: InputConfig) {} public isNormalized(schema: Schema | IRModel): schema is IRModel { - return Reflect.get(schema, "isIRModel") + return schema && Reflect.get(schema, "isIRModel") } public normalize(schemaObject: Schema): IRModel diff --git a/packages/openapi-code-generator/src/core/utils.spec.ts b/packages/openapi-code-generator/src/core/utils.spec.ts index dcc956327..7c2c18ddb 100644 --- a/packages/openapi-code-generator/src/core/utils.spec.ts +++ b/packages/openapi-code-generator/src/core/utils.spec.ts @@ -2,6 +2,7 @@ import {describe, expect, it} from "@jest/globals" import { camelCase, coalesce, + convertBytesToHuman, deepEqual, hasSingleElement, isDefined, @@ -358,4 +359,16 @@ describe("core/utils", () => { expect(mediaTypeToIdentifier(contentType)).toBe(expected) }) }) + + describe("#convertBytesToHuman", () => { + it("converts to mb", () => { + expect(convertBytesToHuman(5 * 1024 * 1024)).toBe("5mb") + }) + it("converts to kb", () => { + expect(convertBytesToHuman(128 * 1024)).toBe("128kb") + }) + it("leaves as b", () => { + expect(convertBytesToHuman(768)).toBe("768b") + }) + }) }) diff --git a/packages/openapi-code-generator/src/core/utils.ts b/packages/openapi-code-generator/src/core/utils.ts index bc766d461..d6029a95d 100644 --- a/packages/openapi-code-generator/src/core/utils.ts +++ b/packages/openapi-code-generator/src/core/utils.ts @@ -207,3 +207,23 @@ export function mediaTypeToIdentifier(mediaType: string): string { return titleCase([type, subType].filter(isDefined).join(" ")) } + +export function convertBytesToHuman( + bytes: number, +): number | `${number}${"mb" | "kb" | "b"}` { + const units = ["mb" as const, "kb" as const, "b" as const] + + let adjusted = bytes + while (units.length > 1 && adjusted % 1024 === 0) { + adjusted /= 1024 + units.pop() + } + + const unit = units.pop() + + if (!unit) { + return bytes + } + + return `${adjusted}${unit}` as const +} diff --git a/packages/openapi-code-generator/src/typescript/client/typescript-angular/angular-service-builder.ts b/packages/openapi-code-generator/src/typescript/client/typescript-angular/angular-service-builder.ts index 85b77f57e..756b3f78a 100644 --- a/packages/openapi-code-generator/src/typescript/client/typescript-angular/angular-service-builder.ts +++ b/packages/openapi-code-generator/src/typescript/client/typescript-angular/angular-service-builder.ts @@ -11,6 +11,7 @@ export class AngularServiceBuilder extends AbstractClientBuilder { "application/json", "application/scim+json", "application/merge-patch+json", + "application/octet-stream", "text/json", "text/plain", "text/x-markdown", @@ -46,6 +47,11 @@ export class AngularServiceBuilder extends AbstractClientBuilder { .concat(["HttpResponse"]) .join(" | ") + const isBlobResponse = builder + .returnType() + .filter((it) => it.statusType.startsWith("2")) + .every((it) => it.responseType === "Blob") + const url = builder.routeToTemplateString() const body = ` @@ -61,7 +67,7 @@ export class AngularServiceBuilder extends AbstractClientBuilder { .filter(Boolean) .join("\n")} -return this.httpClient.request( +return this.httpClient.request${isBlobResponse ? "" : ""}( "${method}", ${hasServers ? "basePath" : "this.config.basePath"} + \`${url}\`, { ${[ @@ -73,6 +79,7 @@ return this.httpClient.request( : `// todo: request bodies with content-type '${requestBody.contentType}' not yet supported` : "", 'observe: "response"', + isBlobResponse ? "responseType: 'blob'" : "", "reportProgress: false", ] .filter(Boolean) diff --git a/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios-client-builder.ts b/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios-client-builder.ts index 03151befb..967a6a579 100644 --- a/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios-client-builder.ts +++ b/packages/openapi-code-generator/src/typescript/client/typescript-axios/typescript-axios-client-builder.ts @@ -16,6 +16,7 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder { "application/scim+json", "application/merge-patch+json", "application/x-www-form-urlencoded", + "application/octet-stream", "text/json", "text/plain", "text/x-markdown", @@ -69,6 +70,9 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder { const axiosFragment = `this._request({${[ `url: url ${query ? "+ query" : ""}`, `method: "${method}"`, + responseSchema?.type === "Blob" + ? "responseType: 'arraybuffer'" + : undefined, requestBody?.parameter ? requestBody.isSupported ? "data: body" @@ -106,7 +110,9 @@ export class TypescriptAxiosClientBuilder extends AbstractClientBuilder { return {...res, data: ${this.schemaBuilder.parse( responseSchema.schema, - "res.data", + responseSchema.type === "Blob" + ? "this._parseBlobResponse(res)" + : "res.data", )}} ` : `return ${axiosFragment}` @@ -207,6 +213,16 @@ ${this.legacyExports(clientName)} return `${param} !== undefined ? ${serialize} : null` } + case "Blob": { + const serialize = param + + if (requestBody.parameter.required) { + return serialize + } + + return `${param} !== undefined ? ${serialize} : null` + } + default: { throw new Error( `typescript-axios does not support request bodies of content-type '${requestBody.contentType}' using serializer '${requestBody.serializer satisfies never}'`, diff --git a/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch-client-builder.ts b/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch-client-builder.ts index e10cf5660..cd5a912bc 100644 --- a/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch-client-builder.ts +++ b/packages/openapi-code-generator/src/typescript/client/typescript-fetch/typescript-fetch-client-builder.ts @@ -16,6 +16,7 @@ export class TypescriptFetchClientBuilder extends AbstractClientBuilder { "application/scim+json", "application/merge-patch+json", "application/x-www-form-urlencoded", + "application/octet-stream", "text/json", "text/plain", "text/x-markdown", @@ -205,6 +206,16 @@ export class TypescriptFetchClientBuilder extends AbstractClientBuilder { return `${param} !== undefined ? ${serialize} : null` } + case "Blob": { + const serialize = param + + if (requestBody.parameter.required) { + return serialize + } + + return `${param} !== undefined ? ${serialize} : null` + } + default: { throw new Error( `typescript-fetch does not support request bodies of content-type '${requestBody.contentType}' using serializer '${requestBody.serializer satisfies never}'`, diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts index 49e3ec8ae..4e9304157 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts @@ -202,9 +202,17 @@ export abstract class AbstractSchemaBuilder< const model = maybeModel switch (model.type) { - case "string": - result = this.string(model) + case "string": { + // todo: byte is base64 encoded string, https://spec.openapis.org/registry/format/byte.html + // model.format === "byte" + if (model.format === "binary") { + result = this.any() + } else { + result = this.string(model) + } + break + } case "number": result = this.number(model) break diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder.ts b/packages/openapi-code-generator/src/typescript/common/type-builder.ts index 9b9b7deb3..59b8332e3 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder.ts @@ -178,6 +178,12 @@ export class TypeBuilder implements ICompilable { if (schemaObject["x-enum-extensibility"] === "open") { result.push(this.addStaticType("UnknownEnumStringValue")) } + } else if ( + schemaObject.format === "binary" + // todo: byte is base64 encoded string, https://spec.openapis.org/registry/format/byte.html + // || schemaObject.format === "byte" + ) { + result.push("Blob") } else { result.push("string") } diff --git a/packages/openapi-code-generator/src/typescript/common/typescript-common.ts b/packages/openapi-code-generator/src/typescript/common/typescript-common.ts index b390a310c..fd98bd232 100644 --- a/packages/openapi-code-generator/src/typescript/common/typescript-common.ts +++ b/packages/openapi-code-generator/src/typescript/common/typescript-common.ts @@ -141,9 +141,12 @@ export function buildExport(args: ExportDefinition) { } } -export type Serializer = "JSON.stringify" | "String" | "URLSearchParams" +export type Serializer = + | "JSON.stringify" + | "String" + | "URLSearchParams" + | "Blob" // TODO: support more serializations -// | "Blob" // | "FormData" export type RequestBodyAsParameter = { @@ -169,10 +172,10 @@ function serializerForNormalizedContentType(contentType: string): Serializer { case "application/x-www-form-urlencoded": return "URLSearchParams" + case "application/octet-stream": + return "Blob" + // TODO: support more serializations - // case "application/octet-stream": - // return "Blob" - // // case "multipart/form-data": // return "FormData" diff --git a/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts index e3569f3ab..33800abcb 100644 --- a/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/abstract-router-builder.ts @@ -17,6 +17,7 @@ export abstract class AbstractRouterBuilder implements ICompilable { "application/scim+json", "application/merge-patch+json", "application/x-www-form-urlencoded", + "application/octet-stream", "text/json", "text/plain", "text/x-markdown", @@ -46,6 +47,7 @@ export abstract class AbstractRouterBuilder implements ICompilable { { requestBody: { supportedMediaTypes: this.capabilities.requestBody.mediaTypes, + defaultMaxSize: "10mb", }, }, ) @@ -64,6 +66,23 @@ export abstract class AbstractRouterBuilder implements ICompilable { protected abstract operationSymbols(operationId: string): ServerSymbols + protected parseRequestInput( + propertyName: string, + opts: { + name: string | undefined + schema: string | undefined + source: string + type: string + comment?: string + }, + ) { + if (!opts.name || !opts.schema) { + return `${opts.comment ? `${opts.comment}\n` : ""}${propertyName}: undefined` + } + + return `${opts.comment ? `${opts.comment}\n` : ""}${propertyName}: parseRequestInput(${opts.name}, ${opts.source}, ${opts.type})` + } + toString(): string { return this.buildRouter(this.name, this.statements) } diff --git a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts index 9e26f8cc1..839af85be 100644 --- a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts @@ -2,10 +2,12 @@ import type { QueryParameter, SchemaStructure, } from "@nahkies/typescript-common-runtime/query-parser" +import type {SizeLimit} from "@nahkies/typescript-common-runtime/request-bodies" import type {Input} from "../../core/input" import {logger} from "../../core/logger" import type {IRModel, IROperation} from "../../core/openapi-types-normalized" import {extractPlaceholders} from "../../core/openapi-utils" +import {convertBytesToHuman} from "../../core/utils" import type {SchemaBuilder} from "../common/schema-builders/schema-builder" import type {TypeBuilder} from "../common/type-builder" import {intersect, object} from "../common/type-utils" @@ -62,6 +64,7 @@ export type Parameters = { schema: string | undefined type: string isRequired: boolean + maxSize: SizeLimit } } @@ -71,7 +74,9 @@ export class ServerOperationBuilder { private readonly input: Input, private readonly types: TypeBuilder, private readonly schemaBuilder: SchemaBuilder, - private readonly config: {requestBody: {supportedMediaTypes: string[]}}, + private readonly config: { + requestBody: {supportedMediaTypes: string[]; defaultMaxSize: SizeLimit} + }, ) {} get operationId(): string { @@ -329,6 +334,10 @@ export class ServerOperationBuilder { const isRequired = Boolean(requestBody?.parameter?.required) const isSupported = Boolean(requestBody?.isSupported) + const deferenced = requestBody?.parameter + ? this.input.schema(requestBody?.parameter.schema) + : undefined + const schema = requestBody?.parameter ? this.schemaBuilder.fromModel( requestBody.parameter.schema, @@ -350,6 +359,7 @@ export class ServerOperationBuilder { contentType: undefined, schema: undefined, isRequired: false, + maxSize: this.config.requestBody.defaultMaxSize, } } @@ -365,6 +375,11 @@ export class ServerOperationBuilder { schema, type, isRequired, + maxSize: + deferenced?.type === "string" && + typeof deferenced.maxLength === "number" + ? convertBytesToHuman(deferenced.maxLength) + : this.config.requestBody.defaultMaxSize, } } diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts index dbd1851d8..f1df55067 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts @@ -4,7 +4,11 @@ import type {ServerImplementationMethod} from "../../../templates.types" import type {ImportBuilder} from "../../common/import-builder" import type {SchemaBuilder} from "../../common/schema-builders/schema-builder" import type {TypeBuilder} from "../../common/type-builder" -import {constStatement, object} from "../../common/type-utils" +import { + constStatement, + object, + quotedStringLiteral, +} from "../../common/type-utils" import {buildExport} from "../../common/typescript-common" import {AbstractRouterBuilder} from "../abstract-router-builder" import type { @@ -45,6 +49,7 @@ export class ExpressRouterBuilder extends AbstractRouterBuilder { "parseQueryParameters", "handleResponse", "handleImplementationError", + "parseOctetStream", ) .addType( "ExpressRuntimeResponder", @@ -136,18 +141,49 @@ export class ExpressRouterBuilder extends AbstractRouterBuilder { ], }) + const inputObject = object([ + this.parseRequestInput("params", { + name: params.path.name, + schema: params.path.schema, + source: "req.params", + type: "RequestInputType.RouteParam", + }), + this.parseRequestInput("query", { + name: params.query.name, + schema: params.query.schema, + source: params.query.isSimpleQuery + ? `req.query` + : `parseQueryParameters(new URL(\`http://localhost\${req.originalUrl}\`).search, ${JSON.stringify(params.query.parameters)})`, + type: "RequestInputType.QueryString", + }), + this.parseRequestInput("body", { + name: params.body.schema, + schema: params.body.schema, + source: + params.body.contentType === "application/octet-stream" + ? `await parseOctetStream(req, ${typeof params.body.maxSize === "string" ? quotedStringLiteral(params.body.maxSize) : params.body.maxSize})` + : `req.body`, + type: `RequestInputType.RequestBody`, + comment: + params.body.schema && !params.body.isSupported + ? `// todo: request bodies with content-type '${params.body.contentType}' not yet supported` + : "", + }) + (params.body.schema && !params.body.isSupported ? " as never" : ""), + this.parseRequestInput("headers", { + name: params.header.name, + schema: params.header.schema, + source: "req.headers", + type: "RequestInputType.RequestHeader", + }), + ]) + statements.push(` const ${symbols.responseBodyValidator} = ${builder.responseValidator()} // ${builder.operationId} router.${builder.method.toLowerCase()}(\`${builder.route}\`, async (req: Request, res: Response, next: NextFunction) => { try { - const input = { - params: ${params.path.schema ? `parseRequestInput(${params.path.name}, req.params, RequestInputType.RouteParam)` : "undefined"}, - 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"}, - ${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"}, - headers: ${params.header.schema ? `parseRequestInput(${params.header.name}, req.headers, RequestInputType.RequestHeader)` : "undefined"} - } + const input = ${inputObject} const responder = ${responder.implementation} diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts index 0c4320960..913ba5bbe 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts @@ -4,7 +4,11 @@ import type {ServerImplementationMethod} from "../../../templates.types" import type {ImportBuilder} from "../../common/import-builder" import type {SchemaBuilder} from "../../common/schema-builders/schema-builder" import type {TypeBuilder} from "../../common/type-builder" -import {constStatement, object} from "../../common/type-utils" +import { + constStatement, + object, + quotedStringLiteral, +} from "../../common/type-utils" import {buildExport} from "../../common/typescript-common" import {AbstractRouterBuilder} from "../abstract-router-builder" import type { @@ -37,6 +41,7 @@ export class KoaRouterBuilder extends AbstractRouterBuilder { "KoaRuntimeResponse", "startServer", "parseQueryParameters", + "parseOctetStream", "handleResponse", "handleImplementationError", ) @@ -142,16 +147,47 @@ export class KoaRouterBuilder extends AbstractRouterBuilder { ], }) + const inputObject = object([ + this.parseRequestInput("params", { + name: params.path.name, + schema: params.path.schema, + source: "ctx.params", + type: "RequestInputType.RouteParam", + }), + this.parseRequestInput("query", { + name: params.query.name, + schema: params.query.schema, + source: params.query.isSimpleQuery + ? `ctx.query` + : `parseQueryParameters(ctx.querystring, ${JSON.stringify(params.query.parameters)})`, + type: "RequestInputType.QueryString", + }), + this.parseRequestInput("body", { + name: params.body.schema, + schema: params.body.schema, + source: + params.body.contentType === "application/octet-stream" + ? `await parseOctetStream(ctx, ${typeof params.body.maxSize === "string" ? quotedStringLiteral(params.body.maxSize) : params.body.maxSize})` + : `Reflect.get(ctx.request, "body")`, + type: `RequestInputType.RequestBody`, + comment: + params.body.schema && !params.body.isSupported + ? `// todo: request bodies with content-type '${params.body.contentType}' not yet supported` + : "", + }) + (params.body.schema && !params.body.isSupported ? " as never" : ""), + this.parseRequestInput("headers", { + name: params.header.name, + schema: params.header.schema, + source: 'Reflect.get(ctx.request, "headers")', + type: "RequestInputType.RequestHeader", + }), + ]) + statements.push(` const ${symbols.responseBodyValidator} = ${builder.responseValidator()} router.${builder.method.toLowerCase()}('${symbols.implPropName}','${builder.route}', async (ctx, next) => { - const input = { - params: ${params.path.schema ? `parseRequestInput(${params.path.name}, ctx.params, RequestInputType.RouteParam)` : "undefined"}, - query: ${params.query.schema ? `parseRequestInput(${params.query.name}, ${params.query.isSimpleQuery ? "ctx.query" : `parseQueryParameters(ctx.querystring, ${JSON.stringify(params.query.parameters)})`}, RequestInputType.QueryString)` : "undefined"}, - ${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"}, - headers: ${params.header.schema ? `parseRequestInput(${params.header.name}, Reflect.get(ctx.request, "headers"), RequestInputType.RequestHeader)` : "undefined"} - } + const input = ${inputObject} const responder = ${responder.implementation} diff --git a/packages/typescript-axios-runtime/src/main.spec.ts b/packages/typescript-axios-runtime/src/main.spec.ts index 66812e131..1bdb0c1b3 100644 --- a/packages/typescript-axios-runtime/src/main.spec.ts +++ b/packages/typescript-axios-runtime/src/main.spec.ts @@ -2,7 +2,7 @@ // biome-ignore-all lint/suspicious/noExplicitAny: tests import {describe, expect, it} from "@jest/globals" -import type {Encoding} from "@nahkies/typescript-common-runtime/request-bodies/url-search-params" +import type {Encoding} from "@nahkies/typescript-common-runtime/request-bodies" import type {AxiosRequestConfig, RawAxiosRequestHeaders} from "axios" import { AbstractAxiosClient, diff --git a/packages/typescript-axios-runtime/src/main.ts b/packages/typescript-axios-runtime/src/main.ts index b1c5cb848..90c3dffd4 100644 --- a/packages/typescript-axios-runtime/src/main.ts +++ b/packages/typescript-axios-runtime/src/main.ts @@ -1,7 +1,7 @@ import { type Encoding, requestBodyToUrlSearchParams, -} from "@nahkies/typescript-common-runtime/request-bodies/url-search-params" +} from "@nahkies/typescript-common-runtime/request-bodies" import type { HeaderParams, QueryParams, @@ -110,6 +110,12 @@ export abstract class AbstractAxiosClient { return requestBodyToUrlSearchParams(obj, encoding) } + protected _parseBlobResponse(res: AxiosResponse) { + return new Blob([res.data], { + type: res.headers["content-type"] || "application/octet-stream", + }) + } + private setHeaders( headers: Pick, headersInit: HeaderParams | AxiosRequestConfig["headers"], diff --git a/packages/typescript-common-runtime/package.json b/packages/typescript-common-runtime/package.json index de059e1ed..11932831d 100644 --- a/packages/typescript-common-runtime/package.json +++ b/packages/typescript-common-runtime/package.json @@ -37,10 +37,10 @@ "import": "./dist/query-parser.js", "types": "./dist/query-parser.d.ts" }, - "./request-bodies/url-search-params": { - "require": "./dist/request-bodies/url-search-params.js", - "import": "./dist/request-bodies/url-search-params.js", - "types": "./dist/request-bodies/url-search-params.d.ts" + "./request-bodies": { + "require": "./dist/request-bodies/index.js", + "import": "./dist/request-bodies/index.js", + "types": "./dist/request-bodies/index.d.ts" } }, "scripts": { @@ -49,6 +49,7 @@ "test": "jest" }, "dependencies": { + "raw-body": "^3.0.2", "tslib": "^2.8.1" }, "peerDependencies": { diff --git a/packages/typescript-common-runtime/src/request-bodies/index.ts b/packages/typescript-common-runtime/src/request-bodies/index.ts new file mode 100644 index 000000000..461f2c6a6 --- /dev/null +++ b/packages/typescript-common-runtime/src/request-bodies/index.ts @@ -0,0 +1,9 @@ +// todo: parseOctetStreamRequestBody is only for server side, and requestBodyToUrlSearchParams is only for client side. +// probably need to split this package to avoid polluting client dependency tress. +export {parseOctetStreamRequestBody, type SizeLimit} from "./octet-stream" + +export { + type Encoding, + requestBodyToUrlSearchParams, + type Style, +} from "./url-search-params" diff --git a/packages/typescript-common-runtime/src/request-bodies/octet-stream.ts b/packages/typescript-common-runtime/src/request-bodies/octet-stream.ts new file mode 100644 index 000000000..3e86bb587 --- /dev/null +++ b/packages/typescript-common-runtime/src/request-bodies/octet-stream.ts @@ -0,0 +1,41 @@ +import type {IncomingMessage} from "node:http" +import getRawBody from "raw-body" + +export type SizeLimit = number | `${number}${"b" | "kb" | "mb" | "gb"}` + +export async function parseOctetStreamRequestBody( + req: IncomingMessage, + opts: { + contentLength?: number | undefined + sizeLimit: SizeLimit + }, +) { + const contentLength = + opts.contentLength ?? + (req.headers["content-length"] + ? parseInt(req.headers["content-length"], 10) + : undefined) + + if (!contentLength) { + throw new Error("No content length provided") + } + + const body = await getRawBody(req, { + length: contentLength, + limit: opts.sizeLimit, + }) + + if (!body) { + return undefined + } + + if (!Buffer.isBuffer(body)) { + throw new Error("body must be a buffer") + } + + const blob = new Blob([new Uint8Array(body)], { + type: "application/octet-stream", + }) + + return blob +} diff --git a/packages/typescript-express-runtime/src/server.ts b/packages/typescript-express-runtime/src/server.ts index bc9a3ec76..245a71393 100644 --- a/packages/typescript-express-runtime/src/server.ts +++ b/packages/typescript-express-runtime/src/server.ts @@ -8,11 +8,19 @@ import type {Response} from "express" import express, { type ErrorRequestHandler, type Express, + type Response as ExpressResponse, + type Request, type RequestHandler, type Router, } from "express" export {parseQueryParameters} from "@nahkies/typescript-common-runtime/query-parser" + +import { + parseOctetStreamRequestBody, + type SizeLimit, +} from "@nahkies/typescript-common-runtime/request-bodies" + export type { Params, Res, @@ -24,6 +32,9 @@ export type { StatusCode5xx, } from "@nahkies/typescript-common-runtime/types" +// biome-ignore lint/suspicious/noExplicitAny: needed +export type ResponseValidator = (status: number, value: unknown) => any + export const SkipResponse = Symbol("skip response processing") export class ExpressRuntimeResponse { @@ -45,12 +56,12 @@ export function handleResponse( res: Response, validator: (status: number, value: unknown) => unknown, ) { - return ( + return async ( response: | ExpressRuntimeResponse | typeof SkipResponse | Res, - ): void => { + ): Promise => { // escape hatch to allow responses to be sent by the implementation handler if (response === SkipResponse) { return @@ -61,10 +72,15 @@ export function handleResponse( res.status(status) - if (body !== undefined) { - res.json(validator(status, body)) - } else { + if (body === undefined) { res.end() + return + } + + if (body instanceof Blob) { + await sendBlob(res, body) + } else { + res.json(validator(status, body)) } } } @@ -212,3 +228,20 @@ export async function startServer({ } }) } + +export async function parseOctetStream( + req: Request, + sizeLimit: SizeLimit, +): Promise { + return parseOctetStreamRequestBody(req, {sizeLimit}) +} + +export async function sendBlob(res: ExpressResponse, body: Blob) { + const arrayBuffer = await body.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + res.setHeader("Content-Type", body.type ?? "application/octet-stream") + res.setHeader("Content-Length", buffer.length) + + res.send(buffer) +} diff --git a/packages/typescript-express-runtime/src/zod-v3.ts b/packages/typescript-express-runtime/src/zod-v3.ts index d268f70f5..cd77d4c2d 100644 --- a/packages/typescript-express-runtime/src/zod-v3.ts +++ b/packages/typescript-express-runtime/src/zod-v3.ts @@ -2,6 +2,7 @@ import {findMatchingSchema} from "@nahkies/typescript-common-runtime/validation" import type {z} from "zod/v3" import {ExpressRuntimeError, type RequestInputType} from "./errors" +import type {ResponseValidator} from "./server" export function parseRequestInput( schema: Schema, @@ -29,7 +30,7 @@ export function parseRequestInput( export function responseValidationFactory( possibleResponses: [string, z.ZodTypeAny][], defaultResponse?: z.ZodTypeAny, -) { +): ResponseValidator { // Exploit the natural ordering matching the desired specificity of eg: 404 vs 4xx possibleResponses.sort((x, y) => (x[0] < y[0] ? -1 : 1)) diff --git a/packages/typescript-fetch-runtime/src/main.spec.ts b/packages/typescript-fetch-runtime/src/main.spec.ts index ef9b4a9a1..cebee2b61 100644 --- a/packages/typescript-fetch-runtime/src/main.spec.ts +++ b/packages/typescript-fetch-runtime/src/main.spec.ts @@ -1,7 +1,7 @@ // biome-ignore-all lint/suspicious/noExplicitAny: tests import {describe, expect, it} from "@jest/globals" -import type {Encoding} from "@nahkies/typescript-common-runtime/request-bodies/url-search-params" +import type {Encoding} from "@nahkies/typescript-common-runtime/request-bodies" import { AbstractFetchClient, type AbstractFetchClientConfig, diff --git a/packages/typescript-fetch-runtime/src/main.ts b/packages/typescript-fetch-runtime/src/main.ts index 1fc3a3ade..70ac9fe5a 100644 --- a/packages/typescript-fetch-runtime/src/main.ts +++ b/packages/typescript-fetch-runtime/src/main.ts @@ -1,7 +1,7 @@ import { type Encoding, requestBodyToUrlSearchParams, -} from "@nahkies/typescript-common-runtime/request-bodies/url-search-params" +} from "@nahkies/typescript-common-runtime/request-bodies" import type { AbstractFetchClientConfig, HeaderParams, diff --git a/packages/typescript-koa-runtime/src/server.ts b/packages/typescript-koa-runtime/src/server.ts index 2f8670397..b3a30bfdc 100644 --- a/packages/typescript-koa-runtime/src/server.ts +++ b/packages/typescript-koa-runtime/src/server.ts @@ -2,11 +2,13 @@ import type {Server} from "node:http" import type {AddressInfo, ListenOptions} from "node:net" import Cors from "@koa/cors" import type Router from "@koa/router" +import type {SizeLimit} from "@nahkies/typescript-common-runtime/request-bodies" +import {parseOctetStreamRequestBody} from "@nahkies/typescript-common-runtime/request-bodies" import type {Res, StatusCode} from "@nahkies/typescript-common-runtime/types" import {KoaRuntimeError} from "@nahkies/typescript-koa-runtime/errors" import Koa, {type Context, type Middleware, type Next} from "koa" +import type {KoaBodyMiddlewareOptions} from "koa-body" import KoaBody from "koa-body" -import type {KoaBodyMiddlewareOptions} from "koa-body/lib/types" export {parseQueryParameters} from "@nahkies/typescript-common-runtime/query-parser" export type { @@ -111,6 +113,15 @@ export type ServerConfig = { port?: number | ListenOptions } +export async function parseOctetStream( + ctx: Context, + sizeLimit: SizeLimit, +): Promise { + const body = await parseOctetStreamRequestBody(ctx.req, {sizeLimit}) + ctx.body = body + return body +} + /** * Starts a Koa server and listens on `port` or a randomly allocated port if none provided. * Enables CORS and body parsing by default. It's recommended to customize the CORS options diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef3660d12..3dfad1a73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,14 +13,14 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: 2.3.8 - version: 2.3.8 + specifier: 2.3.10 + version: 2.3.10 '@biomejs/js-api': specifier: 4.0.0 - version: 4.0.0(@biomejs/wasm-nodejs@2.3.8) + version: 4.0.0(@biomejs/wasm-nodejs@2.3.10) '@biomejs/wasm-nodejs': - specifier: 2.3.8 - version: 2.3.8 + specifier: 2.3.10 + version: 2.3.10 '@commander-js/extra-typings': specifier: ^14.0.0 version: 14.0.0(commander@14.0.2) @@ -347,14 +347,14 @@ importers: packages/openapi-code-generator: dependencies: '@biomejs/biome': - specifier: 2.3.8 - version: 2.3.8 + specifier: 2.3.10 + version: 2.3.10 '@biomejs/js-api': specifier: 4.0.0 - version: 4.0.0(@biomejs/wasm-nodejs@2.3.8) + version: 4.0.0(@biomejs/wasm-nodejs@2.3.10) '@biomejs/wasm-nodejs': - specifier: 2.3.8 - version: 2.3.8 + specifier: 2.3.10 + version: 2.3.10 '@commander-js/extra-typings': specifier: ^14.0.0 version: 14.0.0(commander@14.0.2) @@ -477,6 +477,9 @@ importers: packages/typescript-common-runtime: dependencies: + raw-body: + specifier: ^3.0.2 + version: 3.0.2 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -1034,59 +1037,59 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@biomejs/biome@2.3.8': - resolution: {integrity: sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA==} + '@biomejs/biome@2.3.10': + resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.3.8': - resolution: {integrity: sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww==} + '@biomejs/cli-darwin-arm64@2.3.10': + resolution: {integrity: sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.3.8': - resolution: {integrity: sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA==} + '@biomejs/cli-darwin-x64@2.3.10': + resolution: {integrity: sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.3.8': - resolution: {integrity: sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA==} + '@biomejs/cli-linux-arm64-musl@2.3.10': + resolution: {integrity: sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.3.8': - resolution: {integrity: sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g==} + '@biomejs/cli-linux-arm64@2.3.10': + resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.3.8': - resolution: {integrity: sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA==} + '@biomejs/cli-linux-x64-musl@2.3.10': + resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.3.8': - resolution: {integrity: sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw==} + '@biomejs/cli-linux-x64@2.3.10': + resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.3.8': - resolution: {integrity: sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg==} + '@biomejs/cli-win32-arm64@2.3.10': + resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.3.8': - resolution: {integrity: sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w==} + '@biomejs/cli-win32-x64@2.3.10': + resolution: {integrity: sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -1105,8 +1108,8 @@ packages: '@biomejs/wasm-web': optional: true - '@biomejs/wasm-nodejs@2.3.8': - resolution: {integrity: sha512-c5tuzrW34cRipmrChxX9hboJIkOurjrK7o1GF4pOOuNEQ5UExSpmnEek2FH0mbdp6+TikUcT0XAquQnbFNQ9Eg==} + '@biomejs/wasm-nodejs@2.3.10': + resolution: {integrity: sha512-2mzoki9hFrL7uTkwoHQM+M+xSM5j8PIHvB0bIAUc5EFdGT57wsJgSHNaqNBKLlzjb8BIB8w6pu3wpJKy53lDHg==} '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -8889,46 +8892,46 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@biomejs/biome@2.3.8': + '@biomejs/biome@2.3.10': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.8 - '@biomejs/cli-darwin-x64': 2.3.8 - '@biomejs/cli-linux-arm64': 2.3.8 - '@biomejs/cli-linux-arm64-musl': 2.3.8 - '@biomejs/cli-linux-x64': 2.3.8 - '@biomejs/cli-linux-x64-musl': 2.3.8 - '@biomejs/cli-win32-arm64': 2.3.8 - '@biomejs/cli-win32-x64': 2.3.8 + '@biomejs/cli-darwin-arm64': 2.3.10 + '@biomejs/cli-darwin-x64': 2.3.10 + '@biomejs/cli-linux-arm64': 2.3.10 + '@biomejs/cli-linux-arm64-musl': 2.3.10 + '@biomejs/cli-linux-x64': 2.3.10 + '@biomejs/cli-linux-x64-musl': 2.3.10 + '@biomejs/cli-win32-arm64': 2.3.10 + '@biomejs/cli-win32-x64': 2.3.10 - '@biomejs/cli-darwin-arm64@2.3.8': + '@biomejs/cli-darwin-arm64@2.3.10': optional: true - '@biomejs/cli-darwin-x64@2.3.8': + '@biomejs/cli-darwin-x64@2.3.10': optional: true - '@biomejs/cli-linux-arm64-musl@2.3.8': + '@biomejs/cli-linux-arm64-musl@2.3.10': optional: true - '@biomejs/cli-linux-arm64@2.3.8': + '@biomejs/cli-linux-arm64@2.3.10': optional: true - '@biomejs/cli-linux-x64-musl@2.3.8': + '@biomejs/cli-linux-x64-musl@2.3.10': optional: true - '@biomejs/cli-linux-x64@2.3.8': + '@biomejs/cli-linux-x64@2.3.10': optional: true - '@biomejs/cli-win32-arm64@2.3.8': + '@biomejs/cli-win32-arm64@2.3.10': optional: true - '@biomejs/cli-win32-x64@2.3.8': + '@biomejs/cli-win32-x64@2.3.10': optional: true - '@biomejs/js-api@4.0.0(@biomejs/wasm-nodejs@2.3.8)': + '@biomejs/js-api@4.0.0(@biomejs/wasm-nodejs@2.3.10)': optionalDependencies: - '@biomejs/wasm-nodejs': 2.3.8 + '@biomejs/wasm-nodejs': 2.3.10 - '@biomejs/wasm-nodejs@2.3.8': {} + '@biomejs/wasm-nodejs@2.3.10': {} '@braintree/sanitize-url@7.1.1': {} @@ -10321,7 +10324,7 @@ snapshots: proggy: 3.0.0 promise-all-reject-late: 1.0.1 promise-call-limit: 3.0.2 - semver: 7.7.2 + semver: 7.7.3 ssri: 12.0.0 treeverse: 3.0.0 walk-up-path: 4.0.0 @@ -10330,11 +10333,11 @@ snapshots: '@npmcli/fs@4.0.0': dependencies: - semver: 7.7.2 + semver: 7.7.3 '@npmcli/fs@5.0.0': dependencies: - semver: 7.7.2 + semver: 7.7.3 '@npmcli/git@6.0.3': dependencies: @@ -10344,7 +10347,7 @@ snapshots: npm-pick-manifest: 10.0.0 proc-log: 5.0.0 promise-retry: 2.0.1 - semver: 7.7.2 + semver: 7.7.3 which: 5.0.0 '@npmcli/git@7.0.1': @@ -10355,7 +10358,7 @@ snapshots: npm-pick-manifest: 11.0.3 proc-log: 6.1.0 promise-retry: 2.0.1 - semver: 7.7.2 + semver: 7.7.3 which: 6.0.0 '@npmcli/installed-package-contents@3.0.0': @@ -10381,7 +10384,7 @@ snapshots: json-parse-even-better-errors: 5.0.0 pacote: 21.0.4 proc-log: 6.1.0 - semver: 7.7.2 + semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -10400,7 +10403,7 @@ snapshots: hosted-git-info: 9.0.2 json-parse-even-better-errors: 5.0.0 proc-log: 6.1.0 - semver: 7.7.2 + semver: 7.7.3 validate-npm-package-license: 3.0.4 '@npmcli/package-json@7.0.4': @@ -10458,7 +10461,7 @@ snapshots: enquirer: 2.3.6 minimatch: 9.0.3 nx: 22.2.3(@swc/core@1.15.5(@swc/helpers@0.5.17)) - semver: 7.7.2 + semver: 7.7.3 tslib: 2.8.1 yargs-parser: 21.1.1 @@ -12165,7 +12168,7 @@ snapshots: handlebars: 4.7.8 json-stringify-safe: 5.0.1 meow: 8.1.2 - semver: 7.7.2 + semver: 7.7.3 split: 1.0.1 conventional-commits-filter@3.0.0: @@ -13099,7 +13102,7 @@ snapshots: git-semver-tags@5.0.1: dependencies: meow: 8.1.2 - semver: 7.7.2 + semver: 7.7.3 git-up@7.0.0: dependencies: @@ -13492,7 +13495,7 @@ snapshots: npm-package-arg: 13.0.1 promzard: 2.0.0 read: 4.1.0 - semver: 7.7.2 + semver: 7.7.3 validate-npm-package-license: 3.0.4 validate-npm-package-name: 6.0.2 @@ -14197,7 +14200,7 @@ snapshots: npm-package-arg: 13.0.1 npm-registry-fetch: 19.1.0 proc-log: 5.0.0 - semver: 7.7.2 + semver: 7.7.3 sigstore: 4.0.0 ssri: 12.0.0 transitivePeerDependencies: @@ -14321,7 +14324,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 make-fetch-happen@14.0.3: dependencies: @@ -15224,7 +15227,7 @@ snapshots: make-fetch-happen: 14.0.3 nopt: 8.1.0 proc-log: 5.0.0 - semver: 7.7.2 + semver: 7.7.3 tar: 7.5.2 tinyglobby: 0.2.12 which: 5.0.0 @@ -15271,7 +15274,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.16.1 - semver: 7.7.2 + semver: 7.7.3 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -15286,11 +15289,11 @@ snapshots: npm-install-checks@7.1.2: dependencies: - semver: 7.7.2 + semver: 7.7.3 npm-install-checks@8.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 npm-normalize-package-bin@4.0.0: {} @@ -15300,14 +15303,14 @@ snapshots: dependencies: hosted-git-info: 8.1.0 proc-log: 5.0.0 - semver: 7.7.2 + semver: 7.7.3 validate-npm-package-name: 6.0.2 npm-package-arg@13.0.1: dependencies: hosted-git-info: 9.0.2 proc-log: 5.0.0 - semver: 7.7.2 + semver: 7.7.3 validate-npm-package-name: 6.0.2 npm-packlist@10.0.3: @@ -15320,14 +15323,14 @@ snapshots: npm-install-checks: 7.1.2 npm-normalize-package-bin: 4.0.0 npm-package-arg: 12.0.2 - semver: 7.7.2 + semver: 7.7.3 npm-pick-manifest@11.0.3: dependencies: npm-install-checks: 8.0.0 npm-normalize-package-bin: 5.0.0 npm-package-arg: 13.0.1 - semver: 7.7.2 + semver: 7.7.3 npm-registry-fetch@19.1.0: dependencies: @@ -15396,7 +15399,7 @@ snapshots: open: 8.4.2 ora: 5.3.0 resolve.exports: 2.0.3 - semver: 7.7.2 + semver: 7.7.3 string-width: 4.2.3 tar-stream: 2.2.0 tmp: 0.2.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 81548aa1f..8a2319a89 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,16 @@ minimumReleaseAgeExclude: - '@next/swc-linux-x64-musl@16.1.0' - '@next/swc-win32-arm64-msvc@16.1.0' - '@next/swc-win32-x64-msvc@16.1.0' + - "@biomejs/biome@2.3.10" + - "@biomejs/wasm-nodejs@2.3.10" + - "@biomejs/cli-linux-x64-musl@2.3.10" + - "@biomejs/cli-linux-arm64-musl@2.3.10" + - "@biomejs/cli-linux-arm64@2.3.10" + - "@biomejs/cli-darwin-arm64@2.3.10" + - "@biomejs/cli-linux-x64@2.3.10" + - "@biomejs/cli-win32-x64@2.3.10" + - "@biomejs/cli-darwin-x64@2.3.10" + - "@biomejs/cli-win32-arm64@2.3.10" nodeOptions: "${NODE_OPTIONS:- } --experimental-vm-modules"