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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/google-photos-openapi-presets.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions e2e/scenarios/google-photos-preset-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}),
);
28 changes: 21 additions & 7 deletions packages/plugins/google/src/react/AddGoogleSource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<ReadonlySet<string>>(
googleBundleDefaultPresetIds,
isGooglePhotosPreset ? new Set(googlePhotosPresetIds) : googleBundleDefaultPresetIds,
);
const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]);
const [baseUrl, setBaseUrl] = useState("");
Expand All @@ -55,8 +62,9 @@ export default function AddGoogleSource(props: {
const [addError, setAddError] = useState<string | null>(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(
Expand Down Expand Up @@ -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;

Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/google/src/react/source-plugin.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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();
Expand Down
127 changes: 120 additions & 7 deletions packages/plugins/google/src/sdk/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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[];
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -695,6 +705,72 @@ const googleOauthTemplate = (scopes: Record<string, string>): 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<string, Record<string, OpenApiOperationObject>>,
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(
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -829,12 +918,16 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
const schemas: Record<string, OpenApiSchemaObject> = {};
const rawScopes: Record<string, string> = {};
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;
}

Expand All @@ -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}`;
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/google/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export {
export {
googleOpenApiBundlePreset,
googleOpenApiPresets,
googlePhotosOpenApiBundlePreset,
googlePhotosOpenApiPresets,
googlePhotosPresetIds,
GOOGLE_PHOTOS_ICON,
GOOGLE_PHOTOS_PRESET_ID,
googleStandardUserOAuthPresets,
googleOAuthConsentScopes,
googleOAuthConsentScopesForPreset,
Expand Down
Loading