import SwaggerParser from "@apidevtools/swagger-parser";
import type { OpenAPIDocument, LoadResult, ErrorResponse } from "./types.js";
import type { OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
// Maximum schema size before truncation (50KB)
const MAX_SCHEMA_SIZE = 50 * 1024;
export class OpenAPIStore {
private specs: Map<string, OpenAPIDocument> = new Map();
/**
* Load an OpenAPI spec from a URL or file path
*/
async load(alias: string, source: string): Promise<LoadResult | ErrorResponse> {
try {
// Parse and dereference the spec (resolves all $refs)
const api = await SwaggerParser.dereference(source);
// Validate it's an OpenAPI 3.x document
if (!this.isOpenAPI3(api)) {
// Check if it's Swagger 2.0
if ("swagger" in api && (api as { swagger: string }).swagger === "2.0") {
return {
error: true,
code: "PARSE_ERROR",
message: "Swagger 2.0 detected. Please convert to OpenAPI 3.x format.",
};
}
return {
error: true,
code: "PARSE_ERROR",
message: "Invalid OpenAPI specification. Expected OpenAPI 3.x document.",
};
}
const doc = api as OpenAPIDocument;
// Store the spec
this.specs.set(alias, doc);
// Count endpoints
const endpointCount = this.countEndpoints(doc);
// Collect unique tags
const tags = this.collectTags(doc);
// Get servers if available
const servers = doc.servers?.map((s) => s.url) ?? [];
return {
success: true,
alias,
title: doc.info.title,
version: doc.info.version,
endpointCount,
tags,
...(servers.length > 0 && { servers }),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Determine error type
if (message.includes("ENOENT") || message.includes("not found")) {
return {
error: true,
code: "FETCH_ERROR",
message: `Failed to fetch spec: ${message}`,
};
}
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
return {
error: true,
code: "FETCH_ERROR",
message: `Failed to fetch spec from URL: ${message}`,
};
}
return {
error: true,
code: "PARSE_ERROR",
message: `Failed to parse OpenAPI spec: ${message}`,
};
}
}
/**
* Get a loaded spec by alias
*/
get(alias: string): OpenAPIDocument | undefined {
return this.specs.get(alias);
}
/**
* Check if a spec is loaded
*/
has(alias: string): boolean {
return this.specs.has(alias);
}
/**
* Remove a loaded spec
*/
remove(alias: string): boolean {
return this.specs.delete(alias);
}
/**
* List all loaded aliases
*/
list(): string[] {
return Array.from(this.specs.keys());
}
/**
* Clear all loaded specs (useful for testing)
*/
clear(): void {
this.specs.clear();
}
/**
* Check if API is OpenAPI 3.x
*/
private isOpenAPI3(api: unknown): boolean {
return (
typeof api === "object" &&
api !== null &&
"openapi" in api &&
typeof (api as { openapi: unknown }).openapi === "string" &&
(api as { openapi: string }).openapi.startsWith("3.")
);
}
/**
* Count all endpoints in the spec
*/
private countEndpoints(doc: OpenAPIDocument): number {
let count = 0;
const methods = ["get", "post", "put", "delete", "patch", "options", "head", "trace"];
if (doc.paths) {
for (const pathItem of Object.values(doc.paths)) {
if (!pathItem) continue;
for (const method of methods) {
if (method in pathItem) {
count++;
}
}
}
}
return count;
}
/**
* Collect all unique tags from the spec
*/
private collectTags(doc: OpenAPIDocument): string[] {
const tagSet = new Set<string>();
// Add tags from the top-level tags array
if (doc.tags) {
for (const tag of doc.tags) {
tagSet.add(tag.name);
}
}
// Also collect tags from operations
const methods = ["get", "post", "put", "delete", "patch", "options", "head", "trace"] as const;
if (doc.paths) {
for (const pathItem of Object.values(doc.paths)) {
if (!pathItem) continue;
for (const method of methods) {
const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
if (operation?.tags) {
for (const tag of operation.tags) {
tagSet.add(tag);
}
}
}
}
}
return Array.from(tagSet).sort();
}
}
/**
* Truncate schema if it exceeds the size limit
*/
export function truncateSchema(schema: Record<string, unknown>): Record<string, unknown> {
const json = JSON.stringify(schema);
if (json.length <= MAX_SCHEMA_SIZE) {
return schema;
}
return {
$truncated: true,
message: `Schema truncated (original size: ${json.length} bytes, limit: ${MAX_SCHEMA_SIZE} bytes)`,
type: schema.type ?? "unknown",
};
}
/**
* Handle circular references by marking them
*/
export function handleCircularRef(seen: WeakSet<object>, obj: unknown): unknown {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (seen.has(obj as object)) {
const title =
"title" in (obj as Record<string, unknown>)
? (obj as { title: string }).title
: "Unknown";
return { $circular: title };
}
seen.add(obj as object);
if (Array.isArray(obj)) {
return obj.map((item) => handleCircularRef(seen, item));
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = handleCircularRef(seen, value);
}
return result;
}
// Singleton instance
export const store = new OpenAPIStore();