#!/usr/bin/env node
/**
* @fileoverview Fetches an OpenAPI specification (YAML/JSON) from a URL,
* parses it, and saves it locally in both YAML and JSON formats.
* @module scripts/fetch-openapi-spec
* Includes fallback logic for common OpenAPI file names (openapi.yaml, openapi.json).
* Ensures output paths are within the project directory for security.
*
* @example
* // Fetch spec and save to docs/api/my_api.yaml and docs/api/my_api.json
* // ts-node --esm scripts/fetch-openapi-spec.ts https://api.example.com/v1 docs/api/my_api
*
* @example
* // Fetch spec from a direct file URL
* // ts-node --esm scripts/fetch-openapi-spec.ts https://petstore3.swagger.io/api/v3/openapi.json docs/api/petstore_v3
*/
import axios, { AxiosError } from "axios";
import fs from "fs/promises";
import yaml from "js-yaml";
import path from "path";
const projectRoot = process.cwd();
const args = process.argv.slice(2);
const helpFlag = args.includes("--help");
const urlArg = args[0];
const outputBaseArg = args[1];
if (helpFlag || !urlArg || !outputBaseArg) {
console.log(`
Fetch OpenAPI Specification Script
Usage:
ts-node --esm scripts/fetch-openapi-spec.ts <url> <output-base-path> [--help]
Arguments:
<url> Base URL or direct URL to the OpenAPI spec (YAML/JSON).
<output-base-path> Base path for output files (relative to project root),
e.g., 'docs/api/my_api'. Will generate .yaml and .json.
--help Show this help message.
Example:
ts-node --esm scripts/fetch-openapi-spec.ts https://petstore3.swagger.io/api/v3 docs/api/petstore_v3
`);
process.exit(helpFlag ? 0 : 1);
}
const outputBasePathAbsolute = path.resolve(projectRoot, outputBaseArg);
const yamlOutputPath = `${outputBasePathAbsolute}.yaml`;
const jsonOutputPath = `${outputBasePathAbsolute}.json`;
const outputDirAbsolute = path.dirname(outputBasePathAbsolute);
// Security Check: Ensure output paths are within project root
if (
!outputDirAbsolute.startsWith(projectRoot + path.sep) ||
!yamlOutputPath.startsWith(projectRoot + path.sep) ||
!jsonOutputPath.startsWith(projectRoot + path.sep)
) {
console.error(
`Error: Output path "${outputBaseArg}" resolves outside the project directory. Aborting.`,
);
process.exit(1);
}
/**
* Attempts to fetch content from a given URL.
* @param url - The URL to fetch data from.
* @returns A promise resolving to an object with data and content type, or null if fetch fails.
*/
async function tryFetch(
url: string,
): Promise<{ data: string; contentType: string | null } | null> {
try {
console.log(`Attempting to fetch from: ${url}`);
const response = await axios.get(url, {
responseType: "text",
validateStatus: (status) => status >= 200 && status < 300,
});
const contentType = response.headers["content-type"] || null;
console.log(
`Successfully fetched (Status: ${response.status}, Content-Type: ${contentType || "N/A"})`,
);
return { data: response.data, contentType };
} catch (error) {
let status = "Unknown";
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
status = axiosError.response
? String(axiosError.response.status)
: "Network Error";
}
console.warn(`Failed to fetch from ${url} (Status: ${status})`);
return null;
}
}
/**
* Parses fetched data as YAML or JSON, attempting to infer from content type or by trying both.
* @param data - The raw string data fetched from the URL.
* @param contentType - The content type header from the HTTP response, if available.
* @returns The parsed OpenAPI specification as an object, or null if parsing fails.
*/
function parseSpec(data: string, contentType: string | null): object | null {
try {
const lowerContentType = contentType?.toLowerCase();
if (
lowerContentType?.includes("yaml") ||
lowerContentType?.includes("yml")
) {
console.log("Parsing content as YAML based on Content-Type...");
return yaml.load(data) as object;
} else if (lowerContentType?.includes("json")) {
console.log("Parsing content as JSON based on Content-Type...");
return JSON.parse(data);
} else {
console.log(
"Content-Type is ambiguous or missing. Attempting to parse as YAML first...",
);
try {
const parsedYaml = yaml.load(data) as object;
// Basic validation: check if it's a non-null object.
if (parsedYaml && typeof parsedYaml === "object") {
console.log("Successfully parsed as YAML.");
return parsedYaml;
}
} catch (yamlError) {
console.log("YAML parsing failed. Attempting to parse as JSON...");
try {
const parsedJson = JSON.parse(data);
if (parsedJson && typeof parsedJson === "object") {
console.log("Successfully parsed as JSON.");
return parsedJson;
}
} catch (jsonError) {
console.warn(
"Could not parse content as YAML or JSON after attempting both.",
);
return null;
}
}
// If YAML parsing resulted in a non-object (e.g. string, number) but didn't throw
console.warn(
"Content parsed as YAML but was not a valid object structure. Trying JSON.",
);
try {
const parsedJson = JSON.parse(data);
if (parsedJson && typeof parsedJson === "object") {
console.log(
"Successfully parsed as JSON on second attempt for non-object YAML.",
);
return parsedJson;
}
} catch (jsonError) {
console.warn(
"Could not parse content as YAML or JSON after attempting both.",
);
return null;
}
}
} catch (parseError) {
console.error(
`Error parsing specification: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
);
}
return null;
}
/**
* Main orchestrator function. Fetches the OpenAPI spec from the provided URL (with fallbacks),
* parses it, and saves it to the specified output paths in both YAML and JSON formats.
*/
async function fetchAndProcessSpec(): Promise<void> {
let fetchedResult: { data: string; contentType: string | null } | null = null;
const potentialUrls: string[] = [urlArg];
if (
!urlArg.endsWith(".yaml") &&
!urlArg.endsWith(".yml") &&
!urlArg.endsWith(".json")
) {
const urlWithoutTrailingSlash = urlArg.endsWith("/")
? urlArg.slice(0, -1)
: urlArg;
potentialUrls.push(`${urlWithoutTrailingSlash}/openapi.yaml`);
potentialUrls.push(`${urlWithoutTrailingSlash}/openapi.json`);
}
for (const url of potentialUrls) {
fetchedResult = await tryFetch(url);
if (fetchedResult) break;
}
if (!fetchedResult) {
console.error(
`Error: Failed to fetch specification from all attempted URLs: ${potentialUrls.join(", ")}. Aborting.`,
);
process.exit(1);
}
const openapiSpec = parseSpec(fetchedResult.data, fetchedResult.contentType);
if (!openapiSpec || typeof openapiSpec !== "object") {
console.error(
"Error: Failed to parse specification content or content is not a valid object. Aborting.",
);
process.exit(1);
}
try {
await fs.access(outputDirAbsolute);
} catch (error: any) {
if (error.code === "ENOENT") {
console.log(`Output directory not found. Creating: ${outputDirAbsolute}`);
await fs.mkdir(outputDirAbsolute, { recursive: true });
} else {
console.error(
`Error accessing output directory ${outputDirAbsolute}: ${error.message}. Aborting.`,
);
process.exit(1);
}
}
try {
console.log(`Saving YAML specification to: ${yamlOutputPath}`);
await fs.writeFile(yamlOutputPath, yaml.dump(openapiSpec), "utf8");
console.log(`Successfully saved YAML specification.`);
} catch (error) {
console.error(
`Error saving YAML to ${yamlOutputPath}: ${error instanceof Error ? error.message : String(error)}. Aborting.`,
);
process.exit(1);
}
try {
console.log(`Saving JSON specification to: ${jsonOutputPath}`);
await fs.writeFile(
jsonOutputPath,
JSON.stringify(openapiSpec, null, 2),
"utf8",
);
console.log(`Successfully saved JSON specification.`);
} catch (error) {
console.error(
`Error saving JSON to ${jsonOutputPath}: ${error instanceof Error ? error.message : String(error)}. Aborting.`,
);
process.exit(1);
}
console.log("OpenAPI specification processed and saved successfully.");
}
fetchAndProcessSpec();