* Add Google multi-service source flow * Add bundled Google source flow * Use typed OpenAPI server objects * Fix Google bundle CI checks
Unified · Split
| @@ -37,6 +37,10 @@ const OpenApiSpecInputPayload = Schema.Union([ · 36 unmodified lines | ||
| 37 | 37 | Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), |
| 38 | 38 | Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), |
| 39 | 39 | Schema.Struct({ kind: Schema.Literal("googleDiscovery"), url: Schema.String }), |
| 40 | + Schema.Struct({ | |
| 41 | + kind: Schema.Literal("googleDiscoveryBundle"), | |
| 42 | + urls: Schema.Array(Schema.String), | |
| 43 | + }), | |
| 40 | 44 | ]); |
| 41 | 45 | |
| 42 | 46 | const PreviewSpecFetchCredentialsPayload = Schema.Struct({ |
| @@ -97,6 +97,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 96 unmodified lines | ||
| 97 | 97 | name: source.name, |
| 98 | 98 | config: { |
| 99 | 99 | sourceUrl: source.config.sourceUrl, |
| 100 | + googleDiscoveryUrls: source.config.googleDiscoveryUrls, | |
| 100 | 101 | baseUrl: source.config.baseUrl, |
| 101 | 102 | namespace: source.config.namespace, |
| 102 | 103 | headers: source.config.headers, |
| @@ -65,6 +65,7 @@ import { · 64 unmodified lines | ||
| 65 | 65 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 66 | 66 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 67 | 67 | import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; |
| 68 | +import { Info, InfoDescription, InfoTitle } from "@executor-js/react/components/info"; | |
| 68 | 69 | import { Label } from "@executor-js/react/components/label"; |
| 69 | 70 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 70 | 71 | import { Checkbox } from "@executor-js/react/components/checkbox"; |
| @@ -72,7 +73,12 @@ import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio- · 1 unmodified line | ||
| 72 | 73 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 73 | 74 | import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms"; |
| 74 | 75 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 75 | −import { openApiPresets } from "../sdk/presets"; | |
| 76 | +import { | |
| 77 | + googleOpenApiPresets, | |
| 78 | + googleStandardUserOAuthPresets, | |
| 79 | + openApiPresets, | |
| 80 | +} from "../sdk/presets"; | |
| 81 | +import { GOOGLE_BUNDLE_PRESET_ID } from "../sdk/google-presets"; | |
| 76 | 82 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 77 | 83 | import { |
| 78 | 84 | headerBindingSlot, |
| @@ -85,9 +91,19 @@ import { · 6 unmodified lines | ||
| 85 | 91 | } from "../sdk/source-contracts"; |
| 86 | 92 | import { OAuth2SourceConfig, type ServerInfo } from "../sdk/types"; |
| 87 | 93 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; |
| 94 | +import { | |
| 95 | + compactGoogleOAuthScopes, | |
| 96 | + filterGoogleUserConsentOAuthScopes, | |
| 97 | +} from "../sdk/google-oauth-scopes"; | |
| 98 | +import { googleOAuthConsentBatches } from "../sdk/google-oauth-batches"; | |
| 88 | 99 | |
| 89 | 100 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; |
| 90 | 101 | export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; |
| 102 | +const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | |
| 103 | +const GOOGLE_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | |
| 104 | +const GOOGLE_BUNDLE_DEFAULT_PRESET = googleOpenApiPresets[0]!; | |
| 105 | +const GOOGLE_STANDARD_SERVICE_IDS = googleStandardUserOAuthPresets.map((preset) => preset.id); | |
| 106 | +const GOOGLE_BROAD_SELECTION_WARNING_THRESHOLD = 6; | |
| 91 | 107 | |
| 92 | 108 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 93 | 109 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); |
| @@ -98,6 +114,22 @@ const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: strin · 4 unmodified lines | ||
| 98 | 114 | onSome: ({ message }) => message, |
| 99 | 115 | }); |
| 100 | 116 | |
| 117 | +const googleOAuthErrorMessage = | |
| 118 | + "Google did not approve this permission request. Try selecting fewer services, or add sensitive services as separate Google sources."; | |
| 119 | + | |
| 120 | +const oauthErrorMessage = (providerLabel: string, message: string, details?: string): string => { | |
| 121 | + if (providerLabel !== "Google") return details ? `${message}: ${details}` : message; | |
| 122 | + const combined = `${message}\n${details ?? ""}`; | |
| 123 | + if ( | |
| 124 | + /Authorization server returned error|Token exchange failed|invalid_grant|access_denied|Something went wrong|unknownerror/i.test( | |
| 125 | + combined, | |
| 126 | + ) | |
| 127 | + ) { | |
| 128 | + return googleOAuthErrorMessage; | |
| 129 | + } | |
| 130 | + return details ? `${message}: ${details}` : message; | |
| 131 | +}; | |
| 132 | + | |
| 101 | 133 | export const openApiOAuthConnectionId = ( |
| 102 | 134 | namespaceSlug: string, |
| 103 | 135 | flow: OAuth2Preset["flow"], |
| @@ -170,11 +202,10 @@ const mergeOAuthScopes = (...values: readonly Iterable<string>[]): string[] => { · 66 unmodified lines | ||
| 170 | 202 | return [...merged]; |
| 171 | 203 | }; |
| 172 | 204 | |
| 173 | −const missingOAuthScopes = ( | |
| 174 | − connection: { readonly oauthScope: string | null }, | |
| 205 | +const missingOAuthScopesFromGranted = ( | |
| 206 | + granted: ReadonlySet<string>, | |
| 175 | 207 | requiredApiScopes: Iterable<string>, |
| 176 | 208 | ): readonly string[] => { |
| 177 | − const granted = splitOAuthScopes(connection.oauthScope); | |
| 178 | 209 | return [...requiredApiScopes].filter((scope) => !granted.has(scope)); |
| 179 | 210 | }; |
| 180 | 211 | |
| @@ -207,46 +238,6 @@ const googleAuthorizationParams = (enabled: boolean): Record<string, string> | u · 26 unmodified lines | ||
| 207 | 238 | } |
| 208 | 239 | : undefined; |
| 209 | 240 | |
| 210 | −const googleBroadScopeGroups: readonly { | |
| 211 | − readonly broad: string; | |
| 212 | − readonly prefixes: readonly string[]; | |
| 213 | −}[] = [ | |
| 214 | − { | |
| 215 | − broad: "https://mail.google.com/", | |
| 216 | − prefixes: ["https://www.googleapis.com/auth/gmail."], | |
| 217 | − }, | |
| 218 | − { | |
| 219 | − broad: "https://www.googleapis.com/auth/calendar", | |
| 220 | − prefixes: ["https://www.googleapis.com/auth/calendar."], | |
| 221 | − }, | |
| 222 | − { | |
| 223 | − broad: "https://www.googleapis.com/auth/drive", | |
| 224 | − prefixes: ["https://www.googleapis.com/auth/drive."], | |
| 225 | − }, | |
| 226 | −]; | |
| 227 | − | |
| 228 | −const compactGoogleOAuthScopes = (scopes: Iterable<string>): string[] => { | |
| 229 | − const ordered = mergeOAuthScopes( | |
| 230 | − [...scopes].map((scope) => | |
| 231 | − scope === "https://www.googleapis.com/auth/userinfo.email" | |
| 232 | − ? "email" | |
| 233 | − : scope === "https://www.googleapis.com/auth/userinfo.profile" | |
| 234 | − ? "profile" | |
| 235 | − : scope, | |
| 236 | − ), | |
| 237 | − ); | |
| 238 | − const present = new Set(ordered); | |
| 239 | − return ordered.filter( | |
| 240 | − (scope) => | |
| 241 | − !googleBroadScopeGroups.some( | |
| 242 | − (group) => | |
| 243 | − scope !== group.broad && | |
| 244 | − present.has(group.broad) && | |
| 245 | − group.prefixes.some((prefix) => scope.startsWith(prefix)), | |
| 246 | − ), | |
| 247 | − ); | |
| 248 | −}; | |
| 249 | − | |
| 250 | 241 | type OAuthConnectionChoice = { |
| 251 | 242 | readonly id: ConnectionId; |
| 252 | 243 | readonly scopeId: ScopeId; |
| @@ -256,6 +247,25 @@ type OAuthConnectionChoice = { · 3 unmodified lines | ||
| 256 | 247 | readonly missingApiScopes: readonly string[]; |
| 257 | 248 | }; |
| 258 | 249 | |
| 250 | +type GoogleServicePreviewState = | |
| 251 | + | { readonly status: "loading" } | |
| 252 | + | { | |
| 253 | + readonly status: "success"; | |
| 254 | + readonly preview: SpecPreview; | |
| 255 | + readonly baseUrl: string; | |
| 256 | + } | |
| 257 | + | { readonly status: "error"; readonly message: string }; | |
| 258 | + | |
| 259 | +type GoogleServiceAddItem = { | |
| 260 | + readonly preset: (typeof googleOpenApiPresets)[number]; | |
| 261 | + readonly preview: SpecPreview; | |
| 262 | +}; | |
| 263 | + | |
| 264 | +const scopesForGoogleServiceItem = (item: GoogleServiceAddItem): readonly string[] => { | |
| 265 | + const oauth2Preset = item.preview.oauth2Presets[0]; | |
| 266 | + return filterGoogleUserConsentOAuthScopes(Object.keys(oauth2Preset?.scopes ?? {})); | |
| 267 | +}; | |
| 268 | + | |
| 259 | 269 | const specInputForAdd = (input: string) => { |
| 260 | 270 | const value = input.trim(); |
| 261 | 271 | const parsed = Effect.runSyncExit( |
| @@ -289,6 +299,25 @@ const normalizePresetUrl = (url: string): string => { · 27 unmodified lines | ||
| 289 | 299 | return parsed.toString().replace(/\/$/, ""); |
| 290 | 300 | }; |
| 291 | 301 | |
| 302 | +const googlePresetForSpec = ( | |
| 303 | + presetId: string | undefined, | |
| 304 | + specUrl: string, | |
| 305 | +): (typeof googleOpenApiPresets)[number] | null => { | |
| 306 | + const normalizedSpecUrl = normalizePresetUrl(specUrl); | |
| 307 | + const byUrl = googleOpenApiPresets.find( | |
| 308 | + (preset) => preset.url && normalizePresetUrl(preset.url) === normalizedSpecUrl, | |
| 309 | + ); | |
| 310 | + if (byUrl) return byUrl; | |
| 311 | + const byId = presetId ? googleOpenApiPresets.find((preset) => preset.id === presetId) : undefined; | |
| 312 | + if (byId) return byId; | |
| 313 | + return null; | |
| 314 | +}; | |
| 315 | + | |
| 316 | +const firstBaseUrlForPreview = (preview: SpecPreview): string => { | |
| 317 | + const firstServer = preview.servers[0]; | |
| 318 | + return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : ""; | |
| 319 | +}; | |
| 320 | + | |
| 292 | 321 | type StrategySelection = |
| 293 | 322 | | { readonly kind: "none" } |
| 294 | 323 | | { readonly kind: "custom" } |
| @@ -348,6 +377,31 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 53 unmodified lines | ||
| 348 | 377 | }); |
| 349 | 378 | } |
| 350 | 379 | |
| 380 | +const oauth2ConfigForPreset = (input: { | |
| 381 | + readonly preset: OAuth2Preset; | |
| 382 | + readonly baseUrl: string; | |
| 383 | + readonly scopes: Iterable<string>; | |
| 384 | + readonly identityScopes: OAuth2Preset["identityScopes"]; | |
| 385 | +}): OAuth2SourceConfig => | |
| 386 | + OAuth2SourceConfig.make({ | |
| 387 | + kind: "oauth2", | |
| 388 | + securitySchemeName: input.preset.securitySchemeName, | |
| 389 | + flow: input.preset.flow, | |
| 390 | + tokenUrl: resolveOAuthUrl(input.preset.tokenUrl, input.baseUrl), | |
| 391 | + authorizationUrl: | |
| 392 | + input.preset.flow === "authorizationCode" | |
| 393 | + ? resolveOAuthUrl( | |
| 394 | + Option.getOrElse(input.preset.authorizationUrl, () => ""), | |
| 395 | + input.baseUrl, | |
| 396 | + ) || null | |
| 397 | + : null, | |
| 398 | + clientIdSlot: oauth2ClientIdSlot(input.preset.securitySchemeName), | |
| 399 | + clientSecretSlot: oauth2ClientSecretSlot(input.preset.securitySchemeName), | |
| 400 | + connectionSlot: oauth2ConnectionSlot(input.preset.securitySchemeName), | |
| 401 | + scopes: [...input.scopes], | |
| 402 | + identityScopes: input.identityScopes, | |
| 403 | + }); | |
| 404 | + | |
| 351 | 405 | function OAuthConnectedAccount(props: { |
| 352 | 406 | readonly scopeId: ScopeId; |
| 353 | 407 | readonly connectionId: string; |
| @@ -474,19 +528,30 @@ export default function AddOpenApiSource(props: { · 120 unmodified lines | ||
| 474 | 528 | onComplete: () => void; |
| 475 | 529 | onCancel: () => void; |
| 476 | 530 | initialUrl?: string; |
| 531 | + initialPreset?: string; | |
| 477 | 532 | initialNamespace?: string; |
| 478 | 533 | }) { |
| 479 | 534 | // Spec input |
| 480 | − const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); | |
| 535 | + const isGoogleBundlePreset = props.initialPreset === GOOGLE_BUNDLE_PRESET_ID; | |
| 536 | + const [specUrl, setSpecUrl] = useState( | |
| 537 | + props.initialUrl ?? (isGoogleBundlePreset ? (GOOGLE_BUNDLE_DEFAULT_PRESET.url ?? "") : ""), | |
| 538 | + ); | |
| 481 | 539 | const [analyzing, setAnalyzing] = useState(false); |
| 482 | 540 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| 483 | 541 | |
| 484 | 542 | // After analysis |
| 485 | 543 | const [preview, setPreview] = useState<SpecPreview | null>(null); |
| 486 | 544 | const [baseUrl, setBaseUrl] = useState(""); |
| 545 | + const initialGooglePreset = | |
| 546 | + preview && isGoogleBundlePreset ? googlePresetForSpec(undefined, specUrl) : null; | |
| 547 | + const identityFallbackName = preview | |
| 548 | + ? isGoogleBundlePreset | |
| 549 | + ? "Google" | |
| 550 | + : Option.getOrElse(preview.title, () => "") | |
| 551 | + : ""; | |
| 487 | 552 | const identity = useSourceIdentity({ |
| 488 | − fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", | |
| 489 | − fallbackNamespace: props.initialNamespace, | |
| 553 | + fallbackName: identityFallbackName, | |
| 554 | + fallbackNamespace: props.initialNamespace ?? (isGoogleBundlePreset ? "google" : undefined), | |
| 490 | 555 | }); |
| 491 | 556 | |
| 492 | 557 | // Auth |
| @@ -510,10 +575,21 @@ export default function AddOpenApiSource(props: { · 17 unmodified lines | ||
| 510 | 575 | const [oauth2ScopesOpen, setOauth2ScopesOpen] = useState(false); |
| 511 | 576 | const [oauth2AuthState, setOauth2AuthState] = useState<{ |
| 512 | 577 | readonly fingerprint: string; |
| 513 | − readonly auth: { readonly connectionId: string }; | |
| 578 | + readonly auth: { | |
| 579 | + readonly connectionId: string; | |
| 580 | + readonly grantedScopes: readonly string[]; | |
| 581 | + readonly scopeId: ScopeId; | |
| 582 | + }; | |
| 514 | 583 | } | null>(null); |
| 515 | 584 | const [startingOAuth, setStartingOAuth] = useState(false); |
| 585 | + const [oauth2ProgressLabel, setOauth2ProgressLabel] = useState<string | null>(null); | |
| 516 | 586 | const [oauth2Error, setOauth2Error] = useState<string | null>(null); |
| 587 | + const [selectedGoogleServiceIds, setSelectedGoogleServiceIds] = useState<Set<string>>( | |
| 588 | + () => new Set(), | |
| 589 | + ); | |
| 590 | + const [googleServicePreviews, setGoogleServicePreviews] = useState< | |
| 591 | + Record<string, GoogleServicePreviewState> | |
| 592 | + >({}); | |
| 517 | 593 | |
| 518 | 594 | // Submit |
| 519 | 595 | const [adding, setAdding] = useState(false); |
| @@ -579,6 +655,7 @@ export default function AddOpenApiSource(props: { · 59 unmodified lines | ||
| 579 | 655 | // Keep the latest handleAnalyze in a ref so the debounced effect doesn't |
| 580 | 656 | // need it as a dependency (it closes over fresh state). |
| 581 | 657 | const handleAnalyzeRef = useRef<() => void>(() => {}); |
| 658 | + const googleBundleSelectionSeeded = useRef(false); | |
| 582 | 659 | |
| 583 | 660 | // Auto-analyze whenever the spec input changes, with a short debounce so |
| 584 | 661 | // typing/pasting doesn't fire a request on every keystroke. |
| @@ -606,8 +683,38 @@ export default function AddOpenApiSource(props: { · 21 unmodified lines | ||
| 606 | 683 | new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), |
| 607 | 684 | ); |
| 608 | 685 | const previewPresetIcon = |
| 609 | − openApiPresets.find((preset) => normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl)) | |
| 610 | − ?.icon ?? null; | |
| 686 | + openApiPresets.find( | |
| 687 | + (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), | |
| 688 | + )?.icon ?? null; | |
| 689 | + const primaryGooglePreset = initialGooglePreset; | |
| 690 | + const selectedGoogleServiceIdList = useMemo(() => { | |
| 691 | + return [...selectedGoogleServiceIds]; | |
| 692 | + }, [selectedGoogleServiceIds]); | |
| 693 | + const allStandardGoogleServicesSelected = GOOGLE_STANDARD_SERVICE_IDS.every((presetId) => | |
| 694 | + selectedGoogleServiceIds.has(presetId), | |
| 695 | + ); | |
| 696 | + const selectedGoogleWorkspaceAdminServiceCount = selectedGoogleServiceIdList.filter((presetId) => | |
| 697 | + googleOpenApiPresets.some( | |
| 698 | + (preset) => preset.id === presetId && preset.oauthAudience === "workspace-admin", | |
| 699 | + ), | |
| 700 | + ).length; | |
| 701 | + const selectedGoogleAdvancedServiceCount = selectedGoogleServiceIdList.filter((presetId) => | |
| 702 | + googleOpenApiPresets.some( | |
| 703 | + (preset) => preset.id === presetId && preset.oauthAudience === "advanced-user", | |
| 704 | + ), | |
| 705 | + ).length; | |
| 706 | + const showGoogleSelectionWarning = | |
| 707 | + selectedGoogleServiceIdList.length >= GOOGLE_BROAD_SELECTION_WARNING_THRESHOLD || | |
| 708 | + selectedGoogleWorkspaceAdminServiceCount > 0 || | |
| 709 | + selectedGoogleAdvancedServiceCount > 0; | |
| 710 | + const selectedGooglePresets = useMemo( | |
| 711 | + () => | |
| 712 | + selectedGoogleServiceIdList.flatMap((presetId) => { | |
| 713 | + const preset = googleOpenApiPresets.find((candidate) => candidate.id === presetId); | |
| 714 | + return preset ? [preset] : []; | |
| 715 | + }), | |
| 716 | + [selectedGoogleServiceIdList], | |
| 717 | + ); | |
| 611 | 718 | |
| 612 | 719 | const resolvedBaseUrl = baseUrl.trim(); |
| 613 | 720 | const sourceScope = ScopeId.make(scopeId); |
| @@ -727,15 +834,94 @@ export default function AddOpenApiSource(props: { · 113 unmodified lines | ||
| 727 | 834 | Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), |
| 728 | 835 | ].join("\n") |
| 729 | 836 | : ""; |
| 730 | − const oauth2Auth = | |
| 837 | + const activeOAuth2AuthState = | |
| 731 | 838 | oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint ? oauth2AuthState.auth : null; |
| 732 | 839 | const selectedOAuth2AvailableIdentityScopes = selectedOAuth2Preset |
| 733 | 840 | ? identityScopesForPreset(selectedOAuth2Preset.identityScopes) |
| 734 | 841 | : []; |
| 735 | 842 | const selectedOAuth2IsGoogle = selectedOAuth2Preset |
| 736 | − ? isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl) | |
| 843 | + ? isGoogleBundlePreset || isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl) | |
| 737 | 844 | : false; |
| 738 | 845 | const selectedOAuth2ProviderLabel = selectedOAuth2IsGoogle ? "Google" : "OAuth"; |
| 846 | + const googleServicePickerEnabled = Boolean( | |
| 847 | + preview && isGoogleBundlePreset && selectedOAuth2IsGoogle, | |
| 848 | + ); | |
| 849 | + useEffect(() => { | |
| 850 | + if (!selectedOAuth2IsGoogle) return; | |
| 851 | + if (!oauth2ClientIdSecretId) { | |
| 852 | + const clientIdSecret = secretList.find((secret) => secret.id === "google-client-id"); | |
| 853 | + if (clientIdSecret) { | |
| 854 | + setOauth2ClientIdSecretId(clientIdSecret.id); | |
| 855 | + setOauth2ClientIdScope(ScopeId.make(clientIdSecret.scopeId)); | |
| 856 | + } | |
| 857 | + } | |
| 858 | + if (!oauth2ClientSecretSecretId) { | |
| 859 | + const clientSecretSecret = secretList.find((secret) => secret.id === "google-client-secret"); | |
| 860 | + if (clientSecretSecret) { | |
| 861 | + setOauth2ClientSecretSecretId(clientSecretSecret.id); | |
| 862 | + setOauth2ClientSecretScope(ScopeId.make(clientSecretSecret.scopeId)); | |
| 863 | + } | |
| 864 | + } | |
| 865 | + }, [oauth2ClientIdSecretId, oauth2ClientSecretSecretId, secretList, selectedOAuth2IsGoogle]); | |
| 866 | + const googleBatchAddItems = useMemo((): readonly GoogleServiceAddItem[] => { | |
| 867 | + if (!preview || !primaryGooglePreset || !googleServicePickerEnabled) return []; | |
| 868 | + const items: GoogleServiceAddItem[] = []; | |
| 869 | + for (const presetId of selectedGoogleServiceIdList) { | |
| 870 | + const preset = googleOpenApiPresets.find((candidate) => candidate.id === presetId); | |
| 871 | + if (!preset) continue; | |
| 872 | + if (preset.id === primaryGooglePreset.id) { | |
| 873 | + items.push({ | |
| 874 | + preset, | |
| 875 | + preview, | |
| 876 | + }); | |
| 877 | + continue; | |
| 878 | + } | |
| 879 | + const previewState = googleServicePreviews[preset.id]; | |
| 880 | + if (previewState?.status === "success") { | |
| 881 | + items.push({ | |
| 882 | + preset, | |
| 883 | + preview: previewState.preview, | |
| 884 | + }); | |
| 885 | + } | |
| 886 | + } | |
| 887 | + return items; | |
| 888 | + }, [ | |
| 889 | + googleServicePickerEnabled, | |
| 890 | + googleServicePreviews, | |
| 891 | + preview, | |
| 892 | + primaryGooglePreset, | |
| 893 | + selectedGoogleServiceIdList, | |
| 894 | + ]); | |
| 895 | + const googleBatchPendingCount = googleServicePickerEnabled | |
| 896 | + ? selectedGoogleServiceIdList.length - googleBatchAddItems.length | |
| 897 | + : 0; | |
| 898 | + const googleBatchError = googleServicePickerEnabled | |
| 899 | + ? selectedGoogleServiceIdList | |
| 900 | + .map((presetId) => googleServicePreviews[presetId]) | |
| 901 | + .find((state) => state?.status === "error") | |
| 902 | + : undefined; | |
| 903 | + const effectiveOAuth2SelectedApiScopes = useMemo(() => { | |
| 904 | + if (!googleServicePickerEnabled) { | |
| 905 | + return new Set( | |
| 906 | + selectedOAuth2IsGoogle | |
| 907 | + ? filterGoogleUserConsentOAuthScopes(oauth2SelectedScopes) | |
| 908 | + : oauth2SelectedScopes, | |
| 909 | + ); | |
| 910 | + } | |
| 911 | + const scopes = new Set<string>(); | |
| 912 | + for (const item of googleBatchAddItems) { | |
| 913 | + const preset = item.preview.oauth2Presets[0]; | |
| 914 | + for (const scope of filterGoogleUserConsentOAuthScopes(Object.keys(preset?.scopes ?? {}))) { | |
| 915 | + scopes.add(scope); | |
| 916 | + } | |
| 917 | + } | |
| 918 | + return scopes; | |
| 919 | + }, [ | |
| 920 | + googleBatchAddItems, | |
| 921 | + googleServicePickerEnabled, | |
| 922 | + oauth2SelectedScopes, | |
| 923 | + selectedOAuth2IsGoogle, | |
| 924 | + ]); | |
| 739 | 925 | const configuredOAuth2IdentityScopes = |
| 740 | 926 | selectedOAuth2Preset && includeOAuth2IdentityScopes |
| 741 | 927 | ? selectedOAuth2Preset.identityScopes |
| @@ -743,22 +929,130 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 743 | 929 | const selectedOAuth2Scopes = useMemo( |
| 744 | 930 | () => |
| 745 | 931 | selectedOAuth2Preset |
| 746 | − ? resolvedOAuthScopes(oauth2SelectedScopes, configuredOAuth2IdentityScopes) | |
| 747 | − : [...oauth2SelectedScopes], | |
| 748 | − [configuredOAuth2IdentityScopes, oauth2SelectedScopes, selectedOAuth2Preset], | |
| 932 | + ? resolvedOAuthScopes(effectiveOAuth2SelectedApiScopes, configuredOAuth2IdentityScopes) | |
| 933 | + : [...effectiveOAuth2SelectedApiScopes], | |
| 934 | + [configuredOAuth2IdentityScopes, effectiveOAuth2SelectedApiScopes, selectedOAuth2Preset], | |
| 935 | + ); | |
| 936 | + const oauth2Auth = useMemo(() => { | |
| 937 | + if (!activeOAuth2AuthState) return null; | |
| 938 | + const granted = new Set(activeOAuth2AuthState.grantedScopes); | |
| 939 | + return selectedOAuth2Scopes.every((scope) => granted.has(scope)) ? activeOAuth2AuthState : null; | |
| 940 | + }, [activeOAuth2AuthState, selectedOAuth2Scopes]); | |
| 941 | + const grantedScopesForConnection = useCallback( | |
| 942 | + (connection: { | |
| 943 | + readonly id: ConnectionId; | |
| 944 | + readonly oauthScope: string | null; | |
| 945 | + }): Set<string> => { | |
| 946 | + const granted = splitOAuthScopes(connection.oauthScope); | |
| 947 | + if (activeOAuth2AuthState?.connectionId === connection.id) { | |
| 948 | + for (const scope of activeOAuth2AuthState.grantedScopes) granted.add(scope); | |
| 949 | + } | |
| 950 | + return granted; | |
| 951 | + }, | |
| 952 | + [activeOAuth2AuthState], | |
| 749 | 953 | ); |
| 954 | + const googleConsentBatches = useMemo( | |
| 955 | + () => | |
| 956 | + googleServicePickerEnabled | |
| 957 | + ? googleOAuthConsentBatches( | |
| 958 | + googleBatchAddItems.map((item) => ({ | |
| 959 | + id: item.preset.id, | |
| 960 | + name: item.preset.name, | |
| 961 | + oauthAudience: item.preset.oauthAudience, | |
| 962 | + scopes: scopesForGoogleServiceItem(item), | |
| 963 | + })), | |
| 964 | + ) | |
| 965 | + : [], | |
| 966 | + [googleBatchAddItems, googleServicePickerEnabled], | |
| 967 | + ); | |
| 968 | + useEffect(() => { | |
| 969 | + if (!googleServicePickerEnabled || !primaryGooglePreset) { | |
| 970 | + googleBundleSelectionSeeded.current = false; | |
| 971 | + setSelectedGoogleServiceIds((previous) => | |
| 972 | + previous.size === 0 ? previous : new Set<string>(), | |
| 973 | + ); | |
| 974 | + setGoogleServicePreviews((previous) => (Object.keys(previous).length === 0 ? previous : {})); | |
| 975 | + return; | |
| 976 | + } | |
| 977 | + setBaseUrl((previous) => | |
| 978 | + previous === GOOGLE_BUNDLE_BASE_URL ? previous : GOOGLE_BUNDLE_BASE_URL, | |
| 979 | + ); | |
| 980 | + if (!googleBundleSelectionSeeded.current) { | |
| 981 | + googleBundleSelectionSeeded.current = true; | |
| 982 | + setSelectedGoogleServiceIds((previous) => { | |
| 983 | + if (previous.size > 0 || previous.has(primaryGooglePreset.id)) return previous; | |
| 984 | + return new Set([primaryGooglePreset.id]); | |
| 985 | + }); | |
| 986 | + } | |
| 987 | + }, [googleServicePickerEnabled, primaryGooglePreset]); | |
| 988 | + | |
| 989 | + useEffect(() => { | |
| 990 | + if (!googleServicePickerEnabled || !primaryGooglePreset) return; | |
| 991 | + const missingPresetIds = selectedGoogleServiceIdList.filter( | |
| 992 | + (presetId) => presetId !== primaryGooglePreset.id && !googleServicePreviews[presetId], | |
| 993 | + ); | |
| 994 | + if (missingPresetIds.length === 0) return; | |
| 995 | + setGoogleServicePreviews((previous) => { | |
| 996 | + const next = { ...previous }; | |
| 997 | + for (const presetId of missingPresetIds) next[presetId] = { status: "loading" }; | |
| 998 | + return next; | |
| 999 | + }); | |
| 1000 | + void (async () => { | |
| 1001 | + for (const presetId of missingPresetIds) { | |
| 1002 | + const preset = googleOpenApiPresets.find((candidate) => candidate.id === presetId); | |
| 1003 | + if (!preset) continue; | |
| 1004 | + if (!preset.url) continue; | |
| 1005 | + const exit = await doPreview({ | |
| 1006 | + params: { scopeId }, | |
| 1007 | + payload: { | |
| 1008 | + spec: preset.url, | |
| 1009 | + }, | |
| 1010 | + }); | |
| 1011 | + setGoogleServicePreviews((previous) => ({ | |
| 1012 | + ...previous, | |
| 1013 | + [preset.id]: Exit.isSuccess(exit) | |
| 1014 | + ? { | |
| 1015 | + status: "success", | |
| 1016 | + preview: exit.value, | |
| 1017 | + baseUrl: firstBaseUrlForPreview(exit.value), | |
| 1018 | + } | |
| 1019 | + : { | |
| 1020 | + status: "error", | |
| 1021 | + message: errorMessageFromExit(exit, `Failed to preview ${preset.name}`), | |
| 1022 | + }, | |
| 1023 | + })); | |
| 1024 | + } | |
| 1025 | + })(); | |
| 1026 | + }, [ | |
| 1027 | + doPreview, | |
| 1028 | + googleServicePickerEnabled, | |
| 1029 | + googleServicePreviews, | |
| 1030 | + primaryGooglePreset, | |
| 1031 | + scopeId, | |
| 1032 | + selectedGoogleServiceIdList, | |
| 1033 | + ]); | |
| 1034 | + | |
| 1035 | + const googleBundleOperationCount = googleBatchAddItems.reduce( | |
| 1036 | + (count, item) => count + item.preview.operationCount, | |
| 1037 | + 0, | |
| 1038 | + ); | |
| 1039 | + | |
| 750 | 1040 | const existingOAuthConnections = useMemo(() => { |
| 751 | 1041 | if ( |
| 752 | 1042 | !selectedOAuth2Preset || |
| 753 | 1043 | selectedOAuth2Preset.flow !== "authorizationCode" || |
| 754 | − !AsyncResult.isSuccess(connectionsResult) | |
| 1044 | + !AsyncResult.isSuccess(connectionsResult) || | |
| 1045 | + (googleServicePickerEnabled && selectedGoogleServiceIdList.length === 0) | |
| 755 | 1046 | ) { |
| 756 | 1047 | return []; |
| 757 | 1048 | } |
| 758 | 1049 | return connectionsResult.value |
| 759 | 1050 | .flatMap((connection) => { |
| 760 | 1051 | if (connection.provider !== "oauth2") return []; |
| 761 | − const missingApiScopes = missingOAuthScopes(connection, oauth2SelectedScopes); | |
| 1052 | + const missingApiScopes = missingOAuthScopesFromGranted( | |
| 1053 | + grantedScopesForConnection(connection), | |
| 1054 | + effectiveOAuth2SelectedApiScopes, | |
| 1055 | + ); | |
| 762 | 1056 | if (missingApiScopes.length === 0) { |
| 763 | 1057 | return [{ ...connection, missingApiScopes }]; |
| 764 | 1058 | } |
| @@ -768,35 +1062,35 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 768 | 1062 | return []; |
| 769 | 1063 | }) |
| 770 | 1064 | .sort((a, b) => a.missingApiScopes.length - b.missingApiScopes.length); |
| 771 | − }, [connectionsResult, oauth2SelectedScopes, selectedOAuth2IsGoogle, selectedOAuth2Preset]); | |
| 772 | − | |
| 1065 | + }, [ | |
| 1066 | + connectionsResult, | |
| 1067 | + effectiveOAuth2SelectedApiScopes, | |
| 1068 | + grantedScopesForConnection, | |
| 1069 | + googleServicePickerEnabled, | |
| 1070 | + selectedOAuth2IsGoogle, | |
| 1071 | + selectedOAuth2Preset, | |
| 1072 | + selectedGoogleServiceIdList.length, | |
| 1073 | + ]); | |
| 1074 | + | |
| 1075 | + const effectiveResolvedBaseUrl = resolvedBaseUrl; | |
| 773 | 1076 | const configuredOAuth2 = |
| 774 | 1077 | strategy.kind === "oauth2" && selectedOAuth2Preset |
| 775 | − ? OAuth2SourceConfig.make({ | |
| 776 | − kind: "oauth2", | |
| 777 | − securitySchemeName: selectedOAuth2Preset.securitySchemeName, | |
| 778 | − flow: selectedOAuth2Preset.flow, | |
| 779 | − tokenUrl: resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl), | |
| 780 | − authorizationUrl: | |
| 781 | − selectedOAuth2Preset.flow === "authorizationCode" | |
| 782 | − ? resolveOAuthUrl( | |
| 783 | − Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), | |
| 784 | − resolvedBaseUrl, | |
| 785 | − ) || null | |
| 786 | − : null, | |
| 787 | − clientIdSlot: oauth2ClientIdSlot(selectedOAuth2Preset.securitySchemeName), | |
| 788 | − // Authorization-code specs can still be confidential clients | |
| 789 | − // (Spotify is one example). Persist the slot even when the value is | |
| 790 | − // deferred so the edit screen can collect the secret later. | |
| 791 | − clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName), | |
| 792 | − connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName), | |
| 793 | − scopes: [...oauth2SelectedScopes], | |
| 1078 | + ? oauth2ConfigForPreset({ | |
| 1079 | + preset: selectedOAuth2Preset, | |
| 1080 | + baseUrl: effectiveResolvedBaseUrl, | |
| 1081 | + scopes: effectiveOAuth2SelectedApiScopes, | |
| 794 | 1082 | identityScopes: configuredOAuth2IdentityScopes, |
| 795 | 1083 | }) |
| 796 | 1084 | : null; |
| 797 | 1085 | const hasHeaders = Object.keys(configuredHeaders).length > 0; |
| 798 | 1086 | const oauth2Busy = startingOAuth || oauth.busy; |
| 799 | − const canConnectOAuth2 = Boolean(oauth2ClientIdSecretId) && resolvedBaseUrl.length > 0; | |
| 1087 | + const canConnectOAuth2 = | |
| 1088 | + Boolean(oauth2ClientIdSecretId) && | |
| 1089 | + effectiveResolvedBaseUrl.length > 0 && | |
| 1090 | + (!googleServicePickerEnabled || | |
| 1091 | + (selectedGoogleServiceIdList.length > 0 && | |
| 1092 | + googleBatchPendingCount === 0 && | |
| 1093 | + !googleBatchError)); | |
| 800 | 1094 | const hasIncompleteHeaderCredentials = |
| 801 | 1095 | strategy.kind !== "none" && |
| 802 | 1096 | strategy.kind !== "oauth2" && |
| @@ -815,7 +1109,13 @@ export default function AddOpenApiSource(props: { · 12 unmodified lines | ||
| 815 | 1109 | hasIncompleteHeaderCredentials || |
| 816 | 1110 | hasIncompleteQueryCredentials; |
| 817 | 1111 | |
| 818 | − const canAdd = preview !== null && resolvedBaseUrl.length > 0; | |
| 1112 | + const canAdd = | |
| 1113 | + preview !== null && | |
| 1114 | + effectiveResolvedBaseUrl.length > 0 && | |
| 1115 | + (!googleServicePickerEnabled || | |
| 1116 | + (selectedGoogleServiceIdList.length > 0 && | |
| 1117 | + googleBatchPendingCount === 0 && | |
| 1118 | + !googleBatchError)); | |
| 819 | 1119 | |
| 820 | 1120 | // ---- Handlers ---- |
| 821 | 1121 | |
| @@ -920,23 +1220,46 @@ export default function AddOpenApiSource(props: { · 98 unmodified lines | ||
| 920 | 1220 | if (!selectedOAuth2Preset || !preview) return; |
| 921 | 1221 | oauth.cancel(); |
| 922 | 1222 | setOauth2Error(null); |
| 1223 | + setOauth2ProgressLabel(null); | |
| 923 | 1224 | const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; |
| 924 | 1225 | const tokenTargetScope = existingConnection?.scopeId ?? oauthTokenTargetScope; |
| 925 | 1226 | const connectionId = |
| 926 | 1227 | existingConnection?.id ?? |
| 927 | 1228 | openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); |
| 928 | − const scopesForAuthorization = existingConnection | |
| 929 | − ? mergeOAuthScopes( | |
| 930 | − splitOAuthScopes(existingConnection.oauthScope), | |
| 931 | − selectedOAuth2IsGoogle ? oauth2SelectedScopes : selectedOAuth2Scopes, | |
| 932 | − ) | |
| 933 | − : selectedOAuth2Scopes; | |
| 1229 | + const existingGrantedScopes = existingConnection | |
| 1230 | + ? grantedScopesForConnection(existingConnection) | |
| 1231 | + : null; | |
| 1232 | + const selectedGoogleConnectionScopes = | |
| 1233 | + selectedOAuth2IsGoogle && googleServicePickerEnabled | |
| 1234 | + ? resolvedOAuthScopes( | |
| 1235 | + new Set(googleBatchAddItems.flatMap((item) => scopesForGoogleServiceItem(item))), | |
| 1236 | + configuredOAuth2IdentityScopes, | |
| 1237 | + ) | |
| 1238 | + : selectedOAuth2Scopes; | |
| 1239 | + const scopesForConnection = existingConnection | |
| 1240 | + ? mergeOAuthScopes(existingGrantedScopes ?? [], selectedGoogleConnectionScopes) | |
| 1241 | + : selectedGoogleConnectionScopes; | |
| 1242 | + const providerAuthorizationInput = | |
| 1243 | + selectedOAuth2IsGoogle && googleServicePickerEnabled | |
| 1244 | + ? [ | |
| 1245 | + ...googleConsentBatches.flatMap((batch) => batch.apiScopes), | |
| 1246 | + ...identityScopesForPreset(configuredOAuth2IdentityScopes), | |
| 1247 | + ] | |
| 1248 | + : existingConnection && selectedOAuth2IsGoogle | |
| 1249 | + ? resolvedOAuthScopes( | |
| 1250 | + missingOAuthScopesFromGranted( | |
| 1251 | + existingGrantedScopes ?? new Set<string>(), | |
| 1252 | + effectiveOAuth2SelectedApiScopes, | |
| 1253 | + ), | |
| 1254 | + configuredOAuth2IdentityScopes, | |
| 1255 | + ) | |
| 1256 | + : scopesForConnection; | |
| 934 | 1257 | const scopesForProviderAuthorization = selectedOAuth2IsGoogle |
| 935 | − ? compactGoogleOAuthScopes(scopesForAuthorization) | |
| 1258 | + ? compactGoogleOAuthScopes(providerAuthorizationInput) | |
| 936 | 1259 | : undefined; |
| 937 | 1260 | const extraAuthorizationParams = googleAuthorizationParams(selectedOAuth2IsGoogle); |
| 938 | 1261 | |
| 939 | − const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); | |
| 1262 | + const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, effectiveResolvedBaseUrl); | |
| 940 | 1263 | const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; |
| 941 | 1264 | const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; |
| 942 | 1265 | |
| @@ -964,7 +1287,7 @@ export default function AddOpenApiSource(props: { · 21 unmodified lines | ||
| 964 | 1287 | clientIdSecretScopeId: String(clientIdSecretScope), |
| 965 | 1288 | clientSecretSecretId: oauth2ClientSecretSecretId, |
| 966 | 1289 | clientSecretSecretScopeId: String(clientSecretSecretScope), |
| 967 | − scopes: scopesForAuthorization, | |
| 1290 | + scopes: scopesForConnection, | |
| 968 | 1291 | }, |
| 969 | 1292 | pluginId: "openapi", |
| 970 | 1293 | identityLabel: `${displayName} OAuth`, |
| @@ -983,7 +1306,11 @@ export default function AddOpenApiSource(props: { · 12 unmodified lines | ||
| 983 | 1306 | setOAuthTokenTargetScope(tokenTargetScope); |
| 984 | 1307 | setOauth2AuthState({ |
| 985 | 1308 | fingerprint: selectedOAuth2Fingerprint, |
| 986 | − auth: { connectionId: response.completedConnection.connectionId }, | |
| 1309 | + auth: { | |
| 1310 | + connectionId: response.completedConnection.connectionId, | |
| 1311 | + grantedScopes: scopesForConnection, | |
| 1312 | + scopeId: tokenTargetScope, | |
| 1313 | + }, | |
| 987 | 1314 | }); |
| 988 | 1315 | setOauth2Error(null); |
| 989 | 1316 | return; |
| @@ -992,16 +1319,181 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines | ||
| 992 | 1319 | |
| 993 | 1320 | const authorizationUrl = resolveOAuthUrl( |
| 994 | 1321 | Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), |
| 995 | − resolvedBaseUrl, | |
| 1322 | + effectiveResolvedBaseUrl, | |
| 996 | 1323 | ); |
| 997 | 1324 | const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); |
| 1325 | + | |
| 1326 | + const startAuthorizationCodeFlow = async (input: { | |
| 1327 | + readonly connectionId: string; | |
| 1328 | + readonly scopesForConnection: readonly string[]; | |
| 1329 | + readonly authorizationScopes: readonly string[] | undefined; | |
| 1330 | + readonly identityLabel: string; | |
| 1331 | + readonly onSuccess: (result: OAuthCompletionPayload) => void | Promise<void>; | |
| 1332 | + }) => { | |
| 1333 | + const authorizationStrategy = existingConnection | |
| 1334 | + ? { | |
| 1335 | + kind: "authorization-code-existing-client" as const, | |
| 1336 | + authorizationEndpoint: authorizationUrl, | |
| 1337 | + tokenEndpoint: tokenUrl, | |
| 1338 | + issuerUrl, | |
| 1339 | + scopes: input.scopesForConnection, | |
| 1340 | + ...(input.authorizationScopes && input.authorizationScopes.length > 0 | |
| 1341 | + ? { authorizationScopes: input.authorizationScopes } | |
| 1342 | + : {}), | |
| 1343 | + extraAuthorizationParams, | |
| 1344 | + } | |
| 1345 | + : oauth2ClientIdSecretId | |
| 1346 | + ? { | |
| 1347 | + kind: "authorization-code" as const, | |
| 1348 | + authorizationEndpoint: authorizationUrl, | |
| 1349 | + tokenEndpoint: tokenUrl, | |
| 1350 | + issuerUrl, | |
| 1351 | + clientIdSecretId: oauth2ClientIdSecretId, | |
| 1352 | + clientIdSecretScopeId: String(clientIdSecretScope), | |
| 1353 | + clientSecretSecretId: oauth2ClientSecretSecretId ?? null, | |
| 1354 | + clientSecretSecretScopeId: oauth2ClientSecretSecretId | |
| 1355 | + ? String(clientSecretSecretScope) | |
| 1356 | + : null, | |
| 1357 | + scopes: input.scopesForConnection, | |
| 1358 | + ...(input.authorizationScopes && input.authorizationScopes.length > 0 | |
| 1359 | + ? { authorizationScopes: input.authorizationScopes } | |
| 1360 | + : {}), | |
| 1361 | + extraAuthorizationParams, | |
| 1362 | + } | |
| 1363 | + : null; | |
| 1364 | + if (!authorizationStrategy) return; | |
| 1365 | + | |
| 1366 | + await oauth.openAuthorization({ | |
| 1367 | + tokenScope: tokenTargetScope, | |
| 1368 | + run: async () => { | |
| 1369 | + const exit = await doStartOAuth({ | |
| 1370 | + params: { scopeId: tokenTargetScope }, | |
| 1371 | + payload: { | |
| 1372 | + endpoint: authorizationUrl, | |
| 1373 | + connectionId: input.connectionId, | |
| 1374 | + tokenScope: tokenTargetScope, | |
| 1375 | + redirectUrl: oauth2RedirectUrl, | |
| 1376 | + strategy: authorizationStrategy, | |
| 1377 | + pluginId: "openapi", | |
| 1378 | + identityLabel: input.identityLabel, | |
| 1379 | + }, | |
| 1380 | + }); | |
| 1381 | + if (Exit.isFailure(exit)) { | |
| 1382 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | |
| 1383 | + throw new Error(errorMessageFromExit(exit, "Failed to start OAuth")); | |
| 1384 | + } | |
| 1385 | + const response = exit.value; | |
| 1386 | + if (response.authorizationUrl === null) { | |
| 1387 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | |
| 1388 | + throw new Error("Unexpected response flow from server"); | |
| 1389 | + } | |
| 1390 | + return { | |
| 1391 | + sessionId: response.sessionId, | |
| 1392 | + authorizationUrl: response.authorizationUrl, | |
| 1393 | + }; | |
| 1394 | + }, | |
| 1395 | + onSuccess: input.onSuccess, | |
| 1396 | + onError: (message, details) => { | |
| 1397 | + setStartingOAuth(false); | |
| 1398 | + setOauth2ProgressLabel(null); | |
| 1399 | + setOauth2Error(oauthErrorMessage(selectedOAuth2ProviderLabel, message, details)); | |
| 1400 | + }, | |
| 1401 | + }); | |
| 1402 | + }; | |
| 1403 | + | |
| 1404 | + if (selectedOAuth2IsGoogle && googleConsentBatches.length > 2) { | |
| 1405 | + const currentStoredScopes = existingConnection | |
| 1406 | + ? (existingGrantedScopes ?? splitOAuthScopes(existingConnection.oauthScope)) | |
| 1407 | + : activeOAuth2AuthState?.connectionId === connectionId | |
| 1408 | + ? new Set(activeOAuth2AuthState.grantedScopes) | |
| 1409 | + : new Set<string>(); | |
| 1410 | + const hasStoredConnection = Boolean(existingConnection) || currentStoredScopes.size > 0; | |
| 1411 | + const identityScopes = identityScopesForPreset(configuredOAuth2IdentityScopes); | |
| 1412 | + const missingBatches = googleConsentBatches | |
| 1413 | + .map((batch) => { | |
| 1414 | + const missingApiScopes = batch.apiScopes.filter( | |
| 1415 | + (scope) => !currentStoredScopes.has(scope), | |
| 1416 | + ); | |
| 1417 | + return { ...batch, apiScopes: missingApiScopes }; | |
| 1418 | + }) | |
| 1419 | + .filter((batch) => batch.apiScopes.length > 0); | |
| 1420 | + if (missingBatches.length === 0 && existingConnection) { | |
| 1421 | + setOAuthTokenTargetScope(existingConnection.scopeId); | |
| 1422 | + setOauth2AuthState({ | |
| 1423 | + fingerprint: selectedOAuth2Fingerprint, | |
| 1424 | + auth: { | |
| 1425 | + connectionId: existingConnection.id, | |
| 1426 | + grantedScopes: [...currentStoredScopes], | |
| 1427 | + scopeId: existingConnection.scopeId, | |
| 1428 | + }, | |
| 1429 | + }); | |
| 1430 | + setOauth2Error(null); | |
| 1431 | + return; | |
| 1432 | + } | |
| 1433 | + | |
| 1434 | + const runBatch = async ( | |
| 1435 | + batchIndex: number, | |
| 1436 | + accumulatedScopes: readonly string[], | |
| 1437 | + currentConnectionId: string, | |
| 1438 | + ): Promise<void> => { | |
| 1439 | + const batch = missingBatches[batchIndex]; | |
| 1440 | + if (!batch) { | |
| 1441 | + setOAuthTokenTargetScope(tokenTargetScope); | |
| 1442 | + setOauth2AuthState({ | |
| 1443 | + fingerprint: selectedOAuth2Fingerprint, | |
| 1444 | + auth: { | |
| 1445 | + connectionId: currentConnectionId, | |
| 1446 | + grantedScopes: accumulatedScopes, | |
| 1447 | + scopeId: tokenTargetScope, | |
| 1448 | + }, | |
| 1449 | + }); | |
| 1450 | + setOauth2ProgressLabel(null); | |
| 1451 | + setOauth2Error(null); | |
| 1452 | + return; | |
| 1453 | + } | |
| 1454 | + | |
| 1455 | + const batchAuthorizationScopes = compactGoogleOAuthScopes([ | |
| 1456 | + ...accumulatedScopes, | |
| 1457 | + ...batch.apiScopes, | |
| 1458 | + ...identityScopes, | |
| 1459 | + ]); | |
| 1460 | + const scopesForBatchConnection = | |
| 1461 | + batchIndex === missingBatches.length - 1 | |
| 1462 | + ? scopesForConnection | |
| 1463 | + : batchIndex === 0 && !hasStoredConnection | |
| 1464 | + ? batchAuthorizationScopes | |
| 1465 | + : mergeOAuthScopes(accumulatedScopes, batchAuthorizationScopes); | |
| 1466 | + setOauth2ProgressLabel( | |
| 1467 | + `Connect ${batch.label} (${batchIndex + 1} of ${missingBatches.length})`, | |
| 1468 | + ); | |
| 1469 | + | |
| 1470 | + await startAuthorizationCodeFlow({ | |
| 1471 | + connectionId: currentConnectionId, | |
| 1472 | + scopesForConnection: scopesForBatchConnection, | |
| 1473 | + authorizationScopes: batchAuthorizationScopes, | |
| 1474 | + identityLabel: existingConnection?.identityLabel ?? `${displayName} OAuth`, | |
| 1475 | + onSuccess: (result) => { | |
| 1476 | + const nextAccumulatedScopes = result.scope | |
| 1477 | + ? mergeOAuthScopes(accumulatedScopes, splitOAuthScopes(result.scope)) | |
| 1478 | + : scopesForBatchConnection; | |
| 1479 | + window.setTimeout(() => { | |
| 1480 | + void runBatch(batchIndex + 1, nextAccumulatedScopes, result.connectionId); | |
| 1481 | + }, 0); | |
| 1482 | + }, | |
| 1483 | + }); | |
| 1484 | + }; | |
| 1485 | + | |
| 1486 | + await runBatch(0, [...currentStoredScopes], connectionId); | |
| 1487 | + return; | |
| 1488 | + } | |
| 1489 | + | |
| 998 | 1490 | const authorizationStrategy = existingConnection |
| 999 | 1491 | ? { |
| 1000 | 1492 | kind: "authorization-code-existing-client" as const, |
| 1001 | 1493 | authorizationEndpoint: authorizationUrl, |
| 1002 | 1494 | tokenEndpoint: tokenUrl, |
| 1003 | 1495 | issuerUrl, |
| 1004 | − scopes: scopesForAuthorization, | |
| 1496 | + scopes: scopesForConnection, | |
| 1005 | 1497 | ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 |
| 1006 | 1498 | ? { authorizationScopes: scopesForProviderAuthorization } |
| 1007 | 1499 | : {}), |
| @@ -1019,7 +1511,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines | ||
| 1019 | 1511 | clientSecretSecretScopeId: oauth2ClientSecretSecretId |
| 1020 | 1512 | ? String(clientSecretSecretScope) |
| 1021 | 1513 | : null, |
| 1022 | − scopes: scopesForAuthorization, | |
| 1514 | + scopes: scopesForConnection, | |
| 1023 | 1515 | ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0 |
| 1024 | 1516 | ? { authorizationScopes: scopesForProviderAuthorization } |
| 1025 | 1517 | : {}), |
| @@ -1061,13 +1553,21 @@ export default function AddOpenApiSource(props: { · 35 unmodified lines | ||
| 1061 | 1553 | setOAuthTokenTargetScope(tokenTargetScope); |
| 1062 | 1554 | setOauth2AuthState({ |
| 1063 | 1555 | fingerprint: selectedOAuth2Fingerprint, |
| 1064 | − auth: { connectionId: result.connectionId }, | |
| 1556 | + auth: { | |
| 1557 | + connectionId: result.connectionId, | |
| 1558 | + grantedScopes: result.scope | |
| 1559 | + ? [...splitOAuthScopes(result.scope)] | |
| 1560 | + : scopesForConnection, | |
| 1561 | + scopeId: tokenTargetScope, | |
| 1562 | + }, | |
| 1065 | 1563 | }); |
| 1564 | + setOauth2ProgressLabel(null); | |
| 1066 | 1565 | setOauth2Error(null); |
| 1067 | 1566 | }, |
| 1068 | − onError: (message) => { | |
| 1567 | + onError: (message, details) => { | |
| 1069 | 1568 | setStartingOAuth(false); |
| 1070 | − setOauth2Error(message); | |
| 1569 | + setOauth2ProgressLabel(null); | |
| 1570 | + setOauth2Error(oauthErrorMessage(selectedOAuth2ProviderLabel, message, details)); | |
| 1071 | 1571 | }, |
| 1072 | 1572 | }); |
| 1073 | 1573 | }, |
| @@ -1075,11 +1575,15 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 1075 | 1575 | selectedOAuth2Preset, |
| 1076 | 1576 | oauth2ClientIdSecretId, |
| 1077 | 1577 | oauth2ClientSecretSecretId, |
| 1078 | − oauth2SelectedScopes, | |
| 1578 | + effectiveOAuth2SelectedApiScopes, | |
| 1079 | 1579 | selectedOAuth2Scopes, |
| 1080 | 1580 | selectedOAuth2IsGoogle, |
| 1081 | 1581 | oauth2RedirectUrl, |
| 1082 | − resolvedBaseUrl, | |
| 1582 | + effectiveResolvedBaseUrl, | |
| 1583 | + configuredOAuth2IdentityScopes, | |
| 1584 | + googleBatchAddItems, | |
| 1585 | + googleConsentBatches, | |
| 1586 | + googleServicePickerEnabled, | |
| 1083 | 1587 | preview, |
| 1084 | 1588 | doStartOAuth, |
| 1085 | 1589 | identity.name, |
| @@ -1089,7 +1593,10 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 1089 | 1593 | oauthTokenTargetScope, |
| 1090 | 1594 | oauth2ClientIdScope, |
| 1091 | 1595 | oauth2ClientSecretScope, |
| 1596 | + activeOAuth2AuthState, | |
| 1597 | + grantedScopesForConnection, | |
| 1092 | 1598 | sourceScope, |
| 1599 | + selectedOAuth2ProviderLabel, | |
| 1093 | 1600 | ], |
| 1094 | 1601 | ); |
| 1095 | 1602 | |
| @@ -1106,7 +1613,11 @@ export default function AddOpenApiSource(props: { · 10 unmodified lines | ||
| 1106 | 1613 | setOAuthTokenTargetScope(connection.scopeId); |
| 1107 | 1614 | setOauth2AuthState({ |
| 1108 | 1615 | fingerprint: selectedOAuth2Fingerprint, |
| 1109 | − auth: { connectionId: connection.id }, | |
| 1616 | + auth: { | |
| 1617 | + connectionId: connection.id, | |
| 1618 | + grantedScopes: [...splitOAuthScopes(connection.oauthScope)], | |
| 1619 | + scopeId: connection.scopeId, | |
| 1620 | + }, | |
| 1110 | 1621 | }); |
| 1111 | 1622 | setOauth2Error(null); |
| 1112 | 1623 | }, |
| @@ -1116,14 +1627,35 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 1116 | 1627 | const handleAdd = async () => { |
| 1117 | 1628 | setAdding(true); |
| 1118 | 1629 | setAddError(null); |
| 1630 | + const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); | |
| 1631 | + const clientIdBindingScope = oauth2ClientIdScope ?? sourceScope; | |
| 1632 | + const clientSecretBindingScope = oauth2ClientSecretScope ?? sourceScope; | |
| 1633 | + | |
| 1634 | + if (googleServicePickerEnabled && (googleBatchPendingCount > 0 || googleBatchError)) { | |
| 1635 | + setAddError( | |
| 1636 | + googleBatchError?.status === "error" | |
| 1637 | + ? googleBatchError.message | |
| 1638 | + : "Still loading selected Google services", | |
| 1639 | + ); | |
| 1640 | + setAdding(false); | |
| 1641 | + return; | |
| 1642 | + } | |
| 1643 | + | |
| 1119 | 1644 | const namespace = resolvedSourceId; |
| 1645 | + const specForAdd = | |
| 1646 | + googleServicePickerEnabled && selectedGooglePresets.length > 0 | |
| 1647 | + ? { | |
| 1648 | + kind: "googleDiscoveryBundle" as const, | |
| 1649 | + urls: selectedGooglePresets.flatMap((preset) => (preset.url ? [preset.url] : [])), | |
| 1650 | + } | |
| 1651 | + : specInputForAdd(specUrl); | |
| 1120 | 1652 | const exit = await doAdd({ |
| 1121 | 1653 | params: { scopeId }, |
| 1122 | 1654 | payload: { |
| 1123 | − spec: specInputForAdd(specUrl), | |
| 1655 | + spec: specForAdd, | |
| 1124 | 1656 | name: resolvedDisplayName, |
| 1125 | 1657 | namespace, |
| 1126 | − baseUrl: resolvedBaseUrl, | |
| 1658 | + baseUrl: effectiveResolvedBaseUrl, | |
| 1127 | 1659 | ...(configuredSpecFetchCredentials |
| 1128 | 1660 | ? { specFetchCredentials: configuredSpecFetchCredentials } |
| 1129 | 1661 | : {}), |
| @@ -1142,9 +1674,6 @@ export default function AddOpenApiSource(props: { · 12 unmodified lines | ||
| 1142 | 1674 | } |
| 1143 | 1675 | |
| 1144 | 1676 | const sourceId = exit.value.namespace; |
| 1145 | − const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); | |
| 1146 | − const clientIdBindingScope = oauth2ClientIdScope ?? sourceScope; | |
| 1147 | − const clientSecretBindingScope = oauth2ClientSecretScope ?? sourceScope; | |
| 1148 | 1677 | |
| 1149 | 1678 | for (const binding of headerBindings) { |
| 1150 | 1679 | const bindingExit = await doSetBinding({ |
| @@ -1280,6 +1809,19 @@ export default function AddOpenApiSource(props: { · 129 unmodified lines | ||
| 1280 | 1809 | props.onComplete(); |
| 1281 | 1810 | }; |
| 1282 | 1811 | |
| 1812 | + const handleToggleAllGoogleServices = () => { | |
| 1813 | + setSelectedGoogleServiceIds((previous) => { | |
| 1814 | + const next = new Set(previous); | |
| 1815 | + if (allStandardGoogleServicesSelected) { | |
| 1816 | + for (const presetId of GOOGLE_STANDARD_SERVICE_IDS) next.delete(presetId); | |
| 1817 | + return next; | |
| 1818 | + } | |
| 1819 | + for (const presetId of GOOGLE_STANDARD_SERVICE_IDS) next.add(presetId); | |
| 1820 | + return next; | |
| 1821 | + }); | |
| 1822 | + setOauth2AuthState(null); | |
| 1823 | + }; | |
| 1824 | + | |
| 1283 | 1825 | // ---- Render ---- |
| 1284 | 1826 | |
| 1285 | 1827 | return ( |
| @@ -1349,33 +1891,164 @@ export default function AddOpenApiSource(props: { · 63 unmodified lines | ||
| 1349 | 1891 | |
| 1350 | 1892 | {/* ── Source information card (shown after analysis) ── */} |
| 1351 | 1893 | {preview ? ( |
| 1352 | − <OpenApiSourceDetailsFields | |
| 1353 | − title={Option.getOrElse(preview.title, () => "API")} | |
| 1354 | − description={`${Option.getOrElse(preview.version, () => "")}${ | |
| 1355 | − Option.isSome(preview.version) ? " · " : "" | |
| 1356 | − }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ | |
| 1357 | − preview.tags.length > 0 | |
| 1358 | − ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}` | |
| 1359 | − : "" | |
| 1360 | − }`} | |
| 1361 | − identity={identity} | |
| 1362 | − baseUrl={resolvedBaseUrl} | |
| 1363 | − onBaseUrlChange={setBaseUrl} | |
| 1364 | − baseUrlOptions={baseUrlOptions} | |
| 1365 | − specUrl={specUrl} | |
| 1366 | − onSpecUrlChange={(value) => { | |
| 1367 | − setSpecUrl(value); | |
| 1368 | − setPreview(null); | |
| 1369 | − setBaseUrl(""); | |
| 1370 | − setCustomHeaders([]); | |
| 1371 | − setStrategy({ kind: "none" }); | |
| 1372 | − setOauth2AuthState(null); | |
| 1373 | − setOauth2Error(null); | |
| 1374 | − }} | |
| 1375 | − faviconIcon={previewPresetIcon} | |
| 1376 | − faviconUrl={resolvedBaseUrl} | |
| 1377 | − baseUrlMissingMessage="A base URL is required to make requests." | |
| 1378 | − /> | |
| 1894 | + googleServicePickerEnabled && primaryGooglePreset ? ( | |
| 1895 | + <OpenApiSourceDetailsFields | |
| 1896 | + title="Google" | |
| 1897 | + description={`${selectedGoogleServiceIdList.length} service${ | |
| 1898 | + selectedGoogleServiceIdList.length !== 1 ? "s" : "" | |
| 1899 | + }${ | |
| 1900 | + googleBundleOperationCount > 0 | |
| 1901 | + ? ` · ${googleBundleOperationCount} operation${ | |
| 1902 | + googleBundleOperationCount !== 1 ? "s" : "" | |
| 1903 | + }` | |
| 1904 | + : "" | |
| 1905 | + }`} | |
| 1906 | + identity={identity} | |
| 1907 | + baseUrl={effectiveResolvedBaseUrl} | |
| 1908 | + onBaseUrlChange={setBaseUrl} | |
| 1909 | + faviconIcon={GOOGLE_ICON} | |
| 1910 | + faviconUrl={GOOGLE_BUNDLE_BASE_URL} | |
| 1911 | + baseUrlMissingMessage="A base URL is required to make requests." | |
| 1912 | + /> | |
| 1913 | + ) : ( | |
| 1914 | + <OpenApiSourceDetailsFields | |
| 1915 | + title={Option.getOrElse(preview.title, () => "API")} | |
| 1916 | + description={`${Option.getOrElse(preview.version, () => "")}${ | |
| 1917 | + Option.isSome(preview.version) ? " · " : "" | |
| 1918 | + }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ | |
| 1919 | + preview.tags.length > 0 | |
| 1920 | + ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}` | |
| 1921 | + : "" | |
| 1922 | + }`} | |
| 1923 | + identity={identity} | |
| 1924 | + baseUrl={resolvedBaseUrl} | |
| 1925 | + onBaseUrlChange={setBaseUrl} | |
| 1926 | + baseUrlOptions={baseUrlOptions} | |
| 1927 | + specUrl={specUrl} | |
| 1928 | + onSpecUrlChange={(value) => { | |
| 1929 | + setSpecUrl(value); | |
| 1930 | + setPreview(null); | |
| 1931 | + setBaseUrl(""); | |
| 1932 | + setCustomHeaders([]); | |
| 1933 | + setStrategy({ kind: "none" }); | |
| 1934 | + setOauth2AuthState(null); | |
| 1935 | + setOauth2Error(null); | |
| 1936 | + }} | |
| 1937 | + faviconIcon={previewPresetIcon} | |
| 1938 | + faviconUrl={resolvedBaseUrl} | |
| 1939 | + baseUrlMissingMessage="A base URL is required to make requests." | |
| 1940 | + /> | |
| 1941 | + ) | |
| 1942 | + ) : null} | |
| 1943 | + | |
| 1944 | + {googleServicePickerEnabled && primaryGooglePreset ? ( | |
| 1945 | + <section className="space-y-2.5"> | |
| 1946 | + <div className="flex items-center justify-between gap-3"> | |
| 1947 | + <FieldLabel>Google services</FieldLabel> | |
| 1948 | + <div className="flex items-center gap-2"> | |
| 1949 | + <span className="text-[11px] text-muted-foreground"> | |
| 1950 | + {selectedGoogleServiceIdList.length} selected | |
| 1951 | + </span> | |
| 1952 | + <Button | |
| 1953 | + type="button" | |
| 1954 | + variant="ghost" | |
| 1955 | + size="xs" | |
| 1956 | + onClick={handleToggleAllGoogleServices} | |
| 1957 | + className="-mr-2 h-6 px-2 text-[11px] text-muted-foreground hover:text-foreground" | |
| 1958 | + > | |
| 1959 | + {allStandardGoogleServicesSelected ? "Clear all" : "Select all"} | |
| 1960 | + </Button> | |
| 1961 | + </div> | |
| 1962 | + </div> | |
| 1963 | + <div className="max-h-72 overflow-y-auto rounded-lg border border-border/60 bg-muted/10 p-2"> | |
| 1964 | + <div className="grid gap-1.5 sm:grid-cols-2"> | |
| 1965 | + {googleOpenApiPresets.map((preset) => { | |
| 1966 | + const selected = selectedGoogleServiceIdList.includes(preset.id); | |
| 1967 | + const userOAuthUnsupported = preset.oauthAudience === "unsupported-user"; | |
| 1968 | + const previewState = | |
| 1969 | + preset.id === primaryGooglePreset.id | |
| 1970 | + ? ({ status: "success", preview: preview as SpecPreview } as const) | |
| 1971 | + : googleServicePreviews[preset.id]; | |
| 1972 | + return ( | |
| 1973 | + <Label | |
| 1974 | + key={preset.id} | |
| 1975 | + className={`flex items-start gap-2 rounded-md border px-2.5 py-2 transition-colors ${ | |
| 1976 | + userOAuthUnsupported | |
| 1977 | + ? "cursor-not-allowed border-border/30 bg-background/20 opacity-60" | |
| 1978 | + : selected | |
| 1979 | + ? "cursor-pointer border-primary/35 bg-primary/[0.04]" | |
| 1980 | + : "cursor-pointer border-border/50 bg-background/40 hover:bg-accent/40" | |
| 1981 | + }`} | |
| 1982 | + > | |
| 1983 | + <Checkbox | |
| 1984 | + checked={selected} | |
| 1985 | + disabled={userOAuthUnsupported} | |
| 1986 | + onCheckedChange={(checked) => { | |
| 1987 | + if (userOAuthUnsupported) return; | |
| 1988 | + setSelectedGoogleServiceIds((previous) => { | |
| 1989 | + const next = new Set(previous); | |
| 1990 | + if (checked === true) { | |
| 1991 | + next.add(preset.id); | |
| 1992 | + } else { | |
| 1993 | + next.delete(preset.id); | |
| 1994 | + } | |
| 1995 | + return next; | |
| 1996 | + }); | |
| 1997 | + if (!googleServicePickerEnabled) setOauth2AuthState(null); | |
| 1998 | + }} | |
| 1999 | + className="mt-0.5" | |
| 2000 | + /> | |
| 2001 | + {preset.icon ? ( | |
| 2002 | + <img | |
| 2003 | + src={preset.icon} | |
| 2004 | + alt="" | |
| 2005 | + className="mt-0.5 size-4 shrink-0 object-contain" | |
| 2006 | + loading="lazy" | |
| 2007 | + /> | |
| 2008 | + ) : null} | |
| 2009 | + <span className="min-w-0 flex-1"> | |
| 2010 | + <span className="flex min-w-0 items-center gap-1.5"> | |
| 2011 | + <span className="truncate text-[12px] font-medium text-foreground"> | |
| 2012 | + {preset.name} | |
| 2013 | + </span> | |
| 2014 | + {userOAuthUnsupported ? ( | |
| 2015 | + <span className="shrink-0 rounded-full border border-border/60 px-1.5 py-0.5 text-[9px] uppercase tracking-normal text-muted-foreground"> | |
| 2016 | + Unavailable | |
| 2017 | + </span> | |
| 2018 | + ) : null} | |
| 2019 | + {selected && previewState?.status === "loading" ? ( | |
| 2020 | + <IOSSpinner className="size-3 shrink-0" /> | |
| 2021 | + ) : null} | |
| 2022 | + </span> | |
| 2023 | + <span className="mt-0.5 block truncate text-[10px] text-muted-foreground"> | |
| 2024 | + {previewState?.status === "success" | |
| 2025 | + ? `${previewState.preview.operationCount} operations` | |
| 2026 | + : previewState?.status === "error" | |
| 2027 | + ? "Could not preview" | |
| 2028 | + : preset.summary} | |
| 2029 | + </span> | |
| 2030 | + </span> | |
| 2031 | + </Label> | |
| 2032 | + ); | |
| 2033 | + })} | |
| 2034 | + </div> | |
| 2035 | + </div> | |
| 2036 | + {googleBatchError?.status === "error" ? ( | |
| 2037 | + <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 2038 | + <p className="text-[11px] text-destructive">{googleBatchError.message}</p> | |
| 2039 | + </div> | |
| 2040 | + ) : null} | |
| 2041 | + {showGoogleSelectionWarning ? ( | |
| 2042 | + <Info variant="warning"> | |
| 2043 | + <InfoTitle>Google may reject broad permission requests</InfoTitle> | |
| 2044 | + <InfoDescription> | |
| 2045 | + Large Google selections and admin or developer APIs can ask for permission | |
| 2046 | + combinations Google will not approve in one sign-in. If OAuth fails, select fewer | |
| 2047 | + services or add sensitive services as separate Google sources. | |
| 2048 | + </InfoDescription> | |
| 2049 | + </Info> | |
| 2050 | + ) : null} | |
| 2051 | + </section> | |
| 1379 | 2052 | ) : null} |
| 1380 | 2053 | |
| 1381 | 2054 | {analyzeError && ( |
| @@ -1423,7 +2096,10 @@ export default function AddOpenApiSource(props: { · 41 unmodified lines | ||
| 1423 | 2096 | })} |
| 1424 | 2097 | {oauth2Presets.map((preset, i) => { |
| 1425 | 2098 | const selected = strategy.kind === "oauth2" && strategy.presetIndex === i; |
| 1426 | − const scopeCount = Object.keys(preset.scopes).length; | |
| 2099 | + const scopeCount = | |
| 2100 | + selected && googleServicePickerEnabled | |
| 2101 | + ? selectedOAuth2Scopes.length | |
| 2102 | + : Object.keys(preset.scopes).length; | |
| 1427 | 2103 | return ( |
| 1428 | 2104 | <Label |
| 1429 | 2105 | key={`oauth2-${i}`} |
| @@ -1622,7 +2298,53 @@ export default function AddOpenApiSource(props: { · 192 unmodified lines | ||
| 1622 | 2298 | </CollapsibleTrigger> |
| 1623 | 2299 | <CollapsibleContent> |
| 1624 | 2300 | <div className="space-y-1 rounded-md border border-border/50 bg-background/50 p-2"> |
| 1625 | − {Object.keys(selectedOAuth2Preset.scopes).length === 0 ? ( | |
| 2301 | + {googleServicePickerEnabled ? ( | |
| 2302 | + <div className="space-y-2"> | |
| 2303 | + {googleBatchAddItems.map((item) => { | |
| 2304 | + const oauth2Preset = item.preview.oauth2Presets[0]; | |
| 2305 | + const rawScopes = Object.keys(oauth2Preset?.scopes ?? {}); | |
| 2306 | + const scopes = filterGoogleUserConsentOAuthScopes(rawScopes); | |
| 2307 | + const unavailableScopeCount = rawScopes.length - scopes.length; | |
| 2308 | + return ( | |
| 2309 | + <div key={item.preset.id} className="space-y-1"> | |
| 2310 | + <div className="text-[11px] font-medium text-foreground"> | |
| 2311 | + {item.preset.name} | |
| 2312 | + </div> | |
| 2313 | + {scopes.length === 0 ? ( | |
| 2314 | + <div className="text-[10px] text-muted-foreground"> | |
| 2315 | + No user OAuth scopes requested for this service. | |
| 2316 | + </div> | |
| 2317 | + ) : ( | |
| 2318 | + <div className="flex flex-wrap gap-1.5"> | |
| 2319 | + {scopes.map((scope) => ( | |
| 2320 | + <span | |
| 2321 | + key={scope} | |
| 2322 | + title={scope} | |
| 2323 | + className="max-w-full truncate rounded border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground" | |
| 2324 | + > | |
| 2325 | + {scope} | |
| 2326 | + </span> | |
| 2327 | + ))} | |
| 2328 | + </div> | |
| 2329 | + )} | |
| 2330 | + {unavailableScopeCount > 0 ? ( | |
| 2331 | + <div className="text-[10px] text-muted-foreground"> | |
| 2332 | + {unavailableScopeCount} scope | |
| 2333 | + {unavailableScopeCount === 1 ? "" : "s"} unavailable for | |
| 2334 | + Google user sign-in. | |
| 2335 | + </div> | |
| 2336 | + ) : null} | |
| 2337 | + </div> | |
| 2338 | + ); | |
| 2339 | + })} | |
| 2340 | + {googleBatchPendingCount > 0 ? ( | |
| 2341 | + <div className="flex items-center gap-2 text-[11px] text-muted-foreground"> | |
| 2342 | + <IOSSpinner className="size-3" /> | |
| 2343 | + Loading selected services… | |
| 2344 | + </div> | |
| 2345 | + ) : null} | |
| 2346 | + </div> | |
| 2347 | + ) : Object.keys(selectedOAuth2Preset.scopes).length === 0 ? ( | |
| 1626 | 2348 | <div className="text-[11px] italic text-muted-foreground"> |
| 1627 | 2349 | No scopes declared by the spec. |
| 1628 | 2350 | </div> |
| @@ -1683,8 +2405,8 @@ export default function AddOpenApiSource(props: { · 54 unmodified lines | ||
| 1683 | 2405 | <OAuthConnectedAccount |
| 1684 | 2406 | scopeId={oauthTokenTargetScope} |
| 1685 | 2407 | connectionId={oauth2Auth.connectionId} |
| 1686 | − scopeSummary={`${selectedOAuth2Scopes.length} scope${ | |
| 1687 | − selectedOAuth2Scopes.length === 1 ? "" : "s" | |
| 2408 | + scopeSummary={`${oauth2Auth.grantedScopes.length} scope${ | |
| 2409 | + oauth2Auth.grantedScopes.length === 1 ? "" : "s" | |
| 1688 | 2410 | } granted`} |
| 1689 | 2411 | sourceName={resolvedDisplayName} |
| 1690 | 2412 | onSetSourceName={identity.setName} |
| @@ -1698,7 +2420,9 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | ||
| 1698 | 2420 | <div className="flex items-center gap-2"> |
| 1699 | 2421 | <div className="flex flex-1 items-center gap-2 rounded-md border border-border/60 bg-background/50 px-3 py-2 text-[11px] text-muted-foreground"> |
| 1700 | 2422 | <Spinner className="size-3.5" /> |
| 1701 | − Waiting for OAuth… complete the flow in the popup, or cancel to retry. | |
| 2423 | + {oauth2ProgressLabel | |
| 2424 | + ? `${oauth2ProgressLabel} in the browser.` | |
| 2425 | + : "Waiting for OAuth… complete the flow in the popup, or cancel to retry."} | |
| 1702 | 2426 | </div> |
| 1703 | 2427 | <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}> |
| 1704 | 2428 | Cancel |
| @@ -1808,9 +2532,15 @@ export default function AddOpenApiSource(props: { · 103 unmodified lines | ||
| 1808 | 2532 | {adding && <Spinner className="size-3.5" />} |
| 1809 | 2533 | {adding |
| 1810 | 2534 | ? "Adding…" |
| 1811 | − : willAddWithoutInitialCredentials | |
| 1812 | − ? "Add without credentials" | |
| 1813 | − : "Add source"} | |
| 2535 | + : googleServicePickerEnabled && selectedGoogleServiceIdList.length === 0 | |
| 2536 | + ? "Select services" | |
| 2537 | + : googleServicePickerEnabled | |
| 2538 | + ? willAddWithoutInitialCredentials | |
| 2539 | + ? "Add Google without credentials" | |
| 2540 | + : "Add Google" | |
| 2541 | + : willAddWithoutInitialCredentials | |
| 2542 | + ? "Add without credentials" | |
| 2543 | + : "Add source"} | |
| 1814 | 2544 | </Button> |
| 1815 | 2545 | )} |
| 1816 | 2546 | </FloatActions> |
| @@ -0,0 +1,82 @@ | ||
| 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 | +`; | |
| @@ -6,11 +6,13 @@ import { · 5 unmodified lines | ||
| 6 | 6 | declaredContents, |
| 7 | 7 | DocResolver, |
| 8 | 8 | preferredResponseContent, |
| 9 | + resolveBaseUrl, | |
| 9 | 10 | type OperationObject, |
| 10 | 11 | type ParameterObject, |
| 11 | 12 | type PathItemObject, |
| 12 | 13 | type RequestBodyObject, |
| 13 | 14 | type ResponseObject, |
| 15 | + type ServerObject, | |
| 14 | 16 | } from "./openapi-utils"; |
| 15 | 17 | import { |
| 16 | 18 | EncodingObject, |
| @@ -227,16 +229,31 @@ const explicitToolPath = (operation: OperationObject): string | undefined => { · 210 unmodified lines | ||
| 227 | 229 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; |
| 228 | 230 | }; |
| 229 | 231 | |
| 232 | +const explicitPathTemplate = (operation: OperationObject): string | undefined => { | |
| 233 | + const value = (operation as Record<string, unknown>)["x-executor-pathTemplate"]; | |
| 234 | + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | |
| 235 | +}; | |
| 236 | + | |
| 230 | 237 | // --------------------------------------------------------------------------- |
| 231 | 238 | // Server extraction |
| 232 | 239 | // --------------------------------------------------------------------------- |
| 233 | 240 | |
| 234 | −const extractServers = (doc: ParsedDocument): ServerInfo[] => | |
| 235 | − (doc.servers ?? []).flatMap((server) => { | |
| 241 | +const extractServerList = (servers: readonly ServerObject[] | undefined): ServerInfo[] => | |
| 242 | + (servers ?? []).flatMap((server) => { | |
| 236 | 243 | if (!server.url) return []; |
| 237 | − const vars = server.variables | |
| 244 | + const serverVariables = server.variables as | |
| 245 | + | Record< | |
| 246 | + string, | |
| 247 | + { | |
| 248 | + readonly default?: string; | |
| 249 | + readonly enum?: readonly string[]; | |
| 250 | + readonly description?: string; | |
| 251 | + } | |
| 252 | + > | |
| 253 | + | undefined; | |
| 254 | + const vars = serverVariables | |
| 238 | 255 | ? Object.fromEntries( |
| 239 | − Object.entries(server.variables).flatMap(([name, v]) => { | |
| 256 | + Object.entries(serverVariables).flatMap(([name, v]) => { | |
| 240 | 257 | if (v.default === undefined || v.default === null) return []; |
| 241 | 258 | const enumValues = Array.isArray(v.enum) |
| 242 | 259 | ? v.enum.filter((x): x is string => typeof x === "string") |
| @@ -264,6 +281,21 @@ const extractServers = (doc: ParsedDocument): ServerInfo[] => · 21 unmodified lines | ||
| 264 | 281 | ]; |
| 265 | 282 | }); |
| 266 | 283 | |
| 284 | +const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers); | |
| 285 | + | |
| 286 | +const extractOperationBaseUrl = ( | |
| 287 | + pathItem: PathItemObject, | |
| 288 | + operation: OperationObject, | |
| 289 | +): string | undefined => { | |
| 290 | + const operationServers = extractServerList(operation.servers); | |
| 291 | + if (operationServers.length > 0) return resolveBaseUrl(operationServers); | |
| 292 | + | |
| 293 | + const pathServers = extractServerList(pathItem.servers); | |
| 294 | + if (pathServers.length > 0) return resolveBaseUrl(pathServers); | |
| 295 | + | |
| 296 | + return undefined; | |
| 297 | +}; | |
| 298 | + | |
| 267 | 299 | // --------------------------------------------------------------------------- |
| 268 | 300 | // Main extraction |
| 269 | 301 | // --------------------------------------------------------------------------- |
| @@ -294,13 +326,15 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 24 unmodified lines | ||
| 294 | 326 | const inputSchema = buildInputSchema(parameters, requestBody); |
| 295 | 327 | const outputSchema = extractOutputSchema(operation, r); |
| 296 | 328 | const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0); |
| 329 | + const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate; | |
| 297 | 330 | |
| 298 | 331 | operations.push( |
| 299 | 332 | ExtractedOperation.make({ |
| 300 | 333 | operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)), |
| 301 | 334 | toolPath: Option.fromNullishOr(explicitToolPath(operation)), |
| 302 | 335 | method, |
| 303 | − pathTemplate, | |
| 336 | + baseUrl: extractOperationBaseUrl(pathItem, operation), | |
| 337 | + pathTemplate: operationPathTemplate, | |
| 304 | 338 | summary: Option.fromNullishOr(operation.summary), |
| 305 | 339 | description: Option.fromNullishOr(operation.description), |
| 306 | 340 | tags, |
| @@ -2,7 +2,10 @@ import { expect, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import { Effect, Option, Schema } from "effect"; |
| 3 | 3 | import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; |
| 4 | 4 | |
| 5 | −import { convertGoogleDiscoveryToOpenApi } from "./google-discovery"; | |
| 5 | +import { | |
| 6 | + convertGoogleDiscoveryBundleToOpenApi, | |
| 7 | + convertGoogleDiscoveryToOpenApi, | |
| 8 | +} from "./google-discovery"; | |
| 6 | 9 | import { extract } from "./extract"; |
| 7 | 10 | import { parse } from "./parse"; |
| 8 | 11 | |
| @@ -18,11 +21,14 @@ const ConvertedOperation = Schema.Struct({ · 9 unmodified lines | ||
| 18 | 21 | schema: Schema.Unknown, |
| 19 | 22 | style: Schema.optional(Schema.String), |
| 20 | 23 | explode: Schema.optional(Schema.Boolean), |
| 24 | + allowReserved: Schema.optional(Schema.Boolean), | |
| 21 | 25 | }), |
| 22 | 26 | ), |
| 27 | + servers: Schema.optional(Schema.Array(Schema.Struct({ url: Schema.String }))), | |
| 23 | 28 | security: Schema.optional( |
| 24 | 29 | Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))), |
| 25 | 30 | ), |
| 31 | + "x-executor-pathTemplate": Schema.optional(Schema.String), | |
| 26 | 32 | requestBody: Schema.optional(Schema.Unknown), |
| 27 | 33 | responses: Schema.Unknown, |
| 28 | 34 | "x-google-scopes": Schema.Array(Schema.String), |
| @@ -236,3 +242,201 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI · 207 unmodified lines | ||
| 236 | 242 | expect(result.oauth2?.identityScopes).toEqual(["openid", "email", "profile"]); |
| 237 | 243 | }), |
| 238 | 244 | ); |
| 245 | + | |
| 246 | +it.effect("bundles Google Discovery documents into one Google OpenAPI source", () => | |
| 247 | + Effect.gen(function* () { | |
| 248 | + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ | |
| 249 | + documents: [ | |
| 250 | + { | |
| 251 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | |
| 252 | + // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 253 | + documentText: JSON.stringify({ | |
| 254 | + name: "gmail", | |
| 255 | + version: "v1", | |
| 256 | + title: "Gmail API", | |
| 257 | + rootUrl: "https://gmail.googleapis.com/", | |
| 258 | + servicePath: "", | |
| 259 | + auth: { | |
| 260 | + oauth2: { | |
| 261 | + scopes: { | |
| 262 | + "https://www.googleapis.com/auth/gmail.metadata": { | |
| 263 | + description: "Read metadata", | |
| 264 | + }, | |
| 265 | + }, | |
| 266 | + }, | |
| 267 | + }, | |
| 268 | + resources: { | |
| 269 | + users: { | |
| 270 | + resources: { | |
| 271 | + messages: { | |
| 272 | + methods: { | |
| 273 | + list: { | |
| 274 | + id: "gmail.users.messages.list", | |
| 275 | + httpMethod: "GET", | |
| 276 | + path: "gmail/v1/users/{userId}/messages", | |
| 277 | + response: { $ref: "Message" }, | |
| 278 | + scopes: ["https://www.googleapis.com/auth/gmail.metadata"], | |
| 279 | + parameters: { | |
| 280 | + userId: { | |
| 281 | + location: "path", | |
| 282 | + required: true, | |
| 283 | + type: "string", | |
| 284 | + }, | |
| 285 | + }, | |
| 286 | + }, | |
| 287 | + }, | |
| 288 | + }, | |
| 289 | + }, | |
| 290 | + }, | |
| 291 | + }, | |
| 292 | + schemas: { | |
| 293 | + Message: { | |
| 294 | + type: "object", | |
| 295 | + properties: { | |
| 296 | + id: { type: "string" }, | |
| 297 | + }, | |
| 298 | + }, | |
| 299 | + }, | |
| 300 | + }), | |
| 301 | + }, | |
| 302 | + { | |
| 303 | + discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1", | |
| 304 | + // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 305 | + documentText: JSON.stringify({ | |
| 306 | + name: "chat", | |
| 307 | + version: "v1", | |
| 308 | + title: "Google Chat API", | |
| 309 | + rootUrl: "https://chat.googleapis.com/", | |
| 310 | + servicePath: "", | |
| 311 | + auth: { | |
| 312 | + oauth2: { | |
| 313 | + scopes: { | |
| 314 | + "https://www.googleapis.com/auth/chat.spaces.readonly": { | |
| 315 | + description: "Read spaces", | |
| 316 | + }, | |
| 317 | + }, | |
| 318 | + }, | |
| 319 | + }, | |
| 320 | + resources: { | |
| 321 | + spaces: { | |
| 322 | + methods: { | |
| 323 | + get: { | |
| 324 | + id: "chat.spaces.get", | |
| 325 | + httpMethod: "GET", | |
| 326 | + path: "v1/{+name}", | |
| 327 | + response: { $ref: "Space" }, | |
| 328 | + scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"], | |
| 329 | + parameters: { | |
| 330 | + name: { | |
| 331 | + location: "path", | |
| 332 | + required: true, | |
| 333 | + type: "string", | |
| 334 | + }, | |
| 335 | + }, | |
| 336 | + }, | |
| 337 | + }, | |
| 338 | + resources: { | |
| 339 | + messages: { | |
| 340 | + methods: { | |
| 341 | + get: { | |
| 342 | + id: "chat.spaces.messages.get", | |
| 343 | + httpMethod: "GET", | |
| 344 | + path: "v1/{+name}", | |
| 345 | + response: { $ref: "Message" }, | |
| 346 | + scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"], | |
| 347 | + parameters: { | |
| 348 | + name: { | |
| 349 | + location: "path", | |
| 350 | + required: true, | |
| 351 | + type: "string", | |
| 352 | + }, | |
| 353 | + }, | |
| 354 | + }, | |
| 355 | + }, | |
| 356 | + }, | |
| 357 | + }, | |
| 358 | + }, | |
| 359 | + }, | |
| 360 | + schemas: { | |
| 361 | + Space: { | |
| 362 | + type: "object", | |
| 363 | + properties: { | |
| 364 | + name: { type: "string" }, | |
| 365 | + }, | |
| 366 | + }, | |
| 367 | + Message: { | |
| 368 | + type: "object", | |
| 369 | + properties: { | |
| 370 | + name: { type: "string" }, | |
| 371 | + }, | |
| 372 | + }, | |
| 373 | + }, | |
| 374 | + }), | |
| 375 | + }, | |
| 376 | + ], | |
| 377 | + }); | |
| 378 | + | |
| 379 | + const spec = decodeConvertedSpec(result.specText); | |
| 380 | + expect(result.title).toBe("Google"); | |
| 381 | + expect(result.baseUrl).toBe("https://www.googleapis.com/"); | |
| 382 | + expect(result.discoveryUrls).toEqual([ | |
| 383 | + "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | |
| 384 | + "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest", | |
| 385 | + ]); | |
| 386 | + expect(spec.servers).toEqual([{ url: "https://www.googleapis.com/" }]); | |
| 387 | + expect(spec.components.schemas).toHaveProperty("gmail_v1_Message"); | |
| 388 | + expect(spec.components.schemas).toHaveProperty("chat_v1_Message"); | |
| 389 | + | |
| 390 | + const gmailList = spec.paths["/gmail/v1/users/{userId}/messages"]?.get; | |
| 391 | + expect(gmailList).toMatchObject({ | |
| 392 | + operationId: "gmail.users.messages.list", | |
| 393 | + "x-executor-toolPath": "gmail.users.messages.list", | |
| 394 | + servers: [{ url: "https://gmail.googleapis.com/" }], | |
| 395 | + }); | |
| 396 | + expect(gmailList?.responses).toMatchObject({ | |
| 397 | + "200": { | |
| 398 | + content: { | |
| 399 | + "application/json": { | |
| 400 | + schema: { $ref: "#/components/schemas/gmail_v1_Message" }, | |
| 401 | + }, | |
| 402 | + }, | |
| 403 | + }, | |
| 404 | + }); | |
| 405 | + | |
| 406 | + const chatSpaceGet = spec.paths["/v1/{name}"]?.get; | |
| 407 | + const chatMessageGet = spec.paths["/chat.spaces.messages.get"]?.get; | |
| 408 | + expect(chatSpaceGet).toMatchObject({ | |
| 409 | + operationId: "chat.spaces.get", | |
| 410 | + "x-executor-pathTemplate": "/v1/{+name}", | |
| 411 | + servers: [{ url: "https://chat.googleapis.com/" }], | |
| 412 | + }); | |
| 413 | + expect(chatMessageGet).toMatchObject({ | |
| 414 | + operationId: "chat.spaces.messages.get", | |
| 415 | + "x-executor-pathTemplate": "/v1/{+name}", | |
| 416 | + servers: [{ url: "https://chat.googleapis.com/" }], | |
| 417 | + }); | |
| 418 | + expect(chatSpaceGet?.parameters).toContainEqual( | |
| 419 | + expect.objectContaining({ | |
| 420 | + name: "name", | |
| 421 | + in: "path", | |
| 422 | + allowReserved: true, | |
| 423 | + }), | |
| 424 | + ); | |
| 425 | + | |
| 426 | + const parsed = yield* parse(result.specText); | |
| 427 | + const extracted = yield* extract(parsed); | |
| 428 | + const extractedGmail = extracted.operations.find( | |
| 429 | + (candidate) => candidate.operationId === "gmail.users.messages.list", | |
| 430 | + ); | |
| 431 | + const extractedChatMessage = extracted.operations.find( | |
| 432 | + (candidate) => candidate.operationId === "chat.spaces.messages.get", | |
| 433 | + ); | |
| 434 | + expect(extractedGmail?.baseUrl).toBe("https://gmail.googleapis.com/"); | |
| 435 | + expect(extractedChatMessage?.pathTemplate).toBe("/v1/{+name}"); | |
| 436 | + expect(extractedChatMessage?.baseUrl).toBe("https://chat.googleapis.com/"); | |
| 437 | + expect(result.oauth2?.scopes).toEqual([ | |
| 438 | + "https://www.googleapis.com/auth/gmail.metadata", | |
| 439 | + "https://www.googleapis.com/auth/chat.spaces.readonly", | |
| 440 | + ]); | |
| 441 | + }), | |
| 442 | +); | |
| @@ -15,6 +15,7 @@ import type { OAuth2SourceConfig } from "./types"; · 14 unmodified lines | ||
| 15 | 15 | import type { SpecFetchCredentials } from "./parse"; |
| 16 | 16 | |
| 17 | 17 | const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; |
| 18 | +const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | |
| 18 | 19 | const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; |
| 19 | 20 | const GOOGLE_OAUTH_ISSUER_URL = "https://accounts.google.com"; |
| 20 | 21 | const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; |
| @@ -54,12 +55,16 @@ type OpenApiParameterObject = { · 33 unmodified lines | ||
| 54 | 55 | readonly schema: OpenApiSchemaObject; |
| 55 | 56 | readonly style?: "form"; |
| 56 | 57 | readonly explode?: boolean; |
| 58 | + readonly allowReserved?: boolean; | |
| 57 | 59 | }; |
| 58 | 60 | |
| 59 | 61 | type OpenApiOperationObject = { |
| 60 | 62 | readonly operationId: string; |
| 61 | 63 | readonly "x-executor-toolPath": string; |
| 64 | + readonly "x-executor-pathTemplate"?: string; | |
| 65 | + readonly tags?: readonly string[]; | |
| 62 | 66 | readonly description?: string; |
| 67 | + readonly servers?: readonly { readonly url: string }[]; | |
| 63 | 68 | readonly parameters: readonly OpenApiParameterObject[]; |
| 64 | 69 | readonly requestBody?: { |
| 65 | 70 | readonly required: false; |
| @@ -108,12 +113,21 @@ type OpenApiDocument = { · 42 unmodified lines | ||
| 108 | 113 | >; |
| 109 | 114 | }; |
| 110 | 115 | readonly security?: readonly Record<string, readonly string[]>[]; |
| 111 | − readonly "x-executor-origin": { | |
| 112 | − readonly kind: "googleDiscovery"; | |
| 113 | − readonly discoveryUrl: string; | |
| 114 | − readonly service: string; | |
| 115 | − readonly version: string; | |
| 116 | − }; | |
| 116 | + readonly "x-executor-origin": | |
| 117 | + | { | |
| 118 | + readonly kind: "googleDiscovery"; | |
| 119 | + readonly discoveryUrl: string; | |
| 120 | + readonly service: string; | |
| 121 | + readonly version: string; | |
| 122 | + } | |
| 123 | + | { | |
| 124 | + readonly kind: "googleDiscoveryBundle"; | |
| 125 | + readonly services: readonly { | |
| 126 | + readonly discoveryUrl: string; | |
| 127 | + readonly service: string; | |
| 128 | + readonly version: string; | |
| 129 | + }[]; | |
| 130 | + }; | |
| 117 | 131 | }; |
| 118 | 132 | |
| 119 | 133 | const TextOption = Schema.OptionFromOptional(Schema.Trim).pipe( |
| @@ -206,6 +220,7 @@ export interface GoogleDiscoveryOpenApiConversion { · 86 unmodified lines | ||
| 206 | 220 | readonly title: string; |
| 207 | 221 | readonly service: string; |
| 208 | 222 | readonly version: string; |
| 223 | + readonly discoveryUrls?: readonly string[]; | |
| 209 | 224 | readonly oauth2?: OAuth2SourceConfig; |
| 210 | 225 | } |
| 211 | 226 | |
| @@ -282,6 +297,34 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 70 unmodified lines | ||
| 282 | 297 | |
| 283 | 298 | const schemaRef = (name: string) => `#/components/schemas/${name}`; |
| 284 | 299 | |
| 300 | +const identitySchemaName = (name: string): string => name; | |
| 301 | + | |
| 302 | +const schemaComponentPart = (value: string): string => | |
| 303 | + value | |
| 304 | + .trim() | |
| 305 | + .replace(/[^A-Za-z0-9._-]+/g, "_") | |
| 306 | + .replace(/^_+|_+$/g, "") || "schema"; | |
| 307 | + | |
| 308 | +const normalizeDiscoveryPathTemplate = (pathTemplate: string): string => | |
| 309 | + pathTemplate.replaceAll(/\{\+([^{}]+)\}/g, "{$1}"); | |
| 310 | + | |
| 311 | +const pathUsesReservedExpansion = (pathTemplate: string, parameterName: string): boolean => | |
| 312 | + pathTemplate.includes(`{+${parameterName}}`); | |
| 313 | + | |
| 314 | +const uniquePathKey = ( | |
| 315 | + paths: Record<string, Record<string, OpenApiOperationObject>>, | |
| 316 | + preferredPath: string, | |
| 317 | + method: string, | |
| 318 | + toolPath: string, | |
| 319 | +): string => { | |
| 320 | + if (!paths[preferredPath]?.[method]) return preferredPath; | |
| 321 | + const disambiguated = `/${toolPath.replace(/[^A-Za-z0-9._~-]+/g, "/")}`; | |
| 322 | + if (!paths[disambiguated]?.[method]) return disambiguated; | |
| 323 | + let index = 2; | |
| 324 | + while (paths[`${disambiguated}/${index}`]?.[method]) index += 1; | |
| 325 | + return `${disambiguated}/${index}`; | |
| 326 | +}; | |
| 327 | + | |
| 285 | 328 | const discoveryDescription = (value: unknown): string | undefined => |
| 286 | 329 | typeof value === "string" |
| 287 | 330 | ? value |
| @@ -326,10 +369,13 @@ const schemaType = (value: unknown): OpenApiSchemaObject["type"] | undefined => · 38 unmodified lines | ||
| 326 | 369 | ? (value as OpenApiSchemaObject["type"]) |
| 327 | 370 | : undefined; |
| 328 | 371 | |
| 329 | −const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { | |
| 372 | +const discoverySchemaToOpenApiSchema = ( | |
| 373 | + raw: unknown, | |
| 374 | + schemaNameForRef: (name: string) => string = identitySchemaName, | |
| 375 | +): OpenApiSchemaObject => { | |
| 330 | 376 | if (!isRecord(raw)) return {}; |
| 331 | 377 | const schema = raw; |
| 332 | − if (typeof schema.$ref === "string") return { $ref: schemaRef(schema.$ref) }; | |
| 378 | + if (typeof schema.$ref === "string") return { $ref: schemaRef(schemaNameForRef(schema.$ref)) }; | |
| 333 | 379 | |
| 334 | 380 | const description = discoveryDescription(schema.description); |
| 335 | 381 | const title = discoveryDescription(schema.title); |
| @@ -351,7 +397,11 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 15 unmodified lines | ||
| 351 | 397 | } satisfies OpenApiSchemaObject; |
| 352 | 398 | |
| 353 | 399 | if (type === "array") { |
| 354 | − return { ...base, type: "array", items: discoverySchemaToOpenApiSchema(schema.items) }; | |
| 400 | + return { | |
| 401 | + ...base, | |
| 402 | + type: "array", | |
| 403 | + items: discoverySchemaToOpenApiSchema(schema.items, schemaNameForRef), | |
| 404 | + }; | |
| 355 | 405 | } |
| 356 | 406 | |
| 357 | 407 | const properties = schema.properties; |
| @@ -364,7 +414,7 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 6 unmodified lines | ||
| 364 | 414 | ? Object.fromEntries( |
| 365 | 415 | Object.entries(properties).map(([name, value]) => [ |
| 366 | 416 | name, |
| 367 | − discoverySchemaToOpenApiSchema(value), | |
| 417 | + discoverySchemaToOpenApiSchema(value, schemaNameForRef), | |
| 368 | 418 | ]), |
| 369 | 419 | ) |
| 370 | 420 | : undefined; |
| @@ -374,7 +424,7 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 3 unmodified lines | ||
| 374 | 424 | ? undefined |
| 375 | 425 | : typeof schema.additionalProperties === "boolean" |
| 376 | 426 | ? schema.additionalProperties |
| 377 | − : discoverySchemaToOpenApiSchema(schema.additionalProperties); | |
| 427 | + : discoverySchemaToOpenApiSchema(schema.additionalProperties, schemaNameForRef); | |
| 378 | 428 | return { |
| 379 | 429 | ...base, |
| 380 | 430 | type: "object", |
| @@ -389,8 +439,11 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 8 unmodified lines | ||
| 389 | 439 | return type !== undefined ? { ...base, type } : base; |
| 390 | 440 | }; |
| 391 | 441 | |
| 392 | −const parameterSchema = (parameter: DiscoveryParameter): OpenApiSchemaObject => { | |
| 393 | − const base = discoverySchemaToOpenApiSchema(parameter); | |
| 442 | +const parameterSchema = ( | |
| 443 | + parameter: DiscoveryParameter, | |
| 444 | + schemaNameForRef: (name: string) => string = identitySchemaName, | |
| 445 | +): OpenApiSchemaObject => { | |
| 446 | + const base = discoverySchemaToOpenApiSchema(parameter, schemaNameForRef); | |
| 394 | 447 | return parameter.repeated |
| 395 | 448 | ? { |
| 396 | 449 | type: "array", |
| @@ -417,6 +470,144 @@ const discoveryScopes = (document: DiscoveryDocument): Record<string, string> => · 20 unmodified lines | ||
| 417 | 470 | ]), |
| 418 | 471 | ); |
| 419 | 472 | |
| 473 | +const allDiscoveryMethods = (document: DiscoveryDocument): DiscoveryMethod[] => [ | |
| 474 | + ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)), | |
| 475 | + ...Object.values(document.resources ?? {}).flatMap(collectMethods), | |
| 476 | +]; | |
| 477 | + | |
| 478 | +type DiscoveryDocumentInfo = { | |
| 479 | + readonly discoveryUrl: string; | |
| 480 | + readonly document: DiscoveryDocument; | |
| 481 | + readonly service: string; | |
| 482 | + readonly version: string; | |
| 483 | + readonly rootUrl: string; | |
| 484 | + readonly baseUrl: string; | |
| 485 | + readonly title: string; | |
| 486 | +}; | |
| 487 | + | |
| 488 | +const discoveryDocumentInfo = ( | |
| 489 | + document: DiscoveryDocument, | |
| 490 | + discoveryUrl: string, | |
| 491 | +): Effect.Effect<DiscoveryDocumentInfo, OpenApiParseError> => | |
| 492 | + Effect.gen(function* () { | |
| 493 | + const service = Option.getOrUndefined(document.name); | |
| 494 | + const version = Option.getOrUndefined(document.version); | |
| 495 | + const rootUrl = Option.getOrUndefined(document.rootUrl); | |
| 496 | + if (!service || !version || !rootUrl) { | |
| 497 | + return yield* new OpenApiParseError({ | |
| 498 | + message: "Google Discovery document is missing one of: name, version, rootUrl", | |
| 499 | + }); | |
| 500 | + } | |
| 501 | + | |
| 502 | + return { | |
| 503 | + discoveryUrl, | |
| 504 | + document, | |
| 505 | + service, | |
| 506 | + version, | |
| 507 | + rootUrl, | |
| 508 | + baseUrl: new URL(document.servicePath || "", rootUrl).toString(), | |
| 509 | + title: Option.getOrElse(document.title, () => `${service} ${version}`), | |
| 510 | + }; | |
| 511 | + }); | |
| 512 | + | |
| 513 | +const buildDiscoveryOperation = (input: { | |
| 514 | + readonly document: DiscoveryDocument; | |
| 515 | + readonly method: DiscoveryMethod; | |
| 516 | + readonly toolPath: string; | |
| 517 | + readonly pathTemplate: string; | |
| 518 | + readonly schemaNameForRef?: (name: string) => string; | |
| 519 | + readonly serverUrl?: string; | |
| 520 | + readonly tags?: readonly string[]; | |
| 521 | +}): OpenApiOperationObject => { | |
| 522 | + const mergedParameters = new Map<string, DiscoveryParameter>(); | |
| 523 | + for (const [name, raw] of Object.entries(input.document.parameters ?? {})) { | |
| 524 | + const parameter = decodeDiscoveryParameter(raw); | |
| 525 | + if (parameter.location) mergedParameters.set(name, parameter); | |
| 526 | + } | |
| 527 | + for (const [name, raw] of Object.entries(input.method.parameters ?? {})) { | |
| 528 | + const parameter = decodeDiscoveryParameter(raw); | |
| 529 | + if (parameter.location) mergedParameters.set(name, parameter); | |
| 530 | + } | |
| 531 | + | |
| 532 | + const methodScopes = input.method.scopes ?? []; | |
| 533 | + const methodDescription = Option.getOrUndefined(input.method.description); | |
| 534 | + const schemaNameForRef = input.schemaNameForRef ?? identitySchemaName; | |
| 535 | + | |
| 536 | + return { | |
| 537 | + operationId: input.toolPath, | |
| 538 | + "x-executor-toolPath": input.toolPath, | |
| 539 | + "x-executor-pathTemplate": input.pathTemplate, | |
| 540 | + ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), | |
| 541 | + ...(methodDescription !== undefined ? { description: methodDescription } : {}), | |
| 542 | + ...(input.serverUrl ? { servers: [{ url: input.serverUrl }] } : {}), | |
| 543 | + parameters: [...mergedParameters.entries()].flatMap(([name, parameter]) => { | |
| 544 | + const location = parameter.location; | |
| 545 | + if (!location) return []; | |
| 546 | + const description = Option.getOrUndefined(parameter.description); | |
| 547 | + const allowReserved = | |
| 548 | + location === "path" && pathUsesReservedExpansion(input.pathTemplate, name); | |
| 549 | + return [ | |
| 550 | + { | |
| 551 | + name, | |
| 552 | + in: location, | |
| 553 | + required: location === "path" ? true : parameter.required === true, | |
| 554 | + ...(description !== undefined ? { description } : {}), | |
| 555 | + schema: parameterSchema(parameter, schemaNameForRef), | |
| 556 | + ...(location === "query" | |
| 557 | + ? { style: "form" as const, explode: parameter.repeated === true } | |
| 558 | + : {}), | |
| 559 | + ...(allowReserved ? { allowReserved: true } : {}), | |
| 560 | + }, | |
| 561 | + ]; | |
| 562 | + }), | |
| 563 | + ...(input.method.request?.$ref | |
| 564 | + ? { | |
| 565 | + requestBody: { | |
| 566 | + required: false, | |
| 567 | + content: { | |
| 568 | + "application/json": { | |
| 569 | + schema: { $ref: schemaRef(schemaNameForRef(input.method.request.$ref)) }, | |
| 570 | + }, | |
| 571 | + }, | |
| 572 | + }, | |
| 573 | + } | |
| 574 | + : {}), | |
| 575 | + responses: { | |
| 576 | + "200": { | |
| 577 | + description: "Successful response", | |
| 578 | + content: { | |
| 579 | + "application/json": { | |
| 580 | + schema: input.method.response?.$ref | |
| 581 | + ? { $ref: schemaRef(schemaNameForRef(input.method.response.$ref)) } | |
| 582 | + : {}, | |
| 583 | + }, | |
| 584 | + }, | |
| 585 | + }, | |
| 586 | + }, | |
| 587 | + ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}), | |
| 588 | + "x-google-scopes": methodScopes, | |
| 589 | + }; | |
| 590 | +}; | |
| 591 | + | |
| 592 | +const googleOauth2Config = (scopes: Record<string, string>): OAuth2SourceConfig | undefined => { | |
| 593 | + const securitySchemeName = "googleOAuth2"; | |
| 594 | + return Object.keys(scopes).length > 0 | |
| 595 | + ? { | |
| 596 | + kind: "oauth2", | |
| 597 | + securitySchemeName, | |
| 598 | + flow: "authorizationCode", | |
| 599 | + authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, | |
| 600 | + issuerUrl: GOOGLE_OAUTH_ISSUER_URL, | |
| 601 | + tokenUrl: GOOGLE_OAUTH_TOKEN_URL, | |
| 602 | + clientIdSlot: oauth2ClientIdSlot(securitySchemeName), | |
| 603 | + clientSecretSlot: oauth2ClientSecretSlot(securitySchemeName), | |
| 604 | + connectionSlot: oauth2ConnectionSlot(securitySchemeName), | |
| 605 | + scopes: Object.keys(scopes), | |
| 606 | + identityScopes: ["openid", "email", "profile"], | |
| 607 | + } | |
| 608 | + : undefined; | |
| 609 | +}; | |
| 610 | + | |
| 420 | 611 | export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")( |
| 421 | 612 | function* (input: { readonly discoveryUrl: string; readonly documentText: string }) { |
| 422 | 613 | const parsed = yield* parseJson(input.documentText).pipe( |
| @@ -435,109 +626,33 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 12 unmodified lines | ||
| 435 | 626 | }), |
| 436 | 627 | }); |
| 437 | 628 | |
| 438 | − const service = Option.getOrUndefined(document.name); | |
| 439 | − const version = Option.getOrUndefined(document.version); | |
| 440 | − const rootUrl = Option.getOrUndefined(document.rootUrl); | |
| 441 | − if (!service || !version || !rootUrl) { | |
| 442 | − return yield* new OpenApiParseError({ | |
| 443 | − message: "Google Discovery document is missing one of: name, version, rootUrl", | |
| 444 | − }); | |
| 445 | − } | |
| 446 | − | |
| 447 | − const baseUrl = new URL(document.servicePath || "", rootUrl).toString(); | |
| 448 | − const title = Option.getOrElse(document.title, () => `${service} ${version}`); | |
| 629 | + const info = yield* discoveryDocumentInfo(document, input.discoveryUrl); | |
| 630 | + const { service, version, baseUrl, title } = info; | |
| 449 | 631 | const paths: Record<string, Record<string, OpenApiOperationObject>> = {}; |
| 450 | − const allMethods = [ | |
| 451 | − ...Object.values(document.methods ?? {}).map((raw) => decodeDiscoveryMethod(raw)), | |
| 452 | − ...Object.values(document.resources ?? {}).flatMap(collectMethods), | |
| 453 | − ]; | |
| 454 | 632 | |
| 455 | − for (const method of allMethods) { | |
| 633 | + for (const method of allDiscoveryMethods(document)) { | |
| 456 | 634 | const methodId = Option.getOrUndefined(method.id); |
| 457 | 635 | const pathTemplate = Option.getOrUndefined(method.path); |
| 458 | 636 | if (!methodId || !pathTemplate || !method.httpMethod) continue; |
| 459 | 637 | |
| 460 | 638 | const toolPath = methodToolPath(service, methodId); |
| 461 | − const path = pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`; | |
| 462 | − const mergedParameters = new Map<string, DiscoveryParameter>(); | |
| 463 | − for (const [name, raw] of Object.entries(document.parameters ?? {})) { | |
| 464 | − const parameter = decodeDiscoveryParameter(raw); | |
| 465 | − if (parameter.location) mergedParameters.set(name, parameter); | |
| 466 | − } | |
| 467 | − for (const [name, raw] of Object.entries(method.parameters ?? {})) { | |
| 468 | − const parameter = decodeDiscoveryParameter(raw); | |
| 469 | − if (parameter.location) mergedParameters.set(name, parameter); | |
| 470 | − } | |
| 471 | − const methodScopes = method.scopes ?? []; | |
| 472 | − const methodDescription = Option.getOrUndefined(method.description); | |
| 473 | − | |
| 474 | − paths[path] ??= {}; | |
| 475 | − paths[path]![method.httpMethod.toLowerCase()] = { | |
| 476 | − operationId: toolPath, | |
| 477 | − "x-executor-toolPath": toolPath, | |
| 478 | − ...(methodDescription !== undefined ? { description: methodDescription } : {}), | |
| 479 | − parameters: [...mergedParameters.entries()].flatMap(([name, parameter]) => { | |
| 480 | − const location = parameter.location; | |
| 481 | − if (!location) return []; | |
| 482 | − const description = Option.getOrUndefined(parameter.description); | |
| 483 | − return [ | |
| 484 | − { | |
| 485 | − name, | |
| 486 | − in: location, | |
| 487 | − required: location === "path" ? true : parameter.required === true, | |
| 488 | − ...(description !== undefined ? { description } : {}), | |
| 489 | − schema: parameterSchema(parameter), | |
| 490 | − ...(location === "query" | |
| 491 | − ? { style: "form" as const, explode: parameter.repeated === true } | |
| 492 | − : {}), | |
| 493 | − }, | |
| 494 | − ]; | |
| 495 | − }), | |
| 496 | − ...(method.request?.$ref | |
| 497 | − ? { | |
| 498 | − requestBody: { | |
| 499 | − required: false, | |
| 500 | − content: { | |
| 501 | − "application/json": { | |
| 502 | − schema: { $ref: schemaRef(method.request.$ref) }, | |
| 503 | − }, | |
| 504 | − }, | |
| 505 | − }, | |
| 506 | − } | |
| 507 | − : {}), | |
| 508 | − responses: { | |
| 509 | − "200": { | |
| 510 | − description: "Successful response", | |
| 511 | − content: { | |
| 512 | − "application/json": { | |
| 513 | − schema: method.response?.$ref ? { $ref: schemaRef(method.response.$ref) } : {}, | |
| 514 | − }, | |
| 515 | − }, | |
| 516 | − }, | |
| 517 | − }, | |
| 518 | − ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}), | |
| 519 | − "x-google-scopes": methodScopes, | |
| 520 | − }; | |
| 639 | + const path = normalizeDiscoveryPathTemplate( | |
| 640 | + pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, | |
| 641 | + ); | |
| 642 | + const methodKey = method.httpMethod.toLowerCase(); | |
| 643 | + const pathKey = uniquePathKey(paths, path, methodKey, toolPath); | |
| 644 | + | |
| 645 | + paths[pathKey] ??= {}; | |
| 646 | + paths[pathKey]![methodKey] = buildDiscoveryOperation({ | |
| 647 | + document, | |
| 648 | + method, | |
| 649 | + toolPath, | |
| 650 | + pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, | |
| 651 | + }); | |
| 521 | 652 | } |
| 522 | 653 | |
| 523 | 654 | const scopes = discoveryScopes(document); |
| 524 | − const securitySchemeName = "googleOAuth2"; | |
| 525 | − const oauth2: OAuth2SourceConfig | undefined = | |
| 526 | − Object.keys(scopes).length > 0 | |
| 527 | − ? { | |
| 528 | − kind: "oauth2", | |
| 529 | − securitySchemeName, | |
| 530 | − flow: "authorizationCode", | |
| 531 | − authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, | |
| 532 | − issuerUrl: GOOGLE_OAUTH_ISSUER_URL, | |
| 533 | − tokenUrl: GOOGLE_OAUTH_TOKEN_URL, | |
| 534 | − clientIdSlot: oauth2ClientIdSlot(securitySchemeName), | |
| 535 | − clientSecretSlot: oauth2ClientSecretSlot(securitySchemeName), | |
| 536 | − connectionSlot: oauth2ConnectionSlot(securitySchemeName), | |
| 537 | − scopes: Object.keys(scopes), | |
| 538 | − identityScopes: ["openid", "email", "profile"], | |
| 539 | − } | |
| 540 | − : undefined; | |
| 655 | + const oauth2 = googleOauth2Config(scopes); | |
| 541 | 656 | |
| 542 | 657 | const spec: OpenApiDocument = { |
| 543 | 658 | openapi: "3.1.0", |
| @@ -591,3 +706,126 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 47 unmodified lines | ||
| 591 | 706 | }; |
| 592 | 707 | }, |
| 593 | 708 | ); |
| 709 | + | |
| 710 | +export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( | |
| 711 | + "OpenApi.convertGoogleDiscoveryBundle", | |
| 712 | +)(function* (input: { | |
| 713 | + readonly documents: readonly { readonly discoveryUrl: string; readonly documentText: string }[]; | |
| 714 | +}) { | |
| 715 | + if (input.documents.length === 0) { | |
| 716 | + return yield* new OpenApiParseError({ | |
| 717 | + message: "Google Discovery bundle requires at least one document", | |
| 718 | + }); | |
| 719 | + } | |
| 720 | + | |
| 721 | + const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) => | |
| 722 | + Effect.gen(function* () { | |
| 723 | + const parsed = yield* parseJson(documentText).pipe( | |
| 724 | + Effect.mapError( | |
| 725 | + () => | |
| 726 | + new OpenApiParseError({ | |
| 727 | + message: "Failed to parse Google Discovery document", | |
| 728 | + }), | |
| 729 | + ), | |
| 730 | + ); | |
| 731 | + const document = yield* Effect.try({ | |
| 732 | + try: () => decodeDiscoveryDocument(parsed), | |
| 733 | + catch: () => | |
| 734 | + new OpenApiParseError({ | |
| 735 | + message: "Failed to decode Google Discovery document", | |
| 736 | + }), | |
| 737 | + }); | |
| 738 | + return yield* discoveryDocumentInfo(document, discoveryUrl); | |
| 739 | + }), | |
| 740 | + ); | |
| 741 | + | |
| 742 | + const paths: Record<string, Record<string, OpenApiOperationObject>> = {}; | |
| 743 | + const schemas: Record<string, OpenApiSchemaObject> = {}; | |
| 744 | + const scopes: Record<string, string> = {}; | |
| 745 | + | |
| 746 | + for (const info of infos) { | |
| 747 | + const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); | |
| 748 | + const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; | |
| 749 | + | |
| 750 | + for (const [scope, description] of Object.entries(discoveryScopes(info.document))) { | |
| 751 | + scopes[scope] ??= description; | |
| 752 | + } | |
| 753 | + | |
| 754 | + for (const [name, schema] of Object.entries(info.document.schemas ?? {})) { | |
| 755 | + schemas[schemaNameForRef(name)] = discoverySchemaToOpenApiSchema(schema, schemaNameForRef); | |
| 756 | + } | |
| 757 | + | |
| 758 | + for (const method of allDiscoveryMethods(info.document)) { | |
| 759 | + const methodId = Option.getOrUndefined(method.id); | |
| 760 | + const rawPathTemplate = Option.getOrUndefined(method.path); | |
| 761 | + if (!methodId || !rawPathTemplate || !method.httpMethod) continue; | |
| 762 | + | |
| 763 | + const toolPath = methodId; | |
| 764 | + const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`; | |
| 765 | + const openApiPath = normalizeDiscoveryPathTemplate(wirePath); | |
| 766 | + const methodKey = method.httpMethod.toLowerCase(); | |
| 767 | + const pathKey = uniquePathKey(paths, openApiPath, methodKey, toolPath); | |
| 768 | + | |
| 769 | + paths[pathKey] ??= {}; | |
| 770 | + paths[pathKey]![methodKey] = buildDiscoveryOperation({ | |
| 771 | + document: info.document, | |
| 772 | + method, | |
| 773 | + toolPath, | |
| 774 | + pathTemplate: wirePath, | |
| 775 | + schemaNameForRef, | |
| 776 | + serverUrl: info.baseUrl, | |
| 777 | + tags: [info.title], | |
| 778 | + }); | |
| 779 | + } | |
| 780 | + } | |
| 781 | + | |
| 782 | + const oauth2 = googleOauth2Config(scopes); | |
| 783 | + const spec: OpenApiDocument = { | |
| 784 | + openapi: "3.1.0", | |
| 785 | + info: { | |
| 786 | + title: "Google", | |
| 787 | + version: "google-discovery-bundle", | |
| 788 | + }, | |
| 789 | + servers: [{ url: GOOGLE_BUNDLE_BASE_URL }], | |
| 790 | + paths, | |
| 791 | + components: { | |
| 792 | + schemas, | |
| 793 | + ...(oauth2 | |
| 794 | + ? { | |
| 795 | + securitySchemes: { | |
| 796 | + googleOAuth2: { | |
| 797 | + type: "oauth2", | |
| 798 | + flows: { | |
| 799 | + authorizationCode: { | |
| 800 | + authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, | |
| 801 | + tokenUrl: GOOGLE_OAUTH_TOKEN_URL, | |
| 802 | + scopes, | |
| 803 | + }, | |
| 804 | + }, | |
| 805 | + }, | |
| 806 | + }, | |
| 807 | + } | |
| 808 | + : {}), | |
| 809 | + }, | |
| 810 | + ...(oauth2 ? { security: [{ googleOAuth2: oauth2.scopes }] } : {}), | |
| 811 | + "x-executor-origin": { | |
| 812 | + kind: "googleDiscoveryBundle", | |
| 813 | + services: infos.map((info) => ({ | |
| 814 | + discoveryUrl: normalizeDiscoveryUrl(info.discoveryUrl), | |
| 815 | + service: info.service, | |
| 816 | + version: info.version, | |
| 817 | + })), | |
| 818 | + }, | |
| 819 | + }; | |
| 820 | + | |
| 821 | + return { | |
| 822 | + // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 823 | + specText: JSON.stringify(spec), | |
| 824 | + baseUrl: GOOGLE_BUNDLE_BASE_URL, | |
| 825 | + title: "Google", | |
| 826 | + service: "google", | |
| 827 | + version: "google-discovery-bundle", | |
| 828 | + discoveryUrls: infos.map((info) => normalizeDiscoveryUrl(info.discoveryUrl)), | |
| 829 | + oauth2, | |
| 830 | + }; | |
| 831 | +}); | |
| @@ -0,0 +1,98 @@ | ||
| 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 | +}); | |
| @@ -0,0 +1,69 @@ | ||
| 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 | +}; | |
| @@ -0,0 +1,37 @@ | ||
| 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 | +}); | |
| @@ -0,0 +1,66 @@ | ||
| 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 | +}; | |
| @@ -0,0 +1,29 @@ | ||
| 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,11 +1,30 @@ | ||
| 1 | 1 | import type { OpenApiPreset } from "./presets"; |
| 2 | 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 | + | |
| 3 | 13 | const gd = (service: string, version: string) => |
| 4 | 14 | `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; |
| 5 | 15 | |
| 6 | 16 | const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; |
| 17 | +export const GOOGLE_BUNDLE_PRESET_ID = "google"; | |
| 7 | 18 | |
| 8 | −export const googleOpenApiPresets: readonly OpenApiPreset[] = [ | |
| 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[] = [ | |
| 9 | 28 | { |
| 10 | 29 | id: "google-calendar", |
| 11 | 30 | name: "Google Calendar", |
| @@ -13,6 +32,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 13 | 32 | url: gd("calendar", "v3"), |
| 14 | 33 | icon: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg", |
| 15 | 34 | featured: true, |
| 35 | + oauthAudience: "standard-user", | |
| 16 | 36 | }, |
| 17 | 37 | { |
| 18 | 38 | id: "google-gmail", |
| @@ -21,6 +41,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines | ||
| 21 | 41 | url: gd("gmail", "v1"), |
| 22 | 42 | icon: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png", |
| 23 | 43 | featured: true, |
| 44 | + oauthAudience: "standard-user", | |
| 24 | 45 | }, |
| 25 | 46 | { |
| 26 | 47 | id: "google-sheets", |
| @@ -29,6 +50,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines | ||
| 29 | 50 | url: gd("sheets", "v4"), |
| 30 | 51 | icon: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg", |
| 31 | 52 | featured: true, |
| 53 | + oauthAudience: "standard-user", | |
| 32 | 54 | }, |
| 33 | 55 | { |
| 34 | 56 | id: "google-drive", |
| @@ -37,6 +59,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines | ||
| 37 | 59 | url: gd("drive", "v3"), |
| 38 | 60 | icon: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg", |
| 39 | 61 | featured: true, |
| 62 | + oauthAudience: "standard-user", | |
| 40 | 63 | }, |
| 41 | 64 | { |
| 42 | 65 | id: "google-docs", |
| @@ -45,6 +68,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines | ||
| 45 | 68 | url: gd("docs", "v1"), |
| 46 | 69 | icon: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg", |
| 47 | 70 | featured: true, |
| 71 | + oauthAudience: "standard-user", | |
| 48 | 72 | }, |
| 49 | 73 | { |
| 50 | 74 | id: "google-slides", |
| @@ -52,6 +76,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 52 | 76 | summary: "Presentations, slides, page elements, and deck updates.", |
| 53 | 77 | url: gd("slides", "v1"), |
| 54 | 78 | icon: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg", |
| 79 | + oauthAudience: "standard-user", | |
| 55 | 80 | }, |
| 56 | 81 | { |
| 57 | 82 | id: "google-forms", |
| @@ -59,6 +84,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 59 | 84 | summary: "Forms, questions, responses, and quizzes.", |
| 60 | 85 | url: "https://forms.googleapis.com/$discovery/rest?version=v1", |
| 61 | 86 | icon: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg", |
| 87 | + oauthAudience: "standard-user", | |
| 62 | 88 | }, |
| 63 | 89 | { |
| 64 | 90 | id: "google-tasks", |
| @@ -66,6 +92,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 66 | 92 | summary: "Task lists, task items, notes, and due dates.", |
| 67 | 93 | url: gd("tasks", "v1"), |
| 68 | 94 | icon: "https://fonts.gstatic.com/s/i/productlogos/tasks/v5/192px.svg", |
| 95 | + oauthAudience: "standard-user", | |
| 69 | 96 | }, |
| 70 | 97 | { |
| 71 | 98 | id: "google-people", |
| @@ -73,6 +100,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 73 | 100 | summary: "Contacts, profiles, directory people, and contact groups.", |
| 74 | 101 | url: gd("people", "v1"), |
| 75 | 102 | icon: "https://fonts.gstatic.com/s/i/productlogos/contacts_2022/v2/192px.svg", |
| 103 | + oauthAudience: "standard-user", | |
| 76 | 104 | }, |
| 77 | 105 | { |
| 78 | 106 | id: "google-chat", |
| @@ -80,6 +108,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 80 | 108 | summary: "Spaces, messages, members, reactions, and chat workflows.", |
| 81 | 109 | url: gd("chat", "v1"), |
| 82 | 110 | icon: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg", |
| 111 | + oauthAudience: "workspace-admin", | |
| 83 | 112 | }, |
| 84 | 113 | { |
| 85 | 114 | id: "google-keep", |
| @@ -87,6 +116,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 87 | 116 | summary: "Notes, lists, attachments, and annotations.", |
| 88 | 117 | url: "https://keep.googleapis.com/$discovery/rest?version=v1", |
| 89 | 118 | icon: "https://fonts.gstatic.com/s/i/productlogos/keep_2020q4/v8/192px.svg", |
| 119 | + oauthAudience: "unsupported-user", | |
| 90 | 120 | }, |
| 91 | 121 | { |
| 92 | 122 | id: "google-youtube-data", |
| @@ -94,6 +124,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 94 | 124 | summary: "Channels, playlists, videos, comments, and uploads.", |
| 95 | 125 | url: gd("youtube", "v3"), |
| 96 | 126 | icon: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg", |
| 127 | + oauthAudience: "advanced-user", | |
| 97 | 128 | }, |
| 98 | 129 | { |
| 99 | 130 | id: "google-search-console", |
| @@ -101,6 +132,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 101 | 132 | summary: "Sites, sitemaps, URL inspection, and search performance.", |
| 102 | 133 | url: gd("searchconsole", "v1"), |
| 103 | 134 | icon: GOOGLE_G, |
| 135 | + oauthAudience: "standard-user", | |
| 104 | 136 | }, |
| 105 | 137 | { |
| 106 | 138 | id: "google-classroom", |
| @@ -108,6 +140,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 108 | 140 | summary: "Courses, rosters, coursework, and grading.", |
| 109 | 141 | url: gd("classroom", "v1"), |
| 110 | 142 | icon: "https://fonts.gstatic.com/s/i/productlogos/classroom/v7/192px.svg", |
| 143 | + oauthAudience: "advanced-user", | |
| 111 | 144 | }, |
| 112 | 145 | { |
| 113 | 146 | id: "google-admin-directory", |
| @@ -115,6 +148,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 115 | 148 | summary: "Users, groups, org units, roles, and domain resources.", |
| 116 | 149 | url: "https://admin.googleapis.com/$discovery/rest?version=directory_v1", |
| 117 | 150 | icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg", |
| 151 | + oauthAudience: "workspace-admin", | |
| 118 | 152 | }, |
| 119 | 153 | { |
| 120 | 154 | id: "google-admin-reports", |
| @@ -122,6 +156,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 122 | 156 | summary: "Audit events, usage reports, and admin activity logs.", |
| 123 | 157 | url: "https://admin.googleapis.com/$discovery/rest?version=reports_v1", |
| 124 | 158 | icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg", |
| 159 | + oauthAudience: "workspace-admin", | |
| 125 | 160 | }, |
| 126 | 161 | { |
| 127 | 162 | id: "google-apps-script", |
| @@ -129,6 +164,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 129 | 164 | summary: "Projects, deployments, and script execution.", |
| 130 | 165 | url: gd("script", "v1"), |
| 131 | 166 | icon: "https://fonts.gstatic.com/s/i/productlogos/apps_script/v10/192px.svg", |
| 167 | + oauthAudience: "advanced-user", | |
| 132 | 168 | }, |
| 133 | 169 | { |
| 134 | 170 | id: "google-bigquery", |
| @@ -136,6 +172,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 136 | 172 | summary: "Datasets, tables, jobs, and analytical queries.", |
| 137 | 173 | url: gd("bigquery", "v2"), |
| 138 | 174 | icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg", |
| 175 | + oauthAudience: "advanced-user", | |
| 139 | 176 | }, |
| 140 | 177 | { |
| 141 | 178 | id: "google-cloud-resource-manager", |
| @@ -143,5 +180,10 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line | ||
| 143 | 180 | summary: "Projects, folders, organizations, and IAM hierarchy.", |
| 144 | 181 | url: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3", |
| 145 | 182 | icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg", |
| 183 | + oauthAudience: "advanced-user", | |
| 146 | 184 | }, |
| 147 | 185 | ]; |
| 186 | + | |
| 187 | +export const googleStandardUserOAuthPresets = googleOpenApiPresets.filter( | |
| 188 | + (preset) => preset.oauthAudience === "standard-user", | |
| 189 | +); | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export { parse, resolveSpecText, fetchSpecText } from "./parse"; |
| 2 | 2 | export { |
| 3 | + convertGoogleDiscoveryBundleToOpenApi, | |
| 3 | 4 | convertGoogleDiscoveryToOpenApi, |
| 4 | 5 | fetchGoogleDiscoveryDocument, |
| 5 | 6 | isGoogleDiscoveryUrl, |
| @@ -42,6 +42,20 @@ const readParamValue = (args: Record<string, unknown>, param: OperationParameter · 41 unmodified lines | ||
| 42 | 42 | const primitiveToString = (value: unknown): string => |
| 43 | 43 | typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); |
| 44 | 44 | |
| 45 | +// RFC 3986 §2.2 reserved chars. `allowReserved: true` leaves these | |
| 46 | +// unencoded; default OAS behavior encodes everything non-unreserved. | |
| 47 | +const RESERVED_UNENCODED_RE = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/; | |
| 48 | + | |
| 49 | +const encodeReservedAware = (raw: string, allowReserved: boolean): string => { | |
| 50 | + if (!allowReserved) return encodeURIComponent(raw); | |
| 51 | + // Walk char-by-char so the reserved set passes through as-is. | |
| 52 | + let out = ""; | |
| 53 | + for (const ch of raw) { | |
| 54 | + out += RESERVED_UNENCODED_RE.test(ch) ? ch : encodeURIComponent(ch); | |
| 55 | + } | |
| 56 | + return out; | |
| 57 | +}; | |
| 58 | + | |
| 45 | 59 | const queryParamValues = (value: unknown, param: OperationParameter): string[] => { |
| 46 | 60 | if (value === undefined || value === null) return []; |
| 47 | 61 | if (!Array.isArray(value)) return [primitiveToString(value)]; |
| @@ -78,7 +92,12 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 30 unmodified lines | ||
| 78 | 92 | } |
| 79 | 93 | continue; |
| 80 | 94 | } |
| 81 | − resolved = resolved.replaceAll(`{${param.name}}`, encodeURIComponent(String(value))); | |
| 95 | + const encoded = encodeReservedAware( | |
| 96 | + String(value), | |
| 97 | + Option.getOrElse(param.allowReserved, () => false), | |
| 98 | + ); | |
| 99 | + resolved = resolved.replaceAll(`{${param.name}}`, encoded); | |
| 100 | + resolved = resolved.replaceAll(`{+${param.name}}`, encoded); | |
| 82 | 101 | } |
| 83 | 102 | |
| 84 | 103 | const remaining = [...resolved.matchAll(/\{([^{}]+)\}/g)] |
| @@ -269,19 +288,9 @@ const resolveStyleExplode = (e: EncodingObject | undefined): StyleExplode => { · 184 unmodified lines | ||
| 269 | 288 | }; |
| 270 | 289 | }; |
| 271 | 290 | |
| 272 | −// RFC 3986 §2.2 reserved chars. `allowReserved: true` leaves these | |
| 273 | −// unencoded; default OAS behavior encodes everything non-unreserved. | |
| 274 | −const RESERVED_UNENCODED_RE = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/; | |
| 275 | − | |
| 276 | 291 | const encodeFormValue = (v: unknown, allowReserved: boolean): string => { |
| 277 | 292 | const raw = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v); |
| 278 | − if (!allowReserved) return encodeURIComponent(raw); | |
| 279 | − // Walk char-by-char so the reserved set passes through as-is. | |
| 280 | − let out = ""; | |
| 281 | − for (const ch of raw) { | |
| 282 | − out += RESERVED_UNENCODED_RE.test(ch) ? ch : encodeURIComponent(ch); | |
| 283 | − } | |
| 284 | − return out; | |
| 293 | + return encodeReservedAware(raw, allowReserved); | |
| 285 | 294 | }; |
| 286 | 295 | |
| 287 | 296 | /** |
| @@ -643,12 +652,14 @@ export const invokeWithLayer = ( · 355 unmodified lines | ||
| 643 | 652 | sourceQueryParams: Record<string, string>, |
| 644 | 653 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 645 | 654 | ) => { |
| 646 | − const clientWithBaseUrl = baseUrl | |
| 655 | + const operationBaseUrl = operation.baseUrl; | |
| 656 | + const effectiveBaseUrl = operationBaseUrl ?? baseUrl; | |
| 657 | + const clientWithBaseUrl = effectiveBaseUrl | |
| 647 | 658 | ? Layer.effect( |
| 648 | 659 | HttpClient.HttpClient, |
| 649 | 660 | Effect.map( |
| 650 | 661 | Effect.service(HttpClient.HttpClient), |
| 651 | − HttpClient.mapRequest(HttpClientRequest.prependUrl(baseUrl)), | |
| 662 | + HttpClient.mapRequest(HttpClientRequest.prependUrl(effectiveBaseUrl)), | |
| 652 | 663 | ), |
| 653 | 664 | ).pipe(Layer.provide(httpClientLayer)) |
| 654 | 665 | : httpClientLayer; |
| @@ -659,7 +670,7 @@ export const invokeWithLayer = ( · 4 unmodified lines | ||
| 659 | 670 | attributes: { |
| 660 | 671 | "plugin.openapi.method": operation.method.toUpperCase(), |
| 661 | 672 | "plugin.openapi.path_template": operation.pathTemplate, |
| 662 | − "plugin.openapi.base_url": baseUrl, | |
| 673 | + "plugin.openapi.base_url": effectiveBaseUrl, | |
| 663 | 674 | }, |
| 664 | 675 | }), |
| 665 | 676 | ); |
| @@ -19,6 +19,7 @@ export type PathItemObject = OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObje · 18 unmodified lines | ||
| 19 | 19 | export type RequestBodyObject = OpenAPIV3.RequestBodyObject | OpenAPIV3_1.RequestBodyObject; |
| 20 | 20 | export type ResponseObject = OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject; |
| 21 | 21 | export type MediaTypeObject = OpenAPIV3.MediaTypeObject | OpenAPIV3_1.MediaTypeObject; |
| 22 | +export type ServerObject = OpenAPIV3.ServerObject | OpenAPIV3_1.ServerObject; | |
| 22 | 23 | |
| 23 | 24 | // --------------------------------------------------------------------------- |
| 24 | 25 | // DocResolver — wraps a parsed document for clean $ref resolution |
| @@ -120,7 +120,9 @@ const testApiSourceConfig = (options: TestApiSourceOptions = {}) => · 119 unmodified lines | ||
| 120 | 120 | |
| 121 | 121 | const testApiSpec = () => { |
| 122 | 122 | const spec = testApiSourceConfig().spec; |
| 123 | − return spec.kind === "blob" ? spec.value : spec.url; | |
| 123 | + if (spec.kind === "blob") return spec.value; | |
| 124 | + if (spec.kind === "googleDiscoveryBundle") return spec.urls[0] ?? ""; | |
| 125 | + return spec.url; | |
| 124 | 126 | }; |
| 125 | 127 | |
| 126 | 128 | // --------------------------------------------------------------------------- |
| @@ -34,8 +34,9 @@ import { · 33 unmodified lines | ||
| 34 | 34 | OpenApiOAuthError, |
| 35 | 35 | OpenApiParseError, |
| 36 | 36 | } from "./errors"; |
| 37 | −import { parse, resolveSpecText } from "./parse"; | |
| 37 | +import { parse, resolveSpecText, type SpecFetchCredentials } from "./parse"; | |
| 38 | 38 | import { |
| 39 | + convertGoogleDiscoveryBundleToOpenApi, | |
| 39 | 40 | convertGoogleDiscoveryToOpenApi, |
| 40 | 41 | fetchGoogleDiscoveryDocument, |
| 41 | 42 | isGoogleDiscoveryUrl, |
| @@ -332,6 +333,10 @@ const OpenApiSpecInputSchema = Schema.Union([ · 290 unmodified lines | ||
| 332 | 333 | Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), |
| 333 | 334 | Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), |
| 334 | 335 | Schema.Struct({ kind: Schema.Literal("googleDiscovery"), url: Schema.String }), |
| 336 | + Schema.Struct({ | |
| 337 | + kind: Schema.Literal("googleDiscoveryBundle"), | |
| 338 | + urls: Schema.Array(Schema.String), | |
| 339 | + }), | |
| 335 | 340 | ]); |
| 336 | 341 | const OpenApiSecretShapeInputSchema = Schema.Struct({ |
| 337 | 342 | kind: Schema.Literal("secret"), |
| @@ -590,6 +595,7 @@ const normalizeOpenApiRefs = (node: unknown): unknown => { · 252 unmodified lines | ||
| 590 | 595 | const toBinding = (def: ToolDefinition): OperationBinding => |
| 591 | 596 | OperationBinding.make({ |
| 592 | 597 | method: def.operation.method, |
| 598 | + baseUrl: def.operation.baseUrl, | |
| 593 | 599 | pathTemplate: def.operation.pathTemplate, |
| 594 | 600 | parameters: [...def.operation.parameters], |
| 595 | 601 | requestBody: def.operation.requestBody, |
| @@ -959,6 +965,7 @@ const resolveEffectiveSourceConfig = ( · 363 unmodified lines | ||
| 959 | 965 | config: { |
| 960 | 966 | ...base.config, |
| 961 | 967 | sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl, |
| 968 | + googleDiscoveryUrls: base.config.googleDiscoveryUrls ?? fallback.config.googleDiscoveryUrls, | |
| 962 | 969 | baseUrl: fallback.config.baseUrl, |
| 963 | 970 | namespace: base.config.namespace ?? fallback.config.namespace, |
| 964 | 971 | headers: hasBaseHeaders ? base.config.headers : fallback.config.headers, |
| @@ -1157,6 +1164,21 @@ const resolveStoredSpecFetchCredentials = ( · 192 unmodified lines | ||
| 1157 | 1164 | ), |
| 1158 | 1165 | ); |
| 1159 | 1166 | |
| 1167 | +const fetchGoogleDiscoveryBundleConversion = ( | |
| 1168 | + urls: readonly string[], | |
| 1169 | + credentials: SpecFetchCredentials | undefined, | |
| 1170 | + httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | |
| 1171 | +) => | |
| 1172 | + Effect.forEach( | |
| 1173 | + urls, | |
| 1174 | + (url) => | |
| 1175 | + fetchGoogleDiscoveryDocument(url, credentials).pipe( | |
| 1176 | + Effect.provide(httpClientLayer), | |
| 1177 | + Effect.map((documentText) => ({ discoveryUrl: url, documentText })), | |
| 1178 | + ), | |
| 1179 | + { concurrency: 4 }, | |
| 1180 | + ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); | |
| 1181 | + | |
| 1160 | 1182 | // --------------------------------------------------------------------------- |
| 1161 | 1183 | // OAuth2 token exchange / refresh is owned by `ctx.oauth`, which registers |
| 1162 | 1184 | // the canonical core `"oauth2"` ConnectionProvider. OpenAPI owns only the |
| @@ -1197,13 +1219,18 @@ const toOpenApiSourceConfig = ( · 34 unmodified lines | ||
| 1197 | 1219 | }; |
| 1198 | 1220 | |
| 1199 | 1221 | const specInputToConfigString = (spec: OpenApiSpecInput): string => |
| 1200 | − spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : spec.value; | |
| 1222 | + spec.kind === "url" || spec.kind === "googleDiscovery" | |
| 1223 | + ? spec.url | |
| 1224 | + : spec.kind === "googleDiscoveryBundle" | |
| 1225 | + ? spec.urls.join("\n") | |
| 1226 | + : spec.value; | |
| 1201 | 1227 | |
| 1202 | 1228 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { |
| 1203 | 1229 | type RebuildInput = { |
| 1204 | 1230 | readonly specText: string; |
| 1205 | 1231 | readonly scope: string; |
| 1206 | 1232 | readonly sourceUrl?: string; |
| 1233 | + readonly googleDiscoveryUrls?: readonly string[]; | |
| 1207 | 1234 | readonly name: string; |
| 1208 | 1235 | readonly baseUrl: string | undefined; |
| 1209 | 1236 | readonly namespace: string; |
| @@ -1269,6 +1296,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 59 unmodified lines | ||
| 1269 | 1296 | const sourceConfig: SourceConfig = { |
| 1270 | 1297 | spec: input.specText, |
| 1271 | 1298 | sourceUrl: input.sourceUrl, |
| 1299 | + googleDiscoveryUrls: input.googleDiscoveryUrls, | |
| 1272 | 1300 | baseUrl, |
| 1273 | 1301 | namespace: input.namespace, |
| 1274 | 1302 | headers: canonicalHeaders.headers, |
| @@ -1303,7 +1331,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 28 unmodified lines | ||
| 1303 | 1331 | // `canRefresh` reflects whether we still know the |
| 1304 | 1332 | // origin URL — sources added from raw spec text have |
| 1305 | 1333 | // nothing to re-fetch, so refresh stays disabled. |
| 1306 | − canRefresh: input.sourceUrl != null, | |
| 1334 | + canRefresh: input.sourceUrl != null || input.googleDiscoveryUrls != null, | |
| 1307 | 1335 | canEdit: true, |
| 1308 | 1336 | tools: definitions.map((def) => ({ |
| 1309 | 1337 | name: def.toolPath, |
| @@ -1341,25 +1369,38 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 31 unmodified lines | ||
| 1341 | 1369 | const effective = yield* resolveEffectiveSourceConfig(ctx, existing); |
| 1342 | 1370 | const resolvedConfig = effective.config; |
| 1343 | 1371 | const sourceUrl = resolvedConfig.sourceUrl; |
| 1344 | − if (!sourceUrl) return; | |
| 1372 | + const googleDiscoveryUrls = resolvedConfig.googleDiscoveryUrls; | |
| 1373 | + if (!sourceUrl && !googleDiscoveryUrls) return; | |
| 1345 | 1374 | const credentials = yield* resolveStoredSpecFetchCredentials(ctx, { |
| 1346 | 1375 | sourceId: existing.namespace, |
| 1347 | 1376 | sourceScope: effective.specFetchCredentialsSource.scope, |
| 1348 | 1377 | credentials: resolvedConfig.specFetchCredentials, |
| 1349 | 1378 | }); |
| 1350 | − const specText = isGoogleDiscoveryUrl(sourceUrl) | |
| 1351 | − ? yield* fetchGoogleDiscoveryDocument(sourceUrl, credentials).pipe( | |
| 1352 | − Effect.provide(httpClientLayer), | |
| 1353 | − Effect.flatMap((documentText) => | |
| 1354 | − convertGoogleDiscoveryToOpenApi({ discoveryUrl: sourceUrl, documentText }), | |
| 1355 | − ), | |
| 1356 | − Effect.map((conversion) => conversion.specText), | |
| 1379 | + const conversion = googleDiscoveryUrls | |
| 1380 | + ? yield* fetchGoogleDiscoveryBundleConversion( | |
| 1381 | + googleDiscoveryUrls, | |
| 1382 | + credentials, | |
| 1383 | + httpClientLayer, | |
| 1357 | 1384 | ) |
| 1358 | − : yield* resolveSpecText(sourceUrl, credentials).pipe(Effect.provide(httpClientLayer)); | |
| 1385 | + : undefined; | |
| 1386 | + const specText = conversion | |
| 1387 | + ? conversion.specText | |
| 1388 | + : sourceUrl && isGoogleDiscoveryUrl(sourceUrl) | |
| 1389 | + ? yield* fetchGoogleDiscoveryDocument(sourceUrl, credentials).pipe( | |
| 1390 | + Effect.provide(httpClientLayer), | |
| 1391 | + Effect.flatMap((documentText) => | |
| 1392 | + convertGoogleDiscoveryToOpenApi({ discoveryUrl: sourceUrl, documentText }), | |
| 1393 | + ), | |
| 1394 | + Effect.map((singleConversion) => singleConversion.specText), | |
| 1395 | + ) | |
| 1396 | + : sourceUrl | |
| 1397 | + ? yield* resolveSpecText(sourceUrl, credentials).pipe(Effect.provide(httpClientLayer)) | |
| 1398 | + : existing.config.spec; | |
| 1359 | 1399 | yield* rebuildSource(ctx, { |
| 1360 | 1400 | specText, |
| 1361 | 1401 | scope, |
| 1362 | 1402 | sourceUrl, |
| 1403 | + googleDiscoveryUrls, | |
| 1363 | 1404 | name: existing.name, |
| 1364 | 1405 | baseUrl: existing.config.baseUrl, |
| 1365 | 1406 | namespace: existing.namespace, |
| @@ -1394,16 +1435,22 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 28 unmodified lines | ||
| 1394 | 1435 | }), |
| 1395 | 1436 | ), |
| 1396 | 1437 | ) |
| 1397 | − : { | |
| 1398 | − specText: | |
| 1399 | − config.spec.kind === "url" | |
| 1400 | − ? yield* resolveSpecText(config.spec.url).pipe( | |
| 1401 | − Effect.provide(httpClientLayer), | |
| 1402 | − ) | |
| 1403 | − : config.spec.value, | |
| 1404 | − baseUrl: config.baseUrl, | |
| 1405 | − oauth2: config.oauth2, | |
| 1406 | − }; | |
| 1438 | + : config.spec.kind === "googleDiscoveryBundle" | |
| 1439 | + ? yield* fetchGoogleDiscoveryBundleConversion( | |
| 1440 | + config.spec.urls, | |
| 1441 | + undefined, | |
| 1442 | + httpClientLayer, | |
| 1443 | + ) | |
| 1444 | + : { | |
| 1445 | + specText: | |
| 1446 | + config.spec.kind === "url" | |
| 1447 | + ? yield* resolveSpecText(config.spec.url).pipe( | |
| 1448 | + Effect.provide(httpClientLayer), | |
| 1449 | + ) | |
| 1450 | + : config.spec.value, | |
| 1451 | + baseUrl: config.baseUrl, | |
| 1452 | + oauth2: config.oauth2, | |
| 1453 | + }; | |
| 1407 | 1454 | return yield* rebuildSource(ctx, { |
| 1408 | 1455 | specText: resolvedSpec.specText, |
| 1409 | 1456 | scope: config.scope, |
| @@ -1411,6 +1458,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line | ||
| 1411 | 1458 | config.spec.kind === "url" || config.spec.kind === "googleDiscovery" |
| 1412 | 1459 | ? config.spec.url |
| 1413 | 1460 | : undefined, |
| 1461 | + googleDiscoveryUrls: | |
| 1462 | + config.spec.kind === "googleDiscoveryBundle" ? config.spec.urls : undefined, | |
| 1414 | 1463 | name: config.name, |
| 1415 | 1464 | baseUrl: resolvedSpec.baseUrl || config.baseUrl, |
| 1416 | 1465 | namespace: config.namespace, |
| @@ -1,10 +1,10 @@ | ||
| 1 | −import { googleOpenApiPresets } from "./google-presets"; | |
| 1 | +import { googleOpenApiBundlePreset, googleOpenApiPresets } from "./google-presets"; | |
| 2 | 2 | |
| 3 | 3 | export interface OpenApiPreset { |
| 4 | 4 | readonly id: string; |
| 5 | 5 | readonly name: string; |
| 6 | 6 | readonly summary: string; |
| 7 | − readonly url: string; | |
| 7 | + readonly url?: string; | |
| 8 | 8 | readonly icon?: string; |
| 9 | 9 | readonly featured?: boolean; |
| 10 | 10 | } |
| @@ -140,9 +140,10 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 129 unmodified lines | ||
| 140 | 140 | }, |
| 141 | 141 | ]; |
| 142 | 142 | |
| 143 | −export { googleOpenApiPresets } from "./google-presets"; | |
| 143 | +export { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./google-presets"; | |
| 144 | 144 | |
| 145 | 145 | export const openApiPresets: readonly OpenApiPreset[] = [ |
| 146 | + googleOpenApiBundlePreset, | |
| 146 | 147 | ...openApiOnlyPresets, |
| 147 | 148 | ...googleOpenApiPresets, |
| 148 | 149 | ]; |
| @@ -95,3 +95,44 @@ it.effect("serializes form-exploded query arrays as repeated parameters", () => · 94 unmodified lines | ||
| 95 | 95 | }), |
| 96 | 96 | ), |
| 97 | 97 | ); |
| 98 | + | |
| 99 | +it.effect("uses operation base URL and preserves reserved path expansion when allowed", () => | |
| 100 | + Effect.promise(() => | |
| 101 | + withServer(async ({ baseUrl, requests }) => { | |
| 102 | + const operation = OperationBinding.make({ | |
| 103 | + method: "get", | |
| 104 | + baseUrl, | |
| 105 | + pathTemplate: "/v1/{+name}", | |
| 106 | + requestBody: Option.none(), | |
| 107 | + parameters: [ | |
| 108 | + OperationParameter.make({ | |
| 109 | + name: "name", | |
| 110 | + location: "path", | |
| 111 | + required: true, | |
| 112 | + schema: Option.some({ type: "string" }), | |
| 113 | + style: Option.none(), | |
| 114 | + explode: Option.none(), | |
| 115 | + allowReserved: Option.some(true), | |
| 116 | + description: Option.none(), | |
| 117 | + }), | |
| 118 | + ], | |
| 119 | + }); | |
| 120 | + | |
| 121 | + await Effect.runPromise( | |
| 122 | + invokeWithLayer( | |
| 123 | + operation, | |
| 124 | + { | |
| 125 | + name: "spaces/AAA/messages/BBB", | |
| 126 | + }, | |
| 127 | + "https://unused.example", | |
| 128 | + {}, | |
| 129 | + {}, | |
| 130 | + FetchHttpClient.layer, | |
| 131 | + ), | |
| 132 | + ); | |
| 133 | + | |
| 134 | + const url = new URL(requests[0]!, "http://executor.test"); | |
| 135 | + expect(url.pathname).toBe("/v1/spaces/AAA/messages/BBB"); | |
| 136 | + }), | |
| 137 | + ), | |
| 138 | +); | |
| @@ -9,6 +9,7 @@ export const StoredSourceSchema = Schema.Struct({ · 8 unmodified lines | ||
| 9 | 9 | config: Schema.Struct({ |
| 10 | 10 | spec: Schema.optional(Schema.String), |
| 11 | 11 | sourceUrl: Schema.optional(Schema.String), |
| 12 | + googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)), | |
| 12 | 13 | baseUrl: Schema.optional(Schema.String), |
| 13 | 14 | namespace: Schema.optional(Schema.String), |
| 14 | 15 | headers: Schema.optional(Schema.Record(Schema.String, ConfiguredHeaderValue)), |
| @@ -25,6 +25,7 @@ export { · 24 unmodified lines | ||
| 25 | 25 | export interface SourceConfig { |
| 26 | 26 | readonly spec: string; |
| 27 | 27 | readonly sourceUrl?: string; |
| 28 | + readonly googleDiscoveryUrls?: readonly string[]; | |
| 28 | 29 | readonly baseUrl?: string; |
| 29 | 30 | readonly namespace?: string; |
| 30 | 31 | readonly headers?: Record<string, ConfiguredHeaderValue>; |
| @@ -80,6 +81,7 @@ const SpecFetchCredentialsStorage = Schema.Struct({ · 49 unmodified lines | ||
| 80 | 81 | const SourceConfigStorage = Schema.Struct({ |
| 81 | 82 | spec: Schema.String, |
| 82 | 83 | sourceUrl: Schema.optional(Schema.String), |
| 84 | + googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)), | |
| 83 | 85 | baseUrl: Schema.optional(Schema.String), |
| 84 | 86 | namespace: Schema.optional(Schema.String), |
| 85 | 87 | headers: Schema.optional(ConfiguredHeaderMapStorage), |
| @@ -141,6 +143,7 @@ const normalizeConfiguredMap = ( · 55 unmodified lines | ||
| 141 | 143 | const encodeSourceConfig = (config: SourceConfig): Record<string, unknown> => ({ |
| 142 | 144 | spec: config.spec, |
| 143 | 145 | ...(config.sourceUrl ? { sourceUrl: config.sourceUrl } : {}), |
| 146 | + ...(config.googleDiscoveryUrls ? { googleDiscoveryUrls: config.googleDiscoveryUrls } : {}), | |
| 144 | 147 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), |
| 145 | 148 | ...(config.namespace ? { namespace: config.namespace } : {}), |
| 146 | 149 | ...(config.headers ? { headers: config.headers } : {}), |
| @@ -161,6 +164,7 @@ const rowToSource = (row: PluginStorageEntry): StoredSource | null => { · 14 unmodified lines | ||
| 161 | 164 | config: { |
| 162 | 165 | spec: stored.config.spec, |
| 163 | 166 | sourceUrl: stored.config.sourceUrl, |
| 167 | + googleDiscoveryUrls: stored.config.googleDiscoveryUrls, | |
| 164 | 168 | baseUrl: stored.config.baseUrl, |
| 165 | 169 | namespace: stored.config.namespace, |
| 166 | 170 | headers: normalizeConfiguredMap(stored.config.headers), |
| @@ -97,6 +97,7 @@ export const ExtractedOperation = Schema.Struct({ · 96 unmodified lines | ||
| 97 | 97 | operationId: OperationId, |
| 98 | 98 | toolPath: Schema.OptionFromOptional(Schema.String), |
| 99 | 99 | method: HttpMethod, |
| 100 | + baseUrl: Schema.optional(Schema.String), | |
| 100 | 101 | pathTemplate: Schema.String, |
| 101 | 102 | summary: Schema.OptionFromOptional(Schema.String), |
| 102 | 103 | description: Schema.OptionFromOptional(Schema.String), |
| @@ -137,6 +138,7 @@ export type ExtractionResult = typeof ExtractionResult.Type; · 34 unmodified lines | ||
| 137 | 138 | |
| 138 | 139 | export const OperationBinding = Schema.Struct({ |
| 139 | 140 | method: HttpMethod, |
| 141 | + baseUrl: Schema.optional(Schema.String), | |
| 140 | 142 | pathTemplate: Schema.String, |
| 141 | 143 | parameters: Schema.Array(OperationParameter), |
| 142 | 144 | requestBody: Schema.OptionFromOptional(OperationRequestBody), |
| @@ -0,0 +1,72 @@ | ||
| 1 | +import * as React from "react"; | |
| 2 | +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; | |
| 3 | + | |
| 4 | +import { cn } from "../lib/utils"; | |
| 5 | + | |
| 6 | +type InfoVariant = "default" | "warning"; | |
| 7 | + | |
| 8 | +const variantClassName: Record< | |
| 9 | + InfoVariant, | |
| 10 | + { | |
| 11 | + readonly root: string; | |
| 12 | + readonly icon: string; | |
| 13 | + } | |
| 14 | +> = { | |
| 15 | + default: { | |
| 16 | + root: "border-border/70 bg-card/50 text-foreground", | |
| 17 | + icon: "text-muted-foreground", | |
| 18 | + }, | |
| 19 | + warning: { | |
| 20 | + root: "border-border/70 border-l-amber-500 bg-card text-foreground shadow-xs dark:border-border/70 dark:border-l-amber-400 dark:bg-card/70", | |
| 21 | + icon: "text-amber-600 dark:text-amber-400", | |
| 22 | + }, | |
| 23 | +}; | |
| 24 | + | |
| 25 | +function Info({ | |
| 26 | + className, | |
| 27 | + variant = "default", | |
| 28 | + children, | |
| 29 | + ...props | |
| 30 | +}: React.ComponentProps<"section"> & { | |
| 31 | + readonly variant?: InfoVariant; | |
| 32 | +}) { | |
| 33 | + const Icon = variant === "warning" ? TriangleAlertIcon : InfoIcon; | |
| 34 | + const variantClass = variantClassName[variant]; | |
| 35 | + return ( | |
| 36 | + <section | |
| 37 | + data-slot="info" | |
| 38 | + className={cn( | |
| 39 | + "grid grid-cols-[auto_1fr] gap-x-2.5 gap-y-1 rounded-md border px-3 py-2.5 text-sm", | |
| 40 | + variant === "warning" ? "border-l-[3px]" : null, | |
| 41 | + variantClass.root, | |
| 42 | + className, | |
| 43 | + )} | |
| 44 | + {...props} | |
| 45 | + > | |
| 46 | + <Icon className={cn("mt-0.5 size-4", variantClass.icon)} aria-hidden /> | |
| 47 | + <div className="min-w-0 space-y-1.5">{children}</div> | |
| 48 | + </section> | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +function InfoTitle({ className, ...props }: React.ComponentProps<"div">) { | |
| 53 | + return ( | |
| 54 | + <div | |
| 55 | + data-slot="info-title" | |
| 56 | + className={cn("text-[13px] font-medium leading-5 text-current", className)} | |
| 57 | + {...props} | |
| 58 | + /> | |
| 59 | + ); | |
| 60 | +} | |
| 61 | + | |
| 62 | +function InfoDescription({ className, ...props }: React.ComponentProps<"div">) { | |
| 63 | + return ( | |
| 64 | + <div | |
| 65 | + data-slot="info-description" | |
| 66 | + className={cn("text-[12px] leading-5 text-muted-foreground", className)} | |
| 67 | + {...props} | |
| 68 | + /> | |
| 69 | + ); | |
| 70 | +} | |
| 71 | + | |
| 72 | +export { Info, InfoTitle, InfoDescription }; | |