Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.5",
"@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.3",
"yaml": "^2.8.1"
"yaml": "^2.8.1",
"yummies": "5.7.0"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
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
175 changes: 175 additions & 0 deletions src/resolved-swagger-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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";
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>();

private 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;
}

static async create(
config: CodeGenConfig,
usageSchema: OpenAPI.Document,
originalSchema: OpenAPI.Document,
) {
const resolvers: Awaited<ReturnType<typeof resolve>>[] = [];

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,
);
}
}
Loading