diff --git a/.changeset/graph-slice-urls-first-class.md b/.changeset/graph-slice-urls-first-class.md new file mode 100644 index 0000000000..fd2ba2e367 --- /dev/null +++ b/.changeset/graph-slice-urls-first-class.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Make Microsoft Graph slice URLs first-class spec sources instead of a hidden substitution. Catalog tiles now point directly at the slice release assets, the stored specUrl is exactly what gets fetched, and selection narrowing travels visibly in the URL fragment; requesting the upstream monolith URL fetches the monolith, never a silently swapped slice. diff --git a/e2e/scenarios/microsoft-graph-default.test.ts b/e2e/scenarios/microsoft-graph-default.test.ts index 499a25af92..f0dc33acab 100644 --- a/e2e/scenarios/microsoft-graph-default.test.ts +++ b/e2e/scenarios/microsoft-graph-default.test.ts @@ -6,10 +6,10 @@ import { composePluginApi } from "@executor-js/api/server"; import { MICROSOFT_AUTH_TEMPLATE_SLUG, MICROSOFT_AUTHORIZATION_URL, - MICROSOFT_GRAPH_OPENAPI_URL, MICROSOFT_TOKEN_URL, microsoftCatalog, microsoftGraphAdapter, + microsoftGraphSliceUrl, } from "@executor-js/plugin-openapi/providers/microsoft"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { @@ -60,7 +60,7 @@ scenario( // isolate the same way it did the production one. const preview = yield* client.openapi.previewSpec({ payload: { - spec: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`, + spec: microsoftGraphSliceUrl(MICROSOFT_FILES_PRESET_ID), specFormat: "microsoft-graph", }, }); @@ -77,7 +77,7 @@ scenario( payload: { spec: { kind: "url", - url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`, + url: microsoftGraphSliceUrl(MICROSOFT_FILES_PRESET_ID), }, slug: integration, name: "Microsoft Graph Files", diff --git a/e2e/scenarios/microsoft-graph-full.test.ts b/e2e/scenarios/microsoft-graph-full.test.ts index bba32a2c2a..527b0ddbc3 100644 --- a/e2e/scenarios/microsoft-graph-full.test.ts +++ b/e2e/scenarios/microsoft-graph-full.test.ts @@ -5,9 +5,9 @@ import { Effect } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { MICROSOFT_AUTH_TEMPLATE_SLUG, - MICROSOFT_GRAPH_OPENAPI_URL, microsoftCatalog, microsoftGraphAdapter, + microsoftGraphSliceUrl, } from "@executor-js/plugin-openapi/providers/microsoft"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; @@ -57,7 +57,7 @@ scenario( payload: { spec: { kind: "url", - url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_FILES_PRESET_ID}`, + url: microsoftGraphSliceUrl(MICROSOFT_FILES_PRESET_ID), }, slug: integration, name: "Microsoft Graph Files", diff --git a/e2e/scenarios/provider-plugins-ui.test.ts b/e2e/scenarios/provider-plugins-ui.test.ts index add10ccd15..fd8dda0efa 100644 --- a/e2e/scenarios/provider-plugins-ui.test.ts +++ b/e2e/scenarios/provider-plugins-ui.test.ts @@ -57,11 +57,13 @@ scenario( await step("A Microsoft service preset opens the OpenAPI add flow", async () => { await page.goto( - "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoftgraph%2Fmsgraph-metadata%2Fmaster%2Fopenapi%2Fv1.0%2Fopenapi.yaml%23preset%3Dfiles", + "/integrations/add/openapi?preset=microsoft-files&url=https%3A%2F%2Fgithub.com%2FUsefulSoftwareCo%2Fexecutor%2Freleases%2Fdownload%2Fgraph-slices%2Ffiles.yaml", { waitUntil: "domcontentloaded" }, ); await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor(); - await expect.poll(() => page.locator("textarea").inputValue()).toContain("preset=files"); + await expect + .poll(() => page.locator("textarea").inputValue()) + .toContain("graph-slices/files.yaml"); }); }); }), diff --git a/packages/plugins/openapi/src/providers/microsoft/graph.ts b/packages/plugins/openapi/src/providers/microsoft/graph.ts index 36ac23dc4f..ebb9d900fe 100644 --- a/packages/plugins/openapi/src/providers/microsoft/graph.ts +++ b/packages/plugins/openapi/src/providers/microsoft/graph.ts @@ -15,7 +15,11 @@ import { } from "../../sdk/split"; import type { Authentication } from "../../sdk/types"; -import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices"; +import { + fetchMicrosoftGraphSlice, + microsoftGraphPresetIdsForSliceAsset, + microsoftGraphSliceAssetFromUrl, +} from "./slices"; import { MICROSOFT_AUTHORIZATION_URL, MICROSOFT_AUTH_TEMPLATE_SLUG, @@ -244,6 +248,10 @@ const normalizeMicrosoftGraphSpecUrl = ( policy?: MicrosoftGraphUrlPolicy, ): string | null => { if (value === MICROSOFT_GRAPH_OPENAPI_URL) return value; + const sliceAsset = microsoftGraphSliceAssetFromUrl(value); + if (sliceAsset !== null && microsoftGraphPresetIdsForSliceAsset(sliceAsset) !== null) { + return value; + } return allowUnsafeUrl(value, policy) ?? null; }; @@ -759,26 +767,26 @@ export const buildMicrosoftGraphOpenApiSpec = ( urlPolicy?: MicrosoftGraphUrlPolicy, ): Effect.Effect => Effect.gen(function* () { - const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy); - // Covered selections read a precomputed slice (sub-MB) instead of the 43MB - // monolith: in production, the monolith fetch alone almost never survives - // the 128MB isolate (once in the 30 days before 2026-08-26). Slices apply - // only to the pinned Microsoft URL — an override (local Graph emulators) - // serves its own document. A missing/failed slice (asset not yet published, - // release unreachable) falls back to the monolith path, which is the prior - // behavior for the selections a slice would have covered. - const sliceAsset = - selection.specUrl === MICROSOFT_GRAPH_OPENAPI_URL - ? microsoftGraphSliceAssetForSelection(selection) - : null; + // A slice URL carries its own selection: when the caller passes no preset + // ids, the asset's selection applies (rather than the default bundle). + const inputSliceAsset = input.specUrl + ? microsoftGraphSliceAssetFromUrl(input.specUrl.trim()) + : null; + const inputSlicePresetIds = + inputSliceAsset !== null ? microsoftGraphPresetIdsForSliceAsset(inputSliceAsset) : null; + const selectionInput = + inputSlicePresetIds !== null && (!input.presetIds || input.presetIds.length === 0) + ? { ...input, presetIds: inputSlicePresetIds } + : input; + const selection = yield* validateSelectionUrls(normalizeSelection(selectionInput), urlPolicy); + // The URL is the byte source, never substituted. Catalog selections point + // at precomputed slice URLs (the 43MB monolith cannot be processed in a + // 128MB isolate — its fetch completed once in the 30 days before + // 2026-08-26); the monolith and emulator-override URLs fetch exactly what + // they name. const sourceText = - sliceAsset !== null - ? yield* fetchMicrosoftGraphSlice(sliceAsset).pipe( - Effect.catchTag("OpenApiParseError", () => - fetchMicrosoftGraphOpenApiSpec(selection.specUrl), - ), - Effect.provide(httpClientLayer), - ) + microsoftGraphSliceAssetFromUrl(selection.specUrl) !== null + ? yield* fetchMicrosoftGraphSlice(selection.specUrl).pipe(Effect.provide(httpClientLayer)) : yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( Effect.provide(httpClientLayer), ); diff --git a/packages/plugins/openapi/src/providers/microsoft/index.ts b/packages/plugins/openapi/src/providers/microsoft/index.ts index fe51454d3b..c9b7bacfc3 100644 --- a/packages/plugins/openapi/src/providers/microsoft/index.ts +++ b/packages/plugins/openapi/src/providers/microsoft/index.ts @@ -39,3 +39,11 @@ export { type MicrosoftGraphSpecBuild, } from "./graph"; export { microsoftGraphAdapter } from "./spec-format-adapter"; +export { + MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET, + MICROSOFT_GRAPH_SLICE_BASE_URL, + MICROSOFT_GRAPH_SLICE_RELEASE_TAG, + microsoftGraphPresetIdsForSliceAsset, + microsoftGraphSliceAssetFromUrl, + microsoftGraphSliceUrl, +} from "./slices"; diff --git a/packages/plugins/openapi/src/providers/microsoft/presets.ts b/packages/plugins/openapi/src/providers/microsoft/presets.ts index b1dabc9e2c..33cbcf0e6c 100644 --- a/packages/plugins/openapi/src/providers/microsoft/presets.ts +++ b/packages/plugins/openapi/src/providers/microsoft/presets.ts @@ -1,5 +1,7 @@ import type { IntegrationPreset } from "@executor-js/sdk/core"; +import { microsoftGraphSliceUrl } from "./slice-urls"; + export interface MicrosoftGraphPreset { readonly id: string; readonly name: string; @@ -528,8 +530,10 @@ export const microsoftGraphTagPrefixesForPresetIds = ( export const microsoftServiceSlug = (presetId: string): string => `microsoft_${presetId.replaceAll("-", "_")}`; -const microsoftGraphCatalogUrl = (presetId: string): string => - `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${encodeURIComponent(presetId)}`; +// Catalog tiles point at the slice URL itself: the URL a user sees (and the +// integration stores) is exactly what gets fetched — no server-side source +// substitution. The slice asset name carries the selection. +const microsoftGraphCatalogUrl = (presetId: string): string => microsoftGraphSliceUrl(presetId); const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => [ { diff --git a/packages/plugins/openapi/src/providers/microsoft/slice-urls.ts b/packages/plugins/openapi/src/providers/microsoft/slice-urls.ts new file mode 100644 index 0000000000..1bd4c3e0b0 --- /dev/null +++ b/packages/plugins/openapi/src/providers/microsoft/slice-urls.ts @@ -0,0 +1,37 @@ +/** + * Microsoft Graph slice URL vocabulary. Leaf module (no preset imports) shared + * by the catalog (`presets.ts`), the runtime fetch (`slices.ts`), and the + * adapter's URL classification. + * + * Slice URLs are first-class spec sources: the catalog points at them + * directly, what the integration stores as `specUrl` is what was fetched, and + * any narrowing within a slice travels visibly in the URL fragment + * (`#preset=mail,calendar`). The 43MB upstream monolith URL is never silently + * substituted — requesting it fetches it. + */ + +export const MICROSOFT_GRAPH_SLICE_RELEASE_TAG = "graph-slices"; + +export const MICROSOFT_GRAPH_SLICE_BASE_URL = `https://github.com/UsefulSoftwareCo/executor/releases/download/${MICROSOFT_GRAPH_SLICE_RELEASE_TAG}`; + +/** Asset covering the default catalog bundle (`MICROSOFT_GRAPH_DEFAULT_PRESET_IDS`). */ +export const MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET = "default"; + +export const microsoftGraphSliceUrl = (asset: string): string => + `${MICROSOFT_GRAPH_SLICE_BASE_URL}/${encodeURIComponent(asset)}.yaml`; + +/** The asset a slice URL names, or null for any other URL. Fragment and query + * are ignored — callers strip the fragment into a selection separately. */ +export const microsoftGraphSliceAssetFromUrl = (url: string): string | null => { + if (!URL.canParse(url)) return null; + const parsed = new URL(url); + parsed.hash = ""; + const href = parsed.toString(); + if (!href.startsWith(`${MICROSOFT_GRAPH_SLICE_BASE_URL}/`) || !href.endsWith(".yaml")) { + return null; + } + const asset = decodeURIComponent( + href.slice(`${MICROSOFT_GRAPH_SLICE_BASE_URL}/`.length, -".yaml".length), + ); + return asset.length > 0 && !asset.includes("/") ? asset : null; +}; diff --git a/packages/plugins/openapi/src/providers/microsoft/slices.test.ts b/packages/plugins/openapi/src/providers/microsoft/slices.test.ts index 6f94a2fceb..b04fb0b9d7 100644 --- a/packages/plugins/openapi/src/providers/microsoft/slices.test.ts +++ b/packages/plugins/openapi/src/providers/microsoft/slices.test.ts @@ -1,70 +1,40 @@ import { describe, expect, it } from "@effect/vitest"; -import { MICROSOFT_GRAPH_ALL_PRESET_IDS, MICROSOFT_GRAPH_DEFAULT_PRESET_IDS } from "./presets"; +import { MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; import { MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET, - microsoftGraphSliceAssetForSelection, + microsoftGraphPresetIdsForSliceAsset, + microsoftGraphSliceAssetFromUrl, + microsoftGraphSliceUrl, } from "./slices"; -describe("microsoftGraphSliceAssetForSelection", () => { - it("maps a single catalog preset to its asset", () => { +describe("microsoftGraphSliceAssetFromUrl", () => { + it("round-trips slice URLs and ignores fragments", () => { + expect(microsoftGraphSliceAssetFromUrl(microsoftGraphSliceUrl("mail"))).toBe("mail"); expect( - microsoftGraphSliceAssetForSelection({ - coversFullGraph: false, - presetIds: ["mail"], - customScopes: [], - }), - ).toBe("mail"); + microsoftGraphSliceAssetFromUrl(`${microsoftGraphSliceUrl("default")}#preset=mail,calendar`), + ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET); }); - it("maps the default bundle in any order to the default asset", () => { - expect( - microsoftGraphSliceAssetForSelection({ - coversFullGraph: false, - presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS].reverse(), - customScopes: [], - }), - ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET); + it("rejects non-slice URLs", () => { + expect(microsoftGraphSliceAssetFromUrl(MICROSOFT_GRAPH_OPENAPI_URL)).toBeNull(); + expect(microsoftGraphSliceAssetFromUrl("https://example.com/mail.yaml")).toBeNull(); + expect(microsoftGraphSliceAssetFromUrl("not a url")).toBeNull(); }); +}); - it("serves combinations within the default bundle from the default slice", () => { - expect( - microsoftGraphSliceAssetForSelection({ - coversFullGraph: false, - presetIds: ["mail", "calendar"], - customScopes: [], - }), - ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET); +describe("microsoftGraphPresetIdsForSliceAsset", () => { + it("maps a preset asset to its single preset", () => { + expect(microsoftGraphPresetIdsForSliceAsset("mail")).toEqual(["mail"]); }); - it("needs the monolith for full-graph, custom scopes, unknown presets, and combinations outside the default bundle", () => { - expect( - microsoftGraphSliceAssetForSelection({ - coversFullGraph: true, - presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], - customScopes: [], - }), - ).toBeNull(); - expect( - microsoftGraphSliceAssetForSelection({ - coversFullGraph: false, - presetIds: ["mail"], - customScopes: ["Chat.Read"], - }), - ).toBeNull(); - expect( - microsoftGraphSliceAssetForSelection({ - coversFullGraph: false, - presetIds: ["not-a-preset"], - customScopes: [], - }), - ).toBeNull(); - expect( - microsoftGraphSliceAssetForSelection({ - coversFullGraph: false, - presetIds: ["mail", "users"], - customScopes: [], - }), - ).toBeNull(); + it("maps the default asset to the default bundle", () => { + expect(microsoftGraphPresetIdsForSliceAsset(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET)).toEqual( + MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, + ); + }); + + it("returns null for unknown assets", () => { + expect(microsoftGraphPresetIdsForSliceAsset("not-a-preset")).toBeNull(); }); }); diff --git a/packages/plugins/openapi/src/providers/microsoft/slices.ts b/packages/plugins/openapi/src/providers/microsoft/slices.ts index f129e36740..724ec2ffce 100644 --- a/packages/plugins/openapi/src/providers/microsoft/slices.ts +++ b/packages/plugins/openapi/src/providers/microsoft/slices.ts @@ -4,62 +4,41 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { OpenApiParseError } from "../../sdk/errors"; import { MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, microsoftGraphPresetForId } from "./presets"; +import { MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET } from "./slice-urls"; + +export { + MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET, + MICROSOFT_GRAPH_SLICE_BASE_URL, + MICROSOFT_GRAPH_SLICE_RELEASE_TAG, + microsoftGraphSliceAssetFromUrl, + microsoftGraphSliceUrl, +} from "./slice-urls"; /** * Runtime access to precomputed Microsoft Graph slices. * * The 43MB Graph monolith cannot be processed in a 128MB Workers isolate — in - * production its fetch alone completed once in the 30 days before 2026-08-26; - * every other preview/add died mid-download with an empty 503. Slices are - * built offline (`slice-build.ts`, refreshed by the graph-slices workflow), - * published as release assets, and fetched here per selection: the isolate - * only ever holds the sub-megabyte filtered document for the selection. + * production its fetch alone completed once in the 30 days before 2026-08-26. + * Slices are built offline (`slice-build.ts`, refreshed by the graph-slices + * workflow) and published as release assets that the catalog references as + * ordinary spec URLs; the isolate only ever holds the filtered document it was + * actually asked to fetch. */ -export const MICROSOFT_GRAPH_SLICE_RELEASE_TAG = "graph-slices"; - -export const MICROSOFT_GRAPH_SLICE_BASE_URL = `https://github.com/UsefulSoftwareCo/executor/releases/download/${MICROSOFT_GRAPH_SLICE_RELEASE_TAG}`; - -/** Asset name for the default catalog bundle (`MICROSOFT_GRAPH_DEFAULT_PRESET_IDS`). */ -export const MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET = "default"; - -/** - * The published asset covering a selection, or null when the selection needs - * the monolith. A slice may be a superset of the selection: the runtime always - * applies the selection's `keepPathItem` filter to whatever source it reads, so - * any combination within the default bundle can be served from the default - * slice and narrowed in-band. The monolith remains necessary for full-graph - * coverage, custom scopes (scope matching walks operations outside any - * preset's paths), and combinations reaching outside the default bundle - * (precomputing every combination is combinatorial). - */ -export const microsoftGraphSliceAssetForSelection = (selection: { - readonly coversFullGraph: boolean; - readonly presetIds: readonly string[]; - readonly customScopes: readonly string[]; -}): string | null => { - if (selection.coversFullGraph) return null; - if (selection.customScopes.length > 0) return null; - if (selection.presetIds.length === 0) return null; - if (selection.presetIds.some((presetId) => !microsoftGraphPresetForId(presetId))) return null; - if (selection.presetIds.length === 1) return selection.presetIds[0]!; - const defaultIds = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); - if (selection.presetIds.every((presetId) => defaultIds.has(presetId))) { - return MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET; - } - return null; +/** The preset selection a slice asset carries when the URL has no narrowing + * fragment, or null for an unknown asset. */ +export const microsoftGraphPresetIdsForSliceAsset = (asset: string): readonly string[] | null => { + if (asset === MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET) return MICROSOFT_GRAPH_DEFAULT_PRESET_IDS; + return microsoftGraphPresetForId(asset) ? [asset] : null; }; -export const microsoftGraphSliceUrl = (asset: string): string => - `${MICROSOFT_GRAPH_SLICE_BASE_URL}/${encodeURIComponent(asset)}.yaml`; - export const fetchMicrosoftGraphSlice = Effect.fn("Microsoft.fetchGraphSlice")(function* ( - asset: string, + sliceUrl: string, ) { const client = yield* HttpClient.HttpClient; const response = yield* client .execute( - HttpClientRequest.get(microsoftGraphSliceUrl(asset)).pipe( + HttpClientRequest.get(sliceUrl).pipe( HttpClientRequest.setHeader("Accept", "application/yaml, text/yaml, */*"), ), ) @@ -67,20 +46,20 @@ export const fetchMicrosoftGraphSlice = Effect.fn("Microsoft.fetchGraphSlice")(f Effect.mapError( () => new OpenApiParseError({ - message: `Failed to fetch Microsoft Graph slice: ${asset}`, + message: "Failed to fetch the Microsoft Graph slice document", }), ), ); if (response.status < 200 || response.status >= 300) { return yield* new OpenApiParseError({ - message: `Failed to fetch Microsoft Graph slice ${asset}: HTTP ${response.status}`, + message: `Failed to fetch the Microsoft Graph slice document: HTTP ${response.status}`, }); } return yield* response.text.pipe( Effect.mapError( () => new OpenApiParseError({ - message: `Failed to read Microsoft Graph slice body: ${asset}`, + message: "Failed to read the Microsoft Graph slice document body", }), ), ); diff --git a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts index 44c09c65d9..9db13e8a06 100644 --- a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts +++ b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts @@ -139,31 +139,67 @@ const sliceAwareHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( }), ); -it.effect("reads the published slice for a covered selection", () => +it.effect("fetches a slice URL as the byte source and derives its selection from the asset", () => Effect.gen(function* () { const converted = yield* microsoftGraphAdapter.fetch({ - urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], + urls: [microsoftGraphSliceUrl("profile")], httpClientLayer: sliceAwareHttpClientLayer, }); expect(converted.specText).toBe(sliceFixture); - // The catalog URL (with fragment stripped) stays canonical so refresh - // re-resolves through the adapter, not the slice hosting. - expect(converted.specUrl).toBe(MICROSOFT_GRAPH_OPENAPI_URL); + // What is stored is what was fetched — no source substitution. + expect(converted.specUrl).toBe(microsoftGraphSliceUrl("profile")); + // The asset name carries the selection even with no fragment. + const keepPathItem = converted.keepPathItem!; + expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({ + get: { operationId: "me.GetUser" }, + }); + expect(keepPathItem("/irrelevant", { get: { operationId: "irrelevant.Get" } })).toBeNull(); + }), +); + +it.effect("narrows within a slice via the URL fragment", () => + Effect.gen(function* () { + const layer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + request.url === microsoftGraphSliceUrl("default") ? sliceFixture : "not found", + { status: request.url === microsoftGraphSliceUrl("default") ? 200 : 404 }, + ), + ), + ), + ), + ); + const converted = yield* microsoftGraphAdapter.fetch({ + urls: [`${microsoftGraphSliceUrl("default")}#preset=profile`], + httpClientLayer: layer, + }); + + expect(converted.specText).toBe(sliceFixture); + expect(converted.specUrl).toBe(microsoftGraphSliceUrl("default")); + const keepPathItem = converted.keepPathItem!; + // Profile keeps /me; a mail-only path from the default bundle is dropped. + expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({ + get: { operationId: "me.GetUser" }, + }); + expect(keepPathItem("/me/messages", { post: { operationId: "me.CreateMessage" } })).toBeNull(); }), ); -it.effect("falls back to the monolith when the slice asset is unavailable", () => +it.effect("fetches the monolith URL as given — no silent slice substitution", () => Effect.gen(function* () { - // graphHttpClientLayer 404s everything except the monolith URL, including - // the slice URL — the existing selection tests above exercise this same - // fallback implicitly. + // sliceAwareHttpClientLayer serves both URLs; requesting the monolith with + // a preset fragment must read the monolith bytes. const converted = yield* microsoftGraphAdapter.fetch({ urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], - httpClientLayer: graphHttpClientLayer, + httpClientLayer: sliceAwareHttpClientLayer, }); expect(converted.specText).toBe(graphFixture); + expect(converted.specUrl).toBe(MICROSOFT_GRAPH_OPENAPI_URL); }), ); diff --git a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts index 601b9607a1..16c762dc01 100644 --- a/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts +++ b/packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts @@ -3,19 +3,40 @@ import { Effect } from "effect"; import type { SpecFormatAdapter } from "../../sdk/spec-format"; import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; +import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices"; +const fragmentPresetIds = (hash: string): readonly string[] => + hash.startsWith("#preset=") + ? decodeURIComponent(hash.slice("#preset=".length)) + .split(",") + .map((presetId) => presetId.trim()) + .filter((presetId) => presetId.length > 0) + : []; + +/** + * Selection from a catalog URL. A slice URL is the byte source itself: its + * asset carries the selection, and a fragment may narrow within the asset. + * Any other URL (the upstream monolith, an emulator override) is fetched as + * given, with the fragment as the selection filter. + */ const graphCatalogSelection = ( rawUrl: string | undefined, ): { readonly specUrl?: string; readonly presetIds?: readonly string[] } => { if (!rawUrl || !URL.canParse(rawUrl)) return rawUrl ? { specUrl: rawUrl } : {}; const parsed = new URL(rawUrl); - const preset = parsed.hash.startsWith("#preset=") - ? decodeURIComponent(parsed.hash.slice("#preset=".length)) - : ""; + const fromFragment = fragmentPresetIds(parsed.hash); parsed.hash = ""; + const specUrl = parsed.toString(); + const sliceAsset = microsoftGraphSliceAssetFromUrl(specUrl); + const presetIds = + fromFragment.length > 0 + ? fromFragment + : sliceAsset !== null + ? (microsoftGraphPresetIdsForSliceAsset(sliceAsset) ?? []) + : []; return { - specUrl: parsed.toString(), - ...(preset.length > 0 ? { presetIds: [preset] } : {}), + specUrl, + ...(presetIds.length > 0 ? { presetIds } : {}), }; };