Skip to content

Commit bb0a02b

Browse files
authored
Fix Google Photos Picker discovery (#1203)
* Fix Google Photos Picker discovery * Handle central Google discovery overrides
1 parent 0215474 commit bb0a02b

4 files changed

Lines changed: 160 additions & 20 deletions

File tree

packages/plugins/google/src/sdk/discovery.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,29 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => {
5050
expect(
5151
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"),
5252
).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest");
53+
expect(
54+
normalizeGoogleDiscoveryUrl(
55+
"https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest",
56+
),
57+
).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1");
58+
expect(
59+
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/forms/v1/rest"),
60+
).toBe("https://forms.googleapis.com/$discovery/rest?version=v1");
61+
expect(
62+
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/keep/v1/rest"),
63+
).toBe("https://keep.googleapis.com/$discovery/rest?version=v1");
5364
expect(
5465
normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"),
5566
).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest");
67+
expect(
68+
normalizeGoogleDiscoveryUrl("https://photospicker.googleapis.com/$discovery/rest?version=v1"),
69+
).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1");
70+
expect(
71+
normalizeGoogleDiscoveryUrl("https://forms.googleapis.com/$discovery/rest?version=v1"),
72+
).toBe("https://forms.googleapis.com/$discovery/rest?version=v1");
73+
expect(
74+
normalizeGoogleDiscoveryUrl("https://keep.googleapis.com/$discovery/rest?version=v1"),
75+
).toBe("https://keep.googleapis.com/$discovery/rest?version=v1");
5676

5777
expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
5878
true,
@@ -332,6 +352,49 @@ it.effect("marks Google Discovery media-download methods as binary responses", (
332352
}),
333353
);
334354

355+
it.effect("supplies documented scopes when Picker Discovery omits auth metadata", () =>
356+
Effect.gen(function* () {
357+
const result = yield* convertGoogleDiscoveryToOpenApi({
358+
discoveryUrl: "https://photospicker.googleapis.com/$discovery/rest?version=v1",
359+
// @effect-diagnostics-next-line preferSchemaOverJson:off
360+
documentText: JSON.stringify({
361+
name: "photospicker",
362+
version: "v1",
363+
title: "Google Photos Picker API",
364+
rootUrl: "https://photospicker.googleapis.com/",
365+
servicePath: "v1/",
366+
resources: {
367+
mediaItems: {
368+
methods: {
369+
list: {
370+
id: "photospicker.mediaItems.list",
371+
httpMethod: "GET",
372+
path: "mediaItems",
373+
parameters: {},
374+
},
375+
},
376+
},
377+
},
378+
schemas: {},
379+
}),
380+
});
381+
382+
const pickerScope = "https://www.googleapis.com/auth/photospicker.mediaitems.readonly";
383+
const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
384+
expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
385+
pickerScope,
386+
]);
387+
388+
const spec = decodeConvertedSpec(result.specText);
389+
const operation = spec.paths["/mediaItems"]?.get;
390+
expect(operation).toMatchObject({
391+
operationId: "mediaItems.list",
392+
"x-google-scopes": [pickerScope],
393+
security: [{ googleOAuth2: [pickerScope] }],
394+
});
395+
}),
396+
);
397+
335398
it.effect("bundles Google Discovery documents into one Google OpenAPI source", () =>
336399
Effect.gen(function* () {
337400
const result = yield* convertGoogleDiscoveryBundleToOpenApi({

packages/plugins/google/src/sdk/discovery.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis";
1818
const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
1919
const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
2020
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
21+
const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker";
22+
const GOOGLE_PHOTOS_PICKER_SCOPE =
23+
"https://www.googleapis.com/auth/photospicker.mediaitems.readonly";
24+
const GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION = "Read selected Google Photos media";
2125
const OPENAPI_SCHEMA_TYPES = new Set([
2226
"array",
2327
"boolean",
@@ -28,6 +32,35 @@ const OPENAPI_SCHEMA_TYPES = new Set([
2832
"string",
2933
]);
3034

35+
type GoogleDiscoveryServiceOverride = {
36+
readonly preserveServiceHostedUrl?: true;
37+
readonly scopes?: Record<string, string>;
38+
readonly fallbackMethodScopes?: readonly string[];
39+
};
40+
41+
const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record<string, GoogleDiscoveryServiceOverride> = {
42+
forms: { preserveServiceHostedUrl: true },
43+
keep: { preserveServiceHostedUrl: true },
44+
[GOOGLE_PHOTOS_PICKER_SERVICE]: {
45+
preserveServiceHostedUrl: true,
46+
scopes: {
47+
[GOOGLE_PHOTOS_PICKER_SCOPE]: GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION,
48+
},
49+
fallbackMethodScopes: [GOOGLE_PHOTOS_PICKER_SCOPE],
50+
},
51+
};
52+
53+
const googleDiscoveryUrlForService = (
54+
service: string,
55+
version: string,
56+
host = `${service}.googleapis.com`,
57+
): string => {
58+
const override = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service];
59+
return override?.preserveServiceHostedUrl === true
60+
? `https://${host}/$discovery/rest?version=${version}`
61+
: `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
62+
};
63+
3164
type JsonPrimitive = string | number | boolean | null;
3265
type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
3366

@@ -266,7 +299,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null
266299
const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE);
267300
const service = match?.[1];
268301
const version = match?.[2];
269-
return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null;
302+
return service && version ? googleDiscoveryUrlForService(service, version) : null;
270303
}
271304

272305
const service = serviceFromGoogleApisHost(host);
@@ -283,7 +316,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null
283316
) {
284317
return null;
285318
}
286-
return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
319+
return googleDiscoveryUrlForService(service, version, host);
287320
};
288321

289322
const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
@@ -677,14 +710,38 @@ const buildDiscoveryOperation = (input: {
677710

678711
const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
679712
const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary";
680-
const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker";
681713
const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly";
682714
const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
683715
const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads";
684716

685717
const isGooglePhotosService = (service: string): boolean =>
686718
service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
687719

720+
const discoveryScopesForService = (
721+
service: string,
722+
document: DiscoveryDocument,
723+
): Record<string, string> => {
724+
const scopes = discoveryScopes(document);
725+
const overrideScopes = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.scopes;
726+
if (!overrideScopes) {
727+
return scopes;
728+
}
729+
const missingScopes = Object.fromEntries(
730+
Object.entries(overrideScopes).filter(([scope]) => scopes[scope] === undefined),
731+
);
732+
return Object.keys(missingScopes).length === 0 ? scopes : { ...scopes, ...missingScopes };
733+
};
734+
735+
const discoveryMethodScopesForService = (
736+
service: string,
737+
method: DiscoveryMethod,
738+
): readonly string[] => {
739+
const scopes = method.scopes ?? [];
740+
return scopes.length === 0
741+
? (GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.fallbackMethodScopes ?? scopes)
742+
: scopes;
743+
};
744+
688745
/** The v2 oauth auth template for a Google-discovery integration. The spec
689746
* itself carries the matching `securitySchemes.googleOAuth2` entry; this is the
690747
* catalog-level template a connection's access token renders through. */
@@ -811,6 +868,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD
811868
method,
812869
toolPath,
813870
pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
871+
oauthScopes: discoveryMethodScopesForService(service, method),
814872
});
815873
}
816874

@@ -826,7 +884,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD
826884
});
827885
}
828886

829-
const scopes = compactDiscoveryScopeMap(discoveryScopes(document));
887+
const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document));
830888
const authenticationTemplate = googleOauthTemplate(scopes);
831889

832890
const spec: OpenApiDocument = {
@@ -923,7 +981,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
923981
for (const info of infos) {
924982
const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
925983
const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
926-
const scopeDescriptions = discoveryScopes(info.document);
984+
const scopeDescriptions = discoveryScopesForService(info.service, info.document);
927985
const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);
928986

929987
for (const [scope, description] of Object.entries(scopeDescriptions)) {
@@ -939,7 +997,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
939997
const methodId = Option.getOrUndefined(method.id);
940998
const rawPathTemplate = Option.getOrUndefined(method.path);
941999
if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
942-
const methodScopes = method.scopes ?? [];
1000+
const methodScopes = discoveryMethodScopesForService(info.service, method);
9431001
const oauthScopes = filterPhotosScopes
9441002
? methodScopes.filter((scope) => consentScopeSet.has(scope))
9451003
: methodScopes;

packages/plugins/google/src/sdk/plugin.test.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/r
3535
const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
3636
const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
3737
const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
38-
const PHOTOS_PICKER_URL = "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest";
38+
const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
3939

4040
const calendarDoc = {
4141
name: "calendar",
@@ -197,23 +197,13 @@ const photosPickerDoc = {
197197
title: "Google Photos Picker API",
198198
rootUrl: "https://photospicker.googleapis.com/",
199199
servicePath: "v1/",
200-
auth: {
201-
oauth2: {
202-
scopes: {
203-
"https://www.googleapis.com/auth/photospicker.mediaitems.readonly": {
204-
description: "Read selected Google Photos media",
205-
},
206-
},
207-
},
208-
},
209200
resources: {
210201
mediaItems: {
211202
methods: {
212203
list: {
213204
id: "photospicker.mediaItems.list",
214205
httpMethod: "GET",
215206
path: "mediaItems",
216-
scopes: ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"],
217207
parameters: {},
218208
},
219209
},
@@ -233,12 +223,14 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = {
233223
};
234224

235225
// A stub HTTP client that serves the canned Discovery document for whichever
236-
// URL the bundle converter fetches (query params are ignored when matching).
226+
// URL the bundle converter fetches. Service-hosted Discovery URLs carry their
227+
// version in the query string, so match the full URL before falling back to the
228+
// path-only key used by central Discovery URLs.
237229
const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
238230
HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
239231
const url = new URL(request.url);
240232
const key = `${url.origin}${url.pathname}`;
241-
const body = DISCOVERY_BODIES[key];
233+
const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key];
242234
return Effect.succeed(
243235
HttpClientResponse.fromWeb(
244236
request,

packages/react/src/components/oauth-client-form.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ export function OAuthClientForm(props: {
159159
const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>(
160160
prefill?.discoveredScopes ?? [],
161161
);
162+
const visibleScopes = registrationScopes(declaredScopes, discoveredScopes);
163+
const visibleScopesSource =
164+
declaredScopes.length > 0 ? "Declared by integration" : "Discovered from server";
162165
const [discovering, setDiscovering] = useState(false);
163166
const [submitting, setSubmitting] = useState(false);
164167
// DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded
@@ -470,7 +473,7 @@ export function OAuthClientForm(props: {
470473
</div>
471474
</div>
472475

473-
{/* endpoints + scopes — collapsed when the integration already declares them */}
476+
{/* endpoints */}
474477
{endpointsKnown && !showEndpoints ? (
475478
<Button
476479
type="button"
@@ -552,6 +555,30 @@ export function OAuthClientForm(props: {
552555
</div>
553556
)}
554557

558+
{visibleScopes.length > 0 ? (
559+
<div className="space-y-2 rounded-lg border border-border/50 bg-background/30 p-3">
560+
<div className="flex items-center justify-between gap-3">
561+
<div>
562+
<p className="text-xs font-medium text-foreground">Required OAuth scopes</p>
563+
<p className="text-[11px] text-muted-foreground">{visibleScopesSource}</p>
564+
</div>
565+
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
566+
{visibleScopes.length}
567+
</span>
568+
</div>
569+
<ul className="space-y-1">
570+
{visibleScopes.map((scope: string) => (
571+
<li
572+
key={scope}
573+
className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground"
574+
>
575+
{scope}
576+
</li>
577+
))}
578+
</ul>
579+
</div>
580+
) : null}
581+
555582
{/* client owner (distinct from the connection's saved-to owner). Locked
556583
when editing — an app's owner is part of its (owner, slug) identity. */}
557584
{fixedOwner ? (

0 commit comments

Comments
 (0)