Skip to content

Commit 00e9ed3

Browse files
committed
Fix Google Photos Picker discovery
1 parent 4600a21 commit 00e9ed3

4 files changed

Lines changed: 133 additions & 19 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ 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");
5659

5760
expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
5861
true,
@@ -332,6 +335,49 @@ it.effect("marks Google Discovery media-download methods as binary responses", (
332335
}),
333336
);
334337

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

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

Lines changed: 54 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,23 @@ 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+
// Photos Picker Discovery is only available service-hosted, and its live doc omits the required OAuth scope.
42+
const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record<string, GoogleDiscoveryServiceOverride> = {
43+
[GOOGLE_PHOTOS_PICKER_SERVICE]: {
44+
preserveServiceHostedUrl: true,
45+
scopes: {
46+
[GOOGLE_PHOTOS_PICKER_SCOPE]: GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION,
47+
},
48+
fallbackMethodScopes: [GOOGLE_PHOTOS_PICKER_SCOPE],
49+
},
50+
};
51+
3152
type JsonPrimitive = string | number | boolean | null;
3253
type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
3354

@@ -283,7 +304,10 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null
283304
) {
284305
return null;
285306
}
286-
return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
307+
const override = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service];
308+
return override?.preserveServiceHostedUrl === true
309+
? `https://${host}/$discovery/rest?version=${version}`
310+
: `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
287311
};
288312

289313
const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
@@ -677,14 +701,38 @@ const buildDiscoveryOperation = (input: {
677701

678702
const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
679703
const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary";
680-
const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker";
681704
const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly";
682705
const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
683706
const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads";
684707

685708
const isGooglePhotosService = (service: string): boolean =>
686709
service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
687710

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

@@ -826,7 +875,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD
826875
});
827876
}
828877

829-
const scopes = compactDiscoveryScopeMap(discoveryScopes(document));
878+
const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document));
830879
const authenticationTemplate = googleOauthTemplate(scopes);
831880

832881
const spec: OpenApiDocument = {
@@ -923,7 +972,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
923972
for (const info of infos) {
924973
const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
925974
const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
926-
const scopeDescriptions = discoveryScopes(info.document);
975+
const scopeDescriptions = discoveryScopesForService(info.service, info.document);
927976
const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);
928977

929978
for (const [scope, description] of Object.entries(scopeDescriptions)) {
@@ -939,7 +988,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
939988
const methodId = Option.getOrUndefined(method.id);
940989
const rawPathTemplate = Option.getOrUndefined(method.path);
941990
if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
942-
const methodScopes = method.scopes ?? [];
991+
const methodScopes = discoveryMethodScopesForService(info.service, method);
943992
const oauthScopes = filterPhotosScopes
944993
? methodScopes.filter((scope) => consentScopeSet.has(scope))
945994
: 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)