From 246f1561d094b344a82e83d7bae70f5e397430f3 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 28 Oct 2025 22:17:25 +0100 Subject: [PATCH] feat: add sort-route-params flags --- index.ts | 6 + src/configuration.ts | 1 + templates/default/procedure-call.ejs | 26 +- tests/extended.test.ts | 1 + .../__snapshots__/basic.test.ts.snap | 316 ++++++++++++++++++ tests/spec/sortRouteParams/basic.test.ts | 33 ++ tests/spec/sortRouteParams/schema.json | 129 +++++++ types/index.ts | 2 + 8 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 tests/spec/sortRouteParams/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/sortRouteParams/basic.test.ts create mode 100644 tests/spec/sortRouteParams/schema.json diff --git a/index.ts b/index.ts index f3356e94..bb80bcee 100644 --- a/index.ts +++ b/index.ts @@ -258,6 +258,11 @@ const generateCommand = defineCommand({ description: "sort routes in alphabetical order", default: codeGenBaseConfig.sortRoutes, }, + "sort-route-params": { + type: "boolean", + description: "sort route params from path order", + default: codeGenBaseConfig.sortRouteParams, + }, "sort-types": { type: "boolean", description: "sort fields and types", @@ -324,6 +329,7 @@ const generateCommand = defineCommand({ silent: args.silent, singleHttpClient: args["single-http-client"], sortRoutes: args["sort-routes"], + sortRouteParams: args["sort-route-params"], sortTypes: args["sort-types"], templates: args.templates, toJS: args.js, diff --git a/src/configuration.ts b/src/configuration.ts index ea3f48b3..10f459b0 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -117,6 +117,7 @@ export class CodeGenConfig { disableThrowOnError = false; sortTypes = false; sortRoutes = false; + sortRouteParams = false; templatePaths = { /** `templates/base` */ base: "", diff --git a/templates/default/procedure-call.ejs b/templates/default/procedure-call.ejs index 90af47bd..63f234ee 100644 --- a/templates/default/procedure-call.ejs +++ b/templates/default/procedure-call.ejs @@ -39,11 +39,27 @@ const rawWrapperArgs = config.extractRequestParams ? requestConfigParam, ]) -const wrapperArgs = _ - // Sort by optionality - .sortBy(rawWrapperArgs, [o => o.optional]) - .map(argToTmpl) - .join(', ') + +const requiredArgs = rawWrapperArgs.filter((o) => !o.optional) +const optionalArgs = rawWrapperArgs.filter((o) => o.optional) + +// sort by params index of params in path +if (config.sortRouteParams) { + requiredArgs.sort(({name}) => { + const idx = path.indexOf(`{${name}}`) + if (idx === -1) { + return Infinity + } + return idx + }) +} + +const sortedRawWrapperArgs = [ + ...requiredArgs, + ...optionalArgs +] + +const wrapperArgs = sortedRawWrapperArgs.map(argToTmpl).join(', ') // RequestParams["type"] const requestContentKind = { diff --git a/tests/extended.test.ts b/tests/extended.test.ts index 45a645bb..84c05214 100644 --- a/tests/extended.test.ts +++ b/tests/extended.test.ts @@ -33,6 +33,7 @@ describe("extended", async () => { generateClient: true, generateRouteTypes: true, sortRoutes: true, + sortRouteParams: true, sortTypes: true, }); diff --git a/tests/spec/sortRouteParams/__snapshots__/basic.test.ts.snap b/tests/spec/sortRouteParams/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..2d44dce4 --- /dev/null +++ b/tests/spec/sortRouteParams/__snapshots__/basic.test.ts.snap @@ -0,0 +1,316 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > --sort-route-params 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +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; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = "https://6-dot-authentiqio.appspot.com"; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return \`\${encodedKey}=\${encodeURIComponent(typeof value === "number" ? value : \`\${value}\`)}\`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter( + (key) => "undefined" !== typeof query[key], + ); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? \`?\${queryString}\` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.JsonApi]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input: any) => { + if (input instanceof FormData) { + return input; + } + + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : \`\${property}\`, + ); + return formData; + }, new FormData()); + }, + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = ( + cancelToken: CancelToken, + ): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + \`\${baseUrl || this.baseUrl || ""}\${path}\${queryString ? \`?\${queryString}\` : ""}\`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData + ? { "Content-Type": type } + : {}), + }, + signal: + (cancelToken + ? this.createAbortSignal(cancelToken) + : requestParams.signal) || null, + body: + typeof body === "undefined" || body === null + ? null + : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const responseToParse = responseFormat ? response.clone() : response; + const data = !responseFormat + ? r + : await responseToParse[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data; + }); + }; +} + +/** + * @title Authentiq + * @version 6 + * @license Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html) + * @termsOfService http://authentiq.com/terms/ + * @baseUrl https://6-dot-authentiqio.appspot.com + * @contact Authentiq team (http://authentiq.io/support) + * + * Strong authentication, without the passwords. + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + key = { + /** + * @description Register a new ID \`JWT(sub, devtoken)\` v5: \`JWT(sub, pk, devtoken, ...)\` See: https://github.com/skion/authentiq/wiki/JWT-Examples + * + * @tags key, post + * @name KeyRegister + * @request POST:/key/{PK}/{JobID}/ + */ + keyRegister: ( + pk: string, + jobId: string, + body: any, + params: RequestParams = {}, + ) => + this.request< + { + /** revoke key */ + secret?: string; + /** registered */ + status?: string; + }, + any + >({ + path: \`/key/\${pk}/\${jobId}/\`, + method: "POST", + body: body, + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/sortRouteParams/basic.test.ts b/tests/spec/sortRouteParams/basic.test.ts new file mode 100644 index 00000000..28694008 --- /dev/null +++ b/tests/spec/sortRouteParams/basic.test.ts @@ -0,0 +1,33 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { generateApi } from "../../../src/index.js"; + +describe("basic", async () => { + let tmpdir = ""; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("--sort-route-params", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + sortRouteParams: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/sortRouteParams/schema.json b/tests/spec/sortRouteParams/schema.json new file mode 100644 index 00000000..0700bb6b --- /dev/null +++ b/tests/spec/sortRouteParams/schema.json @@ -0,0 +1,129 @@ +{ + "swagger": "2.0", + "schemes": ["https"], + "host": "6-dot-authentiqio.appspot.com", + "basePath": "/", + "info": { + "contact": { + "email": "hello@authentiq.com", + "name": "Authentiq team", + "url": "http://authentiq.io/support" + }, + "description": "Strong authentication, without the passwords.", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "termsOfService": "http://authentiq.com/terms/", + "title": "Authentiq", + "version": "6", + "x-apisguru-categories": ["security"], + "x-logo": { + "backgroundColor": "#F26641", + "url": "https://api.apis.guru/v2/cache/logo/https_www.authentiq.com_theme_images_authentiq-logo-a-inverse.svg" + }, + "x-origin": [ + { + "format": "swagger", + "url": "https://raw.githubusercontent.com/AuthentiqID/authentiq-docs/master/docs/swagger/issuer.yaml", + "version": "2.0" + } + ], + "x-preferred": true, + "x-providerName": "6-dot-authentiqio.appspot.com" + }, + "parameters": { + "AuthentiqID": { + "description": "Authentiq ID to register", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AuthentiqID" + } + }, + "JobID": { + "description": "Job ID (20 chars)", + "in": "path", + "name": "job", + "required": true, + "type": "string" + }, + "PK": { + "description": "Public Signing Key - Authentiq ID (43 chars)", + "in": "path", + "name": "PK", + "required": true, + "type": "string" + }, + "PushToken": { + "description": "Push Token.", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PushToken" + } + }, + "Scope": { + "description": "Claims of scope", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Claims" + } + } + }, + "responses": { + "ErrorResponse": { + "description": "Error response", + "schema": { + "$ref": "#/definitions/Error" + } + } + }, + "paths": { + "/key/{PK}/{JobID}/": { + "post": { + "consumes": ["application/jwt"], + "description": "Register a new ID `JWT(sub, devtoken)`\n\nv5: `JWT(sub, pk, devtoken, ...)`\n\nSee: https://github.com/skion/authentiq/wiki/JWT-Examples\n", + "operationId": "key_register", + "parameters": [ + { + "$ref": "#/parameters/AuthentiqID" + } + ], + "produces": ["application/json"], + "responses": { + "201": { + "description": "Successfully registered", + "schema": { + "properties": { + "secret": { + "description": "revoke key", + "type": "string" + }, + "status": { + "description": "registered", + "type": "string" + } + }, + "type": "object" + } + }, + "409": { + "description": "Key already registered `duplicate-key`", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "$ref": "#/responses/ErrorResponse" + } + }, + "tags": ["key", "post"] + } + } + } +} diff --git a/types/index.ts b/types/index.ts index 48f5632f..f6a9f004 100644 --- a/types/index.ts +++ b/types/index.ts @@ -523,6 +523,8 @@ export interface GenerateApiConfiguration { sortTypes: boolean; /** sort routes in alphabetical order */ sortRoutes: boolean; + /** sort route params from path order */ + sortRouteParams: boolean; /** ability to send HttpClient instance to Api constructor */ singleHttpClient: boolean; /** prefix string value for type names */