Exposes the `flows` object on `components.securitySchemes` through the preview pipeline so the onboarding UI can offer an OAuth2 path for any spec that declares one. Previously we detected `type: oauth2` but silently dropped the flow metadata, leaving users to paste a bearer token manually. Schema additions: - `OAuth2AuthorizationCodeFlow`, `OAuth2ClientCredentialsFlow`, and `OAuth2Flows` classes capturing `authorizationUrl`, `tokenUrl`, `refreshUrl`, and `scopes` per flow. `implicit` and `password` are intentionally excluded — both are deprecated by OAuth 2.1 and adding them would only encourage users to onboard onto them. - `SecurityScheme` now carries `flows`, `bearerFormat`, and `openIdConnectUrl` (previously dropped). - `OAuth2Preset` class — one entry per (scheme × flow) pair with the label, URLs, and scope map the onboarding UI needs to render a "Connect via OAuth2" flow with a scope checklist. - `SpecPreview.oauth2Presets` carries the derived presets. Extraction: - `extractSecuritySchemes` now takes a `DocResolver` and resolves `$ref` entries instead of silently dropping them. Specs that alias their security schemes through `components.securitySchemes` refs (rare but real) now surface correctly. - `extractFlows` tolerates malformed flows (missing `tokenUrl` / `authorizationUrl`) by dropping them, so a broken flow does not prevent the rest of the spec from previewing. 5 new tests cover authorizationCode + clientCredentials extraction, the `$ref` resolution path, `bearerFormat` / `openIdConnectUrl` capture, and the malformed-flow ignore case.
Showing the changes to packages/plugins/openapi/src (3 of 3 files). Show all
Unified · Split
| @@ -15,6 +15,10 @@ export { · 14 unmodified lines | ||
| 15 | 15 | SecurityScheme, |
| 16 | 16 | AuthStrategy, |
| 17 | 17 | HeaderPreset, |
| 18 | + OAuth2Preset, | |
| 19 | + OAuth2Flows, | |
| 20 | + OAuth2AuthorizationCodeFlow, | |
| 21 | + OAuth2ClientCredentialsFlow, | |
| 18 | 22 | PreviewOperation, |
| 19 | 23 | SpecPreview, |
| 20 | 24 | } from "./preview"; |
| @@ -0,0 +1,192 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Tests for OAuth2 flow extraction in previewSpec. Covers: | |
| 3 | +// - authorizationCode flow extraction (URLs + scopes) | |
| 4 | +// - clientCredentials flow extraction | |
| 5 | +// - security schemes defined via $ref are no longer silently dropped | |
| 6 | +// - bearerFormat / openIdConnectUrl are captured | |
| 7 | +// - invalid flows (missing tokenUrl) are ignored | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | + | |
| 10 | +import { describe, expect, it } from "@effect/vitest"; | |
| 11 | +import { Effect, Option } from "effect"; | |
| 12 | + | |
| 13 | +import { previewSpec } from "./preview"; | |
| 14 | + | |
| 15 | +const minimalSpec = ( | |
| 16 | + securitySchemes: Record<string, unknown>, | |
| 17 | + components: Record<string, unknown> = {}, | |
| 18 | +) => ({ | |
| 19 | + openapi: "3.0.0", | |
| 20 | + info: { title: "Test API", version: "1.0.0" }, | |
| 21 | + servers: [{ url: "https://api.example.com" }], | |
| 22 | + paths: { | |
| 23 | + "/ping": { | |
| 24 | + get: { responses: { "200": { description: "ok" } } }, | |
| 25 | + }, | |
| 26 | + }, | |
| 27 | + components: { ...components, securitySchemes }, | |
| 28 | +}); | |
| 29 | + | |
| 30 | +describe("previewSpec OAuth2 extraction", () => { | |
| 31 | + it.effect("extracts authorizationCode flow with URLs, scopes, refreshUrl", () => | |
| 32 | + Effect.gen(function* () { | |
| 33 | + const spec = minimalSpec({ | |
| 34 | + oauth_app: { | |
| 35 | + type: "oauth2", | |
| 36 | + flows: { | |
| 37 | + authorizationCode: { | |
| 38 | + authorizationUrl: "https://example.com/oauth/authorize", | |
| 39 | + tokenUrl: "https://example.com/oauth/token", | |
| 40 | + refreshUrl: "https://example.com/oauth/refresh", | |
| 41 | + scopes: { | |
| 42 | + read: "Read access", | |
| 43 | + write: "Write access", | |
| 44 | + }, | |
| 45 | + }, | |
| 46 | + }, | |
| 47 | + }, | |
| 48 | + }); | |
| 49 | + const preview = yield* previewSpec(JSON.stringify(spec)); | |
| 50 | + | |
| 51 | + expect(preview.securitySchemes).toHaveLength(1); | |
| 52 | + const scheme = preview.securitySchemes[0]!; | |
| 53 | + expect(scheme.name).toBe("oauth_app"); | |
| 54 | + expect(scheme.type).toBe("oauth2"); | |
| 55 | + expect(Option.isSome(scheme.flows)).toBe(true); | |
| 56 | + const flows = Option.getOrThrow(scheme.flows); | |
| 57 | + expect(Option.isSome(flows.authorizationCode)).toBe(true); | |
| 58 | + const flow = Option.getOrThrow(flows.authorizationCode); | |
| 59 | + expect(flow.authorizationUrl).toBe("https://example.com/oauth/authorize"); | |
| 60 | + expect(flow.tokenUrl).toBe("https://example.com/oauth/token"); | |
| 61 | + expect(Option.getOrElse(flow.refreshUrl, () => "")).toBe( | |
| 62 | + "https://example.com/oauth/refresh", | |
| 63 | + ); | |
| 64 | + expect(flow.scopes).toEqual({ read: "Read access", write: "Write access" }); | |
| 65 | + | |
| 66 | + // A preset should be generated for this flow. | |
| 67 | + expect(preview.oauth2Presets).toHaveLength(1); | |
| 68 | + const preset = preview.oauth2Presets[0]!; | |
| 69 | + expect(preset.flow).toBe("authorizationCode"); | |
| 70 | + expect(preset.securitySchemeName).toBe("oauth_app"); | |
| 71 | + expect(preset.tokenUrl).toBe("https://example.com/oauth/token"); | |
| 72 | + expect(preset.label).toContain("Authorization Code"); | |
| 73 | + expect(preset.label).toContain("oauth_app"); | |
| 74 | + expect(preset.scopes).toEqual({ read: "Read access", write: "Write access" }); | |
| 75 | + }), | |
| 76 | + ); | |
| 77 | + | |
| 78 | + it.effect("extracts clientCredentials flow alongside authorizationCode", () => | |
| 79 | + Effect.gen(function* () { | |
| 80 | + const spec = minimalSpec({ | |
| 81 | + oauth_app: { | |
| 82 | + type: "oauth2", | |
| 83 | + flows: { | |
| 84 | + authorizationCode: { | |
| 85 | + authorizationUrl: "https://example.com/auth", | |
| 86 | + tokenUrl: "https://example.com/token", | |
| 87 | + scopes: {}, | |
| 88 | + }, | |
| 89 | + clientCredentials: { | |
| 90 | + tokenUrl: "https://example.com/token", | |
| 91 | + scopes: { "admin:read": "Admin read" }, | |
| 92 | + }, | |
| 93 | + }, | |
| 94 | + }, | |
| 95 | + }); | |
| 96 | + const preview = yield* previewSpec(JSON.stringify(spec)); | |
| 97 | + | |
| 98 | + expect(preview.oauth2Presets).toHaveLength(2); | |
| 99 | + const flowKinds = preview.oauth2Presets.map((p) => p.flow).sort(); | |
| 100 | + expect(flowKinds).toEqual(["authorizationCode", "clientCredentials"]); | |
| 101 | + | |
| 102 | + const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!; | |
| 103 | + expect(Option.isNone(cc.authorizationUrl)).toBe(true); | |
| 104 | + expect(cc.scopes).toEqual({ "admin:read": "Admin read" }); | |
| 105 | + }), | |
| 106 | + ); | |
| 107 | + | |
| 108 | + it.effect("skips authorizationCode flow missing tokenUrl or authorizationUrl", () => | |
| 109 | + Effect.gen(function* () { | |
| 110 | + const spec = minimalSpec({ | |
| 111 | + broken: { | |
| 112 | + type: "oauth2", | |
| 113 | + flows: { | |
| 114 | + authorizationCode: { | |
| 115 | + authorizationUrl: "https://example.com/auth", | |
| 116 | + // tokenUrl missing | |
| 117 | + scopes: {}, | |
| 118 | + }, | |
| 119 | + }, | |
| 120 | + }, | |
| 121 | + }); | |
| 122 | + const preview = yield* previewSpec(JSON.stringify(spec)); | |
| 123 | + | |
| 124 | + expect(preview.securitySchemes).toHaveLength(1); | |
| 125 | + // Scheme is still captured but with no flows. | |
| 126 | + expect(Option.isNone(preview.securitySchemes[0]!.flows)).toBe(true); | |
| 127 | + expect(preview.oauth2Presets).toHaveLength(0); | |
| 128 | + }), | |
| 129 | + ); | |
| 130 | + | |
| 131 | + it.effect("resolves security schemes defined via $ref", () => | |
| 132 | + Effect.gen(function* () { | |
| 133 | + const spec = minimalSpec( | |
| 134 | + { | |
| 135 | + api_token: { $ref: "#/components/securitySchemes/_api_token_impl" }, | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + securitySchemes: { | |
| 139 | + _api_token_impl: { | |
| 140 | + type: "http", | |
| 141 | + scheme: "bearer", | |
| 142 | + bearerFormat: "JWT", | |
| 143 | + description: "Internal token scheme", | |
| 144 | + }, | |
| 145 | + }, | |
| 146 | + }, | |
| 147 | + ); | |
| 148 | + // Note: the outer securitySchemes at `components.securitySchemes` is | |
| 149 | + // what previewSpec reads; the `_api_token_impl` shim inside | |
| 150 | + // components.securitySchemes allows $ref resolution via the resolver. | |
| 151 | + // The test spec above is slightly awkward because we have to nest both | |
| 152 | + // under the same key — adjust by merging. | |
| 153 | + spec.components = { | |
| 154 | + securitySchemes: { | |
| 155 | + api_token: { $ref: "#/components/securitySchemes/_api_token_impl" }, | |
| 156 | + _api_token_impl: { | |
| 157 | + type: "http", | |
| 158 | + scheme: "bearer", | |
| 159 | + bearerFormat: "JWT", | |
| 160 | + description: "Internal token scheme", | |
| 161 | + }, | |
| 162 | + }, | |
| 163 | + }; | |
| 164 | + | |
| 165 | + const preview = yield* previewSpec(JSON.stringify(spec)); | |
| 166 | + // Both keys are present, but the `api_token` entry should resolve to | |
| 167 | + // the http bearer scheme (previously it was silently dropped). | |
| 168 | + const apiToken = preview.securitySchemes.find((s) => s.name === "api_token"); | |
| 169 | + expect(apiToken).toBeDefined(); | |
| 170 | + expect(apiToken!.type).toBe("http"); | |
| 171 | + expect(Option.getOrElse(apiToken!.scheme, () => "")).toBe("bearer"); | |
| 172 | + expect(Option.getOrElse(apiToken!.bearerFormat, () => "")).toBe("JWT"); | |
| 173 | + }), | |
| 174 | + ); | |
| 175 | + | |
| 176 | + it.effect("captures openIdConnectUrl for openIdConnect schemes", () => | |
| 177 | + Effect.gen(function* () { | |
| 178 | + const spec = minimalSpec({ | |
| 179 | + oidc: { | |
| 180 | + type: "openIdConnect", | |
| 181 | + openIdConnectUrl: "https://example.com/.well-known/openid-configuration", | |
| 182 | + }, | |
| 183 | + }); | |
| 184 | + const preview = yield* previewSpec(JSON.stringify(spec)); | |
| 185 | + const scheme = preview.securitySchemes[0]!; | |
| 186 | + expect(scheme.type).toBe("openIdConnect"); | |
| 187 | + expect(Option.getOrElse(scheme.openIdConnectUrl, () => "")).toBe( | |
| 188 | + "https://example.com/.well-known/openid-configuration", | |
| 189 | + ); | |
| 190 | + }), | |
| 191 | + ); | |
| 192 | +}); | |
| @@ -1,10 +1,41 @@ | ||
| 1 | 1 | import { Effect, Option } from "effect"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | |
| 4 | −import { parse } from "./parse"; | |
| 4 | +import { parse, type ParsedDocument } from "./parse"; | |
| 5 | 5 | import { extract } from "./extract"; |
| 6 | +import { DocResolver } from "./openapi-utils"; | |
| 6 | 7 | import { HttpMethod, ServerInfo, type ExtractionResult } from "./types"; |
| 7 | 8 | |
| 9 | + | |
| 10 | +// --------------------------------------------------------------------------- | |
| 11 | +// OAuth 2.0 flows — one entry per supported grant type | |
| 12 | +// --------------------------------------------------------------------------- | |
| 13 | + | |
| 14 | +/** Scopes declared by a flow: `{ scopeName: description }` */ | |
| 15 | +const OAuth2Scopes = Schema.Record({ key: Schema.String, value: Schema.String }); | |
| 16 | + | |
| 17 | +export class OAuth2AuthorizationCodeFlow extends Schema.Class<OAuth2AuthorizationCodeFlow>( | |
| 18 | + "OAuth2AuthorizationCodeFlow", | |
| 19 | +)({ | |
| 20 | + authorizationUrl: Schema.String, | |
| 21 | + tokenUrl: Schema.String, | |
| 22 | + refreshUrl: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 23 | + scopes: OAuth2Scopes, | |
| 24 | +}) {} | |
| 25 | + | |
| 26 | +export class OAuth2ClientCredentialsFlow extends Schema.Class<OAuth2ClientCredentialsFlow>( | |
| 27 | + "OAuth2ClientCredentialsFlow", | |
| 28 | +)({ | |
| 29 | + tokenUrl: Schema.String, | |
| 30 | + refreshUrl: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 31 | + scopes: OAuth2Scopes, | |
| 32 | +}) {} | |
| 33 | + | |
| 34 | +export class OAuth2Flows extends Schema.Class<OAuth2Flows>("OAuth2Flows")({ | |
| 35 | + authorizationCode: Schema.optionalWith(OAuth2AuthorizationCodeFlow, { as: "Option" }), | |
| 36 | + clientCredentials: Schema.optionalWith(OAuth2ClientCredentialsFlow, { as: "Option" }), | |
| 37 | +}) {} | |
| 38 | + | |
| 8 | 39 | // --------------------------------------------------------------------------- |
| 9 | 40 | // Security scheme — what the spec declares it needs |
| 10 | 41 | // --------------------------------------------------------------------------- |
| @@ -16,11 +47,17 @@ export class SecurityScheme extends Schema.Class<SecurityScheme>("SecurityScheme · 5 unmodified lines | ||
| 16 | 47 | type: Schema.Literal("http", "apiKey", "oauth2", "openIdConnect"), |
| 17 | 48 | /** For type: "http" — e.g. "bearer", "basic" */ |
| 18 | 49 | scheme: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 50 | + /** For type: "http" with scheme "bearer" — e.g. "JWT" */ | |
| 51 | + bearerFormat: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 19 | 52 | /** For type: "apiKey" — where the key goes */ |
| 20 | 53 | in: Schema.optionalWith(Schema.Literal("header", "query", "cookie"), { as: "Option" }), |
| 21 | 54 | /** For type: "apiKey" — the header/query/cookie name */ |
| 22 | 55 | headerName: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 23 | 56 | description: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 57 | + /** For type: "oauth2" — declared flows (authorizationCode / clientCredentials only; implicit and password are deprecated). */ | |
| 58 | + flows: Schema.optionalWith(OAuth2Flows, { as: "Option" }), | |
| 59 | + /** For type: "openIdConnect" — the discovery URL. */ | |
| 60 | + openIdConnectUrl: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 24 | 61 | }) {} |
| 25 | 62 | |
| 26 | 63 | // --------------------------------------------------------------------------- |
| @@ -45,6 +82,27 @@ export class HeaderPreset extends Schema.Class<HeaderPreset>("HeaderPreset")({ · 18 unmodified lines | ||
| 45 | 82 | secretHeaders: Schema.Array(Schema.String), |
| 46 | 83 | }) {} |
| 47 | 84 | |
| 85 | +// --------------------------------------------------------------------------- | |
| 86 | +// OAuth2 preset — derived from an oauth2 security scheme + a flow choice | |
| 87 | +// --------------------------------------------------------------------------- | |
| 88 | + | |
| 89 | +export class OAuth2Preset extends Schema.Class<OAuth2Preset>("OAuth2Preset")({ | |
| 90 | + /** Human-readable label for the UI (e.g. "OAuth2 (Authorization Code) — oauth_app") */ | |
| 91 | + label: Schema.String, | |
| 92 | + /** The source security scheme this preset came from (components.securitySchemes key). */ | |
| 93 | + securitySchemeName: Schema.String, | |
| 94 | + /** Which OAuth2 flow this preset uses. */ | |
| 95 | + flow: Schema.Literal("authorizationCode", "clientCredentials"), | |
| 96 | + /** For authorizationCode: user-agent redirect URL (from the spec). */ | |
| 97 | + authorizationUrl: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 98 | + /** Token endpoint to exchange the code / refresh. */ | |
| 99 | + tokenUrl: Schema.String, | |
| 100 | + /** Optional refresh endpoint if the spec declares one separately. */ | |
| 101 | + refreshUrl: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 102 | + /** Declared scopes for this flow: `{ scope: description }`. */ | |
| 103 | + scopes: Schema.Record({ key: Schema.String, value: Schema.String }), | |
| 104 | +}) {} | |
| 105 | + | |
| 48 | 106 | // --------------------------------------------------------------------------- |
| 49 | 107 | // Preview operation — lightweight shape for the add-source UI list |
| 50 | 108 | // --------------------------------------------------------------------------- |
| @@ -76,17 +134,87 @@ export class SpecPreview extends Schema.Class<SpecPreview>("SpecPreview")({ · 25 unmodified lines | ||
| 76 | 134 | authStrategies: Schema.Array(AuthStrategy), |
| 77 | 135 | /** Pre-built header presets derived from auth strategies */ |
| 78 | 136 | headerPresets: Schema.Array(HeaderPreset), |
| 137 | + /** OAuth2 presets — one per (oauth2 scheme × supported flow) combination */ | |
| 138 | + oauth2Presets: Schema.Array(OAuth2Preset), | |
| 79 | 139 | }) {} |
| 80 | 140 | |
| 81 | 141 | // --------------------------------------------------------------------------- |
| 82 | 142 | // Security scheme extraction |
| 83 | 143 | // --------------------------------------------------------------------------- |
| 84 | 144 | |
| 85 | −const extractSecuritySchemes = (rawSchemes: Record<string, unknown>): SecurityScheme[] => | |
| 145 | +const stringRecord = (value: unknown): Record<string, string> => { | |
| 146 | + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; | |
| 147 | + const out: Record<string, string> = {}; | |
| 148 | + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | |
| 149 | + if (typeof v === "string") out[k] = v; | |
| 150 | + } | |
| 151 | + return out; | |
| 152 | +}; | |
| 153 | + | |
| 154 | +const extractFlows = (rawFlows: unknown): Option.Option<OAuth2Flows> => { | |
| 155 | + if (!rawFlows || typeof rawFlows !== "object") return Option.none(); | |
| 156 | + const flows = rawFlows as Record<string, unknown>; | |
| 157 | + | |
| 158 | + const parseFlow = <K extends "authorizationCode" | "clientCredentials">( | |
| 159 | + key: K, | |
| 160 | + ): unknown => flows[key]; | |
| 161 | + | |
| 162 | + let authorizationCode: Option.Option<OAuth2AuthorizationCodeFlow> = Option.none(); | |
| 163 | + const authCodeRaw = parseFlow("authorizationCode"); | |
| 164 | + if (authCodeRaw && typeof authCodeRaw === "object") { | |
| 165 | + const f = authCodeRaw as Record<string, unknown>; | |
| 166 | + const authUrl = typeof f.authorizationUrl === "string" ? f.authorizationUrl : null; | |
| 167 | + const tokenUrl = typeof f.tokenUrl === "string" ? f.tokenUrl : null; | |
| 168 | + if (authUrl && tokenUrl) { | |
| 169 | + authorizationCode = Option.some( | |
| 170 | + new OAuth2AuthorizationCodeFlow({ | |
| 171 | + authorizationUrl: authUrl, | |
| 172 | + tokenUrl, | |
| 173 | + refreshUrl: Option.fromNullable( | |
| 174 | + typeof f.refreshUrl === "string" ? f.refreshUrl : undefined, | |
| 175 | + ), | |
| 176 | + scopes: stringRecord(f.scopes), | |
| 177 | + }), | |
| 178 | + ); | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + let clientCredentials: Option.Option<OAuth2ClientCredentialsFlow> = Option.none(); | |
| 183 | + const ccRaw = parseFlow("clientCredentials"); | |
| 184 | + if (ccRaw && typeof ccRaw === "object") { | |
| 185 | + const f = ccRaw as Record<string, unknown>; | |
| 186 | + const tokenUrl = typeof f.tokenUrl === "string" ? f.tokenUrl : null; | |
| 187 | + if (tokenUrl) { | |
| 188 | + clientCredentials = Option.some( | |
| 189 | + new OAuth2ClientCredentialsFlow({ | |
| 190 | + tokenUrl, | |
| 191 | + refreshUrl: Option.fromNullable( | |
| 192 | + typeof f.refreshUrl === "string" ? f.refreshUrl : undefined, | |
| 193 | + ), | |
| 194 | + scopes: stringRecord(f.scopes), | |
| 195 | + }), | |
| 196 | + ); | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + if (Option.isNone(authorizationCode) && Option.isNone(clientCredentials)) { | |
| 201 | + return Option.none(); | |
| 202 | + } | |
| 203 | + return Option.some(new OAuth2Flows({ authorizationCode, clientCredentials })); | |
| 204 | +}; | |
| 205 | + | |
| 206 | +const extractSecuritySchemes = ( | |
| 207 | + rawSchemes: Record<string, unknown>, | |
| 208 | + resolver: DocResolver, | |
| 209 | +): SecurityScheme[] => | |
| 86 | 210 | Object.entries(rawSchemes).flatMap(([name, schemeOrRef]) => { |
| 87 | 211 | if (!schemeOrRef || typeof schemeOrRef !== "object") return []; |
| 88 | − const scheme = schemeOrRef as Record<string, unknown>; | |
| 89 | − if ("$ref" in scheme) return []; | |
| 212 | + // Resolve $ref so schemes defined via `$ref` aren't silently dropped. | |
| 213 | + const resolved = resolver.resolve<Record<string, unknown>>( | |
| 214 | + schemeOrRef as Record<string, unknown>, | |
| 215 | + ); | |
| 216 | + if (!resolved || typeof resolved !== "object") return []; | |
| 217 | + const scheme = resolved; | |
| 90 | 218 | |
| 91 | 219 | const type = scheme.type as string; |
| 92 | 220 | if (!["http", "apiKey", "oauth2", "openIdConnect"].includes(type)) return []; |
| @@ -96,9 +224,14 @@ const extractSecuritySchemes = (rawSchemes: Record<string, unknown>): SecuritySc · 3 unmodified lines | ||
| 96 | 224 | name, |
| 97 | 225 | type: type as "http" | "apiKey" | "oauth2" | "openIdConnect", |
| 98 | 226 | scheme: Option.fromNullable(scheme.scheme as string | undefined), |
| 227 | + bearerFormat: Option.fromNullable(scheme.bearerFormat as string | undefined), | |
| 99 | 228 | in: Option.fromNullable(scheme.in as "header" | "query" | "cookie" | undefined), |
| 100 | 229 | headerName: Option.fromNullable(scheme.name as string | undefined), |
| 101 | 230 | description: Option.fromNullable(scheme.description as string | undefined), |
| 231 | + flows: type === "oauth2" ? extractFlows(scheme.flows) : Option.none(), | |
| 232 | + openIdConnectUrl: Option.fromNullable( | |
| 233 | + scheme.openIdConnectUrl as string | undefined, | |
| 234 | + ), | |
| 102 | 235 | }), |
| 103 | 236 | ]; |
| 104 | 237 | }); |
| @@ -153,6 +286,50 @@ const buildHeaderPresets = ( · 48 unmodified lines | ||
| 153 | 286 | }); |
| 154 | 287 | }; |
| 155 | 288 | |
| 289 | +// --------------------------------------------------------------------------- | |
| 290 | +// OAuth2 preset builder | |
| 291 | +// --------------------------------------------------------------------------- | |
| 292 | + | |
| 293 | +const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] => { | |
| 294 | + const presets: OAuth2Preset[] = []; | |
| 295 | + for (const scheme of schemes) { | |
| 296 | + if (scheme.type !== "oauth2") continue; | |
| 297 | + if (Option.isNone(scheme.flows)) continue; | |
| 298 | + const flows = scheme.flows.value; | |
| 299 | + | |
| 300 | + if (Option.isSome(flows.authorizationCode)) { | |
| 301 | + const flow = flows.authorizationCode.value; | |
| 302 | + presets.push( | |
| 303 | + new OAuth2Preset({ | |
| 304 | + label: `OAuth2 Authorization Code · ${scheme.name}`, | |
| 305 | + securitySchemeName: scheme.name, | |
| 306 | + flow: "authorizationCode", | |
| 307 | + authorizationUrl: Option.some(flow.authorizationUrl), | |
| 308 | + tokenUrl: flow.tokenUrl, | |
| 309 | + refreshUrl: flow.refreshUrl, | |
| 310 | + scopes: flow.scopes, | |
| 311 | + }), | |
| 312 | + ); | |
| 313 | + } | |
| 314 | + | |
| 315 | + if (Option.isSome(flows.clientCredentials)) { | |
| 316 | + const flow = flows.clientCredentials.value; | |
| 317 | + presets.push( | |
| 318 | + new OAuth2Preset({ | |
| 319 | + label: `OAuth2 Client Credentials · ${scheme.name}`, | |
| 320 | + securitySchemeName: scheme.name, | |
| 321 | + flow: "clientCredentials", | |
| 322 | + authorizationUrl: Option.none(), | |
| 323 | + tokenUrl: flow.tokenUrl, | |
| 324 | + refreshUrl: flow.refreshUrl, | |
| 325 | + scopes: flow.scopes, | |
| 326 | + }), | |
| 327 | + ); | |
| 328 | + } | |
| 329 | + } | |
| 330 | + return presets; | |
| 331 | +}; | |
| 332 | + | |
| 156 | 333 | // --------------------------------------------------------------------------- |
| 157 | 334 | // Collect unique tags from extraction result |
| 158 | 335 | // --------------------------------------------------------------------------- |
| @@ -172,10 +349,14 @@ const collectTags = (result: ExtractionResult): string[] => { · 13 unmodified lines | ||
| 172 | 349 | /** Preview an OpenAPI spec — extract metadata without registering anything. |
| 173 | 350 | * Reuses parse() + extract() for the heavy lifting. */ |
| 174 | 351 | export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: string) { |
| 175 | − const doc = yield* parse(specText); | |
| 352 | + const doc: ParsedDocument = yield* parse(specText); | |
| 176 | 353 | const result = yield* extract(doc); |
| 177 | 354 | |
| 178 | − const securitySchemes = extractSecuritySchemes(doc.components?.securitySchemes ?? {}); | |
| 355 | + const resolver = new DocResolver(doc); | |
| 356 | + const securitySchemes = extractSecuritySchemes( | |
| 357 | + doc.components?.securitySchemes ?? {}, | |
| 358 | + resolver, | |
| 359 | + ); | |
| 179 | 360 | |
| 180 | 361 | const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>; |
| 181 | 362 | const authStrategies = rawSecurity.map( |
| @@ -202,5 +383,6 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: · 20 unmodified lines | ||
| 202 | 383 | securitySchemes, |
| 203 | 384 | authStrategies, |
| 204 | 385 | headerPresets: buildHeaderPresets(securitySchemes, authStrategies), |
| 386 | + oauth2Presets: buildOAuth2Presets(securitySchemes), | |
| 205 | 387 | }); |
| 206 | 388 | }); |