diff --git a/.changeset/google-photos-openapi-presets.md b/.changeset/google-photos-openapi-presets.md new file mode 100644 index 000000000..f0a77dd3c --- /dev/null +++ b/.changeset/google-photos-openapi-presets.md @@ -0,0 +1,6 @@ +--- +"@executor-js/plugin-google": patch +"@executor-js/plugin-openapi": patch +--- + +Add a Google Photos preset with raw upload support and binary-safe `bodyBase64` handling. diff --git a/e2e/scenarios/google-photos-preset-ui.test.ts b/e2e/scenarios/google-photos-preset-ui.test.ts new file mode 100644 index 000000000..56c94bdf5 --- /dev/null +++ b/e2e/scenarios/google-photos-preset-ui.test.ts @@ -0,0 +1,43 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +scenario( + "Google Photos: the focused preset opens a Photos-scoped add flow", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Find the Google Photos preset from the integrations picker", async () => { + await page.goto("/integrations", { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Connect" }).click(); + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + await dialog.waitFor(); + await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); + await dialog.getByRole("link", { name: /^Google Photos\b/ }).waitFor(); + }); + + await step("Open the Google Photos scoped add flow", async () => { + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + await dialog.getByRole("link", { name: /^Google Photos\b/ }).click(); + await page.waitForURL(/\/integrations\/add\/google/); + await page.getByRole("heading", { name: "Add Google" }).waitFor(); + }); + + await step("The Photos preset defaults to the focused namespace and products", async () => { + await page.locator('input[value="Google Photos"]').waitFor(); + await page.locator('input[value="google_photos"]').waitFor(); + await page.getByText("Google Photos Library").first().waitFor(); + await page.getByText("Google Photos Picker").first().waitFor(); + await page.getByText("2 Google APIs").waitFor(); + expect(await page.locator('input[value="Google"]').count()).toBe(0); + expect(await page.locator('input[value="google"]').count()).toBe(0); + }); + }); + }), +); diff --git a/packages/plugins/google/src/react/AddGoogleSource.tsx b/packages/plugins/google/src/react/AddGoogleSource.tsx index deba66440..a98445d33 100644 --- a/packages/plugins/google/src/react/AddGoogleSource.tsx +++ b/packages/plugins/google/src/react/AddGoogleSource.tsx @@ -20,7 +20,12 @@ import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; import { addGoogleBundle } from "./atoms"; import { GoogleProductPicker } from "./GoogleProductPicker"; -import { googleOpenApiPresets, type GoogleOpenApiPreset } from "../sdk/presets"; +import { + GOOGLE_PHOTOS_PRESET_ID, + googleOpenApiPresets, + googlePhotosPresetIds, + type GoogleOpenApiPreset, +} from "../sdk/presets"; const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; @@ -43,10 +48,12 @@ const googleBundleUrls = ( export default function AddGoogleSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; + initialPreset?: string; initialNamespace?: string; }) { + const isGooglePhotosPreset = props.initialPreset === GOOGLE_PHOTOS_PRESET_ID; const [selectedPresetIds, setSelectedPresetIds] = useState>( - googleBundleDefaultPresetIds, + isGooglePhotosPreset ? new Set(googlePhotosPresetIds) : googleBundleDefaultPresetIds, ); const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState([]); const [baseUrl, setBaseUrl] = useState(""); @@ -55,8 +62,9 @@ export default function AddGoogleSource(props: { const [addError, setAddError] = useState(null); const identity = useIntegrationIdentity({ - fallbackName: "Google", - fallbackNamespace: props.initialNamespace ?? "google", + fallbackName: isGooglePhotosPreset ? "Google Photos" : "Google", + fallbackNamespace: + props.initialNamespace ?? (isGooglePhotosPreset ? "google_photos" : "google"), }); const bundleDiscoveryUrls = useMemo( @@ -87,9 +95,15 @@ export default function AddGoogleSource(props: { const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); - const resolvedSourceId = slugifyNamespace(identity.namespace) || "google"; - const resolvedDisplayName = identity.name.trim() || "Google"; - const resolvedDescription = descriptionDraft ?? "Google APIs"; + const resolvedSourceId = + slugifyNamespace(identity.namespace) || (isGooglePhotosPreset ? "google_photos" : "google"); + const resolvedDisplayName = + identity.name.trim() || (isGooglePhotosPreset ? "Google Photos" : "Google"); + const resolvedDescription = + descriptionDraft ?? + (isGooglePhotosPreset + ? "Google Photos albums, uploads, app-created media, and selected picker media." + : "Google APIs"); const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists; diff --git a/packages/plugins/google/src/react/source-plugin.ts b/packages/plugins/google/src/react/source-plugin.ts index add1ce9e2..88df75282 100644 --- a/packages/plugins/google/src/react/source-plugin.ts +++ b/packages/plugins/google/src/react/source-plugin.ts @@ -1,6 +1,6 @@ import { lazy } from "react"; import type { IntegrationPlugin } from "@executor-js/sdk/client"; -import { googleOpenApiBundlePreset } from "../sdk/presets"; +import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets"; const importAdd = () => import("./AddGoogleSource"); const importAccounts = () => import("./GoogleAccountsPanel"); @@ -10,7 +10,7 @@ export const googleIntegrationPlugin: IntegrationPlugin = { label: "Google", add: lazy(importAdd), accounts: lazy(importAccounts), - presets: [googleOpenApiBundlePreset], + presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], preload: () => { void importAdd(); void importAccounts(); diff --git a/packages/plugins/google/src/sdk/discovery.ts b/packages/plugins/google/src/sdk/discovery.ts index cffa53de9..bc4c0431a 100644 --- a/packages/plugins/google/src/sdk/discovery.ts +++ b/packages/plugins/google/src/sdk/discovery.ts @@ -66,12 +66,13 @@ type OpenApiOperationObject = { readonly servers?: readonly { readonly url: string }[]; readonly parameters: readonly OpenApiParameterObject[]; readonly requestBody?: { - readonly required: false; - readonly content: { - readonly "application/json": { + readonly required: boolean; + readonly content: Record< + string, + { readonly schema: OpenApiSchemaObject; - }; - }; + } + >; }; readonly responses: { readonly "200": { @@ -605,6 +606,7 @@ const buildDiscoveryOperation = (input: { readonly method: DiscoveryMethod; readonly toolPath: string; readonly pathTemplate: string; + readonly oauthScopes?: readonly string[]; readonly schemaNameForRef?: (name: string) => string; readonly serverUrl?: string; readonly tags?: readonly string[]; @@ -619,7 +621,7 @@ const buildDiscoveryOperation = (input: { if (parameter.location) mergedParameters.set(name, parameter); } - const methodScopes = input.method.scopes ?? []; + const methodScopes = input.oauthScopes ?? input.method.scopes ?? []; const methodDescription = Option.getOrUndefined(input.method.description); const schemaNameForRef = input.schemaNameForRef ?? identitySchemaName; @@ -674,6 +676,14 @@ const buildDiscoveryOperation = (input: { }; const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; +const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary"; +const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; +const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"; +const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; +const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; + +const isGooglePhotosService = (service: string): boolean => + service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; /** The v2 oauth auth template for a Google-discovery integration. The spec * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the @@ -695,6 +705,72 @@ const googleOauthTemplate = (scopes: Record): readonly Authentic }, ]; +const googlePhotosUploadOperation = (input: { + readonly toolPath: string; + readonly oauthScopes: readonly string[]; + readonly serverUrl: string; + readonly tags?: readonly string[]; +}): OpenApiOperationObject => ({ + operationId: input.toolPath, + "x-executor-toolPath": input.toolPath, + "x-executor-pathTemplate": GOOGLE_PHOTOS_UPLOAD_PATH, + ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), + description: + "Uploads raw photo or video bytes to Google Photos and returns a plain-text upload token. Call mediaItems.batchCreate with the returned token to create the media item.", + servers: [{ url: input.serverUrl }], + parameters: [ + { + name: "X-Goog-Upload-File-Name", + in: "header", + required: true, + description: "File name Google Photos should associate with the uploaded bytes.", + schema: { type: "string" }, + }, + { + name: "X-Goog-Upload-Protocol", + in: "header", + required: true, + description: "Google Photos raw upload protocol. Set to raw.", + schema: { type: "string", enum: ["raw"], default: "raw" }, + }, + { + name: "X-Goog-Upload-Content-Type", + in: "header", + required: false, + description: "MIME type of the uploaded media, for example image/jpeg or video/mp4.", + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + responses: { + "200": { + description: "Successful response", + content: { + "text/plain": { + schema: { type: "string" }, + }, + }, + }, + }, + ...(input.oauthScopes.length > 0 ? { security: [{ googleOAuth2: input.oauthScopes }] } : {}), + "x-google-scopes": input.oauthScopes, +}); + +const hasOperation = ( + paths: Record>, + operationId: string, +): boolean => + Object.values(paths).some((pathItem) => + Object.values(pathItem).some((operation) => operation.operationId === operationId), + ); + export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")( function* (input: { readonly discoveryUrl: string; readonly documentText: string }) { const parsed = yield* parseJson(input.documentText).pipe( @@ -738,6 +814,18 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD }); } + if ( + service === GOOGLE_PHOTOS_LIBRARY_SERVICE && + !hasOperation(paths, GOOGLE_PHOTOS_UPLOAD_TOOL_PATH) + ) { + paths[GOOGLE_PHOTOS_UPLOAD_PATH] ??= {}; + paths[GOOGLE_PHOTOS_UPLOAD_PATH]!.post = googlePhotosUploadOperation({ + toolPath: GOOGLE_PHOTOS_UPLOAD_TOOL_PATH, + oauthScopes: [GOOGLE_PHOTOS_APPENDONLY_SCOPE], + serverUrl: baseUrl, + }); + } + const scopes = compactDiscoveryScopeMap(discoveryScopes(document)); const authenticationTemplate = googleOauthTemplate(scopes); @@ -798,6 +886,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( "OpenApi.convertGoogleDiscoveryBundle", )(function* (input: { readonly documents: readonly { readonly discoveryUrl: string; readonly documentText: string }[]; + readonly consentScopes?: readonly string[]; }) { if (input.documents.length === 0) { return yield* new OpenApiParseError({ @@ -829,12 +918,16 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const paths: Record> = {}; const schemas: Record = {}; const rawScopes: Record = {}; + const consentScopeSet = input.consentScopes ? new Set(input.consentScopes) : null; for (const info of infos) { const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; + const scopeDescriptions = discoveryScopes(info.document); + const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); - for (const [scope, description] of Object.entries(discoveryScopes(info.document))) { + for (const [scope, description] of Object.entries(scopeDescriptions)) { + if (filterPhotosScopes && !consentScopeSet.has(scope)) continue; rawScopes[scope] ??= description; } @@ -846,6 +939,11 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const methodId = Option.getOrUndefined(method.id); const rawPathTemplate = Option.getOrUndefined(method.path); if (!methodId || !rawPathTemplate || !method.httpMethod) continue; + const methodScopes = method.scopes ?? []; + const oauthScopes = filterPhotosScopes + ? methodScopes.filter((scope) => consentScopeSet.has(scope)) + : methodScopes; + if (filterPhotosScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue; const toolPath = methodId; const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`; @@ -859,11 +957,26 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( method, toolPath, pathTemplate: wirePath, + oauthScopes, schemaNameForRef, serverUrl: info.baseUrl, tags: [info.title], }); } + + if ( + info.service === GOOGLE_PHOTOS_LIBRARY_SERVICE && + (!consentScopeSet || consentScopeSet.has(GOOGLE_PHOTOS_APPENDONLY_SCOPE)) && + !hasOperation(paths, GOOGLE_PHOTOS_UPLOAD_TOOL_PATH) + ) { + paths[GOOGLE_PHOTOS_UPLOAD_PATH] ??= {}; + paths[GOOGLE_PHOTOS_UPLOAD_PATH]!.post = googlePhotosUploadOperation({ + toolPath: GOOGLE_PHOTOS_UPLOAD_TOOL_PATH, + oauthScopes: [GOOGLE_PHOTOS_APPENDONLY_SCOPE], + serverUrl: info.baseUrl, + tags: [info.title], + }); + } } const scopes = compactDiscoveryScopeMap(rawScopes); diff --git a/packages/plugins/google/src/sdk/index.ts b/packages/plugins/google/src/sdk/index.ts index 62e1d12a4..ce32c1e1c 100644 --- a/packages/plugins/google/src/sdk/index.ts +++ b/packages/plugins/google/src/sdk/index.ts @@ -8,6 +8,11 @@ export { export { googleOpenApiBundlePreset, googleOpenApiPresets, + googlePhotosOpenApiBundlePreset, + googlePhotosOpenApiPresets, + googlePhotosPresetIds, + GOOGLE_PHOTOS_ICON, + GOOGLE_PHOTOS_PRESET_ID, googleStandardUserOAuthPresets, googleOAuthConsentScopes, googleOAuthConsentScopesForPreset, diff --git a/packages/plugins/google/src/sdk/plugin.test.ts b/packages/plugins/google/src/sdk/plugin.test.ts index a0df2b661..213cd50af 100644 --- a/packages/plugins/google/src/sdk/plugin.test.ts +++ b/packages/plugins/google/src/sdk/plugin.test.ts @@ -34,6 +34,8 @@ import { googlePlugin } from "./plugin"; const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"; const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; +const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; +const PHOTOS_PICKER_URL = "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest"; const calendarDoc = { name: "calendar", @@ -141,12 +143,93 @@ const driveDoc = { }, }; +const photosLibraryDoc = { + name: "photoslibrary", + version: "v1", + title: "Google Photos Library API", + rootUrl: "https://photoslibrary.googleapis.com/", + servicePath: "v1/", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/photoslibrary": { + description: "Manage the full Google Photos library", + }, + "https://www.googleapis.com/auth/photoslibrary.appendonly": { + description: "Upload to Google Photos", + }, + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata": { + description: "Read app-created Google Photos media", + }, + }, + }, + }, + resources: { + albums: { + methods: { + list: { + id: "photoslibrary.albums.list", + httpMethod: "GET", + path: "albums", + scopes: ["https://www.googleapis.com/auth/photoslibrary"], + parameters: {}, + }, + }, + }, + mediaItems: { + methods: { + search: { + id: "photoslibrary.mediaItems.search", + httpMethod: "POST", + path: "mediaItems:search", + scopes: ["https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"], + parameters: {}, + }, + }, + }, + }, + schemas: {}, +}; + +const photosPickerDoc = { + name: "photospicker", + version: "v1", + title: "Google Photos Picker API", + rootUrl: "https://photospicker.googleapis.com/", + servicePath: "v1/", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly": { + description: "Read selected Google Photos media", + }, + }, + }, + }, + resources: { + mediaItems: { + methods: { + list: { + id: "photospicker.mediaItems.list", + httpMethod: "GET", + path: "mediaItems", + scopes: ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"], + parameters: {}, + }, + }, + }, + }, + schemas: {}, +}; + const toJson = (value: unknown): string => JSON.stringify(value); const DISCOVERY_BODIES: Readonly> = { [CALENDAR_URL]: toJson(calendarDoc), [GMAIL_URL]: toJson(gmailDoc), [DRIVE_URL]: toJson(driveDoc), + [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc), + [PHOTOS_PICKER_URL]: toJson(photosPickerDoc), }; // A stub HTTP client that serves the canned Discovery document for whichever @@ -174,6 +257,16 @@ const bundlePlugins = () => [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; describe("Google bundle add flow", () => { + it("SDK catalog includes the Google Photos focused preset", () => { + const presetIds = + googlePlugin({ httpClientLayer: discoveryHttpClientLayer }).integrationPresets?.map( + (preset) => preset.id, + ) ?? []; + + expect(presetIds).toContain("google"); + expect(presetIds).toContain("google-photos"); + }); + it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () => Effect.scoped( Effect.gen(function* () { @@ -266,4 +359,124 @@ describe("Google bundle add flow", () => { }), ), ); + + it.effect("addBundle constrains Google Photos to the preset scopes and upload tool", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + yield* executor.google.addBundle({ + urls: [ + PHOTOS_LIBRARY_URL, + "https://photospicker.googleapis.com/$discovery/rest?version=v1", + ], + slug: "google_photos", + name: "Google Photos", + }); + + const config = yield* executor.google.getConfig("google_photos"); + const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( + [ + "https://www.googleapis.com/auth/photoslibrary.appendonly", + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", + ].sort(), + ); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("google_photos"), + template: AuthTemplateSlug.make("googleOAuth2"), + value: "token-xyz", + }); + + const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); + expect(toolNames).toContain("photoslibrary.mediaItems.upload"); + expect(toolNames).toContain("photoslibrary.mediaItems.search"); + expect(toolNames).toContain("photospicker.mediaItems.list"); + expect(toolNames).not.toContain("photoslibrary.albums.list"); + }), + ), + ); + + it.effect("addBundle keeps Google Photos scoped when combined with another API", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + yield* executor.google.addBundle({ + urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL], + slug: "google_photos_calendar", + name: "Google Photos and Calendar", + }); + + const config = yield* executor.google.getConfig("google_photos_calendar"); + const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( + [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/photoslibrary.appendonly", + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", + ].sort(), + ); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("google_photos_calendar"), + template: AuthTemplateSlug.make("googleOAuth2"), + value: "token-xyz", + }); + + const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); + expect(toolNames).toContain("calendar.events.list"); + expect(toolNames).toContain("photoslibrary.mediaItems.upload"); + expect(toolNames).toContain("photoslibrary.mediaItems.search"); + expect(toolNames).toContain("photospicker.mediaItems.list"); + expect(toolNames).not.toContain("photoslibrary.albums.list"); + }), + ), + ); + + it.effect("addBundle scopes a partial Google Photos bundle when mixed with another API", () => + Effect.scoped( + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); + + yield* executor.google.addBundle({ + urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL], + slug: "google_photos_library_calendar", + name: "Google Photos Library and Calendar", + }); + + const config = yield* executor.google.getConfig("google_photos_library_calendar"); + const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( + [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/photoslibrary.appendonly", + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", + ].sort(), + ); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("google_photos_library_calendar"), + template: AuthTemplateSlug.make("googleOAuth2"), + value: "token-xyz", + }); + + const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); + expect(toolNames).toContain("calendar.events.list"); + expect(toolNames).toContain("photoslibrary.mediaItems.upload"); + expect(toolNames).toContain("photoslibrary.mediaItems.search"); + expect(toolNames).not.toContain("photospicker.mediaItems.list"); + expect(toolNames).not.toContain("photoslibrary.albums.list"); + }), + ), + ); }); diff --git a/packages/plugins/google/src/sdk/plugin.ts b/packages/plugins/google/src/sdk/plugin.ts index 597a30b96..bc5ef357c 100644 --- a/packages/plugins/google/src/sdk/plugin.ts +++ b/packages/plugins/google/src/sdk/plugin.ts @@ -36,7 +36,12 @@ import { normalizeGoogleDiscoveryUrl, } from "./discovery"; import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; -import { googleOpenApiBundlePreset } from "./presets"; +import { + googleOAuthConsentScopesForPreset, + googleOpenApiBundlePreset, + googlePhotosOpenApiBundlePreset, + googlePhotosOpenApiPresets, +} from "./presets"; export interface GoogleBundleConfig { readonly urls: readonly string[]; @@ -68,6 +73,24 @@ export interface GooglePluginOptions { const DEFAULT_GOOGLE_SLUG = "google"; +const googlePhotosBundlePresetIdByUrl = new Map( + googlePhotosOpenApiPresets.flatMap((preset) => + preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [], + ), +); + +const googlePhotosBundleConsentScopes = ( + urls: readonly string[], +): readonly string[] | undefined => { + const normalized = new Set(urls); + const presetIds = [...googlePhotosBundlePresetIdByUrl.entries()].flatMap(([url, presetId]) => + normalized.has(url) ? [presetId] : [], + ); + return presetIds.length > 0 + ? presetIds.flatMap((presetId) => googleOAuthConsentScopesForPreset(presetId)) + : undefined; +}; + const fetchGoogleBundleConversion = ( urls: readonly string[], httpClientLayer: Layer.Layer, @@ -80,7 +103,15 @@ const fetchGoogleBundleConversion = ( Effect.map((documentText) => ({ discoveryUrl: url, documentText })), ), { concurrency: 4 }, - ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); + ).pipe( + Effect.flatMap((documents) => { + const consentScopes = googlePhotosBundleConsentScopes(urls); + return convertGoogleDiscoveryBundleToOpenApi({ + documents, + ...(consentScopes ? { consentScopes } : {}), + }); + }), + ); const uniqueUrls = (urls: readonly string[]): readonly string[] => [ ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])), @@ -292,7 +323,7 @@ export type GooglePluginExtension = ReturnType export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ id: "google" as const, packageName: "@executor-js/plugin-google", - integrationPresets: [googleOpenApiBundlePreset], + integrationPresets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), extension: (ctx: PluginCtx) => makeGooglePluginExtension(options, ctx), diff --git a/packages/plugins/google/src/sdk/presets.ts b/packages/plugins/google/src/sdk/presets.ts index e369c369d..896326b17 100644 --- a/packages/plugins/google/src/sdk/presets.ts +++ b/packages/plugins/google/src/sdk/presets.ts @@ -24,6 +24,9 @@ const gd = (service: string, version: string) => const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; export const GOOGLE_BUNDLE_PRESET_ID = "google"; +export const GOOGLE_PHOTOS_PRESET_ID = "google-photos"; +export const GOOGLE_PHOTOS_ICON = + "https://www.gstatic.com/images/branding/product/2x/photos_96dp.png"; export const googleOpenApiBundlePreset: GooglePreset = { id: GOOGLE_BUNDLE_PRESET_ID, @@ -33,6 +36,14 @@ export const googleOpenApiBundlePreset: GooglePreset = { featured: true, }; +export const googlePhotosOpenApiBundlePreset: GooglePreset = { + id: GOOGLE_PHOTOS_PRESET_ID, + name: "Google Photos", + summary: "Albums, uploads, app-created media, and user-selected picker media.", + icon: GOOGLE_PHOTOS_ICON, + featured: true, +}; + export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [ { id: "google-calendar", @@ -111,6 +122,22 @@ export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [ icon: "https://fonts.gstatic.com/s/i/productlogos/contacts_2022/v2/192px.svg", oauthAudience: "standard-user", }, + { + id: "google-photos-library", + name: "Google Photos Library", + summary: "Albums, uploads, and app-created media through Google Photos.", + url: gd("photoslibrary", "v1"), + icon: GOOGLE_PHOTOS_ICON, + oauthAudience: "advanced-user", + }, + { + id: "google-photos-picker", + name: "Google Photos Picker", + summary: "Picker sessions and user-selected Google Photos media items.", + url: "https://photospicker.googleapis.com/$discovery/rest?version=v1", + icon: GOOGLE_PHOTOS_ICON, + oauthAudience: "advanced-user", + }, { id: "google-chat", name: "Google Chat", @@ -197,6 +224,14 @@ export const googleStandardUserOAuthPresets = googleOpenApiPresets.filter( (preset) => preset.oauthAudience === "standard-user", ); +export const googlePhotosPresetIds: readonly string[] = [ + "google-photos-library", + "google-photos-picker", +]; + +export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = + googleOpenApiPresets.filter((preset) => googlePhotosPresetIds.includes(preset.id)); + // --------------------------------------------------------------------------- // Representative consent scopes per preset. // @@ -221,6 +256,11 @@ export const googleOAuthConsentScopes: Readonly | undefined => { const properties: Record = {}; const required: string[] = []; + let requiredBodyAlternatives: readonly { readonly required: readonly string[] }[] | undefined; for (const param of parameters) { properties[param.name] = Option.getOrElse(param.schema, () => ({ type: "string" })); @@ -327,14 +328,45 @@ export const buildInputSchema = ( if (serverProperty && !("server" in properties)) properties.server = serverProperty; if (requestBody) { - properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" })); - if (requestBody.required) required.push("body"); - // When the spec declares multiple media types for this requestBody, // expose `contentType` so the model can pick. Default = first declared. - // `body` schema tracks the default; the model is responsible for - // supplying a body shape that matches whichever contentType it picks. + // For mixed bodies, `body` schema tracks the default; the model is + // responsible for supplying a body shape that matches whichever + // contentType it picks. Octet-only operations use `bodyBase64` instead. const contents = Option.getOrUndefined(requestBody.contents); + const defaultIsOctetStream = + requestBody.contentType.split(";")[0]?.trim().toLowerCase() === "application/octet-stream"; + const acceptsOctetStream = + defaultIsOctetStream || + contents?.some( + (content) => + content.contentType.split(";")[0]?.trim().toLowerCase() === "application/octet-stream", + ) === true; + const acceptsBody = + !defaultIsOctetStream || + contents?.some( + (content) => + content.contentType.split(";")[0]?.trim().toLowerCase() !== "application/octet-stream", + ) === true; + if (acceptsBody) { + properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" })); + } + if (acceptsOctetStream) { + properties.bodyBase64 = { + type: "string", + contentEncoding: "base64", + contentMediaType: "application/octet-stream", + description: + "Base64-encoded bytes for application/octet-stream request bodies. When contentType is omitted, this selects application/octet-stream.", + }; + } + if (requestBody.required) { + if (acceptsOctetStream && acceptsBody) { + requiredBodyAlternatives = [{ required: ["body"] }, { required: ["bodyBase64"] }]; + } else { + required.push(acceptsOctetStream ? "bodyBase64" : "body"); + } + } if (contents && contents.length > 1) { properties.contentType = { type: "string", @@ -352,6 +384,7 @@ export const buildInputSchema = ( type: "object", properties, ...(required.length > 0 ? { required } : {}), + ...(requiredBodyAlternatives ? { anyOf: requiredBodyAlternatives } : {}), additionalProperties: false, }; }; diff --git a/packages/plugins/openapi/src/sdk/invoke.ts b/packages/plugins/openapi/src/sdk/invoke.ts index f17bfc558..d9260b9b5 100644 --- a/packages/plugins/openapi/src/sdk/invoke.ts +++ b/packages/plugins/openapi/src/sdk/invoke.ts @@ -272,6 +272,28 @@ const bytesFromBase64Prefix = (base64: string): Uint8Array => { const sniffMimeTypeFromBase64 = (base64: string): string | null => sniffMimeType(bytesFromBase64Prefix(base64)); +type DecodedBase64Body = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false }; + +const base64ToUint8Array = (value: string): Uint8Array | null => { + let binary = ""; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: atob throws for invalid base64; invalid shapes are treated as non-byte input + try { + binary = atob(normalizeBase64(value, "base64")); + } catch { + return null; + } + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; + +const decodeBase64Body = (value: string): DecodedBase64Body => { + const bytes = base64ToUint8Array(value); + return bytes ? { ok: true, bytes } : { ok: false }; +}; + const toUint8Array = (value: unknown): Uint8Array | null => { if (value instanceof Uint8Array) return value; if (value instanceof ArrayBuffer) return new Uint8Array(value); @@ -285,6 +307,14 @@ const toUint8Array = (value: unknown): Uint8Array | null => { return null; }; +const readNestedBodyBase64 = (value: unknown): unknown => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.hasOwnProperty.call(value, "bodyBase64") + ? (value as Record).bodyBase64 + : undefined; + const readHintString = (option: OperationFileHint["dataField"], fallback: string): string => Option.getOrElse(option, () => fallback); @@ -663,18 +693,101 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( if (Option.isSome(operation.requestBody)) { const rb = operation.requestBody.value; - const bodyValue = args.body ?? args.input; + const contentsOpt = Option.getOrUndefined(rb.contents); + const requestedCt = typeof args.contentType === "string" ? args.contentType : undefined; + const octetStreamContent = contentsOpt?.find((c) => isOctetStream(c.contentType)); + const bodyAcceptsOctetStream = Boolean(octetStreamContent) || isOctetStream(rb.contentType); + const hasBodyBase64 = Object.prototype.hasOwnProperty.call(args, "bodyBase64"); + const bodyBase64Raw = args.bodyBase64; + const bodyBase64 = + typeof bodyBase64Raw === "string" ? decodeBase64Body(bodyBase64Raw) : undefined; + + if (hasBodyBase64 && typeof bodyBase64Raw !== "string") { + return yield* new OpenApiInvocationError({ + message: "`bodyBase64` must be a base64 string", + statusCode: Option.none(), + }); + } + if (bodyBase64?.ok === false) { + return yield* new OpenApiInvocationError({ + message: "`bodyBase64` is not valid base64", + statusCode: Option.none(), + }); + } + if (bodyBase64?.ok === true && !bodyAcceptsOctetStream) { + return yield* new OpenApiInvocationError({ + message: "`bodyBase64` requires an application/octet-stream request body", + statusCode: Option.none(), + }); + } + if (bodyBase64?.ok === true && requestedCt && !isOctetStream(requestedCt)) { + return yield* new OpenApiInvocationError({ + message: "`bodyBase64` requires an application/octet-stream contentType", + statusCode: Option.none(), + }); + } + + const rawBodyValue = args.body ?? args.input; + const nestedBodyBase64Raw = readNestedBodyBase64(rawBodyValue); + const hasNestedBodyBase64 = nestedBodyBase64Raw !== undefined; + const nestedBodyBase64 = + typeof nestedBodyBase64Raw === "string" ? decodeBase64Body(nestedBodyBase64Raw) : undefined; + + if (hasNestedBodyBase64 && typeof nestedBodyBase64Raw !== "string") { + return yield* new OpenApiInvocationError({ + message: "`body.bodyBase64` must be a base64 string", + statusCode: Option.none(), + }); + } + if (nestedBodyBase64?.ok === false) { + return yield* new OpenApiInvocationError({ + message: "`body.bodyBase64` is not valid base64", + statusCode: Option.none(), + }); + } + if (nestedBodyBase64?.ok === true && !bodyAcceptsOctetStream) { + return yield* new OpenApiInvocationError({ + message: "`body.bodyBase64` requires an application/octet-stream request body", + statusCode: Option.none(), + }); + } + if (nestedBodyBase64?.ok === true && requestedCt && !isOctetStream(requestedCt)) { + return yield* new OpenApiInvocationError({ + message: "`body.bodyBase64` requires an application/octet-stream contentType", + statusCode: Option.none(), + }); + } + + const binaryBody = bodyBase64?.ok === true ? bodyBase64 : nestedBodyBase64; + const bodyValue = binaryBody?.ok === true ? binaryBody.bytes : rawBodyValue; + if (rb.required && bodyValue === undefined) { + return yield* new OpenApiInvocationError({ + message: bodyAcceptsOctetStream + ? "Missing required request body: provide `bodyBase64`" + : "Missing required request body", + statusCode: Option.none(), + }); + } if (bodyValue !== undefined) { // Resolve which declared media type to use. When the spec declares // multiple, the caller can override via `args.contentType`; otherwise // we use the first-declared (spec author's preferred ordering). - const contentsOpt = Option.getOrUndefined(rb.contents); - const requestedCt = typeof args.contentType === "string" ? args.contentType : undefined; const selected: MediaBinding | undefined = - contentsOpt && requestedCt - ? contentsOpt.find((c) => c.contentType === requestedCt) - : undefined; - const chosenCt = selected?.contentType ?? rb.contentType; + binaryBody?.ok === true && octetStreamContent + ? octetStreamContent + : contentsOpt && requestedCt + ? contentsOpt.find((c) => c.contentType === requestedCt) + : undefined; + const chosenCt = + binaryBody?.ok === true && !octetStreamContent && isOctetStream(rb.contentType) + ? rb.contentType + : (selected?.contentType ?? rb.contentType); + if (isOctetStream(chosenCt) && toUint8Array(bodyValue) === null) { + return yield* new OpenApiInvocationError({ + message: "application/octet-stream request body must be bytes; provide `bodyBase64`", + statusCode: Option.none(), + }); + } const chosenEncoding = selected ? Option.getOrUndefined(selected.encoding) : contentsOpt && contentsOpt[0] diff --git a/packages/plugins/openapi/src/sdk/non-json-body.test.ts b/packages/plugins/openapi/src/sdk/non-json-body.test.ts index 66757edda..e2dc084d0 100644 --- a/packages/plugins/openapi/src/sdk/non-json-body.test.ts +++ b/packages/plugins/openapi/src/sdk/non-json-body.test.ts @@ -14,7 +14,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import { Effect, Exit, Schema } from "effect"; import { FetchHttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpApi, @@ -259,6 +259,139 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); + it.effect("application/octet-stream: bodyBase64 passes through as bytes", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64" }); + + yield* executor.execute(conn.address("body.submit"), { + bodyBase64: "3q2+7w==", + }); + + expect(captured.contentType).toBe("application/octet-stream"); + expect(Array.from(captured.body)).toEqual([0xde, 0xad, 0xbe, 0xef]); + }), + ); + + it.effect("application/octet-stream: invalid bodyBase64 fails before dispatch", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_b64_bad" }); + + const exit = yield* executor + .execute(conn.address("body.submit"), { + bodyBase64: "@@", + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(captured.contentType).toBe(""); + expect(captured.body.length).toBe(0); + }), + ); + + it.effect("application/octet-stream: invalid nested bodyBase64 fails before dispatch", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { + slug: "bin_nested_b64_bad", + }); + + const exit = yield* executor + .execute(conn.address("body.submit"), { + body: { bodyBase64: "@@" }, + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(captured.contentType).toBe(""); + expect(captured.body.length).toBe(0); + }), + ); + + it.effect("application/octet-stream: required body fails before dispatch when missing", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { + slug: "bin_b64_missing", + }); + + const exit = yield* executor.execute(conn.address("body.submit"), {}).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(captured.contentType).toBe(""); + expect(captured.body.length).toBe(0); + }), + ); + + it.effect("application/octet-stream: object body fails before dispatch", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { + slug: "bin_object_body", + }); + + const exit = yield* executor + .execute(conn.address("body.submit"), { + body: { name: "photo.png" }, + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(captured.contentType).toBe(""); + expect(captured.body.length).toBe(0); + }), + ); + + it.effect("application/octet-stream: string body fails before dispatch", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { + slug: "bin_string_body", + }); + + const exit = yield* executor + .execute(conn.address("body.submit"), { + body: "3q2+7w==", + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(captured.contentType).toBe(""); + expect(captured.body.length).toBe(0); + }), + ); + it.effect( "format: byte response: Gmail-style image attachment data is exposed as a file artifact", () => @@ -754,6 +887,95 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); + it.effect("multi-content: bodyBase64 selects octet-stream without explicit contentType", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: multiContentPayload, + transformSpec: replaceRequestBodyContent("/submit", "post", { + "application/json": { + schema: { type: "object" }, + }, + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64" }); + + yield* executor.execute(conn.address("body.submit"), { + bodyBase64: "3q2+7w==", + }); + + expect(captured.contentType).toBe("application/octet-stream"); + expect(Array.from(captured.body)).toEqual([0xde, 0xad, 0xbe, 0xef]); + }), + ); + + it.effect("multi-content: bodyBase64 rejects a non-octet contentType", () => + Effect.gen(function* () { + const { server, captured } = yield* startEchoServer({ + payload: multiContentPayload, + transformSpec: replaceRequestBodyContent("/submit", "post", { + "application/json": { + schema: { type: "object" }, + }, + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }), + }); + + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_bad_ct" }); + + const exit = yield* executor + .execute(conn.address("body.submit"), { + contentType: "application/json", + bodyBase64: "3q2+7w==", + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(captured.contentType).toBe(""); + expect(captured.body.length).toBe(0); + }), + ); + + it.effect("multi-content: required schema accepts body or bodyBase64", () => + Effect.gen(function* () { + const { server } = yield* startEchoServer({ + payload: multiContentPayload, + transformSpec: replaceRequestBodyContent("/submit", "post", { + "application/json": { + schema: { type: "object" }, + }, + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }), + }); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "mc_b64_schema" }); + + const view = yield* executor.tools.schema(conn.address("body.submit")); + expect(view).not.toBeNull(); + const schema = view!.inputSchema as { + anyOf?: readonly { readonly required?: readonly string[] }[]; + properties?: { + bodyBase64?: { contentEncoding?: string; contentMediaType?: string }; + }; + }; + expect(schema.anyOf).toEqual([{ required: ["body"] }, { required: ["bodyBase64"] }]); + expect(schema.properties?.bodyBase64?.contentEncoding).toBe("base64"); + expect(schema.properties?.bodyBase64?.contentMediaType).toBe("application/octet-stream"); + }), + ); + it.effect("multi-content: tool input schema exposes contentType enum", () => Effect.gen(function* () { const { server } = yield* startEchoServer({ @@ -783,6 +1005,32 @@ describe("OpenAPI non-JSON request body dispatch", () => { }), ); + it.effect("octet-stream: tool input schema exposes bodyBase64", () => + Effect.gen(function* () { + const { server } = yield* startEchoServer({ + payload: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + const conn = yield* addOpenApiTestConnection(executor, server, { slug: "bin_schema" }); + + const view = yield* executor.tools.schema(conn.address("body.submit")); + expect(view).not.toBeNull(); + const schema = view!.inputSchema as { + required?: string[]; + properties?: { + body?: unknown; + bodyBase64?: { contentEncoding?: string; contentMediaType?: string }; + }; + }; + expect(schema.required).toContain("bodyBase64"); + expect(schema.required).not.toContain("body"); + expect(schema.properties?.body).toBeUndefined(); + expect(schema.properties?.bodyBase64?.contentEncoding).toBe("base64"); + expect(schema.properties?.bodyBase64?.contentMediaType).toBe("application/octet-stream"); + }), + ); + // ------------------------------------------------------------------------- // Per-part encoding.contentType in multipart — a metadata field declared // as application/json must ship with its own `Content-Type: application/