diff --git a/.changeset/google-analytics-presets.md b/.changeset/google-analytics-presets.md new file mode 100644 index 0000000000..f958f5bdf3 --- /dev/null +++ b/.changeset/google-analytics-presets.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Add Google Analytics Data and Admin as first-party Google Discovery presets with read-only OAuth scopes and service-hosted Discovery URLs. diff --git a/e2e/scenarios/provider-plugins-ui.test.ts b/e2e/scenarios/provider-plugins-ui.test.ts index 81ba31e2c2..66d617e82e 100644 --- a/e2e/scenarios/provider-plugins-ui.test.ts +++ b/e2e/scenarios/provider-plugins-ui.test.ts @@ -34,6 +34,10 @@ scenario( await search.fill("gmail"); await dialog.getByRole("link", { name: /^Gmail\b/ }).waitFor(); + await search.fill("google analytics"); + await dialog.getByRole("link", { name: /^Google Analytics Data\b/ }).waitFor(); + await dialog.getByRole("link", { name: /^Google Analytics Admin\b/ }).waitFor(); + await search.fill("onedrive"); await dialog.getByRole("link", { name: /^OneDrive Files\b/ }).waitFor(); }); diff --git a/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap b/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap index 756a287b87..6fb3c3f43e 100644 --- a/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap +++ b/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap @@ -66,6 +66,14 @@ exports[`classifies every Google service for bundle OAuth UX 1`] = ` "id": "google-search-console", "oauthAudience": "standard-user", }, + { + "id": "google-analytics-data", + "oauthAudience": "standard-user", + }, + { + "id": "google-analytics-admin", + "oauthAudience": "standard-user", + }, { "id": "google-classroom", "oauthAudience": "advanced-user", diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index f48f70fc0e..f06455e4d3 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -163,6 +163,16 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { expect( normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/keep/v1/rest"), ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1"); + expect( + normalizeGoogleDiscoveryUrl( + "https://www.googleapis.com/discovery/v1/apis/analyticsdata/v1beta/rest", + ), + ).toBe("https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta"); + expect( + normalizeGoogleDiscoveryUrl( + "https://www.googleapis.com/discovery/v1/apis/analyticsadmin/v1beta/rest", + ), + ).toBe("https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta"); expect( normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); @@ -175,6 +185,16 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { expect( normalizeGoogleDiscoveryUrl("https://keep.googleapis.com/$discovery/rest?version=v1"), ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1"); + expect( + normalizeGoogleDiscoveryUrl( + "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta", + ), + ).toBe("https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta"); + expect( + normalizeGoogleDiscoveryUrl( + "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta", + ), + ).toBe("https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta"); expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( true, @@ -1071,6 +1091,130 @@ it.effect("filters Gmail operations to the explicitly selected consent scope", ( }), ); +it.effect("publishes only Google Analytics operations covered by read-only consent", () => + Effect.gen(function* () { + const readOnlyScope = "https://www.googleapis.com/auth/analytics.readonly"; + const analyticsScope = "https://www.googleapis.com/auth/analytics"; + const editScope = "https://www.googleapis.com/auth/analytics.edit"; + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents: [ + { + discoveryUrl: "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "analyticsadmin", + version: "v1beta", + title: "Google Analytics Admin API", + rootUrl: "https://analyticsadmin.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + [readOnlyScope]: { description: "Read Analytics configuration" }, + [editScope]: { description: "Edit Analytics configuration" }, + }, + }, + }, + resources: { + accounts: { + methods: { + list: { + id: "analyticsadmin.accounts.list", + httpMethod: "GET", + path: "v1beta/accounts", + scopes: [editScope, readOnlyScope], + }, + patch: { + id: "analyticsadmin.accounts.patch", + httpMethod: "PATCH", + path: "v1beta/{+name}", + scopes: [editScope], + parameters: { + name: { + location: "path", + required: true, + type: "string", + }, + }, + }, + }, + }, + }, + schemas: {}, + }), + }, + { + discoveryUrl: "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "analyticsdata", + version: "v1beta", + title: "Google Analytics Data API", + rootUrl: "https://analyticsdata.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + [readOnlyScope]: { description: "Read Analytics reports" }, + [analyticsScope]: { description: "Manage Analytics data" }, + }, + }, + }, + resources: { + properties: { + methods: { + runReport: { + id: "analyticsdata.properties.runReport", + httpMethod: "POST", + path: "v1beta/{+property}:runReport", + scopes: [analyticsScope, readOnlyScope], + parameters: { + property: { + location: "path", + required: true, + type: "string", + }, + }, + }, + }, + resources: { + audienceExports: { + methods: { + create: { + id: "analyticsdata.properties.audienceExports.create", + httpMethod: "POST", + path: "v1beta/{+parent}/audienceExports", + scopes: [analyticsScope, readOnlyScope], + parameters: { + parent: { + location: "path", + required: true, + type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + schemas: {}, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + const operationIds = Object.values(spec.paths).flatMap((path) => + Object.values(path).map((operation) => operation.operationId), + ); + expect(operationIds).toContain("analyticsadmin.accounts.list"); + expect(operationIds).not.toContain("analyticsadmin.accounts.patch"); + expect(operationIds).toContain("analyticsdata.properties.runReport"); + expect(operationIds).toContain("analyticsdata.properties.audienceExports.create"); + }), +); + it.effect("keeps consumer Gmail settings tools alongside full mailbox access", () => Effect.gen(function* () { const fullScope = "https://mail.google.com/"; diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 87bf0c7d8a..34a17a8e43 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -41,6 +41,8 @@ type GoogleDiscoveryServiceOverride = { }; const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record = { + analyticsadmin: { preserveServiceHostedUrl: true }, + analyticsdata: { preserveServiceHostedUrl: true }, forms: { preserveServiceHostedUrl: true }, keep: { preserveServiceHostedUrl: true }, [GOOGLE_PHOTOS_PICKER_SERVICE]: { diff --git a/packages/plugins/openapi/src/providers/google/presets.test.ts b/packages/plugins/openapi/src/providers/google/presets.test.ts index 3aeedd1a0b..337fd6e3da 100644 --- a/packages/plugins/openapi/src/providers/google/presets.test.ts +++ b/packages/plugins/openapi/src/providers/google/presets.test.ts @@ -5,6 +5,7 @@ import { compileOpenApiSpec } from "@executor-js/plugin-openapi"; import { convertGoogleDiscoveryBundleToOpenApi } from "./discovery"; import { googleCatalog, + googleCatalogOAuthScopesForPreset, googleOAuthConsentScopes, googleOpenApiPresets, googleStandardUserOAuthPresets, @@ -175,6 +176,46 @@ const googleHealthCheckDiscoveryFixtures = { }, }, }, + "google-analytics-admin": { + url: "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta", + document: { + name: "analyticsadmin", + version: "v1beta", + title: "Google Analytics Admin API", + rootUrl: "https://analyticsadmin.googleapis.com/", + servicePath: "", + resources: { + accountSummaries: { + methods: { + list: { + id: "analyticsadmin.accountSummaries.list", + path: "v1beta/accountSummaries", + httpMethod: "GET", + response: { $ref: "GoogleAnalyticsAdminV1betaListAccountSummariesResponse" }, + }, + }, + }, + }, + schemas: { + GoogleAnalyticsAdminV1betaListAccountSummariesResponse: { + type: "object", + properties: { + accountSummaries: { + type: "array", + items: { $ref: "GoogleAnalyticsAdminV1betaAccountSummary" }, + }, + }, + }, + GoogleAnalyticsAdminV1betaAccountSummary: { + type: "object", + properties: { + account: { type: "string" }, + displayName: { type: "string" }, + }, + }, + }, + }, + }, } as const; const FROZEN_GOOGLE_SLUGS = [ @@ -193,6 +234,8 @@ const FROZEN_GOOGLE_SLUGS = [ "google_chat", "google_youtube_data", "google_search_console", + "google_analytics_data", + "google_analytics_admin", "google_classroom", "google_admin_directory", "google_admin_reports", @@ -210,6 +253,8 @@ it("keeps Select all limited to Google services that can use normal user OAuth", expect(standardIds).toContain("google-tasks"); expect(standardIds).toContain("google-people"); expect(standardIds).toContain("google-search-console"); + expect(standardIds).toContain("google-analytics-data"); + expect(standardIds).toContain("google-analytics-admin"); expect(standardIds).not.toContain("google-youtube-data"); expect(standardIds).not.toContain("google-cloud-resource-manager"); @@ -304,6 +349,20 @@ it("does not publish the domain-wide-delegation-only Keep preset", () => { expect(googleCatalog.some((preset) => preset.id === "google-keep")).toBe(false); }); +it("uses read-only OAuth for both Google Analytics APIs", () => { + const readOnlyScope = "https://www.googleapis.com/auth/analytics.readonly"; + const writeScopes = [ + "https://www.googleapis.com/auth/analytics", + "https://www.googleapis.com/auth/analytics.edit", + ]; + + for (const presetId of ["google-analytics-data", "google-analytics-admin"]) { + const scopes = googleCatalogOAuthScopesForPreset(presetId); + expect(scopes).toContain(readOnlyScope); + for (const writeScope of writeScopes) expect(scopes).not.toContain(writeScope); + } +}); + it("classifies every Google service for bundle OAuth UX", () => { expect( googleOpenApiPresets.map((preset) => ({ @@ -369,6 +428,7 @@ it("omits Google health checks when the service spec has no stable cheap read", "google-slides", "google-forms", "google-photos-picker", + "google-analytics-data", ]; for (const presetId of omitted) { diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index 9591ae86b3..25b000b18f 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -184,6 +184,20 @@ export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [ icon: GOOGLE_G, oauthAudience: "standard-user", }, + { + id: "google-analytics-data", + name: "Google Analytics Data", + summary: "Reports, realtime metrics, funnels, and audience exports for GA4 properties.", + url: "https://analyticsdata.googleapis.com/$discovery/rest?version=v1beta", + oauthAudience: "standard-user", + }, + { + id: "google-analytics-admin", + name: "Google Analytics Admin", + summary: "Accounts, properties, data streams, key events, and analytics configuration.", + url: "https://analyticsadmin.googleapis.com/$discovery/rest?version=v1beta", + oauthAudience: "standard-user", + }, { id: "google-classroom", name: "Google Classroom", @@ -297,6 +311,7 @@ const GOOGLE_HEALTH_CHECKS: Readonly> = { args: { part: "id", mine: true }, }, "google-search-console": { operation: "webmasters.sites.list" }, + "google-analytics-admin": { operation: "analyticsadmin.accountSummaries.list" }, "google-classroom": { operation: "classroom.courses.list" }, "google-admin-directory": { operation: "directory.users.list", diff --git a/packages/plugins/openapi/src/providers/google/service-policy.ts b/packages/plugins/openapi/src/providers/google/service-policy.ts index 3fe466c02e..1a5b62c171 100644 --- a/packages/plugins/openapi/src/providers/google/service-policy.ts +++ b/packages/plugins/openapi/src/providers/google/service-policy.ts @@ -66,6 +66,8 @@ export const googleOAuthConsentScopes: Readonly ["google-chat", ["chat.spaces.list"]], ["google-youtube-data", ["youtube.channels.list"]], ["google-search-console", ["searchconsole.sites.list", "webmasters.sites.list"]], + ["google-analytics-data", ["analyticsdata.properties.runReport"]], + ["google-analytics-admin", ["analyticsadmin.accountSummaries.list"]], ["google-classroom", ["classroom.courses.list"]], [ "google-admin-directory", diff --git a/packages/plugins/provider-service-split/src/planner.ts b/packages/plugins/provider-service-split/src/planner.ts index 75a76a6dde..bdd0276052 100644 --- a/packages/plugins/provider-service-split/src/planner.ts +++ b/packages/plugins/provider-service-split/src/planner.ts @@ -280,6 +280,8 @@ const GOOGLE_TOOL_PREFIX_TO_PRESET_ID: ReadonlyMap = new Map([ ["youtube", "google-youtube-data"], ["searchconsole", "google-search-console"], ["webmasters", "google-search-console"], + ["analyticsdata", "google-analytics-data"], + ["analyticsadmin", "google-analytics-admin"], ["classroom", "google-classroom"], ["directory", "google-admin-directory"], ["reports", "google-admin-reports"],