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 | SecurityScheme, | 15 | SecurityScheme, |
| 16 | AuthStrategy, | 16 | AuthStrategy, |
| 17 | HeaderPreset, | 17 | HeaderPreset, |
| 18 | OAuth2Preset, | ||
| 19 | OAuth2Flows, | ||
| 20 | OAuth2AuthorizationCodeFlow, | ||
| 21 | OAuth2ClientCredentialsFlow, | ||
| 18 | PreviewOperation, | 22 | PreviewOperation, |
| 19 | SpecPreview, | 23 | SpecPreview, |
| 20 | } from "./preview"; | 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 | import { Effect, Option } from "effect"; | 1 | import { Effect, Option } from "effect"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { parse } from "./parse"; | 4 | import { parse, type ParsedDocument } from "./parse"; |
| 5 | import { extract } from "./extract"; | 5 | import { extract } from "./extract"; |
| 6 | import { DocResolver } from "./openapi-utils"; | ||
| 6 | import { HttpMethod, ServerInfo, type ExtractionResult } from "./types"; | 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 | // Security scheme — what the spec declares it needs | 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 | type: Schema.Literal("http", "apiKey", "oauth2", "openIdConnect"), | 47 | type: Schema.Literal("http", "apiKey", "oauth2", "openIdConnect"), |
| 17 | /** For type: "http" — e.g. "bearer", "basic" */ | 48 | /** For type: "http" — e.g. "bearer", "basic" */ |
| 18 | scheme: Schema.optionalWith(Schema.String, { as: "Option" }), | 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 | /** For type: "apiKey" — where the key goes */ | 52 | /** For type: "apiKey" — where the key goes */ |
| 20 | in: Schema.optionalWith(Schema.Literal("header", "query", "cookie"), { as: "Option" }), | 53 | in: Schema.optionalWith(Schema.Literal("header", "query", "cookie"), { as: "Option" }), |
| 21 | /** For type: "apiKey" — the header/query/cookie name */ | 54 | /** For type: "apiKey" — the header/query/cookie name */ |
| 22 | headerName: Schema.optionalWith(Schema.String, { as: "Option" }), | 55 | headerName: Schema.optionalWith(Schema.String, { as: "Option" }), |
| 23 | description: Schema.optionalWith(Schema.String, { as: "Option" }), | 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 | secretHeaders: Schema.Array(Schema.String), | 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 | // Preview operation — lightweight shape for the add-source UI list | 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 | authStrategies: Schema.Array(AuthStrategy), | 134 | authStrategies: Schema.Array(AuthStrategy), |
| 77 | /** Pre-built header presets derived from auth strategies */ | 135 | /** Pre-built header presets derived from auth strategies */ |
| 78 | headerPresets: Schema.Array(HeaderPreset), | 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 | // Security scheme extraction | 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 | Object.entries(rawSchemes).flatMap(([name, schemeOrRef]) => { | 210 | Object.entries(rawSchemes).flatMap(([name, schemeOrRef]) => { |
| 87 | if (!schemeOrRef || typeof schemeOrRef !== "object") return []; | 211 | if (!schemeOrRef || typeof schemeOrRef !== "object") return []; |
| 88 | const scheme = schemeOrRef as Record<string, unknown>; | 212 | // Resolve $ref so schemes defined via `$ref` aren't silently dropped. |
| 89 | if ("$ref" in scheme) return []; | 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 | const type = scheme.type as string; | 219 | const type = scheme.type as string; |
| 92 | if (!["http", "apiKey", "oauth2", "openIdConnect"].includes(type)) return []; | 220 | if (!["http", "apiKey", "oauth2", "openIdConnect"].includes(type)) return []; |
| @@ -96,9 +224,14 @@ const extractSecuritySchemes = (rawSchemes: Record<string, unknown>): SecuritySc · 3 unmodified lines | |||
| 96 | name, | 224 | name, |
| 97 | type: type as "http" | "apiKey" | "oauth2" | "openIdConnect", | 225 | type: type as "http" | "apiKey" | "oauth2" | "openIdConnect", |
| 98 | scheme: Option.fromNullable(scheme.scheme as string | undefined), | 226 | scheme: Option.fromNullable(scheme.scheme as string | undefined), |
| 227 | bearerFormat: Option.fromNullable(scheme.bearerFormat as string | undefined), | ||
| 99 | in: Option.fromNullable(scheme.in as "header" | "query" | "cookie" | undefined), | 228 | in: Option.fromNullable(scheme.in as "header" | "query" | "cookie" | undefined), |
| 100 | headerName: Option.fromNullable(scheme.name as string | undefined), | 229 | headerName: Option.fromNullable(scheme.name as string | undefined), |
| 101 | description: Option.fromNullable(scheme.description as string | undefined), | 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 | // Collect unique tags from extraction result | 334 | // Collect unique tags from extraction result |
| 158 | // --------------------------------------------------------------------------- | 335 | // --------------------------------------------------------------------------- |
| @@ -172,10 +349,14 @@ const collectTags = (result: ExtractionResult): string[] => { · 13 unmodified lines | |||
| 172 | /** Preview an OpenAPI spec — extract metadata without registering anything. | 349 | /** Preview an OpenAPI spec — extract metadata without registering anything. |
| 173 | * Reuses parse() + extract() for the heavy lifting. */ | 350 | * Reuses parse() + extract() for the heavy lifting. */ |
| 174 | export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: string) { | 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 | const result = yield* extract(doc); | 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 | const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>; | 361 | const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>; |
| 181 | const authStrategies = rawSecurity.map( | 362 | const authStrategies = rawSecurity.map( |
| @@ -202,5 +383,6 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: · 20 unmodified lines | |||
| 202 | securitySchemes, | 383 | securitySchemes, |
| 203 | authStrategies, | 384 | authStrategies, |
| 204 | headerPresets: buildHeaderPresets(securitySchemes, authStrategies), | 385 | headerPresets: buildHeaderPresets(securitySchemes, authStrategies), |
| 386 | oauth2Presets: buildOAuth2Presets(securitySchemes), | ||
| 205 | }); | 387 | }); |
| 206 | }); | 388 | }); |