/**
* OpenAPI specification loader with caching and operation indexing.
*
* Fetches OpenAPI specs from hosted URLs, caches them locally,
* and provides search/lookup functionality for operations.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getCacheDir, getSpecUrl } from "./config.js";
export type ApiType = "v1" | "internal";
export type EnvType = "prod" | "staging";
export interface OpenApiSpec {
openapi: string;
info: {
title: string;
version: string;
description?: string;
};
paths: Record<string, PathItem>;
components?: {
schemas?: Record<string, SchemaObject>;
securitySchemes?: Record<string, unknown>;
};
tags?: Array<{ name: string; description?: string }>;
}
export interface PathItem {
get?: Operation;
post?: Operation;
put?: Operation;
patch?: Operation;
delete?: Operation;
parameters?: Parameter[];
}
export interface Operation {
operationId?: string;
summary?: string;
description?: string;
tags?: string[];
parameters?: Parameter[];
requestBody?: RequestBody;
responses?: Record<string, Response>;
security?: Array<Record<string, string[]>>;
}
export interface Parameter {
name: string;
in: "path" | "query" | "header" | "cookie";
required?: boolean;
schema?: SchemaObject;
description?: string;
}
export interface RequestBody {
required?: boolean;
description?: string;
content?: Record<string, { schema?: SchemaObject }>;
}
export interface Response {
description: string;
content?: Record<string, { schema?: SchemaObject }>;
}
export interface SchemaObject {
type?: string;
format?: string;
properties?: Record<string, SchemaObject>;
items?: SchemaObject;
required?: string[];
enum?: unknown[];
description?: string;
nullable?: boolean;
additionalProperties?: boolean | SchemaObject;
$ref?: string;
}
export interface OperationIndex {
operationId: string;
method: string;
path: string;
summary?: string;
description?: string;
tags: string[];
}
// In-memory cache of loaded specs
const specCache: Map<string, OpenApiSpec> = new Map();
const operationIndexCache: Map<string, OperationIndex[]> = new Map();
/**
* Get the cache file path for a spec.
*/
function getCacheFilePath(api: ApiType, env: EnvType): string {
return join(getCacheDir(), `${env}-${api}-openapi.json`);
}
/**
* Fetch OpenAPI spec from URL.
*/
async function fetchSpec(url: string): Promise<OpenApiSpec> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch spec from ${url}: ${response.status}`);
}
return (await response.json()) as OpenApiSpec;
}
/**
* Load spec from local cache file.
*/
function loadFromCache(api: ApiType, env: EnvType): OpenApiSpec | null {
const cachePath = getCacheFilePath(api, env);
if (!existsSync(cachePath)) {
return null;
}
try {
const content = readFileSync(cachePath, "utf-8");
return JSON.parse(content) as OpenApiSpec;
} catch {
return null;
}
}
/**
* Save spec to local cache file.
*/
function saveToCache(api: ApiType, env: EnvType, spec: OpenApiSpec): void {
const cachePath = getCacheFilePath(api, env);
try {
writeFileSync(cachePath, JSON.stringify(spec, null, 2));
} catch (error) {
console.error(`Failed to cache spec to ${cachePath}:`, error);
}
}
/**
* Build operation index from spec.
*/
function buildOperationIndex(spec: OpenApiSpec): OperationIndex[] {
const operations: OperationIndex[] = [];
const methods = ["get", "post", "put", "patch", "delete"] as const;
for (const [path, pathItem] of Object.entries(spec.paths)) {
for (const method of methods) {
const operation = pathItem[method];
if (operation?.operationId) {
operations.push({
operationId: operation.operationId,
method: method.toUpperCase(),
path,
summary: operation.summary,
description: operation.description,
tags: operation.tags ?? [],
});
}
}
}
return operations;
}
/**
* Sync (fetch and cache) an OpenAPI spec.
*
* @param api - Which API to sync ("v1" or "internal")
* @param env - Environment ("prod" or "staging")
* @param source - Where to load from ("url", "cache", or "repo")
* @returns The loaded spec and operation count
*/
export async function syncSpec(
api: ApiType,
env: EnvType = "prod",
source: "url" | "cache" = "url"
): Promise<{ spec: OpenApiSpec; operationCount: number; fromCache: boolean }> {
const cacheKey = `${env}-${api}`;
// Try to load from URL first (if requested)
if (source === "url") {
try {
const url = getSpecUrl(api, env);
const spec = await fetchSpec(url);
// Cache it
saveToCache(api, env, spec);
specCache.set(cacheKey, spec);
// Build operation index
const operations = buildOperationIndex(spec);
operationIndexCache.set(cacheKey, operations);
return { spec, operationCount: operations.length, fromCache: false };
} catch (error) {
console.error(`Failed to fetch spec, falling back to cache:`, error);
// Fall through to cache
}
}
// Try to load from cache
const cachedSpec = loadFromCache(api, env);
if (cachedSpec) {
specCache.set(cacheKey, cachedSpec);
const operations = buildOperationIndex(cachedSpec);
operationIndexCache.set(cacheKey, operations);
return { spec: cachedSpec, operationCount: operations.length, fromCache: true };
}
throw new Error(
`No spec available for ${api} (${env}). ` +
`Run spec.sync with source="url" first.`
);
}
/**
* Get a loaded spec from memory.
*/
export function getSpec(api: ApiType, env: EnvType = "prod"): OpenApiSpec | null {
return specCache.get(`${env}-${api}`) ?? null;
}
/**
* Get operations from the index.
*/
export function getOperations(
api: ApiType,
env: EnvType = "prod"
): OperationIndex[] {
return operationIndexCache.get(`${env}-${api}`) ?? [];
}
/**
* Search operations by keyword.
*
* Matches against operationId, summary, description, path, and tags.
*/
export function searchOperations(
api: ApiType,
query: string,
env: EnvType = "prod",
topK: number = 10
): OperationIndex[] {
const operations = getOperations(api, env);
const queryLower = query.toLowerCase();
const queryWords = queryLower.split(/\s+/);
// Score each operation
const scored = operations.map((op) => {
let score = 0;
const searchText = [
op.operationId,
op.summary,
op.description,
op.path,
...op.tags,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
// Exact operationId match
if (op.operationId?.toLowerCase() === queryLower) {
score += 100;
}
// OperationId contains query
if (op.operationId?.toLowerCase().includes(queryLower)) {
score += 50;
}
// Summary contains query
if (op.summary?.toLowerCase().includes(queryLower)) {
score += 30;
}
// Word matches
for (const word of queryWords) {
if (searchText.includes(word)) {
score += 10;
}
}
// Tag exact match
for (const tag of op.tags) {
if (tag.toLowerCase() === queryLower) {
score += 40;
}
}
return { op, score };
});
// Sort by score and return top K
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map((s) => s.op);
}
/**
* Get operation by operationId.
*/
export function getOperationById(
api: ApiType,
operationId: string,
env: EnvType = "prod"
): OperationIndex | null {
const operations = getOperations(api, env);
return operations.find((op) => op.operationId === operationId) ?? null;
}
/**
* Get full operation details (including parameters and request body schema).
*/
export function describeOperation(
api: ApiType,
operationId: string,
env: EnvType = "prod"
): {
operation: OperationIndex;
parameters: Parameter[];
requestBodySchema?: SchemaObject;
responseSchema?: SchemaObject;
} | null {
const spec = getSpec(api, env);
const opIndex = getOperationById(api, operationId, env);
if (!spec || !opIndex) {
return null;
}
const pathItem = spec.paths[opIndex.path];
const method = opIndex.method.toLowerCase() as keyof PathItem;
const operation = pathItem[method] as Operation | undefined;
if (!operation) {
return null;
}
// Combine path-level and operation-level parameters
const parameters: Parameter[] = [
...(pathItem.parameters ?? []),
...(operation.parameters ?? []),
];
// Get request body schema
let requestBodySchema: SchemaObject | undefined;
if (operation.requestBody?.content) {
const jsonContent = operation.requestBody.content["application/json"];
requestBodySchema = jsonContent?.schema;
}
// Get response schema (200 or 201)
let responseSchema: SchemaObject | undefined;
const successResponse =
operation.responses?.["200"] ?? operation.responses?.["201"];
if (successResponse?.content) {
const jsonContent = successResponse.content["application/json"];
responseSchema = jsonContent?.schema;
}
return {
operation: opIndex,
parameters,
requestBodySchema,
responseSchema,
};
}
/**
* Clear all cached specs (useful for testing).
*/
export function clearSpecCache(): void {
specCache.clear();
operationIndexCache.clear();
}