diff --git a/src/cli.ts b/src/cli.ts index 2e88e50..b28342c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 openapiLoader.loadSpec(profile, { headers: buildProfileAuthHeaders(profile) }); const commands = openapiToCommands.buildCommands(spec, profile); const command = commands.find((cmd) => cmd.name === toolName); @@ -345,7 +345,7 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record): Record { +function buildProfileAuthHeaders(profile: Profile): Record { const headers: Record = {}; if (profile.customHeaders) { @@ -359,6 +359,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 +625,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { customHeaders, }; - await openapiLoader.loadSpec(profile, { refresh: true }); + await openapiLoader.loadSpec(profile, { refresh: true, headers: buildProfileAuthHeaders(profile) }); profileStore.saveProfile(cwd, profile, { makeCurrent: true }); }; @@ -753,7 +759,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 openapiLoader.loadSpec(profile, { headers: buildProfileAuthHeaders(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..15b4096 100644 --- a/src/openapi-loader.ts +++ b/src/openapi-loader.ts @@ -27,6 +27,7 @@ export class OpenapiLoader { profile: Profile, options?: { refresh?: boolean; + headers?: Record; } ): Promise { const cachePath = profile.openapiSpecCache; @@ -36,7 +37,7 @@ export class OpenapiLoader { return JSON.parse(cached); } - const spec = await this.loadAndResolveSpec(profile.openapiSpecSource); + const spec = await this.loadAndResolveSpec(profile.openapiSpecSource, options?.headers); this.ensureCacheDir(cachePath); const serialized = JSON.stringify(spec, null, 2); @@ -45,20 +46,21 @@ export class OpenapiLoader { return spec; } - private async loadAndResolveSpec(source: string): Promise { + private async loadAndResolveSpec(source: string, headers?: Record): Promise { const rawDocCache = new Map(); - const root = await this.loadDocument(source, rawDocCache); + const root = await this.loadDocument(source, rawDocCache, headers); return this.resolveRefs(root, { currentSource: source, currentDocument: root, rawDocCache, resolvingRefs: new Set(), + headers, }); } - private async loadFromSource(source: string): Promise { + private async loadFromSource(source: string, headers?: Record): Promise { if (source.startsWith("http://") || source.startsWith("https://")) { - const response = await axios.get(source, { responseType: "text" }); + const response = await axios.get(source, { responseType: "text", headers }); return this.parseSpec(response.data, source); } @@ -66,12 +68,16 @@ export class OpenapiLoader { return this.parseSpec(raw, source); } - private async loadDocument(source: string, rawDocCache: Map): Promise { + private async loadDocument( + source: string, + rawDocCache: Map, + headers?: Record + ): Promise { if (rawDocCache.has(source)) { return rawDocCache.get(source); } - const loaded = await this.loadFromSource(source); + const loaded = await this.loadFromSource(source, headers); rawDocCache.set(source, loaded); return loaded; } @@ -93,6 +99,7 @@ export class OpenapiLoader { currentDocument: unknown; rawDocCache: Map; resolvingRefs: Set; + headers?: Record; } ): Promise { if (Array.isArray(value)) { @@ -139,6 +146,7 @@ export class OpenapiLoader { currentDocument: unknown; rawDocCache: Map; resolvingRefs: Set; + headers?: Record; } ): Promise { const { source, pointer } = this.splitRef(ref, context.currentSource); @@ -152,7 +160,7 @@ export class OpenapiLoader { const targetDocument = source === context.currentSource ? context.currentDocument - : await this.loadDocument(source, context.rawDocCache); + : await this.loadDocument(source, context.rawDocCache, context.headers); const targetValue = this.resolvePointer(targetDocument, pointer); const resolvedValue = await this.resolveRefs(targetValue, { diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5206d75..0eda205 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,9 +2,15 @@ import { ConfigLocator } from "../src/config"; import { ProfileStore } from "../src/profile-store"; import { OpenapiLoader } from "../src/openapi-loader"; import { run, HttpClient } from "../src/cli"; +import axios from "axios"; import { AxiosError } from "axios"; import { VERSION } from "../src/version"; +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), +})); + interface MemoryFsEntry { type: "file" | "dir"; content?: string; @@ -164,6 +170,49 @@ describe("cli", () => { expect(profileStore.getCurrentProfileName(cwd)).toBe("myapi"); }); + it("profiles add sends profile auth headers when fetching a protected HTTP spec", async () => { + const mockedAxios = axios as jest.Mocked; + const spec = { openapi: "3.0.0", paths: {} }; + + mockedAxios.get.mockImplementation(async (_url: string, config?: any) => { + if (config?.headers?.Authorization !== "Bearer secret123" || config?.headers?.["x-api-key"] !== "key123") { + throw new AxiosError("Request failed with status code 401", "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 }); + + 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(mockedAxios.get).toHaveBeenCalledTimes(1); + 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 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..8275f39 100644 --- a/tests/openapi-loader.test.ts +++ b/tests/openapi-loader.test.ts @@ -269,4 +269,43 @@ describe("OpenapiLoader", () => { expect(loaded.paths["/jobs"].get.parameters[0].name).toBe("job_id"); expect(loaded.paths["/jobs"].get.parameters[0].in).toBe("query"); }); + + it("passes headers to axios for the spec and remote ref 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 summary: Get job\n`, + }; + } + + throw new Error(`Unexpected URL: ${source}`); + }); + + const profile: Profile = { + ...baseProfile, + openapiSpecSource: "https://example.com/root.yaml", + }; + + const fs = new MemoryFs(); + const loader = new OpenapiLoader({ fs }); + + await loader.loadSpec(profile, { + refresh: true, + headers: { Authorization: "Bearer token123", "x-api-key": "key123" }, + }); + + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + for (const call of mockedAxios.get.mock.calls) { + expect(call[1]).toEqual({ + responseType: "text", + headers: { Authorization: "Bearer token123", "x-api-key": "key123" }, + }); + } + }); });