Skip to content
Merged
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` sends `Authorization: Bearer <token>`
- `--api-basic-auth <user:password>` sends `Authorization: Basic <base64>`; 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/<profile>.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:
Expand Down
53 changes: 44 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down 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 loadProfileSpec(openapiLoader, profile);
const commands = openapiToCommands.buildCommands(spec, profile);
const command = commands.find((cmd) => cmd.name === toolName);

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

function buildHeaders(profile: Profile, command: CliCommand, flags: Record<string, string>): Record<string, string> {
async function loadProfileSpec(
openapiLoader: OpenapiLoader,
profile: Profile,
options?: { refresh?: boolean }
): Promise<unknown> {
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<string, string> {
const headers: Record<string, string> = {};

if (profile.customHeaders) {
Expand All @@ -359,6 +376,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 +642,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
customHeaders,
};

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

Expand All @@ -630,13 +653,25 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
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"]);

Expand Down Expand Up @@ -753,7 +788,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 loadProfileSpec(openapiLoader, profile);
const commands = openapiToCommands.buildCommands(spec, profile);
if (commands.length === 0) {
stdout(`No commands available for profile ${profile.name}\n`);
Expand Down
159 changes: 121 additions & 38 deletions src/openapi-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,74 @@ interface FileSystemForLoader {
mkdirSync(pathToCreate: string, options?: { recursive?: boolean }): void;
}

export interface SpecHttpClient {
get(url: string, options?: { headers?: Record<string, string> }): Promise<{ data: unknown }>;
}

export interface OpenapiLoaderOptions {
fs?: FileSystemForLoader;
httpClient?: SpecHttpClient;
}

export interface LoadSpecOptions {
refresh?: boolean;
headers?: Record<string, string>;
}

interface RemoteAuth {
headers: Record<string, string>;
origins: Set<string>;
}

interface ResolveContext {
currentSource: string;
currentDocument: unknown;
rawDocCache: Map<string, unknown>;
resolvingRefs: Set<string>;
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<unknown> {
async loadSpec(profile: Profile, options?: LoadSpecOptions): Promise<unknown> {
const cachePath = profile.openapiSpecCache;

if (!options?.refresh && this.fs.existsSync(cachePath)) {
const cached = this.fs.readFileSync(cachePath, "utf-8");
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);
Expand All @@ -45,38 +88,95 @@ export class OpenapiLoader {
return spec;
}

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

private async loadFromSource(source: string): Promise<unknown> {
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<string, string>): RemoteAuth | undefined {
if (!headers || Object.keys(headers).length === 0) {
return undefined;
}

const origins = new Set<string>();
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<string, string> | 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<unknown> {
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);
}

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 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<string, unknown>, remoteAuth?: RemoteAuth): Promise<unknown> {
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;
}
Expand All @@ -86,15 +186,7 @@ export class OpenapiLoader {
return JSON.parse(content);
}

private async resolveRefs(
value: unknown,
context: {
currentSource: string;
currentDocument: unknown;
rawDocCache: Map<string, unknown>;
resolvingRefs: Set<string>;
}
): Promise<unknown> {
private async resolveRefs(value: unknown, context: ResolveContext): Promise<unknown> {
if (Array.isArray(value)) {
const items = await Promise.all(value.map((item) => this.resolveRefs(item, context)));
return items;
Expand Down Expand Up @@ -132,15 +224,7 @@ export class OpenapiLoader {
return Object.fromEntries(resolvedEntries);
}

private async resolveRef(
ref: string,
context: {
currentSource: string;
currentDocument: unknown;
rawDocCache: Map<string, unknown>;
resolvingRefs: Set<string>;
}
): Promise<unknown> {
private async resolveRef(ref: string, context: ResolveContext): Promise<unknown> {
const { source, pointer } = this.splitRef(ref, context.currentSource);
const cacheKey = `${source}#${pointer}`;

Expand All @@ -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);
Expand All @@ -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 };
}

Expand Down
Loading
Loading