419 lines · 16.5 KB · TypeScript
RawDownload
import { Effect, Option, Predicate } from "effect";import { Schema } from "effect"; import { parse, resolveSpecText, type ParsedDocument } from "./parse";import { extract } from "./extract";import { DocResolver } from "./openapi-utils";import { HttpMethod, ServerInfo, type ExtractionResult } from "./types"; // ---------------------------------------------------------------------------// OAuth 2.0 flows — one entry per supported grant type// --------------------------------------------------------------------------- /** Scopes declared by a flow: `{ scopeName: description }` */const OAuth2Scopes = Schema.Record(Schema.String, Schema.String);const SecuritySchemeType = Schema.Literals(["http", "apiKey", "oauth2", "openIdConnect"]);type SecuritySchemeType = typeof SecuritySchemeType.Type; const decodeSecuritySchemeType = Schema.decodeUnknownOption(SecuritySchemeType); export const OAuth2AuthorizationCodeFlow = Schema.Struct({  authorizationUrl: Schema.String,  tokenUrl: Schema.String,  refreshUrl: Schema.OptionFromOptional(Schema.String),  scopes: OAuth2Scopes,});export type OAuth2AuthorizationCodeFlow = typeof OAuth2AuthorizationCodeFlow.Type; export const OAuth2ClientCredentialsFlow = Schema.Struct({  tokenUrl: Schema.String,  refreshUrl: Schema.OptionFromOptional(Schema.String),  scopes: OAuth2Scopes,});export type OAuth2ClientCredentialsFlow = typeof OAuth2ClientCredentialsFlow.Type; export const OAuth2Flows = Schema.Struct({  authorizationCode: Schema.OptionFromOptional(OAuth2AuthorizationCodeFlow),  clientCredentials: Schema.OptionFromOptional(OAuth2ClientCredentialsFlow),});export type OAuth2Flows = typeof OAuth2Flows.Type; // ---------------------------------------------------------------------------// Security scheme — what the spec declares it needs// --------------------------------------------------------------------------- export const SecurityScheme = Schema.Struct({  /** Key name in components.securitySchemes (e.g. "api_token") */  name: Schema.String,  /** OpenAPI security scheme type */  type: SecuritySchemeType,  /** For type: "http" — e.g. "bearer", "basic" */  scheme: Schema.OptionFromOptional(Schema.String),  /** For type: "http" with scheme "bearer" — e.g. "JWT" */  bearerFormat: Schema.OptionFromOptional(Schema.String),  /** For type: "apiKey" — where the key goes */  in: Schema.OptionFromOptional(Schema.Literals(["header", "query", "cookie"])),  /** For type: "apiKey" — the header/query/cookie name */  headerName: Schema.OptionFromOptional(Schema.String),  description: Schema.OptionFromOptional(Schema.String),  /** For type: "oauth2" — declared flows (authorizationCode / clientCredentials only; implicit and password are deprecated). */  flows: Schema.OptionFromOptional(OAuth2Flows),  /** For type: "openIdConnect" — the discovery URL. */  openIdConnectUrl: Schema.OptionFromOptional(Schema.String),});export type SecurityScheme = typeof SecurityScheme.Type; // ---------------------------------------------------------------------------// Auth strategy — a valid combination of security schemes// --------------------------------------------------------------------------- export const AuthStrategy = Schema.Struct({  /** The security schemes required together for this strategy */  schemes: Schema.Array(Schema.String),});export type AuthStrategy = typeof AuthStrategy.Type; // ---------------------------------------------------------------------------// Header preset — derived from an auth strategy// --------------------------------------------------------------------------- export const HeaderPreset = Schema.Struct({  /** Human-readable label for the UI (e.g. "Bearer Token", "API Key + Email") */  label: Schema.String,  /** Headers this strategy needs. Value is null when the user must provide it. */  headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),  /** Which headers should be stored as secrets */  secretHeaders: Schema.Array(Schema.String),});export type HeaderPreset = typeof HeaderPreset.Type; // ---------------------------------------------------------------------------// OAuth2 preset — derived from an oauth2 security scheme + a flow choice// --------------------------------------------------------------------------- export const OAuth2Preset = Schema.Struct({  /** Human-readable label for the UI (e.g. "OAuth2 (Authorization Code) — oauth_app") */  label: Schema.String,  /** The source security scheme this preset came from (components.securitySchemes key). */  securitySchemeName: Schema.String,  /** Which OAuth2 flow this preset uses. */  flow: Schema.Literals(["authorizationCode", "clientCredentials"]),  /** For authorizationCode: user-agent redirect URL (from the spec). */  authorizationUrl: Schema.OptionFromOptional(Schema.String),  /** Token endpoint to exchange the code / refresh. */  tokenUrl: Schema.String,  /** Optional refresh endpoint if the spec declares one separately. */  refreshUrl: Schema.OptionFromOptional(Schema.String),  /** Declared scopes for this flow: `{ scope: description }`. */  scopes: Schema.Record(Schema.String, Schema.String),  /** Identity scopes to request alongside API scopes. `"auto"` discovers standard OIDC scopes. */  identityScopes: Schema.Union([    Schema.Literal("auto"),    Schema.Literal(false),    Schema.Array(Schema.String),  ]),});export type OAuth2Preset = typeof OAuth2Preset.Type; // ---------------------------------------------------------------------------// Preview operation — lightweight shape for the add-source UI list// --------------------------------------------------------------------------- export const PreviewOperation = Schema.Struct({  operationId: Schema.String,  method: HttpMethod,  path: Schema.String,  summary: Schema.OptionFromOptional(Schema.String),  tags: Schema.Array(Schema.String),  deprecated: Schema.Boolean,});export type PreviewOperation = typeof PreviewOperation.Type; // ---------------------------------------------------------------------------// Spec preview — everything the frontend needs// --------------------------------------------------------------------------- export const SpecPreview = Schema.Struct({  title: Schema.OptionFromOptional(Schema.String),  version: Schema.OptionFromOptional(Schema.String),  /** Reuses ServerInfo from extraction */  servers: Schema.Array(ServerInfo),  operationCount: Schema.Number,  /** Lightweight operation list for the add-source UI */  operations: Schema.Array(PreviewOperation),  tags: Schema.Array(Schema.String),  securitySchemes: Schema.Array(SecurityScheme),  /** Valid auth strategies (each is a set of schemes used together) */  authStrategies: Schema.Array(AuthStrategy),  /** Pre-built header presets derived from auth strategies */  headerPresets: Schema.Array(HeaderPreset),  /** OAuth2 presets — one per (oauth2 scheme × supported flow) combination */  oauth2Presets: Schema.Array(OAuth2Preset),});export type SpecPreview = typeof SpecPreview.Type; // ---------------------------------------------------------------------------// Security scheme extraction// --------------------------------------------------------------------------- const stringRecord = (value: unknown): Record<string, string> => {  if (!value || typeof value !== "object" || Array.isArray(value)) return {};  const out: Record<string, string> = {};  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {    if (typeof v === "string") out[k] = v;  }  return out;}; const extractFlows = (rawFlows: unknown): Option.Option<OAuth2Flows> => {  if (!rawFlows || typeof rawFlows !== "object") return Option.none();  const flows = rawFlows as Record<string, unknown>;   const parseFlow = <K extends "authorizationCode" | "clientCredentials">(key: K): unknown =>    flows[key];   let authorizationCode: Option.Option<OAuth2AuthorizationCodeFlow> = Option.none();  const authCodeRaw = parseFlow("authorizationCode");  if (authCodeRaw && typeof authCodeRaw === "object") {    const f = authCodeRaw as Record<string, unknown>;    const authUrl = typeof f.authorizationUrl === "string" ? f.authorizationUrl : null;    const tokenUrl = typeof f.tokenUrl === "string" ? f.tokenUrl : null;    if (authUrl && tokenUrl) {      authorizationCode = Option.some(        OAuth2AuthorizationCodeFlow.make({          authorizationUrl: authUrl,          tokenUrl,          refreshUrl: Option.fromNullishOr(            typeof f.refreshUrl === "string" ? f.refreshUrl : undefined,          ),          scopes: stringRecord(f.scopes),        }),      );    }  }   let clientCredentials: Option.Option<OAuth2ClientCredentialsFlow> = Option.none();  const ccRaw = parseFlow("clientCredentials");  if (ccRaw && typeof ccRaw === "object") {    const f = ccRaw as Record<string, unknown>;    const tokenUrl = typeof f.tokenUrl === "string" ? f.tokenUrl : null;    if (tokenUrl) {      clientCredentials = Option.some(        OAuth2ClientCredentialsFlow.make({          tokenUrl,          refreshUrl: Option.fromNullishOr(            typeof f.refreshUrl === "string" ? f.refreshUrl : undefined,          ),          scopes: stringRecord(f.scopes),        }),      );    }  }   if (Option.isNone(authorizationCode) && Option.isNone(clientCredentials)) {    return Option.none();  }  return Option.some(OAuth2Flows.make({ authorizationCode, clientCredentials }));}; const extractSecuritySchemes = (  rawSchemes: Record<string, unknown>,  resolver: DocResolver,): SecurityScheme[] =>  Object.entries(rawSchemes).flatMap(([name, schemeOrRef]) => {    if (!schemeOrRef || typeof schemeOrRef !== "object") return [];    // Resolve $ref so schemes defined via `$ref` aren't silently dropped.    const resolved = resolver.resolve<Record<string, unknown>>(      schemeOrRef as Record<string, unknown>,    );    if (!resolved || typeof resolved !== "object") return [];    const scheme = resolved;     const type = decodeSecuritySchemeType(scheme.type);    if (Option.isNone(type)) return [];    const schemeType = type.value;     return [      SecurityScheme.make({        name,        type: schemeType,        scheme: Option.fromNullishOr(scheme.scheme as string | undefined),        bearerFormat: Option.fromNullishOr(scheme.bearerFormat as string | undefined),        in: Option.fromNullishOr(scheme.in as "header" | "query" | "cookie" | undefined),        headerName: Option.fromNullishOr(scheme.name as string | undefined),        description: Option.fromNullishOr(scheme.description as string | undefined),        flows: schemeType === "oauth2" ? extractFlows(scheme.flows) : Option.none(),        openIdConnectUrl: Option.fromNullishOr(scheme.openIdConnectUrl as string | undefined),      }),    ];  }); // ---------------------------------------------------------------------------// Header preset builder// --------------------------------------------------------------------------- const buildHeaderPresets = (  schemes: readonly SecurityScheme[],  strategies: readonly AuthStrategy[],): HeaderPreset[] => {  const schemeMap = new Map(schemes.map((s) => [s.name, s]));   return strategies.flatMap((strategy) => {    const resolved = strategy.schemes      .map((name) => schemeMap.get(name))      .filter(Predicate.isNotUndefined);     if (resolved.length === 0) return [];     const headers: Record<string, string | null> = {};    const secretHeaders: string[] = [];    const labelParts: string[] = [];     for (const scheme of resolved) {      if (scheme.type === "http" && Option.getOrElse(scheme.scheme, () => "") === "bearer") {        headers["Authorization"] = null;        secretHeaders.push("Authorization");        labelParts.push("Bearer Token");      } else if (scheme.type === "http" && Option.getOrElse(scheme.scheme, () => "") === "basic") {        headers["Authorization"] = null;        secretHeaders.push("Authorization");        labelParts.push("Basic Auth");      } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "header") {        const headerName = Option.getOrElse(scheme.headerName, () => scheme.name);        headers[headerName] = null;        secretHeaders.push(headerName);        labelParts.push(scheme.name);      } else if (scheme.type === "apiKey") {        labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);      } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {        return [];      } else {        labelParts.push(scheme.name);      }    }     if (Object.keys(headers).length === 0 && resolved.length > 0) {      return [        HeaderPreset.make({          label: labelParts.join(" + "),          headers: {},          secretHeaders: [],        }),      ];    }     return [      HeaderPreset.make({        label: labelParts.join(" + "),        headers,        secretHeaders,      }),    ];  });}; // ---------------------------------------------------------------------------// OAuth2 preset builder// --------------------------------------------------------------------------- const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] => {  const presets: OAuth2Preset[] = [];  for (const scheme of schemes) {    if (scheme.type !== "oauth2") continue;    if (Option.isNone(scheme.flows)) continue;    const flows = scheme.flows.value;     if (Option.isSome(flows.authorizationCode)) {      const flow = flows.authorizationCode.value;      presets.push(        OAuth2Preset.make({          label: `OAuth2 Authorization Code · ${scheme.name}`,          securitySchemeName: scheme.name,          flow: "authorizationCode",          authorizationUrl: Option.some(flow.authorizationUrl),          tokenUrl: flow.tokenUrl,          refreshUrl: flow.refreshUrl,          scopes: flow.scopes,          identityScopes: "auto",        }),      );    }     if (Option.isSome(flows.clientCredentials)) {      const flow = flows.clientCredentials.value;      presets.push(        OAuth2Preset.make({          label: `OAuth2 Client Credentials · ${scheme.name}`,          securitySchemeName: scheme.name,          flow: "clientCredentials",          authorizationUrl: Option.none(),          tokenUrl: flow.tokenUrl,          refreshUrl: flow.refreshUrl,          scopes: flow.scopes,          identityScopes: false,        }),      );    }  }  return presets;}; // ---------------------------------------------------------------------------// Collect unique tags from extraction result// --------------------------------------------------------------------------- const collectTags = (result: ExtractionResult): string[] => {  const tagSet = new Set<string>();  for (const op of result.operations) {    for (const tag of op.tags) tagSet.add(tag);  }  return [...tagSet].sort();}; // ---------------------------------------------------------------------------// Public API// --------------------------------------------------------------------------- /** Preview an OpenAPI spec — extract metadata without registering anything. *  Accepts either a URL or raw JSON/YAML text. */export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) {  const specText = yield* resolveSpecText(input);  const doc: ParsedDocument = yield* parse(specText);  const result = yield* extract(doc);   const resolver = new DocResolver(doc);  const securitySchemes = extractSecuritySchemes(doc.components?.securitySchemes ?? {}, resolver);   const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>;  const declaredStrategies = rawSecurity.map((entry) =>    AuthStrategy.make({ schemes: Object.keys(entry) }),  );  // Fall back to one strategy per scheme when the spec only declares schemes  // under components (e.g. Sentry) so the user still sees auth options.  const authStrategies =    declaredStrategies.length > 0      ? declaredStrategies      : securitySchemes.map((scheme) => AuthStrategy.make({ schemes: [scheme.name] }));   return SpecPreview.make({    title: result.title,    version: result.version,    servers: result.servers,    operationCount: result.operations.length,    operations: result.operations.map((op) =>      PreviewOperation.make({        operationId: op.operationId,        method: op.method,        path: op.pathTemplate,        summary: op.summary,        tags: op.tags,        deprecated: op.deprecated,      }),    ),    tags: collectTags(result),    securitySchemes,    authStrategies,    headerPresets: buildHeaderPresets(securitySchemes, authStrategies),    oauth2Presets: buildOAuth2Presets(securitySchemes),  });});