Unified · Split
| @@ -0,0 +1,86 @@ | |||
| 1 | CREATE TEMP TABLE "__google_openapi_integrations" AS | ||
| 2 | SELECT | ||
| 3 | "tenant", | ||
| 4 | "slug", | ||
| 5 | "config"::jsonb ->> 'specHash' AS "spec_hash" | ||
| 6 | FROM "integration" | ||
| 7 | WHERE "plugin_id" = 'openapi' | ||
| 8 | AND "config" IS NOT NULL | ||
| 9 | AND jsonb_typeof("config"::jsonb -> 'googleDiscoveryUrls') = 'array';--> statement-breakpoint | ||
| 10 | INSERT INTO "blob" ("namespace", "key", "value", "row_id", "id") | ||
| 11 | SELECT | ||
| 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" | ||
| 17 | FROM "__google_openapi_integrations" g | ||
| 18 | JOIN "blob" b | ||
| 19 | ON b."namespace" = 'o:' || g."tenant" || '/openapi' | ||
| 20 | AND b."key" = 'spec/' || g."spec_hash" | ||
| 21 | WHERE g."spec_hash" IS NOT NULL | ||
| 22 | AND g."spec_hash" <> '' | ||
| 23 | ON CONFLICT ("id") DO NOTHING;--> statement-breakpoint | ||
| 24 | INSERT INTO "plugin_storage" | ||
| 25 | ("tenant", "owner", "subject", "plugin_id", "collection", "key", "data", "created_at", "updated_at", "row_id") | ||
| 26 | SELECT | ||
| 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") | ||
| 37 | FROM "plugin_storage" ps | ||
| 38 | JOIN "__google_openapi_integrations" g | ||
| 39 | ON g."tenant" = ps."tenant" | ||
| 40 | WHERE 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 | ) | ||
| 46 | ON CONFLICT ("tenant", "owner", "subject", "plugin_id", "collection", "key") | ||
| 47 | DO UPDATE SET | ||
| 48 | "data" = EXCLUDED."data", | ||
| 49 | "updated_at" = EXCLUDED."updated_at";--> statement-breakpoint | ||
| 50 | DELETE FROM "plugin_storage" ps | ||
| 51 | USING "__google_openapi_integrations" g | ||
| 52 | WHERE 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 | ||
| 59 | UPDATE "tool" | ||
| 60 | SET "plugin_id" = 'google' | ||
| 61 | WHERE "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 | ||
| 68 | UPDATE "definition" | ||
| 69 | SET "plugin_id" = 'google' | ||
| 70 | WHERE "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 | ||
| 77 | UPDATE "integration" | ||
| 78 | SET "plugin_id" = 'google' | ||
| 79 | WHERE "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 | ||
| 86 | DROP TABLE "__google_openapi_integrations"; | ||
| @@ -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 | } | ||
| @@ -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": true | 45 | "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 | } |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store"; | 13 | import { makeR2BlobStore } from "@executor-js/cloudflare/blob-store"; |
| 14 | 14 | ||
| 15 | import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config"; | 15 | import { CLOUDFLARE_NAMESPACE, CLOUDFLARE_SCHEMA_VERSION } from "../config"; |
| 16 | import { 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 run | 45 | // 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, so | 50 | // `interactiveTransactions: false` — D1 rejects interactive transactions, so |
| 49 | // the fuma adapter runs transaction callbacks directly (auto-commit per | 51 | // the fuma adapter runs transaction callbacks directly (auto-commit per |
| @@ -0,0 +1,153 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import type { D1Database, R2Bucket } from "@cloudflare/workers-types"; | ||
| 4 | |||
| 5 | import { collectTables, type SqliteDataMigrationClient } from "@executor-js/sdk"; | ||
| 6 | import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; | ||
| 7 | |||
| 8 | import { runCloudflareDataMigrations } from "./data-migrations"; | ||
| 9 | |||
| 10 | const now = 1_780_000_000_000; | ||
| 11 | |||
| 12 | const 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 | |||
| 35 | const 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 | |||
| 51 | const 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 | |||
| 78 | const 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 | |||
| 105 | describe("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 | }); | ||
| @@ -0,0 +1,90 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import type { D1Database, D1DatabaseSession, R2Bucket } from "@cloudflare/workers-types"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | DataMigrationError, | ||
| 6 | runSqliteDataMigrations, | ||
| 7 | type SqliteDataMigration, | ||
| 8 | type SqliteDataMigrationClient, | ||
| 9 | } from "@executor-js/sdk"; | ||
| 10 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google"; | ||
| 11 | |||
| 12 | const TX_CONTROL = new Set(["BEGIN", "BEGIN TRANSACTION", "COMMIT", "ROLLBACK"]); | ||
| 13 | |||
| 14 | const firstWord = (sql: string): string => sql.trimStart().split(/\s+/, 1)[0]?.toUpperCase() ?? ""; | ||
| 15 | |||
| 16 | const 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 | |||
| 29 | export 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 | |||
| 41 | const r2ObjectName = (tenant: string, pluginId: string, key: string): string => | ||
| 42 | `o:${tenant}/${pluginId}/${key}`; | ||
| 43 | |||
| 44 | const 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 | |||
| 73 | const 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 | |||
| 84 | export const runCloudflareDataMigrations = ( | ||
| 85 | db: D1Database, | ||
| 86 | bucket: R2Bucket | undefined, | ||
| 87 | ): Promise<readonly string[]> => | ||
| 88 | Effect.runPromise( | ||
| 89 | runSqliteDataMigrations(d1DataMigrationClient(db), cloudflareDataMigrations(bucket)), | ||
| 90 | ); | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 2 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | ||
| 2 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 3 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 3 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 4 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 4 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 5 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -17,6 +18,7 @@ import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; · 12 unmodified lines | |||
| 17 | export const makeCloudflarePlugins = (secretKey: string) => | 18 | export 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 }), |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | openApiSpecBlobDataMigration, | 12 | openApiSpecBlobDataMigration, |
| 13 | } from "@executor-js/plugin-openapi"; | 13 | } from "@executor-js/plugin-openapi"; |
| 14 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; | 14 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; |
| 15 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google"; | ||
| 15 | 16 | ||
| 16 | import { authConfigTransforms } from "./auth-config-migration"; | 17 | import { 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 | ]; |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | openApiSpecBlobDataMigration, | 12 | openApiSpecBlobDataMigration, |
| 13 | } from "@executor-js/plugin-openapi"; | 13 | } from "@executor-js/plugin-openapi"; |
| 14 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; | 14 | import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql"; |
| 15 | import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-google"; | ||
| 15 | 16 | ||
| 16 | import { authConfigTransforms } from "./auth-config-migration"; | 17 | import { authConfigTransforms } from "./auth-config-migration"; |
| 17 | import { LOCAL_V1_V2_LEDGER_NAME } from "./v1-v2-migration"; | 18 | import { 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 | ]; |
| @@ -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:*", |
| @@ -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:*", |
| @@ -33,6 +33,7 @@ import { · 32 unmodified lines | |||
| 33 | } from "@executor-js/sdk"; | 33 | } from "@executor-js/sdk"; |
| 34 | 34 | ||
| 35 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | 35 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 36 | import { googlePlugin } from "@executor-js/plugin-google"; | ||
| 36 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; | 37 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; |
| 37 | import { keychainPlugin } from "@executor-js/plugin-keychain"; | 38 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| 38 | import { mcpPlugin } from "@executor-js/plugin-mcp"; | 39 | import { mcpPlugin } from "@executor-js/plugin-mcp"; |
| @@ -77,6 +78,7 @@ const plugins = [ · 38 unmodified lines | |||
| 77 | // Integration plugins — these declare their own schemas (tables), register | 78 | // 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 | // ------------------------------------------------------------------------- |
| @@ -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": { |
| @@ -0,0 +1 @@ | |||
| 1 | # @executor-js/plugin-google | ||
| @@ -0,0 +1,24 @@ | |||
| 1 | import { Option, Schema } from "effect"; | ||
| 2 | import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi"; | ||
| 3 | |||
| 4 | export 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 | |||
| 14 | export type GoogleIntegrationConfig = Omit< | ||
| 15 | typeof GoogleIntegrationConfigSchema.Type, | ||
| 16 | "authenticationTemplate" | ||
| 17 | > & { | ||
| 18 | readonly authenticationTemplate?: readonly Authentication[]; | ||
| 19 | }; | ||
| 20 | |||
| 21 | const decodeConfig = Schema.decodeUnknownOption(GoogleIntegrationConfigSchema); | ||
| 22 | |||
| 23 | export const decodeGoogleIntegrationConfig = (value: unknown): GoogleIntegrationConfig | null => | ||
| 24 | Option.getOrNull(decodeConfig(value)) as GoogleIntegrationConfig | null; | ||
| @@ -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"; |
| 39 | export { | ||
| 40 | googleOpenApiOwnershipDataMigration, | ||
| 41 | runSqliteGoogleOpenApiOwnershipMigration, | ||
| 42 | } from "./openapi-ownership-migration"; | ||
| @@ -0,0 +1,219 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { collectTables } from "@executor-js/sdk"; | ||
| 4 | import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; | ||
| 5 | import type { SqliteDataMigrationClient } from "@executor-js/sdk/core"; | ||
| 6 | |||
| 7 | import { runSqliteGoogleOpenApiOwnershipMigration } from "./openapi-ownership-migration"; | ||
| 8 | |||
| 9 | const now = 1_780_000_000_000; | ||
| 10 | |||
| 11 | const 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 | |||
| 38 | const 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 | |||
| 57 | const 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 | |||
| 85 | const 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 | |||
| 96 | const 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 | |||
| 107 | describe("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 | }); | ||
| @@ -0,0 +1,148 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core"; | ||
| 3 | |||
| 4 | const MIGRATION_NAME = "2026-06-20-google-openapi-ownership"; | ||
| 5 | const 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 | |||
| 10 | const 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 | |||
| 19 | export 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 | |||
| 144 | export const googleOpenApiOwnershipDataMigration = { | ||
| 145 | name: MIGRATION_NAME, | ||
| 146 | run: (client: SqliteDataMigrationClient) => | ||
| 147 | runSqliteGoogleOpenApiOwnershipMigration(client).pipe(Effect.asVoid), | ||
| 148 | }; | ||
| @@ -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"; |
| 19 | import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | 19 | import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; |
| 20 | import { | 20 | import { |
| 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 | ||
| 38 | import { | 33 | import { |
| @@ -40,6 +35,7 @@ import { · 1 unmodified line | |||
| 40 | fetchGoogleDiscoveryDocument, | 35 | fetchGoogleDiscoveryDocument, |
| 41 | isGoogleDiscoveryUrl, | 36 | isGoogleDiscoveryUrl, |
| 42 | } from "./discovery"; | 37 | } from "./discovery"; |
| 38 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; | ||
| 43 | import { googleOpenApiBundlePreset } from "./presets"; | 39 | import { googleOpenApiBundlePreset } from "./presets"; |
| 44 | 40 | ||
| 45 | export interface GoogleBundleConfig { | 41 | export 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 | ||
| 69 | export 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 | |||
| 102 | export interface GooglePluginOptions { | 65 | export 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 | ||
| 126 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { | 89 | const 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 | ||
| 149 | const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => { | 112 | const 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 | ||
| 154 | export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ | 117 | const 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.connections | 192 | |
| 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.integrations | 234 | 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 | record | 242 | 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.integrations | 253 | 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 | |||
| 288 | export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>; | ||
| 289 | |||
| 290 | export 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, |
| @@ -48,14 +48,6 @@ const SlugParams = { · 47 unmodified lines | |||
| 48 | const OpenApiSpecInputPayload = Schema.Union([ | 48 | const 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 | ||
| 61 | const OAuthTemplatePayload = Schema.Struct({ | 53 | const 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). |
| 135 | const OpenApiConfigView = Schema.Struct({ | 127 | const 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)), |
| @@ -91,9 +91,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 90 unmodified lines | |||
| 91 | return config | 91 | 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, |
| @@ -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 = config | 55 | 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 to | 57 | // 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. |
| @@ -1,82 +0,0 @@ | |||
| 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html | ||
| 2 | |||
| 3 | exports[`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 | `; | ||
| @@ -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). */ |
| @@ -68,7 +68,6 @@ const servePluginTestApi = () => · 67 unmodified lines | |||
| 68 | const specText = () => { | 68 | const 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 | ||
| @@ -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 | |||
| 15 | import { describe, expect, it } from "@effect/vitest"; | ||
| 16 | import { Effect, Layer } from "effect"; | ||
| 17 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | ConnectionName, | ||
| 21 | IntegrationSlug, | ||
| 22 | createExecutor, | ||
| 23 | AuthTemplateSlug, | ||
| 24 | } from "@executor-js/sdk"; | ||
| 25 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 26 | |||
| 27 | import { 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 | |||
| 35 | const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"; | ||
| 36 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; | ||
| 37 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; | ||
| 38 | |||
| 39 | const 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 | |||
| 73 | const 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 | |||
| 113 | const 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 | |||
| 145 | const toJson = (value: unknown): string => JSON.stringify(value); | ||
| 146 | |||
| 147 | const 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). | ||
| 155 | const 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 | |||
| 174 | const bundlePlugins = () => | ||
| 175 | [ | ||
| 176 | openApiPlugin({ httpClientLayer: discoveryHttpClientLayer }), | ||
| 177 | memoryCredentialsPlugin(), | ||
| 178 | ] as const; | ||
| 179 | |||
| 180 | describe("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 | }); | ||
| @@ -1,604 +0,0 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Option, Schema } from "effect"; | ||
| 3 | import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | convertGoogleDiscoveryBundleToOpenApi, | ||
| 7 | convertGoogleDiscoveryToOpenApi, | ||
| 8 | } from "./google-discovery"; | ||
| 9 | import { extract } from "./extract"; | ||
| 10 | import { parse } from "./parse"; | ||
| 11 | |||
| 12 | const 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 | |||
| 37 | const 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 | |||
| 46 | const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); | ||
| 47 | |||
| 48 | const 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 | |||
| 61 | it.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 | |||
| 251 | it.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 | |||
| 463 | const 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 | }); | ||
| 483 | const decodeConvertedSpecSecurity = Schema.decodeUnknownSync( | ||
| 484 | Schema.fromJsonString(ConvertedSpecSecurity), | ||
| 485 | ); | ||
| 486 | |||
| 487 | it.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 | ); | ||
| @@ -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. | ||
| 5 | import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect"; | ||
| 6 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 7 | |||
| 8 | import { OpenApiParseError } from "./errors"; | ||
| 9 | import { compactGoogleOAuthScopes } from "./google-oauth-scopes"; | ||
| 10 | import type { SpecFetchCredentials } from "./parse"; | ||
| 11 | import type { Authentication } from "./types"; | ||
| 12 | import { AuthTemplateSlug } from "@executor-js/sdk/shared"; | ||
| 13 | |||
| 14 | const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; | ||
| 15 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | ||
| 16 | const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; | ||
| 17 | const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; | ||
| 18 | const OPENAPI_SCHEMA_TYPES = new Set([ | ||
| 19 | "array", | ||
| 20 | "boolean", | ||
| 21 | "integer", | ||
| 22 | "null", | ||
| 23 | "number", | ||
| 24 | "object", | ||
| 25 | "string", | ||
| 26 | ]); | ||
| 27 | |||
| 28 | type JsonPrimitive = string | number | boolean | null; | ||
| 29 | type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; | ||
| 30 | |||
| 31 | type 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 | |||
| 46 | type 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 | |||
| 57 | type 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 | |||
| 87 | type 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 | |||
| 129 | const 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 | ); | ||
| 136 | const TextArray = Schema.optional(Schema.Array(Schema.String)).pipe( | ||
| 137 | Schema.withDecodingDefaultType(Effect.succeed([] as string[])), | ||
| 138 | ); | ||
| 139 | const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); | ||
| 140 | const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe( | ||
| 141 | Schema.withDecodingDefaultType(Effect.succeed({})), | ||
| 142 | ); | ||
| 143 | |||
| 144 | const 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 | }); | ||
| 159 | type DiscoveryParameter = typeof DiscoveryParameter.Type; | ||
| 160 | |||
| 161 | const DiscoveryRef = Schema.Struct({ | ||
| 162 | $ref: Schema.optional(Schema.String), | ||
| 163 | }); | ||
| 164 | |||
| 165 | const 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 | }); | ||
| 175 | type DiscoveryMethod = typeof DiscoveryMethod.Type; | ||
| 176 | |||
| 177 | const DiscoveryResource = Schema.Struct({ | ||
| 178 | methods: UnknownRecordWithDefault, | ||
| 179 | resources: UnknownRecordWithDefault, | ||
| 180 | }); | ||
| 181 | |||
| 182 | const 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 | }); | ||
| 211 | type DiscoveryDocument = typeof DiscoveryDocument.Type; | ||
| 212 | |||
| 213 | export 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 | |||
| 225 | const decodeDiscoveryDocument = Schema.decodeUnknownSync(DiscoveryDocument); | ||
| 226 | const decodeDiscoveryParameter = Schema.decodeUnknownSync(DiscoveryParameter); | ||
| 227 | const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); | ||
| 228 | const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); | ||
| 229 | const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 230 | |||
| 231 | const 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 | |||
| 250 | export 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 | |||
| 259 | export 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 | |||
| 296 | const schemaRef = (name: string) => `#/components/schemas/${name}`; | ||
| 297 | |||
| 298 | const identitySchemaName = (name: string): string => name; | ||
| 299 | |||
| 300 | const schemaComponentPart = (value: string): string => | ||
| 301 | value | ||
| 302 | .trim() | ||
| 303 | .replace(/[^A-Za-z0-9._-]+/g, "_") | ||
| 304 | .replace(/^_+|_+$/g, "") || "schema"; | ||
| 305 | |||
| 306 | const normalizeDiscoveryPathTemplate = (pathTemplate: string): string => | ||
| 307 | pathTemplate.replaceAll(/\{\+([^{}]+)\}/g, "{$1}"); | ||
| 308 | |||
| 309 | const pathUsesReservedExpansion = (pathTemplate: string, parameterName: string): boolean => | ||
| 310 | pathTemplate.includes(`{+${parameterName}}`); | ||
| 311 | |||
| 312 | const 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 | |||
| 326 | const 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 | |||
| 335 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 336 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 337 | |||
| 338 | const 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 | |||
| 359 | const 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 | |||
| 365 | const schemaType = (value: unknown): OpenApiSchemaObject["type"] | undefined => | ||
| 366 | typeof value === "string" && OPENAPI_SCHEMA_TYPES.has(value) | ||
| 367 | ? (value as OpenApiSchemaObject["type"]) | ||
| 368 | : undefined; | ||
| 369 | |||
| 370 | const 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 | |||
| 440 | const 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 | |||
| 453 | const methodToolPath = (service: string, methodId: string): string => | ||
| 454 | methodId.startsWith(`${service}.`) ? methodId.slice(service.length + 1) : methodId; | ||
| 455 | |||
| 456 | const 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 | |||
| 463 | const 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. | ||
| 480 | const 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 | |||
| 492 | const allDiscoveryMethods = (document: DiscoveryDocument): DiscoveryMethod[] => [ | ||
| 493 | ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)), | ||
| 494 | ...Object.values(document.resources ?? {}).flatMap(collectMethods), | ||
| 495 | ]; | ||
| 496 | |||
| 497 | type 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 | |||
| 507 | const 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 | |||
| 532 | const 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 | |||
| 611 | const 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. */ | ||
| 616 | const 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 | |||
| 633 | export 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 | |||
| 732 | export 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 | }); | ||
| @@ -1,98 +0,0 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { googleOAuthConsentBatches } from "./google-oauth-batches"; | ||
| 4 | |||
| 5 | it("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 | |||
| 33 | it("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 | }); | ||
| @@ -1,69 +0,0 @@ | |||
| 1 | import type { GoogleOpenApiOAuthAudience } from "./google-presets"; | ||
| 2 | import { compactGoogleOAuthScopes } from "./google-oauth-scopes"; | ||
| 3 | |||
| 4 | export type GoogleOAuthBatchInput = { | ||
| 5 | readonly id: string; | ||
| 6 | readonly name: string; | ||
| 7 | readonly oauthAudience: GoogleOpenApiOAuthAudience; | ||
| 8 | readonly scopes: readonly string[]; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export type GoogleOAuthConsentBatch = { | ||
| 12 | readonly id: string; | ||
| 13 | readonly label: string; | ||
| 14 | readonly apiScopes: readonly string[]; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]); | ||
| 18 | |||
| 19 | export 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 | }; | ||
| @@ -1,37 +0,0 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | compactGoogleOAuthScopes, | ||
| 5 | filterGoogleUserConsentOAuthScopes, | ||
| 6 | } from "./google-oauth-scopes"; | ||
| 7 | |||
| 8 | it("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 | |||
| 25 | it("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 | }); | ||
| @@ -1,66 +0,0 @@ | |||
| 1 | const 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 | |||
| 8 | const googleUserConsentBlockedScopePrefixes = ["https://www.googleapis.com/auth/chat.app."]; | ||
| 9 | |||
| 10 | const 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 | |||
| 28 | const 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 | |||
| 35 | const 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 | |||
| 47 | export const isGoogleUserConsentOAuthScope = (scope: string): boolean => | ||
| 48 | !googleUserConsentBlockedScopes.has(scope) && | ||
| 49 | !googleUserConsentBlockedScopePrefixes.some((prefix) => scope.startsWith(prefix)); | ||
| 50 | |||
| 51 | export const filterGoogleUserConsentOAuthScopes = (scopes: Iterable<string>): string[] => | ||
| 52 | orderedUniqueScopes(scopes).filter(isGoogleUserConsentOAuthScope); | ||
| 53 | |||
| 54 | export 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 | }; | ||
| @@ -1,29 +0,0 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./google-presets"; | ||
| 4 | |||
| 5 | it("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 | |||
| 22 | it("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 | }); | ||
| @@ -1,267 +0,0 @@ | |||
| 1 | import type { OpenApiPreset } from "./presets"; | ||
| 2 | |||
| 3 | export type GoogleOpenApiOAuthAudience = | ||
| 4 | | "standard-user" | ||
| 5 | | "advanced-user" | ||
| 6 | | "workspace-admin" | ||
| 7 | | "unsupported-user"; | ||
| 8 | |||
| 9 | export type GoogleOpenApiPreset = OpenApiPreset & { | ||
| 10 | readonly oauthAudience: GoogleOpenApiOAuthAudience; | ||
| 11 | }; | ||
| 12 | |||
| 13 | const gd = (service: string, version: string) => | ||
| 14 | `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; | ||
| 15 | |||
| 16 | const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | ||
| 17 | export const GOOGLE_BUNDLE_PRESET_ID = "google"; | ||
| 18 | |||
| 19 | export 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 | |||
| 27 | export 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 | |||
| 187 | export 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 | |||
| 205 | export 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 | |||
| 227 | export 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 | |||
| 237 | const 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 | |||
| 246 | const googlePresetsByNormalizedUrl: ReadonlyMap<string, GoogleOpenApiPreset> = new Map( | ||
| 247 | googleOpenApiPresets.flatMap((preset) => | ||
| 248 | preset.url ? [[normalizeGooglePresetUrl(preset.url), preset] as const] : [], | ||
| 249 | ), | ||
| 250 | ); | ||
| 251 | |||
| 252 | export 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. */ | ||
| 258 | export 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 | }; | ||
| @@ -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 | |||
| 11 | import { expect, it } from "@effect/vitest"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | googleAudienceWarningsForUrls, | ||
| 15 | googleOAuthConsentScopes, | ||
| 16 | googleOAuthConsentScopesForPreset, | ||
| 17 | googleOpenApiPresets, | ||
| 18 | googlePresetForDiscoveryUrl, | ||
| 19 | type GoogleOpenApiPreset, | ||
| 20 | } from "./google-presets"; | ||
| 21 | import { googleOAuthConsentBatches } from "./google-oauth-batches"; | ||
| 22 | import { compactGoogleOAuthScopes } from "./google-oauth-scopes"; | ||
| 23 | |||
| 24 | const 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 | |||
| 34 | it("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 | |||
| 45 | it("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 | |||
| 63 | it("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 | |||
| 90 | it("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 | |||
| 103 | it("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 | }); | ||
| @@ -1,11 +1,4 @@ | |||
| 1 | export { parse, resolveSpecText, fetchSpecText } from "./parse"; | 1 | export { parse, resolveSpecText, fetchSpecText } from "./parse"; |
| 2 | export { | ||
| 3 | convertGoogleDiscoveryBundleToOpenApi, | ||
| 4 | convertGoogleDiscoveryToOpenApi, | ||
| 5 | fetchGoogleDiscoveryDocument, | ||
| 6 | isGoogleDiscoveryUrl, | ||
| 7 | type GoogleDiscoveryOpenApiConversion, | ||
| 8 | } from "./google-discovery"; | ||
| 9 | export { extract } from "./extract"; | 2 | export { extract } from "./extract"; |
| 10 | export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke"; | 3 | export { invoke, invokeWithLayer, annotationsForOperation } from "./invoke"; |
| 11 | export { | 4 | export { |
| @@ -105,7 +105,6 @@ const TestApi = HttpApi.make("testApi").add(ItemsGroup); · 104 unmodified lines | |||
| 105 | const testApiSpecText = () => { | 105 | const 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 | ||
| @@ -23,12 +23,6 @@ import { · 22 unmodified lines | |||
| 23 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; | 23 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; |
| 24 | import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; | 24 | import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; |
| 25 | import { parse, resolveSpecText } from "./parse"; | 25 | import { parse, resolveSpecText } from "./parse"; |
| 26 | import { | ||
| 27 | convertGoogleDiscoveryBundleToOpenApi, | ||
| 28 | convertGoogleDiscoveryToOpenApi, | ||
| 29 | fetchGoogleDiscoveryDocument, | ||
| 30 | isGoogleDiscoveryUrl, | ||
| 31 | } from "./google-discovery"; | ||
| 32 | import { extract } from "./extract"; | 26 | import { extract } from "./extract"; |
| 33 | import { previewSpecText, type SpecPreview } from "./preview"; | 27 | import { previewSpecText, type SpecPreview } from "./preview"; |
| 34 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; | 28 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; |
| @@ -103,7 +97,7 @@ export interface UpdateSpecResult { · 68 unmodified lines | |||
| 103 | 97 | ||
| 104 | export interface OpenApiUpdateSpecInput { | 98 | export interface OpenApiUpdateSpecInput { |
| 105 | /** New spec source. Omit to re-fetch from the integration's stored | 99 | /** 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 | |||
| 233 | const OpenApiSpecInputSchema = Schema.Union([ | 227 | const 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 | ||
| 246 | const AuthenticationSchema = Schema.Union([ | 232 | const AuthenticationSchema = Schema.Union([ |
| @@ -357,10 +343,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 110 unmodified lines | |||
| 357 | }); | 343 | }); |
| 358 | 344 | ||
| 359 | const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined => | 345 | const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined => |
| 360 | spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined; | 346 | spec.kind === "url" ? spec.url : undefined; |
| 361 | |||
| 362 | const 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 the | 349 | // 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 | ||
| 413 | const 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 | |||
| 427 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { | 396 | export 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 | OpenApiOAuthError | 404 | 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 array | 447 | // 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 || needsDerivedAuth | 453 | 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) !== undefined | 480 | ...(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 otherwise | 483 | // baseUrl is an optional override only. The host is otherwise |
| 553 | // resolved per call from the operation's `servers` (extracted from | 484 | // 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 the | 489 | // Prefer the caller's explicit template; otherwise derive from the |
| 559 | // Google Discovery converter derived from the spec (the bundle add | 490 | // 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.authenticationTemplate | 491 | ...(config.authenticationTemplate |
| 563 | ? { | 492 | ? { |
| 564 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), | 493 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), |
| 565 | } | 494 | } |
| 566 | : resolved.authenticationTemplate | 495 | : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 |
| 567 | ? { authenticationTemplate: resolved.authenticationTemplate } | 496 | ? { authenticationTemplate: derivedAuthenticationTemplate } |
| 568 | : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 | 497 | : {}), |
| 569 | ? { authenticationTemplate: derivedAuthenticationTemplate } | ||
| 570 | : {}), | ||
| 571 | }; | 498 | }; |
| 572 | 499 | ||
| 573 | // The spec blob is written OUTSIDE the transaction: it's | 500 | // 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 has | 542 | // 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) !== undefined | 572 | ...(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)), |
| @@ -62,7 +62,6 @@ const EchoGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 61 unmodified lines | |||
| 62 | const specText = () => { | 62 | const 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 | ||
| @@ -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: [ |
| @@ -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 | ||
| 30 | const normalizeUrl = (url: string | undefined): string | null => { | 30 | const normalizeUrl = (url: string | undefined): string | null => { |
| @@ -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 | ||
| 50 | const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { | 50 | const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { |
| @@ -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"; |
| 14 | import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin"; | 14 | import { mcpPlugin } from "../packages/plugins/mcp/src/sdk/plugin"; |
| 15 | import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin"; | 15 | import { graphqlPlugin } from "../packages/plugins/graphql/src/sdk/plugin"; |
| 16 | import { introspect } from "../packages/plugins/graphql/src/sdk/introspect"; | 16 | import { introspect } from "../packages/plugins/graphql/src/sdk/introspect"; |