Unified · Split
| @@ -968,15 +968,16 @@ · 967 unmodified lines | |||
| 968 | "@effect/platform-node": "catalog:", | 968 | "@effect/platform-node": "catalog:", |
| 969 | "@executor-js/config": "workspace:*", | 969 | "@executor-js/config": "workspace:*", |
| 970 | "@executor-js/sdk": "workspace:*", | 970 | "@executor-js/sdk": "workspace:*", |
| 971 | "js-yaml": "4.1.1", | ||
| 971 | "lucide-react": "^1.7.0", | 972 | "lucide-react": "^1.7.0", |
| 972 | "openapi-types": "^12.1.3", | 973 | "openapi-types": "^12.1.3", |
| 973 | "yaml": "^2.7.1", | ||
| 974 | }, | 974 | }, |
| 975 | "devDependencies": { | 975 | "devDependencies": { |
| 976 | "@effect/atom-react": "catalog:", | 976 | "@effect/atom-react": "catalog:", |
| 977 | "@effect/vitest": "catalog:", | 977 | "@effect/vitest": "catalog:", |
| 978 | "@executor-js/api": "workspace:*", | 978 | "@executor-js/api": "workspace:*", |
| 979 | "@executor-js/react": "workspace:*", | 979 | "@executor-js/react": "workspace:*", |
| 980 | "@types/js-yaml": "4.0.9", | ||
| 980 | "@types/node": "catalog:", | 981 | "@types/node": "catalog:", |
| 981 | "@types/react": "catalog:", | 982 | "@types/react": "catalog:", |
| 982 | "bun-types": "catalog:", | 983 | "bun-types": "catalog:", |
| @@ -2841,6 +2842,8 @@ · 1858 unmodified lines | |||
| 2841 | 2842 | ||
| 2842 | "@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="], | 2843 | "@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="], |
| 2843 | 2844 | ||
| 2845 | "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], | ||
| 2846 | |||
| 2844 | "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], | 2847 | "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], |
| 2845 | 2848 | ||
| 2846 | "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], | 2849 | "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], |
| @@ -64,15 +64,16 @@ · 63 unmodified lines | |||
| 64 | "@effect/platform-node": "catalog:", | 64 | "@effect/platform-node": "catalog:", |
| 65 | "@executor-js/config": "workspace:*", | 65 | "@executor-js/config": "workspace:*", |
| 66 | "@executor-js/sdk": "workspace:*", | 66 | "@executor-js/sdk": "workspace:*", |
| 67 | "js-yaml": "4.1.1", | ||
| 67 | "lucide-react": "^1.7.0", | 68 | "lucide-react": "^1.7.0", |
| 68 | "openapi-types": "^12.1.3", | 69 | "openapi-types": "^12.1.3" |
| 69 | "yaml": "^2.7.1" | ||
| 70 | }, | 70 | }, |
| 71 | "devDependencies": { | 71 | "devDependencies": { |
| 72 | "@effect/atom-react": "catalog:", | 72 | "@effect/atom-react": "catalog:", |
| 73 | "@effect/vitest": "catalog:", | 73 | "@effect/vitest": "catalog:", |
| 74 | "@executor-js/api": "workspace:*", | 74 | "@executor-js/api": "workspace:*", |
| 75 | "@executor-js/react": "workspace:*", | 75 | "@executor-js/react": "workspace:*", |
| 76 | "@types/js-yaml": "4.0.9", | ||
| 76 | "@types/node": "catalog:", | 77 | "@types/node": "catalog:", |
| 77 | "@types/react": "catalog:", | 78 | "@types/react": "catalog:", |
| 78 | "bun-types": "catalog:", | 79 | "bun-types": "catalog:", |
| @@ -2,11 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { OpenApiParseError } from "./errors"; | 4 | import { OpenApiParseError } from "./errors"; |
| 5 | import { | 5 | import { parse } from "./parse"; |
| 6 | ensureGenericOpenApiSpecTextWithinLimit, | ||
| 7 | MAX_GENERIC_OPENAPI_SPEC_TEXT_LENGTH, | ||
| 8 | parse, | ||
| 9 | } from "./parse"; | ||
| 10 | 6 | ||
| 11 | describe("OpenAPI parse", () => { | 7 | describe("OpenAPI parse", () => { |
| 12 | it.effect("parses JSON OpenAPI documents", () => | 8 | it.effect("parses JSON OpenAPI documents", () => |
| @@ -69,17 +65,21 @@ paths: {} · 56 unmodified lines | |||
| 69 | }), | 65 | }), |
| 70 | ); | 66 | ); |
| 71 | 67 | ||
| 72 | it.effect("rejects oversized generic OpenAPI documents before parsing", () => | 68 | it.effect("parses Graph-sized YAML OpenAPI documents", () => |
| 73 | Effect.gen(function* () { | 69 | Effect.gen(function* () { |
| 74 | const error = yield* ensureGenericOpenApiSpecTextWithinLimit( | 70 | const largeDescription = "x".repeat(36 * 1024 * 1024); |
| 75 | "x".repeat(MAX_GENERIC_OPENAPI_SPEC_TEXT_LENGTH + 1), | 71 | const doc = yield* parse( |
| 76 | ).pipe(Effect.flip); | 72 | `openapi: 3.0.0 |
| 77 | 73 | info: | |
| 78 | expect(error).toBeInstanceOf(OpenApiParseError); | 74 | title: Large Test |
| 79 | expect(error).toHaveProperty( | 75 | version: 1.0.0 |
| 80 | "message", | 76 | description: "${largeDescription}" |
| 81 | expect.stringContaining("too large for the generic OpenAPI importer"), | 77 | paths: {} |
| 78 | `, | ||
| 82 | ); | 79 | ); |
| 80 | |||
| 81 | expect(doc.info.title).toBe("Large Test"); | ||
| 82 | expect(doc.info.description).toHaveLength(largeDescription.length); | ||
| 83 | }), | 83 | }), |
| 84 | ); | 84 | ); |
| 85 | }); | 85 | }); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; | 1 | import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from "openapi-types"; |
| 2 | import { Duration, Effect, Schema } from "effect"; | 2 | import { Duration, Effect, Schema } from "effect"; |
| 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 3 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 4 | import YAML from "yaml"; | 4 | import { JSON_SCHEMA, load as parseYamlDocument } from "js-yaml"; |
| 5 | 5 | ||
| 6 | import { OpenApiExtractionError, OpenApiParseError } from "./errors"; | 6 | import { OpenApiExtractionError, OpenApiParseError } from "./errors"; |
| 7 | 7 | ||
| @@ -12,31 +12,6 @@ export interface SpecFetchCredentials { · 4 unmodified lines | |||
| 12 | readonly queryParams?: Record<string, string>; | 12 | readonly queryParams?: Record<string, string>; |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | export const MAX_GENERIC_OPENAPI_SPEC_TEXT_LENGTH = 25 * 1024 * 1024; | ||
| 16 | |||
| 17 | const formatMiB = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(1)} MiB`; | ||
| 18 | |||
| 19 | const oversizedOpenApiSpecError = (size: number): OpenApiParseError => | ||
| 20 | new OpenApiParseError({ | ||
| 21 | message: `OpenAPI document is too large for the generic OpenAPI importer (${formatMiB(size)}; limit ${formatMiB(MAX_GENERIC_OPENAPI_SPEC_TEXT_LENGTH)}). Use a provider-specific integration when available, or import a smaller filtered OpenAPI document.`, | ||
| 22 | }); | ||
| 23 | |||
| 24 | const parseContentLength = ( | ||
| 25 | headers: Readonly<Record<string, string | undefined>>, | ||
| 26 | ): number | undefined => { | ||
| 27 | const raw = headers["content-length"] ?? headers["Content-Length"]; | ||
| 28 | if (!raw) return undefined; | ||
| 29 | const parsed = Number(raw); | ||
| 30 | return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; | ||
| 31 | }; | ||
| 32 | |||
| 33 | export const ensureGenericOpenApiSpecTextWithinLimit = ( | ||
| 34 | specText: string, | ||
| 35 | ): Effect.Effect<string, OpenApiParseError> => | ||
| 36 | specText.length > MAX_GENERIC_OPENAPI_SPEC_TEXT_LENGTH | ||
| 37 | ? Effect.fail(oversizedOpenApiSpecError(specText.length)) | ||
| 38 | : Effect.succeed(specText); | ||
| 39 | |||
| 40 | // ExtractionError subclass raised from parse() for non-3.x specs | 15 | // ExtractionError subclass raised from parse() for non-3.x specs |
| 41 | class OpenApiExtractionErrorFromParse extends OpenApiExtractionError {} | 16 | class OpenApiExtractionErrorFromParse extends OpenApiExtractionError {} |
| 42 | 17 | ||
| @@ -75,10 +50,6 @@ export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* ( · 32 unmodified lines | |||
| 75 | message: `Failed to fetch OpenAPI document: HTTP ${response.status}`, | 50 | message: `Failed to fetch OpenAPI document: HTTP ${response.status}`, |
| 76 | }); | 51 | }); |
| 77 | } | 52 | } |
| 78 | const contentLength = parseContentLength(response.headers); | ||
| 79 | if (contentLength !== undefined && contentLength > MAX_GENERIC_OPENAPI_SPEC_TEXT_LENGTH) { | ||
| 80 | return yield* oversizedOpenApiSpecError(contentLength); | ||
| 81 | } | ||
| 82 | const specText = yield* response.text.pipe( | 53 | const specText = yield* response.text.pipe( |
| 83 | Effect.mapError( | 54 | Effect.mapError( |
| 84 | (_cause) => | 55 | (_cause) => |
| @@ -87,7 +58,7 @@ export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* ( · 2 unmodified lines | |||
| 87 | }), | 58 | }), |
| 88 | ), | 59 | ), |
| 89 | ); | 60 | ); |
| 90 | return yield* ensureGenericOpenApiSpecTextWithinLimit(specText); | 61 | return specText; |
| 91 | }); | 62 | }); |
| 92 | 63 | ||
| 93 | /** | 64 | /** |
| @@ -97,7 +68,7 @@ export const fetchSpecText = Effect.fn("OpenApi.fetchSpecText")(function* ( · 3 unmodified lines | |||
| 97 | export const resolveSpecText = (input: string, credentials?: SpecFetchCredentials) => | 68 | export const resolveSpecText = (input: string, credentials?: SpecFetchCredentials) => |
| 98 | input.startsWith("http://") || input.startsWith("https://") | 69 | input.startsWith("http://") || input.startsWith("https://") |
| 99 | ? fetchSpecText(input, credentials) | 70 | ? fetchSpecText(input, credentials) |
| 100 | : ensureGenericOpenApiSpecTextWithinLimit(input); | 71 | : Effect.succeed(input); |
| 101 | 72 | ||
| 102 | /** | 73 | /** |
| 103 | * Parse an OpenAPI document from spec text and validate it's OpenAPI 3.x. | 74 | * Parse an OpenAPI document from spec text and validate it's OpenAPI 3.x. |
| @@ -158,7 +129,7 @@ const parseJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Un · 54 unmodified lines | |||
| 158 | 129 | ||
| 159 | const parseJsonLike = (text: string): Effect.Effect<unknown, unknown> => { | 130 | const parseJsonLike = (text: string): Effect.Effect<unknown, unknown> => { |
| 160 | const parseYaml = Effect.try({ | 131 | const parseYaml = Effect.try({ |
| 161 | try: () => YAML.parse(text) as unknown, | 132 | try: () => parseYamlDocument(text, { json: true, schema: JSON_SCHEMA }) as unknown, |
| 162 | catch: () => "YamlParseFailed" as const, | 133 | catch: () => "YamlParseFailed" as const, |
| 163 | }); | 134 | }); |
| 164 | if (!text.startsWith("{") && !text.startsWith("[")) return parseYaml; | 135 | if (!text.startsWith("{") && !text.startsWith("[")) return parseYaml; |
| @@ -628,6 +628,37 @@ describe("OpenAPI Plugin", () => { · 627 unmodified lines | |||
| 628 | ), | 628 | ), |
| 629 | ); | 629 | ); |
| 630 | 630 | ||
| 631 | it.effect("addSpec accepts Graph-sized OpenAPI blobs", () => | ||
| 632 | Effect.gen(function* () { | ||
| 633 | const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | ||
| 634 | const largeDescription = "x".repeat(36 * 1024 * 1024); | ||
| 635 | |||
| 636 | const added = yield* executor.openapi.addSpec({ | ||
| 637 | spec: { | ||
| 638 | kind: "blob", | ||
| 639 | value: `openapi: 3.0.0 | ||
| 640 | info: | ||
| 641 | title: Large Test | ||
| 642 | version: 1.0.0 | ||
| 643 | description: "${largeDescription}" | ||
| 644 | servers: | ||
| 645 | - url: https://example.com | ||
| 646 | paths: | ||
| 647 | /me: | ||
| 648 | get: | ||
| 649 | operationId: getMe | ||
| 650 | responses: | ||
| 651 | "200": | ||
| 652 | description: OK | ||
| 653 | `, | ||
| 654 | }, | ||
| 655 | slug: "large_api", | ||
| 656 | }); | ||
| 657 | |||
| 658 | expect(added.toolCount).toBe(1); | ||
| 659 | }), | ||
| 660 | ); | ||
| 661 | |||
| 631 | it.effect("removeSpec cleans up the integration and its tools", () => | 662 | it.effect("removeSpec cleans up the integration and its tools", () => |
| 632 | Effect.scoped( | 663 | Effect.scoped( |
| 633 | Effect.gen(function* () { | 664 | Effect.gen(function* () { |
| @@ -22,7 +22,7 @@ import { · 21 unmodified lines | |||
| 22 | 22 | ||
| 23 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; | 23 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; |
| 24 | import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; | 24 | import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; |
| 25 | import { ensureGenericOpenApiSpecTextWithinLimit, parse, resolveSpecText } from "./parse"; | 25 | import { parse, resolveSpecText } from "./parse"; |
| 26 | import { | 26 | import { |
| 27 | convertGoogleDiscoveryBundleToOpenApi, | 27 | convertGoogleDiscoveryBundleToOpenApi, |
| 28 | convertGoogleDiscoveryToOpenApi, | 28 | convertGoogleDiscoveryToOpenApi, |
| @@ -30,7 +30,7 @@ import { · 1 unmodified line | |||
| 30 | isGoogleDiscoveryUrl, | 30 | isGoogleDiscoveryUrl, |
| 31 | } from "./google-discovery"; | 31 | } from "./google-discovery"; |
| 32 | import { extract } from "./extract"; | 32 | import { extract } from "./extract"; |
| 33 | import { previewSpec, previewSpecText, type SpecPreview } from "./preview"; | 33 | import { previewSpecText, type SpecPreview } from "./preview"; |
| 34 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; | 34 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; |
| 35 | import { openApiPresets } from "./presets"; | 35 | import { openApiPresets } from "./presets"; |
| 36 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; | 36 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; |
| @@ -473,8 +473,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 436 unmodified lines | |||
| 473 | const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer)); | 473 | const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer)); |
| 474 | return { specText }; | 474 | return { specText }; |
| 475 | } | 475 | } |
| 476 | const specText = yield* ensureGenericOpenApiSpecTextWithinLimit(spec.value); | 476 | return { specText: spec.value }; |
| 477 | return { specText }; | ||
| 478 | }); | 477 | }); |
| 479 | 478 | ||
| 480 | return { | 479 | return { |
| @@ -722,7 +721,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 241 unmodified lines | |||
| 722 | Effect.map((conversion) => conversion.specText), | 721 | Effect.map((conversion) => conversion.specText), |
| 723 | ) | 722 | ) |
| 724 | : yield* resolveSpecText(previewInput.spec).pipe(Effect.provide(httpClientLayer)); | 723 | : yield* resolveSpecText(previewInput.spec).pipe(Effect.provide(httpClientLayer)); |
| 725 | return yield* previewSpec(specText).pipe(Effect.provide(httpClientLayer)); | 724 | return yield* previewSpecText(specText); |
| 726 | }), | 725 | }), |
| 727 | 726 | ||
| 728 | addSpec, | 727 | addSpec, |