136 lines · 4.7 KB · TypeScript
RawDownload
import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types";import { Duration, Effect, Schema } from "effect";import { HttpClient, HttpClientRequest } from "effect/unstable/http";import YAML from "yaml"; import { OpenApiExtractionError, OpenApiParseError } from "./errors"; export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document; export interface SpecFetchCredentials {  readonly headers?: Record<string, string>;  readonly queryParams?: Record<string, string>;} // ExtractionError subclass raised from parse() for non-3.x specsclass OpenApiExtractionErrorFromParse extends OpenApiExtractionError {} /** * Fetch an OpenAPI spec URL and return its body text. Uses the Effect * HttpClient so the caller chooses the transport via layer — in Cloudflare * Workers, `FetchHttpClient.layer` binds to the Workers-native `fetch`. * Bounded by a 60s timeout. */export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* (  url: string,  credentials?: SpecFetchCredentials,) {  const client = yield* HttpClient.HttpClient;  const requestUrl = new URL(url);  for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {    requestUrl.searchParams.set(name, value);  }  let request = HttpClientRequest.get(requestUrl.toString()).pipe(    HttpClientRequest.setHeader("Accept", "application/json, application/yaml, text/yaml, */*"),  );  for (const [name, value] of Object.entries(credentials?.headers ?? {})) {    request = HttpClientRequest.setHeader(request, name, value);  }  const response = yield* client.execute(request).pipe(    Effect.timeout(Duration.seconds(60)),    Effect.mapError(      (_cause) =>        new OpenApiParseError({          message: "Failed to fetch OpenAPI document",        }),    ),  );  if (response.status < 200 || response.status >= 300) {    return yield* new OpenApiParseError({      message: `Failed to fetch OpenAPI document: HTTP ${response.status}`,    });  }  return yield* response.text.pipe(    Effect.mapError(      (_cause) =>        new OpenApiParseError({          message: "Failed to read OpenAPI document body",        }),    ),  );}); /** * Resolve an input string to spec text — if it's a URL, fetch it via * HttpClient; otherwise return it as-is. */export const resolveSpecText = (input: string, credentials?: SpecFetchCredentials) =>  input.startsWith("http://") || input.startsWith("https://")    ? fetchSpecText(input, credentials)    : Effect.succeed(input); /** * Parse an OpenAPI document from spec text and validate it's OpenAPI 3.x. * * NOTE: does NOT resolve `$ref`s. `DocResolver` + `normalizeOpenApiRefs` * downstream work on refs lazily, so inlining them here would just waste * memory — and for big specs (e.g. Cloudflare's API) that blows through * the 128MB Cloudflare Workers memory cap. */export const parse = Effect.fn("OpenApi.parse")(function* (text: string) {  const api = yield* parseTextToObject(text);   if (!isOpenApi3(api)) {    return yield* new OpenApiExtractionErrorFromParse({      message:        "Only OpenAPI 3.x documents are supported. Swagger 2.x documents should be converted first.",    });  }   return api as ParsedDocument;}); // ---------------------------------------------------------------------------// Internals// --------------------------------------------------------------------------- const isOpenApi3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document | OpenAPIV3_1.Document =>  "openapi" in doc && typeof doc.openapi === "string" && doc.openapi.startsWith("3."); const parseTextToObject = (text: string): Effect.Effect<OpenAPI.Document, OpenApiParseError> =>  Effect.gen(function* () {    const trimmed = text.trim();    if (trimmed.length === 0) {      return yield* new OpenApiParseError({        message: "OpenAPI document is empty",      });    }     const parsed = yield* parseJsonLike(trimmed).pipe(      Effect.mapError(        () =>          new OpenApiParseError({            message: "Failed to parse OpenAPI document",          }),      ),    );     if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {      return yield* new OpenApiParseError({        message: "OpenAPI document must parse to an object",      });    }     return parsed as OpenAPI.Document;  }); const parseJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); const parseJsonLike = (text: string): Effect.Effect<unknown, unknown> => {  const parseYaml = Effect.try({    try: () => YAML.parse(text) as unknown,    catch: () => "YamlParseFailed" as const,  });  if (!text.startsWith("{") && !text.startsWith("[")) return parseYaml;  return parseJsonText(text).pipe(Effect.catch(() => parseYaml));};