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