History

feat: previewSpec + headers-based auth

Replace AuthConfig union (NoAuth/BearerAuth/ApiKeyAuth) with plain headers on InvocationConfig. Auth is just headers now — simpler and covers all cases. New previewSpec(): - Reuses parse() + extract() for heavy lifting - Extracts security schemes from components.securitySchemes - Derives auth strategies from top-level security array - Builds header presets for the frontend (which headers to show, which are secrets) - Returns metadata: title, version, servers, operation count, tags Tested against Cloudflare spec: 4 security schemes, 3 auth strategies, correct header presets (Bearer Token, API Key + Email, etc.) 19 tests passing, all typechecks clean.

Rhys Sullivan committed 7 months ago

Commit 673821a1 parent a2e062d7 files · +287 −66Browse files

Unified · Split

7 files
  1. packages/plugins/openapi/src/index.ts +7 −4
  2. packages/plugins/openapi/src/invoke.ts +13 −30
  3. packages/plugins/openapi/src/plugin.test.ts +20 −0
  4. packages/plugins/openapi/src/plugin.ts +13 −4
  5. packages/plugins/openapi/src/preview.ts +192 −0
  6. packages/plugins/openapi/src/real-specs.test.ts +37 −0
  7. packages/plugins/openapi/src/types.ts +5 −28
