History

Move Google discovery ownership out of OpenAPI

Rhys Sullivan committed 4 months ago

Commit ba657e31 parent ecf7b3d43 files · +2209 −2876Browse files

Unified · Split

43 files
  1. apps/cloud/drizzle/0006_google_openapi_ownership.sql +86 −0
  2. apps/cloud/drizzle/meta/0006_snapshot.json +1266 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/host-cloudflare/src/db/d1.ts +2 −0
  5. apps/host-cloudflare/src/db/data-migrations.test.ts +153 −0
  6. apps/host-cloudflare/src/db/data-migrations.ts +90 −0
  7. apps/host-cloudflare/src/plugins.ts +2 −0
  8. apps/host-selfhost/src/db/data-migrations.ts +2 −0
  9. apps/local/src/db/data-migrations.ts +2 −0
  10. bun.lock +1 −0
  11. examples/all-plugins/package.json +1 −0
  12. examples/all-plugins/src/main.ts +3 −1
  13. knip.config.ts +1 −1
  14. packages/plugins/google/CHANGELOG.md +1 −0
  15. packages/plugins/google/src/sdk/config.ts +24 −0
  16. packages/plugins/google/src/sdk/index.ts +4 −0
  17. packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts +219 −0
  18. packages/plugins/google/src/sdk/openapi-ownership-migration.ts +148 −0
  19. packages/plugins/google/src/sdk/plugin.ts +175 −205
  20. packages/plugins/openapi/src/api/group.ts +0 −9
  21. packages/plugins/openapi/src/api/handlers.ts +0 −3
  22. packages/plugins/openapi/src/react/UpdateSpecSection.tsx +1 −7
  23. packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap +0 −82
  24. packages/plugins/openapi/src/sdk/config.ts +0 −2
  25. packages/plugins/openapi/src/sdk/configure.test.ts +0 −1
  26. packages/plugins/openapi/src/sdk/google-bundle.test.ts +0 −270
  27. packages/plugins/openapi/src/sdk/google-discovery.test.ts +0 −604
  28. packages/plugins/openapi/src/sdk/google-discovery.ts +0 −854
  29. packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts +0 −98
  30. packages/plugins/openapi/src/sdk/google-oauth-batches.ts +0 −69
  31. packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts +0 −37
  32. packages/plugins/openapi/src/sdk/google-oauth-scopes.ts +0 −66
  33. packages/plugins/openapi/src/sdk/google-presets.test.ts +0 −29
  34. packages/plugins/openapi/src/sdk/google-presets.ts +0 −267
  35. packages/plugins/openapi/src/sdk/google-product-picker-scopes.test.ts +0 −122
  36. packages/plugins/openapi/src/sdk/index.ts +0 −7
  37. packages/plugins/openapi/src/sdk/plugin.test.ts +0 −1
  38. packages/plugins/openapi/src/sdk/plugin.ts +14 −133
  39. packages/plugins/openapi/src/sdk/spec-blob.test.ts +0 −1
  40. packages/react/src/components/integration-favicon.test.tsx +4 −4
  41. packages/react/src/components/integration-favicon.tsx +1 −1
  42. packages/react/src/pages/integrations.tsx +1 −1
  43. tests/presets-reachable.test.ts +1 −1
