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..36a735c3 --- /dev/null +++ b/templates/base/http-clients/ky-http-client.ejs @@ -0,0 +1,220 @@ +<% +const { apiConfig, generateResponses, config } = it; +%> + +import type { + BeforeRequestHook, + Hooks, + KyInstance, + Options as KyOptions, + NormalizedOptions, + SearchParamsOption, +} from "ky"; +import ky from "ky"; + +type KyResponse = Response & { + json(): Promise; +} + +export type ResponsePromise = { + arrayBuffer: () => Promise; + blob: () => Promise; + formData: () => Promise; + json(): Promise; + text: () => Promise; +} & Promise>; + +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?: SearchParamsOption; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; +} + +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; +} + +export enum ContentType { + Json = "application/json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + 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), + }, + }; + } + + 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 = ({ + secure = this.secure, + path, + type, + query, + format, + body, + ...options +<% if (config.unwrapResponseData) { %> + }: FullRequestParams): Promise => { +<% } else { %> + }: 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; + } + } + + 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, + hooks, + }); + +<% if (config.unwrapResponseData) { %> + 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()) as Promise; +<% } else { %> + return request; +<% } %> + }; +} 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/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` }