From 7c8567e5a5df0c1d5db078fcda2bb41f4d174580 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:11:28 -0500 Subject: [PATCH 01/13] feat: add --ky option Generate a client that uses this library: https://github.com/sindresorhus/ky --- README.md | 3 +- index.js | 7 +- package-lock.json | 1 + package.json | 1 + src/constants.js | 1 + .../base/http-clients/ky-http-client.ejs | 140 ++++++++++++++++++ templates/default/api.ejs | 2 + templates/default/procedure-call.ejs | 3 + templates/modular/api.ejs | 2 + templates/modular/procedure-call.ejs | 3 + 10 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 templates/base/http-clients/ky-http-client.ejs diff --git a/README.md b/README.md index 379ebd7c..af15eb39 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Options: --disableStrictSSL disabled strict SSL (default: false) --disableProxy disabled proxy (default: false) --axios generate axios http client (default: false) + --ky generate ky http client (default: false) --unwrap-response-data unwrap the data item from the response (default: false) --disable-throw-on-error Do not throw an error when response.ok is not true (default: false) --single-http-client Ability to send HttpClient instance to Api constructor (default: false) @@ -124,7 +125,7 @@ generateApi({ // ... }, templates: path.resolve(process.cwd(), './api-templates'), - httpClientType: "axios", // or "fetch" + httpClientType: "axios", // or "fetch" or "ky" defaultResponseAsSuccess: false, generateClient: true, generateRouteTypes: false, diff --git a/index.js b/index.js index 9302d910..c0e01154 100644 --- a/index.js +++ b/index.js @@ -162,6 +162,11 @@ const program = cli({ description: 'generate axios http client', default: codeGenBaseConfig.httpClientType === HTTP_CLIENT.AXIOS, }, + { + flags: '--ky', + description: 'generate axios http client', + default: codeGenBaseConfig.httpClientType === HTTP_CLIENT.KY, + }, { flags: '--unwrap-response-data', description: 'unwrap the data item from the response', @@ -324,7 +329,7 @@ const main = async () => { url: options.path, generateRouteTypes: options.routeTypes, generateClient: !!(options.axios || options.client), - httpClientType: options.axios ? HTTP_CLIENT.AXIOS : HTTP_CLIENT.FETCH, + httpClientType: options.axios ? HTTP_CLIENT.AXIOS : options.ky ? HTTP_CLIENT.KY : HTTP_CLIENT.FETCH, input: resolve(process.cwd(), options.path), output: resolve(process.cwd(), options.output || '.'), ...customConfig, diff --git a/package-lock.json b/package-lock.json index 33ecee8e..aaa84095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "eslint-plugin-prettier": "^5.0.0", "git-diff": "^2.0.6", "husky": "^8.0.3", + "ky": "^1.2.2", "pretty-quick": "^3.1.3", "rimraf": "^5.0.1" } diff --git a/package.json b/package.json index ab1bffd8..96d1b3cf 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "dotenv": "^16.3.1", "git-diff": "^2.0.6", "husky": "^8.0.3", + "ky": "^1.2.2", "pretty-quick": "^3.1.3", "rimraf": "^5.0.1" }, diff --git a/src/constants.js b/src/constants.js index 61b55e14..8d7c5282 100644 --- a/src/constants.js +++ b/src/constants.js @@ -28,6 +28,7 @@ const SCHEMA_TYPES = { const HTTP_CLIENT = { FETCH: 'fetch', AXIOS: 'axios', + KY: 'ky', }; const PROJECT_VERSION = packageJson.version; diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs new file mode 100644 index 00000000..6b7ddcfe --- /dev/null +++ b/templates/base/http-clients/ky-http-client.ejs @@ -0,0 +1,140 @@ +<% +const { apiConfig, generateResponses, config } = it; +%> + +import type { KyInstance, Options as KyOptions } from "ky"; +import ky from "ky"; + +export type KyResponse = Response & { + json(): Promise; +} + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit; + +export interface ApiConfig extends Omit { + securityWorker?: (securityData: SecurityDataType | null) => Promise | KyOptions | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = "application/json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public instance: KyInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ securityWorker, secure, format, ...options }: ApiConfig = {}) { + this.instance = axios.create({ ...options, prefixUrl: options.prefixUrl || "<%~ apiConfig.baseUrl %>" }) + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data + } + + protected mergeRequestParams(params1: KyOptions, params2?: KyOptions): KyOptions { + return { + ...params1, + ...params2, + headers: { + ...(params1.headers), + ...(params2 && params2.headers), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = (property instanceof Array) ? property : [property] + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem) + ); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params +<% if (config.unwrapResponseData) { %> + }: FullRequestParams): Promise => { +<% } else { %> + }: FullRequestParams): KyResponse => { +<% } %> + const secureParams = ((typeof secure === 'boolean' ? secure : this.secure) && this.securityWorker && (await this.securityWorker(this.securityData))) || {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = (format || this.format) || undefined; + + if (type === ContentType.FormData && body && body !== null && typeof body === "object") { + body = this.createFormData(body as Record); + } + + if (type === ContentType.Text && body && body !== null && typeof body !== "string") { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, +<% if (config.unwrapResponseData) { %> + }).json(); +<% } else { %> + }); +<% } %> + }; +} diff --git a/templates/default/api.ejs b/templates/default/api.ejs index a393c917..a54340ec 100644 --- a/templates/default/api.ejs +++ b/templates/default/api.ejs @@ -28,6 +28,8 @@ const descriptionLines = _.compact([ <% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %> +<% if (config.httpClientType === config.constants.HTTP_CLIENT.KY) { %> import type { KyResponse } from "ky"; <% } %> + <% if (descriptionLines.length) { %> /** <% descriptionLines.forEach((descriptionLine) => { %> diff --git a/templates/default/procedure-call.ejs b/templates/default/procedure-call.ejs index 465d6327..b2e43226 100644 --- a/templates/default/procedure-call.ejs +++ b/templates/default/procedure-call.ejs @@ -72,6 +72,9 @@ const describeReturnType = () => { case HTTP_CLIENT.AXIOS: { return `Promise>` } + case HTTP_CLIENT.KY: { + return `KyResponse<${type}>` + } default: { return `Promise` } diff --git a/templates/modular/api.ejs b/templates/modular/api.ejs index 25bfa182..ad9c33ac 100644 --- a/templates/modular/api.ejs +++ b/templates/modular/api.ejs @@ -8,6 +8,8 @@ const dataContracts = _.map(modelTypes, "name"); <% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %> +<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { KyResponse } from "ky"; <% } %> + import { HttpClient, RequestParams, ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>"; <% if (dataContracts.length) { %> import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>" diff --git a/templates/modular/procedure-call.ejs b/templates/modular/procedure-call.ejs index 6f15500f..11112b74 100644 --- a/templates/modular/procedure-call.ejs +++ b/templates/modular/procedure-call.ejs @@ -72,6 +72,9 @@ const describeReturnType = () => { case HTTP_CLIENT.AXIOS: { return `Promise>` } + case HTTP_CLIENT.KY: { + return `KyResponse<${type}>` + } default: { return `Promise` } From c0536b842fa9823774cbb42d00bb17c59ac8e837 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:20:52 -0500 Subject: [PATCH 02/13] fix: remove imports from api.ejs --- templates/default/api.ejs | 2 -- templates/modular/api.ejs | 2 -- 2 files changed, 4 deletions(-) diff --git a/templates/default/api.ejs b/templates/default/api.ejs index a54340ec..a393c917 100644 --- a/templates/default/api.ejs +++ b/templates/default/api.ejs @@ -28,8 +28,6 @@ const descriptionLines = _.compact([ <% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %> -<% if (config.httpClientType === config.constants.HTTP_CLIENT.KY) { %> import type { KyResponse } from "ky"; <% } %> - <% if (descriptionLines.length) { %> /** <% descriptionLines.forEach((descriptionLine) => { %> diff --git a/templates/modular/api.ejs b/templates/modular/api.ejs index ad9c33ac..25bfa182 100644 --- a/templates/modular/api.ejs +++ b/templates/modular/api.ejs @@ -8,8 +8,6 @@ const dataContracts = _.map(modelTypes, "name"); <% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %> -<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { KyResponse } from "ky"; <% } %> - import { HttpClient, RequestParams, ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>"; <% if (dataContracts.length) { %> import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>" From 240b55396d0c6827a43add6d5660e5e823bfd6f2 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:22:49 -0500 Subject: [PATCH 03/13] fix: `HttpClient#request` return type --- .../base/http-clients/ky-http-client.ejs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 6b7ddcfe..ed53b41e 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -5,10 +5,39 @@ const { apiConfig, generateResponses, config } = it; import type { KyInstance, Options as KyOptions } from "ky"; import ky from "ky"; -export type KyResponse = Response & { +type KyResponse = Response & { json(): Promise; } +export type ResponsePromise = { + arrayBuffer: () => Promise; + blob: () => Promise; + formData: () => Promise; + /** + Get the response body as JSON. + + @example + ``` + import ky from 'ky'; + + const json = await ky(…).json(); + ``` + + @example + ``` + import ky from 'ky'; + + interface Result { + value: number; + } + + const result = await ky(…).json(); + ``` + */ + json(): Promise; + text: () => Promise; +} & Promise>; + export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; @@ -107,7 +136,7 @@ export class HttpClient { <% if (config.unwrapResponseData) { %> }: FullRequestParams): Promise => { <% } else { %> - }: FullRequestParams): KyResponse => { + }: FullRequestParams): ResponsePromise => { <% } %> const secureParams = ((typeof secure === 'boolean' ? secure : this.secure) && this.securityWorker && (await this.securityWorker(this.securityData))) || {}; const requestParams = this.mergeRequestParams(params, secureParams); From 0128ad22ad1b57263cb07848ca7a9ecae6f9a948 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:23:15 -0500 Subject: [PATCH 04/13] fix: use `ky.create` --- templates/base/http-clients/ky-http-client.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index ed53b41e..eda1d35d 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -79,7 +79,7 @@ export class HttpClient { private format?: ResponseType; constructor({ securityWorker, secure, format, ...options }: ApiConfig = {}) { - this.instance = axios.create({ ...options, prefixUrl: options.prefixUrl || "<%~ apiConfig.baseUrl %>" }) + this.instance = ky.create({ ...options, prefixUrl: options.prefixUrl || "<%~ apiConfig.baseUrl %>" }) this.secure = secure; this.format = format; this.securityWorker = securityWorker; From dc52cf37784a415a1faadaa0bc66432c15531093 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:46:17 -0500 Subject: [PATCH 05/13] fix: remove securityWorker et al Since ky has a special ResponsePromise type, the `request` method cannot use async-await syntax and the returned promise must come from ky (so the expected methods are there). This means the securityWorker cannot be supported by the ky client. --- .../base/http-clients/ky-http-client.ejs | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index eda1d35d..381a5117 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -42,8 +42,6 @@ export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; export interface FullRequestParams extends Omit { - /** set parameter to `true` for call `securityWorker` for this request */ - secure?: boolean; /** request path */ path: string; /** content type of request body */ @@ -59,8 +57,6 @@ export interface FullRequestParams extends Omit { export type RequestParams = Omit; export interface ApiConfig extends Omit { - securityWorker?: (securityData: SecurityDataType | null) => Promise | KyOptions | void; - secure?: boolean; format?: ResponseType; } @@ -73,31 +69,11 @@ export enum ContentType { export class HttpClient { public instance: KyInstance; - private securityData: SecurityDataType | null = null; - private securityWorker?: ApiConfig["securityWorker"]; - private secure?: boolean; private format?: ResponseType; - constructor({ securityWorker, secure, format, ...options }: ApiConfig = {}) { + constructor({ format, ...options }: ApiConfig = {}) { this.instance = ky.create({ ...options, prefixUrl: options.prefixUrl || "<%~ apiConfig.baseUrl %>" }) - this.secure = secure; this.format = format; - this.securityWorker = securityWorker; - } - - public setSecurityData = (data: SecurityDataType | null) => { - this.securityData = data - } - - protected mergeRequestParams(params1: KyOptions, params2?: KyOptions): KyOptions { - return { - ...params1, - ...params2, - headers: { - ...(params1.headers), - ...(params2 && params2.headers), - }, - }; } protected stringifyFormItem(formItem: unknown) { @@ -126,20 +102,17 @@ export class HttpClient { } public request = async ({ - secure, path, type, query, format, body, - ...params + ...requestParams <% if (config.unwrapResponseData) { %> }: FullRequestParams): Promise => { <% } else { %> }: FullRequestParams): ResponsePromise => { <% } %> - const secureParams = ((typeof secure === 'boolean' ? secure : this.secure) && this.securityWorker && (await this.securityWorker(this.securityData))) || {}; - const requestParams = this.mergeRequestParams(params, secureParams); const responseFormat = (format || this.format) || undefined; if (type === ContentType.FormData && body && body !== null && typeof body === "object") { From 1a15458d48a98b663fb89f39fc5efe6cc93d1489 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:46:48 -0500 Subject: [PATCH 06/13] fix: `query` option type --- templates/base/http-clients/ky-http-client.ejs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 381a5117..55c42b19 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -2,7 +2,7 @@ const { apiConfig, generateResponses, config } = it; %> -import type { KyInstance, Options as KyOptions } from "ky"; +import type { KyInstance, Options as KyOptions, SearchParamsOption } from "ky"; import ky from "ky"; type KyResponse = Response & { @@ -38,16 +38,15 @@ export type ResponsePromise = { text: () => Promise; } & Promise>; -export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; -export interface FullRequestParams extends Omit { +export interface FullRequestParams extends Omit { /** request path */ path: string; /** content type of request body */ type?: ContentType; /** query params */ - query?: QueryParamsType; + query?: SearchParamsOption; /** format of response (i.e. response.json() -> format: "json") */ format?: ResponseFormat; /** request body */ From 84710b826217a00e8b164a11fa371583f51a0a6b Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:47:13 -0500 Subject: [PATCH 07/13] fix: ky instance usage --- .../base/http-clients/ky-http-client.ejs | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 55c42b19..4f4d899a 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -67,11 +67,11 @@ export enum ContentType { } export class HttpClient { - public instance: KyInstance; + public ky: KyInstance; private format?: ResponseType; constructor({ format, ...options }: ApiConfig = {}) { - this.instance = ky.create({ ...options, prefixUrl: options.prefixUrl || "<%~ apiConfig.baseUrl %>" }) + this.ky = ky.create({ ...options, prefixUrl: options.prefixUrl || "<%~ apiConfig.baseUrl %>" }) this.format = format; } @@ -100,7 +100,7 @@ export class HttpClient { }, new FormData()); } - public request = async ({ + public request = ({ path, type, query, @@ -112,30 +112,37 @@ export class HttpClient { <% } else { %> }: FullRequestParams): ResponsePromise => { <% } %> - const responseFormat = (format || this.format) || undefined; - - if (type === ContentType.FormData && body && body !== null && typeof body === "object") { - body = this.createFormData(body as Record); - } - - if (type === ContentType.Text && body && body !== null && typeof body !== "string") { - body = JSON.stringify(body); + if (body) { + if (type === ContentType.FormData) { + body = typeof body === "object" ? this.createFormData(body as Record) : body; + } else if (type === ContentType.Text) { + body = typeof body !== "string" ? JSON.stringify(body) : body; + } } - return this.instance.request({ + const request = this.ky(path, { ...requestParams, headers: { ...(requestParams.headers || {}), ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), }, - params: query, - responseType: responseFormat, - data: body, - url: path, + searchParams: query, + body, + }); + <% if (config.unwrapResponseData) { %> - }).json(); + const responseFormat = (format || this.format) || undefined; + return responseFormat === "json" + ? request.json() + : responseFormat === "arrayBuffer" + ? request.arrayBuffer() + : responseFormat === "blob" + ? request.blob() + : responseFormat === "formData" + ? request.formData() + : request.text(); <% } else { %> - }); + return request; <% } %> }; } From b3310efa9fdf87e3bc3646cb18cceae85ee90c79 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:48:09 -0500 Subject: [PATCH 08/13] chore: remove method description --- .../base/http-clients/ky-http-client.ejs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 4f4d899a..6f824278 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -13,27 +13,6 @@ export type ResponsePromise = { arrayBuffer: () => Promise; blob: () => Promise; formData: () => Promise; - /** - Get the response body as JSON. - - @example - ``` - import ky from 'ky'; - - const json = await ky(…).json(); - ``` - - @example - ``` - import ky from 'ky'; - - interface Result { - value: number; - } - - const result = await ky(…).json(); - ``` - */ json(): Promise; text: () => Promise; } & Promise>; From 8ceddadefe11fed9e44e05d86f9e065fbfc46778 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:49:48 -0500 Subject: [PATCH 09/13] fix: body option must be cast to any --- templates/base/http-clients/ky-http-client.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 6f824278..6c6a8c11 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -106,7 +106,7 @@ export class HttpClient { ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), }, searchParams: query, - body, + body: body as any, }); <% if (config.unwrapResponseData) { %> From 66c617d6ba30ec2723fb6e948302a61a0a4d18ed Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:51:11 -0500 Subject: [PATCH 10/13] nit: rename the rest param --- templates/base/http-clients/ky-http-client.ejs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 6c6a8c11..87d73093 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -85,7 +85,7 @@ export class HttpClient { query, format, body, - ...requestParams + ...options <% if (config.unwrapResponseData) { %> }: FullRequestParams): Promise => { <% } else { %> @@ -100,9 +100,9 @@ export class HttpClient { } const request = this.ky(path, { - ...requestParams, + ...options, headers: { - ...(requestParams.headers || {}), + ...(options.headers || {}), ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), }, searchParams: query, From 5ed1fe40c6640a01bdde55a21b473d1b076e9114 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 01:57:05 -0500 Subject: [PATCH 11/13] fix: ky headers option --- .../base/http-clients/ky-http-client.ejs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 87d73093..26fcabfa 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -99,14 +99,24 @@ export class HttpClient { } } + let headers: Headers | Record | undefined; + if (options.headers instanceof Headers) { + headers = new Headers(options.headers); + if (type && type !== ContentType.FormData) { + headers.set('Content-Type', type); + } + } else { + headers = { ...options.headers } as Record; + if (type && type !== ContentType.FormData) { + headers['Content-Type'] = type; + } + } + const request = this.ky(path, { - ...options, - headers: { - ...(options.headers || {}), - ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), - }, - searchParams: query, - body: body as any, + ...options, + headers, + searchParams: query, + body: body as any, }); <% if (config.unwrapResponseData) { %> From 69a26ecf55a634ee752e3c0e0d9e4ecaf326861e Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:22:01 -0500 Subject: [PATCH 12/13] fix: strip leading / to avoid ky error --- templates/base/http-clients/ky-http-client.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 26fcabfa..490fc0ae 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -112,7 +112,7 @@ export class HttpClient { } } - const request = this.ky(path, { + const request = this.ky(path.replace(/^\//, ''), { ...options, headers, searchParams: query, From 8c770b891697d94b70d73a85bc10ed98e52777c1 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Tue, 5 Mar 2024 20:00:30 -0500 Subject: [PATCH 13/13] feat: reimplement securityWorker --- .../base/http-clients/ky-http-client.ejs | 235 ++++++++++++------ 1 file changed, 159 insertions(+), 76 deletions(-) diff --git a/templates/base/http-clients/ky-http-client.ejs b/templates/base/http-clients/ky-http-client.ejs index 490fc0ae..36a735c3 100644 --- a/templates/base/http-clients/ky-http-client.ejs +++ b/templates/base/http-clients/ky-http-client.ejs @@ -2,7 +2,14 @@ const { apiConfig, generateResponses, config } = it; %> -import type { KyInstance, Options as KyOptions, SearchParamsOption } from "ky"; +import type { + BeforeRequestHook, + Hooks, + KyInstance, + Options as KyOptions, + NormalizedOptions, + SearchParamsOption, +} from "ky"; import ky from "ky"; type KyResponse = Response & { @@ -19,7 +26,10 @@ export type ResponsePromise = { export type ResponseFormat = keyof Omit; -export interface FullRequestParams extends Omit { +export interface FullRequestParams + extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; /** request path */ path: string; /** content type of request body */ @@ -32,9 +42,17 @@ export interface FullRequestParams extends Omit; - -export interface ApiConfig extends Omit { +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | NormalizedOptions | void; + secure?: boolean; format?: ResponseType; } @@ -46,92 +64,157 @@ export enum ContentType { } export class HttpClient { - public ky: KyInstance; - private format?: ResponseType; + public ky: KyInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...options + }: ApiConfig = {}) { + this.ky = ky.create({ ...options, prefixUrl: options.prefixUrl || "" }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: KyOptions, + params2?: KyOptions, + ): KyOptions { + return { + ...params1, + ...params2, + headers: { + ...params1.headers, + ...(params2 && params2.headers), + }, + }; + } - constructor({ format, ...options }: ApiConfig = {}) { - this.ky = ky.create({ ...options, prefixUrl: options.prefixUrl || "<%~ apiConfig.baseUrl %>" }) - this.format = format; + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; } - - protected stringifyFormItem(formItem: unknown) { - if (typeof formItem === "object" && formItem !== null) { - return JSON.stringify(formItem); - } else { - return `${formItem}`; + } + + protected createFormData(input: Record): FormData { + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); } - } - - protected createFormData(input: Record): FormData { - return Object.keys(input || {}).reduce((formData, key) => { - const property = input[key]; - const propertyContent: any[] = (property instanceof Array) ? property : [property] - - for (const formItem of propertyContent) { - const isFileType = formItem instanceof Blob || formItem instanceof File; - formData.append( - key, - isFileType ? formItem : this.stringifyFormItem(formItem) - ); - } - - return formData; - }, new FormData()); - } - public request = ({ - path, - type, - query, - format, - body, - ...options + return formData; + }, new FormData()); + } + + public request = ({ + secure = this.secure, + path, + type, + query, + format, + body, + ...options <% if (config.unwrapResponseData) { %> - }: FullRequestParams): Promise => { + }: FullRequestParams): Promise => { <% } else { %> - }: FullRequestParams): ResponsePromise => { + }: FullRequestParams): ResponsePromise => { <% } %> - if (body) { - if (type === ContentType.FormData) { - body = typeof body === "object" ? this.createFormData(body as Record) : body; - } else if (type === ContentType.Text) { - body = typeof body !== "string" ? JSON.stringify(body) : body; - } - } + if (body) { + if (type === ContentType.FormData) { + body = + typeof body === "object" + ? this.createFormData(body as Record) + : body; + } else if (type === ContentType.Text) { + body = typeof body !== "string" ? JSON.stringify(body) : body; + } + } - let headers: Headers | Record | undefined; - if (options.headers instanceof Headers) { - headers = new Headers(options.headers); - if (type && type !== ContentType.FormData) { - headers.set('Content-Type', type); - } - } else { - headers = { ...options.headers } as Record; - if (type && type !== ContentType.FormData) { - headers['Content-Type'] = type; + let headers: Headers | Record | undefined; + if (options.headers instanceof Headers) { + headers = new Headers(options.headers); + if (type && type !== ContentType.FormData) { + headers.set("Content-Type", type); + } + } else { + headers = { ...options.headers } as Record; + if (type && type !== ContentType.FormData) { + headers["Content-Type"] = type; + } + } + + let hooks: Hooks | undefined; + if (secure && this.securityWorker) { + const securityWorker: BeforeRequestHook = async (request, options) => { + const secureOptions = await this.securityWorker!(this.securityData); + if (secureOptions && typeof secureOptions === "object") { + let { headers } = options; + if (secureOptions.headers) { + const mergedHeaders = new Headers(headers); + const secureHeaders = new Headers(secureOptions.headers); + secureHeaders.forEach((value, key) => { + mergedHeaders.set(key, value); + }); + headers = mergedHeaders; } + return new Request(request.url, { + ...options, + ...secureOptions, + headers, + }); } + }; + + hooks = { + ...options.hooks, + beforeRequest: + options.hooks && options.hooks.beforeRequest + ? [securityWorker, ...options.hooks.beforeRequest] + : [securityWorker], + }; + } - const request = this.ky(path.replace(/^\//, ''), { - ...options, - headers, - searchParams: query, - body: body as any, - }); + const request = this.ky(path.replace(/^\//, ""), { + ...options, + headers, + searchParams: query, + body: body as any, + hooks, + }); <% if (config.unwrapResponseData) { %> - const responseFormat = (format || this.format) || undefined; - return responseFormat === "json" - ? request.json() - : responseFormat === "arrayBuffer" - ? request.arrayBuffer() - : responseFormat === "blob" + const responseFormat = format || this.format || undefined; + return (responseFormat === "json" + ? request.json() + : responseFormat === "arrayBuffer" + ? request.arrayBuffer() + : responseFormat === "blob" ? request.blob() : responseFormat === "formData" - ? request.formData() - : request.text(); + ? request.formData() + : request.text()) as Promise; <% } else { %> - return request; + return request; <% } %> - }; + }; }