Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
"typecheck:slow": "bunx tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"@effect/platform": "catalog:",
"@effect/platform-node": "catalog:",
"@executor/config": "workspace:*",
Expand Down
180 changes: 65 additions & 115 deletions packages/plugins/openapi/src/sdk/parse.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,80 @@
import SwaggerParser from "@apidevtools/swagger-parser";
import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types";
import { Effect } from "effect";
import { Duration, Effect } from "effect";
import { HttpClient, HttpClientRequest } from "@effect/platform";
import YAML from "yaml";

import { OpenApiParseError } from "./errors";
import { OpenApiExtractionError, OpenApiParseError } from "./errors";

export type ParsedDocument = OpenAPIV3.Document | OpenAPIV3_1.Document;

/** Parse, validate, and bundle an OpenAPI document from text or URL */
export const parse = Effect.fn("OpenApi.parse")(function* (input: string) {
const api: OpenAPI.Document = yield* Effect.tryPromise({
try: async () => {
const source =
input.startsWith("http://") || input.startsWith("https://")
? input
: parseTextToObject(input);
// ExtractionError subclass raised from parse() for non-3.x specs
class 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` and
* avoids json-schema-ref-parser's Node-polyfill http resolver, which hangs
* in production. Bounded by a 20s timeout.
*/
export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* (url: string) {
const client = yield* HttpClient.HttpClient;
const response = yield* client
.execute(
HttpClientRequest.get(url).pipe(
HttpClientRequest.setHeader("Accept", "application/json, application/yaml, text/yaml, */*"),
),
)
.pipe(
Effect.timeout(Duration.seconds(20)),
Effect.mapError(
(cause) =>
new OpenApiParseError({
message: `Failed to fetch OpenAPI document: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
),
);
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: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
),
);
});

/**
* 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) =>
input.startsWith("http://") || input.startsWith("https://")
? fetchSpecText(input)
: Effect.succeed(input);

// Try full bundle first (resolves $refs cleanly)
try {
return await SwaggerParser.bundle(source);
} catch {
// Bundle failed (broken $refs) — parse without ref resolution,
// then manually resolve valid refs and strip broken ones
const parsed = (await SwaggerParser.parse(source)) as OpenAPI.Document;
resolveRefsInPlace(parsed);
return parsed;
}
},
/**
* 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* Effect.try({
try: () => parseTextToObject(text),
catch: (error) =>
new OpenApiParseError({
message: `Failed to parse OpenAPI document: ${error instanceof Error ? error.message : String(error)}`,
}),
});

// Ensure it's OpenAPI 3.x (not Swagger 2)
if (!isOpenApi3(api)) {
return yield* new OpenApiExtractionErrorFromParse({
message:
Expand All @@ -47,12 +89,6 @@ export const parse = Effect.fn("OpenApi.parse")(function* (input: string) {
// Internals
// ---------------------------------------------------------------------------

import YAML from "yaml";
import { OpenApiExtractionError } from "./errors";

// swagger-parser's dereference needs a tagged error for this path
class OpenApiExtractionErrorFromParse extends OpenApiExtractionError {}

const isOpenApi3 = (doc: OpenAPI.Document): doc is OpenAPIV3.Document | OpenAPIV3_1.Document =>
"openapi" in doc && typeof doc.openapi === "string" && doc.openapi.startsWith("3.");

Expand All @@ -73,89 +109,3 @@ const parseTextToObject = (text: string): OpenAPI.Document => {

return parsed as OpenAPI.Document;
};

// ---------------------------------------------------------------------------
// Manual $ref resolver — resolves valid refs in-place, strips broken ones
// ---------------------------------------------------------------------------

/**
* Walk the document tree and resolve `$ref` pointers that point to
* `#/components/...` paths. Valid refs are inlined (deep-cloned to
* avoid shared references). Broken refs are replaced with a
* placeholder. Circular `$ref`s (a schema referencing itself) are
* left as-is to avoid creating circular object graphs.
*/
const resolveRefsInPlace = (doc: OpenAPI.Document): void => {
const lookup = (pointer: string): unknown | undefined => {
if (!pointer.startsWith("#/")) return undefined;
const parts = pointer.slice(2).split("/");
let current: unknown = doc;
for (const part of parts) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
};

// Track which $ref pointers are currently being resolved to detect cycles
const resolving = new Set<string>();

const resolveRef = (pointer: string): unknown | undefined => {
if (resolving.has(pointer)) return undefined; // circular — leave as $ref
const target = lookup(pointer);
if (!target) return undefined;
resolving.add(pointer);
const cloned = deepClone(target);
walk(cloned);
resolving.delete(pointer);
return cloned;
};

const deepClone = (obj: unknown): unknown => {
if (!obj || typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(deepClone);
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
result[k] = deepClone(v);
}
return result;
};

const walk = (obj: unknown): void => {
if (!obj || typeof obj !== "object") return;

if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const item = obj[i];
if (isRef(item)) {
const resolved = resolveRef(item.$ref);
if (resolved) obj[i] = resolved;
else obj[i] = { description: `Unresolved: ${item.$ref}` };
} else {
walk(item);
}
}
return;
}

const record = obj as Record<string, unknown>;
for (const [k, v] of Object.entries(record)) {
if (k === "$ref") continue;
if (isRef(v)) {
const resolved = resolveRef(v.$ref);
if (resolved) record[k] = resolved;
else record[k] = { description: `Unresolved: ${v.$ref}` };
} else {
walk(v);
}
}
};

walk(doc);
};

const isRef = (v: unknown): v is { $ref: string } =>
typeof v === "object" &&
v !== null &&
"$ref" in v &&
typeof (v as Record<string, unknown>).$ref === "string";
Loading
Loading