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
10 makeInMemoryOperationStore,10 makeInMemoryOperationStore,
11 type OpenApiOperationStore,11 type OpenApiOperationStore,
12} from "./operation-store";12} from "./operation-store";
13export {
14 previewSpec,
15 SecurityScheme,
16 AuthStrategy,
17 HeaderPreset,
18 SpecPreview,
19} from "./preview";
13export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";20export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
14 21
15export {22export {
@@ -19,10 +26,6 @@ export { · 3 unmodified lines
19} from "./errors";26} from "./errors";
20 27
21export {28export {
22 AuthConfig,
23 NoAuth,
24 BearerAuth,
25 ApiKeyAuth,
26 ExtractedOperation,29 ExtractedOperation,
27 ExtractionResult,30 ExtractionResult,
28 InvocationConfig,31 InvocationConfig,
packages/plugins/openapi/src/invoke.ts+13−30
@@ -1,4 +1,4 @@
1import { Effect, Layer, Match, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import {2import {
3 HttpClient,3 HttpClient,
4 HttpClientRequest,4 HttpClientRequest,
@@ -14,7 +14,6 @@ import { · 9 unmodified lines
14import { OpenApiInvocationError } from "./errors";14import { OpenApiInvocationError } from "./errors";
15import type { OpenApiOperationStore } from "./operation-store";15import type { OpenApiOperationStore } from "./operation-store";
16import {16import {
17 type AuthConfig,
18 type OperationBinding,17 type OperationBinding,
19 InvocationConfig,18 InvocationConfig,
20 InvocationResult,19 InvocationResult,
@@ -117,35 +116,19 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 96 unmodified lines
117});116});
118 117
119// ---------------------------------------------------------------------------118// ---------------------------------------------------------------------------
120// Auth application119// Header application
121// ---------------------------------------------------------------------------120// ---------------------------------------------------------------------------
122 121
123const applyAuth = (122const applyHeaders = (
124 request: HttpClientRequest.HttpClientRequest,123 request: HttpClientRequest.HttpClientRequest,
125 auth: AuthConfig,124 headers: Record<string, string>,
126): HttpClientRequest.HttpClientRequest =>125): HttpClientRequest.HttpClientRequest => {
127 Match.valueTags(auth, {126 let req = request;
128 NoAuth: () => request,127 for (const [name, value] of Object.entries(headers)) {
129 BearerAuth: ({ token, headerName, prefix }) =>128 req = HttpClientRequest.setHeader(req, name, value);
130 HttpClientRequest.setHeader(request, headerName, `${prefix}${token}`),129 }
131 ApiKeyAuth: ({ name, value, in: location }) => {130 return req;
132 if (location === "header") {131};
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 });
149 132
150// ---------------------------------------------------------------------------133// ---------------------------------------------------------------------------
151// Response helpers134// Response helpers
@@ -221,8 +204,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 69 unmodified lines
221 }204 }
222 }205 }
223 206
224 // Auth207 // Static headers (auth, custom headers, etc.)
225 request = applyAuth(request, config.auth);208 request = applyHeaders(request, config.headers);
226 209
227 // Execute210 // Execute
228 const response = yield* client.execute(request).pipe(211 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
78// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
79 79
80layer(TestLayer)("OpenAPI Plugin", (it) => {80layer(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
81 it.effect("registers tools from an OpenAPI spec", () =>101 it.effect("registers tools from an OpenAPI spec", () =>
82 Effect.gen(function* () {102 Effect.gen(function* () {
83 const httpClient = yield* HttpClient.HttpClient;103 const httpClient = yield* HttpClient.HttpClient;
packages/plugins/openapi/src/plugin.ts+13−4
@@ -18,11 +18,10 @@ import { · 17 unmodified lines
18 makeInMemoryOperationStore,18 makeInMemoryOperationStore,
19 type OpenApiOperationStore,19 type OpenApiOperationStore,
20} from "./operation-store";20} from "./operation-store";
21import { previewSpec, type SpecPreview } from "./preview";
21import {22import {
22 type AuthConfig,
23 type ExtractedOperation,23 type ExtractedOperation,
24 InvocationConfig,24 InvocationConfig,
25 NoAuth,
26 OperationBinding,25 OperationBinding,
27} from "./types";26} from "./types";
28 27
@@ -34,7 +33,8 @@ export interface OpenApiSpecConfig { · 5 unmodified lines
34 readonly spec: string;33 readonly spec: string;
35 readonly baseUrl?: string;34 readonly baseUrl?: string;
36 readonly namespace?: string;35 readonly namespace?: string;
37 readonly auth?: AuthConfig;36 /** Static headers applied to every request for this spec */
37 readonly headers?: Record<string, string>;
38}38}
39 39
40// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
@@ -42,10 +42,17 @@ export interface OpenApiSpecConfig { · 1 unmodified line
42// ---------------------------------------------------------------------------42// ---------------------------------------------------------------------------
43 43
44export interface OpenApiPluginExtension {44export 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 */
45 readonly addSpec: (51 readonly addSpec: (
46 config: OpenApiSpecConfig,52 config: OpenApiSpecConfig,
47 ) => Effect.Effect<{ readonly toolCount: number }, Error>;53 ) => Effect.Effect<{ readonly toolCount: number }, Error>;
48 54
55 /** Remove all tools from a previously added spec by namespace */
49 readonly removeSpec: (namespace: string) => Effect.Effect<void>;56 readonly removeSpec: (namespace: string) => Effect.Effect<void>;
50}57}
51 58
@@ -103,6 +110,8 @@ export const openApiPlugin = (options?: { · 51 unmodified lines
103 110
104 return {111 return {
105 extension: {112 extension: {
113 previewSpec: (specText: string) => previewSpec(specText),
114
106 addSpec: (config: OpenApiSpecConfig) =>115 addSpec: (config: OpenApiSpecConfig) =>
107 Effect.gen(function* () {116 Effect.gen(function* () {
108 const doc = yield* parse(config.spec);117 const doc = yield* parse(config.spec);
@@ -121,7 +130,7 @@ export const openApiPlugin = (options?: { · 12 unmodified lines
121 const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);130 const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
122 const invocationConfig = new InvocationConfig({131 const invocationConfig = new InvocationConfig({
123 baseUrl,132 baseUrl,
124 auth: config.auth ?? new NoAuth(),133 headers: config.headers ?? {},
125 });134 });
126 135
127 const registrations = result.operations.map((op) =>136 const registrations = result.operations.map((op) =>
packages/plugins/openapi/src/preview.ts+192−0
@@ -0,0 +1,192 @@
1import { Effect, Option } from "effect";
2import { Schema } from "effect";
3
4import { parse } from "./parse";
5import { extract } from "./extract";
6import type { ExtractionResult } from "./types";
7
8// ---------------------------------------------------------------------------
9// Security scheme — what the spec declares it needs
10// ---------------------------------------------------------------------------
11
12export 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
30export 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
39export 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
52export 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
70const 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
99const 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
153const 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. */
167export 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
6import type { ParsedDocument } from "./parse";6import type { ParsedDocument } from "./parse";
7import { parse } from "./parse";7import { parse } from "./parse";
8import { extract } from "./extract";8import { extract } from "./extract";
9import { previewSpec } from "./preview";
9import type { ExtractionResult } from "./types";10import type { ExtractionResult } from "./types";
10import { createExecutor, makeTestConfig } from "@executor/sdk";11import { createExecutor, makeTestConfig } from "@executor/sdk";
11import { openApiPlugin } from "./plugin";12import { openApiPlugin } from "./plugin";
@@ -158,6 +159,42 @@ describe("Real specs: Cloudflare API", () => { · 146 unmodified lines
158 }),159 }),
159 );160 );
160 161
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
161 it.effect("removeSpec cleans up all Cloudflare tools", () =>198 it.effect("removeSpec cleans up all Cloudflare tools", () =>
162 Effect.gen(function* () {199 Effect.gen(function* () {
163 const executor = yield* createExecutor(200 const executor = yield* createExecutor(
packages/plugins/openapi/src/types.ts+5−28
@@ -7,33 +7,6 @@ import { Schema } from "effect"; · 6 unmodified lines
7export const OperationId = Schema.String.pipe(Schema.brand("OperationId"));7export const OperationId = Schema.String.pipe(Schema.brand("OperationId"));
8export type OperationId = typeof OperationId.Type;8export type OperationId = typeof OperationId.Type;
9 9
10// ---------------------------------------------------------------------------
11// Auth
12// ---------------------------------------------------------------------------
13
14export class NoAuth extends Schema.TaggedClass<NoAuth>()("NoAuth", {}) {}
15
16export 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
25export 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
34export const AuthConfig = Schema.Union(NoAuth, BearerAuth, ApiKeyAuth);
35export type AuthConfig = typeof AuthConfig.Type;
36
37// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
38// HTTP11// HTTP
39// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
@@ -125,7 +98,11 @@ export class InvocationConfig extends Schema.Class<InvocationConfig>( · 85 unmodified lines
125 "InvocationConfig",98 "InvocationConfig",
126)({99)({
127 baseUrl: Schema.String,100 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 ),
129}) {}106}) {}
130 107
131export class InvocationResult extends Schema.Class<InvocationResult>(108export class InvocationResult extends Schema.Class<InvocationResult>(