diff --git a/README.md b/README.md index 10ab95f..0fc3d11 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,25 @@ ocli commands -p other --query "send message" `--profile` (short `-p`) overrides the profile selected by `ocli use` for this invocation only. It works for both dynamic API commands and `ocli commands`. Place it anywhere after the command name. When omitted, the profile set via `ocli use` is used (falling back to `default`). +### Authentication and custom headers + +A profile stores up to three credentials, set with `ocli profiles add` (or `ocli onboard`): + +- `--api-bearer-token ` sends `Authorization: Bearer ` +- `--api-basic-auth ` sends `Authorization: Basic `; when both are set, Basic wins +- `--custom-headers '{"X-Tenant":"acme"}'` adds any extra headers + +`ocli` attaches these headers to every API request and to the download of the OpenAPI spec itself, so a spec served behind the same auth as the API (for example `/openapi.json` answering 401 to anonymous requests) loads with `ocli profiles add`. The headers are sent only to the two origins named in the profile, the `--openapi-spec` URL and the `--api-base-url`. External `$ref` documents on those origins receive them too, at any nesting depth; `$ref` documents on any other host are fetched anonymously, so a spec cannot forward your credentials to a third-party host. Specs loaded from a local file path involve no request. + +When the spec download is rejected with 401 or 403, `ocli` reports the failing URL and the status and points at the three flags above: + +```bash +$ ocli profiles add myapi --api-base-url https://api.example.com --openapi-spec https://api.example.com/openapi.json +Failed to fetch OpenAPI document https://api.example.com/openapi.json: HTTP 401. Check --api-basic-auth, --api-bearer-token, or --custom-headers of profile myapi. +``` + +The spec is downloaded once and cached under `.ocli/specs/.json`. Later invocations read the cache and do not contact the spec URL. Re-run `ocli profiles add` with the same profile name to refresh it. + ### Strict flag validation `ocli` refuses to run a command with a flag the spec does not define, instead of dropping it from the request: diff --git a/src/cli.ts b/src/cli.ts index 2e88e50..f79d1ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,7 @@ import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import { ConfigLocator } from "./config"; import { ProfileStore, Profile } from "./profile-store"; -import { OpenapiLoader } from "./openapi-loader"; +import { OpenapiLoader, SpecFetchError } from "./openapi-loader"; import { OpenapiToCommands, CliCommand, CliCommandOption } from "./openapi-to-commands"; import { CommandSearch } from "./command-search"; import { findUnknownFlags, formatUnknownFlagsError } from "./command-args"; @@ -114,7 +114,7 @@ async function runApiCommand( const { profileName: overrideName, remaining: commandArgs } = extractProfileFlag(args); const profile = resolveProfile(profileStore, cwd, overrideName); - const spec = await openapiLoader.loadSpec(profile); + const spec = await loadProfileSpec(openapiLoader, profile); const commands = openapiToCommands.buildCommands(spec, profile); const command = commands.find((cmd) => cmd.name === toolName); @@ -345,7 +345,24 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record): Record { +async function loadProfileSpec( + openapiLoader: OpenapiLoader, + profile: Profile, + options?: { refresh?: boolean } +): Promise { + try { + return await openapiLoader.loadSpec(profile, { refresh: options?.refresh, headers: buildProfileAuthHeaders(profile) }); + } catch (err) { + if (err instanceof SpecFetchError && (err.status === 401 || err.status === 403)) { + throw new Error( + `${err.message}. Check --api-basic-auth, --api-bearer-token, or --custom-headers of profile ${profile.name}.` + ); + } + throw err; + } +} + +function buildProfileAuthHeaders(profile: Profile): Record { const headers: Record = {}; if (profile.customHeaders) { @@ -359,6 +376,12 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record): Record { + const headers = buildProfileAuthHeaders(profile); + const cookiePairs: string[] = []; command.options .filter((opt) => opt.location === "header" || opt.location === "cookie") @@ -619,7 +642,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { customHeaders, }; - await openapiLoader.loadSpec(profile, { refresh: true }); + await loadProfileSpec(openapiLoader, profile, { refresh: true }); profileStore.saveProfile(cwd, profile, { makeCurrent: true }); }; @@ -630,13 +653,25 @@ export async function run(argv: string[], options?: RunOptions): Promise { demandOption: true, description: "Base URL for API requests.", }) - .option("openapi-spec", { type: "string", demandOption: true }) - .option("api-basic-auth", { type: "string", default: "" }) - .option("api-bearer-token", { type: "string", default: "" }) + .option("openapi-spec", { + type: "string", + demandOption: true, + description: "URL or local path of the OpenAPI/Swagger document. Downloaded once and cached.", + }) + .option("api-basic-auth", { + type: "string", + default: "", + description: "user:password for Basic auth. Sent with API requests and the spec download.", + }) + .option("api-bearer-token", { + type: "string", + default: "", + description: "Bearer token. Sent with API requests and the spec download.", + }) .option("include-endpoints", { type: "string", default: "" }) .option("exclude-endpoints", { type: "string", default: "" }) .option("command-prefix", { type: "string", default: "", description: "Prefix for command names (e.g. api_ -> api_messages)" }) - .option("custom-headers", { type: "string", default: "", description: "Custom headers as JSON string, e.g. '{\"X-Tenant\":\"acme\"}'" }); + .option("custom-headers", { type: "string", default: "", description: "Custom headers as JSON string, e.g. '{\"X-Tenant\":\"acme\"}'. Sent with API requests and the spec download." }); const staticCommands = new Set(["onboard", "profiles", "use", "commands", "search", "help", "--help", "-h", "--version"]); @@ -753,7 +788,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { async (args) => { const overrideName = args.profile as string | undefined; const profile = resolveProfile(profileStore, cwd, overrideName); - const spec = await openapiLoader.loadSpec(profile); + const spec = await loadProfileSpec(openapiLoader, profile); const commands = openapiToCommands.buildCommands(spec, profile); if (commands.length === 0) { stdout(`No commands available for profile ${profile.name}\n`); diff --git a/src/openapi-loader.ts b/src/openapi-loader.ts index fa57d33..695ee48 100644 --- a/src/openapi-loader.ts +++ b/src/openapi-loader.ts @@ -12,23 +12,66 @@ interface FileSystemForLoader { mkdirSync(pathToCreate: string, options?: { recursive?: boolean }): void; } +export interface SpecHttpClient { + get(url: string, options?: { headers?: Record }): Promise<{ data: unknown }>; +} + export interface OpenapiLoaderOptions { fs?: FileSystemForLoader; + httpClient?: SpecHttpClient; +} + +export interface LoadSpecOptions { + refresh?: boolean; + headers?: Record; +} + +interface RemoteAuth { + headers: Record; + origins: Set; +} + +interface ResolveContext { + currentSource: string; + currentDocument: unknown; + rawDocCache: Map; + resolvingRefs: Set; + remoteAuth?: RemoteAuth; } +export class SpecFetchError extends Error { + readonly url: string; + readonly status?: number; + + constructor(url: string, status: number | undefined, detail: string) { + super( + status === undefined + ? `Failed to fetch OpenAPI document ${url}: ${detail}` + : `Failed to fetch OpenAPI document ${url}: HTTP ${status}` + ); + this.name = "SpecFetchError"; + this.url = url; + this.status = status; + } +} + +const defaultHttpClient: SpecHttpClient = { + get: async (url, options) => { + const response = await axios.get(url, { responseType: "text", headers: options?.headers }); + return { data: response.data }; + }, +}; + export class OpenapiLoader { private readonly fs: FileSystemForLoader; + private readonly httpClient: SpecHttpClient; constructor(options?: OpenapiLoaderOptions) { this.fs = options?.fs ?? fsModule; + this.httpClient = options?.httpClient ?? defaultHttpClient; } - async loadSpec( - profile: Profile, - options?: { - refresh?: boolean; - } - ): Promise { + async loadSpec(profile: Profile, options?: LoadSpecOptions): Promise { const cachePath = profile.openapiSpecCache; if (!options?.refresh && this.fs.existsSync(cachePath)) { @@ -36,7 +79,7 @@ export class OpenapiLoader { return JSON.parse(cached); } - const spec = await this.loadAndResolveSpec(profile.openapiSpecSource); + const spec = await this.loadAndResolveSpec(profile.openapiSpecSource, this.remoteAuthFor(profile, options?.headers)); this.ensureCacheDir(cachePath); const serialized = JSON.stringify(spec, null, 2); @@ -45,20 +88,68 @@ export class OpenapiLoader { return spec; } - private async loadAndResolveSpec(source: string): Promise { + private async loadAndResolveSpec(source: string, remoteAuth?: RemoteAuth): Promise { const rawDocCache = new Map(); - const root = await this.loadDocument(source, rawDocCache); + const root = await this.loadDocument(source, rawDocCache, remoteAuth); return this.resolveRefs(root, { currentSource: source, currentDocument: root, rawDocCache, resolvingRefs: new Set(), + remoteAuth, }); } - private async loadFromSource(source: string): Promise { - if (source.startsWith("http://") || source.startsWith("https://")) { - const response = await axios.get(source, { responseType: "text" }); + // Profile credentials are meant for the hosts the user configured: the spec URL and the API base URL. + // Any other origin reachable through an external $ref is fetched anonymously. + private remoteAuthFor(profile: Profile, headers?: Record): RemoteAuth | undefined { + if (!headers || Object.keys(headers).length === 0) { + return undefined; + } + + const origins = new Set(); + for (const candidate of [profile.openapiSpecSource, profile.apiBaseUrl]) { + const origin = this.originOf(candidate); + if (origin) { + origins.add(origin); + } + } + + return { headers, origins }; + } + + private headersFor(source: string, remoteAuth?: RemoteAuth): Record | undefined { + if (!remoteAuth) { + return undefined; + } + const origin = this.originOf(source); + return origin && remoteAuth.origins.has(origin) ? remoteAuth.headers : undefined; + } + + private originOf(source: string): string | undefined { + if (!this.isRemote(source)) { + return undefined; + } + try { + return new URL(source).origin; + } catch { + return undefined; + } + } + + private isRemote(source: string): boolean { + return source.startsWith("http://") || source.startsWith("https://"); + } + + private async loadFromSource(source: string, remoteAuth?: RemoteAuth): Promise { + if (this.isRemote(source)) { + const headers = this.headersFor(source, remoteAuth); + let response: { data: unknown }; + try { + response = await this.httpClient.get(source, headers ? { headers } : {}); + } catch (err) { + throw this.toFetchError(source, err); + } return this.parseSpec(response.data, source); } @@ -66,17 +157,26 @@ export class OpenapiLoader { return this.parseSpec(raw, source); } - private async loadDocument(source: string, rawDocCache: Map): Promise { + private toFetchError(url: string, err: unknown): SpecFetchError { + if (err instanceof SpecFetchError) { + return err; + } + const status = (err as { response?: { status?: unknown } } | undefined)?.response?.status; + const detail = err instanceof Error ? err.message : String(err); + return new SpecFetchError(url, typeof status === "number" ? status : undefined, detail); + } + + private async loadDocument(source: string, rawDocCache: Map, remoteAuth?: RemoteAuth): Promise { if (rawDocCache.has(source)) { return rawDocCache.get(source); } - const loaded = await this.loadFromSource(source); + const loaded = await this.loadFromSource(source, remoteAuth); rawDocCache.set(source, loaded); return loaded; } - private parseSpec(content: string | object, source: string): unknown { + private parseSpec(content: unknown, source: string): unknown { if (typeof content !== "string") { return content; } @@ -86,15 +186,7 @@ export class OpenapiLoader { return JSON.parse(content); } - private async resolveRefs( - value: unknown, - context: { - currentSource: string; - currentDocument: unknown; - rawDocCache: Map; - resolvingRefs: Set; - } - ): Promise { + private async resolveRefs(value: unknown, context: ResolveContext): Promise { if (Array.isArray(value)) { const items = await Promise.all(value.map((item) => this.resolveRefs(item, context))); return items; @@ -132,15 +224,7 @@ export class OpenapiLoader { return Object.fromEntries(resolvedEntries); } - private async resolveRef( - ref: string, - context: { - currentSource: string; - currentDocument: unknown; - rawDocCache: Map; - resolvingRefs: Set; - } - ): Promise { + private async resolveRef(ref: string, context: ResolveContext): Promise { const { source, pointer } = this.splitRef(ref, context.currentSource); const cacheKey = `${source}#${pointer}`; @@ -152,14 +236,13 @@ export class OpenapiLoader { const targetDocument = source === context.currentSource ? context.currentDocument - : await this.loadDocument(source, context.rawDocCache); + : await this.loadDocument(source, context.rawDocCache, context.remoteAuth); const targetValue = this.resolvePointer(targetDocument, pointer); const resolvedValue = await this.resolveRefs(targetValue, { + ...context, currentSource: source, currentDocument: targetDocument, - rawDocCache: context.rawDocCache, - resolvingRefs: context.resolvingRefs, }); context.resolvingRefs.delete(cacheKey); @@ -172,11 +255,11 @@ export class OpenapiLoader { return { source: currentSource, pointer }; } - if (refSource.startsWith("http://") || refSource.startsWith("https://")) { + if (this.isRemote(refSource)) { return { source: refSource, pointer }; } - if (currentSource.startsWith("http://") || currentSource.startsWith("https://")) { + if (this.isRemote(currentSource)) { return { source: new URL(refSource, currentSource).toString(), pointer }; } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5206d75..0beca84 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -164,6 +164,86 @@ describe("cli", () => { expect(profileStore.getCurrentProfileName(cwd)).toBe("myapi"); }); + it("profiles add sends profile auth headers when fetching a protected HTTP spec", async () => { + const spec = { openapi: "3.0.0", paths: {} }; + const specHttpClient = { + get: jest.fn(async (_url: string, options?: { headers?: Record }) => { + if (options?.headers?.Authorization !== "Bearer secret123" || options?.headers?.["x-api-key"] !== "key123") { + throw Object.assign(new Error("Request failed with status code 401"), { response: { status: 401 } }); + } + return { data: spec }; + }), + }; + + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const fs = new MemoryFs(); + const locator = new ConfigLocator({ fs, homeDir }); + const profileStore = new ProfileStore({ fs, locator }); + const openapiLoader = new OpenapiLoader({ fs, httpClient: specHttpClient }); + + await run( + [ + "profiles", + "add", + "protected", + "--api-base-url", + "http://127.0.0.1:3000", + "--openapi-spec", + "http://127.0.0.1:3000/openapi.json", + "--api-bearer-token", + "secret123", + "--custom-headers", + '{"x-api-key":"key123"}', + ], + { cwd, profileStore, openapiLoader } + ); + + expect(specHttpClient.get).toHaveBeenCalledTimes(1); + expect(specHttpClient.get).toHaveBeenCalledWith("http://127.0.0.1:3000/openapi.json", { + headers: { Authorization: "Bearer secret123", "x-api-key": "key123" }, + }); + expect(fs.existsSync(profilesPath)).toBe(true); + const profile = profileStore.getCurrentProfile(cwd); + expect(profile?.name).toBe("protected"); + expect(profile?.apiBearerToken).toBe("secret123"); + expect(profile?.customHeaders).toEqual({ "x-api-key": "key123" }); + }); + + it("profiles add reports a 401 from the spec URL and points at the auth flags", async () => { + const specHttpClient = { + get: jest.fn(async () => { + throw Object.assign(new Error("Request failed with status code 401"), { response: { status: 401 } }); + }), + }; + + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const fs = new MemoryFs(); + const locator = new ConfigLocator({ fs, homeDir }); + const profileStore = new ProfileStore({ fs, locator }); + const openapiLoader = new OpenapiLoader({ fs, httpClient: specHttpClient }); + + const failure = await run( + [ + "profiles", + "add", + "protected", + "--api-base-url", + "http://127.0.0.1:3000", + "--openapi-spec", + "http://127.0.0.1:3000/openapi.json", + ], + { cwd, profileStore, openapiLoader } + ).catch((err: Error) => err); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("http://127.0.0.1:3000/openapi.json"); + expect((failure as Error).message).toContain("401"); + expect((failure as Error).message).toContain("--api-bearer-token"); + expect(fs.existsSync(profilesPath)).toBe(false); + }); + it("profiles list prints profile names", async () => { const localDir = `${cwd}/.ocli`; const iniContent = [ diff --git a/tests/openapi-loader.test.ts b/tests/openapi-loader.test.ts index f236948..c34d758 100644 --- a/tests/openapi-loader.test.ts +++ b/tests/openapi-loader.test.ts @@ -1,8 +1,5 @@ -import axios from "axios"; import { Profile } from "../src/profile-store"; -import { OpenapiLoader } from "../src/openapi-loader"; - -jest.mock("axios"); +import { OpenapiLoader, SpecFetchError, SpecHttpClient } from "../src/openapi-loader"; interface MemoryFsEntry { type: "file" | "dir"; @@ -72,9 +69,34 @@ class MemoryFs { } } -describe("OpenapiLoader", () => { - const mockedAxios = axios as jest.Mocked; +type SpecHttpGet = jest.MockedFunction; + +interface FakeSpecHttpClient extends SpecHttpClient { + get: SpecHttpGet; +} + +function createHttpClient(): FakeSpecHttpClient { + return { get: jest.fn() as SpecHttpGet }; +} + +function serveDocuments(documents: Record): SpecHttpClient["get"] { + return async (url: string) => { + if (url in documents) { + return { data: documents[url] }; + } + throw new Error(`Unexpected URL: ${url}`); + }; +} + +function headersSentTo(httpClient: FakeSpecHttpClient, url: string): Record | undefined { + const call = httpClient.get.mock.calls.find(([calledUrl]) => calledUrl === url); + if (!call) { + throw new Error(`No request was made to ${url}`); + } + return call[1]?.headers; +} +describe("OpenapiLoader", () => { const baseProfile: Profile = { name: "myapi", apiBaseUrl: "http://127.0.0.1:3000", @@ -84,20 +106,24 @@ describe("OpenapiLoader", () => { openapiSpecCache: "/home/user/.ocli/specs/myapi.json", includeEndpoints: [], excludeEndpoints: [], - commandPrefix: "", - customHeaders: {}, + commandPrefix: "", + customHeaders: {}, }; + const profileHeaders = { Authorization: "Bearer token123", "x-api-key": "key123" }; + + let httpClient: FakeSpecHttpClient; + beforeEach(() => { - mockedAxios.get.mockReset(); + httpClient = createHttpClient(); }); it("downloads spec from HTTP URL and caches it when cache is missing", async () => { const spec = { openapi: "3.0.0", info: { title: "API", version: "1.0.0" } }; - mockedAxios.get.mockResolvedValueOnce({ data: spec }); + httpClient.get.mockResolvedValueOnce({ data: spec }); const fs = new MemoryFs(); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const profile: Profile = { ...baseProfile, @@ -107,6 +133,8 @@ describe("OpenapiLoader", () => { const loaded = await loader.loadSpec(profile); expect(loaded).toEqual(spec); + expect(httpClient.get).toHaveBeenCalledTimes(1); + expect(httpClient.get.mock.calls[0][0]).toBe(profile.openapiSpecSource); expect(fs.existsSync(profile.openapiSpecCache)).toBe(true); const cachedRaw = fs.readFileSync(profile.openapiSpecCache, "utf-8"); @@ -125,13 +153,12 @@ describe("OpenapiLoader", () => { [profile.openapiSpecCache]: JSON.stringify(cachedSpec), }); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile); expect(loaded).toEqual(cachedSpec); - // No HTTP call is needed when cache exists. - mockedAxios.get.mockClear(); + expect(httpClient.get).not.toHaveBeenCalled(); }); it("loads spec from local file path and writes cache", async () => { @@ -146,11 +173,12 @@ describe("OpenapiLoader", () => { [profile.openapiSpecSource]: JSON.stringify(sourceSpec), }); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile, { refresh: true }); expect(loaded).toEqual(sourceSpec); + expect(httpClient.get).not.toHaveBeenCalled(); expect(fs.existsSync(profile.openapiSpecCache)).toBe(true); const cachedRaw = fs.readFileSync(profile.openapiSpecCache, "utf-8"); @@ -169,17 +197,17 @@ describe("OpenapiLoader", () => { [profile.openapiSpecSource]: yamlContent, }); - const loader = new OpenapiLoader({ fs }); - const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; - expect((loaded as any).openapi).toBe("3.0.0"); - expect((loaded as any).info.title).toBe("YAML API"); - expect((loaded as any).paths["/test"].get.summary).toBe("Test endpoint"); + expect(loaded.openapi).toBe("3.0.0"); + expect(loaded.info.title).toBe("YAML API"); + expect(loaded.paths["/test"].get.summary).toBe("Test endpoint"); }); it("loads YAML spec from HTTP URL", async () => { const yamlContent = `openapi: "3.0.0"\ninfo:\n title: Remote YAML\n version: "2.0"\npaths: {}`; - mockedAxios.get.mockResolvedValueOnce({ data: yamlContent }); + httpClient.get.mockResolvedValueOnce({ data: yamlContent }); const profile: Profile = { ...baseProfile, @@ -187,11 +215,11 @@ describe("OpenapiLoader", () => { }; const fs = new MemoryFs(); - const loader = new OpenapiLoader({ fs }); - const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; - expect((loaded as any).openapi).toBe("3.0.0"); - expect((loaded as any).info.title).toBe("Remote YAML"); + expect(loaded.openapi).toBe("3.0.0"); + expect(loaded.info.title).toBe("Remote YAML"); }); it("auto-detects YAML content even without .yaml extension", async () => { @@ -206,10 +234,10 @@ describe("OpenapiLoader", () => { [profile.openapiSpecSource]: yamlContent, }); - const loader = new OpenapiLoader({ fs }); - const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; - expect((loaded as any).info.title).toBe("Auto Detect"); + expect(loaded.info.title).toBe("Auto Detect"); }); it("resolves local external refs across multiple files", async () => { @@ -228,34 +256,20 @@ describe("OpenapiLoader", () => { "/project/paths/components/request-bodies.yaml": requestBodies, }); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; expect(loaded.paths["/jobs"].post.requestBody.content["application/json"].schema.properties.name.type).toBe("string"); }); it("resolves remote external refs across multiple documents", async () => { - mockedAxios.get.mockImplementation(async (source: string) => { - if (source === "https://example.com/root.yaml") { - return { - data: `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, - }; - } - - if (source === "https://example.com/paths/jobs.yaml") { - return { - data: `jobsPath:\n get:\n parameters:\n - $ref: "../components/params.yaml#/JobId"\n`, - }; - } - - if (source === "https://example.com/components/params.yaml") { - return { - data: `JobId:\n name: job_id\n in: query\n required: true\n schema:\n type: string\n`, - }; - } - - throw new Error(`Unexpected URL: ${source}`); - }); + httpClient.get.mockImplementation( + serveDocuments({ + "https://example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, + "https://example.com/paths/jobs.yaml": `jobsPath:\n get:\n parameters:\n - $ref: "../components/params.yaml#/JobId"\n`, + "https://example.com/components/params.yaml": `JobId:\n name: job_id\n in: query\n required: true\n schema:\n type: string\n`, + }) + ); const profile: Profile = { ...baseProfile, @@ -263,10 +277,107 @@ describe("OpenapiLoader", () => { }; const fs = new MemoryFs(); - const loader = new OpenapiLoader({ fs }); + const loader = new OpenapiLoader({ fs, httpClient }); const loaded = await loader.loadSpec(profile, { refresh: true }) as Record; expect(loaded.paths["/jobs"].get.parameters[0].name).toBe("job_id"); expect(loaded.paths["/jobs"].get.parameters[0].in).toBe("query"); + expect(headersSentTo(httpClient, "https://example.com/components/params.yaml")).toBeUndefined(); + }); + + describe("profile headers", () => { + it("sends the headers to the spec and to every same-origin ref document, including nested ones", async () => { + httpClient.get.mockImplementation( + serveDocuments({ + "https://example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /jobs:\n $ref: "./paths/jobs.yaml#/jobsPath"\n`, + "https://example.com/paths/jobs.yaml": `jobsPath:\n get:\n parameters:\n - $ref: "../components/params.yaml#/JobId"\n`, + "https://example.com/components/params.yaml": `JobId:\n name: job_id\n in: query\n schema:\n type: string\n`, + }) + ); + + const profile: Profile = { + ...baseProfile, + openapiSpecSource: "https://example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true, headers: profileHeaders }) as Record; + + expect(loaded.paths["/jobs"].get.parameters[0].name).toBe("job_id"); + expect(httpClient.get).toHaveBeenCalledTimes(3); + expect(headersSentTo(httpClient, "https://example.com/root.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://example.com/paths/jobs.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://example.com/components/params.yaml")).toEqual(profileHeaders); + }); + + it("does not send the headers to ref documents on another origin", async () => { + httpClient.get.mockImplementation( + serveDocuments({ + "https://api.example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /pets:\n get:\n responses:\n "200":\n description: ok\n content:\n application/json:\n schema:\n $ref: "https://schemas.example.org/pet.yaml#/Pet"\n`, + "https://schemas.example.org/pet.yaml": `Pet:\n type: object\n properties:\n id:\n type: integer\n`, + }) + ); + + const profile: Profile = { + ...baseProfile, + apiBaseUrl: "https://api.example.com", + openapiSpecSource: "https://api.example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true, headers: profileHeaders }) as Record; + + expect(loaded.paths["/pets"].get.responses["200"].content["application/json"].schema.type).toBe("object"); + expect(headersSentTo(httpClient, "https://api.example.com/root.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://schemas.example.org/pet.yaml")).toBeUndefined(); + }); + + it("sends the headers to ref documents on the API base URL origin when the spec lives elsewhere", async () => { + httpClient.get.mockImplementation( + serveDocuments({ + "https://docs.example.com/root.yaml": `openapi: "3.0.0"\npaths:\n /pets:\n $ref: "https://api.example.com/paths/pets.yaml#/petsPath"\n`, + "https://api.example.com/paths/pets.yaml": `petsPath:\n get:\n summary: List pets\n`, + }) + ); + + const profile: Profile = { + ...baseProfile, + apiBaseUrl: "https://api.example.com/v1", + openapiSpecSource: "https://docs.example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs, httpClient }); + const loaded = await loader.loadSpec(profile, { refresh: true, headers: profileHeaders }) as Record; + + expect(loaded.paths["/pets"].get.summary).toBe("List pets"); + expect(headersSentTo(httpClient, "https://docs.example.com/root.yaml")).toEqual(profileHeaders); + expect(headersSentTo(httpClient, "https://api.example.com/paths/pets.yaml")).toEqual(profileHeaders); + }); + }); + + it("wraps a failed download into SpecFetchError carrying the URL and HTTP status", async () => { + httpClient.get.mockRejectedValueOnce( + Object.assign(new Error("Request failed with status code 401"), { response: { status: 401 } }) + ); + + const profile: Profile = { + ...baseProfile, + openapiSpecSource: "https://api.example.com/openapi.json", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs, httpClient }); + + const failure = await loader.loadSpec(profile, { refresh: true }).catch((err: unknown) => err); + + expect(failure).toBeInstanceOf(SpecFetchError); + expect((failure as SpecFetchError).url).toBe("https://api.example.com/openapi.json"); + expect((failure as SpecFetchError).status).toBe(401); + expect((failure as SpecFetchError).message).toContain("https://api.example.com/openapi.json"); + expect((failure as SpecFetchError).message).toContain("401"); + expect(fs.existsSync(profile.openapiSpecCache)).toBe(false); }); });