Unified · Split
| @@ -99,12 +99,13 @@ scenario( · 98 unmodified lines | |||
| 99 | await row.getByRole("link", { name: "Open" }).waitFor(); | 99 | await row.getByRole("link", { name: "Open" }).waitFor(); |
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | const customRow = page.getByTestId("add-result-row-custom"); | 102 | const customRow = page.getByTestId("add-result-row-google_tasks"); |
| 103 | await customRow.waitFor({ timeout: 120_000 }); | 103 | await customRow.waitFor({ timeout: 120_000 }); |
| 104 | expect( | 104 | expect( |
| 105 | await customRow.getAttribute("data-state"), | 105 | await customRow.getAttribute("data-state"), |
| 106 | "the custom Discovery URL is added through the services endpoint", | 106 | "the custom Discovery URL is added through the services endpoint", |
| 107 | ).toBe("added"); | 107 | ).toBe("added"); |
| 108 | await customRow.getByText("Google Tasks").waitFor(); | ||
| 108 | await customRow.getByRole("link", { name: "Open" }).waitFor(); | 109 | await customRow.getByRole("link", { name: "Open" }).waitFor(); |
| 109 | }); | 110 | }); |
| 110 | 111 | ||
| @@ -128,13 +129,14 @@ scenario( · 17 unmodified lines | |||
| 128 | await page.goto("/integrations", { waitUntil: "networkidle" }); | 129 | await page.goto("/integrations", { waitUntil: "networkidle" }); |
| 129 | // Each fanned-out integration is its own list entry, keyed by its slug | 130 | // Each fanned-out integration is its own list entry, keyed by its slug |
| 130 | // (the list renders the slug as each entry's description). | 131 | // (the list renders the slug as each entry's description). |
| 131 | for (const slug of ["google_calendar", "google_gmail", "google_drive", "google_custom"]) { | 132 | for (const slug of ["google_calendar", "google_gmail", "google_drive", "google_tasks"]) { |
| 132 | await page | 133 | await page |
| 133 | .getByRole("main") | 134 | .getByRole("main") |
| 134 | .getByText(slug, { exact: true }) | 135 | .getByText(slug, { exact: true }) |
| 135 | .first() | 136 | .first() |
| 136 | .waitFor({ timeout: 20_000 }); | 137 | .waitFor({ timeout: 20_000 }); |
| 137 | } | 138 | } |
| 139 | await page.getByRole("main").getByText("Google Tasks").first().waitFor({ timeout: 20_000 }); | ||
| 138 | }); | 140 | }); |
| 139 | 141 | ||
| 140 | await step("Re-adding an existing product reports it as skipped", async () => { | 142 | await step("Re-adding an existing product reports it as skipped", async () => { |
| @@ -56,7 +56,7 @@ const AddCustomServicePayload = Schema.Struct({ · 55 unmodified lines | |||
| 56 | custom: Schema.Struct({ | 56 | custom: Schema.Struct({ |
| 57 | urls: Schema.Array(Schema.String), | 57 | urls: Schema.Array(Schema.String), |
| 58 | slug: Schema.optional(Schema.String), | 58 | slug: Schema.optional(Schema.String), |
| 59 | name: Schema.String, | 59 | name: Schema.optional(Schema.String), |
| 60 | description: Schema.optional(Schema.String), | 60 | description: Schema.optional(Schema.String), |
| 61 | }), | 61 | }), |
| 62 | }); | 62 | }); |
| @@ -98,6 +98,11 @@ describe("AddGoogleSource per-service submit", () => { · 97 unmodified lines | |||
| 98 | presetId: "custom", | 98 | presetId: "custom", |
| 99 | error: "Custom Discovery fetch failed", | 99 | error: "Custom Discovery fetch failed", |
| 100 | }, | 100 | }, |
| 101 | { | ||
| 102 | slug: IntegrationSlug.make("google_tasks"), | ||
| 103 | presetId: "google_tasks", | ||
| 104 | error: "Tasks Discovery fetch failed", | ||
| 105 | }, | ||
| 101 | ], | 106 | ], |
| 102 | }; | 107 | }; |
| 103 | 108 | ||
| @@ -117,6 +122,8 @@ describe("AddGoogleSource per-service submit", () => { · 13 unmodified lines | |||
| 117 | expect(text).toContain("Discovery fetch failed"); | 122 | expect(text).toContain("Discovery fetch failed"); |
| 118 | expect(text).toContain("Custom Discovery URLs"); | 123 | expect(text).toContain("Custom Discovery URLs"); |
| 119 | expect(text).toContain("Custom Discovery fetch failed"); | 124 | expect(text).toContain("Custom Discovery fetch failed"); |
| 125 | expect(text).toContain("Google Tasks"); | ||
| 126 | expect(text).toContain("Tasks Discovery fetch failed"); | ||
| 120 | expect(text).toContain("Retry"); | 127 | expect(text).toContain("Retry"); |
| 121 | }); | 128 | }); |
| 122 | 129 | ||
| @@ -181,15 +188,12 @@ describe("AddGoogleSource per-service submit", () => { · 58 unmodified lines | |||
| 181 | }); | 188 | }); |
| 182 | }); | 189 | }); |
| 183 | 190 | ||
| 184 | it("submits custom Discovery URLs as one service entry", () => { | 191 | it("submits custom Discovery URLs without generic identity overrides", () => { |
| 185 | expect( | 192 | expect( |
| 186 | googleAddServicesPayload({ | 193 | googleAddServicesPayload({ |
| 187 | presetIds: ["google-calendar"], | 194 | presetIds: ["google-calendar"], |
| 188 | custom: { | 195 | custom: { |
| 189 | urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], | 196 | urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], |
| 190 | slug: "google_custom", | ||
| 191 | name: "Custom Google APIs", | ||
| 192 | description: "Custom Google APIs.", | ||
| 193 | }, | 197 | }, |
| 194 | baseUrl: " https://proxy.example ", | 198 | baseUrl: " https://proxy.example ", |
| 195 | }), | 199 | }), |
| @@ -199,13 +203,35 @@ describe("AddGoogleSource per-service submit", () => { · 3 unmodified lines | |||
| 199 | { | 203 | { |
| 200 | custom: { | 204 | custom: { |
| 201 | urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], | 205 | urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], |
| 202 | slug: "google_custom", | ||
| 203 | name: "Custom Google APIs", | ||
| 204 | description: "Custom Google APIs.", | ||
| 205 | }, | 206 | }, |
| 206 | }, | 207 | }, |
| 207 | ], | 208 | ], |
| 208 | baseUrl: "https://proxy.example", | 209 | baseUrl: "https://proxy.example", |
| 209 | }); | 210 | }); |
| 210 | }); | 211 | }); |
| 212 | |||
| 213 | it("passes explicit custom Discovery URL identity overrides when supplied", () => { | ||
| 214 | expect( | ||
| 215 | googleAddServicesPayload({ | ||
| 216 | presetIds: [], | ||
| 217 | custom: { | ||
| 218 | urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], | ||
| 219 | slug: "team_tasks", | ||
| 220 | name: "Team Tasks", | ||
| 221 | description: "Internal task workflow.", | ||
| 222 | }, | ||
| 223 | }), | ||
| 224 | ).toEqual({ | ||
| 225 | services: [ | ||
| 226 | { | ||
| 227 | custom: { | ||
| 228 | urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], | ||
| 229 | slug: "team_tasks", | ||
| 230 | name: "Team Tasks", | ||
| 231 | description: "Internal task workflow.", | ||
| 232 | }, | ||
| 233 | }, | ||
| 234 | ], | ||
| 235 | }); | ||
| 236 | }); | ||
| 211 | }); | 237 | }); |
| @@ -13,12 +13,7 @@ import { Button } from "@executor-js/react/components/button"; · 12 unmodified lines | |||
| 13 | import { FieldLabel } from "@executor-js/react/components/field"; | 13 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 14 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 14 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 15 | import { Input } from "@executor-js/react/components/input"; | 15 | import { Input } from "@executor-js/react/components/input"; |
| 16 | import { | 16 | import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; |
| 17 | errorMessageFromExit, | ||
| 18 | FormErrorAlert, | ||
| 19 | SlugCollisionAlert, | ||
| 20 | useSlugAlreadyExists, | ||
| 21 | } from "@executor-js/react/lib/integration-add"; | ||
| 22 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; | 17 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; |
| 23 | 18 | ||
| 24 | import { addGoogleServices } from "./atoms"; | 19 | import { addGoogleServices } from "./atoms"; |
| @@ -52,8 +47,8 @@ export type GoogleServiceIdentityOverride = { · 27 unmodified lines | |||
| 52 | 47 | ||
| 53 | export type GoogleCustomServiceInput = { | 48 | export type GoogleCustomServiceInput = { |
| 54 | readonly urls: readonly string[]; | 49 | readonly urls: readonly string[]; |
| 55 | readonly slug: string; | 50 | readonly slug?: string; |
| 56 | readonly name: string; | 51 | readonly name?: string; |
| 57 | readonly description?: string; | 52 | readonly description?: string; |
| 58 | }; | 53 | }; |
| 59 | 54 | ||
| @@ -81,8 +76,8 @@ export const googleAddServicesPayload = (input: { · 21 unmodified lines | |||
| 81 | { | 76 | { |
| 82 | custom: { | 77 | custom: { |
| 83 | urls: [...input.custom.urls], | 78 | urls: [...input.custom.urls], |
| 84 | slug: input.custom.slug, | 79 | ...(input.custom.slug?.trim() ? { slug: input.custom.slug.trim() } : {}), |
| 85 | name: input.custom.name, | 80 | ...(input.custom.name?.trim() ? { name: input.custom.name.trim() } : {}), |
| 86 | ...(input.custom.description?.trim() | 81 | ...(input.custom.description?.trim() |
| 87 | ? { description: input.custom.description.trim() } | 82 | ? { description: input.custom.description.trim() } |
| 88 | : {}), | 83 | : {}), |
| @@ -152,28 +147,73 @@ export const googleAddServicesResultRows = ( · 63 unmodified lines | |||
| 152 | })), | 147 | })), |
| 153 | ]; | 148 | ]; |
| 154 | 149 | ||
| 150 | const googleAddServicesResultRowId = (row: GoogleServiceResultRow): string => | ||
| 151 | row.presetId === GOOGLE_CUSTOM_SERVICE_ID ? row.slug : row.presetId; | ||
| 152 | |||
| 155 | export const mergeGoogleAddServicesResult = ( | 153 | export const mergeGoogleAddServicesResult = ( |
| 156 | previous: GoogleAddServicesResult, | 154 | previous: GoogleAddServicesResult, |
| 157 | next: GoogleAddServicesResult, | 155 | next: GoogleAddServicesResult, |
| 158 | ): GoogleAddServicesResult => { | 156 | ): GoogleAddServicesResult => { |
| 159 | const nextPresetIds = new Set(googleAddServicesResultRows(next).map((row) => row.presetId)); | 157 | const nextRowIds = new Set(googleAddServicesResultRows(next).map(googleAddServicesResultRowId)); |
| 160 | return { | 158 | return { |
| 161 | added: [...previous.added.filter((entry) => !nextPresetIds.has(entry.presetId)), ...next.added], | 159 | added: [ |
| 160 | ...previous.added.filter( | ||
| 161 | (entry) => | ||
| 162 | !nextRowIds.has( | ||
| 163 | googleAddServicesResultRowId({ | ||
| 164 | status: "added", | ||
| 165 | presetId: entry.presetId, | ||
| 166 | slug: String(entry.slug), | ||
| 167 | toolCount: entry.toolCount, | ||
| 168 | }), | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | ...next.added, | ||
| 172 | ], | ||
| 162 | skipped: [ | 173 | skipped: [ |
| 163 | ...previous.skipped.filter((entry) => !nextPresetIds.has(entry.presetId)), | 174 | ...previous.skipped.filter( |
| 175 | (entry) => | ||
| 176 | !nextRowIds.has( | ||
| 177 | googleAddServicesResultRowId({ | ||
| 178 | status: "skipped", | ||
| 179 | presetId: entry.presetId, | ||
| 180 | slug: String(entry.slug), | ||
| 181 | reason: entry.reason, | ||
| 182 | }), | ||
| 183 | ), | ||
| 184 | ), | ||
| 164 | ...next.skipped, | 185 | ...next.skipped, |
| 165 | ], | 186 | ], |
| 166 | failed: [ | 187 | failed: [ |
| 167 | ...previous.failed.filter((entry) => !nextPresetIds.has(entry.presetId)), | 188 | ...previous.failed.filter( |
| 189 | (entry) => | ||
| 190 | !nextRowIds.has( | ||
| 191 | googleAddServicesResultRowId({ | ||
| 192 | status: "failed", | ||
| 193 | presetId: entry.presetId, | ||
| 194 | slug: String(entry.slug), | ||
| 195 | error: entry.error, | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | ), | ||
| 168 | ...next.failed, | 199 | ...next.failed, |
| 169 | ], | 200 | ], |
| 170 | }; | 201 | }; |
| 171 | }; | 202 | }; |
| 172 | 203 | ||
| 173 | const googlePresetName = (presetId: string): string => | 204 | const humanizeGoogleSlug = (slug: string): string => |
| 174 | presetId === GOOGLE_CUSTOM_SERVICE_ID | 205 | slug |
| 206 | .replace(/^google_/, "") | ||
| 207 | .split("_") | ||
| 208 | .filter((part) => part.length > 0) | ||
| 209 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) | ||
| 210 | .join(" "); | ||
| 211 | |||
| 212 | const googlePresetName = (row: GoogleServiceResultRow): string => | ||
| 213 | row.presetId === GOOGLE_CUSTOM_SERVICE_ID || row.slug === "google_custom" | ||
| 175 | ? "Custom Discovery URLs" | 214 | ? "Custom Discovery URLs" |
| 176 | : (googleOpenApiPresetById.get(presetId)?.name ?? presetId); | 215 | : (googleOpenApiPresetById.get(row.presetId)?.name ?? |
| 216 | (row.slug.startsWith("google_") ? `Google ${humanizeGoogleSlug(row.slug)}` : row.slug)); | ||
| 177 | 217 | ||
| 178 | export function GoogleServiceResultPanel(props: { | 218 | export function GoogleServiceResultPanel(props: { |
| 179 | readonly result: GoogleAddServicesResult; | 219 | readonly result: GoogleAddServicesResult; |
| @@ -196,11 +236,12 @@ export function GoogleServiceResultPanel(props: { · 16 unmodified lines | |||
| 196 | </div> | 236 | </div> |
| 197 | <ul className="space-y-2"> | 237 | <ul className="space-y-2"> |
| 198 | {rows.map((row: GoogleServiceResultRow) => { | 238 | {rows.map((row: GoogleServiceResultRow) => { |
| 199 | const presetName = googlePresetName(row.presetId); | 239 | const rowId = googleAddServicesResultRowId(row); |
| 240 | const presetName = googlePresetName(row); | ||
| 200 | return ( | 241 | return ( |
| 201 | <li | 242 | <li |
| 202 | key={`${row.status}:${row.presetId}`} | 243 | key={`${row.status}:${rowId}`} |
| 203 | data-testid={`add-result-row-${row.presetId}`} | 244 | data-testid={`add-result-row-${rowId}`} |
| 204 | data-state={row.status} | 245 | data-state={row.status} |
| 205 | className="flex items-start gap-2 rounded-md border border-border bg-background px-2.5 py-2" | 246 | className="flex items-start gap-2 rounded-md border border-border bg-background px-2.5 py-2" |
| 206 | > | 247 | > |
| @@ -249,9 +290,9 @@ export function GoogleServiceResultPanel(props: { · 42 unmodified lines | |||
| 249 | type="button" | 290 | type="button" |
| 250 | variant="outline" | 291 | variant="outline" |
| 251 | size="xs" | 292 | size="xs" |
| 252 | data-testid={`add-result-retry-${row.presetId}`} | 293 | data-testid={`add-result-retry-${rowId}`} |
| 253 | loading={props.retryingPresetId === row.presetId} | 294 | loading={props.retryingPresetId === rowId} |
| 254 | onClick={() => void props.onRetry(row.presetId)} | 295 | onClick={() => void props.onRetry(rowId)} |
| 255 | > | 296 | > |
| 256 | Retry | 297 | Retry |
| 257 | </Button> | 298 | </Button> |
| @@ -302,7 +343,6 @@ export default function AddGoogleSource(props: { · 44 unmodified lines | |||
| 302 | ); | 343 | ); |
| 303 | const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]); | 344 | const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]); |
| 304 | const [baseUrl, setBaseUrl] = useState(""); | 345 | const [baseUrl, setBaseUrl] = useState(""); |
| 305 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | ||
| 306 | const [adding, setAdding] = useState(false); | 346 | const [adding, setAdding] = useState(false); |
| 307 | const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null); | 347 | const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null); |
| 308 | const [addError, setAddError] = useState<string | null>(null); | 348 | const [addError, setAddError] = useState<string | null>(null); |
| @@ -357,16 +397,6 @@ export default function AddGoogleSource(props: { · 48 unmodified lines | |||
| 357 | (hasCustomDiscoveryUrls | 397 | (hasCustomDiscoveryUrls |
| 358 | ? "Custom Google APIs" | 398 | ? "Custom Google APIs" |
| 359 | : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google"))); | 399 | : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google"))); |
| 360 | const resolvedDescription = | ||
| 361 | descriptionDraft ?? | ||
| 362 | (hasCustomDiscoveryUrls | ||
| 363 | ? "Custom Google APIs." | ||
| 364 | : isGooglePhotosPreset | ||
| 365 | ? "Google Photos albums, uploads, app-created media, and selected picker media." | ||
| 366 | : "Google APIs"); | ||
| 367 | const customSlugAlreadyExists = useSlugAlreadyExists( | ||
| 368 | hasCustomDiscoveryUrls ? resolvedSourceId : "", | ||
| 369 | ); | ||
| 370 | const identityOverride = | 400 | const identityOverride = |
| 371 | selectedIds.length === 1 && !hasCustomDiscoveryUrls | 401 | selectedIds.length === 1 && !hasCustomDiscoveryUrls |
| 372 | ? { slug: resolvedSourceId, name: resolvedDisplayName } | 402 | ? { slug: resolvedSourceId, name: resolvedDisplayName } |
| @@ -375,15 +405,9 @@ export default function AddGoogleSource(props: { · 2 unmodified lines | |||
| 375 | customDiscoveryUrls.length > 0 | 405 | customDiscoveryUrls.length > 0 |
| 376 | ? { | 406 | ? { |
| 377 | urls: [...customDiscoveryUrls], | 407 | urls: [...customDiscoveryUrls], |
| 378 | slug: resolvedSourceId, | ||
| 379 | name: resolvedDisplayName, | ||
| 380 | description: resolvedDescription, | ||
| 381 | } | 408 | } |
| 382 | : undefined; | 409 | : undefined; |
| 383 | const canAdd = | 410 | const canAdd = (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && !adding; |
| 384 | (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && | ||
| 385 | !customSlugAlreadyExists && | ||
| 386 | !adding; | ||
| 387 | 411 | ||
| 388 | const handleAdd = async () => { | 412 | const handleAdd = async () => { |
| 389 | setAdding(true); | 413 | setAdding(true); |
| @@ -407,7 +431,7 @@ export default function AddGoogleSource(props: { · 17 unmodified lines | |||
| 407 | const handleRetry = async (presetId: string) => { | 431 | const handleRetry = async (presetId: string) => { |
| 408 | setRetryingPresetId(presetId); | 432 | setRetryingPresetId(presetId); |
| 409 | setAddError(null); | 433 | setAddError(null); |
| 410 | const retryingCustom = presetId === GOOGLE_CUSTOM_SERVICE_ID; | 434 | const retryingCustom = !googleOpenApiPresetById.has(presetId); |
| 411 | const retryIdentityOverride = | 435 | const retryIdentityOverride = |
| 412 | !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId | 436 | !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId |
| 413 | ? identityOverride | 437 | ? identityOverride |
| @@ -429,19 +453,9 @@ export default function AddGoogleSource(props: { · 15 unmodified lines | |||
| 429 | setRetryingPresetId(null); | 453 | setRetryingPresetId(null); |
| 430 | }; | 454 | }; |
| 431 | 455 | ||
| 432 | const showIdentityDetails = selectedIds.length === 1 || hasCustomDiscoveryUrls; | 456 | const showIdentityDetails = selectedIds.length === 1 && !hasCustomDiscoveryUrls; |
| 433 | const detailTitle = hasCustomDiscoveryUrls | 457 | const detailTitle = singleSelectedPreset?.name ?? "Google"; |
| 434 | ? "Custom Google Discovery URLs" | 458 | const detailSubtitle = "This product is added as its own integration."; |
| 435 | : (singleSelectedPreset?.name ?? "Google"); | ||
| 436 | const detailSubtitle = hasCustomDiscoveryUrls | ||
| 437 | ? selectedIds.length > 0 | ||
| 438 | ? `${customDiscoveryUrls.length} custom URL${ | ||
| 439 | customDiscoveryUrls.length === 1 ? "" : "s" | ||
| 440 | } added as its own integration. Selected products keep preset names.` | ||
| 441 | : `${customDiscoveryUrls.length} custom URL${ | ||
| 442 | customDiscoveryUrls.length === 1 ? "" : "s" | ||
| 443 | } added as its own integration.` | ||
| 444 | : "This product is added as its own integration."; | ||
| 445 | 459 | ||
| 446 | const dismiss = () => { | 460 | const dismiss = () => { |
| 447 | if (servicesResult) { | 461 | if (servicesResult) { |
| @@ -473,9 +487,6 @@ export default function AddGoogleSource(props: { · 25 unmodified lines | |||
| 473 | title={detailTitle} | 487 | title={detailTitle} |
| 474 | subtitle={detailSubtitle} | 488 | subtitle={detailSubtitle} |
| 475 | identity={identity} | 489 | identity={identity} |
| 476 | {...(hasCustomDiscoveryUrls | ||
| 477 | ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } | ||
| 478 | : {})} | ||
| 479 | baseUrl={baseUrl} | 490 | baseUrl={baseUrl} |
| 480 | onBaseUrlChange={setBaseUrl} | 491 | onBaseUrlChange={setBaseUrl} |
| 481 | baseUrlLabel="Base URL override (optional)" | 492 | baseUrlLabel="Base URL override (optional)" |
| @@ -486,8 +497,6 @@ export default function AddGoogleSource(props: { · 4 unmodified lines | |||
| 486 | <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} /> | 497 | <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} /> |
| 487 | )} | 498 | )} |
| 488 | 499 | ||
| 489 | {customSlugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | ||
| 490 | |||
| 491 | {addError && <FormErrorAlert message={addError} />} | 500 | {addError && <FormErrorAlert message={addError} />} |
| 492 | 501 | ||
| 493 | {servicesResult && ( | 502 | {servicesResult && ( |
| @@ -241,6 +241,7 @@ const DiscoveryDocument = Schema.Struct({ · 240 unmodified lines | |||
| 241 | name: TextOption, | 241 | name: TextOption, |
| 242 | version: TextOption, | 242 | version: TextOption, |
| 243 | title: TextOption, | 243 | title: TextOption, |
| 244 | description: TextOption, | ||
| 244 | rootUrl: TextOption, | 245 | rootUrl: TextOption, |
| 245 | servicePath: Schema.optional(Schema.Trim).pipe( | 246 | servicePath: Schema.optional(Schema.Trim).pipe( |
| 246 | Schema.withDecodingDefaultType(Effect.succeed("")), | 247 | Schema.withDecodingDefaultType(Effect.succeed("")), |
| @@ -268,6 +269,12 @@ const DiscoveryDocument = Schema.Struct({ · 21 unmodified lines | |||
| 268 | }); | 269 | }); |
| 269 | type DiscoveryDocument = typeof DiscoveryDocument.Type; | 270 | type DiscoveryDocument = typeof DiscoveryDocument.Type; |
| 270 | 271 | ||
| 272 | export interface GoogleDiscoveryDocumentIdentity { | ||
| 273 | readonly name: string; | ||
| 274 | readonly title?: string; | ||
| 275 | readonly description?: string; | ||
| 276 | } | ||
| 277 | |||
| 271 | export interface GoogleDiscoveryOpenApiConversion { | 278 | export interface GoogleDiscoveryOpenApiConversion { |
| 272 | readonly specText: string; | 279 | readonly specText: string; |
| 273 | readonly baseUrl: string; | 280 | readonly baseUrl: string; |
| @@ -286,6 +293,25 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); · 12 unmodified lines | |||
| 286 | const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); | 293 | const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); |
| 287 | const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | 294 | const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); |
| 288 | 295 | ||
| 296 | const parseDiscoveryDocument = (documentText: string) => | ||
| 297 | parseJson(documentText).pipe( | ||
| 298 | Effect.mapError( | ||
| 299 | () => | ||
| 300 | new OpenApiParseError({ | ||
| 301 | message: "Failed to parse Google Discovery document", | ||
| 302 | }), | ||
| 303 | ), | ||
| 304 | Effect.flatMap((parsed) => | ||
| 305 | Effect.try({ | ||
| 306 | try: () => decodeDiscoveryDocument(parsed), | ||
| 307 | catch: () => | ||
| 308 | new OpenApiParseError({ | ||
| 309 | message: "Failed to decode Google Discovery document", | ||
| 310 | }), | ||
| 311 | }), | ||
| 312 | ), | ||
| 313 | ); | ||
| 314 | |||
| 289 | const DISCOVERY_SERVICE_PATH_RE = | 315 | const DISCOVERY_SERVICE_PATH_RE = |
| 290 | /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; | 316 | /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; |
| 291 | const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/; | 317 | const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/; |
| @@ -389,6 +415,25 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 97 unmodified lines | |||
| 389 | }, | 415 | }, |
| 390 | ); | 416 | ); |
| 391 | 417 | ||
| 418 | export const googleDiscoveryDocumentIdentity = Effect.fn("OpenApi.googleDiscoveryDocumentIdentity")( | ||
| 419 | function* (input: { readonly documentText: string }) { | ||
| 420 | const document = yield* parseDiscoveryDocument(input.documentText); | ||
| 421 | const name = Option.getOrUndefined(document.name); | ||
| 422 | if (!name) { | ||
| 423 | return yield* new OpenApiParseError({ | ||
| 424 | message: "Google Discovery document is missing name", | ||
| 425 | }); | ||
| 426 | } | ||
| 427 | const title = Option.getOrUndefined(document.title); | ||
| 428 | const description = Option.getOrUndefined(document.description); | ||
| 429 | return { | ||
| 430 | name, | ||
| 431 | ...(title !== undefined ? { title } : {}), | ||
| 432 | ...(description !== undefined ? { description } : {}), | ||
| 433 | }; | ||
| 434 | }, | ||
| 435 | ); | ||
| 436 | |||
| 392 | const schemaRef = (name: string) => `#/components/schemas/${name}`; | 437 | const schemaRef = (name: string) => `#/components/schemas/${name}`; |
| 393 | 438 | ||
| 394 | const identitySchemaName = (name: string): string => name; | 439 | const identitySchemaName = (name: string): string => name; |
| @@ -946,22 +991,7 @@ const hasOperation = ( · 551 unmodified lines | |||
| 946 | 991 | ||
| 947 | export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")( | 992 | export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")( |
| 948 | function* (input: { readonly discoveryUrl: string; readonly documentText: string }) { | 993 | function* (input: { readonly discoveryUrl: string; readonly documentText: string }) { |
| 949 | const parsed = yield* parseJson(input.documentText).pipe( | 994 | const document = yield* parseDiscoveryDocument(input.documentText); |
| 950 | Effect.mapError( | ||
| 951 | () => | ||
| 952 | new OpenApiParseError({ | ||
| 953 | message: "Failed to parse Google Discovery document", | ||
| 954 | }), | ||
| 955 | ), | ||
| 956 | ); | ||
| 957 | const document = yield* Effect.try({ | ||
| 958 | try: () => decodeDiscoveryDocument(parsed), | ||
| 959 | catch: () => | ||
| 960 | new OpenApiParseError({ | ||
| 961 | message: "Failed to decode Google Discovery document", | ||
| 962 | }), | ||
| 963 | }); | ||
| 964 | |||
| 965 | const info = yield* discoveryDocumentInfo(document, input.discoveryUrl); | 995 | const info = yield* discoveryDocumentInfo(document, input.discoveryUrl); |
| 966 | const { service, version, rootUrl, baseUrl, title } = info; | 996 | const { service, version, rootUrl, baseUrl, title } = info; |
| 967 | const paths: Record<string, Record<string, OpenApiOperationObject>> = {}; | 997 | const paths: Record<string, Record<string, OpenApiOperationObject>> = {}; |
| @@ -1095,21 +1125,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 127 unmodified lines | |||
| 1095 | 1125 | ||
| 1096 | const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) => | 1126 | const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) => |
| 1097 | Effect.gen(function* () { | 1127 | Effect.gen(function* () { |
| 1098 | const parsed = yield* parseJson(documentText).pipe( | 1128 | const document = yield* parseDiscoveryDocument(documentText); |
| 1099 | Effect.mapError( | ||
| 1100 | () => | ||
| 1101 | new OpenApiParseError({ | ||
| 1102 | message: "Failed to parse Google Discovery document", | ||
| 1103 | }), | ||
| 1104 | ), | ||
| 1105 | ); | ||
| 1106 | const document = yield* Effect.try({ | ||
| 1107 | try: () => decodeDiscoveryDocument(parsed), | ||
| 1108 | catch: () => | ||
| 1109 | new OpenApiParseError({ | ||
| 1110 | message: "Failed to decode Google Discovery document", | ||
| 1111 | }), | ||
| 1112 | }); | ||
| 1113 | return yield* discoveryDocumentInfo(document, discoveryUrl); | 1129 | return yield* discoveryDocumentInfo(document, discoveryUrl); |
| 1114 | }), | 1130 | }), |
| 1115 | ); | 1131 | ); |
| @@ -1,14 +1,9 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Google bundle add flow, "customize your Google connection". | 2 | // Google per-service add flow, "customize your Google connection". |
| 3 | // | 3 | // |
| 4 | // The product picker emits a URL list. The server fetches each Discovery | 4 | // The product picker emits preset ids and custom Discovery URLs. The server |
| 5 | // document, merges them into ONE `google` | 5 | // fans them out into separate integrations, deriving custom identity from the |
| 6 | // integration spec, and stores the unioned `googleOAuth2` auth template. These | 6 | // fetched Discovery document before it compiles the OpenAPI surface. |
| 7 | // tests exercise that path end-to-end against a stubbed Discovery host: | ||
| 8 | // - a 3-API bundle (calendar + gmail + drive) produces a single `google` | ||
| 9 | // integration whose merged tools carry NO name collisions (each method id | ||
| 10 | // is service-prefixed) even when two APIs share a generic method name; | ||
| 11 | // - the stored oauth template carries the UNION of every API's scopes. | ||
| 12 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 13 | 8 | ||
| 14 | import { describe, expect, it } from "@effect/vitest"; | 9 | import { describe, expect, it } from "@effect/vitest"; |
| @@ -24,7 +19,6 @@ import { · 9 unmodified lines | |||
| 24 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 19 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 25 | 20 | ||
| 26 | import { defaultGoogleHealthCheck, googlePlugin } from "./plugin"; | 21 | import { defaultGoogleHealthCheck, googlePlugin } from "./plugin"; |
| 27 | import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets"; | ||
| 28 | 22 | ||
| 29 | // --- Canned Discovery documents ------------------------------------------- | 23 | // --- Canned Discovery documents ------------------------------------------- |
| 30 | // Each carries one method. Calendar and Gmail BOTH expose a generic `list` | 24 | // Each carries one method. Calendar and Gmail BOTH expose a generic `list` |
| @@ -37,6 +31,7 @@ const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; · 6 unmodified lines | |||
| 37 | const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"; | 31 | const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"; |
| 38 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; | 32 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; |
| 39 | const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest"; | 33 | const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest"; |
| 34 | const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; | ||
| 40 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; | 35 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; |
| 41 | const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; | 36 | const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; |
| 42 | const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest"; | 37 | const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest"; |
| @@ -219,6 +214,40 @@ const docsDoc = { · 176 unmodified lines | |||
| 219 | }, | 214 | }, |
| 220 | }; | 215 | }; |
| 221 | 216 | ||
| 217 | const tasksDoc = { | ||
| 218 | name: "tasks", | ||
| 219 | version: "v1", | ||
| 220 | title: "Tasks API", | ||
| 221 | description: "Manage your tasks and task lists.", | ||
| 222 | rootUrl: "https://tasks.googleapis.com/", | ||
| 223 | servicePath: "tasks/v1/", | ||
| 224 | auth: { | ||
| 225 | oauth2: { | ||
| 226 | scopes: { | ||
| 227 | "https://www.googleapis.com/auth/tasks": { description: "Manage tasks" }, | ||
| 228 | }, | ||
| 229 | }, | ||
| 230 | }, | ||
| 231 | resources: { | ||
| 232 | tasks: { | ||
| 233 | methods: { | ||
| 234 | list: { | ||
| 235 | id: "tasks.tasks.list", | ||
| 236 | httpMethod: "GET", | ||
| 237 | path: "lists/{tasklist}/tasks", | ||
| 238 | scopes: ["https://www.googleapis.com/auth/tasks"], | ||
| 239 | parameters: { | ||
| 240 | tasklist: { location: "path", required: true, type: "string" }, | ||
| 241 | }, | ||
| 242 | }, | ||
| 243 | }, | ||
| 244 | }, | ||
| 245 | }, | ||
| 246 | schemas: { | ||
| 247 | Task: { id: "Task", type: "object", properties: { id: { type: "string" } } }, | ||
| 248 | }, | ||
| 249 | }; | ||
| 250 | |||
| 222 | const photosLibraryDoc = { | 251 | const photosLibraryDoc = { |
| 223 | name: "photoslibrary", | 252 | name: "photoslibrary", |
| 224 | version: "v1", | 253 | version: "v1", |
| @@ -425,6 +454,7 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 200 unmodified lines | |||
| 425 | [SHEETS_URL]: toJson(sheetsDoc), | 454 | [SHEETS_URL]: toJson(sheetsDoc), |
| 426 | [DRIVE_URL]: toJson(driveDoc), | 455 | [DRIVE_URL]: toJson(driveDoc), |
| 427 | [DOCS_URL]: toJson(docsDoc), | 456 | [DOCS_URL]: toJson(docsDoc), |
| 457 | [TASKS_URL]: toJson(tasksDoc), | ||
| 428 | [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc), | 458 | [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc), |
| 429 | [PHOTOS_PICKER_URL]: toJson(photosPickerDoc), | 459 | [PHOTOS_PICKER_URL]: toJson(photosPickerDoc), |
| 430 | [PEOPLE_URL]: toJson(peopleDoc), | 460 | [PEOPLE_URL]: toJson(peopleDoc), |
| @@ -499,6 +529,240 @@ describe("Google custom service add flow", () => { · 68 unmodified lines | |||
| 499 | expect(presetIds).toContain("google-photos"); | 529 | expect(presetIds).toContain("google-photos"); |
| 500 | }); | 530 | }); |
| 501 | 531 | ||
| 532 | it.effect("derives custom integration identity from the Discovery document", () => | ||
| 533 | Effect.scoped( | ||
| 534 | Effect.gen(function* () { | ||
| 535 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 536 | |||
| 537 | const result = yield* executor.google.addServices({ | ||
| 538 | services: [ | ||
| 539 | { | ||
| 540 | custom: { | ||
| 541 | urls: [TASKS_URL], | ||
| 542 | }, | ||
| 543 | }, | ||
| 544 | ], | ||
| 545 | }); | ||
| 546 | |||
| 547 | expect(result).toEqual({ | ||
| 548 | added: [ | ||
| 549 | { | ||
| 550 | slug: IntegrationSlug.make("google_tasks"), | ||
| 551 | presetId: "google_tasks", | ||
| 552 | toolCount: 4, | ||
| 553 | }, | ||
| 554 | ], | ||
| 555 | skipped: [], | ||
| 556 | failed: [], | ||
| 557 | }); | ||
| 558 | |||
| 559 | const integration = (yield* executor.integrations.list()).find( | ||
| 560 | (item) => String(item.slug) === "google_tasks", | ||
| 561 | ); | ||
| 562 | expect(integration?.name).toBe("Google Tasks"); | ||
| 563 | expect(integration?.description).toBe("Manage your tasks and task lists."); | ||
| 564 | |||
| 565 | const config = yield* executor.google.getConfig("google_tasks"); | ||
| 566 | expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]); | ||
| 567 | expect(oauthScopesFromConfig(config)).toEqual( | ||
| 568 | ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(), | ||
| 569 | ); | ||
| 570 | }), | ||
| 571 | ), | ||
| 572 | ); | ||
| 573 | |||
| 574 | it.effect("uses explicit custom identity overrides for a single Discovery URL", () => | ||
| 575 | Effect.scoped( | ||
| 576 | Effect.gen(function* () { | ||
| 577 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 578 | |||
| 579 | const result = yield* executor.google.addServices({ | ||
| 580 | services: [ | ||
| 581 | { | ||
| 582 | custom: { | ||
| 583 | urls: [TASKS_URL], | ||
| 584 | slug: "team_tasks", | ||
| 585 | name: "Team Tasks", | ||
| 586 | description: "Internal task workflow.", | ||
| 587 | }, | ||
| 588 | }, | ||
| 589 | ], | ||
| 590 | }); | ||
| 591 | |||
| 592 | expect(result).toEqual({ | ||
| 593 | added: [ | ||
| 594 | { | ||
| 595 | slug: IntegrationSlug.make("team_tasks"), | ||
| 596 | presetId: "team_tasks", | ||
| 597 | toolCount: 4, | ||
| 598 | }, | ||
| 599 | ], | ||
| 600 | skipped: [], | ||
| 601 | failed: [], | ||
| 602 | }); | ||
| 603 | |||
| 604 | const integration = (yield* executor.integrations.list()).find( | ||
| 605 | (item) => String(item.slug) === "team_tasks", | ||
| 606 | ); | ||
| 607 | expect(integration?.name).toBe("Team Tasks"); | ||
| 608 | expect(integration?.description).toBe("Internal task workflow."); | ||
| 609 | }), | ||
| 610 | ), | ||
| 611 | ); | ||
| 612 | |||
| 613 | it.effect("rejects explicit custom identity overrides for multiple Discovery URLs", () => | ||
| 614 | Effect.scoped( | ||
| 615 | Effect.gen(function* () { | ||
| 616 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 617 | |||
| 618 | const result = yield* executor.google.addServices({ | ||
| 619 | services: [ | ||
| 620 | { | ||
| 621 | custom: { | ||
| 622 | urls: [TASKS_URL, CALENDAR_URL], | ||
| 623 | slug: "team_google", | ||
| 624 | name: "Team Google", | ||
| 625 | }, | ||
| 626 | }, | ||
| 627 | ], | ||
| 628 | }); | ||
| 629 | |||
| 630 | expect(result).toEqual({ | ||
| 631 | added: [], | ||
| 632 | skipped: [], | ||
| 633 | failed: [ | ||
| 634 | { | ||
| 635 | slug: IntegrationSlug.make("team_google"), | ||
| 636 | presetId: "team_google", | ||
| 637 | error: "Custom Google service identity overrides require exactly one Discovery URL", | ||
| 638 | }, | ||
| 639 | ], | ||
| 640 | }); | ||
| 641 | }), | ||
| 642 | ), | ||
| 643 | ); | ||
| 644 | |||
| 645 | it.effect("reports fetch failures with the fallback custom slug", () => | ||
| 646 | Effect.scoped( | ||
| 647 | Effect.gen(function* () { | ||
| 648 | const failingLayer = makeDiscoveryHttpClientLayer({ | ||
| 649 | failedUrls: new Set([TASKS_URL]), | ||
| 650 | }); | ||
| 651 | const executor = yield* createExecutor( | ||
| 652 | makeTestConfig({ | ||
| 653 | plugins: [googlePlugin({ httpClientLayer: failingLayer }), memoryCredentialsPlugin()], | ||
| 654 | }), | ||
| 655 | ); | ||
| 656 | |||
| 657 | const result = yield* executor.google.addServices({ | ||
| 658 | services: [ | ||
| 659 | { | ||
| 660 | custom: { | ||
| 661 | urls: [TASKS_URL], | ||
| 662 | }, | ||
| 663 | }, | ||
| 664 | ], | ||
| 665 | }); | ||
| 666 | |||
| 667 | expect(result).toEqual({ | ||
| 668 | added: [], | ||
| 669 | skipped: [], | ||
| 670 | failed: [ | ||
| 671 | { | ||
| 672 | slug: IntegrationSlug.make("google_custom"), | ||
| 673 | presetId: "google_custom", | ||
| 674 | error: "Failed to fetch Google Discovery document: HTTP 503", | ||
| 675 | }, | ||
| 676 | ], | ||
| 677 | }); | ||
| 678 | expect(yield* executor.google.getIntegration("google_custom")).toBeNull(); | ||
| 679 | }), | ||
| 680 | ), | ||
| 681 | ); | ||
| 682 | |||
| 683 | it.effect("adds one integration per custom Discovery URL", () => | ||
| 684 | Effect.scoped( | ||
| 685 | Effect.gen(function* () { | ||
| 686 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 687 | |||
| 688 | const result = yield* executor.google.addServices({ | ||
| 689 | services: [ | ||
| 690 | { | ||
| 691 | custom: { | ||
| 692 | urls: [TASKS_URL, CALENDAR_URL], | ||
| 693 | }, | ||
| 694 | }, | ||
| 695 | ], | ||
| 696 | }); | ||
| 697 | |||
| 698 | expect(result).toEqual({ | ||
| 699 | added: [ | ||
| 700 | { | ||
| 701 | slug: IntegrationSlug.make("google_tasks"), | ||
| 702 | presetId: "google_tasks", | ||
| 703 | toolCount: 4, | ||
| 704 | }, | ||
| 705 | { | ||
| 706 | slug: IntegrationSlug.make("google_calendar"), | ||
| 707 | presetId: "google_calendar", | ||
| 708 | toolCount: 4, | ||
| 709 | }, | ||
| 710 | ], | ||
| 711 | skipped: [], | ||
| 712 | failed: [], | ||
| 713 | }); | ||
| 714 | const integrations = yield* executor.integrations.list(); | ||
| 715 | expect(integrations.find((item) => String(item.slug) === "google_tasks")?.name).toBe( | ||
| 716 | "Google Tasks", | ||
| 717 | ); | ||
| 718 | expect(integrations.find((item) => String(item.slug) === "google_calendar")?.name).toBe( | ||
| 719 | "Google Calendar", | ||
| 720 | ); | ||
| 721 | }), | ||
| 722 | ), | ||
| 723 | ); | ||
| 724 | |||
| 725 | it.effect("skips a duplicate custom Discovery URL in the same request", () => | ||
| 726 | Effect.scoped( | ||
| 727 | Effect.gen(function* () { | ||
| 728 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 729 | |||
| 730 | const result = yield* executor.google.addServices({ | ||
| 731 | services: [ | ||
| 732 | { | ||
| 733 | custom: { | ||
| 734 | urls: [TASKS_URL, TASKS_URL], | ||
| 735 | }, | ||
| 736 | }, | ||
| 737 | ], | ||
| 738 | }); | ||
| 739 | |||
| 740 | expect(result).toEqual({ | ||
| 741 | added: [ | ||
| 742 | { | ||
| 743 | slug: IntegrationSlug.make("google_tasks"), | ||
| 744 | presetId: "google_tasks", | ||
| 745 | toolCount: 4, | ||
| 746 | }, | ||
| 747 | ], | ||
| 748 | skipped: [ | ||
| 749 | { | ||
| 750 | slug: IntegrationSlug.make("google_tasks"), | ||
| 751 | presetId: "google_tasks", | ||
| 752 | reason: "already_exists", | ||
| 753 | }, | ||
| 754 | ], | ||
| 755 | failed: [], | ||
| 756 | }); | ||
| 757 | |||
| 758 | const integrations = yield* executor.integrations.list(); | ||
| 759 | expect( | ||
| 760 | integrations.filter((integration) => String(integration.slug) === "google_tasks"), | ||
| 761 | ).toHaveLength(1); | ||
| 762 | }), | ||
| 763 | ), | ||
| 764 | ); | ||
| 765 | |||
| 502 | it.effect("rejects lookalike Discovery hosts before fetching custom service documents", () => | 766 | it.effect("rejects lookalike Discovery hosts before fetching custom service documents", () => |
| 503 | Effect.scoped( | 767 | Effect.scoped( |
| 504 | Effect.gen(function* () { | 768 | Effect.gen(function* () { |
| @@ -541,7 +805,7 @@ describe("Google custom service add flow", () => { · 36 unmodified lines | |||
| 541 | failed: [ | 805 | failed: [ |
| 542 | { | 806 | { |
| 543 | slug: IntegrationSlug.make("bad_google"), | 807 | slug: IntegrationSlug.make("bad_google"), |
| 544 | presetId: "custom", | 808 | presetId: "bad_google", |
| 545 | error: | 809 | error: |
| 546 | "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", | 810 | "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", |
| 547 | }, | 811 | }, |
| @@ -552,239 +816,47 @@ describe("Google custom service add flow", () => { · 4 unmodified lines | |||
| 552 | ), | 816 | ), |
| 553 | ); | 817 | ); |
| 554 | 818 | ||
| 555 | it.effect( | 819 | it.effect("custom Google Photos Library uses the focused Photos scopes and upload tool", () => |
| 556 | "addServices custom entry merges calendar+gmail+drive into one requested integration with no tool-name collisions", | 820 | Effect.scoped( |
| 557 | () => | 821 | Effect.gen(function* () { |
| 558 | Effect.scoped( | 822 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); |
| 559 | Effect.gen(function* () { | ||
| 560 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 561 | |||
| 562 | const result = yield* executor.google.addServices({ | ||
| 563 | services: [ | ||
| 564 | { | ||
| 565 | custom: { | ||
| 566 | urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL], | ||
| 567 | slug: "google_custom", | ||
| 568 | name: "Custom Google APIs", | ||
| 569 | description: "Google", | ||
| 570 | }, | ||
| 571 | }, | ||
| 572 | ], | ||
| 573 | }); | ||
| 574 | expect(result).toEqual({ | ||
| 575 | added: [ | ||
| 576 | { | ||
| 577 | slug: IntegrationSlug.make("google_custom"), | ||
| 578 | presetId: "custom", | ||
| 579 | toolCount: 6, | ||
| 580 | }, | ||
| 581 | ], | ||
| 582 | skipped: [], | ||
| 583 | failed: [], | ||
| 584 | }); | ||
| 585 | |||
| 586 | const integration = yield* executor.google.getIntegration("google_custom"); | ||
| 587 | expect(integration?.slug).toBe(IntegrationSlug.make("google_custom")); | ||
| 588 | |||
| 589 | // The stored oauth template carries the COMPACTED union of every API's | ||
| 590 | // scopes plus the hidden OAuth2 identity scopes that `oauth.start` | ||
| 591 | // requests. | ||
| 592 | // `calendar.readonly` collapses under `calendar`, and `gmail.readonly` | ||
| 593 | // collapses under `https://mail.google.com/`, so the requested consent | ||
| 594 | // is clean rather than the raw per-method union. | ||
| 595 | const config = yield* executor.google.getConfig("google_custom"); | ||
| 596 | expect(config?.googleDiscoveryUrls).toEqual([ | ||
| 597 | CALENDAR_URL, | ||
| 598 | GMAIL_URL, | ||
| 599 | DRIVE_URL, | ||
| 600 | OAUTH2_URL, | ||
| 601 | ]); | ||
| 602 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 603 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 604 | [ | ||
| 605 | "email", | ||
| 606 | "https://mail.google.com/", | ||
| 607 | "https://www.googleapis.com/auth/calendar", | ||
| 608 | "https://www.googleapis.com/auth/drive", | ||
| 609 | "openid", | ||
| 610 | "profile", | ||
| 611 | ].sort(), | ||
| 612 | ); | ||
| 613 | |||
| 614 | // A connection stamps the merged tools; assert all three `list`s are | ||
| 615 | // present under distinct service-prefixed names (no collision). | ||
| 616 | yield* executor.connections.create({ | ||
| 617 | owner: "org", | ||
| 618 | name: ConnectionName.make("main"), | ||
| 619 | integration: IntegrationSlug.make("google_custom"), | ||
| 620 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 621 | value: "token-xyz", | ||
| 622 | }); | ||
| 623 | |||
| 624 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 625 | expect(toolNames).toContain("calendar.events.list"); | ||
| 626 | expect(toolNames).toContain("gmail.users.messages.list"); | ||
| 627 | expect(toolNames).toContain("drive.files.list"); | ||
| 628 | |||
| 629 | // No duplicate tool names across the merged surface. | ||
| 630 | const googleTools = toolNames.filter((name) => name.endsWith(".list")); | ||
| 631 | expect(new Set(googleTools).size).toBe(googleTools.length); | ||
| 632 | expect(googleTools.length).toBe(3); | ||
| 633 | }), | ||
| 634 | ), | ||
| 635 | ); | ||
| 636 | |||
| 637 | it.effect( | ||
| 638 | "addServices custom entry constrains Google Photos to the preset scopes and upload tool", | ||
| 639 | () => | ||
| 640 | Effect.scoped( | ||
| 641 | Effect.gen(function* () { | ||
| 642 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 643 | |||
| 644 | yield* executor.google.addServices({ | ||
| 645 | services: [ | ||
| 646 | { | ||
| 647 | custom: { | ||
| 648 | urls: [ | ||
| 649 | PHOTOS_LIBRARY_URL, | ||
| 650 | "https://photospicker.googleapis.com/$discovery/rest?version=v1", | ||
| 651 | ], | ||
| 652 | slug: "google_photos", | ||
| 653 | name: "Google Photos", | ||
| 654 | }, | ||
| 655 | }, | ||
| 656 | ], | ||
| 657 | }); | ||
| 658 | |||
| 659 | const config = yield* executor.google.getConfig("google_photos"); | ||
| 660 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 661 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 662 | [ | ||
| 663 | "email", | ||
| 664 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 665 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 666 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", | ||
| 667 | "openid", | ||
| 668 | "profile", | ||
| 669 | ].sort(), | ||
| 670 | ); | ||
| 671 | |||
| 672 | yield* executor.connections.create({ | ||
| 673 | owner: "org", | ||
| 674 | name: ConnectionName.make("main"), | ||
| 675 | integration: IntegrationSlug.make("google_photos"), | ||
| 676 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 677 | value: "token-xyz", | ||
| 678 | }); | ||
| 679 | |||
| 680 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 681 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 682 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 683 | expect(toolNames).toContain("photospicker.mediaItems.list"); | ||
| 684 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 685 | }), | ||
| 686 | ), | ||
| 687 | ); | ||
| 688 | |||
| 689 | it.effect( | ||
| 690 | "addServices custom entry keeps Google Photos scoped when combined with another API", | ||
| 691 | () => | ||
| 692 | Effect.scoped( | ||
| 693 | Effect.gen(function* () { | ||
| 694 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 695 | |||
| 696 | yield* executor.google.addServices({ | ||
| 697 | services: [ | ||
| 698 | { | ||
| 699 | custom: { | ||
| 700 | urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL], | ||
| 701 | slug: "google_photos_calendar", | ||
| 702 | name: "Google Photos and Calendar", | ||
| 703 | }, | ||
| 704 | }, | ||
| 705 | ], | ||
| 706 | }); | ||
| 707 | |||
| 708 | const config = yield* executor.google.getConfig("google_photos_calendar"); | ||
| 709 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 710 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 711 | [ | ||
| 712 | "email", | ||
| 713 | "https://www.googleapis.com/auth/calendar", | ||
| 714 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 715 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 716 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", | ||
| 717 | "openid", | ||
| 718 | "profile", | ||
| 719 | ].sort(), | ||
| 720 | ); | ||
| 721 | |||
| 722 | yield* executor.connections.create({ | ||
| 723 | owner: "org", | ||
| 724 | name: ConnectionName.make("main"), | ||
| 725 | integration: IntegrationSlug.make("google_photos_calendar"), | ||
| 726 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 727 | value: "token-xyz", | ||
| 728 | }); | ||
| 729 | |||
| 730 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 731 | expect(toolNames).toContain("calendar.events.list"); | ||
| 732 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 733 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 734 | expect(toolNames).toContain("photospicker.mediaItems.list"); | ||
| 735 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 736 | }), | ||
| 737 | ), | ||
| 738 | ); | ||
| 739 | |||
| 740 | it.effect( | ||
| 741 | "addServices custom entry scopes partial Google Photos URLs when mixed with another API", | ||
| 742 | () => | ||
| 743 | Effect.scoped( | ||
| 744 | Effect.gen(function* () { | ||
| 745 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 746 | 823 | ||
| 747 | yield* executor.google.addServices({ | 824 | yield* executor.google.addServices({ |
| 748 | services: [ | 825 | services: [ |
| 749 | { | 826 | { |
| 750 | custom: { | 827 | custom: { |
| 751 | urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL], | 828 | urls: [PHOTOS_LIBRARY_URL], |
| 752 | slug: "google_photos_library_calendar", | ||
| 753 | name: "Google Photos Library and Calendar", | ||
| 754 | }, | ||
| 755 | }, | 829 | }, |
| 756 | ], | 830 | }, |
| 757 | }); | 831 | ], |
| 832 | }); | ||
| 758 | 833 | ||
| 759 | const config = yield* executor.google.getConfig("google_photos_library_calendar"); | 834 | const config = yield* executor.google.getConfig("google_photoslibrary"); |
| 760 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | 835 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); |
| 761 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | 836 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( |
| 762 | [ | 837 | [ |
| 763 | "email", | 838 | "email", |
| 764 | "https://www.googleapis.com/auth/calendar", | 839 | "https://www.googleapis.com/auth/photoslibrary.appendonly", |
| 765 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | 840 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", |
| 766 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | 841 | "openid", |
| 767 | "openid", | 842 | "profile", |
| 768 | "profile", | 843 | ].sort(), |
| 769 | ].sort(), | 844 | ); |
| 770 | ); | ||
| 771 | 845 | ||
| 772 | yield* executor.connections.create({ | 846 | yield* executor.connections.create({ |
| 773 | owner: "org", | 847 | owner: "org", |
| 774 | name: ConnectionName.make("main"), | 848 | name: ConnectionName.make("main"), |
| 775 | integration: IntegrationSlug.make("google_photos_library_calendar"), | 849 | integration: IntegrationSlug.make("google_photoslibrary"), |
| 776 | template: AuthTemplateSlug.make("googleOAuth2"), | 850 | template: AuthTemplateSlug.make("googleOAuth2"), |
| 777 | value: "token-xyz", | 851 | value: "token-xyz", |
| 778 | }); | 852 | }); |
| 779 | 853 | ||
| 780 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | 854 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); |
| 781 | expect(toolNames).toContain("calendar.events.list"); | 855 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); |
| 782 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | 856 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); |
| 783 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | 857 | expect(toolNames).not.toContain("photoslibrary.albums.list"); |
| 784 | expect(toolNames).not.toContain("photospicker.mediaItems.list"); | 858 | }), |
| 785 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | 859 | ), |
| 786 | }), | ||
| 787 | ), | ||
| 788 | ); | 860 | ); |
| 789 | }); | 861 | }); |
| 790 | 862 | ||
| @@ -1021,31 +1093,23 @@ describe("Google per-service add flow", () => { · 230 unmodified lines | |||
| 1021 | }); | 1093 | }); |
| 1022 | 1094 | ||
| 1023 | describe("Google health-check default", () => { | 1095 | describe("Google health-check default", () => { |
| 1024 | it.effect("custom service with featured URLs auto-configures OAuth2 userinfo identity", () => | 1096 | it.effect("custom service auto-configures OAuth2 userinfo identity", () => |
| 1025 | Effect.scoped( | 1097 | Effect.scoped( |
| 1026 | Effect.gen(function* () { | 1098 | Effect.gen(function* () { |
| 1027 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | 1099 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); |
| 1028 | const defaultUrls = googleOpenApiPresets | ||
| 1029 | .filter((preset: GoogleOpenApiPreset) => preset.featured) | ||
| 1030 | .flatMap((preset: GoogleOpenApiPreset) => (preset.url ? [preset.url] : [])); | ||
| 1031 | |||
| 1032 | expect(defaultUrls).toEqual([CALENDAR_URL, GMAIL_URL, SHEETS_URL, DRIVE_URL, DOCS_URL]); | ||
| 1033 | 1100 | ||
| 1034 | yield* executor.google.addServices({ | 1101 | yield* executor.google.addServices({ |
| 1035 | services: [ | 1102 | services: [ |
| 1036 | { | 1103 | { |
| 1037 | custom: { | 1104 | custom: { |
| 1038 | urls: defaultUrls, | 1105 | urls: [TASKS_URL], |
| 1039 | slug: "google_default", | ||
| 1040 | name: "Google", | ||
| 1041 | description: "Google", | ||
| 1042 | }, | 1106 | }, |
| 1043 | }, | 1107 | }, |
| 1044 | ], | 1108 | ], |
| 1045 | }); | 1109 | }); |
| 1046 | 1110 | ||
| 1047 | const stored = yield* executor.integrations.healthCheck.get( | 1111 | const stored = yield* executor.integrations.healthCheck.get( |
| 1048 | IntegrationSlug.make("google_default"), | 1112 | IntegrationSlug.make("google_tasks"), |
| 1049 | ); | 1113 | ); |
| 1050 | expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe( | 1114 | expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe( |
| 1051 | "oauth2.userinfo.get", | 1115 | "oauth2.userinfo.get", |
| @@ -1053,24 +1117,15 @@ describe("Google health-check default", () => { · 1 unmodified line | |||
| 1053 | expect(stored?.args, "userinfo needs no args").toBeUndefined(); | 1117 | expect(stored?.args, "userinfo needs no args").toBeUndefined(); |
| 1054 | expect(stored?.identityField, "the default reads the account email").toBe("email"); | 1118 | expect(stored?.identityField, "the default reads the account email").toBe("email"); |
| 1055 | 1119 | ||
| 1056 | const config = yield* executor.google.getConfig("google_default"); | 1120 | const config = yield* executor.google.getConfig("google_tasks"); |
| 1057 | expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]); | 1121 | expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]); |
| 1058 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | 1122 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); |
| 1059 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | 1123 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( |
| 1060 | [ | 1124 | ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(), |
| 1061 | "email", | ||
| 1062 | "https://mail.google.com/", | ||
| 1063 | "https://www.googleapis.com/auth/calendar", | ||
| 1064 | "https://www.googleapis.com/auth/documents", | ||
| 1065 | "https://www.googleapis.com/auth/drive", | ||
| 1066 | "https://www.googleapis.com/auth/spreadsheets", | ||
| 1067 | "openid", | ||
| 1068 | "profile", | ||
| 1069 | ].sort(), | ||
| 1070 | ); | 1125 | ); |
| 1071 | 1126 | ||
| 1072 | const candidates = yield* executor.integrations.healthCheck.candidates( | 1127 | const candidates = yield* executor.integrations.healthCheck.candidates( |
| 1073 | IntegrationSlug.make("google_default"), | 1128 | IntegrationSlug.make("google_tasks"), |
| 1074 | ); | 1129 | ); |
| 1075 | const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get"); | 1130 | const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get"); |
| 1076 | expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined(); | 1131 | expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined(); |
| @@ -1091,10 +1146,7 @@ describe("Google health-check default", () => { · 14 unmodified lines | |||
| 1091 | services: [ | 1146 | services: [ |
| 1092 | { | 1147 | { |
| 1093 | custom: { | 1148 | custom: { |
| 1094 | urls: [PEOPLE_URL, CALENDAR_URL], | 1149 | urls: [PEOPLE_URL], |
| 1095 | slug: "google_people", | ||
| 1096 | name: "Google", | ||
| 1097 | description: "Google", | ||
| 1098 | }, | 1150 | }, |
| 1099 | }, | 1151 | }, |
| 1100 | ], | 1152 | ], |
| @@ -38,7 +38,9 @@ import { · 37 unmodified lines | |||
| 38 | import { | 38 | import { |
| 39 | convertGoogleDiscoveryBundleToOpenApi, | 39 | convertGoogleDiscoveryBundleToOpenApi, |
| 40 | fetchGoogleDiscoveryDocument, | 40 | fetchGoogleDiscoveryDocument, |
| 41 | googleDiscoveryDocumentIdentity, | ||
| 41 | normalizeGoogleDiscoveryUrl, | 42 | normalizeGoogleDiscoveryUrl, |
| 43 | type GoogleDiscoveryDocumentIdentity, | ||
| 42 | } from "./discovery"; | 44 | } from "./discovery"; |
| 43 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; | 45 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; |
| 44 | import { | 46 | import { |
| @@ -103,9 +105,15 @@ export interface GooglePresetServiceConfig { · 58 unmodified lines | |||
| 103 | 105 | ||
| 104 | export interface GoogleCustomServiceConfig { | 106 | export interface GoogleCustomServiceConfig { |
| 105 | readonly custom: { | 107 | readonly custom: { |
| 108 | /** | ||
| 109 | * Each Discovery URL is added as its own integration. `slug`, `name`, and | ||
| 110 | * `description` are explicit overrides for a single URL; supplying them | ||
| 111 | * with multiple URLs returns a failed row instead of guessing which service | ||
| 112 | * the override belongs to. | ||
| 113 | */ | ||
| 106 | readonly urls: readonly string[]; | 114 | readonly urls: readonly string[]; |
| 107 | readonly slug?: string; | 115 | readonly slug?: string; |
| 108 | readonly name: string; | 116 | readonly name?: string; |
| 109 | readonly description?: string; | 117 | readonly description?: string; |
| 110 | }; | 118 | }; |
| 111 | } | 119 | } |
| @@ -162,6 +170,8 @@ export interface GooglePluginOptions { · 50 unmodified lines | |||
| 162 | } | 170 | } |
| 163 | 171 | ||
| 164 | const DEFAULT_GOOGLE_SLUG = "google"; | 172 | const DEFAULT_GOOGLE_SLUG = "google"; |
| 173 | const DEFAULT_GOOGLE_CUSTOM_SLUG = "google_custom"; | ||
| 174 | const MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH = 500; | ||
| 165 | 175 | ||
| 166 | const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map( | 176 | const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map( |
| 167 | googleOpenApiPresets.map((preset) => [preset.id, preset]), | 177 | googleOpenApiPresets.map((preset) => [preset.id, preset]), |
| @@ -177,7 +187,7 @@ const googleServiceEntryId = (service: GoogleServiceConfig): string => · 9 unmodified lines | |||
| 177 | const googleServiceEntrySlug = (service: GoogleServiceConfig): IntegrationSlug => | 187 | const googleServiceEntrySlug = (service: GoogleServiceConfig): IntegrationSlug => |
| 178 | isGooglePresetServiceConfig(service) | 188 | isGooglePresetServiceConfig(service) |
| 179 | ? IntegrationSlug.make(service.slug?.trim() || googleServiceSlug(service.presetId)) | 189 | ? IntegrationSlug.make(service.slug?.trim() || googleServiceSlug(service.presetId)) |
| 180 | : IntegrationSlug.make(service.custom.slug?.trim() || "google_custom"); | 190 | : IntegrationSlug.make(service.custom.slug?.trim() || DEFAULT_GOOGLE_CUSTOM_SLUG); |
| 181 | 191 | ||
| 182 | type GoogleAddServiceOutcome = { | 192 | type GoogleAddServiceOutcome = { |
| 183 | readonly added: readonly GoogleAddServicesAdded[]; | 193 | readonly added: readonly GoogleAddServicesAdded[]; |
| @@ -185,16 +195,21 @@ type GoogleAddServiceOutcome = { · 1 unmodified line | |||
| 185 | readonly failed: readonly GoogleAddServicesFailed[]; | 195 | readonly failed: readonly GoogleAddServicesFailed[]; |
| 186 | }; | 196 | }; |
| 187 | 197 | ||
| 188 | const googleAddServiceFailure = ( | 198 | const googleAddServiceFailure = (input: { |
| 189 | service: GoogleServiceConfig, | 199 | readonly slug: IntegrationSlug; |
| 190 | slug: IntegrationSlug, | 200 | readonly presetId: string; |
| 191 | error: string, | 201 | readonly error: string; |
| 192 | ): GoogleAddServiceOutcome => ({ | 202 | }): GoogleAddServiceOutcome => ({ |
| 193 | added: [], | 203 | added: [], |
| 194 | skipped: [], | 204 | skipped: [], |
| 195 | failed: [{ slug, presetId: googleServiceEntryId(service), error }], | 205 | failed: [{ slug: input.slug, presetId: input.presetId, error: input.error }], |
| 196 | }); | 206 | }); |
| 197 | 207 | ||
| 208 | type GoogleFetchedDiscoveryDocument = { | ||
| 209 | readonly discoveryUrl: string; | ||
| 210 | readonly documentText: string; | ||
| 211 | }; | ||
| 212 | |||
| 198 | const googlePhotosBundlePresetIdByUrl = new Map( | 213 | const googlePhotosBundlePresetIdByUrl = new Map( |
| 199 | googlePhotosOpenApiPresets.flatMap((preset) => | 214 | googlePhotosOpenApiPresets.flatMap((preset) => |
| 200 | preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [], | 215 | preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [], |
| @@ -213,20 +228,28 @@ const googlePhotosBundleConsentScopes = ( · 12 unmodified lines | |||
| 213 | : undefined; | 228 | : undefined; |
| 214 | }; | 229 | }; |
| 215 | 230 | ||
| 216 | const fetchGoogleBundleConversion = ( | 231 | const fetchGoogleDiscoveryDocuments = ( |
| 217 | urls: readonly string[], | 232 | urls: readonly string[], |
| 218 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | 233 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 219 | consentScopesOverride?: readonly string[], | ||
| 220 | ) => | 234 | ) => |
| 221 | Effect.forEach( | 235 | Effect.forEach( |
| 222 | urls, | 236 | urls, |
| 223 | (url) => | 237 | (url) => |
| 224 | fetchGoogleDiscoveryDocument(url).pipe( | 238 | fetchGoogleDiscoveryDocument(url).pipe( |
| 225 | Effect.provide(httpClientLayer), | 239 | Effect.provide(httpClientLayer), |
| 226 | Effect.map((documentText) => ({ discoveryUrl: url, documentText })), | 240 | Effect.map( |
| 241 | (documentText): GoogleFetchedDiscoveryDocument => ({ discoveryUrl: url, documentText }), | ||
| 242 | ), | ||
| 227 | ), | 243 | ), |
| 228 | { concurrency: 4 }, | 244 | { concurrency: 4 }, |
| 229 | ).pipe( | 245 | ); |
| 246 | |||
| 247 | const fetchGoogleBundleConversion = ( | ||
| 248 | urls: readonly string[], | ||
| 249 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | ||
| 250 | consentScopesOverride?: readonly string[], | ||
| 251 | ) => | ||
| 252 | fetchGoogleDiscoveryDocuments(urls, httpClientLayer).pipe( | ||
| 230 | Effect.flatMap((documents) => { | 253 | Effect.flatMap((documents) => { |
| 231 | const consentScopes = consentScopesOverride ?? googlePhotosBundleConsentScopes(urls); | 254 | const consentScopes = consentScopesOverride ?? googlePhotosBundleConsentScopes(urls); |
| 232 | return convertGoogleDiscoveryBundleToOpenApi({ | 255 | return convertGoogleDiscoveryBundleToOpenApi({ |
| @@ -258,6 +281,132 @@ const googleBundleUrlsWithIdentity = ( · 25 unmodified lines | |||
| 258 | return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]); | 281 | return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]); |
| 259 | }); | 282 | }); |
| 260 | 283 | ||
| 284 | type GoogleServiceAddPlan = | ||
| 285 | | { | ||
| 286 | readonly kind: "preset"; | ||
| 287 | readonly service: GooglePresetServiceConfig; | ||
| 288 | } | ||
| 289 | | { | ||
| 290 | readonly kind: "custom"; | ||
| 291 | readonly service: GoogleCustomServiceConfig; | ||
| 292 | readonly url: string; | ||
| 293 | readonly fallbackSlug: IntegrationSlug; | ||
| 294 | } | ||
| 295 | | { | ||
| 296 | readonly kind: "invalid-custom"; | ||
| 297 | readonly service: GoogleCustomServiceConfig; | ||
| 298 | readonly slug: IntegrationSlug; | ||
| 299 | readonly presetId: string; | ||
| 300 | readonly error: string; | ||
| 301 | }; | ||
| 302 | |||
| 303 | const hasCustomGoogleServiceOverride = (service: GoogleCustomServiceConfig): boolean => | ||
| 304 | Boolean( | ||
| 305 | service.custom.slug?.trim() || | ||
| 306 | service.custom.name?.trim() || | ||
| 307 | service.custom.description?.trim(), | ||
| 308 | ); | ||
| 309 | |||
| 310 | const googleCustomFallbackSlug = (index: number): IntegrationSlug => | ||
| 311 | IntegrationSlug.make( | ||
| 312 | index === 0 ? DEFAULT_GOOGLE_CUSTOM_SLUG : `${DEFAULT_GOOGLE_CUSTOM_SLUG}_${index + 1}`, | ||
| 313 | ); | ||
| 314 | |||
| 315 | const googleCustomResultId = (slug: IntegrationSlug): string => String(slug); | ||
| 316 | |||
| 317 | const googleDiscoveryNameSlugPart = (name: string): string => | ||
| 318 | name | ||
| 319 | .trim() | ||
| 320 | .toLowerCase() | ||
| 321 | .replace(/[^a-z0-9_-]+/g, "_") | ||
| 322 | .replace(/^_+|_+$/g, ""); | ||
| 323 | |||
| 324 | const googleDiscoverySlug = (name: string): IntegrationSlug => | ||
| 325 | IntegrationSlug.make( | ||
| 326 | googleServiceSlug(`google-${googleDiscoveryNameSlugPart(name) || "custom"}`), | ||
| 327 | ); | ||
| 328 | |||
| 329 | const googleDiscoveryDisplayName = (identity: GoogleDiscoveryDocumentIdentity): string => { | ||
| 330 | const title = identity.title | ||
| 331 | ?.trim() | ||
| 332 | .replace(/\s+API(?:\s+v[0-9][A-Za-z0-9._-]*)?$/i, "") | ||
| 333 | .trim(); | ||
| 334 | if (title && title.length > 0) { | ||
| 335 | return /^google\b/i.test(title) ? title : `Google ${title}`; | ||
| 336 | } | ||
| 337 | return identity.name | ||
| 338 | .trim() | ||
| 339 | .replace(/[-_.]+/g, " ") | ||
| 340 | .replace(/\b\w/g, (char) => char.toUpperCase()); | ||
| 341 | }; | ||
| 342 | |||
| 343 | const googleDiscoveryDescription = ( | ||
| 344 | identity: GoogleDiscoveryDocumentIdentity, | ||
| 345 | displayName: string, | ||
| 346 | ): string => { | ||
| 347 | const description = identity.description?.trim(); | ||
| 348 | if (description && description.length > 0) { | ||
| 349 | return description.length > MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH | ||
| 350 | ? `${description.slice(0, MAX_GOOGLE_CUSTOM_DESCRIPTION_LENGTH - 3).trimEnd()}...` | ||
| 351 | : description; | ||
| 352 | } | ||
| 353 | return `${displayName}.`; | ||
| 354 | }; | ||
| 355 | |||
| 356 | const googleServiceAddPlans = ( | ||
| 357 | services: readonly GoogleServiceConfig[], | ||
| 358 | ): readonly GoogleServiceAddPlan[] => { | ||
| 359 | const plans: GoogleServiceAddPlan[] = []; | ||
| 360 | let customIndex = 0; | ||
| 361 | for (const service of services) { | ||
| 362 | if (isGooglePresetServiceConfig(service)) { | ||
| 363 | plans.push({ kind: "preset", service }); | ||
| 364 | continue; | ||
| 365 | } | ||
| 366 | |||
| 367 | if (service.custom.urls.length === 0) { | ||
| 368 | const slug = googleCustomFallbackSlug(customIndex); | ||
| 369 | customIndex += 1; | ||
| 370 | plans.push({ | ||
| 371 | kind: "invalid-custom", | ||
| 372 | service, | ||
| 373 | slug, | ||
| 374 | presetId: googleCustomResultId(slug), | ||
| 375 | error: "Custom Google service requires at least one Discovery URL", | ||
| 376 | }); | ||
| 377 | continue; | ||
| 378 | } | ||
| 379 | |||
| 380 | if (service.custom.urls.length > 1 && hasCustomGoogleServiceOverride(service)) { | ||
| 381 | const slug = googleServiceEntrySlug(service); | ||
| 382 | plans.push({ | ||
| 383 | kind: "invalid-custom", | ||
| 384 | service, | ||
| 385 | slug, | ||
| 386 | presetId: googleCustomResultId(slug), | ||
| 387 | error: "Custom Google service identity overrides require exactly one Discovery URL", | ||
| 388 | }); | ||
| 389 | continue; | ||
| 390 | } | ||
| 391 | |||
| 392 | for (const url of service.custom.urls) { | ||
| 393 | const overrideSlug = service.custom.slug?.trim(); | ||
| 394 | const fallbackSlug = | ||
| 395 | overrideSlug && overrideSlug.length > 0 | ||
| 396 | ? IntegrationSlug.make(overrideSlug) | ||
| 397 | : googleCustomFallbackSlug(customIndex); | ||
| 398 | plans.push({ | ||
| 399 | kind: "custom", | ||
| 400 | service, | ||
| 401 | url, | ||
| 402 | fallbackSlug, | ||
| 403 | }); | ||
| 404 | customIndex += 1; | ||
| 405 | } | ||
| 406 | } | ||
| 407 | return plans; | ||
| 408 | }; | ||
| 409 | |||
| 261 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { | 410 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { |
| 262 | const config = decodeGoogleIntegrationConfig(record.config); | 411 | const config = decodeGoogleIntegrationConfig(record.config); |
| 263 | if (!config) return []; | 412 | if (!config) return []; |
| @@ -292,8 +441,9 @@ const makeGooglePluginExtension = ( · 28 unmodified lines | |||
| 292 | ) => { | 441 | ) => { |
| 293 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | 442 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; |
| 294 | 443 | ||
| 295 | const addGoogleOpenApiIntegration = (input: { | 444 | const addGoogleOpenApiIntegrationFromDocuments = (input: { |
| 296 | readonly urls: readonly string[]; | 445 | readonly urls: readonly string[]; |
| 446 | readonly documents: readonly GoogleFetchedDiscoveryDocument[]; | ||
| 297 | readonly slug: IntegrationSlug; | 447 | readonly slug: IntegrationSlug; |
| 298 | readonly name: string; | 448 | readonly name: string; |
| 299 | readonly description: string; | 449 | readonly description: string; |
| @@ -301,12 +451,11 @@ const makeGooglePluginExtension = ( · 1 unmodified line | |||
| 301 | readonly consentScopes?: readonly string[]; | 451 | readonly consentScopes?: readonly string[]; |
| 302 | }) => | 452 | }) => |
| 303 | Effect.gen(function* () { | 453 | Effect.gen(function* () { |
| 304 | const urls = yield* googleBundleUrlsWithIdentity(input.urls); | 454 | const consentScopes = input.consentScopes ?? googlePhotosBundleConsentScopes(input.urls); |
| 305 | const conversion = yield* fetchGoogleBundleConversion( | 455 | const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ |
| 306 | urls, | 456 | documents: input.documents, |
| 307 | httpClientLayer, | 457 | ...(consentScopes !== undefined ? { consentScopes } : {}), |
| 308 | input.consentScopes, | 458 | }); |
| 309 | ); | ||
| 310 | const compiled = yield* compileOpenApiSpec(conversion.specText); | 459 | const compiled = yield* compileOpenApiSpec(conversion.specText); |
| 311 | const slug = input.slug; | 460 | const slug = input.slug; |
| 312 | 461 | ||
| @@ -318,7 +467,7 @@ const makeGooglePluginExtension = ( · 5 unmodified lines | |||
| 318 | const specHash = yield* sha256Hex(conversion.specText); | 467 | const specHash = yield* sha256Hex(conversion.specText); |
| 319 | const integrationConfig: GoogleIntegrationConfig = { | 468 | const integrationConfig: GoogleIntegrationConfig = { |
| 320 | specHash, | 469 | specHash, |
| 321 | googleDiscoveryUrls: urls, | 470 | googleDiscoveryUrls: input.urls, |
| 322 | ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}), | 471 | ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}), |
| 323 | ...(conversion.authenticationTemplate | 472 | ...(conversion.authenticationTemplate |
| 324 | ? { authenticationTemplate: conversion.authenticationTemplate } | 473 | ? { authenticationTemplate: conversion.authenticationTemplate } |
| @@ -349,7 +498,7 @@ const makeGooglePluginExtension = ( · 24 unmodified lines | |||
| 349 | // added to every new Google OpenAPI integration. Older integrations | 498 | // added to every new Google OpenAPI integration. Older integrations |
| 350 | // without oauth2/v2 can still fall back to the People API identity | 499 | // without oauth2/v2 can still fall back to the People API identity |
| 351 | // operation. | 500 | // operation. |
| 352 | const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions); | 501 | const defaultHealthCheck = defaultGoogleHealthCheck(input.urls, compiled.definitions); |
| 353 | if (defaultHealthCheck) { | 502 | if (defaultHealthCheck) { |
| 354 | yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck); | 503 | yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck); |
| 355 | } | 504 | } |
| @@ -357,23 +506,26 @@ const makeGooglePluginExtension = ( · 1 unmodified line | |||
| 357 | return { slug, toolCount: compiled.definitions.length }; | 506 | return { slug, toolCount: compiled.definitions.length }; |
| 358 | }); | 507 | }); |
| 359 | 508 | ||
| 360 | const addOneService = (service: GoogleServiceConfig, baseUrl?: string) => | 509 | const addGoogleOpenApiIntegration = (input: { |
| 510 | readonly urls: readonly string[]; | ||
| 511 | readonly slug: IntegrationSlug; | ||
| 512 | readonly name: string; | ||
| 513 | readonly description: string; | ||
| 514 | readonly baseUrl?: string; | ||
| 515 | readonly consentScopes?: readonly string[]; | ||
| 516 | }) => | ||
| 361 | Effect.gen(function* () { | 517 | Effect.gen(function* () { |
| 362 | if (!isGooglePresetServiceConfig(service)) { | 518 | const urls = yield* googleBundleUrlsWithIdentity(input.urls); |
| 363 | if (service.custom.urls.length === 0) { | 519 | const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer); |
| 364 | return yield* new OpenApiParseError({ | 520 | return yield* addGoogleOpenApiIntegrationFromDocuments({ |
| 365 | message: "Custom Google service requires at least one Discovery URL", | 521 | ...input, |
| 366 | }); | 522 | urls, |
| 367 | } | 523 | documents, |
| 368 | return yield* addGoogleOpenApiIntegration({ | 524 | }); |
| 369 | urls: service.custom.urls, | 525 | }); |
| 370 | slug: googleServiceEntrySlug(service), | ||
| 371 | name: service.custom.name.trim() || "Custom Google APIs", | ||
| 372 | description: service.custom.description ?? "Custom Google APIs.", | ||
| 373 | baseUrl, | ||
| 374 | }); | ||
| 375 | } | ||
| 376 | 526 | ||
| 527 | const addOnePresetService = (service: GooglePresetServiceConfig, baseUrl?: string) => | ||
| 528 | Effect.gen(function* () { | ||
| 377 | const preset = googleOpenApiPresetById.get(service.presetId); | 529 | const preset = googleOpenApiPresetById.get(service.presetId); |
| 378 | if (!preset?.url) { | 530 | if (!preset?.url) { |
| 379 | return yield* new OpenApiParseError({ | 531 | return yield* new OpenApiParseError({ |
| @@ -391,53 +543,201 @@ const makeGooglePluginExtension = ( · 11 unmodified lines | |||
| 391 | }); | 543 | }); |
| 392 | }); | 544 | }); |
| 393 | 545 | ||
| 394 | const addServices = (input: GoogleAddServicesInput) => | 546 | const addCustomServicePlanOutcome = ( |
| 395 | Effect.gen(function* () { | 547 | plan: Extract<GoogleServiceAddPlan, { kind: "custom" }>, |
| 396 | const outcomes = yield* Effect.forEach( | 548 | baseUrl?: string, |
| 397 | input.services, | 549 | ): Effect.Effect<GoogleAddServiceOutcome, never> => { |
| 398 | (service): Effect.Effect<GoogleAddServiceOutcome, never> => { | 550 | const fallbackSlug = plan.fallbackSlug; |
| 399 | const slug = googleServiceEntrySlug(service); | 551 | const fallbackPresetId = googleCustomResultId(fallbackSlug); |
| 400 | const presetId = googleServiceEntryId(service); | 552 | const fallbackFailure = (error: string): GoogleAddServiceOutcome => |
| 401 | return addOneService(service, input.baseUrl).pipe( | 553 | googleAddServiceFailure({ |
| 402 | Effect.map( | 554 | slug: fallbackSlug, |
| 403 | (result): GoogleAddServiceOutcome => ({ | 555 | presetId: fallbackPresetId, |
| 404 | added: [ | 556 | error, |
| 405 | { | 557 | }); |
| 406 | slug: result.slug, | 558 | |
| 407 | presetId, | 559 | return Effect.gen(function* () { |
| 408 | toolCount: result.toolCount, | 560 | const urls = yield* googleBundleUrlsWithIdentity([plan.url]); |
| 409 | }, | 561 | const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer); |
| 410 | ], | 562 | const serviceDocument = documents[0]; |
| 411 | skipped: [], | 563 | if (!serviceDocument) { |
| 412 | failed: [], | 564 | return yield* new OpenApiParseError({ |
| 565 | message: "Custom Google service requires a Discovery document", | ||
| 566 | }); | ||
| 567 | } | ||
| 568 | const identity = yield* googleDiscoveryDocumentIdentity({ | ||
| 569 | documentText: serviceDocument.documentText, | ||
| 570 | }); | ||
| 571 | const overrideSlug = plan.service.custom.slug?.trim(); | ||
| 572 | const overrideName = plan.service.custom.name?.trim(); | ||
| 573 | const displayName = | ||
| 574 | overrideName && overrideName.length > 0 | ||
| 575 | ? overrideName | ||
| 576 | : googleDiscoveryDisplayName(identity); | ||
| 577 | const slug = | ||
| 578 | overrideSlug && overrideSlug.length > 0 | ||
| 579 | ? IntegrationSlug.make(overrideSlug) | ||
| 580 | : googleDiscoverySlug(identity.name); | ||
| 581 | const presetId = googleCustomResultId(slug); | ||
| 582 | const description = | ||
| 583 | plan.service.custom.description?.trim() || | ||
| 584 | googleDiscoveryDescription(identity, displayName); | ||
| 585 | |||
| 586 | return yield* addGoogleOpenApiIntegrationFromDocuments({ | ||
| 587 | urls, | ||
| 588 | documents, | ||
| 589 | slug, | ||
| 590 | name: displayName, | ||
| 591 | description, | ||
| 592 | baseUrl, | ||
| 593 | }).pipe( | ||
| 594 | Effect.map( | ||
| 595 | (result): GoogleAddServiceOutcome => ({ | ||
| 596 | added: [ | ||
| 597 | { | ||
| 598 | slug: result.slug, | ||
| 599 | presetId, | ||
| 600 | toolCount: result.toolCount, | ||
| 601 | }, | ||
| 602 | ], | ||
| 603 | skipped: [], | ||
| 604 | failed: [], | ||
| 605 | }), | ||
| 606 | ), | ||
| 607 | Effect.catchTag("IntegrationAlreadyExistsError", (error) => { | ||
| 608 | const skippedSlug = error.slug; | ||
| 609 | return Effect.succeed({ | ||
| 610 | added: [], | ||
| 611 | skipped: [ | ||
| 612 | { | ||
| 613 | slug: skippedSlug, | ||
| 614 | presetId: googleCustomResultId(skippedSlug), | ||
| 615 | reason: "already_exists" as const, | ||
| 616 | }, | ||
| 617 | ], | ||
| 618 | failed: [], | ||
| 619 | }); | ||
| 620 | }), | ||
| 621 | Effect.catchTags({ | ||
| 622 | OpenApiParseError: (error: OpenApiParseError) => | ||
| 623 | Effect.succeed( | ||
| 624 | googleAddServiceFailure({ | ||
| 625 | slug, | ||
| 626 | presetId, | ||
| 627 | error: error.message, | ||
| 413 | }), | 628 | }), |
| 414 | ), | 629 | ), |
| 415 | Effect.catchTag("IntegrationAlreadyExistsError", () => | 630 | OpenApiExtractionError: (error: OpenApiExtractionError) => |
| 416 | Effect.succeed({ | 631 | Effect.succeed( |
| 417 | added: [], | 632 | googleAddServiceFailure({ |
| 418 | skipped: [ | 633 | slug, |
| 419 | { | 634 | presetId, |
| 420 | slug, | 635 | error: error.message, |
| 421 | presetId, | ||
| 422 | reason: "already_exists" as const, | ||
| 423 | }, | ||
| 424 | ], | ||
| 425 | failed: [], | ||
| 426 | }), | 636 | }), |
| 427 | ), | 637 | ), |
| 428 | Effect.catchTags({ | 638 | }), |
| 429 | OpenApiParseError: (error: OpenApiParseError) => | 639 | ); |
| 430 | Effect.succeed(googleAddServiceFailure(service, slug, error.message)), | 640 | }).pipe( |
| 431 | OpenApiExtractionError: (error: OpenApiExtractionError) => | 641 | Effect.catchTags({ |
| 432 | Effect.succeed(googleAddServiceFailure(service, slug, error.message)), | 642 | OpenApiParseError: (error: OpenApiParseError) => |
| 643 | Effect.succeed(fallbackFailure(error.message)), | ||
| 644 | }), | ||
| 645 | ); | ||
| 646 | }; | ||
| 647 | |||
| 648 | const addServicePlanOutcome = ( | ||
| 649 | plan: GoogleServiceAddPlan, | ||
| 650 | baseUrl?: string, | ||
| 651 | ): Effect.Effect<GoogleAddServiceOutcome, never> => { | ||
| 652 | if (plan.kind === "invalid-custom") { | ||
| 653 | return Effect.succeed( | ||
| 654 | googleAddServiceFailure({ | ||
| 655 | slug: plan.slug, | ||
| 656 | presetId: plan.presetId, | ||
| 657 | error: plan.error, | ||
| 658 | }), | ||
| 659 | ); | ||
| 660 | } | ||
| 661 | |||
| 662 | if (plan.kind === "custom") { | ||
| 663 | return addCustomServicePlanOutcome(plan, baseUrl); | ||
| 664 | } | ||
| 665 | |||
| 666 | const fallbackSlug = googleServiceEntrySlug(plan.service); | ||
| 667 | const fallbackPresetId = googleServiceEntryId(plan.service); | ||
| 668 | const add = addOnePresetService(plan.service, baseUrl).pipe( | ||
| 669 | Effect.map((result) => ({ | ||
| 670 | slug: result.slug, | ||
| 671 | presetId: googleServiceEntryId(plan.service), | ||
| 672 | toolCount: result.toolCount, | ||
| 673 | })), | ||
| 674 | ); | ||
| 675 | |||
| 676 | return add.pipe( | ||
| 677 | Effect.map( | ||
| 678 | (result): GoogleAddServiceOutcome => ({ | ||
| 679 | added: [ | ||
| 680 | { | ||
| 681 | slug: result.slug, | ||
| 682 | presetId: result.presetId, | ||
| 683 | toolCount: result.toolCount, | ||
| 684 | }, | ||
| 685 | ], | ||
| 686 | skipped: [], | ||
| 687 | failed: [], | ||
| 688 | }), | ||
| 689 | ), | ||
| 690 | Effect.catchTag("IntegrationAlreadyExistsError", (error) => { | ||
| 691 | const skippedSlug = error.slug; | ||
| 692 | return Effect.succeed({ | ||
| 693 | added: [], | ||
| 694 | skipped: [ | ||
| 695 | { | ||
| 696 | slug: skippedSlug, | ||
| 697 | presetId: fallbackPresetId, | ||
| 698 | reason: "already_exists" as const, | ||
| 699 | }, | ||
| 700 | ], | ||
| 701 | failed: [], | ||
| 702 | }); | ||
| 703 | }), | ||
| 704 | Effect.catchTags({ | ||
| 705 | OpenApiParseError: (error: OpenApiParseError) => | ||
| 706 | Effect.succeed( | ||
| 707 | googleAddServiceFailure({ | ||
| 708 | slug: fallbackSlug, | ||
| 709 | presetId: fallbackPresetId, | ||
| 710 | error: error.message, | ||
| 433 | }), | 711 | }), |
| 434 | Effect.catch(() => | 712 | ), |
| 435 | Effect.succeed( | 713 | OpenApiExtractionError: (error: OpenApiExtractionError) => |
| 436 | googleAddServiceFailure(service, slug, "Failed to add Google service"), | 714 | Effect.succeed( |
| 437 | ), | 715 | googleAddServiceFailure({ |
| 438 | ), | 716 | slug: fallbackSlug, |
| 439 | ); | 717 | presetId: fallbackPresetId, |
| 440 | }, | 718 | error: error.message, |
| 719 | }), | ||
| 720 | ), | ||
| 721 | }), | ||
| 722 | Effect.catch(() => | ||
| 723 | Effect.succeed( | ||
| 724 | googleAddServiceFailure({ | ||
| 725 | slug: fallbackSlug, | ||
| 726 | presetId: fallbackPresetId, | ||
| 727 | error: "Failed to add Google service", | ||
| 728 | }), | ||
| 729 | ), | ||
| 730 | ), | ||
| 731 | ); | ||
| 732 | }; | ||
| 733 | |||
| 734 | const addServices = (input: GoogleAddServicesInput) => | ||
| 735 | Effect.gen(function* () { | ||
| 736 | const plans = googleServiceAddPlans(input.services); | ||
| 737 | const outcomes = yield* Effect.forEach( | ||
| 738 | plans, | ||
| 739 | (plan): Effect.Effect<GoogleAddServiceOutcome, never> => | ||
| 740 | addServicePlanOutcome(plan, input.baseUrl), | ||
| 441 | { concurrency: 1 }, | 741 | { concurrency: 1 }, |
| 442 | ); | 742 | ); |
| 443 | 743 | ||