From a56ded29f8eab147fc66c2cb3da027be2318df7f Mon Sep 17 00:00:00 2001 From: Sergey Volkov Date: Sat, 27 Sep 2025 15:01:21 +0300 Subject: [PATCH 1/5] feat: add ability to use in paths --- .vscode/settings.json | 19 ++ package.json | 1 + src/code-gen-process.ts | 43 ++- src/configuration.ts | 3 +- src/schema-components-map.ts | 22 +- src/schema-routes/schema-routes.ts | 29 +- .../paths/__snapshots__/basic.test.ts.snap | 322 ++++++++++++++++++ tests/spec/paths/basic.test.ts | 39 +++ tests/spec/paths/paths/repro.yaml | 8 + tests/spec/paths/schema.yaml | 32 ++ yarn.lock | 113 +++++- 11 files changed, 621 insertions(+), 10 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 tests/spec/paths/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/paths/basic.test.ts create mode 100644 tests/spec/paths/paths/repro.yaml create mode 100644 tests/spec/paths/schema.yaml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..8538dc8ea --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 40d8b403f..bd6bbead2 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "typedoc": "typedoc" }, "dependencies": { + "@apidevtools/swagger-parser": "12.0.0", "@biomejs/js-api": "3.0.0", "@biomejs/wasm-nodejs": "2.2.4", "@types/lodash": "^4.17.20", diff --git a/src/code-gen-process.ts b/src/code-gen-process.ts index 8793b0e8c..07355923a 100644 --- a/src/code-gen-process.ts +++ b/src/code-gen-process.ts @@ -1,3 +1,4 @@ +import SwaggerParser, { type resolve } from "@apidevtools/swagger-parser"; import { consola } from "consola"; import lodash from "lodash"; import * as typescript from "typescript"; @@ -46,9 +47,12 @@ export class CodeGenProcess { templatesWorker: TemplatesWorker; schemaWalker: SchemaWalker; javascriptTranslator: JavascriptTranslator; + swaggerParser: SwaggerParser; + swaggerRefs: Awaited> | undefined | null; constructor(config: Partial) { this.config = new CodeGenConfig(config); + this.swaggerParser = new SwaggerParser(); this.fileSystem = new FileSystem(); this.swaggerSchemaResolver = new SwaggerSchemaResolver( this.config, @@ -58,7 +62,7 @@ export class CodeGenProcess { this.config, this.swaggerSchemaResolver, ); - this.schemaComponentsMap = new SchemaComponentsMap(this.config); + this.schemaComponentsMap = new SchemaComponentsMap(this.config, this); this.typeNameFormatter = new TypeNameFormatter(this.config); this.templatesWorker = new TemplatesWorker( this.config, @@ -75,6 +79,7 @@ export class CodeGenProcess { ); this.schemaRoutes = new SchemaRoutes( this.config, + this, this.schemaParserFabric, this.schemaComponentsMap, this.templatesWorker, @@ -98,6 +103,42 @@ export class CodeGenProcess { this.swaggerSchemaResolver.fixSwaggerSchema(swagger); + try { + this.swaggerRefs = await this.swaggerParser.resolve( + this.config.url || this.config.input || (this.config.spec as any), + { + continueOnError: true, + mutateInputSchema: true, + validate: { + schema: false, + spec: false, + }, + resolve: { + external: true, + http: { + ...this.config.requestOptions, + headers: Object.assign( + {}, + this.config.authorizationToken + ? { + Authorization: this.config.authorizationToken, + } + : {}, + this.config.requestOptions?.headers ?? {}, + ), + }, + }, + }, + ); + this.swaggerRefs.set("fixed-swagger-schema", swagger.usageSchema as any); + this.swaggerRefs.set( + "original-swagger-schema", + swagger.originalSchema as any, + ); + } catch (e) { + consola.error(e); + } + this.config.update({ swaggerSchema: swagger.usageSchema, originalSchema: swagger.originalSchema, diff --git a/src/configuration.ts b/src/configuration.ts index ea3f48b38..354fdb023 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -13,6 +13,7 @@ import type { MonoSchemaParser } from "./schema-parser/mono-schema-parser.js"; import type { SchemaParser } from "./schema-parser/schema-parser.js"; import type { Translator } from "./translators/translator.js"; import { objectAssign } from "./util/object-assign.js"; +import SwaggerParser from "@apidevtools/swagger-parser"; const TsKeyword = { Number: "number", @@ -167,7 +168,7 @@ export class CodeGenConfig { spec: OpenAPI.Document | null = null; fileName = "Api.ts"; authorizationToken: string | undefined; - requestOptions = null; + requestOptions: Record | null = null; jsPrimitiveTypes: string[] = []; jsEmptyTypes: string[] = []; diff --git a/src/schema-components-map.ts b/src/schema-components-map.ts index e27573edf..57a82eb00 100644 --- a/src/schema-components-map.ts +++ b/src/schema-components-map.ts @@ -1,11 +1,16 @@ +import consola from "consola"; import type { SchemaComponent } from "../types/index.js"; +import type { CodeGenProcess } from "./code-gen-process.js"; import type { CodeGenConfig } from "./configuration.js"; export class SchemaComponentsMap { _data: SchemaComponent[] = []; config: CodeGenConfig; - constructor(config: CodeGenConfig) { + constructor( + config: CodeGenConfig, + private codegenProcess: CodeGenProcess, + ) { this.config = config; } @@ -66,7 +71,20 @@ export class SchemaComponentsMap { } get = ($ref: string) => { - return this._data.find((c) => c.$ref === $ref) || null; + const localFound = this._data.find((c) => c.$ref === $ref) || null; + + if (localFound != null) { + return localFound; + } + + if (this.codegenProcess.swaggerRefs) { + try { + return this.codegenProcess.swaggerRefs.get($ref) as Record; + } catch (e) { + consola.error(e); + return null; + } + } }; // Ensure enums are at the top of components list diff --git a/src/schema-routes/schema-routes.ts b/src/schema-routes/schema-routes.ts index b570502e9..67d855e2c 100644 --- a/src/schema-routes/schema-routes.ts +++ b/src/schema-routes/schema-routes.ts @@ -4,6 +4,7 @@ import type { GenerateApiConfiguration, ParsedRoute, } from "../../types/index.js"; +import type { CodeGenProcess } from "../code-gen-process.js"; import type { CodeGenConfig } from "../configuration.js"; import { DEFAULT_BODY_ARG_NAME, @@ -32,6 +33,7 @@ const CONTENT_KIND = { export class SchemaRoutes { config: CodeGenConfig; + codegenProcess: CodeGenProcess; schemaParserFabric: SchemaParserFabric; schemaUtils: SchemaUtils; typeNameFormatter: TypeNameFormatter; @@ -47,12 +49,14 @@ export class SchemaRoutes { constructor( config: CodeGenConfig, + codegenProcess: CodeGenProcess, schemaParserFabric: SchemaParserFabric, schemaComponentsMap: SchemaComponentsMap, templatesWorker: TemplatesWorker, typeNameFormatter: TypeNameFormatter, ) { this.config = config; + this.codegenProcess = codegenProcess; this.schemaParserFabric = schemaParserFabric; this.schemaUtils = this.schemaParserFabric.schemaUtils; this.typeNameFormatter = typeNameFormatter; @@ -71,10 +75,21 @@ export class SchemaRoutes { return lodash.reduce( routeInfoByMethodsMap, (acc, requestInfo, method) => { - if ( - method.startsWith("x-") || - ["parameters", "$ref"].includes(method) - ) { + if (method.startsWith("x-") || ["parameters"].includes(method)) { + return acc; + } + + if (method === "$ref") { + if (this.codegenProcess.swaggerRefs) { + try { + const resolved = this.codegenProcess.swaggerRefs.get(requestInfo); + Object.assign(acc, this.createRequestsMap(resolved)); + return acc; + } catch (e) { + consola.error(e); + return acc; + } + } return acc; } @@ -209,7 +224,11 @@ export class SchemaRoutes { let routeParam = null; - if (refTypeInfo?.rawTypeData.in && refTypeInfo.rawTypeData) { + if ( + !!refTypeInfo?.rawTypeData && + typeof refTypeInfo === "object" && + refTypeInfo?.rawTypeData.in + ) { if (!routeParams[refTypeInfo.rawTypeData.in]) { routeParams[refTypeInfo.rawTypeData.in] = []; } diff --git a/tests/spec/paths/__snapshots__/basic.test.ts.snap b/tests/spec/paths/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..7b4def8ef --- /dev/null +++ b/tests/spec/paths/__snapshots__/basic.test.ts.snap @@ -0,0 +1,322 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > paths(simplest-1-path) 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 ## + * --------------------------------------------------------------- + */ + +/** Weather */ +export interface Weather { + /** + * Weather condition id + * @format int32 + * @example 803 + */ + id?: number; + /** + * Group of weather parameters (Rain, Snow, Extreme etc.) + * @example "Clouds" + */ + main?: string; + /** + * Weather condition within the group + * @example "broken clouds" + */ + description?: string; + /** + * Weather icon id + * @example "04n" + */ + icon?: string; +} + +export type HelloListData = object; + +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 = ""; + 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 repro + * @version 2.0.0 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + hello = { + /** + * No description + * + * @name HelloList + * @request GET:/hello + */ + helloList: (params: RequestParams = {}) => + this.request({ + path: \`/hello\`, + method: "GET", + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/paths/basic.test.ts b/tests/spec/paths/basic.test.ts new file mode 100644 index 000000000..4674a44c1 --- /dev/null +++ b/tests/spec/paths/basic.test.ts @@ -0,0 +1,39 @@ +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("paths(simplest-1-path)", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.yaml"), + output: tmpdir, + silent: true, + extractRequestBody: true, + extractRequestParams: true, + extractResponses: true, + extractResponseError: true, + extractResponseBody: true, + extractEnums: true, + generateClient: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/paths/paths/repro.yaml b/tests/spec/paths/paths/repro.yaml new file mode 100644 index 000000000..27b8d933a --- /dev/null +++ b/tests/spec/paths/paths/repro.yaml @@ -0,0 +1,8 @@ +hello: + get: + responses: + '200': + content: + application/json: + schema: + type: object \ No newline at end of file diff --git a/tests/spec/paths/schema.yaml b/tests/spec/paths/schema.yaml new file mode 100644 index 000000000..b4b1fee32 --- /dev/null +++ b/tests/spec/paths/schema.yaml @@ -0,0 +1,32 @@ +openapi: 3.0.2 +info: + version: 2.0.0 + title: repro + +paths: + /hello: + $ref: './paths/repro.yaml#/hello' + +components: + schemas: + Weather: + title: Weather + type: object + properties: + id: + type: integer + description: Weather condition id + format: int32 + example: 803 + main: + type: string + description: Group of weather parameters (Rain, Snow, Extreme etc.) + example: Clouds + description: + type: string + description: Weather condition within the group + example: broken clouds + icon: + type: string + description: Weather icon id + example: 04n \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index e345e7f8f..4e1255cbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,46 @@ __metadata: version: 8 cacheKey: 10c0 +"@apidevtools/json-schema-ref-parser@npm:14.0.1": + version: 14.0.1 + resolution: "@apidevtools/json-schema-ref-parser@npm:14.0.1" + dependencies: + "@types/json-schema": "npm:^7.0.15" + js-yaml: "npm:^4.1.0" + checksum: 10c0/f8aff4d32f66b81be0e641da175d359ec3e4191f9c65343b30f90cfbcfdbdb78b13e57c4a0a8d0574c828294abde56400a031858f61cf38b3309a4213698dc0c + languageName: node + linkType: hard + +"@apidevtools/openapi-schemas@npm:^2.1.0": + version: 2.1.0 + resolution: "@apidevtools/openapi-schemas@npm:2.1.0" + checksum: 10c0/f4aa0f9df32e474d166c84ef91bceb18fa1c4f44b5593879529154ef340846811ea57dc2921560f157f692262827d28d988dd6e19fb21f00320e9961964176b4 + languageName: node + linkType: hard + +"@apidevtools/swagger-methods@npm:^3.0.2": + version: 3.0.2 + resolution: "@apidevtools/swagger-methods@npm:3.0.2" + checksum: 10c0/8c390e8e50c0be7787ba0ba4c3758488bde7c66c2d995209b4b48c1f8bc988faf393cbb24a4bd1cd2d42ce5167c26538e8adea5c85eb922761b927e4dab9fa1c + languageName: node + linkType: hard + +"@apidevtools/swagger-parser@npm:12.0.0": + version: 12.0.0 + resolution: "@apidevtools/swagger-parser@npm:12.0.0" + dependencies: + "@apidevtools/json-schema-ref-parser": "npm:14.0.1" + "@apidevtools/openapi-schemas": "npm:^2.1.0" + "@apidevtools/swagger-methods": "npm:^3.0.2" + ajv: "npm:^8.17.1" + ajv-draft-04: "npm:^1.0.0" + call-me-maybe: "npm:^1.0.2" + peerDependencies: + openapi-types: ">=7" + checksum: 10c0/c905c49dc54788e8c697a61072b05dc3b51ba57b428ddbc683d364dc9981a1d07a5799d70c607858192d54b98b1256c380b2580d9b0f4fd55ff8400f6f285cd7 + languageName: node + linkType: hard + "@babel/generator@npm:^7.28.3": version: 7.28.3 resolution: "@babel/generator@npm:7.28.3" @@ -1203,6 +1243,13 @@ __metadata: languageName: node linkType: hard +"@types/json-schema@npm:^7.0.15": + version: 7.0.15 + resolution: "@types/json-schema@npm:7.0.15" + checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db + languageName: node + linkType: hard + "@types/lodash@npm:^4.17.20": version: 4.17.20 resolution: "@types/lodash@npm:4.17.20" @@ -1347,6 +1394,30 @@ __metadata: languageName: node linkType: hard +"ajv-draft-04@npm:^1.0.0": + version: 1.0.0 + resolution: "ajv-draft-04@npm:1.0.0" + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: 10c0/6044310bd38c17d77549fd326bd40ce1506fa10b0794540aa130180808bf94117fac8c9b448c621512bea60e4a947278f6a978e87f10d342950c15b33ddd9271 + languageName: node + linkType: hard + +"ajv@npm:^8.17.1": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + languageName: node + linkType: hard + "ansi-colors@npm:^4.1.1, ansi-colors@npm:^4.1.3": version: 4.1.3 resolution: "ansi-colors@npm:4.1.3" @@ -1552,7 +1623,7 @@ __metadata: languageName: node linkType: hard -"call-me-maybe@npm:^1.0.1": +"call-me-maybe@npm:^1.0.1, call-me-maybe@npm:^1.0.2": version: 1.0.2 resolution: "call-me-maybe@npm:1.0.2" checksum: 10c0/8eff5dbb61141ebb236ed71b4e9549e488bcb5451c48c11e5667d5c75b0532303788a1101e6978cafa2d0c8c1a727805599c2741e3e0982855c9f1d78cd06c9f @@ -2053,6 +2124,13 @@ __metadata: languageName: node linkType: hard +"fast-deep-equal@npm:^3.1.3": + version: 3.1.3 + resolution: "fast-deep-equal@npm:3.1.3" + checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 + languageName: node + linkType: hard + "fast-glob@npm:^3.2.9": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" @@ -2073,6 +2151,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.0 + resolution: "fast-uri@npm:3.1.0" + checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.19.1 resolution: "fastq@npm:1.19.1" @@ -2531,6 +2616,17 @@ __metadata: languageName: node linkType: hard +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: "npm:^2.0.1" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + languageName: node + linkType: hard + "jsesc@npm:^3.0.2": version: 3.1.0 resolution: "jsesc@npm:3.1.0" @@ -2540,6 +2636,13 @@ __metadata: languageName: node linkType: hard +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -3247,6 +3350,13 @@ __metadata: languageName: node linkType: hard +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + "resolve-from@npm:^5.0.0": version: 5.0.0 resolution: "resolve-from@npm:5.0.0" @@ -3704,6 +3814,7 @@ __metadata: version: 0.0.0-use.local resolution: "swagger-typescript-api@workspace:." dependencies: + "@apidevtools/swagger-parser": "npm:12.0.0" "@biomejs/biome": "npm:2.2.4" "@biomejs/js-api": "npm:3.0.0" "@biomejs/wasm-nodejs": "npm:2.2.4" From c670ca3206f3813dfc909582a0644beb7f561b79 Mon Sep 17 00:00:00 2001 From: Sergey Volkov Date: Mon, 29 Sep 2025 00:48:47 +0300 Subject: [PATCH 2/5] feat: partial support external refs in paths (#447) --- .changeset/silent-beds-greet.md | 5 + package.json | 3 +- src/code-gen-process.ts | 97 +++------- src/configuration.ts | 11 +- src/resolved-swagger-schema.ts | 105 +++++++++++ src/schema-components-map.ts | 90 ++++++--- src/schema-parser/schema-parser-fabric.ts | 4 - src/schema-parser/schema-utils.ts | 3 - src/schema-routes/schema-routes.ts | 89 ++++----- src/schema-walker.ts | 42 ----- src/swagger-schema-resolver.ts | 172 +++++++++++++++--- src/type-name-formatter.ts | 2 +- .../paths/__snapshots__/basic.test.ts.snap | 12 ++ tests/spec/paths/paths/repro.yaml | 13 +- tests/spec/paths/schema.yaml | 2 + types/index.ts | 10 + vitest.config.ts | 2 +- yarn.lock | 72 ++++++++ 18 files changed, 521 insertions(+), 213 deletions(-) create mode 100644 .changeset/silent-beds-greet.md create mode 100644 src/resolved-swagger-schema.ts delete mode 100644 src/schema-walker.ts diff --git a/.changeset/silent-beds-greet.md b/.changeset/silent-beds-greet.md new file mode 100644 index 000000000..804d0928f --- /dev/null +++ b/.changeset/silent-beds-greet.md @@ -0,0 +1,5 @@ +--- +"swagger-typescript-api": minor +--- + +partial support external paths by ref (#447) diff --git a/package.json b/package.json index bd6bbead2..2ce3c0940 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "swagger-schema-official": "2.0.0-bab6bed", "swagger2openapi": "^7.0.8", "typescript": "~5.9.2", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "yummies": "5.7.0" }, "devDependencies": { "@biomejs/biome": "2.2.4", diff --git a/src/code-gen-process.ts b/src/code-gen-process.ts index 07355923a..45e93756a 100644 --- a/src/code-gen-process.ts +++ b/src/code-gen-process.ts @@ -1,4 +1,4 @@ -import SwaggerParser, { type resolve } from "@apidevtools/swagger-parser"; +import type { resolve } from "@apidevtools/swagger-parser"; import { consola } from "consola"; import lodash from "lodash"; import * as typescript from "typescript"; @@ -11,7 +11,6 @@ import { CodeGenConfig } from "./configuration.js"; import { SchemaComponentsMap } from "./schema-components-map.js"; import { SchemaParserFabric } from "./schema-parser/schema-parser-fabric.js"; import { SchemaRoutes } from "./schema-routes/schema-routes.js"; -import { SchemaWalker } from "./schema-walker.js"; import { SwaggerSchemaResolver } from "./swagger-schema-resolver.js"; import { TemplatesWorker } from "./templates-worker.js"; import { JavascriptTranslator } from "./translators/javascript.js"; @@ -45,24 +44,17 @@ export class CodeGenProcess { fileSystem: FileSystem; codeFormatter: CodeFormatter; templatesWorker: TemplatesWorker; - schemaWalker: SchemaWalker; javascriptTranslator: JavascriptTranslator; - swaggerParser: SwaggerParser; swaggerRefs: Awaited> | undefined | null; constructor(config: Partial) { this.config = new CodeGenConfig(config); - this.swaggerParser = new SwaggerParser(); this.fileSystem = new FileSystem(); this.swaggerSchemaResolver = new SwaggerSchemaResolver( this.config, this.fileSystem, ); - this.schemaWalker = new SchemaWalker( - this.config, - this.swaggerSchemaResolver, - ); - this.schemaComponentsMap = new SchemaComponentsMap(this.config, this); + this.schemaComponentsMap = new SchemaComponentsMap(this.config); this.typeNameFormatter = new TypeNameFormatter(this.config); this.templatesWorker = new TemplatesWorker( this.config, @@ -75,11 +67,9 @@ export class CodeGenProcess { this.templatesWorker, this.schemaComponentsMap, this.typeNameFormatter, - this.schemaWalker, ); this.schemaRoutes = new SchemaRoutes( this.config, - this, this.schemaParserFabric, this.schemaComponentsMap, this.templatesWorker, @@ -99,73 +89,35 @@ export class CodeGenProcess { templatesToRender: this.templatesWorker.getTemplates(this.config), }); - const swagger = await this.swaggerSchemaResolver.create(); - - this.swaggerSchemaResolver.fixSwaggerSchema(swagger); - - try { - this.swaggerRefs = await this.swaggerParser.resolve( - this.config.url || this.config.input || (this.config.spec as any), - { - continueOnError: true, - mutateInputSchema: true, - validate: { - schema: false, - spec: false, - }, - resolve: { - external: true, - http: { - ...this.config.requestOptions, - headers: Object.assign( - {}, - this.config.authorizationToken - ? { - Authorization: this.config.authorizationToken, - } - : {}, - this.config.requestOptions?.headers ?? {}, - ), - }, - }, - }, - ); - this.swaggerRefs.set("fixed-swagger-schema", swagger.usageSchema as any); - this.swaggerRefs.set( - "original-swagger-schema", - swagger.originalSchema as any, - ); - } catch (e) { - consola.error(e); - } + const resolvedSwaggerSchema = await this.swaggerSchemaResolver.create(); this.config.update({ - swaggerSchema: swagger.usageSchema, - originalSchema: swagger.originalSchema, + resolvedSwaggerSchema: resolvedSwaggerSchema, + swaggerSchema: resolvedSwaggerSchema.usageSchema, + originalSchema: resolvedSwaggerSchema.originalSchema, }); - this.schemaWalker.addSchema("$usage", swagger.usageSchema); - this.schemaWalker.addSchema("$original", swagger.originalSchema); - consola.info("start generating your typescript api"); this.config.update( - this.config.hooks.onInit(this.config, this) || this.config, + this.config.hooks.onInit?.(this.config, this) || this.config, ); this.schemaComponentsMap.clear(); - lodash.each(swagger.usageSchema.components, (component, componentName) => - lodash.each(component, (rawTypeData, typeName) => { - this.schemaComponentsMap.createComponent( - this.schemaComponentsMap.createRef([ - "components", - componentName, - typeName, - ]), - rawTypeData, - ); - }), + lodash.each( + resolvedSwaggerSchema.usageSchema.components, + (component, componentName) => + lodash.each(component, (rawTypeData, typeName) => { + this.schemaComponentsMap.createComponent( + this.schemaComponentsMap.createRef([ + "components", + componentName, + typeName, + ]), + rawTypeData, + ); + }), ); // Set all discriminators at the top @@ -190,13 +142,10 @@ export class CodeGenProcess { return parsed; }); - this.schemaRoutes.attachSchema({ - usageSchema: swagger.usageSchema, - parsedSchemas, - }); + this.schemaRoutes.attachSchema(resolvedSwaggerSchema, parsedSchemas); const rawConfiguration = { - apiConfig: this.createApiConfig(swagger.usageSchema), + apiConfig: this.createApiConfig(resolvedSwaggerSchema.usageSchema), config: this.config, modelTypes: this.collectModelTypes(), hasSecurityRoutes: this.schemaRoutes.hasSecurityRoutes, @@ -214,7 +163,7 @@ export class CodeGenProcess { }; const configuration = - this.config.hooks.onPrepareConfig(rawConfiguration) || rawConfiguration; + this.config.hooks.onPrepareConfig?.(rawConfiguration) || rawConfiguration; if (this.fileSystem.pathIsExist(this.config.output)) { if (this.config.cleanOutput) { diff --git a/src/configuration.ts b/src/configuration.ts index 354fdb023..1becab217 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -9,11 +9,11 @@ import type { } from "../types/index.js"; import { ComponentTypeNameResolver } from "./component-type-name-resolver.js"; import * as CONSTANTS from "./constants.js"; +import type { ResolvedSwaggerSchema } from "./resolved-swagger-schema.js"; import type { MonoSchemaParser } from "./schema-parser/mono-schema-parser.js"; import type { SchemaParser } from "./schema-parser/schema-parser.js"; import type { Translator } from "./translators/translator.js"; import { objectAssign } from "./util/object-assign.js"; -import SwaggerParser from "@apidevtools/swagger-parser"; const TsKeyword = { Number: "number", @@ -111,6 +111,7 @@ export class CodeGenConfig { ) => {}, onFormatRouteName: (_routeInfo: unknown, _templateRouteName: unknown) => {}, }; + resolvedSwaggerSchema!: ResolvedSwaggerSchema; defaultResponseType; singleHttpClient = false; httpClientType = CONSTANTS.HTTP_CLIENT.FETCH; @@ -440,7 +441,13 @@ export class CodeGenConfig { this.componentTypeNameResolver = new ComponentTypeNameResolver(this, []); } - update = (update: Partial) => { + update = ( + update: Partial< + GenerateApiConfiguration["config"] & { + resolvedSwaggerSchema: ResolvedSwaggerSchema; + } + >, + ) => { objectAssign(this, update); if (this.enumNamesAsValues) { this.extractEnums = true; diff --git a/src/resolved-swagger-schema.ts b/src/resolved-swagger-schema.ts new file mode 100644 index 000000000..56d5ae108 --- /dev/null +++ b/src/resolved-swagger-schema.ts @@ -0,0 +1,105 @@ +import type { resolve } from "@apidevtools/swagger-parser"; +import consola from "consola"; +import type { OpenAPI } from "openapi-types"; +import type { AnyObject, Maybe, Primitive } from "yummies/utils/types"; +import type { CodeGenConfig } from "./configuration.js"; + +export interface RefDetails { + ref: string; + isLocal: boolean; + externalUrlOrPath: Maybe; + externalOpenapiFileName?: string; +} + +export class ResolvedSwaggerSchema { + private parsedRefsCache = new Map(); + + constructor( + private config: CodeGenConfig, + public usageSchema: OpenAPI.Document, + public originalSchema: OpenAPI.Document, + private resolvers: Awaited>[], + ) { + this.usageSchema = usageSchema; + this.originalSchema = originalSchema; + } + + getRefDetails(ref: string): RefDetails { + if (!this.parsedRefsCache.has(ref)) { + const isLocal = ref.startsWith("#"); + + if (isLocal) { + this.parsedRefsCache.set(ref, { + ref, + isLocal, + externalUrlOrPath: null, + }); + } else { + const externalUrlOrPath = ref.split("#")[0]! + let externalOpenapiFileName = (externalUrlOrPath.split('/').at(-1) || '') + + if (externalOpenapiFileName.endsWith('.json') || externalOpenapiFileName.endsWith('.yaml')) { + externalOpenapiFileName = externalOpenapiFileName.slice(0, -5); + } else if (externalOpenapiFileName.endsWith('.yml')) { + externalOpenapiFileName = externalOpenapiFileName.slice(0, -4); + } + + + this.parsedRefsCache.set(ref, { + ref, + isLocal, + externalUrlOrPath, + externalOpenapiFileName, + }); + } + } + + return this.parsedRefsCache.get(ref)!; + } + + isLocalRef(ref: string): boolean { + return this.getRefDetails(ref).isLocal; + } + + getRef(ref: Maybe): Maybe { + if (!ref) { + return null; + } + + const resolvedByOrigRef = this.tryToResolveRef(ref); + + if (resolvedByOrigRef) { + return resolvedByOrigRef; + } + + // const ref.match(/\#[a-z]/) + if (/#[a-z]/.test(ref)) { + const fixedRef = ref.replace(/#[a-z]/, (match) => { + const [hashtag, char] = match.split(""); + return `${hashtag}/${char}`; + }); + + return this.tryToResolveRef(fixedRef); + } + + // this.tryToResolveRef(`@usage${ref}`) ?? + // this.tryToResolveRef(`@original${ref}`) + } + + private tryToResolveRef(ref: Maybe) { + if (!this.resolvers || !ref) { + return null; + } + + for (const resolver of this.resolvers) { + try { + const resolvedAsIs = resolver.get(ref); + return resolvedAsIs; + } catch (e) { + consola.debug(e); + } + } + + return null; + } +} diff --git a/src/schema-components-map.ts b/src/schema-components-map.ts index 57a82eb00..6f011733c 100644 --- a/src/schema-components-map.ts +++ b/src/schema-components-map.ts @@ -1,18 +1,13 @@ -import consola from "consola"; +import { typeGuard } from "yummies/type-guard"; +import type { AnyObject, Maybe } from "yummies/utils/types"; import type { SchemaComponent } from "../types/index.js"; -import type { CodeGenProcess } from "./code-gen-process.js"; import type { CodeGenConfig } from "./configuration.js"; +import { pascalCase } from "./util/pascal-case.js"; export class SchemaComponentsMap { _data: SchemaComponent[] = []; - config: CodeGenConfig; - constructor( - config: CodeGenConfig, - private codegenProcess: CodeGenProcess, - ) { - this.config = config; - } + constructor(public config: CodeGenConfig) {} clear() { this._data = []; @@ -26,31 +21,52 @@ export class SchemaComponentsMap { return ref.split("/"); }; - createComponent( + private createComponentDraft( $ref: string, - rawTypeData: SchemaComponent["rawTypeData"], + rawTypeData: Maybe | SchemaComponent, ): SchemaComponent { + if ( + typeGuard.isObject(rawTypeData) && + rawTypeData.typeName && + rawTypeData.rawTypeData && + rawTypeData.$ref + ) { + return rawTypeData as SchemaComponent; + } + const parsed = this.parseRef($ref); - const typeName = parsed[parsed.length - 1]!; + const typeName = parsed.at(-1)!; const componentName = parsed[ parsed.length - 2 ] as SchemaComponent["componentName"]; - const componentSchema: SchemaComponent = { + + return { $ref, typeName, - rawTypeData, + rawTypeData: rawTypeData as SchemaComponent["rawTypeData"], componentName, /** result from schema parser */ typeData: null, }; + } + createComponent( + $ref: string, + rawTypeData: SchemaComponent["rawTypeData"] | SchemaComponent, + addAtStart?: boolean, + ): SchemaComponent { + const componentSchema = this.createComponentDraft($ref, rawTypeData); const usageComponent = this.config.hooks.onCreateComponent(componentSchema) || componentSchema; const refIndex = this._data.findIndex((c) => c.$ref === $ref); if (refIndex === -1) { - this._data.push(usageComponent); + if (addAtStart) { + this._data.unshift(usageComponent); + } else { + this._data.push(usageComponent); + } } else { this._data[refIndex] = usageComponent; } @@ -77,14 +93,46 @@ export class SchemaComponentsMap { return localFound; } - if (this.codegenProcess.swaggerRefs) { - try { - return this.codegenProcess.swaggerRefs.get($ref) as Record; - } catch (e) { - consola.error(e); - return null; + const { resolvedSwaggerSchema } = this.config; + + if (resolvedSwaggerSchema.isLocalRef($ref)) { + return null; + } + + const foundByRef = resolvedSwaggerSchema.getRef($ref); + const refDetails = resolvedSwaggerSchema.getRefDetails($ref); + + if (foundByRef != null) { + const componentDraft = this.createComponentDraft( + $ref, + foundByRef as AnyObject, + ); + + componentDraft.typeName = + this.config.hooks.onFormatExternalTypeName?.( + componentDraft.typeName, + refDetails, + ) || componentDraft.typeName; + + if ( + // duplicate name + this._data.some( + (component) => component.typeName === componentDraft.typeName, + ) + ) { + componentDraft.typeName = + this.config.hooks.onFixDuplicateExternalTypeName?.( + componentDraft.typeName, + refDetails, + this._data.map((it) => it.typeName), + ) ?? + `${pascalCase(refDetails.externalOpenapiFileName || "External")}${componentDraft.typeName}`; } + + return this.createComponent($ref, componentDraft); } + + return null; }; // Ensure enums are at the top of components list diff --git a/src/schema-parser/schema-parser-fabric.ts b/src/schema-parser/schema-parser-fabric.ts index 1e1ad4efd..6aabe06a0 100644 --- a/src/schema-parser/schema-parser-fabric.ts +++ b/src/schema-parser/schema-parser-fabric.ts @@ -7,7 +7,6 @@ import type { } from "../../types/index.js"; import type { CodeGenConfig } from "../configuration.js"; import type { SchemaComponentsMap } from "../schema-components-map.js"; -import type { SchemaWalker } from "../schema-walker.js"; import type { TemplatesWorker } from "../templates-worker.js"; import type { TypeNameFormatter } from "../type-name-formatter.js"; import { SchemaFormatters } from "./schema-formatters.js"; @@ -21,20 +20,17 @@ export class SchemaParserFabric { schemaFormatters: SchemaFormatters; templatesWorker: TemplatesWorker; schemaUtils: SchemaUtils; - schemaWalker: SchemaWalker; constructor( config: CodeGenConfig, templatesWorker: TemplatesWorker, schemaComponentsMap: SchemaComponentsMap, typeNameFormatter: TypeNameFormatter, - schemaWalker: SchemaWalker, ) { this.config = config; this.schemaComponentsMap = schemaComponentsMap; this.typeNameFormatter = typeNameFormatter; this.templatesWorker = templatesWorker; - this.schemaWalker = schemaWalker; this.schemaUtils = new SchemaUtils(this); this.schemaFormatters = new SchemaFormatters(this); } diff --git a/src/schema-parser/schema-utils.ts b/src/schema-parser/schema-utils.ts index bf7614e33..027a37de1 100644 --- a/src/schema-parser/schema-utils.ts +++ b/src/schema-parser/schema-utils.ts @@ -11,18 +11,15 @@ export class SchemaUtils { config: CodeGenConfig; schemaComponentsMap: SchemaComponentsMap; typeNameFormatter: TypeNameFormatter; - schemaWalker: SchemaWalker; constructor({ config, schemaComponentsMap, typeNameFormatter, - schemaWalker, }) { this.config = config; this.schemaComponentsMap = schemaComponentsMap; this.typeNameFormatter = typeNameFormatter; - this.schemaWalker = schemaWalker; } getRequiredProperties = (schema) => { diff --git a/src/schema-routes/schema-routes.ts b/src/schema-routes/schema-routes.ts index 67d855e2c..50d40d373 100644 --- a/src/schema-routes/schema-routes.ts +++ b/src/schema-routes/schema-routes.ts @@ -1,8 +1,14 @@ import { consola } from "consola"; import lodash from "lodash"; +import { typeGuard } from "yummies/type-guard"; +import type { AnyObject } from "yummies/utils/types"; import type { GenerateApiConfiguration, ParsedRoute, + ParsedSchema, + SchemaTypeEnumContent, + SchemaTypeObjectContent, + SchemaTypePrimitiveContent, } from "../../types/index.js"; import type { CodeGenProcess } from "../code-gen-process.js"; import type { CodeGenConfig } from "../configuration.js"; @@ -13,6 +19,7 @@ import { RESERVED_PATH_ARG_NAMES, RESERVED_QUERY_ARG_NAMES, } from "../constants.js"; +import type { ResolvedSwaggerSchema } from "../resolved-swagger-schema.js"; import type { SchemaComponentsMap } from "../schema-components-map.js"; import type { SchemaParserFabric } from "../schema-parser/schema-parser-fabric.js"; import type { SchemaUtils } from "../schema-parser/schema-utils.js"; @@ -32,14 +39,7 @@ const CONTENT_KIND = { }; export class SchemaRoutes { - config: CodeGenConfig; - codegenProcess: CodeGenProcess; - schemaParserFabric: SchemaParserFabric; schemaUtils: SchemaUtils; - typeNameFormatter: TypeNameFormatter; - schemaComponentsMap: SchemaComponentsMap; - templatesWorker: TemplatesWorker; - FORM_DATA_TYPES: string[] = []; routes: ParsedRoute[] = []; @@ -48,20 +48,13 @@ export class SchemaRoutes { hasFormDataRoutes = false; constructor( - config: CodeGenConfig, - codegenProcess: CodeGenProcess, - schemaParserFabric: SchemaParserFabric, - schemaComponentsMap: SchemaComponentsMap, - templatesWorker: TemplatesWorker, - typeNameFormatter: TypeNameFormatter, + public config: CodeGenConfig, + public schemaParserFabric: SchemaParserFabric, + public schemaComponentsMap: SchemaComponentsMap, + public templatesWorker: TemplatesWorker, + public typeNameFormatter: TypeNameFormatter, ) { - this.config = config; - this.codegenProcess = codegenProcess; - this.schemaParserFabric = schemaParserFabric; this.schemaUtils = this.schemaParserFabric.schemaUtils; - this.typeNameFormatter = typeNameFormatter; - this.schemaComponentsMap = schemaComponentsMap; - this.templatesWorker = templatesWorker; this.FORM_DATA_TYPES = lodash.uniq([ this.schemaUtils.getSchemaType({ type: "string", format: "file" }), @@ -69,34 +62,34 @@ export class SchemaRoutes { ]); } - createRequestsMap = (routeInfoByMethodsMap) => { + createRequestsMap = ( + resolvedSwaggerSchema: ResolvedSwaggerSchema, + routeInfoByMethodsMap: AnyObject, + ) => { const parameters = lodash.get(routeInfoByMethodsMap, "parameters"); return lodash.reduce( routeInfoByMethodsMap, - (acc, requestInfo, method) => { - if (method.startsWith("x-") || ["parameters"].includes(method)) { + (acc, anything, property) => { + if (property.startsWith("x-") || ["parameters"].includes(property)) { return acc; } - if (method === "$ref") { - if (this.codegenProcess.swaggerRefs) { - try { - const resolved = this.codegenProcess.swaggerRefs.get(requestInfo); - Object.assign(acc, this.createRequestsMap(resolved)); - return acc; - } catch (e) { - consola.error(e); - return acc; - } + if (property === "$ref") { + const refData = resolvedSwaggerSchema.getRef(anything); + if (typeGuard.isObject(refData)) { + Object.assign( + acc, + this.createRequestsMap(resolvedSwaggerSchema, refData), + ); } return acc; } - acc[method] = { - ...requestInfo, + acc[property] = { + ...anything, parameters: lodash.compact( - lodash.concat(parameters, requestInfo.parameters), + lodash.concat(parameters, anything.parameters), ), }; @@ -106,7 +99,7 @@ export class SchemaRoutes { ); }; - parseRouteName = (originalRouteName) => { + parseRouteName = (originalRouteName: string) => { const routeName = this.config.hooks.onPreBuildRoutePath(originalRouteName) || originalRouteName; @@ -834,7 +827,7 @@ export class SchemaRoutes { ); const routeName = - this.config.hooks.onFormatRouteName( + this.config.hooks.onFormatRouteName?.( rawRouteInfo, routeNameFromTemplate, ) || routeNameFromTemplate; @@ -866,7 +859,7 @@ export class SchemaRoutes { }; return ( - this.config.hooks.onCreateRouteName(routeNameInfo, rawRouteInfo) || + this.config.hooks.onCreateRouteName?.(routeNameInfo, rawRouteInfo) || routeNameInfo ); }; @@ -1113,20 +1106,32 @@ export class SchemaRoutes { }; }; - attachSchema = ({ usageSchema, parsedSchemas }) => { + attachSchema = ( + resolvedSwaggerSchema: ResolvedSwaggerSchema, + parsedSchemas: ParsedSchema< + | SchemaTypeObjectContent + | SchemaTypeEnumContent + | SchemaTypePrimitiveContent + >[], + ) => { this.config.routeNameDuplicatesMap.clear(); - const pathsEntries = lodash.entries(usageSchema.paths); + const pathsEntries = lodash.entries( + resolvedSwaggerSchema.usageSchema.paths, + ); for (const [rawRouteName, routeInfoByMethodsMap] of pathsEntries) { - const routeInfosMap = this.createRequestsMap(routeInfoByMethodsMap); + const routeInfosMap = this.createRequestsMap( + resolvedSwaggerSchema, + routeInfoByMethodsMap, + ); for (const [method, routeInfo] of Object.entries(routeInfosMap)) { const parsedRouteInfo = this.parseRouteInfo( rawRouteName, routeInfo, method, - usageSchema, + resolvedSwaggerSchema.usageSchema, parsedSchemas, ); const processedRouteInfo = diff --git a/src/schema-walker.ts b/src/schema-walker.ts deleted file mode 100644 index 9c8248415..000000000 --- a/src/schema-walker.ts +++ /dev/null @@ -1,42 +0,0 @@ -import lodash from "lodash"; -import type { OpenAPI } from "openapi-types"; -import type { CodeGenConfig } from "./configuration.js"; -import type { SwaggerSchemaResolver } from "./swagger-schema-resolver.js"; - -// TODO: WIP -// this class will be needed to walk by schema everywhere -export class SchemaWalker { - config: CodeGenConfig; - swaggerSchemaResolver: SwaggerSchemaResolver; - schemas = new Map(); - caches = new Map(); - - constructor( - config: CodeGenConfig, - swaggerSchemaResolver: SwaggerSchemaResolver, - ) { - this.config = config; - this.swaggerSchemaResolver = swaggerSchemaResolver; - } - - addSchema = (name: string, schema: OpenAPI.Document) => { - this.schemas.set(name, structuredClone(schema)); - }; - - _isLocalRef = (ref: string) => { - return ref.startsWith("#"); - }; - - _isRemoteRef = (ref: string) => { - return ref.startsWith("http://") || ref.startsWith("https://"); - }; - - _getRefDataFromSchema = (schema: Record, ref: string) => { - const path = ref.replace("#", "").split("/"); - const refData = lodash.get(schema, path); - if (refData) { - this.caches.set(ref, refData); - } - return refData; - }; -} diff --git a/src/swagger-schema-resolver.ts b/src/swagger-schema-resolver.ts index 20a1efe17..6982020ed 100644 --- a/src/swagger-schema-resolver.ts +++ b/src/swagger-schema-resolver.ts @@ -1,12 +1,19 @@ +import SwaggerParser, { type resolve } from "@apidevtools/swagger-parser"; import { consola } from "consola"; import lodash from "lodash"; -import type { OpenAPI, OpenAPIV2 } from "openapi-types"; +import type { OpenAPI, OpenAPIV2, OpenAPIV3 } from "openapi-types"; import * as swagger2openapi from "swagger2openapi"; import * as YAML from "yaml"; import type { CodeGenConfig } from "./configuration.js"; +import { ResolvedSwaggerSchema } from "./resolved-swagger-schema.js"; import type { FileSystem } from "./util/file-system.js"; import { Request } from "./util/request.js"; +interface SwaggerSchemas { + usageSchema: OpenAPI.Document; + originalSchema: OpenAPI.Document; +} + export class SwaggerSchemaResolver { config: CodeGenConfig; fileSystem: FileSystem; @@ -18,30 +25,146 @@ export class SwaggerSchemaResolver { this.request = new Request(config); } - async create() { + async create(): Promise { const { spec, patch, input, url, authorizationToken } = this.config; + let swaggerSchemas: SwaggerSchemas; if (spec) { - return await this.convertSwaggerObject(spec, { patch }); + swaggerSchemas = await this.convertSwaggerObject(spec, { patch }); + } else { + const swaggerSchemaFile = await this.fetchSwaggerSchemaFile( + input, + url, + authorizationToken, + ); + const swaggerSchemaObject = + this.processSwaggerSchemaFile(swaggerSchemaFile); + + swaggerSchemas = await this.convertSwaggerObject(swaggerSchemaObject, { + patch, + }); } - const swaggerSchemaFile = await this.fetchSwaggerSchemaFile( - input, - url, - authorizationToken, + this.fixSwaggerSchemas(swaggerSchemas); + + const resolvers: Awaited>[] = []; + + try { + resolvers.push( + await SwaggerParser.resolve( + swaggerSchemas.originalSchema, + // this.config.url || this.config.input || (this.config.spec as any), + { + continueOnError: true, + mutateInputSchema: true, + dereference: {}, + validate: { + schema: false, + spec: false, + }, + resolve: { + external: true, + http: { + ...this.config.requestOptions, + headers: Object.assign( + {}, + this.config.authorizationToken + ? { + Authorization: this.config.authorizationToken, + } + : {}, + this.config.requestOptions?.headers ?? {}, + ), + }, + }, + }, + ), + ); + } catch (e) { + consola.debug(e); + } + try { + resolvers.push( + await SwaggerParser.resolve( + swaggerSchemas.usageSchema, + // this.config.url || this.config.input || (this.config.spec as any), + { + continueOnError: true, + mutateInputSchema: true, + dereference: {}, + validate: { + schema: false, + spec: false, + }, + resolve: { + external: true, + http: { + ...this.config.requestOptions, + headers: Object.assign( + {}, + this.config.authorizationToken + ? { + Authorization: this.config.authorizationToken, + } + : {}, + this.config.requestOptions?.headers ?? {}, + ), + }, + }, + }, + ), + ); + } catch (e) { + consola.debug(e); + } + try { + resolvers.push( + await SwaggerParser.resolve( + this.config.url || this.config.input || (this.config.spec as any), + { + continueOnError: true, + mutateInputSchema: true, + dereference: {}, + validate: { + schema: false, + spec: false, + }, + resolve: { + external: true, + http: { + ...this.config.requestOptions, + headers: Object.assign( + {}, + this.config.authorizationToken + ? { + Authorization: this.config.authorizationToken, + } + : {}, + this.config.requestOptions?.headers ?? {}, + ), + }, + }, + }, + ), + ); + } catch (e) { + consola.debug(e); + } + + const resolvedSwaggerSchema = new ResolvedSwaggerSchema( + this.config, + swaggerSchemas.usageSchema, + swaggerSchemas.originalSchema, + resolvers, ); - const swaggerSchemaObject = - this.processSwaggerSchemaFile(swaggerSchemaFile); - return await this.convertSwaggerObject(swaggerSchemaObject, { patch }); + + return resolvedSwaggerSchema; } convertSwaggerObject( swaggerSchema: OpenAPI.Document, converterOptions: { patch?: boolean }, - ): Promise<{ - usageSchema: OpenAPI.Document; - originalSchema: OpenAPI.Document; - }> { + ): Promise { return new Promise((resolve) => { const result = structuredClone(swaggerSchema); result.info = lodash.merge( @@ -119,7 +242,7 @@ export class SwaggerSchemaResolver { } } - fixSwaggerSchema({ usageSchema, originalSchema }) { + private fixSwaggerSchemas({ usageSchema, originalSchema }: SwaggerSchemas) { const usagePaths = lodash.get(usageSchema, "paths"); const originalPaths = lodash.get(originalSchema, "paths"); @@ -130,23 +253,30 @@ export class SwaggerSchemaResolver { // walk by methods lodash.each(usagePathObject, (usageRouteInfo, methodName) => { const originalRouteInfo = lodash.get(originalPathObject, methodName); - const usageRouteParams = lodash.get(usageRouteInfo, "parameters", []); + const usageRouteParams = lodash.get( + usageRouteInfo, + "parameters", + [], + ) as OpenAPIV3.ParameterObject[]; const originalRouteParams = lodash.get( originalRouteInfo, "parameters", [], ); + const usageAsOpenapiv2 = + usageRouteInfo as unknown as OpenAPIV2.Document; + if (typeof usageRouteInfo === "object") { - usageRouteInfo.consumes = lodash.uniq( + usageAsOpenapiv2.consumes = lodash.uniq( lodash.compact([ - ...(usageRouteInfo.consumes || []), + ...(usageAsOpenapiv2.consumes || []), ...(originalRouteInfo.consumes || []), ]), ); - usageRouteInfo.produces = lodash.uniq( + usageAsOpenapiv2.produces = lodash.uniq( lodash.compact([ - ...(usageRouteInfo.produces || []), + ...(usageAsOpenapiv2.produces || []), ...(originalRouteInfo.produces || []), ]), ); @@ -154,7 +284,7 @@ export class SwaggerSchemaResolver { lodash.each(originalRouteParams, (originalRouteParam) => { const existUsageParam = usageRouteParams.find( - (param) => + (param: OpenAPIV3.ParameterObject) => originalRouteParam.in === param.in && originalRouteParam.name === param.name, ); diff --git a/src/type-name-formatter.ts b/src/type-name-formatter.ts index 58743a863..9dd5aefac 100644 --- a/src/type-name-formatter.ts +++ b/src/type-name-formatter.ts @@ -46,7 +46,7 @@ export class TypeNameFormatter { .startCase(`${typePrefix}_${fixedModelName}_${typeSuffix}`) .replace(/\s/g, ""); const formattedResultName = - this.config.hooks.onFormatTypeName(formattedName, name, schemaType) || + this.config.hooks.onFormatTypeName?.(formattedName, name, schemaType) || formattedName; this.formattedModelNamesMap.set(hashKey, formattedResultName); diff --git a/tests/spec/paths/__snapshots__/basic.test.ts.snap b/tests/spec/paths/__snapshots__/basic.test.ts.snap index 7b4def8ef..560376df6 100644 --- a/tests/spec/paths/__snapshots__/basic.test.ts.snap +++ b/tests/spec/paths/__snapshots__/basic.test.ts.snap @@ -13,6 +13,8 @@ exports[`basic > paths(simplest-1-path) 1`] = ` * --------------------------------------------------------------- */ +export type ReportSchema = ReproReportSchema; + /** Weather */ export interface Weather { /** @@ -40,6 +42,16 @@ export interface Weather { export type HelloListData = object; +/** ReportSchema */ +export interface ReproReportSchema { + /** + * Weather condition id + * @format int32 + * @example 803 + */ + id?: number; +} + export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; diff --git a/tests/spec/paths/paths/repro.yaml b/tests/spec/paths/paths/repro.yaml index 27b8d933a..607c1bb72 100644 --- a/tests/spec/paths/paths/repro.yaml +++ b/tests/spec/paths/paths/repro.yaml @@ -5,4 +5,15 @@ hello: content: application/json: schema: - type: object \ No newline at end of file + type: object +components: + schemas: + ReportSchema: + title: ReportSchema + type: object + properties: + id: + type: integer + description: Weather condition id + format: int32 + example: 803 \ No newline at end of file diff --git a/tests/spec/paths/schema.yaml b/tests/spec/paths/schema.yaml index b4b1fee32..d86dc54e5 100644 --- a/tests/spec/paths/schema.yaml +++ b/tests/spec/paths/schema.yaml @@ -9,6 +9,8 @@ paths: components: schemas: + ReportSchema: + $ref: './paths/repro.yaml#components/schemas/ReportSchema' Weather: title: Weather type: object diff --git a/types/index.ts b/types/index.ts index 48f5632f0..323df34d5 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,5 +1,6 @@ import type { ComponentTypeNameResolver } from "../src/component-type-name-resolver.js"; import type * as CONSTANTS from "../src/constants.js"; +import type { RefDetails } from "../src/resolved-swagger-schema.js"; import type { MonoSchemaParser } from "../src/schema-parser/mono-schema-parser.js"; import type { Translator } from "../src/translators/translator.js"; @@ -176,6 +177,15 @@ export interface Hooks { routeInfo: RawRouteInfo, templateRouteName: string, ) => string | undefined; + onFormatExternalTypeName?: ( + typeName: string, + refInfo: RefDetails, + ) => string | undefined; + onFixDuplicateExternalTypeName?: ( + typeName: string, + refInfo: RefDetails, + existedTypeNames: string[], + ) => string | undefined; } export type RouteNameRouteInfo = Record; diff --git a/vitest.config.ts b/vitest.config.ts index 797541199..5d11c8f83 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - testTimeout: 10000, + testTimeout: 100_000, typecheck: { enabled: true, }, diff --git a/yarn.lock b/yarn.lock index 4e1255cbd..41bcd94e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1290,6 +1290,13 @@ __metadata: languageName: node linkType: hard +"@types/trusted-types@npm:^2.0.7": + version: 2.0.7 + resolution: "@types/trusted-types@npm:2.0.7" + checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c + languageName: node + linkType: hard + "@types/unist@npm:*": version: 3.0.3 resolution: "@types/unist@npm:3.0.3" @@ -1689,6 +1696,15 @@ __metadata: languageName: node linkType: hard +"class-variance-authority@npm:^0.7.1": + version: 0.7.1 + resolution: "class-variance-authority@npm:0.7.1" + dependencies: + clsx: "npm:^2.1.1" + checksum: 10c0/0f438cea22131808b99272de0fa933c2532d5659773bfec0c583de7b3f038378996d3350683426b8e9c74a6286699382106d71fbec52f0dd5fbb191792cccb5b + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -1700,6 +1716,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -1757,6 +1780,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.13": + version: 1.11.18 + resolution: "dayjs@npm:1.11.18" + checksum: 10c0/83b67f5d977e2634edf4f5abdd91d9041a696943143638063016915d2cd8c7e57e0751e40379a07ebca8be7a48dd380bef8752d22a63670f2d15970e34f96d7a + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.3.4, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -1820,6 +1850,18 @@ __metadata: languageName: node linkType: hard +"dompurify@npm:^3.2.6": + version: 3.2.7 + resolution: "dompurify@npm:3.2.7" + dependencies: + "@types/trusted-types": "npm:^2.0.7" + dependenciesMeta: + "@types/trusted-types": + optional: true + checksum: 10c0/d41bb31a72f1acdf9b84c56723c549924b05d92a39a15bd8c40bec9007ff80d5fccf844bc53ee12af5b69044f9a7ce24a1e71c267a4f49cf38711379ed8c1363 + languageName: node + linkType: hard + "dotenv@npm:^17.2.2": version: 17.2.2 resolution: "dotenv@npm:17.2.2" @@ -3841,6 +3883,7 @@ __metadata: typescript: "npm:~5.9.2" vitest: "npm:3.2.4" yaml: "npm:^2.8.1" + yummies: "npm:5.7.0" bin: sta: ./dist/cli.js swagger-typescript-api: ./dist/cli.js @@ -3870,6 +3913,13 @@ __metadata: languageName: node linkType: hard +"tailwind-merge@npm:^3.3.1": + version: 3.3.1 + resolution: "tailwind-merge@npm:3.3.1" + checksum: 10c0/b84c6a78d4669fa12bf5ab8f0cdc4400a3ce0a7c006511af4af4be70bb664a27466dbe13ee9e3b31f50ddf6c51d380e8192ce0ec9effce23ca729d71a9f63818 + languageName: node + linkType: hard + "tar@npm:^7.4.3": version: 7.5.1 resolution: "tar@npm:7.5.1" @@ -4360,3 +4410,25 @@ __metadata: checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 languageName: node linkType: hard + +"yummies@npm:5.7.0": + version: 5.7.0 + resolution: "yummies@npm:5.7.0" + dependencies: + class-variance-authority: "npm:^0.7.1" + clsx: "npm:^2.1.1" + dayjs: "npm:^1.11.13" + dompurify: "npm:^3.2.6" + nanoid: "npm:^5.1.5" + tailwind-merge: "npm:^3.3.1" + peerDependencies: + mobx: ^6.12.4 + react: ^18 || ^19 + peerDependenciesMeta: + mobx: + optional: true + react: + optional: true + checksum: 10c0/e7b107de6c7a27b68cf89f8583b79922cefde38065110a5c6031e4c8963be91ce4479ecfc7c11089f9e173f14717d0e95bf4d8daa4d3229a96fa55c39c25bb12 + languageName: node + linkType: hard From 016589df82a4abebb1a7577f6bf12d2cf815074e Mon Sep 17 00:00:00 2001 From: Sergey Volkov Date: Mon, 29 Sep 2025 00:52:49 +0300 Subject: [PATCH 3/5] chore: fix build after new changes --- src/schema-parser/schema-parser-fabric.ts | 6 +++++- src/schema-parser/schema-parser.ts | 3 --- src/schema-parser/schema-utils.ts | 19 +++++-------------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/schema-parser/schema-parser-fabric.ts b/src/schema-parser/schema-parser-fabric.ts index 6aabe06a0..68377a992 100644 --- a/src/schema-parser/schema-parser-fabric.ts +++ b/src/schema-parser/schema-parser-fabric.ts @@ -31,7 +31,11 @@ export class SchemaParserFabric { this.schemaComponentsMap = schemaComponentsMap; this.typeNameFormatter = typeNameFormatter; this.templatesWorker = templatesWorker; - this.schemaUtils = new SchemaUtils(this); + this.schemaUtils = new SchemaUtils( + this.config, + this.schemaComponentsMap, + this.typeNameFormatter, + ); this.schemaFormatters = new SchemaFormatters(this); } diff --git a/src/schema-parser/schema-parser.ts b/src/schema-parser/schema-parser.ts index fc8e45878..a6f89acfe 100644 --- a/src/schema-parser/schema-parser.ts +++ b/src/schema-parser/schema-parser.ts @@ -3,7 +3,6 @@ import lodash from "lodash"; import type { CodeGenConfig } from "../configuration.js"; import { SCHEMA_TYPES } from "../constants.js"; import type { SchemaComponentsMap } from "../schema-components-map.js"; -import type { SchemaWalker } from "../schema-walker.js"; import type { TemplatesWorker } from "../templates-worker.js"; import type { TypeNameFormatter } from "../type-name-formatter.js"; import { sortByProperty } from "../util/sort-by-property.js"; @@ -29,7 +28,6 @@ export class SchemaParser { schemaFormatters: SchemaFormatters; schemaUtils: SchemaUtils; templatesWorker: TemplatesWorker; - schemaWalker: SchemaWalker; typeName; schema; @@ -42,7 +40,6 @@ export class SchemaParser { this.templatesWorker = schemaParserFabric.templatesWorker; this.schemaComponentsMap = schemaParserFabric.schemaComponentsMap; this.typeNameFormatter = schemaParserFabric.typeNameFormatter; - this.schemaWalker = schemaParserFabric.schemaWalker; this.schemaFormatters = schemaParserFabric.schemaFormatters; this.schemaUtils = schemaParserFabric.schemaUtils; diff --git a/src/schema-parser/schema-utils.ts b/src/schema-parser/schema-utils.ts index 027a37de1..f663b4645 100644 --- a/src/schema-parser/schema-utils.ts +++ b/src/schema-parser/schema-utils.ts @@ -2,25 +2,16 @@ import lodash from "lodash"; import type { CodeGenConfig } from "../configuration.js"; import { SCHEMA_TYPES } from "../constants.js"; import type { SchemaComponentsMap } from "../schema-components-map.js"; -import type { SchemaWalker } from "../schema-walker.js"; import type { TypeNameFormatter } from "../type-name-formatter.js"; import { internalCase } from "../util/internal-case.js"; import { pascalCase } from "../util/pascal-case.js"; export class SchemaUtils { - config: CodeGenConfig; - schemaComponentsMap: SchemaComponentsMap; - typeNameFormatter: TypeNameFormatter; - - constructor({ - config, - schemaComponentsMap, - typeNameFormatter, - }) { - this.config = config; - this.schemaComponentsMap = schemaComponentsMap; - this.typeNameFormatter = typeNameFormatter; - } + constructor( + public config: CodeGenConfig, + public schemaComponentsMap: SchemaComponentsMap, + public typeNameFormatter: TypeNameFormatter, + ) {} getRequiredProperties = (schema) => { return lodash.uniq( From 780c0c8a2bad8f2d5becb7b48655d6837207a072 Mon Sep 17 00:00:00 2001 From: Sergey Volkov Date: Mon, 29 Sep 2025 00:57:28 +0300 Subject: [PATCH 4/5] chore: fix build after new changes --- .vscode/settings.json | 2 +- src/resolved-swagger-schema.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8538dc8ea..3ecf57cc7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,4 +16,4 @@ "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" } -} \ No newline at end of file +} diff --git a/src/resolved-swagger-schema.ts b/src/resolved-swagger-schema.ts index 56d5ae108..edf0dc220 100644 --- a/src/resolved-swagger-schema.ts +++ b/src/resolved-swagger-schema.ts @@ -35,22 +35,24 @@ export class ResolvedSwaggerSchema { externalUrlOrPath: null, }); } else { - const externalUrlOrPath = ref.split("#")[0]! - let externalOpenapiFileName = (externalUrlOrPath.split('/').at(-1) || '') + const externalUrlOrPath = ref.split("#")[0]!; + let externalOpenapiFileName = externalUrlOrPath.split("/").at(-1) || ""; - if (externalOpenapiFileName.endsWith('.json') || externalOpenapiFileName.endsWith('.yaml')) { + if ( + externalOpenapiFileName.endsWith(".json") || + externalOpenapiFileName.endsWith(".yaml") + ) { externalOpenapiFileName = externalOpenapiFileName.slice(0, -5); - } else if (externalOpenapiFileName.endsWith('.yml')) { + } else if (externalOpenapiFileName.endsWith(".yml")) { externalOpenapiFileName = externalOpenapiFileName.slice(0, -4); } - this.parsedRefsCache.set(ref, { ref, isLocal, externalUrlOrPath, externalOpenapiFileName, - }); + }); } } From 90e0267c43a41964db37c37853deadead245c65d Mon Sep 17 00:00:00 2001 From: Sergey Volkov Date: Mon, 6 Oct 2025 00:06:05 +0300 Subject: [PATCH 5/5] feat: multiple swagger schemas using $ref in paths --- src/resolved-swagger-schema.ts | 70 +++++++++++- src/swagger-schema-resolver.ts | 108 +----------------- .../paths/__snapshots__/basic.test.ts.snap | 24 ++++ tests/spec/paths/paths/repro.yaml | 23 ++++ tests/spec/paths/paths/third.yaml | 23 ++++ 5 files changed, 140 insertions(+), 108 deletions(-) create mode 100644 tests/spec/paths/paths/third.yaml diff --git a/src/resolved-swagger-schema.ts b/src/resolved-swagger-schema.ts index edf0dc220..a56bbf100 100644 --- a/src/resolved-swagger-schema.ts +++ b/src/resolved-swagger-schema.ts @@ -1,4 +1,5 @@ import type { resolve } from "@apidevtools/swagger-parser"; +import SwaggerParser from "@apidevtools/swagger-parser"; import consola from "consola"; import type { OpenAPI } from "openapi-types"; import type { AnyObject, Maybe, Primitive } from "yummies/utils/types"; @@ -14,7 +15,7 @@ export interface RefDetails { export class ResolvedSwaggerSchema { private parsedRefsCache = new Map(); - constructor( + private constructor( private config: CodeGenConfig, public usageSchema: OpenAPI.Document, public originalSchema: OpenAPI.Document, @@ -104,4 +105,71 @@ export class ResolvedSwaggerSchema { return null; } + + static async create( + config: CodeGenConfig, + usageSchema: OpenAPI.Document, + originalSchema: OpenAPI.Document, + ) { + const resolvers: Awaited>[] = []; + + const options: SwaggerParser.Options = { + continueOnError: true, + mutateInputSchema: true, + dereference: {}, + validate: { + schema: false, + spec: false, + }, + resolve: { + external: true, + http: { + ...config.requestOptions, + headers: Object.assign( + {}, + config.authorizationToken + ? { + Authorization: config.authorizationToken, + } + : {}, + config.requestOptions?.headers ?? {}, + ), + }, + }, + }; + + try { + resolvers.push( + await SwaggerParser.resolve( + originalSchema, + // this.config.url || this.config.input || (this.config.spec as any), + options, + ), + ); + } catch (e) { + consola.debug(e); + } + try { + resolvers.push(await SwaggerParser.resolve(usageSchema, options)); + } catch (e) { + consola.debug(e); + } + try { + resolvers.push( + await SwaggerParser.resolve( + config.url || config.input || (config.spec as any), + options, + ), + ); + } catch (e) { + consola.debug(e); + } + + return new ResolvedSwaggerSchema( + config, + usageSchema, + originalSchema, + resolvers, + ); + } } diff --git a/src/swagger-schema-resolver.ts b/src/swagger-schema-resolver.ts index 6982020ed..193115fd3 100644 --- a/src/swagger-schema-resolver.ts +++ b/src/swagger-schema-resolver.ts @@ -1,4 +1,3 @@ -import SwaggerParser, { type resolve } from "@apidevtools/swagger-parser"; import { consola } from "consola"; import lodash from "lodash"; import type { OpenAPI, OpenAPIV2, OpenAPIV3 } from "openapi-types"; @@ -47,115 +46,10 @@ export class SwaggerSchemaResolver { this.fixSwaggerSchemas(swaggerSchemas); - const resolvers: Awaited>[] = []; - - try { - resolvers.push( - await SwaggerParser.resolve( - swaggerSchemas.originalSchema, - // this.config.url || this.config.input || (this.config.spec as any), - { - continueOnError: true, - mutateInputSchema: true, - dereference: {}, - validate: { - schema: false, - spec: false, - }, - resolve: { - external: true, - http: { - ...this.config.requestOptions, - headers: Object.assign( - {}, - this.config.authorizationToken - ? { - Authorization: this.config.authorizationToken, - } - : {}, - this.config.requestOptions?.headers ?? {}, - ), - }, - }, - }, - ), - ); - } catch (e) { - consola.debug(e); - } - try { - resolvers.push( - await SwaggerParser.resolve( - swaggerSchemas.usageSchema, - // this.config.url || this.config.input || (this.config.spec as any), - { - continueOnError: true, - mutateInputSchema: true, - dereference: {}, - validate: { - schema: false, - spec: false, - }, - resolve: { - external: true, - http: { - ...this.config.requestOptions, - headers: Object.assign( - {}, - this.config.authorizationToken - ? { - Authorization: this.config.authorizationToken, - } - : {}, - this.config.requestOptions?.headers ?? {}, - ), - }, - }, - }, - ), - ); - } catch (e) { - consola.debug(e); - } - try { - resolvers.push( - await SwaggerParser.resolve( - this.config.url || this.config.input || (this.config.spec as any), - { - continueOnError: true, - mutateInputSchema: true, - dereference: {}, - validate: { - schema: false, - spec: false, - }, - resolve: { - external: true, - http: { - ...this.config.requestOptions, - headers: Object.assign( - {}, - this.config.authorizationToken - ? { - Authorization: this.config.authorizationToken, - } - : {}, - this.config.requestOptions?.headers ?? {}, - ), - }, - }, - }, - ), - ); - } catch (e) { - consola.debug(e); - } - - const resolvedSwaggerSchema = new ResolvedSwaggerSchema( + const resolvedSwaggerSchema = ResolvedSwaggerSchema.create( this.config, swaggerSchemas.usageSchema, swaggerSchemas.originalSchema, - resolvers, ); return resolvedSwaggerSchema; diff --git a/tests/spec/paths/__snapshots__/basic.test.ts.snap b/tests/spec/paths/__snapshots__/basic.test.ts.snap index 560376df6..cc7ab35e5 100644 --- a/tests/spec/paths/__snapshots__/basic.test.ts.snap +++ b/tests/spec/paths/__snapshots__/basic.test.ts.snap @@ -42,6 +42,13 @@ export interface Weather { export type HelloListData = object; +export interface GetRepositoryParams { + username: string; + slug: string; +} + +export type GetRepositoryData = any; + /** ReportSchema */ export interface ReproReportSchema { /** @@ -328,6 +335,23 @@ export class Api< format: "json", ...params, }), + + /** + * No description + * + * @name GetRepository + * @request PUT:/hello + */ + getRepository: ( + { username, slug, ...query }: GetRepositoryParams, + params: RequestParams = {}, + ) => + this.request({ + path: \`/hello\`, + method: "PUT", + format: "json", + ...params, + }), }; } " diff --git a/tests/spec/paths/paths/repro.yaml b/tests/spec/paths/paths/repro.yaml index 607c1bb72..42d3b6675 100644 --- a/tests/spec/paths/paths/repro.yaml +++ b/tests/spec/paths/paths/repro.yaml @@ -6,6 +6,29 @@ hello: application/json: schema: type: object + put: + operationId: getRepository + parameters: + - name: username + in: path + required: true + schema: + type: string + - name: slug + in: path + required: true + schema: + type: string + responses: + "200": + description: The repository + content: + application/json: + schema: + $ref: "./third.yaml/#/components/schemas/repository" + links: + repositoryPullRequests: + $ref: "./third.yaml/#/components/links/RepositoryPullRequests" components: schemas: ReportSchema: diff --git a/tests/spec/paths/paths/third.yaml b/tests/spec/paths/paths/third.yaml new file mode 100644 index 000000000..acc09ff7f --- /dev/null +++ b/tests/spec/paths/paths/third.yaml @@ -0,0 +1,23 @@ +components: + schemas: + user: + type: object + properties: + username: + type: string + uuid: + type: string + repository: + type: object + properties: + slug: + type: string + owner: + $ref: "#/components/schemas/user" + links: + RepositoryPullRequests: + # returns '#/components/schemas/pullrequest' + operationId: getPullRequestsByRepository + parameters: + username: $response.body#/owner/username + slug: $response.body#/slug \ No newline at end of file