Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silent-beds-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"swagger-typescript-api": minor
---

partial support external paths by ref (#447)
19 changes: 19 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -60,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",
Expand Down
56 changes: 23 additions & 33 deletions src/code-gen-process.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { resolve } from "@apidevtools/swagger-parser";
import { consola } from "consola";
import lodash from "lodash";
import * as typescript from "typescript";
Expand All @@ -10,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";
Expand Down Expand Up @@ -44,8 +44,8 @@ export class CodeGenProcess {
fileSystem: FileSystem;
codeFormatter: CodeFormatter;
templatesWorker: TemplatesWorker;
schemaWalker: SchemaWalker;
javascriptTranslator: JavascriptTranslator;
swaggerRefs: Awaited<ReturnType<typeof resolve>> | undefined | null;

constructor(config: Partial<GenerateApiConfiguration["config"]>) {
this.config = new CodeGenConfig(config);
Expand All @@ -54,10 +54,6 @@ export class CodeGenProcess {
this.config,
this.fileSystem,
);
this.schemaWalker = new SchemaWalker(
this.config,
this.swaggerSchemaResolver,
);
this.schemaComponentsMap = new SchemaComponentsMap(this.config);
this.typeNameFormatter = new TypeNameFormatter(this.config);
this.templatesWorker = new TemplatesWorker(
Expand All @@ -71,7 +67,6 @@ export class CodeGenProcess {
this.templatesWorker,
this.schemaComponentsMap,
this.typeNameFormatter,
this.schemaWalker,
);
this.schemaRoutes = new SchemaRoutes(
this.config,
Expand All @@ -94,37 +89,35 @@ export class CodeGenProcess {
templatesToRender: this.templatesWorker.getTemplates(this.config),
});

const swagger = await this.swaggerSchemaResolver.create();

this.swaggerSchemaResolver.fixSwaggerSchema(swagger);
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
Expand All @@ -149,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,
Expand All @@ -173,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) {
Expand Down
12 changes: 10 additions & 2 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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";
Expand Down Expand Up @@ -110,6 +111,7 @@ export class CodeGenConfig {
) => {},
onFormatRouteName: (_routeInfo: unknown, _templateRouteName: unknown) => {},
};
resolvedSwaggerSchema!: ResolvedSwaggerSchema;
defaultResponseType;
singleHttpClient = false;
httpClientType = CONSTANTS.HTTP_CLIENT.FETCH;
Expand Down Expand Up @@ -167,7 +169,7 @@ export class CodeGenConfig {
spec: OpenAPI.Document | null = null;
fileName = "Api.ts";
authorizationToken: string | undefined;
requestOptions = null;
requestOptions: Record<string, any> | null = null;

jsPrimitiveTypes: string[] = [];
jsEmptyTypes: string[] = [];
Expand Down Expand Up @@ -439,7 +441,13 @@ export class CodeGenConfig {
this.componentTypeNameResolver = new ComponentTypeNameResolver(this, []);
}

update = (update: Partial<GenerateApiConfiguration["config"]>) => {
update = (
update: Partial<
GenerateApiConfiguration["config"] & {
resolvedSwaggerSchema: ResolvedSwaggerSchema;
}
>,
) => {
objectAssign(this, update);
if (this.enumNamesAsValues) {
this.extractEnums = true;
Expand Down
107 changes: 107 additions & 0 deletions src/resolved-swagger-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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<string>;
externalOpenapiFileName?: string;
}

export class ResolvedSwaggerSchema {
private parsedRefsCache = new Map<string, RefDetails>();

constructor(
private config: CodeGenConfig,
public usageSchema: OpenAPI.Document,
public originalSchema: OpenAPI.Document,
private resolvers: Awaited<ReturnType<typeof resolve>>[],
) {
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<string>): Maybe<AnyObject | Primitive> {
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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: JSON Pointer Normalization Fails for Valid Paths

The getRef method's logic for normalizing local references is flawed. The regex /#[a-z]/ is too restrictive, missing valid JSON pointer paths that don't start with a lowercase letter. When it does match, the replacement logic incorrectly truncates the path (e.g., #<char> becomes #/char), leading to invalid references and preventing proper schema resolution.

Fix in Cursor Fix in Web


// this.tryToResolveRef(`@usage${ref}`) ??
// this.tryToResolveRef(`@original${ref}`)
}

private tryToResolveRef(ref: Maybe<string>) {
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;
}
}
Loading