History

Add bundled Google source flow (#875)

* Add Google multi-service source flow * Add bundled Google source flow * Use typed OpenAPI server objects * Fix Google bundle CI checks

Rhys Sullivan committed 5 months ago

Commit bd61b6e1 parent 314b91624 files · +2112 −293Browse files

Unified · Split

24 files
  1. packages/plugins/openapi/src/api/group.ts +4 −0
  2. packages/plugins/openapi/src/api/handlers.ts +1 −0
  3. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +869 −139
  4. packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap +82 −0
  5. packages/plugins/openapi/src/sdk/extract.ts +39 −5
  6. packages/plugins/openapi/src/sdk/google-discovery.test.ts +205 −1
  7. packages/plugins/openapi/src/sdk/google-discovery.ts +344 −106
  8. packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts +98 −0
  9. packages/plugins/openapi/src/sdk/google-oauth-batches.ts +69 −0
  10. packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts +37 −0
  11. packages/plugins/openapi/src/sdk/google-oauth-scopes.ts +66 −0
  12. packages/plugins/openapi/src/sdk/google-presets.test.ts +29 −0
  13. packages/plugins/openapi/src/sdk/google-presets.ts +43 −1
  14. packages/plugins/openapi/src/sdk/index.ts +1 −0
  15. packages/plugins/openapi/src/sdk/invoke.ts +26 −15
  16. packages/plugins/openapi/src/sdk/openapi-utils.ts +1 −0
  17. packages/plugins/openapi/src/sdk/plugin.test.ts +3 −1
  18. packages/plugins/openapi/src/sdk/plugin.ts +71 −22
  19. packages/plugins/openapi/src/sdk/presets.ts +4 −3
  20. packages/plugins/openapi/src/sdk/query-serialization.test.ts +41 −0
  21. packages/plugins/openapi/src/sdk/source-contracts.ts +1 −0
  22. packages/plugins/openapi/src/sdk/store.ts +4 −0
  23. packages/plugins/openapi/src/sdk/types.ts +2 −0
  24. packages/react/src/components/info.tsx +72 −0
packages/plugins/openapi/src/api/group.ts+4−0
@@ -37,6 +37,10 @@ const OpenApiSpecInputPayload = Schema.Union([ · 36 unmodified lines
3737 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
3838 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
3939 Schema.Struct({ kind: Schema.Literal("googleDiscovery"), url: Schema.String }),
40+ Schema.Struct({
41+ kind: Schema.Literal("googleDiscoveryBundle"),
42+ urls: Schema.Array(Schema.String),
43+ }),
4044 ]);
4145
4246 const PreviewSpecFetchCredentialsPayload = Schema.Struct({
packages/plugins/openapi/src/api/handlers.ts+1−0
@@ -97,6 +97,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 96 unmodified lines
9797 name: source.name,
9898 config: {
9999 sourceUrl: source.config.sourceUrl,
100+ googleDiscoveryUrls: source.config.googleDiscoveryUrls,
100101 baseUrl: source.config.baseUrl,
101102 namespace: source.config.namespace,
102103 headers: source.config.headers,
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+869−139
@@ -65,6 +65,7 @@ import { · 64 unmodified lines
6565 import { FieldLabel } from "@executor-js/react/components/field";
6666 import { FloatActions } from "@executor-js/react/components/float-actions";
6767 import { HelpTooltip } from "@executor-js/react/components/help-tooltip";
68+import { Info, InfoDescription, InfoTitle } from "@executor-js/react/components/info";
6869 import { Label } from "@executor-js/react/components/label";
6970 import { Textarea } from "@executor-js/react/components/textarea";
7071 import { Checkbox } from "@executor-js/react/components/checkbox";
@@ -72,7 +73,12 @@ import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio- · 1 unmodified line
7273 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
7374 import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms";
7475 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";
7682 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
7783 import {
7884 headerBindingSlot,
@@ -85,9 +91,19 @@ import { · 6 unmodified lines
8591 } from "../sdk/source-contracts";
8692 import { OAuth2SourceConfig, type ServerInfo } from "../sdk/types";
8793 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";
8899
89100 export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
90101 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;
91107
92108 const ErrorMessage = Schema.Struct({ message: Schema.String });
93109 const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
@@ -98,6 +114,22 @@ const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: strin · 4 unmodified lines
98114 onSome: ({ message }) => message,
99115 });
100116
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+
101133 export const openApiOAuthConnectionId = (
102134 namespaceSlug: string,
103135 flow: OAuth2Preset["flow"],
@@ -170,11 +202,10 @@ const mergeOAuthScopes = (...values: readonly Iterable<string>[]): string[] => { · 66 unmodified lines
170202 return [...merged];
171203 };
172204
173−const missingOAuthScopes = (
174− connection: { readonly oauthScope: string | null },
205+const missingOAuthScopesFromGranted = (
206+ granted: ReadonlySet<string>,
175207 requiredApiScopes: Iterable<string>,
176208 ): readonly string[] => {
177− const granted = splitOAuthScopes(connection.oauthScope);
178209 return [...requiredApiScopes].filter((scope) => !granted.has(scope));
179210 };
180211
@@ -207,46 +238,6 @@ const googleAuthorizationParams = (enabled: boolean): Record<string, string> | u · 26 unmodified lines
207238 }
208239 : undefined;
209240
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−
250241 type OAuthConnectionChoice = {
251242 readonly id: ConnectionId;
252243 readonly scopeId: ScopeId;
@@ -256,6 +247,25 @@ type OAuthConnectionChoice = { · 3 unmodified lines
256247 readonly missingApiScopes: readonly string[];
257248 };
258249
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+
259269 const specInputForAdd = (input: string) => {
260270 const value = input.trim();
261271 const parsed = Effect.runSyncExit(
@@ -289,6 +299,25 @@ const normalizePresetUrl = (url: string): string => { · 27 unmodified lines
289299 return parsed.toString().replace(/\/$/, "");
290300 };
291301
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+
292321 type StrategySelection =
293322 | { readonly kind: "none" }
294323 | { readonly kind: "custom" }
@@ -348,6 +377,31 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 53 unmodified lines
348377 });
349378 }
350379
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+
351405 function OAuthConnectedAccount(props: {
352406 readonly scopeId: ScopeId;
353407 readonly connectionId: string;
@@ -474,19 +528,30 @@ export default function AddOpenApiSource(props: { · 120 unmodified lines
474528 onComplete: () => void;
475529 onCancel: () => void;
476530 initialUrl?: string;
531+ initialPreset?: string;
477532 initialNamespace?: string;
478533 }) {
479534 // 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+ );
481539 const [analyzing, setAnalyzing] = useState(false);
482540 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
483541
484542 // After analysis
485543 const [preview, setPreview] = useState<SpecPreview | null>(null);
486544 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+ : "";
487552 const identity = useSourceIdentity({
488− fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",
489− fallbackNamespace: props.initialNamespace,
553+ fallbackName: identityFallbackName,
554+ fallbackNamespace: props.initialNamespace ?? (isGoogleBundlePreset ? "google" : undefined),
490555 });
491556
492557 // Auth
@@ -510,10 +575,21 @@ export default function AddOpenApiSource(props: { · 17 unmodified lines
510575 const [oauth2ScopesOpen, setOauth2ScopesOpen] = useState(false);
511576 const [oauth2AuthState, setOauth2AuthState] = useState<{
512577 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+ };
514583 } | null>(null);
515584 const [startingOAuth, setStartingOAuth] = useState(false);
585+ const [oauth2ProgressLabel, setOauth2ProgressLabel] = useState<string | null>(null);
516586 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+ >({});
517593
518594 // Submit
519595 const [adding, setAdding] = useState(false);
@@ -579,6 +655,7 @@ export default function AddOpenApiSource(props: { · 59 unmodified lines
579655 // Keep the latest handleAnalyze in a ref so the debounced effect doesn't
580656 // need it as a dependency (it closes over fresh state).
581657 const handleAnalyzeRef = useRef<() => void>(() => {});
658+ const googleBundleSelectionSeeded = useRef(false);
582659
583660 // Auto-analyze whenever the spec input changes, with a short debounce so
584661 // typing/pasting doesn't fire a request on every keystroke.
@@ -606,8 +683,38 @@ export default function AddOpenApiSource(props: { · 21 unmodified lines
606683 new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(),
607684 );
608685 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+ );
611718
612719 const resolvedBaseUrl = baseUrl.trim();
613720 const sourceScope = ScopeId.make(scopeId);
@@ -727,15 +834,94 @@ export default function AddOpenApiSource(props: { · 113 unmodified lines
727834 Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
728835 ].join("\n")
729836 : "";
730− const oauth2Auth =
837+ const activeOAuth2AuthState =
731838 oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint ? oauth2AuthState.auth : null;
732839 const selectedOAuth2AvailableIdentityScopes = selectedOAuth2Preset
733840 ? identityScopesForPreset(selectedOAuth2Preset.identityScopes)
734841 : [];
735842 const selectedOAuth2IsGoogle = selectedOAuth2Preset
736− ? isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl)
843+ ? isGoogleBundlePreset || isGoogleOAuthTarget(selectedOAuth2Preset, resolvedBaseUrl, specUrl)
737844 : false;
738845 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+ ]);
739925 const configuredOAuth2IdentityScopes =
740926 selectedOAuth2Preset && includeOAuth2IdentityScopes
741927 ? selectedOAuth2Preset.identityScopes
@@ -743,22 +929,130 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
743929 const selectedOAuth2Scopes = useMemo(
744930 () =>
745931 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],
749953 );
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+
7501040 const existingOAuthConnections = useMemo(() => {
7511041 if (
7521042 !selectedOAuth2Preset ||
7531043 selectedOAuth2Preset.flow !== "authorizationCode" ||
754− !AsyncResult.isSuccess(connectionsResult)
1044+ !AsyncResult.isSuccess(connectionsResult) ||
1045+ (googleServicePickerEnabled && selectedGoogleServiceIdList.length === 0)
7551046 ) {
7561047 return [];
7571048 }
7581049 return connectionsResult.value
7591050 .flatMap((connection) => {
7601051 if (connection.provider !== "oauth2") return [];
761− const missingApiScopes = missingOAuthScopes(connection, oauth2SelectedScopes);
1052+ const missingApiScopes = missingOAuthScopesFromGranted(
1053+ grantedScopesForConnection(connection),
1054+ effectiveOAuth2SelectedApiScopes,
1055+ );
7621056 if (missingApiScopes.length === 0) {
7631057 return [{ ...connection, missingApiScopes }];
7641058 }
@@ -768,35 +1062,35 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
7681062 return [];
7691063 })
7701064 .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;
7731076 const configuredOAuth2 =
7741077 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,
7941082 identityScopes: configuredOAuth2IdentityScopes,
7951083 })
7961084 : null;
7971085 const hasHeaders = Object.keys(configuredHeaders).length > 0;
7981086 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));
8001094 const hasIncompleteHeaderCredentials =
8011095 strategy.kind !== "none" &&
8021096 strategy.kind !== "oauth2" &&
@@ -815,7 +1109,13 @@ export default function AddOpenApiSource(props: { · 12 unmodified lines
8151109 hasIncompleteHeaderCredentials ||
8161110 hasIncompleteQueryCredentials;
8171111
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));
8191119
8201120 // ---- Handlers ----
8211121
@@ -920,23 +1220,46 @@ export default function AddOpenApiSource(props: { · 98 unmodified lines
9201220 if (!selectedOAuth2Preset || !preview) return;
9211221 oauth.cancel();
9221222 setOauth2Error(null);
1223+ setOauth2ProgressLabel(null);
9231224 const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName;
9241225 const tokenTargetScope = existingConnection?.scopeId ?? oauthTokenTargetScope;
9251226 const connectionId =
9261227 existingConnection?.id ??
9271228 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;
9341257 const scopesForProviderAuthorization = selectedOAuth2IsGoogle
935− ? compactGoogleOAuthScopes(scopesForAuthorization)
1258+ ? compactGoogleOAuthScopes(providerAuthorizationInput)
9361259 : undefined;
9371260 const extraAuthorizationParams = googleAuthorizationParams(selectedOAuth2IsGoogle);
9381261
939− const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);
1262+ const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, effectiveResolvedBaseUrl);
9401263 const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope;
9411264 const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope;
9421265
@@ -964,7 +1287,7 @@ export default function AddOpenApiSource(props: { · 21 unmodified lines
9641287 clientIdSecretScopeId: String(clientIdSecretScope),
9651288 clientSecretSecretId: oauth2ClientSecretSecretId,
9661289 clientSecretSecretScopeId: String(clientSecretSecretScope),
967− scopes: scopesForAuthorization,
1290+ scopes: scopesForConnection,
9681291 },
9691292 pluginId: "openapi",
9701293 identityLabel: `${displayName} OAuth`,
@@ -983,7 +1306,11 @@ export default function AddOpenApiSource(props: { · 12 unmodified lines
9831306 setOAuthTokenTargetScope(tokenTargetScope);
9841307 setOauth2AuthState({
9851308 fingerprint: selectedOAuth2Fingerprint,
986− auth: { connectionId: response.completedConnection.connectionId },
1309+ auth: {
1310+ connectionId: response.completedConnection.connectionId,
1311+ grantedScopes: scopesForConnection,
1312+ scopeId: tokenTargetScope,
1313+ },
9871314 });
9881315 setOauth2Error(null);
9891316 return;
@@ -992,16 +1319,181 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
9921319
9931320 const authorizationUrl = resolveOAuthUrl(
9941321 Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
995− resolvedBaseUrl,
1322+ effectiveResolvedBaseUrl,
9961323 );
9971324 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+
9981490 const authorizationStrategy = existingConnection
9991491 ? {
10001492 kind: "authorization-code-existing-client" as const,
10011493 authorizationEndpoint: authorizationUrl,
10021494 tokenEndpoint: tokenUrl,
10031495 issuerUrl,
1004− scopes: scopesForAuthorization,
1496+ scopes: scopesForConnection,
10051497 ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0
10061498 ? { authorizationScopes: scopesForProviderAuthorization }
10071499 : {}),
@@ -1019,7 +1511,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
10191511 clientSecretSecretScopeId: oauth2ClientSecretSecretId
10201512 ? String(clientSecretSecretScope)
10211513 : null,
1022− scopes: scopesForAuthorization,
1514+ scopes: scopesForConnection,
10231515 ...(scopesForProviderAuthorization && scopesForProviderAuthorization.length > 0
10241516 ? { authorizationScopes: scopesForProviderAuthorization }
10251517 : {}),
@@ -1061,13 +1553,21 @@ export default function AddOpenApiSource(props: { · 35 unmodified lines
10611553 setOAuthTokenTargetScope(tokenTargetScope);
10621554 setOauth2AuthState({
10631555 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+ },
10651563 });
1564+ setOauth2ProgressLabel(null);
10661565 setOauth2Error(null);
10671566 },
1068− onError: (message) => {
1567+ onError: (message, details) => {
10691568 setStartingOAuth(false);
1070− setOauth2Error(message);
1569+ setOauth2ProgressLabel(null);
1570+ setOauth2Error(oauthErrorMessage(selectedOAuth2ProviderLabel, message, details));
10711571 },
10721572 });
10731573 },
@@ -1075,11 +1575,15 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
10751575 selectedOAuth2Preset,
10761576 oauth2ClientIdSecretId,
10771577 oauth2ClientSecretSecretId,
1078− oauth2SelectedScopes,
1578+ effectiveOAuth2SelectedApiScopes,
10791579 selectedOAuth2Scopes,
10801580 selectedOAuth2IsGoogle,
10811581 oauth2RedirectUrl,
1082− resolvedBaseUrl,
1582+ effectiveResolvedBaseUrl,
1583+ configuredOAuth2IdentityScopes,
1584+ googleBatchAddItems,
1585+ googleConsentBatches,
1586+ googleServicePickerEnabled,
10831587 preview,
10841588 doStartOAuth,
10851589 identity.name,
@@ -1089,7 +1593,10 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
10891593 oauthTokenTargetScope,
10901594 oauth2ClientIdScope,
10911595 oauth2ClientSecretScope,
1596+ activeOAuth2AuthState,
1597+ grantedScopesForConnection,
10921598 sourceScope,
1599+ selectedOAuth2ProviderLabel,
10931600 ],
10941601 );
10951602
@@ -1106,7 +1613,11 @@ export default function AddOpenApiSource(props: { · 10 unmodified lines
11061613 setOAuthTokenTargetScope(connection.scopeId);
11071614 setOauth2AuthState({
11081615 fingerprint: selectedOAuth2Fingerprint,
1109− auth: { connectionId: connection.id },
1616+ auth: {
1617+ connectionId: connection.id,
1618+ grantedScopes: [...splitOAuthScopes(connection.oauthScope)],
1619+ scopeId: connection.scopeId,
1620+ },
11101621 });
11111622 setOauth2Error(null);
11121623 },
@@ -1116,14 +1627,35 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
11161627 const handleAdd = async () => {
11171628 setAdding(true);
11181629 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+
11191644 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);
11201652 const exit = await doAdd({
11211653 params: { scopeId },
11221654 payload: {
1123− spec: specInputForAdd(specUrl),
1655+ spec: specForAdd,
11241656 name: resolvedDisplayName,
11251657 namespace,
1126− baseUrl: resolvedBaseUrl,
1658+ baseUrl: effectiveResolvedBaseUrl,
11271659 ...(configuredSpecFetchCredentials
11281660 ? { specFetchCredentials: configuredSpecFetchCredentials }
11291661 : {}),
@@ -1142,9 +1674,6 @@ export default function AddOpenApiSource(props: { · 12 unmodified lines
11421674 }
11431675
11441676 const sourceId = exit.value.namespace;
1145− const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope);
1146− const clientIdBindingScope = oauth2ClientIdScope ?? sourceScope;
1147− const clientSecretBindingScope = oauth2ClientSecretScope ?? sourceScope;
11481677
11491678 for (const binding of headerBindings) {
11501679 const bindingExit = await doSetBinding({
@@ -1280,6 +1809,19 @@ export default function AddOpenApiSource(props: { · 129 unmodified lines
12801809 props.onComplete();
12811810 };
12821811
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+
12831825 // ---- Render ----
12841826
12851827 return (
@@ -1349,33 +1891,164 @@ export default function AddOpenApiSource(props: { · 63 unmodified lines
13491891
13501892 {/* ── Source information card (shown after analysis) ── */}
13511893 {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>
13792052 ) : null}
13802053
13812054 {analyzeError && (
@@ -1423,7 +2096,10 @@ export default function AddOpenApiSource(props: { · 41 unmodified lines
14232096 })}
14242097 {oauth2Presets.map((preset, i) => {
14252098 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;
14272103 return (
14282104 <Label
14292105 key={`oauth2-${i}`}
@@ -1622,7 +2298,53 @@ export default function AddOpenApiSource(props: { · 192 unmodified lines
16222298 </CollapsibleTrigger>
16232299 <CollapsibleContent>
16242300 <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 ? (
16262348 <div className="text-[11px] italic text-muted-foreground">
16272349 No scopes declared by the spec.
16282350 </div>
@@ -1683,8 +2405,8 @@ export default function AddOpenApiSource(props: { · 54 unmodified lines
16832405 <OAuthConnectedAccount
16842406 scopeId={oauthTokenTargetScope}
16852407 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"
16882410 } granted`}
16892411 sourceName={resolvedDisplayName}
16902412 onSetSourceName={identity.setName}
@@ -1698,7 +2420,9 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
16982420 <div className="flex items-center gap-2">
16992421 <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">
17002422 <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."}
17022426 </div>
17032427 <Button variant="ghost" size="sm" onClick={handleCancelOAuth2}>
17042428 Cancel
@@ -1808,9 +2532,15 @@ export default function AddOpenApiSource(props: { · 103 unmodified lines
18082532 {adding && <Spinner className="size-3.5" />}
18092533 {adding
18102534 ? "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"}
18142544 </Button>
18152545 )}
18162546 </FloatActions>
packages/plugins/openapi/src/sdk/__snapshots__/google-presets.test.ts.snap+82−0
@@ -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+`;
packages/plugins/openapi/src/sdk/extract.ts+39−5
@@ -6,11 +6,13 @@ import { · 5 unmodified lines
66 declaredContents,
77 DocResolver,
88 preferredResponseContent,
9+ resolveBaseUrl,
910 type OperationObject,
1011 type ParameterObject,
1112 type PathItemObject,
1213 type RequestBodyObject,
1314 type ResponseObject,
15+ type ServerObject,
1416 } from "./openapi-utils";
1517 import {
1618 EncodingObject,
@@ -227,16 +229,31 @@ const explicitToolPath = (operation: OperationObject): string | undefined => { · 210 unmodified lines
227229 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
228230 };
229231
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+
230237 // ---------------------------------------------------------------------------
231238 // Server extraction
232239 // ---------------------------------------------------------------------------
233240
234−const extractServers = (doc: ParsedDocument): ServerInfo[] =>
235− (doc.servers ?? []).flatMap((server) => {
241+const extractServerList = (servers: readonly ServerObject[] | undefined): ServerInfo[] =>
242+ (servers ?? []).flatMap((server) => {
236243 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
238255 ? Object.fromEntries(
239− Object.entries(server.variables).flatMap(([name, v]) => {
256+ Object.entries(serverVariables).flatMap(([name, v]) => {
240257 if (v.default === undefined || v.default === null) return [];
241258 const enumValues = Array.isArray(v.enum)
242259 ? v.enum.filter((x): x is string => typeof x === "string")
@@ -264,6 +281,21 @@ const extractServers = (doc: ParsedDocument): ServerInfo[] => · 21 unmodified lines
264281 ];
265282 });
266283
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+
267299 // ---------------------------------------------------------------------------
268300 // Main extraction
269301 // ---------------------------------------------------------------------------
@@ -294,13 +326,15 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume · 24 unmodified lines
294326 const inputSchema = buildInputSchema(parameters, requestBody);
295327 const outputSchema = extractOutputSchema(operation, r);
296328 const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);
329+ const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate;
297330
298331 operations.push(
299332 ExtractedOperation.make({
300333 operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),
301334 toolPath: Option.fromNullishOr(explicitToolPath(operation)),
302335 method,
303− pathTemplate,
336+ baseUrl: extractOperationBaseUrl(pathItem, operation),
337+ pathTemplate: operationPathTemplate,
304338 summary: Option.fromNullishOr(operation.summary),
305339 description: Option.fromNullishOr(operation.description),
306340 tags,
packages/plugins/openapi/src/sdk/google-discovery.test.ts+205−1
@@ -2,7 +2,10 @@ import { expect, it } from "@effect/vitest"; · 1 unmodified line
22 import { Effect, Option, Schema } from "effect";
33 import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
44
5−import { convertGoogleDiscoveryToOpenApi } from "./google-discovery";
5+import {
6+ convertGoogleDiscoveryBundleToOpenApi,
7+ convertGoogleDiscoveryToOpenApi,
8+} from "./google-discovery";
69 import { extract } from "./extract";
710 import { parse } from "./parse";
811
@@ -18,11 +21,14 @@ const ConvertedOperation = Schema.Struct({ · 9 unmodified lines
1821 schema: Schema.Unknown,
1922 style: Schema.optional(Schema.String),
2023 explode: Schema.optional(Schema.Boolean),
24+ allowReserved: Schema.optional(Schema.Boolean),
2125 }),
2226 ),
27+ servers: Schema.optional(Schema.Array(Schema.Struct({ url: Schema.String }))),
2328 security: Schema.optional(
2429 Schema.Array(Schema.Record(Schema.String, Schema.Array(Schema.String))),
2530 ),
31+ "x-executor-pathTemplate": Schema.optional(Schema.String),
2632 requestBody: Schema.optional(Schema.Unknown),
2733 responses: Schema.Unknown,
2834 "x-google-scopes": Schema.Array(Schema.String),
@@ -236,3 +242,201 @@ it.effect("converts Google Discovery documents into Executor-preserving OpenAPI · 207 unmodified lines
236242 expect(result.oauth2?.identityScopes).toEqual(["openid", "email", "profile"]);
237243 }),
238244 );
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+);
packages/plugins/openapi/src/sdk/google-discovery.ts+344−106
@@ -15,6 +15,7 @@ import type { OAuth2SourceConfig } from "./types"; · 14 unmodified lines
1515 import type { SpecFetchCredentials } from "./parse";
1616
1717 const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis";
18+const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
1819 const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
1920 const GOOGLE_OAUTH_ISSUER_URL = "https://accounts.google.com";
2021 const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -54,12 +55,16 @@ type OpenApiParameterObject = { · 33 unmodified lines
5455 readonly schema: OpenApiSchemaObject;
5556 readonly style?: "form";
5657 readonly explode?: boolean;
58+ readonly allowReserved?: boolean;
5759 };
5860
5961 type OpenApiOperationObject = {
6062 readonly operationId: string;
6163 readonly "x-executor-toolPath": string;
64+ readonly "x-executor-pathTemplate"?: string;
65+ readonly tags?: readonly string[];
6266 readonly description?: string;
67+ readonly servers?: readonly { readonly url: string }[];
6368 readonly parameters: readonly OpenApiParameterObject[];
6469 readonly requestBody?: {
6570 readonly required: false;
@@ -108,12 +113,21 @@ type OpenApiDocument = { · 42 unmodified lines
108113 >;
109114 };
110115 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+ };
117131 };
118132
119133 const TextOption = Schema.OptionFromOptional(Schema.Trim).pipe(
@@ -206,6 +220,7 @@ export interface GoogleDiscoveryOpenApiConversion { · 86 unmodified lines
206220 readonly title: string;
207221 readonly service: string;
208222 readonly version: string;
223+ readonly discoveryUrls?: readonly string[];
209224 readonly oauth2?: OAuth2SourceConfig;
210225 }
211226
@@ -282,6 +297,34 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 70 unmodified lines
282297
283298 const schemaRef = (name: string) => `#/components/schemas/${name}`;
284299
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+
285328 const discoveryDescription = (value: unknown): string | undefined =>
286329 typeof value === "string"
287330 ? value
@@ -326,10 +369,13 @@ const schemaType = (value: unknown): OpenApiSchemaObject["type"] | undefined => · 38 unmodified lines
326369 ? (value as OpenApiSchemaObject["type"])
327370 : undefined;
328371
329−const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => {
372+const discoverySchemaToOpenApiSchema = (
373+ raw: unknown,
374+ schemaNameForRef: (name: string) => string = identitySchemaName,
375+): OpenApiSchemaObject => {
330376 if (!isRecord(raw)) return {};
331377 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)) };
333379
334380 const description = discoveryDescription(schema.description);
335381 const title = discoveryDescription(schema.title);
@@ -351,7 +397,11 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 15 unmodified lines
351397 } satisfies OpenApiSchemaObject;
352398
353399 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+ };
355405 }
356406
357407 const properties = schema.properties;
@@ -364,7 +414,7 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 6 unmodified lines
364414 ? Object.fromEntries(
365415 Object.entries(properties).map(([name, value]) => [
366416 name,
367− discoverySchemaToOpenApiSchema(value),
417+ discoverySchemaToOpenApiSchema(value, schemaNameForRef),
368418 ]),
369419 )
370420 : undefined;
@@ -374,7 +424,7 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 3 unmodified lines
374424 ? undefined
375425 : typeof schema.additionalProperties === "boolean"
376426 ? schema.additionalProperties
377− : discoverySchemaToOpenApiSchema(schema.additionalProperties);
427+ : discoverySchemaToOpenApiSchema(schema.additionalProperties, schemaNameForRef);
378428 return {
379429 ...base,
380430 type: "object",
@@ -389,8 +439,11 @@ const discoverySchemaToOpenApiSchema = (raw: unknown): OpenApiSchemaObject => { · 8 unmodified lines
389439 return type !== undefined ? { ...base, type } : base;
390440 };
391441
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);
394447 return parameter.repeated
395448 ? {
396449 type: "array",
@@ -417,6 +470,144 @@ const discoveryScopes = (document: DiscoveryDocument): Record<string, string> => · 20 unmodified lines
417470 ]),
418471 );
419472
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+
420611 export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")(
421612 function* (input: { readonly discoveryUrl: string; readonly documentText: string }) {
422613 const parsed = yield* parseJson(input.documentText).pipe(
@@ -435,109 +626,33 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 12 unmodified lines
435626 }),
436627 });
437628
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;
449631 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− ];
454632
455− for (const method of allMethods) {
633+ for (const method of allDiscoveryMethods(document)) {
456634 const methodId = Option.getOrUndefined(method.id);
457635 const pathTemplate = Option.getOrUndefined(method.path);
458636 if (!methodId || !pathTemplate || !method.httpMethod) continue;
459637
460638 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+ });
521652 }
522653
523654 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);
541656
542657 const spec: OpenApiDocument = {
543658 openapi: "3.1.0",
@@ -591,3 +706,126 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 47 unmodified lines
591706 };
592707 },
593708 );
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+});
packages/plugins/openapi/src/sdk/google-oauth-batches.test.ts+98−0
@@ -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+});
packages/plugins/openapi/src/sdk/google-oauth-batches.ts+69−0
@@ -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+};
packages/plugins/openapi/src/sdk/google-oauth-scopes.test.ts+37−0
@@ -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+});
packages/plugins/openapi/src/sdk/google-oauth-scopes.ts+66−0
@@ -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+};
packages/plugins/openapi/src/sdk/google-presets.test.ts+29−0
@@ -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+});
packages/plugins/openapi/src/sdk/google-presets.ts+43−1
@@ -1,11 +1,30 @@
11 import type { OpenApiPreset } from "./presets";
22
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+
313 const gd = (service: string, version: string) =>
414 `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
515
616 const GOOGLE_G = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
17+export const GOOGLE_BUNDLE_PRESET_ID = "google";
718
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[] = [
928 {
1029 id: "google-calendar",
1130 name: "Google Calendar",
@@ -13,6 +32,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
1332 url: gd("calendar", "v3"),
1433 icon: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg",
1534 featured: true,
35+ oauthAudience: "standard-user",
1636 },
1737 {
1838 id: "google-gmail",
@@ -21,6 +41,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines
2141 url: gd("gmail", "v1"),
2242 icon: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png",
2343 featured: true,
44+ oauthAudience: "standard-user",
2445 },
2546 {
2647 id: "google-sheets",
@@ -29,6 +50,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines
2950 url: gd("sheets", "v4"),
3051 icon: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg",
3152 featured: true,
53+ oauthAudience: "standard-user",
3254 },
3355 {
3456 id: "google-drive",
@@ -37,6 +59,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines
3759 url: gd("drive", "v3"),
3860 icon: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg",
3961 featured: true,
62+ oauthAudience: "standard-user",
4063 },
4164 {
4265 id: "google-docs",
@@ -45,6 +68,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 2 unmodified lines
4568 url: gd("docs", "v1"),
4669 icon: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg",
4770 featured: true,
71+ oauthAudience: "standard-user",
4872 },
4973 {
5074 id: "google-slides",
@@ -52,6 +76,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
5276 summary: "Presentations, slides, page elements, and deck updates.",
5377 url: gd("slides", "v1"),
5478 icon: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg",
79+ oauthAudience: "standard-user",
5580 },
5681 {
5782 id: "google-forms",
@@ -59,6 +84,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
5984 summary: "Forms, questions, responses, and quizzes.",
6085 url: "https://forms.googleapis.com/$discovery/rest?version=v1",
6186 icon: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg",
87+ oauthAudience: "standard-user",
6288 },
6389 {
6490 id: "google-tasks",
@@ -66,6 +92,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
6692 summary: "Task lists, task items, notes, and due dates.",
6793 url: gd("tasks", "v1"),
6894 icon: "https://fonts.gstatic.com/s/i/productlogos/tasks/v5/192px.svg",
95+ oauthAudience: "standard-user",
6996 },
7097 {
7198 id: "google-people",
@@ -73,6 +100,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
73100 summary: "Contacts, profiles, directory people, and contact groups.",
74101 url: gd("people", "v1"),
75102 icon: "https://fonts.gstatic.com/s/i/productlogos/contacts_2022/v2/192px.svg",
103+ oauthAudience: "standard-user",
76104 },
77105 {
78106 id: "google-chat",
@@ -80,6 +108,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
80108 summary: "Spaces, messages, members, reactions, and chat workflows.",
81109 url: gd("chat", "v1"),
82110 icon: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg",
111+ oauthAudience: "workspace-admin",
83112 },
84113 {
85114 id: "google-keep",
@@ -87,6 +116,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
87116 summary: "Notes, lists, attachments, and annotations.",
88117 url: "https://keep.googleapis.com/$discovery/rest?version=v1",
89118 icon: "https://fonts.gstatic.com/s/i/productlogos/keep_2020q4/v8/192px.svg",
119+ oauthAudience: "unsupported-user",
90120 },
91121 {
92122 id: "google-youtube-data",
@@ -94,6 +124,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
94124 summary: "Channels, playlists, videos, comments, and uploads.",
95125 url: gd("youtube", "v3"),
96126 icon: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg",
127+ oauthAudience: "advanced-user",
97128 },
98129 {
99130 id: "google-search-console",
@@ -101,6 +132,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
101132 summary: "Sites, sitemaps, URL inspection, and search performance.",
102133 url: gd("searchconsole", "v1"),
103134 icon: GOOGLE_G,
135+ oauthAudience: "standard-user",
104136 },
105137 {
106138 id: "google-classroom",
@@ -108,6 +140,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
108140 summary: "Courses, rosters, coursework, and grading.",
109141 url: gd("classroom", "v1"),
110142 icon: "https://fonts.gstatic.com/s/i/productlogos/classroom/v7/192px.svg",
143+ oauthAudience: "advanced-user",
111144 },
112145 {
113146 id: "google-admin-directory",
@@ -115,6 +148,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
115148 summary: "Users, groups, org units, roles, and domain resources.",
116149 url: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
117150 icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg",
151+ oauthAudience: "workspace-admin",
118152 },
119153 {
120154 id: "google-admin-reports",
@@ -122,6 +156,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
122156 summary: "Audit events, usage reports, and admin activity logs.",
123157 url: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
124158 icon: "https://fonts.gstatic.com/s/i/productlogos/admin_2020q4/v6/192px.svg",
159+ oauthAudience: "workspace-admin",
125160 },
126161 {
127162 id: "google-apps-script",
@@ -129,6 +164,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
129164 summary: "Projects, deployments, and script execution.",
130165 url: gd("script", "v1"),
131166 icon: "https://fonts.gstatic.com/s/i/productlogos/apps_script/v10/192px.svg",
167+ oauthAudience: "advanced-user",
132168 },
133169 {
134170 id: "google-bigquery",
@@ -136,6 +172,7 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
136172 summary: "Datasets, tables, jobs, and analytical queries.",
137173 url: gd("bigquery", "v2"),
138174 icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg",
175+ oauthAudience: "advanced-user",
139176 },
140177 {
141178 id: "google-cloud-resource-manager",
@@ -143,5 +180,10 @@ export const googleOpenApiPresets: readonly OpenApiPreset[] = [ · 1 unmodified line
143180 summary: "Projects, folders, organizations, and IAM hierarchy.",
144181 url: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
145182 icon: "https://fonts.gstatic.com/s/i/productlogos/google_cloud/v6/192px.svg",
183+ oauthAudience: "advanced-user",
146184 },
147185 ];
186+
187+export const googleStandardUserOAuthPresets = googleOpenApiPresets.filter(
188+ (preset) => preset.oauthAudience === "standard-user",
189+);
packages/plugins/openapi/src/sdk/index.ts+1−0
@@ -1,5 +1,6 @@
11 export { parse, resolveSpecText, fetchSpecText } from "./parse";
22 export {
3+ convertGoogleDiscoveryBundleToOpenApi,
34 convertGoogleDiscoveryToOpenApi,
45 fetchGoogleDiscoveryDocument,
56 isGoogleDiscoveryUrl,
packages/plugins/openapi/src/sdk/invoke.ts+26−15
@@ -42,6 +42,20 @@ const readParamValue = (args: Record<string, unknown>, param: OperationParameter · 41 unmodified lines
4242 const primitiveToString = (value: unknown): string =>
4343 typeof value === "object" && value !== null ? JSON.stringify(value) : String(value);
4444
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+
4559 const queryParamValues = (value: unknown, param: OperationParameter): string[] => {
4660 if (value === undefined || value === null) return [];
4761 if (!Array.isArray(value)) return [primitiveToString(value)];
@@ -78,7 +92,12 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 30 unmodified lines
7892 }
7993 continue;
8094 }
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);
82101 }
83102
84103 const remaining = [...resolved.matchAll(/\{([^{}]+)\}/g)]
@@ -269,19 +288,9 @@ const resolveStyleExplode = (e: EncodingObject | undefined): StyleExplode => { · 184 unmodified lines
269288 };
270289 };
271290
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−
276291 const encodeFormValue = (v: unknown, allowReserved: boolean): string => {
277292 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);
285294 };
286295
287296 /**
@@ -643,12 +652,14 @@ export const invokeWithLayer = ( · 355 unmodified lines
643652 sourceQueryParams: Record<string, string>,
644653 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
645654 ) => {
646− const clientWithBaseUrl = baseUrl
655+ const operationBaseUrl = operation.baseUrl;
656+ const effectiveBaseUrl = operationBaseUrl ?? baseUrl;
657+ const clientWithBaseUrl = effectiveBaseUrl
647658 ? Layer.effect(
648659 HttpClient.HttpClient,
649660 Effect.map(
650661 Effect.service(HttpClient.HttpClient),
651− HttpClient.mapRequest(HttpClientRequest.prependUrl(baseUrl)),
662+ HttpClient.mapRequest(HttpClientRequest.prependUrl(effectiveBaseUrl)),
652663 ),
653664 ).pipe(Layer.provide(httpClientLayer))
654665 : httpClientLayer;
@@ -659,7 +670,7 @@ export const invokeWithLayer = ( · 4 unmodified lines
659670 attributes: {
660671 "plugin.openapi.method": operation.method.toUpperCase(),
661672 "plugin.openapi.path_template": operation.pathTemplate,
662− "plugin.openapi.base_url": baseUrl,
673+ "plugin.openapi.base_url": effectiveBaseUrl,
663674 },
664675 }),
665676 );
packages/plugins/openapi/src/sdk/openapi-utils.ts+1−0
@@ -19,6 +19,7 @@ export type PathItemObject = OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObje · 18 unmodified lines
1919 export type RequestBodyObject = OpenAPIV3.RequestBodyObject | OpenAPIV3_1.RequestBodyObject;
2020 export type ResponseObject = OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject;
2121 export type MediaTypeObject = OpenAPIV3.MediaTypeObject | OpenAPIV3_1.MediaTypeObject;
22+export type ServerObject = OpenAPIV3.ServerObject | OpenAPIV3_1.ServerObject;
2223
2324 // ---------------------------------------------------------------------------
2425 // DocResolver — wraps a parsed document for clean $ref resolution
packages/plugins/openapi/src/sdk/plugin.test.ts+3−1
@@ -120,7 +120,9 @@ const testApiSourceConfig = (options: TestApiSourceOptions = {}) => · 119 unmodified lines
120120
121121 const testApiSpec = () => {
122122 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;
124126 };
125127
126128 // ---------------------------------------------------------------------------
packages/plugins/openapi/src/sdk/plugin.ts+71−22
@@ -34,8 +34,9 @@ import { · 33 unmodified lines
3434 OpenApiOAuthError,
3535 OpenApiParseError,
3636 } from "./errors";
37−import { parse, resolveSpecText } from "./parse";
37+import { parse, resolveSpecText, type SpecFetchCredentials } from "./parse";
3838 import {
39+ convertGoogleDiscoveryBundleToOpenApi,
3940 convertGoogleDiscoveryToOpenApi,
4041 fetchGoogleDiscoveryDocument,
4142 isGoogleDiscoveryUrl,
@@ -332,6 +333,10 @@ const OpenApiSpecInputSchema = Schema.Union([ · 290 unmodified lines
332333 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
333334 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
334335 Schema.Struct({ kind: Schema.Literal("googleDiscovery"), url: Schema.String }),
336+ Schema.Struct({
337+ kind: Schema.Literal("googleDiscoveryBundle"),
338+ urls: Schema.Array(Schema.String),
339+ }),
335340 ]);
336341 const OpenApiSecretShapeInputSchema = Schema.Struct({
337342 kind: Schema.Literal("secret"),
@@ -590,6 +595,7 @@ const normalizeOpenApiRefs = (node: unknown): unknown => { · 252 unmodified lines
590595 const toBinding = (def: ToolDefinition): OperationBinding =>
591596 OperationBinding.make({
592597 method: def.operation.method,
598+ baseUrl: def.operation.baseUrl,
593599 pathTemplate: def.operation.pathTemplate,
594600 parameters: [...def.operation.parameters],
595601 requestBody: def.operation.requestBody,
@@ -959,6 +965,7 @@ const resolveEffectiveSourceConfig = ( · 363 unmodified lines
959965 config: {
960966 ...base.config,
961967 sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl,
968+ googleDiscoveryUrls: base.config.googleDiscoveryUrls ?? fallback.config.googleDiscoveryUrls,
962969 baseUrl: fallback.config.baseUrl,
963970 namespace: base.config.namespace ?? fallback.config.namespace,
964971 headers: hasBaseHeaders ? base.config.headers : fallback.config.headers,
@@ -1157,6 +1164,21 @@ const resolveStoredSpecFetchCredentials = ( · 192 unmodified lines
11571164 ),
11581165 );
11591166
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+
11601182 // ---------------------------------------------------------------------------
11611183 // OAuth2 token exchange / refresh is owned by `ctx.oauth`, which registers
11621184 // the canonical core `"oauth2"` ConnectionProvider. OpenAPI owns only the
@@ -1197,13 +1219,18 @@ const toOpenApiSourceConfig = ( · 34 unmodified lines
11971219 };
11981220
11991221 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;
12011227
12021228 export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
12031229 type RebuildInput = {
12041230 readonly specText: string;
12051231 readonly scope: string;
12061232 readonly sourceUrl?: string;
1233+ readonly googleDiscoveryUrls?: readonly string[];
12071234 readonly name: string;
12081235 readonly baseUrl: string | undefined;
12091236 readonly namespace: string;
@@ -1269,6 +1296,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 59 unmodified lines
12691296 const sourceConfig: SourceConfig = {
12701297 spec: input.specText,
12711298 sourceUrl: input.sourceUrl,
1299+ googleDiscoveryUrls: input.googleDiscoveryUrls,
12721300 baseUrl,
12731301 namespace: input.namespace,
12741302 headers: canonicalHeaders.headers,
@@ -1303,7 +1331,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 28 unmodified lines
13031331 // `canRefresh` reflects whether we still know the
13041332 // origin URL — sources added from raw spec text have
13051333 // nothing to re-fetch, so refresh stays disabled.
1306− canRefresh: input.sourceUrl != null,
1334+ canRefresh: input.sourceUrl != null || input.googleDiscoveryUrls != null,
13071335 canEdit: true,
13081336 tools: definitions.map((def) => ({
13091337 name: def.toolPath,
@@ -1341,25 +1369,38 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 31 unmodified lines
13411369 const effective = yield* resolveEffectiveSourceConfig(ctx, existing);
13421370 const resolvedConfig = effective.config;
13431371 const sourceUrl = resolvedConfig.sourceUrl;
1344− if (!sourceUrl) return;
1372+ const googleDiscoveryUrls = resolvedConfig.googleDiscoveryUrls;
1373+ if (!sourceUrl && !googleDiscoveryUrls) return;
13451374 const credentials = yield* resolveStoredSpecFetchCredentials(ctx, {
13461375 sourceId: existing.namespace,
13471376 sourceScope: effective.specFetchCredentialsSource.scope,
13481377 credentials: resolvedConfig.specFetchCredentials,
13491378 });
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,
13571384 )
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;
13591399 yield* rebuildSource(ctx, {
13601400 specText,
13611401 scope,
13621402 sourceUrl,
1403+ googleDiscoveryUrls,
13631404 name: existing.name,
13641405 baseUrl: existing.config.baseUrl,
13651406 namespace: existing.namespace,
@@ -1394,16 +1435,22 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 28 unmodified lines
13941435 }),
13951436 ),
13961437 )
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+ };
14071454 return yield* rebuildSource(ctx, {
14081455 specText: resolvedSpec.specText,
14091456 scope: config.scope,
@@ -1411,6 +1458,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
14111458 config.spec.kind === "url" || config.spec.kind === "googleDiscovery"
14121459 ? config.spec.url
14131460 : undefined,
1461+ googleDiscoveryUrls:
1462+ config.spec.kind === "googleDiscoveryBundle" ? config.spec.urls : undefined,
14141463 name: config.name,
14151464 baseUrl: resolvedSpec.baseUrl || config.baseUrl,
14161465 namespace: config.namespace,
packages/plugins/openapi/src/sdk/presets.ts+4−3
@@ -1,10 +1,10 @@
1−import { googleOpenApiPresets } from "./google-presets";
1+import { googleOpenApiBundlePreset, googleOpenApiPresets } from "./google-presets";
22
33 export interface OpenApiPreset {
44 readonly id: string;
55 readonly name: string;
66 readonly summary: string;
7− readonly url: string;
7+ readonly url?: string;
88 readonly icon?: string;
99 readonly featured?: boolean;
1010 }
@@ -140,9 +140,10 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 129 unmodified lines
140140 },
141141 ];
142142
143−export { googleOpenApiPresets } from "./google-presets";
143+export { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./google-presets";
144144
145145 export const openApiPresets: readonly OpenApiPreset[] = [
146+ googleOpenApiBundlePreset,
146147 ...openApiOnlyPresets,
147148 ...googleOpenApiPresets,
148149 ];
packages/plugins/openapi/src/sdk/query-serialization.test.ts+41−0
@@ -95,3 +95,44 @@ it.effect("serializes form-exploded query arrays as repeated parameters", () => · 94 unmodified lines
9595 }),
9696 ),
9797 );
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+);
packages/plugins/openapi/src/sdk/source-contracts.ts+1−0
@@ -9,6 +9,7 @@ export const StoredSourceSchema = Schema.Struct({ · 8 unmodified lines
99 config: Schema.Struct({
1010 spec: Schema.optional(Schema.String),
1111 sourceUrl: Schema.optional(Schema.String),
12+ googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
1213 baseUrl: Schema.optional(Schema.String),
1314 namespace: Schema.optional(Schema.String),
1415 headers: Schema.optional(Schema.Record(Schema.String, ConfiguredHeaderValue)),
packages/plugins/openapi/src/sdk/store.ts+4−0
@@ -25,6 +25,7 @@ export { · 24 unmodified lines
2525 export interface SourceConfig {
2626 readonly spec: string;
2727 readonly sourceUrl?: string;
28+ readonly googleDiscoveryUrls?: readonly string[];
2829 readonly baseUrl?: string;
2930 readonly namespace?: string;
3031 readonly headers?: Record<string, ConfiguredHeaderValue>;
@@ -80,6 +81,7 @@ const SpecFetchCredentialsStorage = Schema.Struct({ · 49 unmodified lines
8081 const SourceConfigStorage = Schema.Struct({
8182 spec: Schema.String,
8283 sourceUrl: Schema.optional(Schema.String),
84+ googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
8385 baseUrl: Schema.optional(Schema.String),
8486 namespace: Schema.optional(Schema.String),
8587 headers: Schema.optional(ConfiguredHeaderMapStorage),
@@ -141,6 +143,7 @@ const normalizeConfiguredMap = ( · 55 unmodified lines
141143 const encodeSourceConfig = (config: SourceConfig): Record<string, unknown> => ({
142144 spec: config.spec,
143145 ...(config.sourceUrl ? { sourceUrl: config.sourceUrl } : {}),
146+ ...(config.googleDiscoveryUrls ? { googleDiscoveryUrls: config.googleDiscoveryUrls } : {}),
144147 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
145148 ...(config.namespace ? { namespace: config.namespace } : {}),
146149 ...(config.headers ? { headers: config.headers } : {}),
@@ -161,6 +164,7 @@ const rowToSource = (row: PluginStorageEntry): StoredSource | null => { · 14 unmodified lines
161164 config: {
162165 spec: stored.config.spec,
163166 sourceUrl: stored.config.sourceUrl,
167+ googleDiscoveryUrls: stored.config.googleDiscoveryUrls,
164168 baseUrl: stored.config.baseUrl,
165169 namespace: stored.config.namespace,
166170 headers: normalizeConfiguredMap(stored.config.headers),
packages/plugins/openapi/src/sdk/types.ts+2−0
@@ -97,6 +97,7 @@ export const ExtractedOperation = Schema.Struct({ · 96 unmodified lines
9797 operationId: OperationId,
9898 toolPath: Schema.OptionFromOptional(Schema.String),
9999 method: HttpMethod,
100+ baseUrl: Schema.optional(Schema.String),
100101 pathTemplate: Schema.String,
101102 summary: Schema.OptionFromOptional(Schema.String),
102103 description: Schema.OptionFromOptional(Schema.String),
@@ -137,6 +138,7 @@ export type ExtractionResult = typeof ExtractionResult.Type; · 34 unmodified lines
137138
138139 export const OperationBinding = Schema.Struct({
139140 method: HttpMethod,
141+ baseUrl: Schema.optional(Schema.String),
140142 pathTemplate: Schema.String,
141143 parameters: Schema.Array(OperationParameter),
142144 requestBody: Schema.OptionFromOptional(OperationRequestBody),
packages/react/src/components/info.tsx+72−0
@@ -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 };