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
5 changes: 5 additions & 0 deletions .changeset/google-analytics-presets.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions e2e/scenarios/provider-plugins-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 144 additions & 0 deletions packages/plugins/openapi/src/providers/google/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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,
Expand Down Expand Up @@ -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/";
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/openapi/src/providers/google/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ type GoogleDiscoveryServiceOverride = {
};

const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record<string, GoogleDiscoveryServiceOverride> = {
analyticsadmin: { preserveServiceHostedUrl: true },
analyticsdata: { preserveServiceHostedUrl: true },
forms: { preserveServiceHostedUrl: true },
keep: { preserveServiceHostedUrl: true },
[GOOGLE_PHOTOS_PICKER_SERVICE]: {
Expand Down
60 changes: 60 additions & 0 deletions packages/plugins/openapi/src/providers/google/presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { compileOpenApiSpec } from "@executor-js/plugin-openapi";
import { convertGoogleDiscoveryBundleToOpenApi } from "./discovery";
import {
googleCatalog,
googleCatalogOAuthScopesForPreset,
googleOAuthConsentScopes,
googleOpenApiPresets,
googleStandardUserOAuthPresets,
Expand Down Expand Up @@ -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 = [
Expand All @@ -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",
Expand All @@ -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");
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/openapi/src/providers/google/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -297,6 +311,7 @@ const GOOGLE_HEALTH_CHECKS: Readonly<Record<string, HealthCheckSpec>> = {
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]
"google-keep": [auth("keep")],
"google-youtube-data": [auth("youtube.force-ssl"), auth("youtube.channel-memberships.creator")],
"google-search-console": [auth("webmasters")],
"google-analytics-data": [auth("analytics.readonly")],
"google-analytics-admin": [auth("analytics.readonly")],
"google-classroom": [
auth("classroom.announcements"),
auth("classroom.courses"),
Expand Down Expand Up @@ -223,6 +225,8 @@ const GOOGLE_DISCOVERY_POLICIES: Readonly<Record<string, GoogleDiscoveryServiceP
]),
}),
"searchconsole/v1": policy("google-search-console"),
"analyticsdata/v1beta": policy("google-analytics-data"),
"analyticsadmin/v1beta": policy("google-analytics-admin"),
"classroom/v1": policy("google-classroom", {
blockedMethodIds: new Set(["classroom.courses.teachers.create"]),
blockedMethodPrefixes: ["classroom.courses.studentGroups.", "classroom.courses.posts."],
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/provider-service-split/src/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ const googleCatalogMethodPrefixFixtures: ReadonlyMap<string, readonly string[]>
["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",
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/provider-service-split/src/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ const GOOGLE_TOOL_PREFIX_TO_PRESET_ID: ReadonlyMap<string, string> = 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"],
Expand Down
Loading