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
50 expect(50 expect(
51 normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"),51 normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"),
52 ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest");52 ).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");
53 expect(64 expect(
54 normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"),65 normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"),
55 ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest");66 ).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");
56 76
57 expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(77 expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe(
58 true,78 true,
@@ -332,6 +352,49 @@ it.effect("marks Google Discovery media-download methods as binary responses", ( · 273 unmodified lines
332 }),352 }),
333);353);
334 354
355it.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
335it.effect("bundles Google Discovery documents into one Google OpenAPI source", () =>398it.effect("bundles Google Discovery documents into one Google OpenAPI source", () =>
336 Effect.gen(function* () {399 Effect.gen(function* () {
337 const result = yield* convertGoogleDiscoveryBundleToOpenApi({400 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
18const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";18const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
19const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";19const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
20const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";20const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
21const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker";
22const GOOGLE_PHOTOS_PICKER_SCOPE =
23 "https://www.googleapis.com/auth/photospicker.mediaitems.readonly";
24const GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION = "Read selected Google Photos media";
21const OPENAPI_SCHEMA_TYPES = new Set([25const OPENAPI_SCHEMA_TYPES = new Set([
22 "array",26 "array",
23 "boolean",27 "boolean",
@@ -28,6 +32,35 @@ const OPENAPI_SCHEMA_TYPES = new Set([ · 4 unmodified lines
28 "string",32 "string",
29]);33]);
30 34
35type GoogleDiscoveryServiceOverride = {
36 readonly preserveServiceHostedUrl?: true;
37 readonly scopes?: Record<string, string>;
38 readonly fallbackMethodScopes?: readonly string[];
39};
40
41const 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
53const 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
31type JsonPrimitive = string | number | boolean | null;64type JsonPrimitive = string | number | boolean | null;
32type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };65type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
33 66
@@ -266,7 +299,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null · 232 unmodified lines
266 const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE);299 const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE);
267 const service = match?.[1];300 const service = match?.[1];
268 const version = match?.[2];301 const version = match?.[2];
269 return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null;302 return service && version ? googleDiscoveryUrlForService(service, version) : null;
270 }303 }
271 304
272 const service = serviceFromGoogleApisHost(host);305 const service = serviceFromGoogleApisHost(host);
@@ -283,7 +316,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null · 10 unmodified lines
283 ) {316 ) {
284 return null;317 return null;
285 }318 }
286 return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;319 return googleDiscoveryUrlForService(service, version, host);
287};320};
288 321
289const normalizeDiscoveryUrl = (discoveryUrl: string): string => {322const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
@@ -677,7 +710,6 @@ const buildDiscoveryOperation = (input: { · 387 unmodified lines
677 710
678const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";711const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
679const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary";712const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary";
680const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker";
681const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly";713const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly";
682const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";714const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
683const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads";715const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads";
@@ -685,6 +717,31 @@ const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; · 1 unmodified line
685const isGooglePhotosService = (service: string): boolean =>717const isGooglePhotosService = (service: string): boolean =>
686 service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;718 service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
687 719
720const 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
735const 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
688/** The v2 oauth auth template for a Google-discovery integration. The spec745/** The v2 oauth auth template for a Google-discovery integration. The spec
689 * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the746 * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the
690 * catalog-level template a connection's access token renders through. */747 * catalog-level template a connection's access token renders through. */
@@ -811,6 +868,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 120 unmodified lines
811 method,868 method,
812 toolPath,869 toolPath,
813 pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,870 pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
871 oauthScopes: discoveryMethodScopesForService(service, method),
814 });872 });
815 }873 }
816 874
@@ -826,7 +884,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 9 unmodified lines
826 });884 });
827 }885 }
828 886
829 const scopes = compactDiscoveryScopeMap(discoveryScopes(document));887 const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document));
830 const authenticationTemplate = googleOauthTemplate(scopes);888 const authenticationTemplate = googleOauthTemplate(scopes);
831 889
832 const spec: OpenApiDocument = {890 const spec: OpenApiDocument = {
@@ -923,7 +981,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 90 unmodified lines
923 for (const info of infos) {981 for (const info of infos) {
924 const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);982 const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
925 const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;983 const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
926 const scopeDescriptions = discoveryScopes(info.document);984 const scopeDescriptions = discoveryScopesForService(info.service, info.document);
927 const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);985 const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);
928 986
929 for (const [scope, description] of Object.entries(scopeDescriptions)) {987 for (const [scope, description] of Object.entries(scopeDescriptions)) {
@@ -939,7 +997,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 9 unmodified lines
939 const methodId = Option.getOrUndefined(method.id);997 const methodId = Option.getOrUndefined(method.id);
940 const rawPathTemplate = Option.getOrUndefined(method.path);998 const rawPathTemplate = Option.getOrUndefined(method.path);
941 if (!methodId || !rawPathTemplate || !method.httpMethod) continue;999 if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
942 const methodScopes = method.scopes ?? [];1000 const methodScopes = discoveryMethodScopesForService(info.service, method);
943 const oauthScopes = filterPhotosScopes1001 const oauthScopes = filterPhotosScopes
944 ? methodScopes.filter((scope) => consentScopeSet.has(scope))1002 ? methodScopes.filter((scope) => consentScopeSet.has(scope))
945 : methodScopes;1003 : 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
35const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";35const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
36const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";36const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
37const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";37const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
38const PHOTOS_PICKER_URL = "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest";38const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
39 39
40const calendarDoc = {40const calendarDoc = {
41 name: "calendar",41 name: "calendar",
@@ -197,15 +197,6 @@ const photosPickerDoc = { · 155 unmodified lines
197 title: "Google Photos Picker API",197 title: "Google Photos Picker API",
198 rootUrl: "https://photospicker.googleapis.com/",198 rootUrl: "https://photospicker.googleapis.com/",
199 servicePath: "v1/",199 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 },
209 resources: {200 resources: {
210 mediaItems: {201 mediaItems: {
211 methods: {202 methods: {
@@ -213,7 +204,6 @@ const photosPickerDoc = { · 1 unmodified line
213 id: "photospicker.mediaItems.list",204 id: "photospicker.mediaItems.list",
214 httpMethod: "GET",205 httpMethod: "GET",
215 path: "mediaItems",206 path: "mediaItems",
216 scopes: ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"],
217 parameters: {},207 parameters: {},
218 },208 },
219 },209 },
@@ -233,12 +223,14 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 13 unmodified lines
233};223};
234 224
235// A stub HTTP client that serves the canned Discovery document for whichever225// 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.
237const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(229const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
238 HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {230 HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
239 const url = new URL(request.url);231 const url = new URL(request.url);
240 const key = `${url.origin}${url.pathname}`;232 const key = `${url.origin}${url.pathname}`;
241 const body = DISCOVERY_BODIES[key];233 const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key];
242 return Effect.succeed(234 return Effect.succeed(
243 HttpClientResponse.fromWeb(235 HttpClientResponse.fromWeb(
244 request,236 request,
packages/react/src/components/oauth-client-form.tsx+28−1
@@ -159,6 +159,9 @@ export function OAuthClientForm(props: { · 158 unmodified lines
159 const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>(159 const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>(
160 prefill?.discoveredScopes ?? [],160 prefill?.discoveredScopes ?? [],
161 );161 );
162 const visibleScopes = registrationScopes(declaredScopes, discoveredScopes);
163 const visibleScopesSource =
164 declaredScopes.length > 0 ? "Declared by integration" : "Discovered from server";
162 const [discovering, setDiscovering] = useState(false);165 const [discovering, setDiscovering] = useState(false);
163 const [submitting, setSubmitting] = useState(false);166 const [submitting, setSubmitting] = useState(false);
164 // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded167 // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded
@@ -470,7 +473,7 @@ export function OAuthClientForm(props: { · 305 unmodified lines
470 </div>473 </div>
471 </div>474 </div>
472 475
473 {/* endpoints + scopes — collapsed when the integration already declares them */}476 {/* endpoints */}
474 {endpointsKnown && !showEndpoints ? (477 {endpointsKnown && !showEndpoints ? (
475 <Button478 <Button
476 type="button"479 type="button"
@@ -552,6 +555,30 @@ export function OAuthClientForm(props: { · 75 unmodified lines
552 </div>555 </div>
553 )}556 )}
554 557
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
555 {/* client owner (distinct from the connection's saved-to owner). Locked582 {/* client owner (distinct from the connection's saved-to owner). Locked
556 when editing — an app's owner is part of its (owner, slug) identity. */}583 when editing — an app's owner is part of its (owner, slug) identity. */}
557 {fixedOwner ? (584 {fixedOwner ? (