Showing the changes to packages/plugins/openapi/src (6 of 10 files). Show all
Unified · Split
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | } from "../../sdk/split"; |
| 16 | 16 | import type { Authentication } from "../../sdk/types"; |
| 17 | 17 | |
| 18 | +import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices"; | |
| 18 | 19 | import { |
| 19 | 20 | MICROSOFT_AUTHORIZATION_URL, |
| 20 | 21 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| @@ -759,9 +760,28 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 738 unmodified lines | ||
| 759 | 760 | ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> => |
| 760 | 761 | Effect.gen(function* () { |
| 761 | 762 | const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy); |
| 762 | − const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( | |
| 763 | − Effect.provide(httpClientLayer), | |
| 764 | − ); | |
| 763 | + // Covered selections read a precomputed slice (sub-MB) instead of the 43MB | |
| 764 | + // monolith: in production, the monolith fetch alone almost never survives | |
| 765 | + // the 128MB isolate (once in the 30 days before 2026-08-26). Slices apply | |
| 766 | + // only to the pinned Microsoft URL — an override (local Graph emulators) | |
| 767 | + // serves its own document. A missing/failed slice (asset not yet published, | |
| 768 | + // release unreachable) falls back to the monolith path, which is the prior | |
| 769 | + // behavior for the selections a slice would have covered. | |
| 770 | + const sliceAsset = | |
| 771 | + selection.specUrl === MICROSOFT_GRAPH_OPENAPI_URL | |
| 772 | + ? microsoftGraphSliceAssetForSelection(selection) | |
| 773 | + : null; | |
| 774 | + const sourceText = | |
| 775 | + sliceAsset !== null | |
| 776 | + ? yield* fetchMicrosoftGraphSlice(sliceAsset).pipe( | |
| 777 | + Effect.catchTag("OpenApiParseError", () => | |
| 778 | + fetchMicrosoftGraphOpenApiSpec(selection.specUrl), | |
| 779 | + ), | |
| 780 | + Effect.provide(httpClientLayer), | |
| 781 | + ) | |
| 782 | + : yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( | |
| 783 | + Effect.provide(httpClientLayer), | |
| 784 | + ); | |
| 765 | 785 | |
| 766 | 786 | // Structural split is the only entry point: parsing the whole 37MB tree |
| 767 | 787 | // OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec |
| @@ -0,0 +1,104 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { structuralSplit } from "../../sdk/split"; | |
| 4 | +import { buildGraphSliceDocument, parseGraphSourceDocument } from "./slice-build"; | |
| 5 | + | |
| 6 | +// Graph-shaped source: a mail path, an unrelated path, and a schema chain | |
| 7 | +// where only part is reachable from the mail selection. | |
| 8 | +const source = `openapi: 3.0.4 | |
| 9 | +info: | |
| 10 | + title: Microsoft Graph Fixture | |
| 11 | + version: v1.0 | |
| 12 | +servers: | |
| 13 | + - url: https://graph.microsoft.com/v1.0 | |
| 14 | +paths: | |
| 15 | + /me/messages: | |
| 16 | + get: | |
| 17 | + operationId: me.ListMessages | |
| 18 | + security: | |
| 19 | + - azureAdDelegated: | |
| 20 | + - Mail.ReadWrite | |
| 21 | + parameters: | |
| 22 | + - $ref: '#/components/parameters/Top' | |
| 23 | + responses: | |
| 24 | + "200": | |
| 25 | + description: OK | |
| 26 | + content: | |
| 27 | + application/json: | |
| 28 | + schema: | |
| 29 | + $ref: '#/components/schemas/microsoft.graph.messageCollection' | |
| 30 | + /irrelevant: | |
| 31 | + get: | |
| 32 | + operationId: irrelevant.Get | |
| 33 | + responses: | |
| 34 | + "200": | |
| 35 | + description: OK | |
| 36 | + content: | |
| 37 | + application/json: | |
| 38 | + schema: | |
| 39 | + $ref: '#/components/schemas/microsoft.graph.unrelated' | |
| 40 | +components: | |
| 41 | + parameters: | |
| 42 | + Top: | |
| 43 | + name: $top | |
| 44 | + in: query | |
| 45 | + schema: | |
| 46 | + type: integer | |
| 47 | + securitySchemes: | |
| 48 | + azureAdDelegated: | |
| 49 | + type: oauth2 | |
| 50 | + flows: | |
| 51 | + authorizationCode: | |
| 52 | + authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize | |
| 53 | + tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token | |
| 54 | + scopes: | |
| 55 | + Mail.ReadWrite: Read and write mail | |
| 56 | + schemas: | |
| 57 | + microsoft.graph.messageCollection: | |
| 58 | + type: object | |
| 59 | + properties: | |
| 60 | + value: | |
| 61 | + type: array | |
| 62 | + items: | |
| 63 | + $ref: '#/components/schemas/microsoft.graph.message' | |
| 64 | + microsoft.graph.message: | |
| 65 | + type: object | |
| 66 | + properties: | |
| 67 | + id: | |
| 68 | + type: string | |
| 69 | + microsoft.graph.unrelated: | |
| 70 | + type: object | |
| 71 | + properties: | |
| 72 | + name: | |
| 73 | + type: string | |
| 74 | +`; | |
| 75 | + | |
| 76 | +describe("buildGraphSliceDocument", () => { | |
| 77 | + it("keeps the selection's paths and prunes components to the reachable closure", () => { | |
| 78 | + const doc = parseGraphSourceDocument(source); | |
| 79 | + expect(doc).not.toBeNull(); | |
| 80 | + const slice = buildGraphSliceDocument(doc!, ["mail"]); | |
| 81 | + | |
| 82 | + expect(slice.pathCount).toBe(1); | |
| 83 | + expect(slice.operationCount).toBe(1); | |
| 84 | + expect(slice.specText).toContain("/me/messages"); | |
| 85 | + expect(slice.specText).not.toContain("/irrelevant"); | |
| 86 | + expect(slice.specText).toContain("microsoft.graph.messageCollection"); | |
| 87 | + expect(slice.specText).toContain("microsoft.graph.message"); | |
| 88 | + expect(slice.specText).not.toContain("microsoft.graph.unrelated"); | |
| 89 | + // Referenced small components survive; securitySchemes always survive. | |
| 90 | + expect(slice.specText).toContain("$top"); | |
| 91 | + expect(slice.specText).toContain("azureAdDelegated"); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it("emits the streamable block-YAML profile the runtime splitter accepts", () => { | |
| 95 | + const doc = parseGraphSourceDocument(source); | |
| 96 | + expect(doc).not.toBeNull(); | |
| 97 | + const slice = buildGraphSliceDocument(doc!, ["mail"]); | |
| 98 | + | |
| 99 | + const structure = structuralSplit(slice.specText); | |
| 100 | + expect(structure).not.toBeNull(); | |
| 101 | + expect(structure!.pathItems).toHaveLength(slice.pathCount); | |
| 102 | + expect(structure!.schemas).toHaveLength(slice.schemaCount); | |
| 103 | + }); | |
| 104 | +}); | |
| @@ -0,0 +1,193 @@ | ||
| 1 | +import { JSON_SCHEMA, dump as dumpYamlDocument, load as parseYamlDocument } from "js-yaml"; | |
| 2 | + | |
| 3 | +import { microsoftGraphKeepPathItem } from "./graph"; | |
| 4 | +import { | |
| 5 | + microsoftGraphExactPathsForPresetIds, | |
| 6 | + microsoftGraphPathPrefixesForPresetIds, | |
| 7 | + microsoftGraphTagPrefixesForPresetIds, | |
| 8 | +} from "./presets"; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Offline Microsoft Graph slice construction. NOT part of the runtime import | |
| 12 | + * graph: the 43MB Graph source cannot be held in a 128MB Workers isolate (its | |
| 13 | + * fetch alone has completed once in the last 30 days of production traces), so | |
| 14 | + * slices are built where memory is free — the graph-slices workflow / a dev | |
| 15 | + * machine — published as release assets, and fetched per selection at runtime | |
| 16 | + * by `slices.ts`. Import this module only from scripts and tests. | |
| 17 | + */ | |
| 18 | + | |
| 19 | +/** `components` subkeys retained whole in a slice — mirrors the runtime | |
| 20 | + * splitter's `SMALL_COMPONENT_SECTIONS` (`sdk/split.ts`), which keeps these | |
| 21 | + * resident for `$ref` resolution. `schemas` is pruned to the transitive | |
| 22 | + * closure instead; `examples` is dropped. */ | |
| 23 | +const SMALL_COMPONENT_SECTIONS = [ | |
| 24 | + "parameters", | |
| 25 | + "requestBodies", | |
| 26 | + "responses", | |
| 27 | + "headers", | |
| 28 | + "links", | |
| 29 | + "securitySchemes", | |
| 30 | +] as const; | |
| 31 | + | |
| 32 | +const COMPONENT_REF_PREFIX = "#/components/"; | |
| 33 | + | |
| 34 | +const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 35 | + value !== null && typeof value === "object" && !Array.isArray(value); | |
| 36 | + | |
| 37 | +const decodeRefSegment = (segment: string): string => | |
| 38 | + segment.replace(/~1/g, "/").replace(/~0/g, "~"); | |
| 39 | + | |
| 40 | +interface ComponentRef { | |
| 41 | + readonly section: string; | |
| 42 | + readonly name: string; | |
| 43 | +} | |
| 44 | + | |
| 45 | +const collectComponentRefs = (value: unknown, into: (ref: ComponentRef) => void): void => { | |
| 46 | + if (typeof value === "string") { | |
| 47 | + if (value.startsWith(COMPONENT_REF_PREFIX)) { | |
| 48 | + const rest = value.slice(COMPONENT_REF_PREFIX.length); | |
| 49 | + const slash = rest.indexOf("/"); | |
| 50 | + if (slash > 0) { | |
| 51 | + const section = rest.slice(0, slash); | |
| 52 | + const name = decodeRefSegment(rest.slice(slash + 1)); | |
| 53 | + if (name.length > 0) into({ section, name }); | |
| 54 | + } | |
| 55 | + } | |
| 56 | + return; | |
| 57 | + } | |
| 58 | + if (Array.isArray(value)) { | |
| 59 | + for (const item of value) collectComponentRefs(item, into); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + if (isRecord(value)) { | |
| 63 | + for (const item of Object.values(value)) collectComponentRefs(item, into); | |
| 64 | + } | |
| 65 | +}; | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * Transitive `#/components/<section>/<name>` closure over every component | |
| 69 | + * section, seeded from `roots` (the kept path-items). Only components actually | |
| 70 | + * reachable from a kept operation survive — Graph's ~8k schemas and its | |
| 71 | + * catch-all responses/parameters sections otherwise drag nearly the whole | |
| 72 | + * component graph into every slice. | |
| 73 | + */ | |
| 74 | +const componentClosure = ( | |
| 75 | + components: Record<string, unknown>, | |
| 76 | + roots: readonly unknown[], | |
| 77 | +): Record<string, Record<string, unknown>> => { | |
| 78 | + const kept: Record<string, Record<string, unknown>> = {}; | |
| 79 | + const queue: ComponentRef[] = []; | |
| 80 | + const seen = new Set<string>(); | |
| 81 | + const enqueue = (ref: ComponentRef): void => { | |
| 82 | + const key = `${ref.section}/${ref.name}`; | |
| 83 | + if (seen.has(key)) return; | |
| 84 | + seen.add(key); | |
| 85 | + queue.push(ref); | |
| 86 | + }; | |
| 87 | + | |
| 88 | + for (const root of roots) collectComponentRefs(root, enqueue); | |
| 89 | + for (let i = 0; i < queue.length; i += 1) { | |
| 90 | + const { section, name } = queue[i]!; | |
| 91 | + const sectionValues = components[section]; | |
| 92 | + if (!isRecord(sectionValues)) continue; | |
| 93 | + const component = sectionValues[name]; | |
| 94 | + if (component === undefined) continue; | |
| 95 | + (kept[section] ??= {})[name] = component; | |
| 96 | + collectComponentRefs(component, enqueue); | |
| 97 | + } | |
| 98 | + return kept; | |
| 99 | +}; | |
| 100 | + | |
| 101 | +export interface GraphSliceBuild { | |
| 102 | + /** The slice as streamable block YAML (the same profile `structuralSplit` | |
| 103 | + * accepts, so the runtime pipeline treats a slice exactly like a source). */ | |
| 104 | + readonly specText: string; | |
| 105 | + readonly pathCount: number; | |
| 106 | + readonly operationCount: number; | |
| 107 | + readonly schemaCount: number; | |
| 108 | +} | |
| 109 | + | |
| 110 | +const HTTP_METHODS = new Set(["delete", "get", "head", "options", "patch", "post", "put", "trace"]); | |
| 111 | + | |
| 112 | +const countOperations = (paths: Record<string, unknown>): number => { | |
| 113 | + let count = 0; | |
| 114 | + for (const pathItem of Object.values(paths)) { | |
| 115 | + if (!isRecord(pathItem)) continue; | |
| 116 | + for (const key of Object.keys(pathItem)) { | |
| 117 | + if (HTTP_METHODS.has(key.toLowerCase())) count += 1; | |
| 118 | + } | |
| 119 | + } | |
| 120 | + return count; | |
| 121 | +}; | |
| 122 | + | |
| 123 | +/** | |
| 124 | + * Build one preset selection's slice from the parsed Graph document: keep the | |
| 125 | + * selection's path-items (via the same `microsoftGraphKeepPathItem` filter the | |
| 126 | + * runtime applies), retain the small component sections whole, and prune | |
| 127 | + * `components.schemas` to the transitive `$ref` closure of everything kept. | |
| 128 | + */ | |
| 129 | +export const buildGraphSliceDocument = ( | |
| 130 | + doc: Record<string, unknown>, | |
| 131 | + presetIds: readonly string[], | |
| 132 | +): GraphSliceBuild => { | |
| 133 | + const keepPathItem = microsoftGraphKeepPathItem({ | |
| 134 | + coversFullGraph: false, | |
| 135 | + presetIds, | |
| 136 | + customScopes: [], | |
| 137 | + exactPaths: microsoftGraphExactPathsForPresetIds(presetIds), | |
| 138 | + pathPrefixes: microsoftGraphPathPrefixesForPresetIds(presetIds), | |
| 139 | + tagPrefixes: microsoftGraphTagPrefixesForPresetIds(presetIds), | |
| 140 | + }); | |
| 141 | + | |
| 142 | + const sourcePaths = isRecord(doc.paths) ? doc.paths : {}; | |
| 143 | + const paths: Record<string, unknown> = {}; | |
| 144 | + for (const [path, pathItem] of Object.entries(sourcePaths)) { | |
| 145 | + if (!isRecord(pathItem)) continue; | |
| 146 | + const kept = keepPathItem(path, pathItem); | |
| 147 | + if (kept) paths[path] = kept; | |
| 148 | + } | |
| 149 | + | |
| 150 | + const sourceComponents = isRecord(doc.components) ? doc.components : {}; | |
| 151 | + const closure = componentClosure(sourceComponents, [paths]); | |
| 152 | + const components: Record<string, unknown> = {}; | |
| 153 | + for (const section of SMALL_COMPONENT_SECTIONS) { | |
| 154 | + if (closure[section]) components[section] = closure[section]; | |
| 155 | + } | |
| 156 | + // securitySchemes are never `$ref`'d from operations by name in Graph | |
| 157 | + // (operations reference them via `security` entries), so retain the section | |
| 158 | + // whole — it is tiny and the runtime reads OAuth endpoints from it. | |
| 159 | + if (isRecord(sourceComponents.securitySchemes)) { | |
| 160 | + components.securitySchemes = sourceComponents.securitySchemes; | |
| 161 | + } | |
| 162 | + const schemas = closure.schemas ?? {}; | |
| 163 | + components.schemas = schemas; | |
| 164 | + | |
| 165 | + const slice: Record<string, unknown> = { | |
| 166 | + ...(doc.openapi !== undefined ? { openapi: doc.openapi } : {}), | |
| 167 | + ...(doc.info !== undefined ? { info: doc.info } : {}), | |
| 168 | + ...(doc.servers !== undefined ? { servers: doc.servers } : {}), | |
| 169 | + ...(doc.security !== undefined ? { security: doc.security } : {}), | |
| 170 | + paths, | |
| 171 | + components, | |
| 172 | + }; | |
| 173 | + | |
| 174 | + // noRefs duplicates shared subtrees instead of emitting YAML anchors, which | |
| 175 | + // the streamable block profile forbids; lineWidth -1 keeps scalars on one | |
| 176 | + // line so no wrapped line can be mistaken for structure. | |
| 177 | + const specText = dumpYamlDocument(slice, { noRefs: true, lineWidth: -1, schema: JSON_SCHEMA }); | |
| 178 | + | |
| 179 | + return { | |
| 180 | + specText, | |
| 181 | + pathCount: Object.keys(paths).length, | |
| 182 | + operationCount: countOperations(paths), | |
| 183 | + schemaCount: Object.keys(schemas).length, | |
| 184 | + }; | |
| 185 | +}; | |
| 186 | + | |
| 187 | +/** Parse Graph source YAML for slice building, or null when the source does | |
| 188 | + * not parse to an object. Offline-only: this is the whole-document parse the | |
| 189 | + * runtime can never do. */ | |
| 190 | +export const parseGraphSourceDocument = (sourceText: string): Record<string, unknown> | null => { | |
| 191 | + const parsed = parseYamlDocument(sourceText, { json: true, schema: JSON_SCHEMA }); | |
| 192 | + return isRecord(parsed) ? parsed : null; | |
| 193 | +}; | |
| @@ -0,0 +1,70 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { MICROSOFT_GRAPH_ALL_PRESET_IDS, MICROSOFT_GRAPH_DEFAULT_PRESET_IDS } from "./presets"; | |
| 4 | +import { | |
| 5 | + MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET, | |
| 6 | + microsoftGraphSliceAssetForSelection, | |
| 7 | +} from "./slices"; | |
| 8 | + | |
| 9 | +describe("microsoftGraphSliceAssetForSelection", () => { | |
| 10 | + it("maps a single catalog preset to its asset", () => { | |
| 11 | + expect( | |
| 12 | + microsoftGraphSliceAssetForSelection({ | |
| 13 | + coversFullGraph: false, | |
| 14 | + presetIds: ["mail"], | |
| 15 | + customScopes: [], | |
| 16 | + }), | |
| 17 | + ).toBe("mail"); | |
| 18 | + }); | |
| 19 | + | |
| 20 | + it("maps the default bundle in any order to the default asset", () => { | |
| 21 | + expect( | |
| 22 | + microsoftGraphSliceAssetForSelection({ | |
| 23 | + coversFullGraph: false, | |
| 24 | + presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS].reverse(), | |
| 25 | + customScopes: [], | |
| 26 | + }), | |
| 27 | + ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("serves combinations within the default bundle from the default slice", () => { | |
| 31 | + expect( | |
| 32 | + microsoftGraphSliceAssetForSelection({ | |
| 33 | + coversFullGraph: false, | |
| 34 | + presetIds: ["mail", "calendar"], | |
| 35 | + customScopes: [], | |
| 36 | + }), | |
| 37 | + ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it("needs the monolith for full-graph, custom scopes, unknown presets, and combinations outside the default bundle", () => { | |
| 41 | + expect( | |
| 42 | + microsoftGraphSliceAssetForSelection({ | |
| 43 | + coversFullGraph: true, | |
| 44 | + presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | |
| 45 | + customScopes: [], | |
| 46 | + }), | |
| 47 | + ).toBeNull(); | |
| 48 | + expect( | |
| 49 | + microsoftGraphSliceAssetForSelection({ | |
| 50 | + coversFullGraph: false, | |
| 51 | + presetIds: ["mail"], | |
| 52 | + customScopes: ["Chat.Read"], | |
| 53 | + }), | |
| 54 | + ).toBeNull(); | |
| 55 | + expect( | |
| 56 | + microsoftGraphSliceAssetForSelection({ | |
| 57 | + coversFullGraph: false, | |
| 58 | + presetIds: ["not-a-preset"], | |
| 59 | + customScopes: [], | |
| 60 | + }), | |
| 61 | + ).toBeNull(); | |
| 62 | + expect( | |
| 63 | + microsoftGraphSliceAssetForSelection({ | |
| 64 | + coversFullGraph: false, | |
| 65 | + presetIds: ["mail", "users"], | |
| 66 | + customScopes: [], | |
| 67 | + }), | |
| 68 | + ).toBeNull(); | |
| 69 | + }); | |
| 70 | +}); | |
| @@ -0,0 +1,87 @@ | ||
| 1 | +import { Effect } from "effect"; | |
| 2 | +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | |
| 3 | + | |
| 4 | +import { OpenApiParseError } from "../../sdk/errors"; | |
| 5 | + | |
| 6 | +import { MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, microsoftGraphPresetForId } from "./presets"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Runtime access to precomputed Microsoft Graph slices. | |
| 10 | + * | |
| 11 | + * The 43MB Graph monolith cannot be processed in a 128MB Workers isolate — in | |
| 12 | + * production its fetch alone completed once in the 30 days before 2026-08-26; | |
| 13 | + * every other preview/add died mid-download with an empty 503. Slices are | |
| 14 | + * built offline (`slice-build.ts`, refreshed by the graph-slices workflow), | |
| 15 | + * published as release assets, and fetched here per selection: the isolate | |
| 16 | + * only ever holds the sub-megabyte filtered document for the selection. | |
| 17 | + */ | |
| 18 | + | |
| 19 | +export const MICROSOFT_GRAPH_SLICE_RELEASE_TAG = "graph-slices"; | |
| 20 | + | |
| 21 | +export const MICROSOFT_GRAPH_SLICE_BASE_URL = `https://github.com/UsefulSoftwareCo/executor/releases/download/${MICROSOFT_GRAPH_SLICE_RELEASE_TAG}`; | |
| 22 | + | |
| 23 | +/** Asset name for the default catalog bundle (`MICROSOFT_GRAPH_DEFAULT_PRESET_IDS`). */ | |
| 24 | +export const MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET = "default"; | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * The published asset covering a selection, or null when the selection needs | |
| 28 | + * the monolith. A slice may be a superset of the selection: the runtime always | |
| 29 | + * applies the selection's `keepPathItem` filter to whatever source it reads, so | |
| 30 | + * any combination within the default bundle can be served from the default | |
| 31 | + * slice and narrowed in-band. The monolith remains necessary for full-graph | |
| 32 | + * coverage, custom scopes (scope matching walks operations outside any | |
| 33 | + * preset's paths), and combinations reaching outside the default bundle | |
| 34 | + * (precomputing every combination is combinatorial). | |
| 35 | + */ | |
| 36 | +export const microsoftGraphSliceAssetForSelection = (selection: { | |
| 37 | + readonly coversFullGraph: boolean; | |
| 38 | + readonly presetIds: readonly string[]; | |
| 39 | + readonly customScopes: readonly string[]; | |
| 40 | +}): string | null => { | |
| 41 | + if (selection.coversFullGraph) return null; | |
| 42 | + if (selection.customScopes.length > 0) return null; | |
| 43 | + if (selection.presetIds.length === 0) return null; | |
| 44 | + if (selection.presetIds.some((presetId) => !microsoftGraphPresetForId(presetId))) return null; | |
| 45 | + if (selection.presetIds.length === 1) return selection.presetIds[0]!; | |
| 46 | + const defaultIds = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); | |
| 47 | + if (selection.presetIds.every((presetId) => defaultIds.has(presetId))) { | |
| 48 | + return MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET; | |
| 49 | + } | |
| 50 | + return null; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +export const microsoftGraphSliceUrl = (asset: string): string => | |
| 54 | + `${MICROSOFT_GRAPH_SLICE_BASE_URL}/${encodeURIComponent(asset)}.yaml`; | |
| 55 | + | |
| 56 | +export const fetchMicrosoftGraphSlice = Effect.fn("Microsoft.fetchGraphSlice")(function* ( | |
| 57 | + asset: string, | |
| 58 | +) { | |
| 59 | + const client = yield* HttpClient.HttpClient; | |
| 60 | + const response = yield* client | |
| 61 | + .execute( | |
| 62 | + HttpClientRequest.get(microsoftGraphSliceUrl(asset)).pipe( | |
| 63 | + HttpClientRequest.setHeader("Accept", "application/yaml, text/yaml, */*"), | |
| 64 | + ), | |
| 65 | + ) | |
| 66 | + .pipe( | |
| 67 | + Effect.mapError( | |
| 68 | + () => | |
| 69 | + new OpenApiParseError({ | |
| 70 | + message: `Failed to fetch Microsoft Graph slice: ${asset}`, | |
| 71 | + }), | |
| 72 | + ), | |
| 73 | + ); | |
| 74 | + if (response.status < 200 || response.status >= 300) { | |
| 75 | + return yield* new OpenApiParseError({ | |
| 76 | + message: `Failed to fetch Microsoft Graph slice ${asset}: HTTP ${response.status}`, | |
| 77 | + }); | |
| 78 | + } | |
| 79 | + return yield* response.text.pipe( | |
| 80 | + Effect.mapError( | |
| 81 | + () => | |
| 82 | + new OpenApiParseError({ | |
| 83 | + message: `Failed to read Microsoft Graph slice body: ${asset}`, | |
| 84 | + }), | |
| 85 | + ), | |
| 86 | + ); | |
| 87 | +}); | |
| @@ -6,6 +6,7 @@ import { structuralSplit } from "@executor-js/plugin-openapi"; · 5 unmodified lines | ||
| 6 | 6 | import { previewSpecTextStreaming } from "../../sdk/preview"; |
| 7 | 7 | import { microsoftGraphAdapter } from "./spec-format-adapter"; |
| 8 | 8 | import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; |
| 9 | +import { microsoftGraphSliceUrl } from "./slices"; | |
| 9 | 10 | |
| 10 | 11 | const graphFixture = ` |
| 11 | 12 | openapi: 3.0.4 |
| @@ -92,6 +93,80 @@ it.effect("uses catalog URL fragments to select one Graph workload", () => · 80 unmodified lines | ||
| 92 | 93 | }), |
| 93 | 94 | ); |
| 94 | 95 | |
| 96 | +// Distinct content at the slice URL so tests can tell which source was read. | |
| 97 | +const sliceFixture = `openapi: 3.0.4 | |
| 98 | +info: | |
| 99 | + title: Microsoft Graph Slice Fixture | |
| 100 | + version: v1.0 | |
| 101 | +servers: | |
| 102 | + - url: https://graph.microsoft.com/v1.0 | |
| 103 | +paths: | |
| 104 | + /me: | |
| 105 | + get: | |
| 106 | + operationId: me.GetUser | |
| 107 | + security: | |
| 108 | + - azureAdDelegated: | |
| 109 | + - User.Read | |
| 110 | + responses: | |
| 111 | + "200": | |
| 112 | + description: OK | |
| 113 | +components: | |
| 114 | + securitySchemes: | |
| 115 | + azureAdDelegated: | |
| 116 | + type: oauth2 | |
| 117 | + flows: | |
| 118 | + authorizationCode: | |
| 119 | + authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize | |
| 120 | + tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token | |
| 121 | + scopes: | |
| 122 | + User.Read: Read user profile | |
| 123 | +`; | |
| 124 | + | |
| 125 | +const sliceAwareHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | |
| 126 | + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | |
| 127 | + const body = | |
| 128 | + request.url === microsoftGraphSliceUrl("profile") | |
| 129 | + ? sliceFixture | |
| 130 | + : request.url === MICROSOFT_GRAPH_OPENAPI_URL | |
| 131 | + ? graphFixture | |
| 132 | + : null; | |
| 133 | + return Effect.succeed( | |
| 134 | + HttpClientResponse.fromWeb( | |
| 135 | + request, | |
| 136 | + new Response(body ?? "not found", { status: body === null ? 404 : 200 }), | |
| 137 | + ), | |
| 138 | + ); | |
| 139 | + }), | |
| 140 | +); | |
| 141 | + | |
| 142 | +it.effect("reads the published slice for a covered selection", () => | |
| 143 | + Effect.gen(function* () { | |
| 144 | + const converted = yield* microsoftGraphAdapter.fetch({ | |
| 145 | + urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], | |
| 146 | + httpClientLayer: sliceAwareHttpClientLayer, | |
| 147 | + }); | |
| 148 | + | |
| 149 | + expect(converted.specText).toBe(sliceFixture); | |
| 150 | + // The catalog URL (with fragment stripped) stays canonical so refresh | |
| 151 | + // re-resolves through the adapter, not the slice hosting. | |
| 152 | + expect(converted.specUrl).toBe(MICROSOFT_GRAPH_OPENAPI_URL); | |
| 153 | + }), | |
| 154 | +); | |
| 155 | + | |
| 156 | +it.effect("falls back to the monolith when the slice asset is unavailable", () => | |
| 157 | + Effect.gen(function* () { | |
| 158 | + // graphHttpClientLayer 404s everything except the monolith URL, including | |
| 159 | + // the slice URL — the existing selection tests above exercise this same | |
| 160 | + // fallback implicitly. | |
| 161 | + const converted = yield* microsoftGraphAdapter.fetch({ | |
| 162 | + urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], | |
| 163 | + httpClientLayer: graphHttpClientLayer, | |
| 164 | + }); | |
| 165 | + | |
| 166 | + expect(converted.specText).toBe(graphFixture); | |
| 167 | + }), | |
| 168 | +); | |
| 169 | + | |
| 95 | 170 | it.effect("stream-previews a Graph selection without a whole-document parse", () => |
| 96 | 171 | Effect.gen(function* () { |
| 97 | 172 | const converted = yield* microsoftGraphAdapter.fetch({ |