apps/cloud/drizzle/0006_google_openapi_ownership.sql+86−0
@@ -0,0 +1,86 @@
1CREATE TEMP TABLE "__google_openapi_integrations" AS
2SELECT
3 "tenant",
4 "slug",
5 "config"::jsonb ->> 'specHash' AS "spec_hash"
6FROM "integration"
7WHERE "plugin_id" = 'openapi'
8 AND "config" IS NOT NULL
9 AND jsonb_typeof("config"::jsonb -> 'googleDiscoveryUrls') = 'array';--> statement-breakpoint
10INSERT INTO "blob" ("namespace", "key", "value", "row_id", "id")
11SELECT
12 'o:' || g."tenant" || '/google' AS "namespace",
13 b."key",
14 b."value",
15 'mig_google_blob_' || md5('o:' || g."tenant" || '/google' || ':' || b."key") AS "row_id",
16 '["' || 'o:' || g."tenant" || '/google' || '","' || b."key" || '"]' AS "id"
17FROM "__google_openapi_integrations" g
18JOIN "blob" b
19 ON b."namespace" = 'o:' || g."tenant" || '/openapi'
20 AND b."key" = 'spec/' || g."spec_hash"
21WHERE g."spec_hash" IS NOT NULL
22 AND g."spec_hash" <> ''
23ON CONFLICT ("id") DO NOTHING;--> statement-breakpoint
24INSERT INTO "plugin_storage"
25 ("tenant", "owner", "subject", "plugin_id", "collection", "key", "data", "created_at", "updated_at", "row_id")
26SELECT
27 ps."tenant",
28 ps."owner",
29 ps."subject",
30 'google',
31 ps."collection",
32 ps."key",
33 ps."data",
34 ps."created_at",
35 ps."updated_at",
36 'mig_google_storage_' || md5(ps."tenant" || ':' || ps."owner" || ':' || ps."subject" || ':' || ps."collection" || ':' || ps."key")
37FROM "plugin_storage" ps
38JOIN "__google_openapi_integrations" g
39 ON g."tenant" = ps."tenant"
40WHERE ps."plugin_id" = 'openapi'
41 AND ps."collection" = 'operation'
42 AND (
43 ps."data"::jsonb ->> 'integration' = g."slug"
44 OR ps."key" LIKE g."slug" || '.%'
45 )
46ON CONFLICT ("tenant", "owner", "subject", "plugin_id", "collection", "key")
47DO UPDATE SET
48 "data" = EXCLUDED."data",
49 "updated_at" = EXCLUDED."updated_at";--> statement-breakpoint
50DELETE FROM "plugin_storage" ps
51USING "__google_openapi_integrations" g
52WHERE ps."plugin_id" = 'openapi'
53 AND ps."collection" = 'operation'
54 AND g."tenant" = ps."tenant"
55 AND (
56 ps."data"::jsonb ->> 'integration' = g."slug"
57 OR ps."key" LIKE g."slug" || '.%'
58 );--> statement-breakpoint
59UPDATE "tool"
60SET "plugin_id" = 'google'
61WHERE "plugin_id" = 'openapi'
62 AND EXISTS (
63 SELECT 1
64 FROM "__google_openapi_integrations" g
65 WHERE g."tenant" = "tool"."tenant"
66 AND g."slug" = "tool"."integration"
67 );--> statement-breakpoint
68UPDATE "definition"
69SET "plugin_id" = 'google'
70WHERE "plugin_id" = 'openapi'
71 AND EXISTS (
72 SELECT 1
73 FROM "__google_openapi_integrations" g
74 WHERE g."tenant" = "definition"."tenant"
75 AND g."slug" = "definition"."integration"
76 );--> statement-breakpoint
77UPDATE "integration"
78SET "plugin_id" = 'google'
79WHERE "plugin_id" = 'openapi'
80 AND EXISTS (
81 SELECT 1
82 FROM "__google_openapi_integrations" g
83 WHERE g."tenant" = "integration"."tenant"
84 AND g."slug" = "integration"."slug"
85 );--> statement-breakpoint
86DROP TABLE "__google_openapi_integrations";
apps/cloud/drizzle/meta/0006_snapshot.json+1266−0
@@ -0,0 +1,1266 @@
1{
2 "id": "3beb97b9-a590-403f-88f5-8a67b499dcf4",
3 "prevId": "e105c61d-2fde-4d2e-a785-a74fafd0f4ea",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.blob": {
144 "name": "blob",
145 "schema": "",
146 "columns": {
147 "namespace": {
148 "name": "namespace",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "key": {
154 "name": "key",
155 "type": "varchar(255)",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "value": {
160 "name": "value",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": true
164 },
165 "row_id": {
166 "name": "row_id",
167 "type": "varchar(255)",
168 "primaryKey": true,
169 "notNull": true
170 },
171 "id": {
172 "name": "id",
173 "type": "varchar(255)",
174 "primaryKey": false,
175 "notNull": true
176 }
177 },
178 "indexes": {
179 "blob_id_uidx": {
180 "name": "blob_id_uidx",
181 "columns": [
182 {
183 "expression": "id",
184 "isExpression": false,
185 "asc": true,
186 "nulls": "last"
187 }
188 ],
189 "isUnique": true,
190 "concurrently": false,
191 "method": "btree",
192 "with": {}
193 }
194 },
195 "foreignKeys": {},
196 "compositePrimaryKeys": {},
197 "uniqueConstraints": {},
198 "policies": {},
199 "checkConstraints": {},
200 "isRLSEnabled": false
201 },
202 "public.connection": {
203 "name": "connection",
204 "schema": "",
205 "columns": {
206 "integration": {
207 "name": "integration",
208 "type": "varchar(255)",
209 "primaryKey": false,
210 "notNull": true
211 },
212 "name": {
213 "name": "name",
214 "type": "varchar(255)",
215 "primaryKey": false,
216 "notNull": true
217 },
218 "template": {
219 "name": "template",
220 "type": "text",
221 "primaryKey": false,
222 "notNull": true
223 },
224 "provider": {
225 "name": "provider",
226 "type": "text",
227 "primaryKey": false,
228 "notNull": true
229 },
230 "item_ids": {
231 "name": "item_ids",
232 "type": "json",
233 "primaryKey": false,
234 "notNull": true
235 },
236 "identity_label": {
237 "name": "identity_label",
238 "type": "text",
239 "primaryKey": false,
240 "notNull": false
241 },
242 "description": {
243 "name": "description",
244 "type": "text",
245 "primaryKey": false,
246 "notNull": false
247 },
248 "tools_synced_at": {
249 "name": "tools_synced_at",
250 "type": "bigint",
251 "primaryKey": false,
252 "notNull": false
253 },
254 "oauth_client": {
255 "name": "oauth_client",
256 "type": "text",
257 "primaryKey": false,
258 "notNull": false
259 },
260 "oauth_client_owner": {
261 "name": "oauth_client_owner",
262 "type": "text",
263 "primaryKey": false,
264 "notNull": false
265 },
266 "refresh_item_id": {
267 "name": "refresh_item_id",
268 "type": "text",
269 "primaryKey": false,
270 "notNull": false
271 },
272 "expires_at": {
273 "name": "expires_at",
274 "type": "bigint",
275 "primaryKey": false,
276 "notNull": false
277 },
278 "oauth_scope": {
279 "name": "oauth_scope",
280 "type": "text",
281 "primaryKey": false,
282 "notNull": false
283 },
284 "provider_state": {
285 "name": "provider_state",
286 "type": "json",
287 "primaryKey": false,
288 "notNull": false
289 },
290 "created_at": {
291 "name": "created_at",
292 "type": "timestamp",
293 "primaryKey": false,
294 "notNull": true
295 },
296 "updated_at": {
297 "name": "updated_at",
298 "type": "timestamp",
299 "primaryKey": false,
300 "notNull": true
301 },
302 "row_id": {
303 "name": "row_id",
304 "type": "varchar(255)",
305 "primaryKey": true,
306 "notNull": true
307 },
308 "tenant": {
309 "name": "tenant",
310 "type": "varchar(255)",
311 "primaryKey": false,
312 "notNull": true
313 },
314 "owner": {
315 "name": "owner",
316 "type": "varchar(255)",
317 "primaryKey": false,
318 "notNull": true
319 },
320 "subject": {
321 "name": "subject",
322 "type": "varchar(255)",
323 "primaryKey": false,
324 "notNull": true
325 }
326 },
327 "indexes": {
328 "connection_uidx": {
329 "name": "connection_uidx",
330 "columns": [
331 {
332 "expression": "tenant",
333 "isExpression": false,
334 "asc": true,
335 "nulls": "last"
336 },
337 {
338 "expression": "owner",
339 "isExpression": false,
340 "asc": true,
341 "nulls": "last"
342 },
343 {
344 "expression": "subject",
345 "isExpression": false,
346 "asc": true,
347 "nulls": "last"
348 },
349 {
350 "expression": "integration",
351 "isExpression": false,
352 "asc": true,
353 "nulls": "last"
354 },
355 {
356 "expression": "name",
357 "isExpression": false,
358 "asc": true,
359 "nulls": "last"
360 }
361 ],
362 "isUnique": true,
363 "concurrently": false,
364 "method": "btree",
365 "with": {}
366 }
367 },
368 "foreignKeys": {},
369 "compositePrimaryKeys": {},
370 "uniqueConstraints": {},
371 "policies": {},
372 "checkConstraints": {},
373 "isRLSEnabled": false
374 },
375 "public.definition": {
376 "name": "definition",
377 "schema": "",
378 "columns": {
379 "integration": {
380 "name": "integration",
381 "type": "varchar(255)",
382 "primaryKey": false,
383 "notNull": true
384 },
385 "connection": {
386 "name": "connection",
387 "type": "varchar(255)",
388 "primaryKey": false,
389 "notNull": true
390 },
391 "plugin_id": {
392 "name": "plugin_id",
393 "type": "text",
394 "primaryKey": false,
395 "notNull": true
396 },
397 "name": {
398 "name": "name",
399 "type": "text",
400 "primaryKey": false,
401 "notNull": true
402 },
403 "schema": {
404 "name": "schema",
405 "type": "json",
406 "primaryKey": false,
407 "notNull": true
408 },
409 "created_at": {
410 "name": "created_at",
411 "type": "timestamp",
412 "primaryKey": false,
413 "notNull": true
414 },
415 "row_id": {
416 "name": "row_id",
417 "type": "varchar(255)",
418 "primaryKey": true,
419 "notNull": true
420 },
421 "tenant": {
422 "name": "tenant",
423 "type": "varchar(255)",
424 "primaryKey": false,
425 "notNull": true
426 },
427 "owner": {
428 "name": "owner",
429 "type": "varchar(255)",
430 "primaryKey": false,
431 "notNull": true
432 },
433 "subject": {
434 "name": "subject",
435 "type": "varchar(255)",
436 "primaryKey": false,
437 "notNull": true
438 }
439 },
440 "indexes": {
441 "definition_uidx": {
442 "name": "definition_uidx",
443 "columns": [
444 {
445 "expression": "tenant",
446 "isExpression": false,
447 "asc": true,
448 "nulls": "last"
449 },
450 {
451 "expression": "owner",
452 "isExpression": false,
453 "asc": true,
454 "nulls": "last"
455 },
456 {
457 "expression": "subject",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "integration",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 },
468 {
469 "expression": "connection",
470 "isExpression": false,
471 "asc": true,
472 "nulls": "last"
473 },
474 {
475 "expression": "name",
476 "isExpression": false,
477 "asc": true,
478 "nulls": "last"
479 }
480 ],
481 "isUnique": true,
482 "concurrently": false,
483 "method": "btree",
484 "with": {}
485 }
486 },
487 "foreignKeys": {},
488 "compositePrimaryKeys": {},
489 "uniqueConstraints": {},
490 "policies": {},
491 "checkConstraints": {},
492 "isRLSEnabled": false
493 },
494 "public.integration": {
495 "name": "integration",
496 "schema": "",
497 "columns": {
498 "slug": {
499 "name": "slug",
500 "type": "varchar(255)",
501 "primaryKey": false,
502 "notNull": true
503 },
504 "plugin_id": {
505 "name": "plugin_id",
506 "type": "text",
507 "primaryKey": false,
508 "notNull": true
509 },
510 "name": {
511 "name": "name",
512 "type": "text",
513 "primaryKey": false,
514 "notNull": false
515 },
516 "description": {
517 "name": "description",
518 "type": "text",
519 "primaryKey": false,
520 "notNull": false
521 },
522 "config_revised_at": {
523 "name": "config_revised_at",
524 "type": "bigint",
525 "primaryKey": false,
526 "notNull": false
527 },
528 "config": {
529 "name": "config",
530 "type": "json",
531 "primaryKey": false,
532 "notNull": false
533 },
534 "can_remove": {
535 "name": "can_remove",
536 "type": "boolean",
537 "primaryKey": false,
538 "notNull": true,
539 "default": true
540 },
541 "can_refresh": {
542 "name": "can_refresh",
543 "type": "boolean",
544 "primaryKey": false,
545 "notNull": true,
546 "default": false
547 },
548 "created_at": {
549 "name": "created_at",
550 "type": "timestamp",
551 "primaryKey": false,
552 "notNull": true
553 },
554 "updated_at": {
555 "name": "updated_at",
556 "type": "timestamp",
557 "primaryKey": false,
558 "notNull": true
559 },
560 "row_id": {
561 "name": "row_id",
562 "type": "varchar(255)",
563 "primaryKey": true,
564 "notNull": true
565 },
566 "tenant": {
567 "name": "tenant",
568 "type": "varchar(255)",
569 "primaryKey": false,
570 "notNull": true
571 }
572 },
573 "indexes": {
574 "integration_uidx": {
575 "name": "integration_uidx",
576 "columns": [
577 {
578 "expression": "tenant",
579 "isExpression": false,
580 "asc": true,
581 "nulls": "last"
582 },
583 {
584 "expression": "slug",
585 "isExpression": false,
586 "asc": true,
587 "nulls": "last"
588 }
589 ],
590 "isUnique": true,
591 "concurrently": false,
592 "method": "btree",
593 "with": {}
594 }
595 },
596 "foreignKeys": {},
597 "compositePrimaryKeys": {},
598 "uniqueConstraints": {},
599 "policies": {},
600 "checkConstraints": {},
601 "isRLSEnabled": false
602 },
603 "public.oauth_client": {
604 "name": "oauth_client",
605 "schema": "",
606 "columns": {
607 "slug": {
608 "name": "slug",
609 "type": "varchar(255)",
610 "primaryKey": false,
611 "notNull": true
612 },
613 "authorization_url": {
614 "name": "authorization_url",
615 "type": "text",
616 "primaryKey": false,
617 "notNull": true
618 },
619 "token_url": {
620 "name": "token_url",
621 "type": "text",
622 "primaryKey": false,
623 "notNull": true
624 },
625 "grant": {
626 "name": "grant",
627 "type": "text",
628 "primaryKey": false,
629 "notNull": true
630 },
631 "client_id": {
632 "name": "client_id",
633 "type": "text",
634 "primaryKey": false,
635 "notNull": true
636 },
637 "client_secret_item_id": {
638 "name": "client_secret_item_id",
639 "type": "text",
640 "primaryKey": false,
641 "notNull": false
642 },
643 "resource": {
644 "name": "resource",
645 "type": "text",
646 "primaryKey": false,
647 "notNull": false
648 },
649 "origin_kind": {
650 "name": "origin_kind",
651 "type": "text",
652 "primaryKey": false,
653 "notNull": false
654 },
655 "origin_integration": {
656 "name": "origin_integration",
657 "type": "text",
658 "primaryKey": false,
659 "notNull": false
660 },
661 "created_at": {
662 "name": "created_at",
663 "type": "timestamp",
664 "primaryKey": false,
665 "notNull": true
666 },
667 "row_id": {
668 "name": "row_id",
669 "type": "varchar(255)",
670 "primaryKey": true,
671 "notNull": true
672 },
673 "tenant": {
674 "name": "tenant",
675 "type": "varchar(255)",
676 "primaryKey": false,
677 "notNull": true
678 },
679 "owner": {
680 "name": "owner",
681 "type": "varchar(255)",
682 "primaryKey": false,
683 "notNull": true
684 },
685 "subject": {
686 "name": "subject",
687 "type": "varchar(255)",
688 "primaryKey": false,
689 "notNull": true
690 }
691 },
692 "indexes": {
693 "oauth_client_uidx": {
694 "name": "oauth_client_uidx",
695 "columns": [
696 {
697 "expression": "tenant",
698 "isExpression": false,
699 "asc": true,
700 "nulls": "last"
701 },
702 {
703 "expression": "owner",
704 "isExpression": false,
705 "asc": true,
706 "nulls": "last"
707 },
708 {
709 "expression": "subject",
710 "isExpression": false,
711 "asc": true,
712 "nulls": "last"
713 },
714 {
715 "expression": "slug",
716 "isExpression": false,
717 "asc": true,
718 "nulls": "last"
719 }
720 ],
721 "isUnique": true,
722 "concurrently": false,
723 "method": "btree",
724 "with": {}
725 }
726 },
727 "foreignKeys": {},
728 "compositePrimaryKeys": {},
729 "uniqueConstraints": {},
730 "policies": {},
731 "checkConstraints": {},
732 "isRLSEnabled": false
733 },
734 "public.oauth_session": {
735 "name": "oauth_session",
736 "schema": "",
737 "columns": {
738 "state": {
739 "name": "state",
740 "type": "varchar(255)",
741 "primaryKey": false,
742 "notNull": true
743 },
744 "client_slug": {
745 "name": "client_slug",
746 "type": "text",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "integration": {
751 "name": "integration",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": true
755 },
756 "name": {
757 "name": "name",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": true
761 },
762 "template": {
763 "name": "template",
764 "type": "text",
765 "primaryKey": false,
766 "notNull": true
767 },
768 "redirect_url": {
769 "name": "redirect_url",
770 "type": "text",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "pkce_verifier": {
775 "name": "pkce_verifier",
776 "type": "text",
777 "primaryKey": false,
778 "notNull": false
779 },
780 "identity_label": {
781 "name": "identity_label",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": false
785 },
786 "payload": {
787 "name": "payload",
788 "type": "json",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "expires_at": {
793 "name": "expires_at",
794 "type": "bigint",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "created_at": {
799 "name": "created_at",
800 "type": "timestamp",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "row_id": {
805 "name": "row_id",
806 "type": "varchar(255)",
807 "primaryKey": true,
808 "notNull": true
809 },
810 "tenant": {
811 "name": "tenant",
812 "type": "varchar(255)",
813 "primaryKey": false,
814 "notNull": true
815 },
816 "owner": {
817 "name": "owner",
818 "type": "varchar(255)",
819 "primaryKey": false,
820 "notNull": true
821 },
822 "subject": {
823 "name": "subject",
824 "type": "varchar(255)",
825 "primaryKey": false,
826 "notNull": true
827 }
828 },
829 "indexes": {
830 "oauth_session_uidx": {
831 "name": "oauth_session_uidx",
832 "columns": [
833 {
834 "expression": "tenant",
835 "isExpression": false,
836 "asc": true,
837 "nulls": "last"
838 },
839 {
840 "expression": "state",
841 "isExpression": false,
842 "asc": true,
843 "nulls": "last"
844 }
845 ],
846 "isUnique": true,
847 "concurrently": false,
848 "method": "btree",
849 "with": {}
850 }
851 },
852 "foreignKeys": {},
853 "compositePrimaryKeys": {},
854 "uniqueConstraints": {},
855 "policies": {},
856 "checkConstraints": {},
857 "isRLSEnabled": false
858 },
859 "public.plugin_storage": {
860 "name": "plugin_storage",
861 "schema": "",
862 "columns": {
863 "plugin_id": {
864 "name": "plugin_id",
865 "type": "varchar(255)",
866 "primaryKey": false,
867 "notNull": true
868 },
869 "collection": {
870 "name": "collection",
871 "type": "varchar(255)",
872 "primaryKey": false,
873 "notNull": true
874 },
875 "key": {
876 "name": "key",
877 "type": "varchar(255)",
878 "primaryKey": false,
879 "notNull": true
880 },
881 "data": {
882 "name": "data",
883 "type": "json",
884 "primaryKey": false,
885 "notNull": true
886 },
887 "created_at": {
888 "name": "created_at",
889 "type": "timestamp",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "updated_at": {
894 "name": "updated_at",
895 "type": "timestamp",
896 "primaryKey": false,
897 "notNull": true
898 },
899 "row_id": {
900 "name": "row_id",
901 "type": "varchar(255)",
902 "primaryKey": true,
903 "notNull": true
904 },
905 "tenant": {
906 "name": "tenant",
907 "type": "varchar(255)",
908 "primaryKey": false,
909 "notNull": true
910 },
911 "owner": {
912 "name": "owner",
913 "type": "varchar(255)",
914 "primaryKey": false,
915 "notNull": true
916 },
917 "subject": {
918 "name": "subject",
919 "type": "varchar(255)",
920 "primaryKey": false,
921 "notNull": true
922 }
923 },
924 "indexes": {
925 "plugin_storage_uidx": {
926 "name": "plugin_storage_uidx",
927 "columns": [
928 {
929 "expression": "tenant",
930 "isExpression": false,
931 "asc": true,
932 "nulls": "last"
933 },
934 {
935 "expression": "owner",
936 "isExpression": false,
937 "asc": true,
938 "nulls": "last"
939 },
940 {
941 "expression": "subject",
942 "isExpression": false,
943 "asc": true,
944 "nulls": "last"
945 },
946 {
947 "expression": "plugin_id",
948 "isExpression": false,
949 "asc": true,
950 "nulls": "last"
951 },
952 {
953 "expression": "collection",
954 "isExpression": false,
955 "asc": true,
956 "nulls": "last"
957 },
958 {
959 "expression": "key",
960 "isExpression": false,
961 "asc": true,
962 "nulls": "last"
963 }
964 ],
965 "isUnique": true,
966 "concurrently": false,
967 "method": "btree",
968 "with": {}
969 }
970 },
971 "foreignKeys": {},
972 "compositePrimaryKeys": {},
973 "uniqueConstraints": {},
974 "policies": {},
975 "checkConstraints": {},
976 "isRLSEnabled": false
977 },
978 "public.private_executor_cloud_settings": {
979 "name": "private_executor_cloud_settings",
980 "schema": "",
981 "columns": {
982 "id": {
983 "name": "id",
984 "type": "varchar(255)",
985 "primaryKey": true,
986 "notNull": true
987 },
988 "version": {
989 "name": "version",
990 "type": "varchar(255)",
991 "primaryKey": false,
992 "notNull": true,
993 "default": "'1.0.0'"
994 }
995 },
996 "indexes": {},
997 "foreignKeys": {},
998 "compositePrimaryKeys": {},
999 "uniqueConstraints": {},
1000 "policies": {},
1001 "checkConstraints": {},
1002 "isRLSEnabled": false
1003 },
1004 "public.tool": {
1005 "name": "tool",
1006 "schema": "",
1007 "columns": {
1008 "integration": {
1009 "name": "integration",
1010 "type": "varchar(255)",
1011 "primaryKey": false,
1012 "notNull": true
1013 },
1014 "connection": {
1015 "name": "connection",
1016 "type": "varchar(255)",
1017 "primaryKey": false,
1018 "notNull": true
1019 },
1020 "plugin_id": {
1021 "name": "plugin_id",
1022 "type": "text",
1023 "primaryKey": false,
1024 "notNull": true
1025 },
1026 "name": {
1027 "name": "name",
1028 "type": "varchar(255)",
1029 "primaryKey": false,
1030 "notNull": true
1031 },
1032 "description": {
1033 "name": "description",
1034 "type": "text",
1035 "primaryKey": false,
1036 "notNull": true
1037 },
1038 "input_schema": {
1039 "name": "input_schema",
1040 "type": "json",
1041 "primaryKey": false,
1042 "notNull": false
1043 },
1044 "output_schema": {
1045 "name": "output_schema",
1046 "type": "json",
1047 "primaryKey": false,
1048 "notNull": false
1049 },
1050 "annotations": {
1051 "name": "annotations",
1052 "type": "json",
1053 "primaryKey": false,
1054 "notNull": false
1055 },
1056 "created_at": {
1057 "name": "created_at",
1058 "type": "timestamp",
1059 "primaryKey": false,
1060 "notNull": true
1061 },
1062 "updated_at": {
1063 "name": "updated_at",
1064 "type": "timestamp",
1065 "primaryKey": false,
1066 "notNull": true
1067 },
1068 "row_id": {
1069 "name": "row_id",
1070 "type": "varchar(255)",
1071 "primaryKey": true,
1072 "notNull": true
1073 },
1074 "tenant": {
1075 "name": "tenant",
1076 "type": "varchar(255)",
1077 "primaryKey": false,
1078 "notNull": true
1079 },
1080 "owner": {
1081 "name": "owner",
1082 "type": "varchar(255)",
1083 "primaryKey": false,
1084 "notNull": true
1085 },
1086 "subject": {
1087 "name": "subject",
1088 "type": "varchar(255)",
1089 "primaryKey": false,
1090 "notNull": true
1091 }
1092 },
1093 "indexes": {
1094 "tool_uidx": {
1095 "name": "tool_uidx",
1096 "columns": [
1097 {
1098 "expression": "tenant",
1099 "isExpression": false,
1100 "asc": true,
1101 "nulls": "last"
1102 },
1103 {
1104 "expression": "owner",
1105 "isExpression": false,
1106 "asc": true,
1107 "nulls": "last"
1108 },
1109 {
1110 "expression": "subject",
1111 "isExpression": false,
1112 "asc": true,
1113 "nulls": "last"
1114 },
1115 {
1116 "expression": "integration",
1117 "isExpression": false,
1118 "asc": true,
1119 "nulls": "last"
1120 },
1121 {
1122 "expression": "connection",
1123 "isExpression": false,
1124 "asc": true,
1125 "nulls": "last"
1126 },
1127 {
1128 "expression": "name",
1129 "isExpression": false,
1130 "asc": true,
1131 "nulls": "last"
1132 }
1133 ],
1134 "isUnique": true,
1135 "concurrently": false,
1136 "method": "btree",
1137 "with": {}
1138 }
1139 },
1140 "foreignKeys": {},
1141 "compositePrimaryKeys": {},
1142 "uniqueConstraints": {},
1143 "policies": {},
1144 "checkConstraints": {},
1145 "isRLSEnabled": false
1146 },
1147 "public.tool_policy": {
1148 "name": "tool_policy",
1149 "schema": "",
1150 "columns": {
1151 "id": {
1152 "name": "id",
1153 "type": "varchar(255)",
1154 "primaryKey": false,
1155 "notNull": true
1156 },
1157 "pattern": {
1158 "name": "pattern",
1159 "type": "text",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "action": {
1164 "name": "action",
1165 "type": "text",
1166 "primaryKey": false,
1167 "notNull": true
1168 },
1169 "position": {
1170 "name": "position",
1171 "type": "text",
1172 "primaryKey": false,
1173 "notNull": true
1174 },
1175 "created_at": {
1176 "name": "created_at",
1177 "type": "timestamp",
1178 "primaryKey": false,
1179 "notNull": true
1180 },
1181 "updated_at": {
1182 "name": "updated_at",
1183 "type": "timestamp",
1184 "primaryKey": false,
1185 "notNull": true
1186 },
1187 "row_id": {
1188 "name": "row_id",
1189 "type": "varchar(255)",
1190 "primaryKey": true,
1191 "notNull": true
1192 },
1193 "tenant": {
1194 "name": "tenant",
1195 "type": "varchar(255)",
1196 "primaryKey": false,
1197 "notNull": true
1198 },
1199 "owner": {
1200 "name": "owner",
1201 "type": "varchar(255)",
1202 "primaryKey": false,
1203 "notNull": true
1204 },
1205 "subject": {
1206 "name": "subject",
1207 "type": "varchar(255)",
1208 "primaryKey": false,
1209 "notNull": true
1210 }
1211 },
1212 "indexes": {
1213 "tool_policy_uidx": {
1214 "name": "tool_policy_uidx",
1215 "columns": [
1216 {
1217 "expression": "tenant",
1218 "isExpression": false,
1219 "asc": true,
1220 "nulls": "last"
1221 },
1222 {
1223 "expression": "owner",
1224 "isExpression": false,
1225 "asc": true,
1226 "nulls": "last"
1227 },
1228 {
1229 "expression": "subject",
1230 "isExpression": false,
1231 "asc": true,
1232 "nulls": "last"
1233 },
1234 {
1235 "expression": "id",
1236 "isExpression": false,
1237 "asc": true,
1238 "nulls": "last"
1239 }
1240 ],
1241 "isUnique": true,
1242 "concurrently": false,
1243 "method": "btree",
1244 "with": {}
1245 }
1246 },
1247 "foreignKeys": {},
1248 "compositePrimaryKeys": {},
1249 "uniqueConstraints": {},
1250 "policies": {},
1251 "checkConstraints": {},
1252 "isRLSEnabled": false
1253 }
1254 },
1255 "enums": {},
1256 "schemas": {},
1257 "sequences": {},
1258 "roles": {},
1259 "policies": {},
1260 "views": {},
1261 "_meta": {
1262 "columns": {},
1263 "schemas": {},
1264 "tables": {}
1265 }
1266}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -43,6 +43,13 @@ · 42 unmodified lines
43 "when": 1781491100009,43 "when": 1781491100009,
44 "tag": "0005_integration_descriptions",44 "tag": "0005_integration_descriptions",
45 "breakpoints": true45 "breakpoints": true
46 },
47 {
48 "idx": 6,
49 "version": "7",
50 "when": 1781994790376,
51 "tag": "0006_google_openapi_ownership",
52 "breakpoints": true
46 }53 }
47 ]54 ]
48}55}
apps/host-cloudflare/src/db/d1.ts+2−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store";13import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store";
14 14
15import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config";15import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config";
16import { runCloudflareDataMigrations } from "./data-migrations";
16 17
17// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
18// D1 DbProvider handle — the CF-native swap for self-host's libSQL handle.19// D1 DbProvider handle — the CF-native swap for self-host's libSQL handle.
@@ -44,6 +45,7 @@ export const createD1ExecutorDb = async ( · 25 unmodified lines
44 // exposes one. The bring-up is idempotent `CREATE TABLE IF NOT EXISTS`, so run45 // exposes one. The bring-up is idempotent `CREATE TABLE IF NOT EXISTS`, so run
45 // it WITHOUT a transaction by handing the ensure a run-only view of the handle.46 // it WITHOUT a transaction by handing the ensure a run-only view of the handle.
46 await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options);47 await ensureDrizzleRuntimeSchemaFromTables({ run: (query) => drizzleDb.run(query) }, options);
48 await runCloudflareDataMigrations(db, blobs);
47 49
48 // `interactiveTransactions: false` — D1 rejects interactive transactions, so50 // `interactiveTransactions: false` — D1 rejects interactive transactions, so
49 // the fuma adapter runs transaction callbacks directly (auto-commit per51 // the fuma adapter runs transaction callbacks directly (auto-commit per
apps/host-cloudflare/src/db/data-migrations.test.ts+153−0
@@ -0,0 +1,153 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
4
5import { collectTables, type SqliteDataMigrationClient } from "@executor-js/sdk";
6import { createSqliteTestFumaDb } from "@executor-js/sdk/testing";
7
8import { runCloudflareDataMigrations } from "./data-migrations";
9
10const now = 1_780_000_000_000;
11
12const makeFakeD1 = (client: SqliteDataMigrationClient): D1Database => {
13 const prepare = (sql: string) => {
14 const statement = (args: readonly unknown[]): Record<string, unknown> => ({
15 bind: (...values: readonly unknown[]) => statement([...args, ...values]),
16 all: async () => {
17 const result = await client.execute({ sql, args });
18 return { success: true, meta: {}, results: result.rows };
19 },
20 run: async () => {
21 await client.execute({ sql, args });
22 return { success: true, meta: {}, results: [] };
23 },
24 });
25 return statement([]);
26 };
27
28 // oxlint-disable-next-line executor/no-double-cast -- test double: only the D1 methods used by the migration runner are implemented
29 return {
30 prepare,
31 withSession: () => ({ prepare }),
32 } as unknown as D1Database;
33};
34
35const makeFakeR2 = (): { readonly bucket: R2Bucket; readonly objects: Map<string, string> } => {
36 const objects = new Map<string, string>();
37 // oxlint-disable-next-line executor/no-double-cast -- test double: only the R2 methods used by the migration are implemented
38 const bucket = {
39 get: async (key: string) => {
40 const value = objects.get(key);
41 return value === undefined ? null : { text: async () => value };
42 },
43 put: async (key: string, value: string) => {
44 objects.set(key, value);
45 },
46 head: async (key: string) => (objects.has(key) ? {} : null),
47 } as unknown as R2Bucket;
48 return { bucket, objects };
49};
50
51const insertIntegration = (
52 client: SqliteDataMigrationClient,
53 row: {
54 readonly rowId: string;
55 readonly tenant: string;
56 readonly slug: string;
57 readonly pluginId: string;
58 readonly config: unknown;
59 },
60) =>
61 client.execute({
62 sql: `INSERT INTO integration
63 (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
64 VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
65 args: [
66 row.rowId,
67 row.tenant,
68 row.slug,
69 row.pluginId,
70 row.slug,
71 row.slug,
72 JSON.stringify(row.config),
73 now,
74 now,
75 ],
76 });
77
78const insertOperationStorage = (
79 client: SqliteDataMigrationClient,
80 row: {
81 readonly tenant: string;
82 readonly pluginId: string;
83 readonly integration: string;
84 },
85) =>
86 client.execute({
87 sql: `INSERT INTO plugin_storage
88 (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id)
89 VALUES (?, 'org', '', ?, 'operation', ?, ?, ?, ?, ?)`,
90 args: [
91 row.tenant,
92 row.pluginId,
93 `${row.integration}.items.list`,
94 JSON.stringify({
95 integration: row.integration,
96 toolName: "items.list",
97 binding: { method: "get", pathTemplate: "/items" },
98 }),
99 now,
100 now,
101 `storage-${row.pluginId}-${row.integration}`,
102 ],
103 });
104
105describe("runCloudflareDataMigrations", () => {
106 it.effect("moves Google OpenAPI ownership and copies the R2 spec object", () =>
107 Effect.gen(function* () {
108 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
109 const { bucket, objects } = makeFakeR2();
110
111 yield* Effect.promise(() =>
112 insertIntegration(db.client, {
113 rowId: "google-row",
114 tenant: "org_1",
115 slug: "google",
116 pluginId: "openapi",
117 config: {
118 specHash: "googlehash",
119 googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
120 },
121 }),
122 );
123 yield* Effect.promise(() =>
124 insertOperationStorage(db.client, {
125 tenant: "org_1",
126 pluginId: "openapi",
127 integration: "google",
128 }),
129 );
130 objects.set("o:org_1/openapi/spec/googlehash", "google spec");
131
132 const d1 = makeFakeD1(db.client);
133 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
134 "2026-06-20-google-openapi-ownership",
135 ]);
136 expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
137
138 expect(objects.get("o:org_1/google/spec/googlehash")).toBe("google spec");
139
140 const integrations = yield* Effect.promise(() =>
141 db.client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
142 );
143 expect(integrations.rows).toEqual([{ slug: "google", plugin_id: "google" }]);
144
145 const storage = yield* Effect.promise(() =>
146 db.client.execute("SELECT plugin_id, key FROM plugin_storage ORDER BY plugin_id, key"),
147 );
148 expect(storage.rows).toEqual([{ plugin_id: "google", key: "google.items.list" }]);
149
150 yield* Effect.promise(() => db.close());
151 }),
152 );
153});
apps/host-cloudflare/src/db/data-migrations.ts+90−0
@@ -0,0 +1,90 @@
1import { Effect } from "effect";
2import type { D1Database, D1DatabaseSession, R2Bucket } from "@cloudflare/workers-types";
3
4import {
5 DataMigrationError,
6 runSqliteDataMigrations,
7 type SqliteDataMigration,
8 type SqliteDataMigrationClient,
9} from "@executor-js/sdk";
10import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";
11
12const TX_CONTROL = new Set(["BEGIN", "BEGIN TRANSACTION", "COMMIT", "ROLLBACK"]);
13
14const firstWord = (sql: string): string => sql.trimStart().split(/\s+/, 1)[0]?.toUpperCase() ?? "";
15
16const queryRows = <T extends Record<string, unknown>>(
17 session: D1DatabaseSession,
18 stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
19): Promise<readonly T[]> => {
20 const sql = typeof stmt === "string" ? stmt : stmt.sql;
21 const args = typeof stmt === "string" ? [] : stmt.args;
22 const prepared = session.prepare(sql).bind(...args);
23 if (firstWord(sql) === "SELECT") {
24 return prepared.all<T>().then((result) => result.results);
25 }
26 return prepared.run<T>().then(() => []);
27};
28
29export const d1DataMigrationClient = (db: D1Database): SqliteDataMigrationClient => {
30 const session = db.withSession("first-primary");
31 return {
32 execute: (stmt) => {
33 const sql =
34 typeof stmt === "string" ? stmt.trim().toUpperCase() : stmt.sql.trim().toUpperCase();
35 if (TX_CONTROL.has(sql)) return Promise.resolve({ rows: [] });
36 return queryRows(session, stmt).then((rows) => ({ rows }));
37 },
38 };
39};
40
41const r2ObjectName = (tenant: string, pluginId: string, key: string): string =>
42 `o:${tenant}/${pluginId}/${key}`;
43
44const copyGoogleOpenApiSpecBlobsToR2 = (
45 client: SqliteDataMigrationClient,
46 bucket: R2Bucket,
47): Effect.Effect<void, DataMigrationError> =>
48 Effect.tryPromise({
49 try: async () => {
50 const result = await client.execute(
51 `SELECT tenant, json_extract(config, '$.specHash') AS spec_hash
52 FROM integration
53 WHERE plugin_id = 'openapi'
54 AND config IS NOT NULL
55 AND json_type(config, '$.googleDiscoveryUrls') = 'array'
56 AND json_extract(config, '$.specHash') IS NOT NULL
57 AND json_extract(config, '$.specHash') <> ''`,
58 );
59 for (const row of result.rows) {
60 if (typeof row.tenant !== "string" || typeof row.spec_hash !== "string") continue;
61 const key = `spec/${row.spec_hash}`;
62 const target = r2ObjectName(row.tenant, "google", key);
63 if ((await bucket.head(target)) != null) continue;
64 const source = await bucket.get(r2ObjectName(row.tenant, "openapi", key));
65 if (source == null) continue;
66 await bucket.put(target, await source.text());
67 }
68 },
69 catch: (cause) =>
70 new DataMigrationError({ migration: googleOpenApiOwnershipDataMigration.name, cause }),
71 });
72
73const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
74 {
75 name: googleOpenApiOwnershipDataMigration.name,
76 run: (client) =>
77 Effect.gen(function* () {
78 if (bucket) yield* copyGoogleOpenApiSpecBlobsToR2(client, bucket);
79 yield* googleOpenApiOwnershipDataMigration.run(client);
80 }),
81 },
82];
83
84export const runCloudflareDataMigrations = (
85 db: D1Database,
86 bucket: R2Bucket | undefined,
87): Promise<readonly string[]> =>
88 Effect.runPromise(
89 runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)),
90 );
apps/host-cloudflare/src/plugins.ts+2−0
@@ -1,4 +1,5 @@
1import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";1import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
2import { googleHttpPlugin } from "@executor-js/plugin-google/api";
2import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";3import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
3import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";4import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
4import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";5import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -17,6 +18,7 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; · 12 unmodified lines
17export const makeCloudflarePlugins = (secretKey: string) =>18export const makeCloudflarePlugins = (secretKey: string) =>
18 [19 [
19 openApiHttpPlugin(),20 openApiHttpPlugin(),
21 googleHttpPlugin(),
20 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),22 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
21 graphqlHttpPlugin(),23 graphqlHttpPlugin(),
22 encryptedSecretsPlugin({ key: secretKey }),24 encryptedSecretsPlugin({ key: secretKey }),
apps/host-selfhost/src/db/data-migrations.ts+2−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 openApiSpecBlobDataMigration,12 openApiSpecBlobDataMigration,
13} from "@executor-js/plugin-openapi";13} from "@executor-js/plugin-openapi";
14import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";14import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
15import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";
15 16
16import { authConfigTransforms } from "./auth-config-migration";17import { authConfigTransforms } from "./auth-config-migration";
17 18
@@ -29,4 +30,5 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [ · 11 unmodified lines
29 // migrate-specs-to-blobs script.30 // migrate-specs-to-blobs script.
30 openApiSpecBlobDataMigration,31 openApiSpecBlobDataMigration,
31 graphqlIntrospectionBlobDataMigration,32 graphqlIntrospectionBlobDataMigration,
33 googleOpenApiOwnershipDataMigration,
32];34];
apps/local/src/db/data-migrations.ts+2−0
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 openApiSpecBlobDataMigration,12 openApiSpecBlobDataMigration,
13} from "@executor-js/plugin-openapi";13} from "@executor-js/plugin-openapi";
14import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";14import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
15import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google";
15 16
16import { authConfigTransforms } from "./auth-config-migration";17import { authConfigTransforms } from "./auth-config-migration";
17import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";18import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration";
@@ -36,4 +37,5 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 18 unmodified lines
36 // migrate-specs-to-blobs script.37 // migrate-specs-to-blobs script.
37 openApiSpecBlobDataMigration,38 openApiSpecBlobDataMigration,
38 graphqlIntrospectionBlobDataMigration,39 graphqlIntrospectionBlobDataMigration,
40 googleOpenApiOwnershipDataMigration,
39];41];
bun.lock+1−0
@@ -369,6 +369,7 @@ · 368 unmodified lines
369 "version": "0.0.33",369 "version": "0.0.33",
370 "dependencies": {370 "dependencies": {
371 "@executor-js/plugin-file-secrets": "workspace:*",371 "@executor-js/plugin-file-secrets": "workspace:*",
372 "@executor-js/plugin-google": "workspace:*",
372 "@executor-js/plugin-graphql": "workspace:*",373 "@executor-js/plugin-graphql": "workspace:*",
373 "@executor-js/plugin-keychain": "workspace:*",374 "@executor-js/plugin-keychain": "workspace:*",
374 "@executor-js/plugin-mcp": "workspace:*",375 "@executor-js/plugin-mcp": "workspace:*",
examples/all-plugins/package.json+1−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 },10 },
11 "dependencies": {11 "dependencies": {
12 "@executor-js/plugin-file-secrets": "workspace:*",12 "@executor-js/plugin-file-secrets": "workspace:*",
13 "@executor-js/plugin-google": "workspace:*",
13 "@executor-js/plugin-graphql": "workspace:*",14 "@executor-js/plugin-graphql": "workspace:*",
14 "@executor-js/plugin-keychain": "workspace:*",15 "@executor-js/plugin-keychain": "workspace:*",
15 "@executor-js/plugin-mcp": "workspace:*",16 "@executor-js/plugin-mcp": "workspace:*",
examples/all-plugins/src/main.ts+3−1
@@ -33,6 +33,7 @@ import { · 32 unmodified lines
33} from "@executor-js/sdk";33} from "@executor-js/sdk";
34 34
35import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";35import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
36import { googlePlugin } from "@executor-js/plugin-google";
36import { graphqlPlugin } from "@executor-js/plugin-graphql";37import { graphqlPlugin } from "@executor-js/plugin-graphql";
37import { keychainPlugin } from "@executor-js/plugin-keychain";38import { keychainPlugin } from "@executor-js/plugin-keychain";
38import { mcpPlugin } from "@executor-js/plugin-mcp";39import { mcpPlugin } from "@executor-js/plugin-mcp";
@@ -77,6 +78,7 @@ const plugins = [ · 38 unmodified lines
77 // Integration plugins — these declare their own schemas (tables), register78 // Integration plugins — these declare their own schemas (tables), register
78 // integrations via their extension methods (`addSpec` / `addServer` /79 // integrations via their extension methods (`addSpec` / `addServer` /
79 // `addIntegration`), and produce tools per connection.80 // `addIntegration`), and produce tools per connection.
81 googlePlugin(),
80 graphqlPlugin(),82 graphqlPlugin(),
81 mcpPlugin({ dangerouslyAllowStdioMCP: false }),83 mcpPlugin({ dangerouslyAllowStdioMCP: false }),
82 openApiPlugin(),84 openApiPlugin(),
@@ -416,7 +418,7 @@ const program = Effect.gen(function* () { · 333 unmodified lines
416 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);418 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);
417 419
418 // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." });420 // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." });
419 // executor.openapi.addSpec({ spec: { kind: "googleDiscovery", url: "..." }, slug: "..." });421 // executor.google.addBundle({ urls: ["..."], slug: "google" });
420 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });422 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });
421 423
422 // -------------------------------------------------------------------------424 // -------------------------------------------------------------------------
knip.config.ts+1−1
@@ -41,7 +41,7 @@ const config: KnipConfig = { · 40 unmodified lines
41 "packages/plugins/file-secrets": {41 "packages/plugins/file-secrets": {
42 includeEntryExports: true,42 includeEntryExports: true,
43 },43 },
44 "packages/plugins/google-discovery": {44 "packages/plugins/google": {
45 includeEntryExports: true,45 includeEntryExports: true,
46 },46 },
47 "packages/plugins/onepassword": {47 "packages/plugins/onepassword": {
packages/plugins/google/CHANGELOG.md+1−0
@@ -0,0 +1 @@
1# @executor-js/plugin-google
packages/plugins/google/src/sdk/config.ts+24−0
@@ -0,0 +1,24 @@
1import { Option, Schema } from "effect";
2import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi";
3
4export const GoogleIntegrationConfigSchema = Schema.Struct({
5 specHash: Schema.optional(Schema.String),
6 sourceUrl: Schema.optional(Schema.String),
7 googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
8 baseUrl: Schema.optional(Schema.String),
9 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
10 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
11 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
12});
13
14export type GoogleIntegrationConfig = Omit<
15 typeof GoogleIntegrationConfigSchema.Type,
16 "authenticationTemplate"
17> & {
18 readonly authenticationTemplate?: readonly Authentication[];
19};
20
21const decodeConfig = Schema.decodeUnknownOption(GoogleIntegrationConfigSchema);
22
23export const decodeGoogleIntegrationConfig = (value: unknown): GoogleIntegrationConfig | null =>
24 Option.getOrNull(decodeConfig(value)) as GoogleIntegrationConfig | null;
packages/plugins/google/src/sdk/index.ts+4−0
@@ -36,3 +36,7 @@ export { · 35 unmodified lines
36 type GoogleUpdateInput,36 type GoogleUpdateInput,
37 type GoogleUpdateResult,37 type GoogleUpdateResult,
38} from "./plugin";38} from "./plugin";
39export {
40 googleOpenApiOwnershipDataMigration,
41 runSqliteGoogleOpenApiOwnershipMigration,
42} from "./openapi-ownership-migration";
packages/plugins/google/src/sdk/openapi-ownership-migration.test.ts+219−0
@@ -0,0 +1,219 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { collectTables } from "@executor-js/sdk";
4import { createSqliteTestFumaDb } from "@executor-js/sdk/testing";
5import type { SqliteDataMigrationClient } from "@executor-js/sdk/core";
6
7import { runSqliteGoogleOpenApiOwnershipMigration } from "./openapi-ownership-migration";
8
9const now = 1_780_000_000_000;
10
11const insertIntegration = (
12 client: SqliteDataMigrationClient,
13 row: {
14 readonly rowId: string;
15 readonly tenant: string;
16 readonly slug: string;
17 readonly pluginId: string;
18 readonly config: unknown;
19 },
20) =>
21 client.execute({
22 sql: `INSERT INTO integration
23 (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at)
24 VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)`,
25 args: [
26 row.rowId,
27 row.tenant,
28 row.slug,
29 row.pluginId,
30 row.slug,
31 row.slug,
32 JSON.stringify(row.config),
33 now,
34 now,
35 ],
36 });
37
38const insertBlob = (
39 client: SqliteDataMigrationClient,
40 row: {
41 readonly namespace: string;
42 readonly key: string;
43 readonly value: string;
44 },
45) =>
46 client.execute({
47 sql: "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)",
48 args: [
49 row.namespace,
50 row.key,
51 row.value,
52 `blob-${row.namespace}-${row.key}`,
53 JSON.stringify([row.namespace, row.key]),
54 ],
55 });
56
57const insertOperationStorage = (
58 client: SqliteDataMigrationClient,
59 row: {
60 readonly tenant: string;
61 readonly pluginId: string;
62 readonly integration: string;
63 readonly toolName: string;
64 },
65) =>
66 client.execute({
67 sql: `INSERT INTO plugin_storage
68 (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id)
69 VALUES (?, 'org', '', ?, 'operation', ?, ?, ?, ?, ?)`,
70 args: [
71 row.tenant,
72 row.pluginId,
73 `${row.integration}.${row.toolName}`,
74 JSON.stringify({
75 integration: row.integration,
76 toolName: row.toolName,
77 binding: { method: "get", pathTemplate: "/items" },
78 }),
79 now,
80 now,
81 `storage-${row.pluginId}-${row.integration}-${row.toolName}`,
82 ],
83 });
84
85const insertTool = (
86 client: SqliteDataMigrationClient,
87 row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },
88) =>
89 client.execute({
90 sql: `INSERT INTO tool
91 (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id)
92 VALUES (?, 'org', '', ?, 'default', ?, 'items.list', 'List items', NULL, NULL, NULL, ?, ?, ?)`,
93 args: [row.tenant, row.integration, row.pluginId, now, now, `tool-${row.integration}`],
94 });
95
96const insertDefinition = (
97 client: SqliteDataMigrationClient,
98 row: { readonly tenant: string; readonly pluginId: string; readonly integration: string },
99) =>
100 client.execute({
101 sql: `INSERT INTO definition
102 (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id)
103 VALUES (?, 'org', '', ?, 'default', ?, 'Item', '{}', ?, ?)`,
104 args: [row.tenant, row.integration, row.pluginId, now, `definition-${row.integration}`],
105 });
106
107describe("runSqliteGoogleOpenApiOwnershipMigration", () => {
108 it.effect("moves OpenAPI-owned Google bundle rows to the Google plugin", () =>
109 Effect.gen(function* () {
110 const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
111 const client = db.client;
112
113 yield* Effect.promise(() =>
114 insertIntegration(client, {
115 rowId: "google-row",
116 tenant: "org_1",
117 slug: "google",
118 pluginId: "openapi",
119 config: {
120 specHash: "googlehash",
121 googleDiscoveryUrls: ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"],
122 },
123 }),
124 );
125 yield* Effect.promise(() =>
126 insertIntegration(client, {
127 rowId: "stripe-row",
128 tenant: "org_1",
129 slug: "stripe",
130 pluginId: "openapi",
131 config: { specHash: "stripehash", sourceUrl: "https://stripe.example/openapi.json" },
132 }),
133 );
134 yield* Effect.promise(() =>
135 insertBlob(client, {
136 namespace: "o:org_1/openapi",
137 key: "spec/googlehash",
138 value: "google spec",
139 }),
140 );
141 yield* Effect.promise(() =>
142 insertBlob(client, {
143 namespace: "o:org_1/openapi",
144 key: "spec/stripehash",
145 value: "stripe spec",
146 }),
147 );
148
149 for (const integration of ["google", "stripe"] as const) {
150 yield* Effect.promise(() =>
151 insertOperationStorage(client, {
152 tenant: "org_1",
153 pluginId: "openapi",
154 integration,
155 toolName: "items.list",
156 }),
157 );
158 yield* Effect.promise(() =>
159 insertTool(client, { tenant: "org_1", pluginId: "openapi", integration }),
160 );
161 yield* Effect.promise(() =>
162 insertDefinition(client, { tenant: "org_1", pluginId: "openapi", integration }),
163 );
164 }
165
166 expect(yield* runSqliteGoogleOpenApiOwnershipMigration(client)).toBe(1);
167 expect(yield* runSqliteGoogleOpenApiOwnershipMigration(client)).toBe(0);
168
169 const integrations = yield* Effect.promise(() =>
170 client.execute("SELECT slug, plugin_id FROM integration ORDER BY slug"),
171 );
172 expect(integrations.rows).toEqual([
173 { slug: "google", plugin_id: "google" },
174 { slug: "stripe", plugin_id: "openapi" },
175 ]);
176
177 const googleBlob = yield* Effect.promise(() =>
178 client.execute({
179 sql: "SELECT value FROM blob WHERE id = ?",
180 args: [JSON.stringify(["o:org_1/google", "spec/googlehash"])],
181 }),
182 );
183 expect(googleBlob.rows).toEqual([{ value: "google spec" }]);
184
185 const openApiBlobs = yield* Effect.promise(() =>
186 client.execute("SELECT key FROM blob WHERE namespace = 'o:org_1/openapi' ORDER BY key"),
187 );
188 expect(openApiBlobs.rows).toEqual([{ key: "spec/googlehash" }, { key: "spec/stripehash" }]);
189
190 const storage = yield* Effect.promise(() =>
191 client.execute(
192 "SELECT plugin_id, key FROM plugin_storage WHERE collection = 'operation' ORDER BY plugin_id, key",
193 ),
194 );
195 expect(storage.rows).toEqual([
196 { plugin_id: "google", key: "google.items.list" },
197 { plugin_id: "openapi", key: "stripe.items.list" },
198 ]);
199
200 const tools = yield* Effect.promise(() =>
201 client.execute("SELECT integration, plugin_id FROM tool ORDER BY integration"),
202 );
203 expect(tools.rows).toEqual([
204 { integration: "google", plugin_id: "google" },
205 { integration: "stripe", plugin_id: "openapi" },
206 ]);
207
208 const definitions = yield* Effect.promise(() =>
209 client.execute("SELECT integration, plugin_id FROM definition ORDER BY integration"),
210 );
211 expect(definitions.rows).toEqual([
212 { integration: "google", plugin_id: "google" },
213 { integration: "stripe", plugin_id: "openapi" },
214 ]);
215
216 yield* Effect.promise(() => db.close());
217 }),
218 );
219});
packages/plugins/google/src/sdk/openapi-ownership-migration.ts+148−0
@@ -0,0 +1,148 @@
1import { Effect } from "effect";
2import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core";
3
4const MIGRATION_NAME = "2026-06-20-google-openapi-ownership";
5const googleOpenApiCandidate = (alias?: string): string => {
6 const column = (name: string) => (alias ? `${alias}.${name}` : name);
7 return `${column("plugin_id")} = 'openapi' AND ${column("config")} IS NOT NULL AND json_type(${column("config")}, '$.googleDiscoveryUrls') = 'array'`;
8};
9
10const execute = (
11 client: SqliteDataMigrationClient,
12 stmt: string | { readonly sql: string; readonly args: readonly unknown[] },
13) =>
14 Effect.tryPromise({
15 try: () => client.execute(stmt),
16 catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }),
17 });
18
19export const runSqliteGoogleOpenApiOwnershipMigration = (
20 client: SqliteDataMigrationClient,
21): Effect.Effect<number, DataMigrationError> =>
22 Effect.gen(function* () {
23 const exists = yield* execute(
24 client,
25 "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'integration'",
26 );
27 if (exists.rows.length === 0) return 0;
28
29 const count = yield* execute(
30 client,
31 `SELECT COUNT(*) AS count FROM integration WHERE ${googleOpenApiCandidate()}`,
32 );
33 const moved = Number(count.rows[0]?.count ?? 0);
34 if (moved === 0) return 0;
35
36 const applyAll = Effect.gen(function* () {
37 yield* execute(
38 client,
39 `INSERT OR IGNORE INTO blob (namespace, key, value, row_id, id)
40 SELECT
41 'o:' || g.tenant || '/google',
42 b.key,
43 b.value,
44 lower(hex(randomblob(16))),
45 json_array('o:' || g.tenant || '/google', b.key)
46 FROM integration g
47 JOIN blob b
48 ON b.namespace = 'o:' || g.tenant || '/openapi'
49 AND b.key = 'spec/' || json_extract(g.config, '$.specHash')
50 WHERE ${googleOpenApiCandidate("g")}
51 AND json_extract(g.config, '$.specHash') IS NOT NULL
52 AND json_extract(g.config, '$.specHash') <> ''`,
53 );
54
55 yield* execute(
56 client,
57 `INSERT OR IGNORE INTO plugin_storage
58 (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id)
59 SELECT
60 ps.tenant,
61 ps.owner,
62 ps.subject,
63 'google',
64 ps.collection,
65 ps.key,
66 ps.data,
67 ps.created_at,
68 ps.updated_at,
69 lower(hex(randomblob(16)))
70 FROM plugin_storage ps
71 JOIN integration g
72 ON g.tenant = ps.tenant
73 WHERE ps.plugin_id = 'openapi'
74 AND ${googleOpenApiCandidate("g")}
75 AND ps.collection = 'operation'
76 AND (
77 json_extract(ps.data, '$.integration') = g.slug
78 OR ps.key LIKE g.slug || '.%'
79 )`,
80 );
81
82 yield* execute(
83 client,
84 `DELETE FROM plugin_storage
85 WHERE plugin_id = 'openapi'
86 AND collection = 'operation'
87 AND EXISTS (
88 SELECT 1
89 FROM integration g
90 WHERE g.tenant = plugin_storage.tenant
91 AND ${googleOpenApiCandidate("g")}
92 AND (
93 json_extract(plugin_storage.data, '$.integration') = g.slug
94 OR plugin_storage.key LIKE g.slug || '.%'
95 )
96 )`,
97 );
98
99 yield* execute(
100 client,
101 `UPDATE tool
102 SET plugin_id = 'google'
103 WHERE plugin_id = 'openapi'
104 AND EXISTS (
105 SELECT 1
106 FROM integration g
107 WHERE g.tenant = tool.tenant
108 AND ${googleOpenApiCandidate("g")}
109 AND g.slug = tool.integration
110 )`,
111 );
112
113 yield* execute(
114 client,
115 `UPDATE definition
116 SET plugin_id = 'google'
117 WHERE plugin_id = 'openapi'
118 AND EXISTS (
119 SELECT 1
120 FROM integration g
121 WHERE g.tenant = definition.tenant
122 AND ${googleOpenApiCandidate("g")}
123 AND g.slug = definition.integration
124 )`,
125 );
126
127 yield* execute(
128 client,
129 `UPDATE integration
130 SET plugin_id = 'google'
131 WHERE ${googleOpenApiCandidate()}`,
132 );
133
134 yield* execute(client, "COMMIT");
135 return moved;
136 });
137
138 yield* execute(client, "BEGIN");
139 return yield* applyAll.pipe(
140 Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)),
141 );
142 });
143
144export const googleOpenApiOwnershipDataMigration = {
145 name: MIGRATION_NAME,
146 run: (client: SqliteDataMigrationClient) =>
147 runSqliteGoogleOpenApiOwnershipMigration(client).pipe(Effect.asVoid),
148};
packages/plugins/google/src/sdk/plugin.ts+175−205
@@ -14,12 +14,11 @@ import { · 13 unmodified lines
14 type Integration,14 type Integration,
15 type IntegrationConfig,15 type IntegrationConfig,
16 type IntegrationRecord,16 type IntegrationRecord,
17 type StorageFailure,17 type PluginCtx,
18} from "@executor-js/sdk/core";18} from "@executor-js/sdk/core";
19import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";19import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
20import {20import {
21 compileOpenApiSpec,21 compileOpenApiSpec,
22 decodeOpenApiIntegrationConfig,
23 invokeOpenApiBackedTool,22 invokeOpenApiBackedTool,
24 makeDefaultOpenapiStore,23 makeDefaultOpenapiStore,
25 normalizeOpenApiAuthInputs,24 normalizeOpenApiAuthInputs,
@@ -28,11 +27,7 @@ import { · 2 unmodified lines
28 resolveOpenApiBackedTools,27 resolveOpenApiBackedTools,
29 type Authentication,28 type Authentication,
30 type AuthenticationInput,29 type AuthenticationInput,
31 type OpenApiIntegrationConfig,
32 type OpenapiStore,30 type OpenapiStore,
33 type OpenApiExtractionError,
34 type OpenApiOAuthError,
35 type OpenApiParseError,
36} from "@executor-js/plugin-openapi";31} from "@executor-js/plugin-openapi";
37 32
38import {33import {
@@ -40,6 +35,7 @@ import { · 1 unmodified line
40 fetchGoogleDiscoveryDocument,35 fetchGoogleDiscoveryDocument,
41 isGoogleDiscoveryUrl,36 isGoogleDiscoveryUrl,
42} from "./discovery";37} from "./discovery";
38import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
43import { googleOpenApiBundlePreset } from "./presets";39import { googleOpenApiBundlePreset } from "./presets";
44 40
45export interface GoogleBundleConfig {41export interface GoogleBundleConfig {
@@ -66,39 +62,6 @@ export interface GoogleUpdateResult { · 20 unmodified lines
66 readonly removedTools: readonly string[];62 readonly removedTools: readonly string[];
67}63}
68 64
69export interface GooglePluginExtension {
70 readonly addBundle: (
71 config: GoogleBundleConfig,
72 ) => Effect.Effect<
73 { readonly slug: IntegrationSlug; readonly toolCount: number },
74 | OpenApiParseError
75 | OpenApiExtractionError
76 | OpenApiOAuthError
77 | IntegrationAlreadyExistsError
78 | StorageFailure
79 >;
80 readonly updateBundle: (
81 slug: string,
82 input?: GoogleUpdateInput,
83 ) => Effect.Effect<
84 GoogleUpdateResult,
85 | OpenApiParseError
86 | OpenApiExtractionError
87 | OpenApiOAuthError
88 | IntegrationNotFoundError
89 | StorageFailure
90 >;
91 readonly removeBundle: (slug: string) => Effect.Effect<void, StorageFailure>;
92 readonly getIntegration: (slug: string) => Effect.Effect<Integration | null, StorageFailure>;
93 readonly getConfig: (
94 slug: string,
95 ) => Effect.Effect<OpenApiIntegrationConfig | null, StorageFailure>;
96 readonly configure: (
97 slug: string,
98 input: GoogleConfigureInput,
99 ) => Effect.Effect<readonly Authentication[], StorageFailure>;
100}
101
102export interface GooglePluginOptions {65export interface GooglePluginOptions {
103 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;66 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
104}67}
@@ -124,7 +87,7 @@ const uniqueUrls = (urls: readonly string[]): readonly string[] => [ · 19 unmodified lines
124];87];
125 88
126const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {89const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
127 const config = decodeOpenApiIntegrationConfig(record.config);90 const config = decodeGoogleIntegrationConfig(record.config);
128 if (!config) return [];91 if (!config) return [];
129 return (config.authenticationTemplate ?? []).map(92 return (config.authenticationTemplate ?? []).map(
130 (template: Authentication): AuthMethodDescriptor => {93 (template: Authentication): AuthMethodDescriptor => {
@@ -147,183 +110,190 @@ const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMeth · 16 unmodified lines
147};110};
148 111
149const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {112const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {
150 const config = decodeOpenApiIntegrationConfig(record.config);113 const config = decodeGoogleIntegrationConfig(record.config);
151 return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };114 return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };
152};115};
153 116
154export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({117const makeGooglePluginExtension = (
155 id: "google" as const,118 options: GooglePluginOptions | undefined,
156 packageName: "@executor-js/plugin-google",119 ctx: PluginCtx<OpenapiStore>,
157 integrationPresets: [googleOpenApiBundlePreset],120) => {
158 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),121 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
159
160 extension: (ctx): GooglePluginExtension => {
161 const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
162 122
163 const addBundle = (config: GoogleBundleConfig) =>123 const addBundle = (config: GoogleBundleConfig) =>
164 Effect.gen(function* () {124 Effect.gen(function* () {
165 const urls = uniqueUrls(config.urls);125 const urls = uniqueUrls(config.urls);
166 const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);126 const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
167 const compiled = yield* compileOpenApiSpec(conversion.specText);127 const compiled = yield* compileOpenApiSpec(conversion.specText);
168 const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG);128 const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG);
169 129
170 const existing = yield* ctx.core.integrations.get(slug);130 const existing = yield* ctx.core.integrations.get(slug);
171 if (existing) {131 if (existing) {
172 return yield* new IntegrationAlreadyExistsError({ slug });132 return yield* new IntegrationAlreadyExistsError({ slug });
173 }133 }
174
175 const specHash = yield* sha256Hex(conversion.specText);
176 const integrationConfig: OpenApiIntegrationConfig = {
177 specHash,
178 googleDiscoveryUrls: urls,
179 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
180 ...(conversion.authenticationTemplate
181 ? { authenticationTemplate: conversion.authenticationTemplate }
182 : {}),
183 };
184 134
185 yield* ctx.storage.putSpec(specHash, conversion.specText);135 const specHash = yield* sha256Hex(conversion.specText);
186 136 const integrationConfig: GoogleIntegrationConfig = {
187 yield* ctx.transaction(137 specHash,
188 Effect.gen(function* () {138 googleDiscoveryUrls: urls,
189 yield* ctx.core.integrations.register({139 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
190 slug,140 ...(conversion.authenticationTemplate
191 name: config.name?.trim() || "Google",141 ? { authenticationTemplate: conversion.authenticationTemplate }
192 description: config.description ?? "Google APIs",142 : {}),
193 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,143 };
194 canRemove: true,144
195 canRefresh: true,145 yield* ctx.storage.putSpec(specHash, conversion.specText);
196 });146
197 yield* ctx.storage.putOperations(147 yield* ctx.transaction(
198 String(slug),148 Effect.gen(function* () {
199 openApiStoredOperationsFromCompiled(String(slug), compiled),149 yield* ctx.core.integrations.register({
200 );150 slug,
201 }),151 name: config.name?.trim() || "Google",
202 );152 description: config.description ?? "Google APIs",
153 config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
154 canRemove: true,
155 canRefresh: true,
156 });
157 yield* ctx.storage.putOperations(
158 String(slug),
159 openApiStoredOperationsFromCompiled(String(slug), compiled),
160 );
161 }),
162 );
203 163
204 return { slug, toolCount: compiled.definitions.length };164 return { slug, toolCount: compiled.definitions.length };
205 });165 });
206 166
207 const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) =>167 const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) =>
208 Effect.gen(function* () {168 Effect.gen(function* () {
209 const slug = IntegrationSlug.make(rawSlug);169 const slug = IntegrationSlug.make(rawSlug);
210 const record = yield* ctx.core.integrations.get(slug);170 const record = yield* ctx.core.integrations.get(slug);
211 const current = record ? decodeOpenApiIntegrationConfig(record.config) : null;171 const current = record ? decodeGoogleIntegrationConfig(record.config) : null;
212 if (!record || !current) {172 if (!record || !current) {
213 return yield* new IntegrationNotFoundError({ slug });173 return yield* new IntegrationNotFoundError({ slug });
214 }174 }
215
216 const urls = uniqueUrls(input?.urls ?? current.googleDiscoveryUrls ?? []);
217 const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
218 const compiled = yield* compileOpenApiSpec(conversion.specText);
219
220 const previousOperations = yield* ctx.storage.listOperations(rawSlug);
221 const previousNames = new Set(previousOperations.map((op) => op.toolName));
222 const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
223
224 const specHash = yield* sha256Hex(conversion.specText);
225 yield* ctx.storage.putSpec(specHash, conversion.specText);
226
227 const nextConfig: OpenApiIntegrationConfig = {
228 ...current,
229 specHash,
230 googleDiscoveryUrls: urls,
231 };
232 175
233 yield* ctx.transaction(176 const urls = uniqueUrls(input?.urls ?? current.googleDiscoveryUrls ?? []);
234 Effect.gen(function* () {177 const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
235 yield* ctx.core.integrations.update(slug, {178 const compiled = yield* compileOpenApiSpec(conversion.specText);
236 config: nextConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,179
237 });180 const previousOperations = yield* ctx.storage.listOperations(rawSlug);
238 yield* ctx.storage.putOperations(181 const previousNames = new Set(previousOperations.map((op) => op.toolName));
239 rawSlug,182 const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
240 openApiStoredOperationsFromCompiled(rawSlug, compiled),183
241 );184 const specHash = yield* sha256Hex(conversion.specText);
242 }),185 yield* ctx.storage.putSpec(specHash, conversion.specText);
243 );186
244 187 const nextConfig: GoogleIntegrationConfig = {
245 const connections = yield* ctx.connections.list({ integration: slug });188 ...current,
246 yield* Effect.forEach(189 specHash,
247 connections,190 googleDiscoveryUrls: urls,
248 (connection) =>191 };
249 ctx.connections192
250 .refresh({193 yield* ctx.transaction(
251 owner: connection.owner,194 Effect.gen(function* () {
252 integration: connection.integration,195 yield* ctx.core.integrations.update(slug, {
253 name: connection.name,196 config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
254 })197 });
255 .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),198 yield* ctx.storage.putOperations(
256 { discard: true },199 rawSlug,
257 ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));200 openApiStoredOperationsFromCompiled(rawSlug, compiled),
201 );
202 }),
203 );
258 204
259 return {205 const connections = yield* ctx.connections.list({ integration: slug });
260 slug,206 yield* Effect.forEach(
261 toolCount: compiled.definitions.length,207 connections,
262 addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),208 (connection) =>
263 removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),209 ctx.connections
264 };210 .refresh({
265 });211 owner: connection.owner,
212 integration: connection.integration,
213 name: connection.name,
214 })
215 .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
216 { discard: true },
217 ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));
218
219 return {
220 slug,
221 toolCount: compiled.definitions.length,
222 addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
223 removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
224 };
225 });
266 226
267 return {227 return {
268 addBundle,228 addBundle,
269 updateBundle,229 updateBundle,
270 removeBundle: (slug: string) =>230 removeBundle: (slug: string) =>
271 ctx.transaction(231 ctx.transaction(
272 Effect.gen(function* () {232 Effect.gen(function* () {
273 yield* ctx.storage.removeOperations(slug);233 yield* ctx.storage.removeOperations(slug);
274 yield* ctx.core.integrations234 yield* ctx.core.integrations
275 .remove(IntegrationSlug.make(slug))235 .remove(IntegrationSlug.make(slug))
276 .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));236 .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
277 }),237 }),
278 ),238 ),
279 getIntegration: (slug: string) =>239 getIntegration: (slug: string) =>
280 ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(240 ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
281 Effect.map((record) =>241 Effect.map((record) =>
282 record242 record
283 ? ({243 ? ({
284 slug: record.slug,244 slug: record.slug,
285 description: record.description,245 description: record.description,
286 kind: record.kind,246 kind: record.kind,
287 canRemove: record.canRemove,247 canRemove: record.canRemove,
288 canRefresh: record.canRefresh,248 canRefresh: record.canRefresh,
289 } as Integration)249 } as Integration)
290 : null,250 : null,
291 ),
292 ),251 ),
293 getConfig: (slug: string) =>252 ),
294 ctx.core.integrations253 getConfig: (slug: string) =>
295 .get(IntegrationSlug.make(slug))254 ctx.core.integrations
296 .pipe(255 .get(IntegrationSlug.make(slug))
297 Effect.map((record) => (record ? decodeOpenApiIntegrationConfig(record.config) : null)),256 .pipe(
298 ),257 Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)),
299 configure: (slug: string, input: GoogleConfigureInput) =>
300 ctx.transaction(
301 Effect.gen(function* () {
302 const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
303 if (!record) return [] as readonly Authentication[];
304 const current = decodeOpenApiIntegrationConfig(record.config);
305 if (!current) return [] as readonly Authentication[];
306
307 const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
308 const merged =
309 input.mode === "replace"
310 ? incoming
311 : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
312
313 const next: OpenApiIntegrationConfig = {
314 ...current,
315 authenticationTemplate: merged,
316 };
317
318 yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
319 config: next satisfies OpenApiIntegrationConfig as IntegrationConfig,
320 });
321
322 return merged;
323 }),
324 ),258 ),
325 };259 configure: (slug: string, input: GoogleConfigureInput) =>
326 },260 ctx.transaction(
261 Effect.gen(function* () {
262 const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
263 if (!record) return [] as readonly Authentication[];
264 const current = decodeGoogleIntegrationConfig(record.config);
265 if (!current) return [] as readonly Authentication[];
266
267 const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
268 const merged =
269 input.mode === "replace"
270 ? incoming
271 : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
272
273 const next: GoogleIntegrationConfig = {
274 ...current,
275 authenticationTemplate: merged,
276 };
277
278 yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
279 config: next satisfies GoogleIntegrationConfig as IntegrationConfig,
280 });
281
282 return merged;
283 }),
284 ),
285 };
286};
287
288export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>;
289
290export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
291 id: "google" as const,
292 packageName: "@executor-js/plugin-google",
293 integrationPresets: [googleOpenApiBundlePreset],
294 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
295
296 extension: (ctx: PluginCtx<OpenapiStore>) => makeGooglePluginExtension(options, ctx),
327 297
328 describeAuthMethods: describeGoogleAuthMethods,298 describeAuthMethods: describeGoogleAuthMethods,
329 describeIntegrationDisplay: describeGoogleIntegrationDisplay,299 describeIntegrationDisplay: describeGoogleIntegrationDisplay,
packages/plugins/openapi/src/api/group.ts+0−9
@@ -48,14 +48,6 @@ const SlugParams = { · 47 unmodified lines
48const OpenApiSpecInputPayload = Schema.Union([48const OpenApiSpecInputPayload = Schema.Union([
49 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),49 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
50 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),50 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
51 Schema.Struct({
52 kind: Schema.Literal("googleDiscovery"),
53 url: Schema.String,
54 }),
55 Schema.Struct({
56 kind: Schema.Literal("googleDiscoveryBundle"),
57 urls: Schema.Array(Schema.String),
58 }),
59]);51]);
60 52
61const OAuthTemplatePayload = Schema.Struct({53const OAuthTemplatePayload = Schema.Struct({
@@ -134,7 +126,6 @@ const IntegrationView = Schema.Struct({ · 72 unmodified lines
134// store, and no client reads it (the configure UI only touches the template).126// store, and no client reads it (the configure UI only touches the template).
135const OpenApiConfigView = Schema.Struct({127const OpenApiConfigView = Schema.Struct({
136 sourceUrl: Schema.optional(Schema.String),128 sourceUrl: Schema.optional(Schema.String),
137 googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
138 baseUrl: Schema.optional(Schema.String),129 baseUrl: Schema.optional(Schema.String),
139 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),130 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
140 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),131 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
packages/plugins/openapi/src/api/handlers.ts+0−3
@@ -91,9 +91,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 90 unmodified lines
91 return config91 return config
92 ? {92 ? {
93 sourceUrl: config.sourceUrl,93 sourceUrl: config.sourceUrl,
94 googleDiscoveryUrls: config.googleDiscoveryUrls
95 ? [...config.googleDiscoveryUrls]
96 : undefined,
97 baseUrl: config.baseUrl,94 baseUrl: config.baseUrl,
98 headers: config.headers ? { ...config.headers } : undefined,95 headers: config.headers ? { ...config.headers } : undefined,
99 queryParams: config.queryParams ? { ...config.queryParams } : undefined,96 queryParams: config.queryParams ? { ...config.queryParams } : undefined,
packages/plugins/openapi/src/react/UpdateSpecSection.tsx+1−7
@@ -52,13 +52,7 @@ export default function UpdateSpecSection(props: EditSheetSectionProps) { · 51 unmodified lines
52 const config =52 const config =
53 AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null;53 AsyncResult.isSuccess(configResult) && configResult.value ? configResult.value : null;
54 54
55 const sourceUrl = config55 const sourceUrl = config?.sourceUrl;
56 ? config.googleDiscoveryUrls?.length
57 ? `${config.googleDiscoveryUrls.length} Google Discovery document${
58 config.googleDiscoveryUrls.length !== 1 ? "s" : ""
59 }`
60 : config.sourceUrl
61 : undefined;
62 56
63 // The staged apply, rebuilt whenever the staged inputs change. Reported to57 // The staged apply, rebuilt whenever the staged inputs change. Reported to
64 // the sheet through a ref-stable callback so Save can run it.58 // the sheet through a ref-stable callback so Save can run it.
packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap+0−82
@@ -1,82 +0,0 @@
1// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
3exports[`classifies every Google service for bundle OAuth UX 1`] = `
4[
5 {
6 "id": "google-calendar",
7 "oauthAudience": "standard-user",
8 },
9 {
10 "id": "google-gmail",
11 "oauthAudience": "standard-user",
12 },
13 {
14 "id": "google-sheets",
15 "oauthAudience": "standard-user",
16 },
17 {
18 "id": "google-drive",
19 "oauthAudience": "standard-user",
20 },
21 {
22 "id": "google-docs",
23 "oauthAudience": "standard-user",
24 },
25 {
26 "id": "google-slides",
27 "oauthAudience": "standard-user",
28 },
29 {
30 "id": "google-forms",
31 "oauthAudience": "standard-user",
32 },
33 {
34 "id": "google-tasks",
35 "oauthAudience": "standard-user",
36 },
37 {
38 "id": "google-people",
39 "oauthAudience": "standard-user",
40 },
41 {
42 "id": "google-chat",
43 "oauthAudience": "workspace-admin",
44 },
45 {
46 "id": "google-keep",
47 "oauthAudience": "unsupported-user",
48 },
49 {
50 "id": "google-youtube-data",
51 "oauthAudience": "advanced-user",
52 },
53 {
54 "id": "google-search-console",
55 "oauthAudience": "standard-user",
56 },
57 {
58 "id": "google-classroom",
59 "oauthAudience": "advanced-user",
60 },
61 {
62 "id": "google-admin-directory",
63 "oauthAudience": "workspace-admin",
64 },
65 {
66 "id": "google-admin-reports",
67 "oauthAudience": "workspace-admin",
68 },
69 {
70 "id": "google-apps-script",
71 "oauthAudience": "advanced-user",
72 },
73 {
74 "id": "google-bigquery",
75 "oauthAudience": "advanced-user",
76 },
77 {
78 "id": "google-cloud-resource-manager",
79 "oauthAudience": "advanced-user",
80 },
81]
82`;
packages/plugins/openapi/src/sdk/config.ts+0−2
@@ -43,8 +43,6 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 42 unmodified lines
43 specHash: Schema.optional(Schema.String),43 specHash: Schema.optional(Schema.String),
44 /** Origin URL the spec was fetched from, when known. Enables refresh. */44 /** Origin URL the spec was fetched from, when known. Enables refresh. */
45 sourceUrl: Schema.optional(Schema.String),45 sourceUrl: Schema.optional(Schema.String),
46 /** Google Discovery bundle URLs, when the spec came from a Google bundle. */
47 googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
48 /** Optional base URL override. */46 /** Optional base URL override. */
49 baseUrl: Schema.optional(Schema.String),47 baseUrl: Schema.optional(Schema.String),
50 /** Static headers applied to every request (no secret material). */48 /** Static headers applied to every request (no secret material). */
packages/plugins/openapi/src/sdk/configure.test.ts+0−1
@@ -68,7 +68,6 @@ const servePluginTestApi = () => · 67 unmodified lines
68const specText = () => {68const specText = () => {
69 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;69 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;
70 if (spec.kind === "blob") return spec.value;70 if (spec.kind === "blob") return spec.value;
71 if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? "";
72 return spec.url;71 return spec.url;
73};72};
74 73
packages/plugins/openapi/src/sdk/google-bundle.test.ts+0−270
@@ -1,270 +0,0 @@
1// ---------------------------------------------------------------------------
2// Google bundle add flow — Option A ("customize your Google connection").
3//
4// The product picker emits `{ kind: "googleDiscoveryBundle", urls }`; the
5// server fetches each Discovery document, merges them into ONE `google`
6// integration spec, and stores the unioned `googleOAuth2` auth template. These
7// tests exercise that path end-to-end against a stubbed Discovery host:
8// - a 3-API bundle (calendar + gmail + drive) produces a single `google`
9// integration whose merged tools carry NO name collisions (each method id
10// is service-prefixed) even when two APIs share a generic method name;
11// - the stored oauth template carries the UNION of every API's scopes;
12// - the per-API single-preset path (`kind: "googleDiscovery"`) is unchanged.
13// ---------------------------------------------------------------------------
14
15import { describe, expect, it } from "@effect/vitest";
16import { Effect, Layer } from "effect";
17import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
18
19import {
20 ConnectionName,
21 IntegrationSlug,
22 createExecutor,
23 AuthTemplateSlug,
24} from "@executor-js/sdk";
25import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
26
27import { openApiPlugin } from "./plugin";
28
29// --- Canned Discovery documents -------------------------------------------
30// Each carries one method. Calendar and Gmail BOTH expose a generic `list`
31// method id segment, so a naive merge that keyed tools on the trailing method
32// name would collide. The bundle converter keys on the full method id
33// (`calendar.events.list`, `gmail.users.messages.list`, …), so they don't.
34
35const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest";
36const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
37const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
38
39const calendarDoc = {
40 name: "calendar",
41 version: "v3",
42 title: "Calendar API",
43 rootUrl: "https://www.googleapis.com/",
44 servicePath: "calendar/v3/",
45 auth: {
46 oauth2: {
47 scopes: {
48 "https://www.googleapis.com/auth/calendar": { description: "Manage calendars" },
49 "https://www.googleapis.com/auth/calendar.readonly": { description: "Read calendars" },
50 },
51 },
52 },
53 resources: {
54 events: {
55 methods: {
56 list: {
57 id: "calendar.events.list",
58 httpMethod: "GET",
59 path: "calendars/{calendarId}/events",
60 scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
61 parameters: {
62 calendarId: { location: "path", required: true, type: "string" },
63 },
64 },
65 },
66 },
67 },
68 schemas: {
69 Event: { id: "Event", type: "object", properties: { id: { type: "string" } } },
70 },
71};
72
73const gmailDoc = {
74 name: "gmail",
75 version: "v1",
76 title: "Gmail API",
77 rootUrl: "https://gmail.googleapis.com/",
78 servicePath: "",
79 auth: {
80 oauth2: {
81 scopes: {
82 "https://mail.google.com/": { description: "Full Gmail access" },
83 "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" },
84 },
85 },
86 },
87 resources: {
88 users: {
89 resources: {
90 messages: {
91 methods: {
92 // Same trailing `list` as calendar.events.list — would collide on a
93 // naive merge; service-prefixed method id keeps them distinct.
94 list: {
95 id: "gmail.users.messages.list",
96 httpMethod: "GET",
97 path: "gmail/v1/users/{userId}/messages",
98 scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
99 parameters: {
100 userId: { location: "path", required: true, type: "string" },
101 },
102 },
103 },
104 },
105 },
106 },
107 },
108 schemas: {
109 Message: { id: "Message", type: "object", properties: { id: { type: "string" } } },
110 },
111};
112
113const driveDoc = {
114 name: "drive",
115 version: "v3",
116 title: "Drive API",
117 rootUrl: "https://www.googleapis.com/",
118 servicePath: "drive/v3/",
119 auth: {
120 oauth2: {
121 scopes: {
122 "https://www.googleapis.com/auth/drive": { description: "Manage Drive" },
123 },
124 },
125 },
126 resources: {
127 files: {
128 methods: {
129 // A third `list` — three generic method names across three APIs.
130 list: {
131 id: "drive.files.list",
132 httpMethod: "GET",
133 path: "files",
134 scopes: ["https://www.googleapis.com/auth/drive"],
135 parameters: {},
136 },
137 },
138 },
139 },
140 schemas: {
141 File: { id: "File", type: "object", properties: { id: { type: "string" } } },
142 },
143};
144
145const toJson = (value: unknown): string => JSON.stringify(value);
146
147const DISCOVERY_BODIES: Readonly<Record<string, string>> = {
148 [CALENDAR_URL]: toJson(calendarDoc),
149 [GMAIL_URL]: toJson(gmailDoc),
150 [DRIVE_URL]: toJson(driveDoc),
151};
152
153// A stub HTTP client that serves the canned Discovery document for whichever
154// URL the bundle converter fetches (query params are ignored when matching).
155const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
156 HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
157 const url = new URL(request.url);
158 const key = `${url.origin}${url.pathname}`;
159 const body = DISCOVERY_BODIES[key];
160 return Effect.succeed(
161 HttpClientResponse.fromWeb(
162 request,
163 body === undefined
164 ? new Response("not found", { status: 404 })
165 : new Response(body, {
166 status: 200,
167 headers: { "content-type": "application/json" },
168 }),
169 ),
170 );
171 }),
172);
173
174const bundlePlugins = () =>
175 [
176 openApiPlugin({ httpClientLayer: discoveryHttpClientLayer }),
177 memoryCredentialsPlugin(),
178 ] as const;
179
180describe("Google bundle add flow", () => {
181 it.effect(
182 "addSpec(googleDiscoveryBundle) merges calendar+gmail+drive into one google integration with no tool-name collisions",
183 () =>
184 Effect.scoped(
185 Effect.gen(function* () {
186 const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
187
188 const result = yield* executor.openapi.addSpec({
189 spec: {
190 kind: "googleDiscoveryBundle",
191 urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL],
192 },
193 slug: "google",
194 description: "Google",
195 });
196 expect(String(result.slug)).toBe("google");
197
198 // ONE integration, not three.
199 const integration = yield* executor.openapi.getIntegration("google");
200 expect(integration?.slug).toBe(IntegrationSlug.make("google"));
201
202 // The stored oauth template carries the COMPACTED union of every API's
203 // scopes — the same set the picker previews and `oauth.start` requests.
204 // `calendar.readonly` collapses under `calendar`, and `gmail.readonly`
205 // collapses under `https://mail.google.com/`, so the requested consent
206 // is clean rather than the raw per-method union.
207 const config = yield* executor.openapi.getConfig("google");
208 const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
209 expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
210 [
211 "https://mail.google.com/",
212 "https://www.googleapis.com/auth/calendar",
213 "https://www.googleapis.com/auth/drive",
214 ].sort(),
215 );
216
217 // A connection stamps the merged tools; assert all three `list`s are
218 // present under distinct service-prefixed names (no collision).
219 yield* executor.connections.create({
220 owner: "org",
221 name: ConnectionName.make("main"),
222 integration: IntegrationSlug.make("google"),
223 template: AuthTemplateSlug.make("googleOAuth2"),
224 value: "token-xyz",
225 });
226
227 const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
228 expect(toolNames).toContain("calendar.events.list");
229 expect(toolNames).toContain("gmail.users.messages.list");
230 expect(toolNames).toContain("drive.files.list");
231
232 // No duplicate tool names across the merged surface.
233 const googleTools = toolNames.filter((name) => name.endsWith(".list"));
234 expect(new Set(googleTools).size).toBe(googleTools.length);
235 expect(googleTools.length).toBe(3);
236 }),
237 ),
238 );
239
240 it.effect(
241 "single-API googleDiscovery path is unchanged (one integration, service-trimmed tools)",
242 () =>
243 Effect.scoped(
244 Effect.gen(function* () {
245 const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
246
247 const result = yield* executor.openapi.addSpec({
248 spec: { kind: "googleDiscovery", url: CALENDAR_URL },
249 slug: "google-calendar",
250 description: "Google Calendar",
251 });
252 expect(String(result.slug)).toBe("google-calendar");
253
254 yield* executor.connections.create({
255 owner: "org",
256 name: ConnectionName.make("main"),
257 integration: IntegrationSlug.make("google-calendar"),
258 template: AuthTemplateSlug.make("googleOAuth2"),
259 value: "token-xyz",
260 });
261
262 // The single-API path trims the service prefix off the tool name
263 // (`events.list`, not `calendar.events.list`) — distinct from the bundle.
264 const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
265 expect(toolNames).toContain("events.list");
266 expect(toolNames).not.toContain("calendar.events.list");
267 }),
268 ),
269 );
270});
packages/plugins/openapi/src/sdk/google-discovery.test.ts+0−604
@@ -1,604 +0,0 @@
1import { expect, it } from "@effect/vitest";
2import { Effect, Option, Schema } from "effect";
3import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
4
5import {
6 convertGoogleDiscoveryBundleToOpenApi,
7 convertGoogleDiscoveryToOpenApi,
8} from "./google-discovery";
9import { extract } from "./extract";
10import { parse } from "./parse";
11
12const ConvertedOperation = Schema.Struct({
13 operationId: Schema.String,
14 "x-executor-toolPath": Schema.String,
15 parameters: Schema.Array(
16 Schema.Struct({
17 name: Schema.String,
18 in: Schema.String,
19 required: Schema.Boolean,
20 description: Schema.optional(Schema.String),
21 schema: Schema.Unknown,
22 style: Schema.optional(Schema.String),
23 explode: Schema.optional(Schema.Boolean),
24 allowReserved: Schema.optional(Schema.Boolean),
25 }),
26 ),
27 servers: Schema.optional(Schema.Array(Schema.Struct({ url: Schema.String }))),
28 security: Schema.optional(
29 Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))),
30 ),
31 "x-executor-pathTemplate": Schema.optional(Schema.String),
32 requestBody: Schema.optional(Schema.Unknown),
33 responses: Schema.Unknown,
34 "x-google-scopes": Schema.Array(Schema.String),
35});
36
37const ConvertedSpec = Schema.Struct({
38 openapi: Schema.String,
39 servers: Schema.Array(Schema.Struct({ url: Schema.String })),
40 paths: Schema.Record(Schema.String, Schema.Record(Schema.String, ConvertedOperation)),
41 components: Schema.Struct({
42 schemas: Schema.Record(Schema.String, Schema.Unknown),
43 }),
44});
45
46const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec));
47
48const normalizeOpenApiRefsForPreview = (node: unknown): unknown => {
49 if (node == null || typeof node !== "object") return node;
50 if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview);
51 const obj = node as Record<string, unknown>;
52 if (typeof obj.$ref === "string") {
53 const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/);
54 return match ? { ...obj, $ref: `#/$defs/${match[1]}` } : obj;
55 }
56 return Object.fromEntries(
57 Object.entries(obj).map(([key, value]) => [key, normalizeOpenApiRefsForPreview(value)]),
58 );
59};
60
61it.effect("converts Google Discovery documents into Executor-preserving OpenAPI 3 specs", () =>
62 Effect.gen(function* () {
63 const result = yield* convertGoogleDiscoveryToOpenApi({
64 discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
65 // @effect-diagnostics-next-line preferSchemaOverJson:off
66 documentText: JSON.stringify({
67 name: "gmail",
68 version: "v1",
69 title: "Gmail API",
70 rootUrl: "https://gmail.googleapis.com/",
71 servicePath: "",
72 auth: {
73 oauth2: {
74 scopes: {
75 "https://www.googleapis.com/auth/gmail.metadata": {
76 description: "Read metadata",
77 },
78 },
79 },
80 },
81 resources: {
82 users: {
83 resources: {
84 messages: {
85 methods: {
86 list: {
87 id: "gmail.users.messages.list",
88 httpMethod: "GET",
89 path: "gmail/v1/users/{userId}/messages",
90 scopes: ["https://www.googleapis.com/auth/gmail.metadata"],
91 parameters: {
92 userId: {
93 location: "path",
94 required: true,
95 type: "string",
96 description: "The user's email address. The special value me can be used.",
97 },
98 metadataHeaders: {
99 location: "query",
100 repeated: true,
101 type: "string",
102 },
103 },
104 },
105 },
106 },
107 drafts: {
108 methods: {
109 create: {
110 id: "gmail.users.drafts.create",
111 httpMethod: "POST",
112 path: "gmail/v1/users/{userId}/drafts",
113 request: { $ref: "Draft" },
114 response: { $ref: "Draft" },
115 scopes: ["https://www.googleapis.com/auth/gmail.metadata"],
116 parameters: {
117 userId: {
118 location: "path",
119 required: true,
120 type: "string",
121 },
122 },
123 },
124 },
125 },
126 },
127 },
128 },
129 schemas: {
130 Draft: {
131 id: "Draft",
132 type: "object",
133 description: "A draft email.",
134 properties: {
135 id: {
136 type: "string",
137 description: "The immutable ID of the draft.",
138 },
139 message: {
140 $ref: "Message",
141 },
142 },
143 },
144 Message: {
145 id: "Message",
146 type: "object",
147 properties: {
148 id: {
149 type: "string",
150 },
151 labelIds: {
152 type: "array",
153 items: { type: "string" },
154 },
155 },
156 },
157 },
158 }),
159 });
160
161 const spec = decodeConvertedSpec(result.specText);
162 const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get;
163 const createDraft = spec.paths["/gmail/v1/users/{userId}/drafts"]?.post;
164 expect(spec.openapi).toBe("3.1.0");
165 expect(spec.servers).toEqual([{ url: "https://gmail.googleapis.com/" }]);
166 expect(result.specText).not.toContain("_tag");
167 expect(operation).toMatchObject({
168 operationId: "users.messages.list",
169 "x-executor-toolPath": "users.messages.list",
170 "x-google-scopes": ["https://www.googleapis.com/auth/gmail.metadata"],
171 });
172 expect(operation?.security).toEqual([
173 { googleOAuth2: ["https://www.googleapis.com/auth/gmail.metadata"] },
174 ]);
175 expect(operation?.parameters).toContainEqual(
176 expect.objectContaining({
177 name: "metadataHeaders",
178 in: "query",
179 style: "form",
180 explode: true,
181 }),
182 );
183 expect(operation?.parameters).toContainEqual(
184 expect.objectContaining({
185 name: "userId",
186 description: "The user's email address. The special value me can be used.",
187 schema: expect.objectContaining({ type: "string" }),
188 }),
189 );
190 expect(createDraft).toMatchObject({
191 operationId: "users.drafts.create",
192 "x-executor-toolPath": "users.drafts.create",
193 });
194 expect(createDraft).toMatchObject({
195 requestBody: {
196 content: {
197 "application/json": {
198 schema: { $ref: "#/components/schemas/Draft" },
199 },
200 },
201 },
202 });
203 expect(createDraft?.parameters).toContainEqual(
204 expect.objectContaining({
205 name: "userId",
206 schema: expect.objectContaining({ type: "string" }),
207 }),
208 );
209
210 const parsed = yield* parse(result.specText);
211 const extracted = yield* extract(parsed);
212 const extractedDraftCreate = extracted.operations.find(
213 (candidate) => candidate.operationId === "users.drafts.create",
214 );
215 expect(extractedDraftCreate?.operationId).toBe("users.drafts.create");
216 const preview = yield* Effect.promise(() =>
217 buildToolTypeScriptPreview({
218 inputSchema: normalizeOpenApiRefsForPreview(
219 extractedDraftCreate
220 ? Option.getOrUndefined(extractedDraftCreate.inputSchema)
221 : undefined,
222 ),
223 outputSchema: normalizeOpenApiRefsForPreview(
224 extractedDraftCreate
225 ? Option.getOrUndefined(extractedDraftCreate.outputSchema)
226 : undefined,
227 ),
228 defs: new Map(
229 Object.entries(spec.components.schemas).map(([name, schema]) => [
230 name,
231 normalizeOpenApiRefsForPreview(schema),
232 ]),
233 ),
234 }),
235 );
236 expect(preview.inputTypeScript).toBe("{ userId: string; body?: Draft; }");
237 expect(preview.outputTypeScript).toBe("Draft");
238 expect(preview.typeScriptDefinitions).toMatchObject({
239 Draft: "{ id?: string; message?: Message; }",
240 Message: "{ id?: string; labelIds?: string[]; }",
241 });
242 // v2: the conversion now exposes a v2 oauth `authenticationTemplate` rather
243 // than v1's `oauth2` source config.
244 // removed: identityScopes assertion — that field belonged to the v1
245 // OAuth2SourceConfig slot model, which no longer exists in v2.
246 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
247 expect(oauthTemplate).toBeDefined();
248 }),
249);
250
251it.effect("bundles Google Discovery documents into one Google OpenAPI source", () =>
252 Effect.gen(function* () {
253 const result = yield* convertGoogleDiscoveryBundleToOpenApi({
254 documents: [
255 {
256 discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
257 // @effect-diagnostics-next-line preferSchemaOverJson:off
258 documentText: JSON.stringify({
259 name: "gmail",
260 version: "v1",
261 title: "Gmail API",
262 rootUrl: "https://gmail.googleapis.com/",
263 servicePath: "",
264 auth: {
265 oauth2: {
266 scopes: {
267 "https://www.googleapis.com/auth/gmail.metadata": {
268 description: "Read metadata",
269 },
270 },
271 },
272 },
273 resources: {
274 users: {
275 resources: {
276 messages: {
277 methods: {
278 list: {
279 id: "gmail.users.messages.list",
280 httpMethod: "GET",
281 path: "gmail/v1/users/{userId}/messages",
282 response: { $ref: "Message" },
283 scopes: ["https://www.googleapis.com/auth/gmail.metadata"],
284 parameters: {
285 userId: {
286 location: "path",
287 required: true,
288 type: "string",
289 },
290 },
291 },
292 },
293 },
294 },
295 },
296 },
297 schemas: {
298 Message: {
299 type: "object",
300 properties: {
301 id: { type: "string" },
302 },
303 },
304 },
305 }),
306 },
307 {
308 discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
309 // @effect-diagnostics-next-line preferSchemaOverJson:off
310 documentText: JSON.stringify({
311 name: "chat",
312 version: "v1",
313 title: "Google Chat API",
314 rootUrl: "https://chat.googleapis.com/",
315 servicePath: "",
316 auth: {
317 oauth2: {
318 scopes: {
319 "https://www.googleapis.com/auth/chat.spaces.readonly": {
320 description: "Read spaces",
321 },
322 },
323 },
324 },
325 resources: {
326 spaces: {
327 methods: {
328 get: {
329 id: "chat.spaces.get",
330 httpMethod: "GET",
331 path: "v1/{+name}",
332 response: { $ref: "Space" },
333 scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"],
334 parameters: {
335 name: {
336 location: "path",
337 required: true,
338 type: "string",
339 },
340 },
341 },
342 },
343 resources: {
344 messages: {
345 methods: {
346 get: {
347 id: "chat.spaces.messages.get",
348 httpMethod: "GET",
349 path: "v1/{+name}",
350 response: { $ref: "Message" },
351 scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"],
352 parameters: {
353 name: {
354 location: "path",
355 required: true,
356 type: "string",
357 },
358 },
359 },
360 },
361 },
362 },
363 },
364 },
365 schemas: {
366 Space: {
367 type: "object",
368 properties: {
369 name: { type: "string" },
370 },
371 },
372 Message: {
373 type: "object",
374 properties: {
375 name: { type: "string" },
376 },
377 },
378 },
379 }),
380 },
381 ],
382 });
383
384 const spec = decodeConvertedSpec(result.specText);
385 expect(result.title).toBe("Google");
386 expect(result.baseUrl).toBe("https://www.googleapis.com/");
387 expect(result.discoveryUrls).toEqual([
388 "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
389 "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest",
390 ]);
391 expect(spec.servers).toEqual([{ url: "https://www.googleapis.com/" }]);
392 expect(spec.components.schemas).toHaveProperty("gmail_v1_Message");
393 expect(spec.components.schemas).toHaveProperty("chat_v1_Message");
394
395 const gmailList = spec.paths["/gmail/v1/users/{userId}/messages"]?.get;
396 expect(gmailList).toMatchObject({
397 operationId: "gmail.users.messages.list",
398 "x-executor-toolPath": "gmail.users.messages.list",
399 servers: [{ url: "https://gmail.googleapis.com/" }],
400 });
401 expect(gmailList?.responses).toMatchObject({
402 "200": {
403 content: {
404 "application/json": {
405 schema: { $ref: "#/components/schemas/gmail_v1_Message" },
406 },
407 },
408 },
409 });
410
411 const chatSpaceGet = spec.paths["/v1/{name}"]?.get;
412 const chatMessageGet = spec.paths["/chat.spaces.messages.get"]?.get;
413 expect(chatSpaceGet).toMatchObject({
414 operationId: "chat.spaces.get",
415 "x-executor-pathTemplate": "/v1/{+name}",
416 servers: [{ url: "https://chat.googleapis.com/" }],
417 });
418 expect(chatMessageGet).toMatchObject({
419 operationId: "chat.spaces.messages.get",
420 "x-executor-pathTemplate": "/v1/{+name}",
421 servers: [{ url: "https://chat.googleapis.com/" }],
422 });
423 expect(chatSpaceGet?.parameters).toContainEqual(
424 expect.objectContaining({
425 name: "name",
426 in: "path",
427 allowReserved: true,
428 }),
429 );
430
431 const parsed = yield* parse(result.specText);
432 const extracted = yield* extract(parsed);
433 const extractedGmail = extracted.operations.find(
434 (candidate) => candidate.operationId === "gmail.users.messages.list",
435 );
436 const extractedChatMessage = extracted.operations.find(
437 (candidate) => candidate.operationId === "chat.spaces.messages.get",
438 );
439 expect(extractedGmail?.servers[0]?.url).toBe("https://gmail.googleapis.com/");
440 expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}");
441 expect(extractedChatMessage?.servers[0]?.url).toBe("https://chat.googleapis.com/");
442 // v2: the bundled oauth scopes are carried on the oauth auth template.
443 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
444 expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
445 "https://www.googleapis.com/auth/gmail.metadata",
446 "https://www.googleapis.com/auth/chat.spaces.readonly",
447 ]);
448 }),
449);
450
451// ---------------------------------------------------------------------------
452// The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes
453// collapse under their broad parent (`gmail.*` → `mail.google.com/`,
454// `calendar.*` → `calendar`, `userinfo.email` → `email`), and scopes a user
455// OAuth consent screen can't show (`chat.bot`, `chat.app.*`, `keep`) are
456// dropped. The persisted auth template, the spec `securitySchemes.googleOAuth2`
457// flow scopes, and the root `security` entry all agree — so the preview the
458// picker shows and the set `oauth.start` requests at connect are the same.
459// Per-operation `x-google-scopes`/`security` stay RAW (they describe per-method
460// scope needs, not consent).
461// ---------------------------------------------------------------------------
462
463const ConvertedSpecSecurity = Schema.Struct({
464 components: Schema.Struct({
465 securitySchemes: Schema.Record(
466 Schema.String,
467 Schema.Struct({
468 type: Schema.String,
469 flows: Schema.Struct({
470 authorizationCode: Schema.Struct({
471 scopes: Schema.Record(Schema.String, Schema.String),
472 }),
473 }),
474 }),
475 ),
476 }),
477 security: Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))),
478 paths: Schema.Record(
479 Schema.String,
480 Schema.Record(Schema.String, Schema.Struct({ "x-google-scopes": Schema.Array(Schema.String) })),
481 ),
482});
483const decodeConvertedSpecSecurity = Schema.decodeUnknownSync(
484 Schema.fromJsonString(ConvertedSpecSecurity),
485);
486
487it.effect("compacts and filters the merged bundle scope set into a clean consent set", () =>
488 Effect.gen(function* () {
489 const result = yield* convertGoogleDiscoveryBundleToOpenApi({
490 documents: [
491 {
492 discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
493 // @effect-diagnostics-next-line preferSchemaOverJson:off
494 documentText: JSON.stringify({
495 name: "gmail",
496 version: "v1",
497 title: "Gmail API",
498 rootUrl: "https://gmail.googleapis.com/",
499 servicePath: "",
500 auth: {
501 oauth2: {
502 scopes: {
503 // Broad parent + a sub-scope that must collapse under it.
504 "https://mail.google.com/": { description: "Full Gmail access" },
505 "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" },
506 // Identity scope normalized to `email`.
507 "https://www.googleapis.com/auth/userinfo.email": { description: "Email" },
508 },
509 },
510 },
511 resources: {
512 users: {
513 resources: {
514 messages: {
515 methods: {
516 list: {
517 id: "gmail.users.messages.list",
518 httpMethod: "GET",
519 path: "gmail/v1/users/{userId}/messages",
520 scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
521 parameters: {
522 userId: { location: "path", required: true, type: "string" },
523 },
524 },
525 },
526 },
527 },
528 },
529 },
530 schemas: {},
531 }),
532 },
533 {
534 discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
535 // @effect-diagnostics-next-line preferSchemaOverJson:off
536 documentText: JSON.stringify({
537 name: "chat",
538 version: "v1",
539 title: "Google Chat API",
540 rootUrl: "https://chat.googleapis.com/",
541 servicePath: "",
542 auth: {
543 oauth2: {
544 scopes: {
545 // A keepable consent scope plus two that the user-consent filter
546 // must drop (`chat.bot`, `chat.app.*`).
547 "https://www.googleapis.com/auth/chat.spaces.readonly": { description: "Spaces" },
548 "https://www.googleapis.com/auth/chat.bot": { description: "Bot" },
549 "https://www.googleapis.com/auth/chat.app.spaces": { description: "App spaces" },
550 },
551 },
552 },
553 resources: {
554 spaces: {
555 methods: {
556 get: {
557 id: "chat.spaces.get",
558 httpMethod: "GET",
559 path: "v1/{+name}",
560 scopes: ["https://www.googleapis.com/auth/chat.bot"],
561 parameters: {
562 name: { location: "path", required: true, type: "string" },
563 },
564 },
565 },
566 },
567 },
568 schemas: {},
569 }),
570 },
571 ],
572 });
573
574 const expectedConsentScopes = [
575 "https://mail.google.com/",
576 "email",
577 "https://www.googleapis.com/auth/chat.spaces.readonly",
578 ];
579
580 // The derived oauth auth template carries the compacted/filtered set
581 // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped).
582 const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
583 expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual(
584 [...expectedConsentScopes].sort(),
585 );
586
587 const spec = decodeConvertedSpecSecurity(result.specText);
588 // The spec's securitySchemes flow scopes match the consent set exactly.
589 expect(
590 Object.keys(spec.components.securitySchemes["googleOAuth2"]!.flows.authorizationCode.scopes)
591 .slice()
592 .sort(),
593 ).toEqual([...expectedConsentScopes].sort());
594 // The root security entry references the same compacted set.
595 expect([...(spec.security[0]?.["googleOAuth2"] ?? [])].sort()).toEqual(
596 [...expectedConsentScopes].sort(),
597 );
598 // Per-operation x-google-scopes stay RAW — a dropped consent scope can still
599 // be the scope a given method advertises.
600 expect(spec.paths["/v1/{name}"]?.get?.["x-google-scopes"]).toEqual([
601 "https://www.googleapis.com/auth/chat.bot",
602 ]);
603 }),
604);
packages/plugins/openapi/src/sdk/google-discovery.ts+0−854
@@ -1,854 +0,0 @@
1// Converts Google Discovery documents directly into OpenAPI 3.x. Public
2// Discovery converters currently target Swagger 2.0 or a broad conversion
3// pipeline; this adapter emits the shape Executor parses while preserving
4// Executor-specific tool ids and query semantics.
5import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect";
6import { HttpClient, HttpClientRequest } from "effect/unstable/http";
7
8import { OpenApiParseError } from "./errors";
9import { compactGoogleOAuthScopes } from "./google-oauth-scopes";
10import type { SpecFetchCredentials } from "./parse";
11import type { Authentication } from "./types";
12import { AuthTemplateSlug } from "@executor-js/sdk/shared";
13
14const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis";
15const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
16const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
17const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
18const OPENAPI_SCHEMA_TYPES = new Set([
19 "array",
20 "boolean",
21 "integer",
22 "null",
23 "number",
24 "object",
25 "string",
26]);
27
28type JsonPrimitive = string | number | boolean | null;
29type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
30
31type OpenApiSchemaObject = {
32 readonly $ref?: string;
33 readonly type?: "array" | "boolean" | "integer" | "null" | "number" | "object" | "string";
34 readonly description?: string;
35 readonly title?: string;
36 readonly format?: string;
37 readonly readOnly?: boolean;
38 readonly default?: JsonValue;
39 readonly enum?: readonly JsonValue[];
40 readonly items?: OpenApiSchemaObject;
41 readonly properties?: Record<string, OpenApiSchemaObject>;
42 readonly required?: readonly string[];
43 readonly additionalProperties?: boolean | OpenApiSchemaObject;
44};
45
46type OpenApiParameterObject = {
47 readonly name: string;
48 readonly in: "path" | "query" | "header";
49 readonly required: boolean;
50 readonly description?: string;
51 readonly schema: OpenApiSchemaObject;
52 readonly style?: "form";
53 readonly explode?: boolean;
54 readonly allowReserved?: boolean;
55};
56
57type OpenApiOperationObject = {
58 readonly operationId: string;
59 readonly "x-executor-toolPath": string;
60 readonly "x-executor-pathTemplate"?: string;
61 readonly tags?: readonly string[];
62 readonly description?: string;
63 readonly servers?: readonly { readonly url: string }[];
64 readonly parameters: readonly OpenApiParameterObject[];
65 readonly requestBody?: {
66 readonly required: false;
67 readonly content: {
68 readonly "application/json": {
69 readonly schema: OpenApiSchemaObject;
70 };
71 };
72 };
73 readonly responses: {
74 readonly "200": {
75 readonly description: "Successful response";
76 readonly content: {
77 readonly "application/json": {
78 readonly schema: OpenApiSchemaObject;
79 };
80 };
81 };
82 };
83 readonly security?: readonly Record<string, readonly string[]>[];
84 readonly "x-google-scopes": readonly string[];
85};
86
87type OpenApiDocument = {
88 readonly openapi: "3.1.0";
89 readonly info: {
90 readonly title: string;
91 readonly version: string;
92 };
93 readonly servers: readonly { readonly url: string }[];
94 readonly paths: Record<string, Record<string, OpenApiOperationObject>>;
95 readonly components: {
96 readonly schemas: Record<string, OpenApiSchemaObject>;
97 readonly securitySchemes?: Record<
98 string,
99 {
100 readonly type: "oauth2";
101 readonly flows: {
102 readonly authorizationCode: {
103 readonly authorizationUrl: string;
104 readonly tokenUrl: string;
105 readonly scopes: Record<string, string>;
106 };
107 };
108 }
109 >;
110 };
111 readonly security?: readonly Record<string, readonly string[]>[];
112 readonly "x-executor-origin":
113 | {
114 readonly kind: "googleDiscovery";
115 readonly discoveryUrl: string;
116 readonly service: string;
117 readonly version: string;
118 }
119 | {
120 readonly kind: "googleDiscoveryBundle";
121 readonly services: readonly {
122 readonly discoveryUrl: string;
123 readonly service: string;
124 readonly version: string;
125 }[];
126 };
127};
128
129const TextOption = Schema.OptionFromOptional(Schema.Trim).pipe(
130 Schema.decode({
131 decode: SchemaGetter.transform((value) => Option.filter(value, (text) => text.length > 0)),
132 encode: SchemaGetter.transform((value) => value),
133 }),
134 Schema.withDecodingDefaultType(Effect.succeed(Option.none())),
135);
136const TextArray = Schema.optional(Schema.Array(Schema.String)).pipe(
137 Schema.withDecodingDefaultType(Effect.succeed([] as string[])),
138);
139const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
140const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe(
141 Schema.withDecodingDefaultType(Effect.succeed({})),
142);
143
144const DiscoveryParameter = Schema.Struct({
145 type: Schema.optional(Schema.String),
146 description: TextOption,
147 properties: UnknownRecordWithDefault,
148 items: Schema.optional(Schema.Unknown),
149 additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])),
150 enum: TextArray,
151 format: Schema.optional(Schema.String),
152 readOnly: Schema.optional(Schema.Boolean),
153 default: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Boolean])),
154 $ref: Schema.optional(Schema.String),
155 location: Schema.optional(Schema.Literals(["path", "query", "header"])),
156 required: Schema.optional(Schema.Boolean),
157 repeated: Schema.optional(Schema.Boolean),
158});
159type DiscoveryParameter = typeof DiscoveryParameter.Type;
160
161const DiscoveryRef = Schema.Struct({
162 $ref: Schema.optional(Schema.String),
163});
164
165const DiscoveryMethod = Schema.Struct({
166 id: TextOption,
167 description: TextOption,
168 httpMethod: Schema.optional(Schema.String),
169 path: TextOption,
170 parameters: UnknownRecordWithDefault,
171 request: Schema.optional(DiscoveryRef),
172 response: Schema.optional(DiscoveryRef),
173 scopes: TextArray,
174});
175type DiscoveryMethod = typeof DiscoveryMethod.Type;
176
177const DiscoveryResource = Schema.Struct({
178 methods: UnknownRecordWithDefault,
179 resources: UnknownRecordWithDefault,
180});
181
182const DiscoveryDocument = Schema.Struct({
183 name: TextOption,
184 version: TextOption,
185 title: TextOption,
186 rootUrl: TextOption,
187 servicePath: Schema.optional(Schema.Trim).pipe(
188 Schema.withDecodingDefaultType(Effect.succeed("")),
189 ),
190 parameters: UnknownRecordWithDefault,
191 methods: UnknownRecordWithDefault,
192 resources: UnknownRecordWithDefault,
193 schemas: UnknownRecordWithDefault,
194 auth: Schema.optional(
195 Schema.Struct({
196 oauth2: Schema.optional(
197 Schema.Struct({
198 scopes: Schema.optional(
199 Schema.Record(
200 Schema.String,
201 Schema.Struct({
202 description: TextOption,
203 }),
204 ),
205 ).pipe(Schema.withDecodingDefaultType(Effect.succeed({}))),
206 }),
207 ),
208 }),
209 ),
210});
211type DiscoveryDocument = typeof DiscoveryDocument.Type;
212
213export interface GoogleDiscoveryOpenApiConversion {
214 readonly specText: string;
215 readonly baseUrl: string;
216 readonly title: string;
217 readonly service: string;
218 readonly version: string;
219 readonly discoveryUrls?: readonly string[];
220 /** The v2 oauth auth template the converted integration declares, when the
221 * Discovery document advertises OAuth2 scopes. */
222 readonly authenticationTemplate?: readonly Authentication[];
223}
224
225const decodeDiscoveryDocument = Schema.decodeUnknownSync(DiscoveryDocument);
226const decodeDiscoveryParameter = Schema.decodeUnknownSync(DiscoveryParameter);
227const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod);
228const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource);
229const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
230
231const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
232 const trimmed = discoveryUrl.trim();
233 if (!URL.canParse(trimmed)) return trimmed;
234 const parsed = new URL(trimmed);
235 if (parsed.pathname !== "/$discovery/rest") return trimmed;
236 const version = parsed.searchParams.get("version")?.trim();
237 if (!version) return trimmed;
238 const host = parsed.hostname.toLowerCase();
239 if (!host.endsWith(".googleapis.com")) return trimmed;
240 const rawService = host.slice(0, -".googleapis.com".length);
241 const service =
242 rawService === "calendar-json"
243 ? "calendar"
244 : rawService.endsWith("-json")
245 ? rawService.slice(0, -5)
246 : rawService;
247 return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed;
248};
249
250export const isGoogleDiscoveryUrl = (url: string): boolean => {
251 const trimmed = url.trim();
252 if (!URL.canParse(trimmed)) return false;
253 const parsed = new URL(trimmed);
254 const host = parsed.hostname.toLowerCase();
255 if (!host.endsWith("googleapis.com")) return false;
256 return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");
257};
258
259export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
260 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {
261 const client = yield* HttpClient.HttpClient;
262 const requestUrl = new URL(discoveryUrl);
263 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
264 requestUrl.searchParams.set(name, value);
265 }
266 let request = HttpClientRequest.get(requestUrl.toString()).pipe(
267 HttpClientRequest.setHeader("Accept", "application/json, */*"),
268 );
269 for (const [name, value] of Object.entries(credentials?.headers ?? {})) {
270 request = HttpClientRequest.setHeader(request, name, value);
271 }
272 const response = yield* client.execute(request).pipe(
273 Effect.mapError(
274 () =>
275 new OpenApiParseError({
276 message: "Failed to fetch Google Discovery document",
277 }),
278 ),
279 );
280 if (response.status < 200 || response.status >= 300) {
281 return yield* new OpenApiParseError({
282 message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,
283 });
284 }
285 return yield* response.text.pipe(
286 Effect.mapError(
287 () =>
288 new OpenApiParseError({
289 message: "Failed to read Google Discovery document body",
290 }),
291 ),
292 );
293 },
294);
295
296const schemaRef = (name: string) => `#/components/schemas/${name}`;
297
298const identitySchemaName = (name: string): string => name;
299
300const schemaComponentPart = (value: string): string =>
301 value
302 .trim()
303 .replace(/[^A-Za-z0-9._-]+/g, "_")
304 .replace(/^_+|_+$/g, "") || "schema";
305
306const normalizeDiscoveryPathTemplate = (pathTemplate: string): string =>
307 pathTemplate.replaceAll(/\{\+([^{}]+)\}/g, "{$1}");
308
309const pathUsesReservedExpansion = (pathTemplate: string, parameterName: string): boolean =>
310 pathTemplate.includes(`{+${parameterName}}`);
311
312const uniquePathKey = (
313 paths: Record<string, Record<string, OpenApiOperationObject>>,
314 preferredPath: string,
315 method: string,
316 toolPath: string,
317): string => {
318 if (!paths[preferredPath]?.[method]) return preferredPath;
319 const disambiguated = `/${toolPath.replace(/[^A-Za-z0-9._~-]+/g, "/")}`;
320 if (!paths[disambiguated]?.[method]) return disambiguated;
321 let index = 2;
322 while (paths[`${disambiguated}/${index}`]?.[method]) index += 1;
323 return `${disambiguated}/${index}`;
324};
325
326const discoveryDescription = (value: unknown): string | undefined =>
327 typeof value === "string"
328 ? value
329 : Option.isOption(value) && Option.isSome(value)
330 ? typeof value.value === "string"
331 ? value.value
332 : undefined
333 : undefined;
334
335const isRecord = (value: unknown): value is Record<string, unknown> =>
336 typeof value === "object" && value !== null && !Array.isArray(value);
337
338const jsonValue = (value: unknown): JsonValue | undefined => {
339 if (
340 value === null ||
341 typeof value === "string" ||
342 typeof value === "number" ||
343 typeof value === "boolean"
344 ) {
345 return value;
346 }
347 if (Array.isArray(value)) {
348 const values = value.map(jsonValue);
349 return values.every(Predicate.isNotUndefined) ? values : undefined;
350 }
351 if (!isRecord(value)) return undefined;
352 const entries = Object.entries(value).flatMap(([key, item]) => {
353 const converted = jsonValue(item);
354 return converted === undefined ? [] : [[key, converted] as const];
355 });
356 return Object.fromEntries(entries);
357};
358
359const stringArray = (value: unknown): readonly string[] | undefined => {
360 if (!Array.isArray(value)) return undefined;
361 const strings = value.filter((item): item is string => typeof item === "string");
362 return strings.length === value.length ? strings : undefined;
363};
364
365const schemaType = (value: unknown): OpenApiSchemaObject["type"] | undefined =>
366 typeof value === "string" && OPENAPI_SCHEMA_TYPES.has(value)
367 ? (value as OpenApiSchemaObject["type"])
368 : undefined;
369
370const discoverySchemaToOpenApiSchema = (
371 raw: unknown,
372 schemaNameForRef: (name: string) => string = identitySchemaName,
373): OpenApiSchemaObject => {
374 if (!isRecord(raw)) return {};
375 const schema = raw;
376 if (typeof schema.$ref === "string") return { $ref: schemaRef(schemaNameForRef(schema.$ref)) };
377
378 const description = discoveryDescription(schema.description);
379 const title = discoveryDescription(schema.title);
380 const defaultValue = jsonValue(schema.default);
381 const enumValues = Array.isArray(schema.enum)
382 ? schema.enum.map(jsonValue).filter(Predicate.isNotUndefined)
383 : [];
384 const format = typeof schema.format === "string" ? schema.format : undefined;
385 const readOnly = typeof schema.readOnly === "boolean" ? schema.readOnly : undefined;
386 const type = schemaType(schema.type);
387
388 const base = {
389 ...(description !== undefined ? { description } : {}),
390 ...(title !== undefined ? { title } : {}),
391 ...(format !== undefined ? { format } : {}),
392 ...(readOnly !== undefined ? { readOnly } : {}),
393 ...(defaultValue !== undefined ? { default: defaultValue } : {}),
394 ...(enumValues.length > 0 ? { enum: enumValues } : {}),
395 } satisfies OpenApiSchemaObject;
396
397 if (type === "array") {
398 return {
399 ...base,
400 type: "array",
401 items: discoverySchemaToOpenApiSchema(schema.items, schemaNameForRef),
402 };
403 }
404
405 const properties = schema.properties;
406 if (
407 type === "object" ||
408 (isRecord(properties) && Object.keys(properties).length > 0) ||
409 schema.additionalProperties !== undefined
410 ) {
411 const convertedProperties = isRecord(properties)
412 ? Object.fromEntries(
413 Object.entries(properties).map(([name, value]) => [
414 name,
415 discoverySchemaToOpenApiSchema(value, schemaNameForRef),
416 ]),
417 )
418 : undefined;
419 const required = stringArray(schema.required);
420 const additionalProperties =
421 schema.additionalProperties === undefined
422 ? undefined
423 : typeof schema.additionalProperties === "boolean"
424 ? schema.additionalProperties
425 : discoverySchemaToOpenApiSchema(schema.additionalProperties, schemaNameForRef);
426 return {
427 ...base,
428 type: "object",
429 ...(convertedProperties && Object.keys(convertedProperties).length > 0
430 ? { properties: convertedProperties }
431 : {}),
432 ...(required && required.length > 0 ? { required } : {}),
433 ...(additionalProperties !== undefined ? { additionalProperties } : {}),
434 };
435 }
436
437 return type !== undefined ? { ...base, type } : base;
438};
439
440const parameterSchema = (
441 parameter: DiscoveryParameter,
442 schemaNameForRef: (name: string) => string = identitySchemaName,
443): OpenApiSchemaObject => {
444 const base = discoverySchemaToOpenApiSchema(parameter, schemaNameForRef);
445 return parameter.repeated
446 ? {
447 type: "array",
448 items: base,
449 }
450 : base;
451};
452
453const methodToolPath = (service: string, methodId: string): string =>
454 methodId.startsWith(`${service}.`) ? methodId.slice(service.length + 1) : methodId;
455
456const collectMethods = (resource: unknown): DiscoveryMethod[] => {
457 const decoded = decodeDiscoveryResource(resource);
458 const direct = Object.values(decoded.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw));
459 const nested = Object.values(decoded.resources ?? {}).flatMap(collectMethods);
460 return [...direct, ...nested];
461};
462
463const discoveryScopes = (document: DiscoveryDocument): Record<string, string> =>
464 Object.fromEntries(
465 Object.entries(document.auth?.oauth2?.scopes ?? {}).map(([scope, value]) => [
466 scope,
467 Option.getOrElse(value.description, () => ""),
468 ]),
469 );
470
471// The scope set the converted integration DECLARES (and that `oauth.start`
472// requests at connect) must match the consent the picker previews. Both run the
473// raw Discovery union through `compactGoogleOAuthScopes`, which drops scopes a
474// user OAuth consent screen can't show (`chat.bot`/`chat.app.*`/`keep`) and
475// collapses sub-scopes under their broad parent (`gmail.*` → `mail.google.com`,
476// `userinfo.email` → `email`). Descriptions are preserved where the raw map had
477// them; compaction-introduced identity scopes (`email`/`profile`) fall back to
478// the broad parent's description. Per-operation `x-google-scopes`/`security`
479// stay RAW — they describe which scope each method needs, not consent.
480const compactDiscoveryScopeMap = (raw: Record<string, string>): Record<string, string> => {
481 const descriptionFor = (scope: string): string => {
482 if (raw[scope] !== undefined) return raw[scope];
483 if (scope === "email") return raw["https://www.googleapis.com/auth/userinfo.email"] ?? "";
484 if (scope === "profile") return raw["https://www.googleapis.com/auth/userinfo.profile"] ?? "";
485 return "";
486 };
487 return Object.fromEntries(
488 compactGoogleOAuthScopes(Object.keys(raw)).map((scope) => [scope, descriptionFor(scope)]),
489 );
490};
491
492const allDiscoveryMethods = (document: DiscoveryDocument): DiscoveryMethod[] => [
493 ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)),
494 ...Object.values(document.resources ?? {}).flatMap(collectMethods),
495];
496
497type DiscoveryDocumentInfo = {
498 readonly discoveryUrl: string;
499 readonly document: DiscoveryDocument;
500 readonly service: string;
501 readonly version: string;
502 readonly rootUrl: string;
503 readonly baseUrl: string;
504 readonly title: string;
505};
506
507const discoveryDocumentInfo = (
508 document: DiscoveryDocument,
509 discoveryUrl: string,
510): Effect.Effect<DiscoveryDocumentInfo, OpenApiParseError> =>
511 Effect.gen(function* () {
512 const service = Option.getOrUndefined(document.name);
513 const version = Option.getOrUndefined(document.version);
514 const rootUrl = Option.getOrUndefined(document.rootUrl);
515 if (!service || !version || !rootUrl) {
516 return yield* new OpenApiParseError({
517 message: "Google Discovery document is missing one of: name, version, rootUrl",
518 });
519 }
520
521 return {
522 discoveryUrl,
523 document,
524 service,
525 version,
526 rootUrl,
527 baseUrl: new URL(document.servicePath || "", rootUrl).toString(),
528 title: Option.getOrElse(document.title, () => `${service} ${version}`),
529 };
530 });
531
532const buildDiscoveryOperation = (input: {
533 readonly document: DiscoveryDocument;
534 readonly method: DiscoveryMethod;
535 readonly toolPath: string;
536 readonly pathTemplate: string;
537 readonly schemaNameForRef?: (name: string) => string;
538 readonly serverUrl?: string;
539 readonly tags?: readonly string[];
540}): OpenApiOperationObject => {
541 const mergedParameters = new Map<string, DiscoveryParameter>();
542 for (const [name, raw] of Object.entries(input.document.parameters ?? {})) {
543 const parameter = decodeDiscoveryParameter(raw);
544 if (parameter.location) mergedParameters.set(name, parameter);
545 }
546 for (const [name, raw] of Object.entries(input.method.parameters ?? {})) {
547 const parameter = decodeDiscoveryParameter(raw);
548 if (parameter.location) mergedParameters.set(name, parameter);
549 }
550
551 const methodScopes = input.method.scopes ?? [];
552 const methodDescription = Option.getOrUndefined(input.method.description);
553 const schemaNameForRef = input.schemaNameForRef ?? identitySchemaName;
554
555 return {
556 operationId: input.toolPath,
557 "x-executor-toolPath": input.toolPath,
558 "x-executor-pathTemplate": input.pathTemplate,
559 ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
560 ...(methodDescription !== undefined ? { description: methodDescription } : {}),
561 ...(input.serverUrl ? { servers: [{ url: input.serverUrl }] } : {}),
562 parameters: [...mergedParameters.entries()].flatMap(([name, parameter]) => {
563 const location = parameter.location;
564 if (!location) return [];
565 const description = Option.getOrUndefined(parameter.description);
566 const allowReserved =
567 location === "path" && pathUsesReservedExpansion(input.pathTemplate, name);
568 return [
569 {
570 name,
571 in: location,
572 required: location === "path" ? true : parameter.required === true,
573 ...(description !== undefined ? { description } : {}),
574 schema: parameterSchema(parameter, schemaNameForRef),
575 ...(location === "query"
576 ? { style: "form" as const, explode: parameter.repeated === true }
577 : {}),
578 ...(allowReserved ? { allowReserved: true } : {}),
579 },
580 ];
581 }),
582 ...(input.method.request?.$ref
583 ? {
584 requestBody: {
585 required: false,
586 content: {
587 "application/json": {
588 schema: { $ref: schemaRef(schemaNameForRef(input.method.request.$ref)) },
589 },
590 },
591 },
592 }
593 : {}),
594 responses: {
595 "200": {
596 description: "Successful response",
597 content: {
598 "application/json": {
599 schema: input.method.response?.$ref
600 ? { $ref: schemaRef(schemaNameForRef(input.method.response.$ref)) }
601 : {},
602 },
603 },
604 },
605 },
606 ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}),
607 "x-google-scopes": methodScopes,
608 };
609};
610
611const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
612
613/** The v2 oauth auth template for a Google-discovery integration. The spec
614 * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the
615 * catalog-level template a connection's access token renders through. */
616const googleOauthTemplate = (scopes: Record<string, string>): readonly Authentication[] =>
617 // A Google-discovery integration is ALWAYS OAuth, so it ALWAYS declares its
618 // oauth method — even when the compacted scope set is empty (e.g. a bundle of
619 // only limited-consent APIs like Google Keep, whose scopes Google won't grant
620 // through standard consent). Without this the integration declares no auth
621 // method and the "Add account" / "Add a connection" actions are disabled, so
622 // you can't even start the flow. Empty `scopes` just requests no scope.
623 [
624 {
625 slug: AuthTemplateSlug.make(GOOGLE_OAUTH_SECURITY_SCHEME),
626 kind: "oauth2",
627 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
628 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
629 scopes: Object.keys(scopes),
630 },
631 ];
632
633export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")(
634 function* (input: { readonly discoveryUrl: string; readonly documentText: string }) {
635 const parsed = yield* parseJson(input.documentText).pipe(
636 Effect.mapError(
637 () =>
638 new OpenApiParseError({
639 message: "Failed to parse Google Discovery document",
640 }),
641 ),
642 );
643 const document = yield* Effect.try({
644 try: () => decodeDiscoveryDocument(parsed),
645 catch: () =>
646 new OpenApiParseError({
647 message: "Failed to decode Google Discovery document",
648 }),
649 });
650
651 const info = yield* discoveryDocumentInfo(document, input.discoveryUrl);
652 const { service, version, baseUrl, title } = info;
653 const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
654
655 for (const method of allDiscoveryMethods(document)) {
656 const methodId = Option.getOrUndefined(method.id);
657 const pathTemplate = Option.getOrUndefined(method.path);
658 if (!methodId || !pathTemplate || !method.httpMethod) continue;
659
660 const toolPath = methodToolPath(service, methodId);
661 const path = normalizeDiscoveryPathTemplate(
662 pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
663 );
664 const methodKey = method.httpMethod.toLowerCase();
665 const pathKey = uniquePathKey(paths, path, methodKey, toolPath);
666
667 paths[pathKey] ??= {};
668 paths[pathKey]![methodKey] = buildDiscoveryOperation({
669 document,
670 method,
671 toolPath,
672 pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
673 });
674 }
675
676 const scopes = compactDiscoveryScopeMap(discoveryScopes(document));
677 const authenticationTemplate = googleOauthTemplate(scopes);
678
679 const spec: OpenApiDocument = {
680 openapi: "3.1.0",
681 info: {
682 title,
683 version,
684 },
685 servers: [{ url: baseUrl }],
686 paths,
687 components: {
688 schemas: Object.fromEntries(
689 Object.entries(document.schemas ?? {}).map(([name, schema]) => [
690 name,
691 discoverySchemaToOpenApiSchema(schema),
692 ]),
693 ),
694 ...(authenticationTemplate
695 ? {
696 securitySchemes: {
697 googleOAuth2: {
698 type: "oauth2",
699 flows: {
700 authorizationCode: {
701 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
702 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
703 scopes,
704 },
705 },
706 },
707 },
708 }
709 : {}),
710 },
711 ...(authenticationTemplate ? { security: [{ googleOAuth2: Object.keys(scopes) }] } : {}),
712 "x-executor-origin": {
713 kind: "googleDiscovery",
714 discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),
715 service,
716 version,
717 },
718 };
719
720 return {
721 // @effect-diagnostics-next-line preferSchemaOverJson:off
722 specText: JSON.stringify(spec),
723 baseUrl,
724 title,
725 service,
726 version,
727 ...(authenticationTemplate ? { authenticationTemplate } : {}),
728 };
729 },
730);
731
732export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn(
733 "OpenApi.convertGoogleDiscoveryBundle",
734)(function* (input: {
735 readonly documents: readonly { readonly discoveryUrl: string; readonly documentText: string }[];
736}) {
737 if (input.documents.length === 0) {
738 return yield* new OpenApiParseError({
739 message: "Google Discovery bundle requires at least one document",
740 });
741 }
742
743 const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) =>
744 Effect.gen(function* () {
745 const parsed = yield* parseJson(documentText).pipe(
746 Effect.mapError(
747 () =>
748 new OpenApiParseError({
749 message: "Failed to parse Google Discovery document",
750 }),
751 ),
752 );
753 const document = yield* Effect.try({
754 try: () => decodeDiscoveryDocument(parsed),
755 catch: () =>
756 new OpenApiParseError({
757 message: "Failed to decode Google Discovery document",
758 }),
759 });
760 return yield* discoveryDocumentInfo(document, discoveryUrl);
761 }),
762 );
763
764 const paths: Record<string, Record<string, OpenApiOperationObject>> = {};
765 const schemas: Record<string, OpenApiSchemaObject> = {};
766 const rawScopes: Record<string, string> = {};
767
768 for (const info of infos) {
769 const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
770 const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
771
772 for (const [scope, description] of Object.entries(discoveryScopes(info.document))) {
773 rawScopes[scope] ??= description;
774 }
775
776 for (const [name, schema] of Object.entries(info.document.schemas ?? {})) {
777 schemas[schemaNameForRef(name)] = discoverySchemaToOpenApiSchema(schema, schemaNameForRef);
778 }
779
780 for (const method of allDiscoveryMethods(info.document)) {
781 const methodId = Option.getOrUndefined(method.id);
782 const rawPathTemplate = Option.getOrUndefined(method.path);
783 if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
784
785 const toolPath = methodId;
786 const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`;
787 const openApiPath = normalizeDiscoveryPathTemplate(wirePath);
788 const methodKey = method.httpMethod.toLowerCase();
789 const pathKey = uniquePathKey(paths, openApiPath, methodKey, toolPath);
790
791 paths[pathKey] ??= {};
792 paths[pathKey]![methodKey] = buildDiscoveryOperation({
793 document: info.document,
794 method,
795 toolPath,
796 pathTemplate: wirePath,
797 schemaNameForRef,
798 serverUrl: info.baseUrl,
799 tags: [info.title],
800 });
801 }
802 }
803
804 const scopes = compactDiscoveryScopeMap(rawScopes);
805 const authenticationTemplate = googleOauthTemplate(scopes);
806 const spec: OpenApiDocument = {
807 openapi: "3.1.0",
808 info: {
809 title: "Google",
810 version: "google-discovery-bundle",
811 },
812 servers: [{ url: GOOGLE_BUNDLE_BASE_URL }],
813 paths,
814 components: {
815 schemas,
816 ...(authenticationTemplate
817 ? {
818 securitySchemes: {
819 googleOAuth2: {
820 type: "oauth2",
821 flows: {
822 authorizationCode: {
823 authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
824 tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
825 scopes,
826 },
827 },
828 },
829 },
830 }
831 : {}),
832 },
833 ...(authenticationTemplate ? { security: [{ googleOAuth2: Object.keys(scopes) }] } : {}),
834 "x-executor-origin": {
835 kind: "googleDiscoveryBundle",
836 services: infos.map((info) => ({
837 discoveryUrl: normalizeDiscoveryUrl(info.discoveryUrl),
838 service: info.service,
839 version: info.version,
840 })),
841 },
842 };
843
844 return {
845 // @effect-diagnostics-next-line preferSchemaOverJson:off
846 specText: JSON.stringify(spec),
847 baseUrl: GOOGLE_BUNDLE_BASE_URL,
848 title: "Google",
849 service: "google",
850 version: "google-discovery-bundle",
851 discoveryUrls: infos.map((info) => normalizeDiscoveryUrl(info.discoveryUrl)),
852 ...(authenticationTemplate ? { authenticationTemplate } : {}),
853 };
854});
packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts+0−98
@@ -1,98 +0,0 @@
1import { expect, it } from "@effect/vitest";
2
3import { googleOAuthConsentBatches } from "./google-oauth-batches";
4
5it("keeps core Google OAuth in one consent batch", () => {
6 expect(
7 googleOAuthConsentBatches([
8 {
9 id: "google-gmail",
10 name: "Gmail",
11 oauthAudience: "standard-user",
12 scopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/gmail.send"],
13 },
14 {
15 id: "google-calendar",
16 name: "Google Calendar",
17 oauthAudience: "standard-user",
18 scopes: [
19 "https://www.googleapis.com/auth/calendar",
20 "https://www.googleapis.com/auth/calendar.readonly",
21 ],
22 },
23 ]),
24 ).toEqual([
25 {
26 id: "google-core",
27 label: "Core Google services",
28 apiScopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/calendar"],
29 },
30 ]);
31});
32
33it("splits advanced Google OAuth into smaller known-good consent batches", () => {
34 expect(
35 googleOAuthConsentBatches([
36 {
37 id: "google-youtube-data",
38 name: "YouTube Data",
39 oauthAudience: "advanced-user",
40 scopes: ["https://www.googleapis.com/auth/youtube"],
41 },
42 {
43 id: "google-classroom",
44 name: "Google Classroom",
45 oauthAudience: "advanced-user",
46 scopes: ["https://www.googleapis.com/auth/classroom.courses"],
47 },
48 {
49 id: "google-apps-script",
50 name: "Google Apps Script",
51 oauthAudience: "advanced-user",
52 scopes: [
53 "https://www.googleapis.com/auth/script.projects",
54 "https://www.googleapis.com/auth/drive",
55 ],
56 },
57 {
58 id: "google-bigquery",
59 name: "Google BigQuery",
60 oauthAudience: "advanced-user",
61 scopes: ["https://www.googleapis.com/auth/bigquery"],
62 },
63 {
64 id: "google-cloud-resource-manager",
65 name: "Google Cloud Resource Manager",
66 oauthAudience: "advanced-user",
67 scopes: ["https://www.googleapis.com/auth/cloud-platform"],
68 },
69 ]).map((batch) => ({ id: batch.id, label: batch.label, apiScopes: batch.apiScopes })),
70 ).toEqual([
71 {
72 id: "google-youtube-data",
73 label: "YouTube Data",
74 apiScopes: ["https://www.googleapis.com/auth/youtube"],
75 },
76 {
77 id: "google-classroom",
78 label: "Google Classroom",
79 apiScopes: ["https://www.googleapis.com/auth/classroom.courses"],
80 },
81 {
82 id: "google-apps-script",
83 label: "Google Apps Script",
84 apiScopes: [
85 "https://www.googleapis.com/auth/script.projects",
86 "https://www.googleapis.com/auth/drive",
87 ],
88 },
89 {
90 id: "google-cloud",
91 label: "Google Cloud services",
92 apiScopes: [
93 "https://www.googleapis.com/auth/bigquery",
94 "https://www.googleapis.com/auth/cloud-platform",
95 ],
96 },
97 ]);
98});
packages/plugins/openapi/src/sdk/google-oauth-batches.ts+0−69
@@ -1,69 +0,0 @@
1import type { GoogleOpenApiOAuthAudience } from "./google-presets";
2import { compactGoogleOAuthScopes } from "./google-oauth-scopes";
3
4export type GoogleOAuthBatchInput = {
5 readonly id: string;
6 readonly name: string;
7 readonly oauthAudience: GoogleOpenApiOAuthAudience;
8 readonly scopes: readonly string[];
9};
10
11export type GoogleOAuthConsentBatch = {
12 readonly id: string;
13 readonly label: string;
14 readonly apiScopes: readonly string[];
15};
16
17const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]);
18
19export const googleOAuthConsentBatches = (
20 items: readonly GoogleOAuthBatchInput[],
21): readonly GoogleOAuthConsentBatch[] => {
22 const standardScopes: string[] = [];
23 const cloudScopes: string[] = [];
24 const batches: GoogleOAuthConsentBatch[] = [];
25
26 for (const item of items) {
27 if (item.scopes.length === 0) continue;
28 if (item.oauthAudience === "standard-user") {
29 standardScopes.push(...item.scopes);
30 continue;
31 }
32 if (GOOGLE_CLOUD_BATCH_IDS.has(item.id)) {
33 cloudScopes.push(...item.scopes);
34 continue;
35 }
36 batches.push({
37 id: item.id,
38 label: item.name,
39 apiScopes: item.scopes,
40 });
41 }
42
43 const compactedStandardScopes = compactGoogleOAuthScopes(standardScopes);
44 const compactedCloudScopes = compactGoogleOAuthScopes(cloudScopes);
45 return [
46 ...(compactedStandardScopes.length > 0
47 ? [
48 {
49 id: "google-core",
50 label: "Core Google services",
51 apiScopes: compactedStandardScopes,
52 },
53 ]
54 : []),
55 ...batches.map((batch) => ({
56 ...batch,
57 apiScopes: compactGoogleOAuthScopes(batch.apiScopes),
58 })),
59 ...(compactedCloudScopes.length > 0
60 ? [
61 {
62 id: "google-cloud",
63 label: "Google Cloud services",
64 apiScopes: compactedCloudScopes,
65 },
66 ]
67 : []),
68 ].filter((batch) => batch.apiScopes.length > 0);
69};
packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts+0−37
@@ -1,37 +0,0 @@
1import { expect, it } from "@effect/vitest";
2
3import {
4 compactGoogleOAuthScopes,
5 filterGoogleUserConsentOAuthScopes,
6} from "./google-oauth-scopes";
7
8it("filters Google scopes that cannot be shown on a user OAuth consent screen", () => {
9 expect(
10 filterGoogleUserConsentOAuthScopes([
11 "https://www.googleapis.com/auth/calendar",
12 "https://www.googleapis.com/auth/chat.app.messages.readonly",
13 "https://www.googleapis.com/auth/chat.bot",
14 "https://www.googleapis.com/auth/chat.import",
15 "https://www.googleapis.com/auth/chat.messages.readonly",
16 "https://www.googleapis.com/auth/keep",
17 "https://www.googleapis.com/auth/keep.readonly",
18 ]),
19 ).toEqual([
20 "https://www.googleapis.com/auth/calendar",
21 "https://www.googleapis.com/auth/chat.messages.readonly",
22 ]);
23});
24
25it("compacts Google OAuth scopes after filtering user-consent-incompatible scopes", () => {
26 expect(
27 compactGoogleOAuthScopes([
28 "https://mail.google.com/",
29 "https://www.googleapis.com/auth/gmail.send",
30 "https://www.googleapis.com/auth/userinfo.email",
31 "https://www.googleapis.com/auth/userinfo.profile",
32 "openid",
33 "https://www.googleapis.com/auth/chat.app.spaces",
34 "https://www.googleapis.com/auth/keep.readonly",
35 ]),
36 ).toEqual(["https://mail.google.com/", "email", "profile", "openid"]);
37});
packages/plugins/openapi/src/sdk/google-oauth-scopes.ts+0−66
@@ -1,66 +0,0 @@
1const googleUserConsentBlockedScopes = new Set([
2 "https://www.googleapis.com/auth/chat.bot",
3 "https://www.googleapis.com/auth/chat.import",
4 "https://www.googleapis.com/auth/keep",
5 "https://www.googleapis.com/auth/keep.readonly",
6]);
7
8const googleUserConsentBlockedScopePrefixes = ["https://www.googleapis.com/auth/chat.app."];
9
10const googleBroadScopeGroups: readonly {
11 readonly broad: string;
12 readonly prefixes: readonly string[];
13}[] = [
14 {
15 broad: "https://mail.google.com/",
16 prefixes: ["https://www.googleapis.com/auth/gmail."],
17 },
18 {
19 broad: "https://www.googleapis.com/auth/calendar",
20 prefixes: ["https://www.googleapis.com/auth/calendar."],
21 },
22 {
23 broad: "https://www.googleapis.com/auth/drive",
24 prefixes: ["https://www.googleapis.com/auth/drive."],
25 },
26];
27
28const normalizeGoogleIdentityScope = (scope: string): string =>
29 scope === "https://www.googleapis.com/auth/userinfo.email"
30 ? "email"
31 : scope === "https://www.googleapis.com/auth/userinfo.profile"
32 ? "profile"
33 : scope;
34
35const orderedUniqueScopes = (scopes: Iterable<string>): string[] => {
36 const ordered: string[] = [];
37 const seen = new Set<string>();
38 for (const scope of scopes) {
39 const trimmed = scope.trim();
40 if (!trimmed || seen.has(trimmed)) continue;
41 seen.add(trimmed);
42 ordered.push(trimmed);
43 }
44 return ordered;
45};
46
47export const isGoogleUserConsentOAuthScope = (scope: string): boolean =>
48 !googleUserConsentBlockedScopes.has(scope) &&
49 !googleUserConsentBlockedScopePrefixes.some((prefix) => scope.startsWith(prefix));
50
51export const filterGoogleUserConsentOAuthScopes = (scopes: Iterable<string>): string[] =>
52 orderedUniqueScopes(scopes).filter(isGoogleUserConsentOAuthScope);
53
54export const compactGoogleOAuthScopes = (scopes: Iterable<string>): string[] => {
55 const ordered = filterGoogleUserConsentOAuthScopes([...scopes].map(normalizeGoogleIdentityScope));
56 const present = new Set(ordered);
57 return ordered.filter(
58 (scope) =>
59 !googleBroadScopeGroups.some(
60 (group) =>
61 scope !== group.broad &&
62 present.has(group.broad) &&
63 group.prefixes.some((prefix) => scope.startsWith(prefix)),
64 ),
65 );
66};
packages/plugins/openapi/src/sdk/google-presets.test.ts+0−29
@@ -1,29 +0,0 @@
1import { expect, it } from "@effect/vitest";
2
3import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./google-presets";
4
5it("keeps Select all limited to Google services that can use normal user OAuth", () => {
6 const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id));
7
8 expect(standardIds).toContain("google-calendar");
9 expect(standardIds).toContain("google-gmail");
10 expect(standardIds).toContain("google-tasks");
11 expect(standardIds).toContain("google-people");
12 expect(standardIds).toContain("google-search-console");
13
14 expect(standardIds).not.toContain("google-youtube-data");
15 expect(standardIds).not.toContain("google-cloud-resource-manager");
16 expect(standardIds).not.toContain("google-chat");
17 expect(standardIds).not.toContain("google-keep");
18 expect(standardIds).not.toContain("google-admin-directory");
19 expect(standardIds).not.toContain("google-admin-reports");
20});
21
22it("classifies every Google service for bundle OAuth UX", () => {
23 expect(
24 googleOpenApiPresets.map((preset) => ({
25 id: preset.id,
26 oauthAudience: preset.oauthAudience,
27 })),
28 ).toMatchSnapshot();
29});
packages/plugins/openapi/src/sdk/google-presets.ts+0−267
@@ -1,267 +0,0 @@
1import type { OpenApiPreset } from "./presets";
2
3export type GoogleOpenApiOAuthAudience =
4 | "standard-user"
5 | "advanced-user"
6 | "workspace-admin"
7 | "unsupported-user";
8
9export type GoogleOpenApiPreset = OpenApiPreset & {
10 readonly oauthAudience: GoogleOpenApiOAuthAudience;
11};
12
13const gd = (service: string, version: string) =>
14 `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
15
16const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
17export const GOOGLE_BUNDLE_PRESET_ID = "google";
18
19export const googleOpenApiBundlePreset: OpenApiPreset = {
20 id: GOOGLE_BUNDLE_PRESET_ID,
21 name: "Google",
22 summary: "Bundle Gmail, Calendar, Drive, Docs, and other Google APIs into one source.",
23 icon: GOOGLE_G,
24 featured: true,
25};
26
27export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [
28 {
29 id: "google-calendar",
30 name: "Google Calendar",
31 summary: "Calendars, events, ACLs, and scheduling.",
32 url: gd("calendar", "v3"),
33 icon: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg",
34 featured: true,
35 oauthAudience: "standard-user",
36 },
37 {
38 id: "google-gmail",
39 name: "Gmail",
40 summary: "Messages, threads, labels, and drafts.",
41 url: gd("gmail", "v1"),
42 icon: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png",
43 featured: true,
44 oauthAudience: "standard-user",
45 },
46 {
47 id: "google-sheets",
48 name: "Google Sheets",
49 summary: "Spreadsheets, values, ranges, and formatting.",
50 url: gd("sheets", "v4"),
51 icon: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg",
52 featured: true,
53 oauthAudience: "standard-user",
54 },
55 {
56 id: "google-drive",
57 name: "Google Drive",
58 summary: "Files, folders, permissions, and shared drives.",
59 url: gd("drive", "v3"),
60 icon: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg",
61 featured: true,
62 oauthAudience: "standard-user",
63 },
64 {
65 id: "google-docs",
66 name: "Google Docs",
67 summary: "Documents, structural edits, and formatting.",
68 url: gd("docs", "v1"),
69 icon: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg",
70 featured: true,
71 oauthAudience: "standard-user",
72 },
73 {
74 id: "google-slides",
75 name: "Google Slides",
76 summary: "Presentations, slides, page elements, and deck updates.",
77 url: gd("slides", "v1"),
78 icon: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg",
79 oauthAudience: "standard-user",
80 },
81 {
82 id: "google-forms",
83 name: "Google Forms",
84 summary: "Forms, questions, responses, and quizzes.",
85 url: "https://forms.googleapis.com/$discovery/rest?version=v1",
86 icon: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg",
87 oauthAudience: "standard-user",
88 },
89 {
90 id: "google-tasks",
91 name: "Google Tasks",
92 summary: "Task lists, task items, notes, and due dates.",
93 url: gd("tasks", "v1"),
94 icon: "https://fonts.gstatic.com/s/i/productlogos/tasks/v5/192px.svg",
95 oauthAudience: "standard-user",
96 },
97 {
98 id: "google-people",
99 name: "Google People",
100 summary: "Contacts, profiles, directory people, and contact groups.",
101 url: gd("people", "v1"),
102 icon: "https://fonts.gstatic.com/s/i/productlogos/contacts_2022/v2/192px.svg",
103 oauthAudience: "standard-user",
104 },
105 {
106 id: "google-chat",
107 name: "Google Chat",
108 summary: "Spaces, messages, members, reactions, and chat workflows.",
109 url: gd("chat", "v1"),
110 icon: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg",
111 oauthAudience: "workspace-admin",
112 },
113 {
114 id: "google-keep",
115 name: "Google Keep",
116 summary: "Notes, lists, attachments, and annotations.",
117 url: "https://keep.googleapis.com/$discovery/rest?version=v1",
118 icon: "https://fonts.gstatic.com/s/i/productlogos/keep_2020q4/v8/192px.svg",
119 oauthAudience: "unsupported-user",
120 },
121 {
122 id: "google-youtube-data",
123 name: "YouTube Data",
124 summary: "Channels, playlists, videos, comments, and uploads.",
125 url: gd("youtube", "v3"),
126 icon: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg",
127 oauthAudience: "advanced-user",
128 },
129 {
130 id: "google-search-console",
131 name: "Google Search Console",
132 summary: "Sites, sitemaps, URL inspection, and search performance.",
133 url: gd("searchconsole", "v1"),
134 icon: GOOGLE_G,
135 oauthAudience: "standard-user",
136 },
137 {
138 id: "google-classroom",
139 name: "Google Classroom",
140 summary: "Courses, rosters, coursework, and grading.",
141 url: gd("classroom", "v1"),
142 icon: "https://fonts.gstatic.com/s/i/productlogos/classroom/v7/192px.svg",
143 oauthAudience: "advanced-user",
144 },
145 {
146 id: "google-admin-directory",
147 name: "Google Admin Directory",
148 summary: "Users, groups, org units, roles, and domain resources.",
149 url: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
150 icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg",
151 oauthAudience: "workspace-admin",
152 },
153 {
154 id: "google-admin-reports",
155 name: "Google Admin Reports",
156 summary: "Audit events, usage reports, and admin activity logs.",
157 url: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
158 icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg",
159 oauthAudience: "workspace-admin",
160 },
161 {
162 id: "google-apps-script",
163 name: "Google Apps Script",
164 summary: "Projects, deployments, and script execution.",
165 url: gd("script", "v1"),
166 icon: "https://fonts.gstatic.com/s/i/productlogos/apps_script/v10/192px.svg",
167 oauthAudience: "advanced-user",
168 },
169 {
170 id: "google-bigquery",
171 name: "Google BigQuery",
172 summary: "Datasets, tables, jobs, and analytical queries.",
173 url: gd("bigquery", "v2"),
174 icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg",
175 oauthAudience: "advanced-user",
176 },
177 {
178 id: "google-cloud-resource-manager",
179 name: "Google Cloud Resource Manager",
180 summary: "Projects, folders, organizations, and IAM hierarchy.",
181 url: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
182 icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg",
183 oauthAudience: "advanced-user",
184 },
185];
186
187export const googleStandardUserOAuthPresets = googleOpenApiPresets.filter(
188 (preset) => preset.oauthAudience === "standard-user",
189);
190
191// ---------------------------------------------------------------------------
192// Representative consent scopes per preset.
193//
194// The picker shows the OAuth consent a user is about to grant BEFORE connecting
195// (the "View scopes" panel), but the authoritative scope list only exists in
196// each API's live Discovery document — which the add flow fetches lazily at
197// `addSpec` time. To preview consent without N network round-trips, each preset
198// declares the broad top-level scope(s) a full integration grants. These flow
199// through `googleOAuthConsentBatches` (which compacts sub-scopes under their
200// broad parent), so the previewed grant matches the unioned scopes the bundle
201// converter ultimately stores. Grounded against each API's published Discovery
202// `auth.oauth2.scopes`.
203// ---------------------------------------------------------------------------
204
205export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = {
206 "google-calendar": ["https://www.googleapis.com/auth/calendar"],
207 "google-gmail": ["https://mail.google.com/"],
208 "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"],
209 "google-drive": ["https://www.googleapis.com/auth/drive"],
210 "google-docs": ["https://www.googleapis.com/auth/documents"],
211 "google-slides": ["https://www.googleapis.com/auth/presentations"],
212 "google-forms": ["https://www.googleapis.com/auth/forms.body"],
213 "google-tasks": ["https://www.googleapis.com/auth/tasks"],
214 "google-people": ["https://www.googleapis.com/auth/contacts"],
215 "google-chat": ["https://www.googleapis.com/auth/chat.spaces"],
216 "google-keep": ["https://www.googleapis.com/auth/keep"],
217 "google-youtube-data": ["https://www.googleapis.com/auth/youtube"],
218 "google-search-console": ["https://www.googleapis.com/auth/webmasters"],
219 "google-classroom": ["https://www.googleapis.com/auth/classroom.courses"],
220 "google-admin-directory": ["https://www.googleapis.com/auth/admin.directory.user"],
221 "google-admin-reports": ["https://www.googleapis.com/auth/admin.reports.audit.readonly"],
222 "google-apps-script": ["https://www.googleapis.com/auth/script.projects"],
223 "google-bigquery": ["https://www.googleapis.com/auth/bigquery"],
224 "google-cloud-resource-manager": ["https://www.googleapis.com/auth/cloud-platform"],
225};
226
227export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>
228 googleOAuthConsentScopes[presetId] ?? [];
229
230// ---------------------------------------------------------------------------
231// Resolve a stored/normalized Discovery URL back to its preset, so a bundled
232// `google` integration can surface each selected API's `oauthAudience` (e.g. a
233// caution on a connection's auth method when admin-only or unsupported-consent
234// APIs are part of the bundle).
235// ---------------------------------------------------------------------------
236
237const normalizeGooglePresetUrl = (url: string): string => {
238 const trimmed = url.trim();
239 if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, "");
240 const parsed = new URL(trimmed);
241 parsed.hash = "";
242 parsed.searchParams.sort();
243 return parsed.toString().replace(/\/$/, "");
244};
245
246const googlePresetsByNormalizedUrl: ReadonlyMap<string, GoogleOpenApiPreset> = new Map(
247 googleOpenApiPresets.flatMap((preset) =>
248 preset.url ? [[normalizeGooglePresetUrl(preset.url), preset] as const] : [],
249 ),
250);
251
252export const googlePresetForDiscoveryUrl = (url: string): GoogleOpenApiPreset | undefined =>
253 googlePresetsByNormalizedUrl.get(normalizeGooglePresetUrl(url));
254
255/** The distinct caution-tier audiences (`workspace-admin`, `unsupported-user`)
256 * among the supplied Discovery URLs — the ones whose consent the user should be
257 * warned about. Returns `[]` when every URL is a standard/advanced API. */
258export const googleAudienceWarningsForUrls = (
259 urls: readonly string[],
260): readonly GoogleOpenApiOAuthAudience[] => {
261 const seen = new Set<GoogleOpenApiOAuthAudience>();
262 for (const url of urls) {
263 const audience = googlePresetForDiscoveryUrl(url)?.oauthAudience;
264 if (audience === "workspace-admin" || audience === "unsupported-user") seen.add(audience);
265 }
266 return [...seen];
267};
packages/plugins/openapi/src/sdk/google-product-picker-scopes.test.ts+0−122
@@ -1,122 +0,0 @@
1// ---------------------------------------------------------------------------
2// GoogleProductPicker "View scopes" data source.
3//
4// The picker previews the OAuth consent BEFORE connecting by feeding each
5// selected preset's representative scopes through `googleOAuthConsentBatches`.
6// These tests pin that bridge: the consent-scope map is complete and the
7// previewed batches match what the user is about to grant (core services
8// collapsed into one batch; admin/cloud services split out).
9// ---------------------------------------------------------------------------
10
11import { expect, it } from "@effect/vitest";
12
13import {
14 googleAudienceWarningsForUrls,
15 googleOAuthConsentScopes,
16 googleOAuthConsentScopesForPreset,
17 googleOpenApiPresets,
18 googlePresetForDiscoveryUrl,
19 type GoogleOpenApiPreset,
20} from "./google-presets";
21import { googleOAuthConsentBatches } from "./google-oauth-batches";
22import { compactGoogleOAuthScopes } from "./google-oauth-scopes";
23
24const batchInputsFor = (presetIds: readonly string[]) =>
25 googleOpenApiPresets
26 .filter((preset: GoogleOpenApiPreset) => presetIds.includes(preset.id))
27 .map((preset: GoogleOpenApiPreset) => ({
28 id: preset.id,
29 name: preset.name,
30 oauthAudience: preset.oauthAudience,
31 scopes: googleOAuthConsentScopesForPreset(preset.id),
32 }));
33
34it("declares representative consent scopes for every Google preset", () => {
35 for (const preset of googleOpenApiPresets) {
36 expect(googleOAuthConsentScopesForPreset(preset.id).length).toBeGreaterThan(0);
37 }
38 // No stray keys for presets that no longer exist.
39 const presetIds = new Set(googleOpenApiPresets.map((preset) => preset.id));
40 for (const key of Object.keys(googleOAuthConsentScopes)) {
41 expect(presetIds.has(key)).toBe(true);
42 }
43});
44
45it("previews the featured selection as a single core-services consent batch", () => {
46 const featuredIds = googleOpenApiPresets
47 .filter((preset: GoogleOpenApiPreset) => preset.featured)
48 .map((preset: GoogleOpenApiPreset) => preset.id);
49
50 const batches = googleOAuthConsentBatches(batchInputsFor(featuredIds));
51
52 // The featured presets are all standard-user, so they collapse into one batch.
53 expect(batches.map((batch) => batch.id)).toEqual(["google-core"]);
54 expect(batches[0]?.apiScopes).toEqual([
55 "https://www.googleapis.com/auth/calendar",
56 "https://mail.google.com/",
57 "https://www.googleapis.com/auth/spreadsheets",
58 "https://www.googleapis.com/auth/drive",
59 "https://www.googleapis.com/auth/documents",
60 ]);
61});
62
63it("maps stored Discovery URLs back to presets and flags caution-tier audiences", () => {
64 // A normalized stored URL (trailing slash, sorted query) still resolves.
65 expect(
66 googlePresetForDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest/")
67 ?.id,
68 ).toBe("google-calendar");
69
70 // A bundle of only standard/advanced APIs raises no audience warning.
71 expect(
72 googleAudienceWarningsForUrls([
73 "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
74 "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest",
75 ]),
76 ).toEqual([]);
77
78 // Admin-only (Chat) and unsupported-consent (Keep) APIs both flag.
79 expect(
80 [
81 ...googleAudienceWarningsForUrls([
82 "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
83 "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest",
84 "https://keep.googleapis.com/$discovery/rest?version=v1",
85 ]),
86 ].sort(),
87 ).toEqual(["unsupported-user", "workspace-admin"]);
88});
89
90it("previews a consent set that matches what the bundle persists (compaction parity)", () => {
91 // The bundle converter runs the unioned Discovery scopes through
92 // `compactGoogleOAuthScopes` before persisting the auth template. The picker
93 // preview must agree: feeding every preview batch's scopes back through the
94 // same compaction is idempotent (already collapsed/filtered), so the previewed
95 // grant is exactly the persisted/requested grant.
96 const allPresetIds = googleOpenApiPresets.map((preset: GoogleOpenApiPreset) => preset.id);
97 const batches = googleOAuthConsentBatches(batchInputsFor(allPresetIds));
98 for (const batch of batches) {
99 expect(compactGoogleOAuthScopes(batch.apiScopes)).toEqual([...batch.apiScopes]);
100 }
101});
102
103it("splits a mixed selection into core + per-service consent batches", () => {
104 const batches = googleOAuthConsentBatches(
105 batchInputsFor(["google-calendar", "google-youtube-data", "google-bigquery"]),
106 );
107
108 expect(batches.map((batch) => ({ id: batch.id, apiScopes: batch.apiScopes }))).toEqual([
109 {
110 id: "google-core",
111 apiScopes: ["https://www.googleapis.com/auth/calendar"],
112 },
113 {
114 id: "google-youtube-data",
115 apiScopes: ["https://www.googleapis.com/auth/youtube"],
116 },
117 {
118 id: "google-cloud",
119 apiScopes: ["https://www.googleapis.com/auth/bigquery"],
120 },
121 ]);
122});
packages/plugins/openapi/src/sdk/index.ts+0−7
@@ -1,11 +1,4 @@
1export { parse, resolveSpecText, fetchSpecText } from "./parse";1export { parse, resolveSpecText, fetchSpecText } from "./parse";
2export {
3 convertGoogleDiscoveryBundleToOpenApi,
4 convertGoogleDiscoveryToOpenApi,
5 fetchGoogleDiscoveryDocument,
6 isGoogleDiscoveryUrl,
7 type GoogleDiscoveryOpenApiConversion,
8} from "./google-discovery";
9export { extract } from "./extract";2export { extract } from "./extract";
10export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";3export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke";
11export {4export {
packages/plugins/openapi/src/sdk/plugin.test.ts+0−1
@@ -105,7 +105,6 @@ const TestApi = HttpApi.make("testApi").add(ItemsGroup); · 104 unmodified lines
105const testApiSpecText = () => {105const testApiSpecText = () => {
106 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;106 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;
107 if (spec.kind === "blob") return spec.value;107 if (spec.kind === "blob") return spec.value;
108 if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? "";
109 return spec.url;108 return spec.url;
110};109};
111 110
packages/plugins/openapi/src/sdk/plugin.ts+14−133
@@ -23,12 +23,6 @@ import { · 22 unmodified lines
23import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";23import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config";
24import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";24import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors";
25import { parse, resolveSpecText } from "./parse";25import { parse, resolveSpecText } from "./parse";
26import {
27 convertGoogleDiscoveryBundleToOpenApi,
28 convertGoogleDiscoveryToOpenApi,
29 fetchGoogleDiscoveryDocument,
30 isGoogleDiscoveryUrl,
31} from "./google-discovery";
32import { extract } from "./extract";26import { extract } from "./extract";
33import { previewSpecText, type SpecPreview } from "./preview";27import { previewSpecText, type SpecPreview } from "./preview";
34import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";28import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
@@ -103,7 +97,7 @@ export interface UpdateSpecResult { · 68 unmodified lines
103 97
104export interface OpenApiUpdateSpecInput {98export interface OpenApiUpdateSpecInput {
105 /** New spec source. Omit to re-fetch from the integration's stored99 /** New spec source. Omit to re-fetch from the integration's stored
106 * `sourceUrl` / Google Discovery bundle URLs. */100 * `sourceUrl`. */
107 readonly spec?: OpenApiSpecInput;101 readonly spec?: OpenApiSpecInput;
108}102}
109 103
@@ -233,14 +227,6 @@ type StaticPreviewSpecOutput = typeof StaticPreviewSpecOutputSchema.Type; · 123 unmodified lines
233const OpenApiSpecInputSchema = Schema.Union([227const OpenApiSpecInputSchema = Schema.Union([
234 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),228 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
235 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),229 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
236 Schema.Struct({
237 kind: Schema.Literal("googleDiscovery"),
238 url: Schema.String,
239 }),
240 Schema.Struct({
241 kind: Schema.Literal("googleDiscoveryBundle"),
242 urls: Schema.Array(Schema.String),
243 }),
244]);230]);
245 231
246const AuthenticationSchema = Schema.Union([232const AuthenticationSchema = Schema.Union([
@@ -357,10 +343,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 110 unmodified lines
357});343});
358 344
359const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined =>345const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined =>
360 spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined;346 spec.kind === "url" ? spec.url : undefined;
361
362const specInputToGoogleBundle = (spec: OpenApiSpecInput): readonly string[] | undefined =>
363 spec.kind === "googleDiscoveryBundle" ? spec.urls : undefined;
364 347
365// ---------------------------------------------------------------------------348// ---------------------------------------------------------------------------
366// Declared auth methods - project the stored `authenticationTemplate` into the349// Declared auth methods - project the stored `authenticationTemplate` into the
@@ -410,20 +393,6 @@ export interface OpenApiPluginOptions { · 43 unmodified lines
410 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;393 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
411}394}
412 395
413const fetchGoogleDiscoveryBundleConversion = (
414 urls: readonly string[],
415 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
416) =>
417 Effect.forEach(
418 urls,
419 (url) =>
420 fetchGoogleDiscoveryDocument(url).pipe(
421 Effect.provide(httpClientLayer),
422 Effect.map((documentText) => ({ discoveryUrl: url, documentText })),
423 ),
424 { concurrency: 4 },
425 ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents })));
426
427export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {396export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
428 const resolveSpecForInput = (397 const resolveSpecForInput = (
429 spec: OpenApiSpecInput,398 spec: OpenApiSpecInput,
@@ -431,44 +400,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
431 ): Effect.Effect<400 ): Effect.Effect<
432 {401 {
433 readonly specText: string;402 readonly specText: string;
434 readonly baseUrl?: string;
435 // The Google Discovery converters derive the `googleOAuth2` oauth template
436 // straight from the spec's declared scopes. `addSpec` adopts it when the
437 // caller didn't pass an explicit `authenticationTemplate` (the bundle add
438 // path has no preview to detect auth from).
439 readonly authenticationTemplate?: readonly Authentication[];
440 },403 },
441 OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError404 OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError
442 > =>405 > =>
443 Effect.gen(function* () {406 Effect.gen(function* () {
444 if (spec.kind === "googleDiscovery") {
445 const conversion = yield* fetchGoogleDiscoveryDocument(spec.url).pipe(
446 Effect.provide(httpClientLayer),
447 Effect.flatMap((documentText) =>
448 convertGoogleDiscoveryToOpenApi({
449 discoveryUrl: spec.url,
450 documentText,
451 }),
452 ),
453 );
454 return {
455 specText: conversion.specText,
456 baseUrl: conversion.baseUrl,
457 ...(conversion.authenticationTemplate
458 ? { authenticationTemplate: conversion.authenticationTemplate }
459 : {}),
460 };
461 }
462 if (spec.kind === "googleDiscoveryBundle") {
463 const conversion = yield* fetchGoogleDiscoveryBundleConversion(spec.urls, httpClientLayer);
464 return {
465 specText: conversion.specText,
466 baseUrl: conversion.baseUrl,
467 ...(conversion.authenticationTemplate
468 ? { authenticationTemplate: conversion.authenticationTemplate }
469 : {}),
470 };
471 }
472 if (spec.kind === "url") {407 if (spec.kind === "url") {
473 const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer));408 const specText = yield* resolveSpecText(spec.url).pipe(Effect.provide(httpClientLayer));
474 return { specText };409 return { specText };
@@ -511,10 +446,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 36 unmodified lines
511 // end with nowhere to paste a credential)446 // end with nowhere to paste a credential)
512 // An explicit input always wins; for auth, an explicit EMPTY array447 // An explicit input always wins; for auth, an explicit EMPTY array
513 // means "no auth methods" and suppresses the derivation.448 // means "no auth methods" and suppresses the derivation.
514 const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl;449 const explicitBaseUrl = config.baseUrl;
515 const needsDerivedBaseUrl = explicitBaseUrl == null;450 const needsDerivedBaseUrl = explicitBaseUrl == null;
516 const needsDerivedAuth =451 const needsDerivedAuth = config.authenticationTemplate == null;
517 config.authenticationTemplate == null && resolved.authenticationTemplate == null;
518 const preview =452 const preview =
519 needsDerivedBaseUrl || needsDerivedAuth453 needsDerivedBaseUrl || needsDerivedAuth
520 ? yield* previewSpecText(resolved.specText)454 ? yield* previewSpecText(resolved.specText)
@@ -546,28 +480,21 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 25 unmodified lines
546 ...(specInputToSourceUrl(config.spec) !== undefined480 ...(specInputToSourceUrl(config.spec) !== undefined
547 ? { sourceUrl: specInputToSourceUrl(config.spec) }481 ? { sourceUrl: specInputToSourceUrl(config.spec) }
548 : {}),482 : {}),
549 ...(specInputToGoogleBundle(config.spec) !== undefined
550 ? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) }
551 : {}),
552 // baseUrl is an optional override only. The host is otherwise483 // baseUrl is an optional override only. The host is otherwise
553 // resolved per call from the operation's `servers` (extracted from484 // resolved per call from the operation's `servers` (extracted from
554 // the spec), so we never bake a derived base URL into the config.485 // the spec), so we never bake a derived base URL into the config.
555 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),486 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
556 ...(config.headers ? { headers: config.headers } : {}),487 ...(config.headers ? { headers: config.headers } : {}),
557 ...(config.queryParams ? { queryParams: config.queryParams } : {}),488 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
558 // Prefer the caller's explicit template; otherwise adopt the one the489 // Prefer the caller's explicit template; otherwise derive from the
559 // Google Discovery converter derived from the spec (the bundle add490 // spec's declared security schemes.
560 // path relies on this - it has no preview to detect auth from);
561 // otherwise derive from the spec's declared security schemes.
562 ...(config.authenticationTemplate491 ...(config.authenticationTemplate
563 ? {492 ? {
564 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),493 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
565 }494 }
566 : resolved.authenticationTemplate495 : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
567 ? { authenticationTemplate: resolved.authenticationTemplate }496 ? { authenticationTemplate: derivedAuthenticationTemplate }
568 : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0497 : {}),
569 ? { authenticationTemplate: derivedAuthenticationTemplate }
570 : {}),
571 };498 };
572 499
573 // The spec blob is written OUTSIDE the transaction: it's500 // The spec blob is written OUTSIDE the transaction: it's
@@ -585,9 +512,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 11 unmodified lines
585 config.description ?? compiled.description ?? compiled.title ?? config.slug,512 config.description ?? compiled.description ?? compiled.title ?? config.slug,
586 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,513 config: integrationConfig satisfies OpenApiIntegrationConfig as IntegrationConfig,
587 canRemove: true,514 canRemove: true,
588 canRefresh:515 canRefresh: specInputToSourceUrl(config.spec) != null,
589 specInputToSourceUrl(config.spec) != null ||
590 specInputToGoogleBundle(config.spec) != null,
591 });516 });
592 yield* ctx.storage.putOperations(517 yield* ctx.storage.putOperations(
593 config.slug,518 config.slug,
@@ -617,14 +542,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 23 unmodified lines
617 // where the spec originally came from. A pasted-blob integration has542 // where the spec originally came from. A pasted-blob integration has
618 // no origin, so updating it requires a new input.543 // no origin, so updating it requires a new input.
619 const specInput: OpenApiSpecInput | null =544 const specInput: OpenApiSpecInput | null =
620 input?.spec ??545 input?.spec ?? (current.sourceUrl ? { kind: "url", url: current.sourceUrl } : null);
621 (current.googleDiscoveryUrls
622 ? { kind: "googleDiscoveryBundle", urls: current.googleDiscoveryUrls }
623 : current.sourceUrl
624 ? isGoogleDiscoveryUrl(current.sourceUrl)
625 ? { kind: "googleDiscovery", url: current.sourceUrl }
626 : { kind: "url", url: current.sourceUrl }
627 : null);
628 if (specInput === null) {546 if (specInput === null) {
629 return yield* new OpenApiParseError({547 return yield* new OpenApiParseError({
630 message:548 message:
@@ -654,9 +572,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 23 unmodified lines
654 ...(specInputToSourceUrl(specInput) !== undefined572 ...(specInputToSourceUrl(specInput) !== undefined
655 ? { sourceUrl: specInputToSourceUrl(specInput) }573 ? { sourceUrl: specInputToSourceUrl(specInput) }
656 : {}),574 : {}),
657 ...(specInputToGoogleBundle(specInput) !== undefined
658 ? { googleDiscoveryUrls: specInputToGoogleBundle(specInput) }
659 : {}),
660 };575 };
661 576
662 yield* ctx.transaction(577 yield* ctx.transaction(
@@ -709,18 +624,9 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 46 unmodified lines
709 previewSpec: (input: string | OpenApiPreviewInput) =>624 previewSpec: (input: string | OpenApiPreviewInput) =>
710 Effect.gen(function* () {625 Effect.gen(function* () {
711 const previewInput = typeof input === "string" ? { spec: input } : input;626 const previewInput = typeof input === "string" ? { spec: input } : input;
712 const specText = isGoogleDiscoveryUrl(previewInput.spec)627 const specText = yield* resolveSpecText(previewInput.spec).pipe(
713 ? yield* fetchGoogleDiscoveryDocument(previewInput.spec).pipe(628 Effect.provide(httpClientLayer),
714 Effect.provide(httpClientLayer),629 );
715 Effect.flatMap((documentText) =>
716 convertGoogleDiscoveryToOpenApi({
717 discoveryUrl: previewInput.spec,
718 documentText,
719 }),
720 ),
721 Effect.map((conversion) => conversion.specText),
722 )
723 : yield* resolveSpecText(previewInput.spec).pipe(Effect.provide(httpClientLayer));
724 return yield* previewSpecText(specText);630 return yield* previewSpecText(specText);
725 }),631 }),
726 632
@@ -916,31 +822,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 189 unmodified lines
916 catch: (error) => error,822 catch: (error) => error,
917 }).pipe(Effect.option);823 }).pipe(Effect.option);
918 if (Option.isNone(parsed)) return null;824 if (Option.isNone(parsed)) return null;
919 if (isGoogleDiscoveryUrl(trimmed)) {
920 const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe(
921 Effect.provide(httpClientLayer),
922 Effect.flatMap((documentText) =>
923 convertGoogleDiscoveryToOpenApi({
924 discoveryUrl: trimmed,
925 documentText,
926 }),
927 ),
928 Effect.catch(() => Effect.succeed(null)),
929 );
930 if (conversion) {
931 return IntegrationDetectionResult.make({
932 kind: "openapi",
933 confidence: "high",
934 endpoint: trimmed,
935 name: conversion.title,
936 slug:
937 conversion.title
938 .toLowerCase()
939 .replace(/[^a-z0-9]+/g, "_")
940 .replace(/^_+|_+$/g, "") || `google_${conversion.service}`,
941 });
942 }
943 }
944 const specText = yield* resolveSpecText(trimmed).pipe(825 const specText = yield* resolveSpecText(trimmed).pipe(
945 Effect.provide(httpClientLayer),826 Effect.provide(httpClientLayer),
946 Effect.catch(() => Effect.succeed(null)),827 Effect.catch(() => Effect.succeed(null)),
packages/plugins/openapi/src/sdk/spec-blob.test.ts+0−1
@@ -62,7 +62,6 @@ const EchoGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 61 unmodified lines
62const specText = () => {62const specText = () => {
63 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;63 const spec = makeOpenApiHttpApiTestSourceConfig(TestApi, {}).spec;
64 if (spec.kind === "blob") return spec.value;64 if (spec.kind === "blob") return spec.value;
65 if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? "";
66 return spec.url;65 return spec.url;
67};66};
68 67
packages/react/src/components/integration-favicon.test.tsx+4−4
@@ -41,8 +41,8 @@ describe("IntegrationFavicon", () => { · 40 unmodified lines
41 },41 },
42 [42 [
43 {43 {
44 key: "openapi",44 key: "google",
45 label: "OpenAPI",45 label: "Google",
46 add: () => null,46 add: () => null,
47 edit: () => null,47 edit: () => null,
48 presets: [48 presets: [
@@ -70,8 +70,8 @@ describe("IntegrationFavicon", () => { · 21 unmodified lines
70 },70 },
71 [71 [
72 {72 {
73 key: "openapi",73 key: "google",
74 label: "OpenAPI",74 label: "Google",
75 add: () => null,75 add: () => null,
76 edit: () => null,76 edit: () => null,
77 presets: [77 presets: [
packages/react/src/components/integration-favicon.tsx+1−1
@@ -24,7 +24,7 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 23 unmodified lines
24 openapi: "openapi",24 openapi: "openapi",
25 mcp: "mcp",25 mcp: "mcp",
26 graphql: "graphql",26 graphql: "graphql",
27 googleDiscovery: "openapi",27 googleDiscovery: "google",
28};28};
29 29
30const normalizeUrl = (url: string | undefined): string | null => {30const normalizeUrl = (url: string | undefined): string | null => {
packages/react/src/pages/integrations.tsx+1−1
@@ -44,7 +44,7 @@ const KIND_TO_PLUGIN_KEY: Record<string, string> = { · 43 unmodified lines
44 openapi: "openapi",44 openapi: "openapi",
45 mcp: "mcp",45 mcp: "mcp",
46 graphql: "graphql",46 graphql: "graphql",
47 googleDiscovery: "openapi",47 googleDiscovery: "google",
48};48};
49 49
50const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {50const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = {
tests/presets-reachable.test.ts+1−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10 convertGoogleDiscoveryToOpenApi,10 convertGoogleDiscoveryToOpenApi,
11 fetchGoogleDiscoveryDocument,11 fetchGoogleDiscoveryDocument,
12 isGoogleDiscoveryUrl,12 isGoogleDiscoveryUrl,
13} from "../packages/plugins/openapi/src/sdk/google-discovery";13} from "../packages/plugins/google/src/sdk/discovery";
14import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin";14import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin";
15import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin";15import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin";
16import { introspect } from "../packages/plugins/graphql/src/sdk/introspect";16import { introspect } from "../packages/plugins/graphql/src/sdk/introspect";