History

Fix Google Photos Picker discovery (#1203)

* Fix Google Photos Picker discovery * Handle central Google discovery overrides

Zak committed 4 months ago

Commit bb0a02b1 parent 02154744 files · +160 −20Browse files

Unified · Split

4 files
  1. packages/plugins/google/src/sdk/discovery.test.ts +63 −0
  2. packages/plugins/google/src/sdk/discovery.ts +64 −6
  3. packages/plugins/google/src/sdk/plugin.test.ts +5 −13
  4. packages/react/src/components/oauth-client-form.tsx +28 −1
packages/plugins/google/src/sdk/discovery.test.ts+63−0
@@ -50,9 +50,29 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { · 49 unmodified lines
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", ( · 273 unmodified lines
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+64−6
@@ -18,6 +18,10 @@ const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; · 17 unmodified lines
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([ · 4 unmodified lines
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 · 232 unmodified lines
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 · 10 unmodified lines
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,7 +710,6 @@ const buildDiscoveryOperation = (input: { · 387 unmodified lines
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";
@@ -685,6 +717,31 @@ const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; · 1 unmodified line
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 · 120 unmodified lines
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 · 9 unmodified lines
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( · 90 unmodified lines
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( · 9 unmodified lines
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+5−13
@@ -35,7 +35,7 @@ const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/r · 34 unmodified lines
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,15 +197,6 @@ const photosPickerDoc = { · 155 unmodified lines
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: {
@@ -213,7 +204,6 @@ const photosPickerDoc = { · 1 unmodified line
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>> = { · 13 unmodified lines
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+28−1
@@ -159,6 +159,9 @@ export function OAuthClientForm(props: { · 158 unmodified lines
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: { · 305 unmodified lines
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: { · 75 unmodified lines
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 ? (