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