Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -345,7 +345,7 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record<st
return url;
}

function buildHeaders(profile: Profile, command: CliCommand, flags: Record<string, string>): Record<string, string> {
function buildProfileAuthHeaders(profile: Profile): Record<string, string> {
const headers: Record<string, string> = {};

if (profile.customHeaders) {
Expand All @@ -359,6 +359,12 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record<strin
headers.Authorization = `Bearer ${profile.apiBearerToken}`;
}

return headers;
}

function buildHeaders(profile: Profile, command: CliCommand, flags: Record<string, string>): Record<string, string> {
const headers = buildProfileAuthHeaders(profile);

const cookiePairs: string[] = [];
command.options
.filter((opt) => opt.location === "header" || opt.location === "cookie")
Expand Down Expand Up @@ -619,7 +625,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
customHeaders,
};

await openapiLoader.loadSpec(profile, { refresh: true });
await openapiLoader.loadSpec(profile, { refresh: true, headers: buildProfileAuthHeaders(profile) });
profileStore.saveProfile(cwd, profile, { makeCurrent: true });
};

Expand Down Expand Up @@ -753,7 +759,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
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`);
Expand Down
24 changes: 16 additions & 8 deletions src/openapi-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class OpenapiLoader {
profile: Profile,
options?: {
refresh?: boolean;
headers?: Record<string, string>;
}
): Promise<unknown> {
const cachePath = profile.openapiSpecCache;
Expand All @@ -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);
Expand All @@ -45,33 +46,38 @@ export class OpenapiLoader {
return spec;
}

private async loadAndResolveSpec(source: string): Promise<unknown> {
private async loadAndResolveSpec(source: string, headers?: Record<string, string>): Promise<unknown> {
const rawDocCache = new Map<string, unknown>();
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<string>(),
headers,
});
}

private async loadFromSource(source: string): Promise<unknown> {
private async loadFromSource(source: string, headers?: Record<string, string>): Promise<unknown> {
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);
}

const raw = this.fs.readFileSync(source, "utf-8");
return this.parseSpec(raw, source);
}

private async loadDocument(source: string, rawDocCache: Map<string, unknown>): Promise<unknown> {
private async loadDocument(
source: string,
rawDocCache: Map<string, unknown>,
headers?: Record<string, string>
): Promise<unknown> {
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;
}
Expand All @@ -93,6 +99,7 @@ export class OpenapiLoader {
currentDocument: unknown;
rawDocCache: Map<string, unknown>;
resolvingRefs: Set<string>;
headers?: Record<string, string>;
}
): Promise<unknown> {
if (Array.isArray(value)) {
Expand Down Expand Up @@ -139,6 +146,7 @@ export class OpenapiLoader {
currentDocument: unknown;
rawDocCache: Map<string, unknown>;
resolvingRefs: Set<string>;
headers?: Record<string, string>;
}
): Promise<unknown> {
const { source, pointer } = this.splitRef(ref, context.currentSource);
Expand All @@ -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, {
Expand Down
49 changes: 49 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof axios>;
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 = [
Expand Down
39 changes: 39 additions & 0 deletions tests/openapi-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
}
});
});
Loading