Skip to content

Commit e91270a

Browse files
committed
Fix Google Photos Picker discovery
1 parent 9586e68 commit e91270a

4 files changed

Lines changed: 140 additions & 19 deletions

File tree

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => {
5353
expect(
5454
normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"),
5555
).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest");
56+
expect(
57+
normalizeGoogleDiscoveryUrl("https://photospicker.googleapis.com/$discovery/rest?version=v1"),
58+
).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1");
59+
expect(
60+
normalizeGoogleDiscoveryUrl("https://forms.googleapis.com/$discovery/rest?version=v1"),
61+
).toBe("https://forms.googleapis.com/$discovery/rest?version=v1");
62+
expect(
63+
normalizeGoogleDiscoveryUrl("https://keep.googleapis.com/$discovery/rest?version=v1"),
64+
).toBe("https://keep.googleapis.com/$discovery/rest?version=v1");
5665

5766
expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
5867
true,
@@ -332,6 +341,49 @@ it.effect("marks Google Discovery media-download methods as binary responses", (
332341
}),
333342
);
334343

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

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

Lines changed: 55 additions & 5 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,24 @@ 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+
3153
type JsonPrimitive = string | number | boolean | null;
3254
type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
3355

@@ -283,7 +305,10 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null
283305
) {
284306
return null;
285307
}
286-
return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
308+
const override = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service];
309+
return override?.preserveServiceHostedUrl === true
310+
? `https://${host}/$discovery/rest?version=${version}`
311+
: `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
287312
};
288313

289314
const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
@@ -677,14 +702,38 @@ const buildDiscoveryOperation = (input: {
677702

678703
const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
679704
const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary";
680-
const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker";
681705
const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly";
682706
const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
683707
const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads";
684708

685709
const isGooglePhotosService = (service: string): boolean =>
686710
service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
687711

712+
const discoveryScopesForService = (
713+
service: string,
714+
document: DiscoveryDocument,
715+
): Record<string, string> => {
716+
const scopes = discoveryScopes(document);
717+
const overrideScopes = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.scopes;
718+
if (!overrideScopes) {
719+
return scopes;
720+
}
721+
const missingScopes = Object.fromEntries(
722+
Object.entries(overrideScopes).filter(([scope]) => scopes[scope] === undefined),
723+
);
724+
return Object.keys(missingScopes).length === 0 ? scopes : { ...scopes, ...missingScopes };
725+
};
726+
727+
const discoveryMethodScopesForService = (
728+
service: string,
729+
method: DiscoveryMethod,
730+
): readonly string[] => {
731+
const scopes = method.scopes ?? [];
732+
return scopes.length === 0
733+
? (GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.fallbackMethodScopes ?? scopes)
734+
: scopes;
735+
};
736+
688737
/** The v2 oauth auth template for a Google-discovery integration. The spec
689738
* itself carries the matching `securitySchemes.googleOAuth2` entry; this is the
690739
* catalog-level template a connection's access token renders through. */
@@ -811,6 +860,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD
811860
method,
812861
toolPath,
813862
pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
863+
oauthScopes: discoveryMethodScopesForService(service, method),
814864
});
815865
}
816866

@@ -826,7 +876,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD
826876
});
827877
}
828878

829-
const scopes = compactDiscoveryScopeMap(discoveryScopes(document));
879+
const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document));
830880
const authenticationTemplate = googleOauthTemplate(scopes);
831881

832882
const spec: OpenApiDocument = {
@@ -923,7 +973,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
923973
for (const info of infos) {
924974
const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
925975
const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
926-
const scopeDescriptions = discoveryScopes(info.document);
976+
const scopeDescriptions = discoveryScopesForService(info.service, info.document);
927977
const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);
928978

929979
for (const [scope, description] of Object.entries(scopeDescriptions)) {
@@ -939,7 +989,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
939989
const methodId = Option.getOrUndefined(method.id);
940990
const rawPathTemplate = Option.getOrUndefined(method.path);
941991
if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
942-
const methodScopes = method.scopes ?? [];
992+
const methodScopes = discoveryMethodScopesForService(info.service, method);
943993
const oauthScopes = filterPhotosScopes
944994
? methodScopes.filter((scope) => consentScopeSet.has(scope))
945995
: 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)