packages/plugins/openapi/src/index.ts+7−4
@@ -10,6 +10,13 @@ export { · 9 unmodified lines
1010 makeInMemoryOperationStore,
1111 type OpenApiOperationStore,
1212 } from "./operation-store";
13+export {
14+ previewSpec,
15+ SecurityScheme,
16+ AuthStrategy,
17+ HeaderPreset,
18+ SpecPreview,
19+} from "./preview";
1320 export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
1421
1522 export {
@@ -19,10 +26,6 @@ export { · 3 unmodified lines
1926 } from "./errors";
2027
2128 export {
22− AuthConfig,
23− NoAuth,
24− BearerAuth,
25− ApiKeyAuth,
2629 ExtractedOperation,
2730 ExtractionResult,
2831 InvocationConfig,
packages/plugins/openapi/src/invoke.ts+13−30
@@ -1,4 +1,4 @@
1−import { Effect, Layer, Match, Option } from "effect";
1+import { Effect, Layer, Option } from "effect";
22 import {
33 HttpClient,
44 HttpClientRequest,
@@ -14,7 +14,6 @@ import { · 9 unmodified lines
1414 import { OpenApiInvocationError } from "./errors";
1515 import type { OpenApiOperationStore } from "./operation-store";
1616 import {
17− type AuthConfig,
1817 type OperationBinding,
1918 InvocationConfig,
2019 InvocationResult,
@@ -117,35 +116,19 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 96 unmodified lines
117116 });
118117
119118 // ---------------------------------------------------------------------------
120−// Auth application
119+// Header application
121120 // ---------------------------------------------------------------------------
122121
123−const applyAuth = (
122+const applyHeaders = (
124123 request: HttpClientRequest.HttpClientRequest,
125− auth: AuthConfig,
126−): HttpClientRequest.HttpClientRequest =>
127− Match.valueTags(auth, {
128− NoAuth: () => request,
129− BearerAuth: ({ token, headerName, prefix }) =>
130− HttpClientRequest.setHeader(request, headerName, `${prefix}${token}`),
131− ApiKeyAuth: ({ name, value, in: location }) => {
132− if (location === "header") {
133− return HttpClientRequest.setHeader(request, name, value);
134− }
135− if (location === "query") {
136− return HttpClientRequest.setUrlParam(request, name, value);
137− }
138− // cookie
139− const existing =
140− request.headers["cookie"] ?? "";
141− const cookie = `${name}=${encodeURIComponent(value)}`;
142− return HttpClientRequest.setHeader(
143− request,
144− "cookie",
145− existing ? `${existing}; ${cookie}` : cookie,
146− );
147− },
148− });
124+ headers: Record<string, string>,
125+): HttpClientRequest.HttpClientRequest => {
126+ let req = request;
127+ for (const [name, value] of Object.entries(headers)) {
128+ req = HttpClientRequest.setHeader(req, name, value);
129+ }
130+ return req;
131+};
149132
150133 // ---------------------------------------------------------------------------
151134 // Response helpers
@@ -221,8 +204,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 69 unmodified lines
221204 }
222205 }
223206
224− // Auth
225− request = applyAuth(request, config.auth);
207+ // Static headers (auth, custom headers, etc.)
208+ request = applyHeaders(request, config.headers);
226209
227210 // Execute
228211 const response = yield* client.execute(request).pipe(
packages/plugins/openapi/src/plugin.test.ts+20−0
@@ -78,6 +78,26 @@ const TestLayer = HttpApiBuilder.serve().pipe( · 77 unmodified lines
7878 // ---------------------------------------------------------------------------
7979
8080 layer(TestLayer)("OpenAPI Plugin", (it) => {
81+ it.effect("previewSpec returns metadata and header presets", () =>
82+ Effect.gen(function* () {
83+ const httpClient = yield* HttpClient.HttpClient;
84+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
85+
86+ const executor = yield* createExecutor(
87+ makeTestConfig({
88+ plugins: [
89+ openApiPlugin({ httpClientLayer: clientLayer }),
90+ ] as const,
91+ }),
92+ );
93+
94+ const preview = yield* executor.openapi.previewSpec(specJson);
95+
96+ expect(preview.operationCount).toBeGreaterThanOrEqual(2);
97+ expect(preview.servers).toBeDefined();
98+ }),
99+ );
100+
81101 it.effect("registers tools from an OpenAPI spec", () =>
82102 Effect.gen(function* () {
83103 const httpClient = yield* HttpClient.HttpClient;
packages/plugins/openapi/src/plugin.ts+13−4
@@ -18,11 +18,10 @@ import { · 17 unmodified lines
1818 makeInMemoryOperationStore,
1919 type OpenApiOperationStore,
2020 } from "./operation-store";
21+import { previewSpec, type SpecPreview } from "./preview";
2122 import {
22− type AuthConfig,
2323 type ExtractedOperation,
2424 InvocationConfig,
25− NoAuth,
2625 OperationBinding,
2726 } from "./types";
2827
@@ -34,7 +33,8 @@ export interface OpenApiSpecConfig { · 5 unmodified lines
3433 readonly spec: string;
3534 readonly baseUrl?: string;
3635 readonly namespace?: string;
37− readonly auth?: AuthConfig;
36+ /** Static headers applied to every request for this spec */
37+ readonly headers?: Record<string, string>;
3838 }
3939
4040 // ---------------------------------------------------------------------------
@@ -42,10 +42,17 @@ export interface OpenApiSpecConfig { · 1 unmodified line
4242 // ---------------------------------------------------------------------------
4343
4444 export interface OpenApiPluginExtension {
45+ /** Preview a spec without registering — returns metadata, auth strategies, header presets */
46+ readonly previewSpec: (
47+ specText: string,
48+ ) => Effect.Effect<SpecPreview, Error>;
49+
50+ /** Add an OpenAPI spec and register its operations as tools */
4551 readonly addSpec: (
4652 config: OpenApiSpecConfig,
4753 ) => Effect.Effect<{ readonly toolCount: number }, Error>;
4854
55+ /** Remove all tools from a previously added spec by namespace */
4956 readonly removeSpec: (namespace: string) => Effect.Effect<void>;
5057 }
5158
@@ -103,6 +110,8 @@ export const openApiPlugin = (options?: { · 51 unmodified lines
103110
104111 return {
105112 extension: {
113+ previewSpec: (specText: string) => previewSpec(specText),
114+
106115 addSpec: (config: OpenApiSpecConfig) =>
107116 Effect.gen(function* () {
108117 const doc = yield* parse(config.spec);
@@ -121,7 +130,7 @@ export const openApiPlugin = (options?: { · 12 unmodified lines
121130 const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
122131 const invocationConfig = new InvocationConfig({
123132 baseUrl,
124− auth: config.auth ?? new NoAuth(),
133+ headers: config.headers ?? {},
125134 });
126135
127136 const registrations = result.operations.map((op) =>
packages/plugins/openapi/src/preview.ts+192−0
@@ -0,0 +1,192 @@
1+import { Effect, Option } from "effect";
2+import { Schema } from "effect";
3+
4+import { parse } from "./parse";
5+import { extract } from "./extract";
6+import type { ExtractionResult } from "./types";
7+
8+// ---------------------------------------------------------------------------
9+// Security scheme — what the spec declares it needs
10+// ---------------------------------------------------------------------------
11+
12+export class SecurityScheme extends Schema.Class<SecurityScheme>("SecurityScheme")({
13+ /** Key name in components.securitySchemes (e.g. "api_token") */
14+ name: Schema.String,
15+ /** OpenAPI security scheme type */
16+ type: Schema.Literal("http", "apiKey", "oauth2", "openIdConnect"),
17+ /** For type: "http" — e.g. "bearer", "basic" */
18+ scheme: Schema.optionalWith(Schema.String, { as: "Option" }),
19+ /** For type: "apiKey" — where the key goes */
20+ in: Schema.optionalWith(Schema.Literal("header", "query", "cookie"), { as: "Option" }),
21+ /** For type: "apiKey" — the header/query/cookie name */
22+ headerName: Schema.optionalWith(Schema.String, { as: "Option" }),
23+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
24+}) {}
25+
26+// ---------------------------------------------------------------------------
27+// Auth strategy — a valid combination of security schemes
28+// ---------------------------------------------------------------------------
29+
30+export class AuthStrategy extends Schema.Class<AuthStrategy>("AuthStrategy")({
31+ /** The security schemes required together for this strategy */
32+ schemes: Schema.Array(Schema.String),
33+}) {}
34+
35+// ---------------------------------------------------------------------------
36+// Header preset — derived from an auth strategy
37+// ---------------------------------------------------------------------------
38+
39+export class HeaderPreset extends Schema.Class<HeaderPreset>("HeaderPreset")({
40+ /** Human-readable label for the UI (e.g. "Bearer Token", "API Key + Email") */
41+ label: Schema.String,
42+ /** Headers this strategy needs. Value is null when the user must provide it. */
43+ headers: Schema.Record({ key: Schema.String, value: Schema.NullOr(Schema.String) }),
44+ /** Which headers should be stored as secrets */
45+ secretHeaders: Schema.Array(Schema.String),
46+}) {}
47+
48+// ---------------------------------------------------------------------------
49+// Spec preview — everything the frontend needs
50+// ---------------------------------------------------------------------------
51+
52+export class SpecPreview extends Schema.Class<SpecPreview>("SpecPreview")({
53+ title: Schema.optionalWith(Schema.String, { as: "Option" }),
54+ version: Schema.optionalWith(Schema.String, { as: "Option" }),
55+ /** Reuses ServerInfo from extraction */
56+ servers: Schema.Array(Schema.Unknown),
57+ operationCount: Schema.Number,
58+ tags: Schema.Array(Schema.String),
59+ securitySchemes: Schema.Array(SecurityScheme),
60+ /** Valid auth strategies (each is a set of schemes used together) */
61+ authStrategies: Schema.Array(AuthStrategy),
62+ /** Pre-built header presets derived from auth strategies */
63+ headerPresets: Schema.Array(HeaderPreset),
64+}) {}
65+
66+// ---------------------------------------------------------------------------
67+// Security scheme extraction
68+// ---------------------------------------------------------------------------
69+
70+const extractSecuritySchemes = (
71+ rawSchemes: Record<string, unknown>,
72+): SecurityScheme[] =>
73+ Object.entries(rawSchemes).flatMap(([name, schemeOrRef]) => {
74+ if (!schemeOrRef || typeof schemeOrRef !== "object") return [];
75+ const scheme = schemeOrRef as Record<string, unknown>;
76+ if ("$ref" in scheme) return [];
77+
78+ const type = scheme.type as string;
79+ if (!["http", "apiKey", "oauth2", "openIdConnect"].includes(type)) return [];
80+
81+ return [
82+ new SecurityScheme({
83+ name,
84+ type: type as "http" | "apiKey" | "oauth2" | "openIdConnect",
85+ scheme: Option.fromNullable(scheme.scheme as string | undefined),
86+ in: Option.fromNullable(
87+ scheme.in as "header" | "query" | "cookie" | undefined,
88+ ),
89+ headerName: Option.fromNullable(scheme.name as string | undefined),
90+ description: Option.fromNullable(scheme.description as string | undefined),
91+ }),
92+ ];
93+ });
94+
95+// ---------------------------------------------------------------------------
96+// Header preset builder
97+// ---------------------------------------------------------------------------
98+
99+const buildHeaderPresets = (
100+ schemes: readonly SecurityScheme[],
101+ strategies: readonly AuthStrategy[],
102+): HeaderPreset[] => {
103+ const schemeMap = new Map(schemes.map((s) => [s.name, s]));
104+
105+ return strategies.flatMap((strategy) => {
106+ const resolved = strategy.schemes
107+ .map((name) => schemeMap.get(name))
108+ .filter((s): s is SecurityScheme => s !== undefined);
109+
110+ if (resolved.length === 0) return [];
111+
112+ const headers: Record<string, string | null> = {};
113+ const secretHeaders: string[] = [];
114+ const labelParts: string[] = [];
115+
116+ for (const scheme of resolved) {
117+ if (scheme.type === "http" && Option.getOrElse(scheme.scheme, () => "") === "bearer") {
118+ headers["Authorization"] = null;
119+ secretHeaders.push("Authorization");
120+ labelParts.push("Bearer Token");
121+ } else if (scheme.type === "http" && Option.getOrElse(scheme.scheme, () => "") === "basic") {
122+ headers["Authorization"] = null;
123+ secretHeaders.push("Authorization");
124+ labelParts.push("Basic Auth");
125+ } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "header") {
126+ const headerName = Option.getOrElse(scheme.headerName, () => scheme.name);
127+ headers[headerName] = null;
128+ secretHeaders.push(headerName);
129+ labelParts.push(scheme.name);
130+ } else if (scheme.type === "apiKey") {
131+ labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);
132+ } else {
133+ labelParts.push(scheme.name);
134+ }
135+ }
136+
137+ if (Object.keys(headers).length === 0 && resolved.length > 0) {
138+ return [
139+ new HeaderPreset({ label: labelParts.join(" + "), headers: {}, secretHeaders: [] }),
140+ ];
141+ }
142+
143+ return [
144+ new HeaderPreset({ label: labelParts.join(" + "), headers, secretHeaders }),
145+ ];
146+ });
147+};
148+
149+// ---------------------------------------------------------------------------
150+// Collect unique tags from extraction result
151+// ---------------------------------------------------------------------------
152+
153+const collectTags = (result: ExtractionResult): string[] => {
154+ const tagSet = new Set<string>();
155+ for (const op of result.operations) {
156+ for (const tag of op.tags) tagSet.add(tag);
157+ }
158+ return [...tagSet].sort();
159+};
160+
161+// ---------------------------------------------------------------------------
162+// Public API
163+// ---------------------------------------------------------------------------
164+
165+/** Preview an OpenAPI spec — extract metadata without registering anything.
166+ * Reuses parse() + extract() for the heavy lifting. */
167+export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (
168+ specText: string,
169+) {
170+ const doc = yield* parse(specText);
171+ const result = yield* extract(doc);
172+
173+ const securitySchemes = extractSecuritySchemes(
174+ doc.components?.securitySchemes ?? {},
175+ );
176+
177+ const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>;
178+ const authStrategies = rawSecurity.map(
179+ (entry) => new AuthStrategy({ schemes: Object.keys(entry) }),
180+ );
181+
182+ return new SpecPreview({
183+ title: result.title,
184+ version: result.version,
185+ servers: result.servers as unknown as readonly unknown[],
186+ operationCount: result.operations.length,
187+ tags: collectTags(result),
188+ securitySchemes,
189+ authStrategies,
190+ headerPresets: buildHeaderPresets(securitySchemes, authStrategies),
191+ });
192+});
packages/plugins/openapi/src/real-specs.test.ts+37−0
@@ -6,6 +6,7 @@ import { resolve } from "node:path"; · 5 unmodified lines
66 import type { ParsedDocument } from "./parse";
77 import { parse } from "./parse";
88 import { extract } from "./extract";
9+import { previewSpec } from "./preview";
910 import type { ExtractionResult } from "./types";
1011 import { createExecutor, makeTestConfig } from "@executor/sdk";
1112 import { openApiPlugin } from "./plugin";
@@ -158,6 +159,42 @@ describe("Real specs: Cloudflare API", () => { · 146 unmodified lines
158159 }),
159160 );
160161
162+ it.effect("previewSpec returns security schemes and header presets", () =>
163+ Effect.gen(function* () {
164+ const preview = yield* previewSpec(specText);
165+
166+ expect(preview.operationCount).toBeGreaterThan(1000);
167+ expect(Option.isSome(preview.title)).toBe(true);
168+ expect(preview.servers.length).toBeGreaterThan(0);
169+
170+ // Cloudflare has 4 security schemes
171+ expect(preview.securitySchemes.length).toBe(4);
172+ const schemeNames = preview.securitySchemes.map((s) => s.name);
173+ expect(schemeNames).toContain("api_token");
174+ expect(schemeNames).toContain("api_key");
175+ expect(schemeNames).toContain("api_email");
176+
177+ // Should have header presets derived from security strategies
178+ expect(preview.headerPresets.length).toBeGreaterThan(0);
179+
180+ // Bearer token preset should include Authorization header
181+ const bearerPreset = preview.headerPresets.find((p) =>
182+ p.label.includes("Bearer"),
183+ );
184+ expect(bearerPreset).toBeDefined();
185+ expect(bearerPreset!.headers["Authorization"]).toBeNull(); // user must provide
186+ expect(bearerPreset!.secretHeaders).toContain("Authorization");
187+
188+ // API key + email preset
189+ const keyEmailPreset = preview.headerPresets.find((p) =>
190+ p.label.includes("api_email"),
191+ );
192+ expect(keyEmailPreset).toBeDefined();
193+ expect(keyEmailPreset!.headers["X-Auth-Email"]).toBeNull();
194+ expect(keyEmailPreset!.headers["X-Auth-Key"]).toBeNull();
195+ }),
196+ );
197+
161198 it.effect("removeSpec cleans up all Cloudflare tools", () =>
162199 Effect.gen(function* () {
163200 const executor = yield* createExecutor(
packages/plugins/openapi/src/types.ts+5−28
@@ -7,33 +7,6 @@ import { Schema } from "effect"; · 6 unmodified lines
77 export const OperationId = Schema.String.pipe(Schema.brand("OperationId"));
88 export type OperationId = typeof OperationId.Type;
99
10−// ---------------------------------------------------------------------------
11−// Auth
12−// ---------------------------------------------------------------------------
13−
14−export class NoAuth extends Schema.TaggedClass<NoAuth>()("NoAuth", {}) {}
15−
16−export class BearerAuth extends Schema.TaggedClass<BearerAuth>()(
17− "BearerAuth",
18− {
19− token: Schema.String,
20− headerName: Schema.optionalWith(Schema.String, { default: () => "Authorization" }),
21− prefix: Schema.optionalWith(Schema.String, { default: () => "Bearer " }),
22− },
23−) {}
24−
25−export class ApiKeyAuth extends Schema.TaggedClass<ApiKeyAuth>()(
26− "ApiKeyAuth",
27− {
28− name: Schema.String,
29− value: Schema.String,
30− in: Schema.Literal("header", "query", "cookie"),
31− },
32−) {}
33−
34−export const AuthConfig = Schema.Union(NoAuth, BearerAuth, ApiKeyAuth);
35−export type AuthConfig = typeof AuthConfig.Type;
36−
3710 // ---------------------------------------------------------------------------
3811 // HTTP
3912 // ---------------------------------------------------------------------------
@@ -125,7 +98,11 @@ export class InvocationConfig extends Schema.Class<InvocationConfig>( · 85 unmodified lines
12598 "InvocationConfig",
12699 )({
127100 baseUrl: Schema.String,
128− auth: AuthConfig,
101+ /** Static headers applied to every request (auth, custom headers, etc.) */
102+ headers: Schema.optionalWith(
103+ Schema.Record({ key: Schema.String, value: Schema.String }),
104+ { default: () => ({}) },
105+ ),
129106 }) {}
130107
131108 export class InvocationResult extends Schema.Class<InvocationResult>(