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