Unified · Split
| @@ -99,12 +99,13 @@ scenario( · 98 unmodified lines | ||
| 99 | 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 | 103 | await customRow.waitFor({ timeout: 120_000 }); |
| 104 | 104 | expect( |
| 105 | 105 | await customRow.getAttribute("data-state"), |
| 106 | 106 | "the custom Discovery URL is added through the services endpoint", |
| 107 | 107 | ).toBe("added"); |
| 108 | + await customRow.getByText("Google Tasks").waitFor(); | |
| 108 | 109 | await customRow.getByRole("link", { name: "Open" }).waitFor(); |
| 109 | 110 | }); |
| 110 | 111 | |
| @@ -128,13 +129,14 @@ scenario( · 17 unmodified lines | ||
| 128 | 129 | await page.goto("/integrations", { waitUntil: "networkidle" }); |
| 129 | 130 | // Each fanned-out integration is its own list entry, keyed by its slug |
| 130 | 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 | 133 | await page |
| 133 | 134 | .getByRole("main") |
| 134 | 135 | .getByText(slug, { exact: true }) |
| 135 | 136 | .first() |
| 136 | 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 | 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 | 56 | custom: Schema.Struct({ |
| 57 | 57 | urls: Schema.Array(Schema.String), |
| 58 | 58 | slug: Schema.optional(Schema.String), |
| 59 | − name: Schema.String, | |
| 59 | + name: Schema.optional(Schema.String), | |
| 60 | 60 | description: Schema.optional(Schema.String), |
| 61 | 61 | }), |
| 62 | 62 | }); |
| @@ -98,6 +98,11 @@ describe("AddGoogleSource per-service submit", () => { · 97 unmodified lines | ||
| 98 | 98 | presetId: "custom", |
| 99 | 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 | 122 | expect(text).toContain("Discovery fetch failed"); |
| 118 | 123 | expect(text).toContain("Custom Discovery URLs"); |
| 119 | 124 | expect(text).toContain("Custom Discovery fetch failed"); |
| 125 | + expect(text).toContain("Google Tasks"); | |
| 126 | + expect(text).toContain("Tasks Discovery fetch failed"); | |
| 120 | 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 | 192 | expect( |
| 186 | 193 | googleAddServicesPayload({ |
| 187 | 194 | presetIds: ["google-calendar"], |
| 188 | 195 | custom: { |
| 189 | 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 | 198 | baseUrl: " https://proxy.example ", |
| 195 | 199 | }), |
| @@ -199,13 +203,35 @@ describe("AddGoogleSource per-service submit", () => { · 3 unmodified lines | ||
| 199 | 203 | { |
| 200 | 204 | custom: { |
| 201 | 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 | 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 | 13 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 14 | 14 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 15 | 15 | import { Input } from "@executor-js/react/components/input"; |
| 16 | −import { | |
| 17 | − errorMessageFromExit, | |
| 18 | − FormErrorAlert, | |
| 19 | − SlugCollisionAlert, | |
| 20 | − useSlugAlreadyExists, | |
| 21 | −} from "@executor-js/react/lib/integration-add"; | |
| 16 | +import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; | |
| 22 | 17 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; |
| 23 | 18 | |
| 24 | 19 | import { addGoogleServices } from "./atoms"; |
| @@ -52,8 +47,8 @@ export type GoogleServiceIdentityOverride = { · 27 unmodified lines | ||
| 52 | 47 | |
| 53 | 48 | export type GoogleCustomServiceInput = { |
| 54 | 49 | readonly urls: readonly string[]; |
| 55 | − readonly slug: string; | |
| 56 | − readonly name: string; | |
| 50 | + readonly slug?: string; | |
| 51 | + readonly name?: string; | |
| 57 | 52 | readonly description?: string; |
| 58 | 53 | }; |
| 59 | 54 | |
| @@ -81,8 +76,8 @@ export const googleAddServicesPayload = (input: { · 21 unmodified lines | ||
| 81 | 76 | { |
| 82 | 77 | custom: { |
| 83 | 78 | urls: [...input.custom.urls], |
| 84 | − slug: input.custom.slug, | |
| 85 | − name: input.custom.name, | |
| 79 | + ...(input.custom.slug?.trim() ? { slug: input.custom.slug.trim() } : {}), | |
| 80 | + ...(input.custom.name?.trim() ? { name: input.custom.name.trim() } : {}), | |
| 86 | 81 | ...(input.custom.description?.trim() |
| 87 | 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 | 153 | export const mergeGoogleAddServicesResult = ( |
| 156 | 154 | previous: GoogleAddServicesResult, |
| 157 | 155 | next: GoogleAddServicesResult, |
| 158 | 156 | ): GoogleAddServicesResult => { |
| 159 | − const nextPresetIds = new Set(googleAddServicesResultRows(next).map((row) => row.presetId)); | |
| 157 | + const nextRowIds = new Set(googleAddServicesResultRows(next).map(googleAddServicesResultRowId)); | |
| 160 | 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 | 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 | 185 | ...next.skipped, |
| 165 | 186 | ], |
| 166 | 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 | 199 | ...next.failed, |
| 169 | 200 | ], |
| 170 | 201 | }; |
| 171 | 202 | }; |
| 172 | 203 | |
| 173 | −const googlePresetName = (presetId: string): string => | |
| 174 | − presetId === GOOGLE_CUSTOM_SERVICE_ID | |
| 204 | +const humanizeGoogleSlug = (slug: string): string => | |
| 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 | 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 | 218 | export function GoogleServiceResultPanel(props: { |
| 179 | 219 | readonly result: GoogleAddServicesResult; |
| @@ -196,11 +236,12 @@ export function GoogleServiceResultPanel(props: { · 16 unmodified lines | ||
| 196 | 236 | </div> |
| 197 | 237 | <ul className="space-y-2"> |
| 198 | 238 | {rows.map((row: GoogleServiceResultRow) => { |
| 199 | − const presetName = googlePresetName(row.presetId); | |
| 239 | + const rowId = googleAddServicesResultRowId(row); | |
| 240 | + const presetName = googlePresetName(row); | |
| 200 | 241 | return ( |
| 201 | 242 | <li |
| 202 | − key={`${row.status}:${row.presetId}`} | |
| 203 | − data-testid={`add-result-row-${row.presetId}`} | |
| 243 | + key={`${row.status}:${rowId}`} | |
| 244 | + data-testid={`add-result-row-${rowId}`} | |
| 204 | 245 | data-state={row.status} |
| 205 | 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 | 290 | type="button" |
| 250 | 291 | variant="outline" |
| 251 | 292 | size="xs" |
| 252 | − data-testid={`add-result-retry-${row.presetId}`} | |
| 253 | − loading={props.retryingPresetId === row.presetId} | |
| 254 | − onClick={() => void props.onRetry(row.presetId)} | |
| 293 | + data-testid={`add-result-retry-${rowId}`} | |
| 294 | + loading={props.retryingPresetId === rowId} | |
| 295 | + onClick={() => void props.onRetry(rowId)} | |
| 255 | 296 | > |
| 256 | 297 | Retry |
| 257 | 298 | </Button> |
| @@ -302,7 +343,6 @@ export default function AddGoogleSource(props: { · 44 unmodified lines | ||
| 302 | 343 | ); |
| 303 | 344 | const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]); |
| 304 | 345 | const [baseUrl, setBaseUrl] = useState(""); |
| 305 | − const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | |
| 306 | 346 | const [adding, setAdding] = useState(false); |
| 307 | 347 | const [retryingPresetId, setRetryingPresetId] = useState<string | null>(null); |
| 308 | 348 | const [addError, setAddError] = useState<string | null>(null); |
| @@ -357,16 +397,6 @@ export default function AddGoogleSource(props: { · 48 unmodified lines | ||
| 357 | 397 | (hasCustomDiscoveryUrls |
| 358 | 398 | ? "Custom Google APIs" |
| 359 | 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 | 400 | const identityOverride = |
| 371 | 401 | selectedIds.length === 1 && !hasCustomDiscoveryUrls |
| 372 | 402 | ? { slug: resolvedSourceId, name: resolvedDisplayName } |
| @@ -375,15 +405,9 @@ export default function AddGoogleSource(props: { · 2 unmodified lines | ||
| 375 | 405 | customDiscoveryUrls.length > 0 |
| 376 | 406 | ? { |
| 377 | 407 | urls: [...customDiscoveryUrls], |
| 378 | − slug: resolvedSourceId, | |
| 379 | − name: resolvedDisplayName, | |
| 380 | − description: resolvedDescription, | |
| 381 | 408 | } |
| 382 | 409 | : undefined; |
| 383 | − const canAdd = | |
| 384 | − (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && | |
| 385 | − !customSlugAlreadyExists && | |
| 386 | − !adding; | |
| 410 | + const canAdd = (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && !adding; | |
| 387 | 411 | |
| 388 | 412 | const handleAdd = async () => { |
| 389 | 413 | setAdding(true); |
| @@ -407,7 +431,7 @@ export default function AddGoogleSource(props: { · 17 unmodified lines | ||
| 407 | 431 | const handleRetry = async (presetId: string) => { |
| 408 | 432 | setRetryingPresetId(presetId); |
| 409 | 433 | setAddError(null); |
| 410 | − const retryingCustom = presetId === GOOGLE_CUSTOM_SERVICE_ID; | |
| 434 | + const retryingCustom = !googleOpenApiPresetById.has(presetId); | |
| 411 | 435 | const retryIdentityOverride = |
| 412 | 436 | !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId |
| 413 | 437 | ? identityOverride |
| @@ -429,19 +453,9 @@ export default function AddGoogleSource(props: { · 15 unmodified lines | ||
| 429 | 453 | setRetryingPresetId(null); |
| 430 | 454 | }; |
| 431 | 455 | |
| 432 | − const showIdentityDetails = selectedIds.length === 1 || hasCustomDiscoveryUrls; | |
| 433 | − const detailTitle = hasCustomDiscoveryUrls | |
| 434 | − ? "Custom Google Discovery URLs" | |
| 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."; | |
| 456 | + const showIdentityDetails = selectedIds.length === 1 && !hasCustomDiscoveryUrls; | |
| 457 | + const detailTitle = singleSelectedPreset?.name ?? "Google"; | |
| 458 | + const detailSubtitle = "This product is added as its own integration."; | |
| 445 | 459 | |
| 446 | 460 | const dismiss = () => { |
| 447 | 461 | if (servicesResult) { |
| @@ -473,9 +487,6 @@ export default function AddGoogleSource(props: { · 25 unmodified lines | ||
| 473 | 487 | title={detailTitle} |
| 474 | 488 | subtitle={detailSubtitle} |
| 475 | 489 | identity={identity} |
| 476 | − {...(hasCustomDiscoveryUrls | |
| 477 | − ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } | |
| 478 | − : {})} | |
| 479 | 490 | baseUrl={baseUrl} |
| 480 | 491 | onBaseUrlChange={setBaseUrl} |
| 481 | 492 | baseUrlLabel="Base URL override (optional)" |
| @@ -486,8 +497,6 @@ export default function AddGoogleSource(props: { · 4 unmodified lines | ||
| 486 | 497 | <BaseUrlSettings baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} /> |
| 487 | 498 | )} |
| 488 | 499 | |
| 489 | − {customSlugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | |
| 490 | − | |
| 491 | 500 | {addError && <FormErrorAlert message={addError} />} |
| 492 | 501 | |
| 493 | 502 | {servicesResult && ( |
| @@ -241,6 +241,7 @@ const DiscoveryDocument = Schema.Struct({ · 240 unmodified lines | ||
| 241 | 241 | name: TextOption, |
| 242 | 242 | version: TextOption, |
| 243 | 243 | title: TextOption, |
| 244 | + description: TextOption, | |
| 244 | 245 | rootUrl: TextOption, |
| 245 | 246 | servicePath: Schema.optional(Schema.Trim).pipe( |
| 246 | 247 | Schema.withDecodingDefaultType(Effect.succeed("")), |
| @@ -268,6 +269,12 @@ const DiscoveryDocument = Schema.Struct({ · 21 unmodified lines | ||
| 268 | 269 | }); |
| 269 | 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 | 278 | export interface GoogleDiscoveryOpenApiConversion { |
| 272 | 279 | readonly specText: string; |
| 273 | 280 | readonly baseUrl: string; |
| @@ -286,6 +293,25 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); · 12 unmodified lines | ||
| 286 | 293 | const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); |
| 287 | 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 | 315 | const DISCOVERY_SERVICE_PATH_RE = |
| 290 | 316 | /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; |
| 291 | 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 | 437 | const schemaRef = (name: string) => `#/components/schemas/${name}`; |
| 393 | 438 | |
| 394 | 439 | const identitySchemaName = (name: string): string => name; |
| @@ -946,22 +991,7 @@ const hasOperation = ( · 551 unmodified lines | ||
| 946 | 991 | |
| 947 | 992 | export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleDiscovery")( |
| 948 | 993 | function* (input: { readonly discoveryUrl: string; readonly documentText: string }) { |
| 949 | − const parsed = yield* parseJson(input.documentText).pipe( | |
| 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 | − | |
| 994 | + const document = yield* parseDiscoveryDocument(input.documentText); | |
| 965 | 995 | const info = yield* discoveryDocumentInfo(document, input.discoveryUrl); |
| 966 | 996 | const { service, version, rootUrl, baseUrl, title } = info; |
| 967 | 997 | const paths: Record<string, Record<string, OpenApiOperationObject>> = {}; |
| @@ -1095,21 +1125,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 127 unmodified lines | ||
| 1095 | 1125 | |
| 1096 | 1126 | const infos = yield* Effect.forEach(input.documents, ({ discoveryUrl, documentText }) => |
| 1097 | 1127 | Effect.gen(function* () { |
| 1098 | − const parsed = yield* parseJson(documentText).pipe( | |
| 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 | − }); | |
| 1128 | + const document = yield* parseDiscoveryDocument(documentText); | |
| 1113 | 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 | |
| 5 | −// document, merges them into ONE `google` | |
| 6 | −// integration spec, and stores the unioned `googleOAuth2` auth template. These | |
| 7 | −// tests exercise that path end-to-end against a stubbed Discovery host: | |
| 8 | −// - a 3-API bundle (calendar + gmail + drive) produces a single `google` | |
| 9 | −// integration whose merged tools carry NO name collisions (each method id | |
| 10 | −// is service-prefixed) even when two APIs share a generic method name; | |
| 11 | −// - the stored oauth template carries the UNION of every API's scopes. | |
| 4 | +// The product picker emits preset ids and custom Discovery URLs. The server | |
| 5 | +// fans them out into separate integrations, deriving custom identity from the | |
| 6 | +// fetched Discovery document before it compiles the OpenAPI surface. | |
| 12 | 7 | // --------------------------------------------------------------------------- |
| 13 | 8 | |
| 14 | 9 | import { describe, expect, it } from "@effect/vitest"; |
| @@ -24,7 +19,6 @@ import { · 9 unmodified lines | ||
| 24 | 19 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 25 | 20 | |
| 26 | 21 | import { defaultGoogleHealthCheck, googlePlugin } from "./plugin"; |
| 27 | −import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets"; | |
| 28 | 22 | |
| 29 | 23 | // --- Canned Discovery documents ------------------------------------------- |
| 30 | 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 | 31 | const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"; |
| 38 | 32 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; |
| 39 | 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 | 35 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; |
| 41 | 36 | const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; |
| 42 | 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 | 251 | const photosLibraryDoc = { |
| 223 | 252 | name: "photoslibrary", |
| 224 | 253 | version: "v1", |
| @@ -425,6 +454,7 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 200 unmodified lines | ||
| 425 | 454 | [SHEETS_URL]: toJson(sheetsDoc), |
| 426 | 455 | [DRIVE_URL]: toJson(driveDoc), |
| 427 | 456 | [DOCS_URL]: toJson(docsDoc), |
| 457 | + [TASKS_URL]: toJson(tasksDoc), | |
| 428 | 458 | [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc), |
| 429 | 459 | [PHOTOS_PICKER_URL]: toJson(photosPickerDoc), |
| 430 | 460 | [PEOPLE_URL]: toJson(peopleDoc), |
| @@ -499,6 +529,240 @@ describe("Google custom service add flow", () => { · 68 unmodified lines | ||
| 499 | 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 | 766 | it.effect("rejects lookalike Discovery hosts before fetching custom service documents", () => |
| 503 | 767 | Effect.scoped( |
| 504 | 768 | Effect.gen(function* () { |
| @@ -541,7 +805,7 @@ describe("Google custom service add flow", () => { · 36 unmodified lines | ||
| 541 | 805 | failed: [ |
| 542 | 806 | { |
| 543 | 807 | slug: IntegrationSlug.make("bad_google"), |
| 544 | − presetId: "custom", | |
| 808 | + presetId: "bad_google", | |
| 545 | 809 | error: |
| 546 | 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( | |
| 556 | − "addServices custom entry merges calendar+gmail+drive into one requested integration with no tool-name collisions", | |
| 557 | − () => | |
| 558 | − Effect.scoped( | |
| 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() })); | |
| 819 | + it.effect("custom Google Photos Library uses the focused Photos scopes and upload tool", () => | |
| 820 | + Effect.scoped( | |
| 821 | + Effect.gen(function* () { | |
| 822 | + const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | |
| 746 | 823 | |
| 747 | − yield* executor.google.addServices({ | |
| 748 | − services: [ | |
| 749 | − { | |
| 750 | − custom: { | |
| 751 | − urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL], | |
| 752 | − slug: "google_photos_library_calendar", | |
| 753 | − name: "Google Photos Library and Calendar", | |
| 754 | − }, | |
| 824 | + yield* executor.google.addServices({ | |
| 825 | + services: [ | |
| 826 | + { | |
| 827 | + custom: { | |
| 828 | + urls: [PHOTOS_LIBRARY_URL], | |
| 755 | 829 | }, |
| 756 | − ], | |
| 757 | − }); | |
| 830 | + }, | |
| 831 | + ], | |
| 832 | + }); | |
| 758 | 833 | |
| 759 | − const config = yield* executor.google.getConfig("google_photos_library_calendar"); | |
| 760 | − const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | |
| 761 | − expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | |
| 762 | − [ | |
| 763 | − "email", | |
| 764 | − "https://www.googleapis.com/auth/calendar", | |
| 765 | − "https://www.googleapis.com/auth/photoslibrary.appendonly", | |
| 766 | − "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | |
| 767 | − "openid", | |
| 768 | − "profile", | |
| 769 | − ].sort(), | |
| 770 | − ); | |
| 834 | + const config = yield* executor.google.getConfig("google_photoslibrary"); | |
| 835 | + const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | |
| 836 | + expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | |
| 837 | + [ | |
| 838 | + "email", | |
| 839 | + "https://www.googleapis.com/auth/photoslibrary.appendonly", | |
| 840 | + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | |
| 841 | + "openid", | |
| 842 | + "profile", | |
| 843 | + ].sort(), | |
| 844 | + ); | |
| 771 | 845 | |
| 772 | − yield* executor.connections.create({ | |
| 773 | − owner: "org", | |
| 774 | − name: ConnectionName.make("main"), | |
| 775 | − integration: IntegrationSlug.make("google_photos_library_calendar"), | |
| 776 | − template: AuthTemplateSlug.make("googleOAuth2"), | |
| 777 | − value: "token-xyz", | |
| 778 | − }); | |
| 846 | + yield* executor.connections.create({ | |
| 847 | + owner: "org", | |
| 848 | + name: ConnectionName.make("main"), | |
| 849 | + integration: IntegrationSlug.make("google_photoslibrary"), | |
| 850 | + template: AuthTemplateSlug.make("googleOAuth2"), | |
| 851 | + value: "token-xyz", | |
| 852 | + }); | |
| 779 | 853 | |
| 780 | − const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | |
| 781 | − expect(toolNames).toContain("calendar.events.list"); | |
| 782 | − expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | |
| 783 | − expect(toolNames).toContain("photoslibrary.mediaItems.search"); | |
| 784 | − expect(toolNames).not.toContain("photospicker.mediaItems.list"); | |
| 785 | − expect(toolNames).not.toContain("photoslibrary.albums.list"); | |
| 786 | − }), | |
| 787 | − ), | |
| 854 | + const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | |
| 855 | + expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | |
| 856 | + expect(toolNames).toContain("photoslibrary.mediaItems.search"); | |
| 857 | + expect(toolNames).not.toContain("photoslibrary.albums.list"); | |
| 858 | + }), | |
| 859 | + ), | |
| 788 | 860 | ); |
| 789 | 861 | }); |
| 790 | 862 | |
| @@ -1021,31 +1093,23 @@ describe("Google per-service add flow", () => { · 230 unmodified lines | ||
| 1021 | 1093 | }); |
| 1022 | 1094 | |
| 1023 | 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 | 1097 | Effect.scoped( |
| 1026 | 1098 | Effect.gen(function* () { |
| 1027 | 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 | 1101 | yield* executor.google.addServices({ |
| 1035 | 1102 | services: [ |
| 1036 | 1103 | { |
| 1037 | 1104 | custom: { |
| 1038 | − urls: defaultUrls, | |
| 1039 | − slug: "google_default", | |
| 1040 | − name: "Google", | |
| 1041 | − description: "Google", | |
| 1105 | + urls: [TASKS_URL], | |
| 1042 | 1106 | }, |
| 1043 | 1107 | }, |
| 1044 | 1108 | ], |
| 1045 | 1109 | }); |
| 1046 | 1110 | |
| 1047 | 1111 | const stored = yield* executor.integrations.healthCheck.get( |
| 1048 | − IntegrationSlug.make("google_default"), | |
| 1112 | + IntegrationSlug.make("google_tasks"), | |
| 1049 | 1113 | ); |
| 1050 | 1114 | expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe( |
| 1051 | 1115 | "oauth2.userinfo.get", |
| @@ -1053,24 +1117,15 @@ describe("Google health-check default", () => { · 1 unmodified line | ||
| 1053 | 1117 | expect(stored?.args, "userinfo needs no args").toBeUndefined(); |
| 1054 | 1118 | expect(stored?.identityField, "the default reads the account email").toBe("email"); |
| 1055 | 1119 | |
| 1056 | − const config = yield* executor.google.getConfig("google_default"); | |
| 1057 | − expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]); | |
| 1120 | + const config = yield* executor.google.getConfig("google_tasks"); | |
| 1121 | + expect(config?.googleDiscoveryUrls).toEqual([TASKS_URL, OAUTH2_URL]); | |
| 1058 | 1122 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); |
| 1059 | 1123 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( |
| 1060 | − [ | |
| 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(), | |
| 1124 | + ["email", "https://www.googleapis.com/auth/tasks", "openid", "profile"].sort(), | |
| 1070 | 1125 | ); |
| 1071 | 1126 | |
| 1072 | 1127 | const candidates = yield* executor.integrations.healthCheck.candidates( |
| 1073 | − IntegrationSlug.make("google_default"), | |
| 1128 | + IntegrationSlug.make("google_tasks"), | |
| 1074 | 1129 | ); |
| 1075 | 1130 | const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get"); |
| 1076 | 1131 | expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined(); |
| @@ -1091,10 +1146,7 @@ describe("Google health-check default", () => { · 14 unmodified lines | ||
| 1091 | 1146 | services: [ |
| 1092 | 1147 | { |
| 1093 | 1148 | custom: { |
| 1094 | − urls: [PEOPLE_URL, CALENDAR_URL], | |
| 1095 | − slug: "google_people", | |
| 1096 | − name: "Google", | |
| 1097 | − description: "Google", | |
| 1149 | + urls: [PEOPLE_URL], | |
| 1098 | 1150 | }, |
| 1099 | 1151 | }, |
| 1100 | 1152 | ], |
| @@ -38,7 +38,9 @@ import { · 37 unmodified lines | ||
| 38 | 38 | import { |
| 39 | 39 | convertGoogleDiscoveryBundleToOpenApi, |
| 40 | 40 | fetchGoogleDiscoveryDocument, |
| 41 | + googleDiscoveryDocumentIdentity, | |
| 41 | 42 | normalizeGoogleDiscoveryUrl, |
| 43 | + type GoogleDiscoveryDocumentIdentity, | |
| 42 | 44 | } from "./discovery"; |
| 43 | 45 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; |
| 44 | 46 | import { |
| @@ -103,9 +105,15 @@ export interface GooglePresetServiceConfig { · 58 unmodified lines | ||
| 103 | 105 | |
| 104 | 106 | export interface GoogleCustomServiceConfig { |
| 105 | 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 | 114 | readonly urls: readonly string[]; |
| 107 | 115 | readonly slug?: string; |
| 108 | − readonly name: string; | |
| 116 | + readonly name?: string; | |
| 109 | 117 | readonly description?: string; |
| 110 | 118 | }; |
| 111 | 119 | } |
| @@ -162,6 +170,8 @@ export interface GooglePluginOptions { · 50 unmodified lines | ||
| 162 | 170 | } |
| 163 | 171 | |
| 164 | 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 | 176 | const googleOpenApiPresetById: ReadonlyMap<string, GoogleOpenApiPreset> = new Map( |
| 167 | 177 | googleOpenApiPresets.map((preset) => [preset.id, preset]), |
| @@ -177,7 +187,7 @@ const googleServiceEntryId = (service: GoogleServiceConfig): string => · 9 unmodified lines | ||
| 177 | 187 | const googleServiceEntrySlug = (service: GoogleServiceConfig): IntegrationSlug => |
| 178 | 188 | isGooglePresetServiceConfig(service) |
| 179 | 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 | 192 | type GoogleAddServiceOutcome = { |
| 183 | 193 | readonly added: readonly GoogleAddServicesAdded[]; |
| @@ -185,16 +195,21 @@ type GoogleAddServiceOutcome = { · 1 unmodified line | ||
| 185 | 195 | readonly failed: readonly GoogleAddServicesFailed[]; |
| 186 | 196 | }; |
| 187 | 197 | |
| 188 | −const googleAddServiceFailure = ( | |
| 189 | − service: GoogleServiceConfig, | |
| 190 | − slug: IntegrationSlug, | |
| 191 | − error: string, | |
| 192 | −): GoogleAddServiceOutcome => ({ | |
| 198 | +const googleAddServiceFailure = (input: { | |
| 199 | + readonly slug: IntegrationSlug; | |
| 200 | + readonly presetId: string; | |
| 201 | + readonly error: string; | |
| 202 | +}): GoogleAddServiceOutcome => ({ | |
| 193 | 203 | added: [], |
| 194 | 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 | 213 | const googlePhotosBundlePresetIdByUrl = new Map( |
| 199 | 214 | googlePhotosOpenApiPresets.flatMap((preset) => |
| 200 | 215 | preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [], |
| @@ -213,20 +228,28 @@ const googlePhotosBundleConsentScopes = ( · 12 unmodified lines | ||
| 213 | 228 | : undefined; |
| 214 | 229 | }; |
| 215 | 230 | |
| 216 | −const fetchGoogleBundleConversion = ( | |
| 231 | +const fetchGoogleDiscoveryDocuments = ( | |
| 217 | 232 | urls: readonly string[], |
| 218 | 233 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 219 | − consentScopesOverride?: readonly string[], | |
| 220 | 234 | ) => |
| 221 | 235 | Effect.forEach( |
| 222 | 236 | urls, |
| 223 | 237 | (url) => |
| 224 | 238 | fetchGoogleDiscoveryDocument(url).pipe( |
| 225 | 239 | Effect.provide(httpClientLayer), |
| 226 | − Effect.map((documentText) => ({ discoveryUrl: url, documentText })), | |
| 240 | + Effect.map( | |
| 241 | + (documentText): GoogleFetchedDiscoveryDocument => ({ discoveryUrl: url, documentText }), | |
| 242 | + ), | |
| 227 | 243 | ), |
| 228 | 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 | 253 | Effect.flatMap((documents) => { |
| 231 | 254 | const consentScopes = consentScopesOverride ?? googlePhotosBundleConsentScopes(urls); |
| 232 | 255 | return convertGoogleDiscoveryBundleToOpenApi({ |
| @@ -258,6 +281,132 @@ const googleBundleUrlsWithIdentity = ( · 25 unmodified lines | ||
| 258 | 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 | 410 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { |
| 262 | 411 | const config = decodeGoogleIntegrationConfig(record.config); |
| 263 | 412 | if (!config) return []; |
| @@ -292,8 +441,9 @@ const makeGooglePluginExtension = ( · 28 unmodified lines | ||
| 292 | 441 | ) => { |
| 293 | 442 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; |
| 294 | 443 | |
| 295 | − const addGoogleOpenApiIntegration = (input: { | |
| 444 | + const addGoogleOpenApiIntegrationFromDocuments = (input: { | |
| 296 | 445 | readonly urls: readonly string[]; |
| 446 | + readonly documents: readonly GoogleFetchedDiscoveryDocument[]; | |
| 297 | 447 | readonly slug: IntegrationSlug; |
| 298 | 448 | readonly name: string; |
| 299 | 449 | readonly description: string; |
| @@ -301,12 +451,11 @@ const makeGooglePluginExtension = ( · 1 unmodified line | ||
| 301 | 451 | readonly consentScopes?: readonly string[]; |
| 302 | 452 | }) => |
| 303 | 453 | Effect.gen(function* () { |
| 304 | − const urls = yield* googleBundleUrlsWithIdentity(input.urls); | |
| 305 | − const conversion = yield* fetchGoogleBundleConversion( | |
| 306 | − urls, | |
| 307 | − httpClientLayer, | |
| 308 | − input.consentScopes, | |
| 309 | − ); | |
| 454 | + const consentScopes = input.consentScopes ?? googlePhotosBundleConsentScopes(input.urls); | |
| 455 | + const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ | |
| 456 | + documents: input.documents, | |
| 457 | + ...(consentScopes !== undefined ? { consentScopes } : {}), | |
| 458 | + }); | |
| 310 | 459 | const compiled = yield* compileOpenApiSpec(conversion.specText); |
| 311 | 460 | const slug = input.slug; |
| 312 | 461 | |
| @@ -318,7 +467,7 @@ const makeGooglePluginExtension = ( · 5 unmodified lines | ||
| 318 | 467 | const specHash = yield* sha256Hex(conversion.specText); |
| 319 | 468 | const integrationConfig: GoogleIntegrationConfig = { |
| 320 | 469 | specHash, |
| 321 | − googleDiscoveryUrls: urls, | |
| 470 | + googleDiscoveryUrls: input.urls, | |
| 322 | 471 | ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}), |
| 323 | 472 | ...(conversion.authenticationTemplate |
| 324 | 473 | ? { authenticationTemplate: conversion.authenticationTemplate } |
| @@ -349,7 +498,7 @@ const makeGooglePluginExtension = ( · 24 unmodified lines | ||
| 349 | 498 | // added to every new Google OpenAPI integration. Older integrations |
| 350 | 499 | // without oauth2/v2 can still fall back to the People API identity |
| 351 | 500 | // operation. |
| 352 | − const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions); | |
| 501 | + const defaultHealthCheck = defaultGoogleHealthCheck(input.urls, compiled.definitions); | |
| 353 | 502 | if (defaultHealthCheck) { |
| 354 | 503 | yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck); |
| 355 | 504 | } |
| @@ -357,23 +506,26 @@ const makeGooglePluginExtension = ( · 1 unmodified line | ||
| 357 | 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 | 517 | Effect.gen(function* () { |
| 362 | − if (!isGooglePresetServiceConfig(service)) { | |
| 363 | − if (service.custom.urls.length === 0) { | |
| 364 | − return yield* new OpenApiParseError({ | |
| 365 | − message: "Custom Google service requires at least one Discovery URL", | |
| 366 | − }); | |
| 367 | − } | |
| 368 | − return yield* addGoogleOpenApiIntegration({ | |
| 369 | − urls: service.custom.urls, | |
| 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 | − } | |
| 518 | + const urls = yield* googleBundleUrlsWithIdentity(input.urls); | |
| 519 | + const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer); | |
| 520 | + return yield* addGoogleOpenApiIntegrationFromDocuments({ | |
| 521 | + ...input, | |
| 522 | + urls, | |
| 523 | + documents, | |
| 524 | + }); | |
| 525 | + }); | |
| 376 | 526 | |
| 527 | + const addOnePresetService = (service: GooglePresetServiceConfig, baseUrl?: string) => | |
| 528 | + Effect.gen(function* () { | |
| 377 | 529 | const preset = googleOpenApiPresetById.get(service.presetId); |
| 378 | 530 | if (!preset?.url) { |
| 379 | 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) => | |
| 395 | − Effect.gen(function* () { | |
| 396 | − const outcomes = yield* Effect.forEach( | |
| 397 | − input.services, | |
| 398 | − (service): Effect.Effect<GoogleAddServiceOutcome, never> => { | |
| 399 | − const slug = googleServiceEntrySlug(service); | |
| 400 | − const presetId = googleServiceEntryId(service); | |
| 401 | − return addOneService(service, input.baseUrl).pipe( | |
| 402 | − Effect.map( | |
| 403 | − (result): GoogleAddServiceOutcome => ({ | |
| 404 | − added: [ | |
| 405 | − { | |
| 406 | − slug: result.slug, | |
| 407 | − presetId, | |
| 408 | − toolCount: result.toolCount, | |
| 409 | − }, | |
| 410 | − ], | |
| 411 | − skipped: [], | |
| 412 | − failed: [], | |
| 546 | + const addCustomServicePlanOutcome = ( | |
| 547 | + plan: Extract<GoogleServiceAddPlan, { kind: "custom" }>, | |
| 548 | + baseUrl?: string, | |
| 549 | + ): Effect.Effect<GoogleAddServiceOutcome, never> => { | |
| 550 | + const fallbackSlug = plan.fallbackSlug; | |
| 551 | + const fallbackPresetId = googleCustomResultId(fallbackSlug); | |
| 552 | + const fallbackFailure = (error: string): GoogleAddServiceOutcome => | |
| 553 | + googleAddServiceFailure({ | |
| 554 | + slug: fallbackSlug, | |
| 555 | + presetId: fallbackPresetId, | |
| 556 | + error, | |
| 557 | + }); | |
| 558 | + | |
| 559 | + return Effect.gen(function* () { | |
| 560 | + const urls = yield* googleBundleUrlsWithIdentity([plan.url]); | |
| 561 | + const documents = yield* fetchGoogleDiscoveryDocuments(urls, httpClientLayer); | |
| 562 | + const serviceDocument = documents[0]; | |
| 563 | + if (!serviceDocument) { | |
| 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", () => | |
| 416 | − Effect.succeed({ | |
| 417 | − added: [], | |
| 418 | − skipped: [ | |
| 419 | − { | |
| 420 | − slug, | |
| 421 | − presetId, | |
| 422 | − reason: "already_exists" as const, | |
| 423 | − }, | |
| 424 | − ], | |
| 425 | − failed: [], | |
| 630 | + OpenApiExtractionError: (error: OpenApiExtractionError) => | |
| 631 | + Effect.succeed( | |
| 632 | + googleAddServiceFailure({ | |
| 633 | + slug, | |
| 634 | + presetId, | |
| 635 | + error: error.message, | |
| 426 | 636 | }), |
| 427 | 637 | ), |
| 428 | − Effect.catchTags({ | |
| 429 | − OpenApiParseError: (error: OpenApiParseError) => | |
| 430 | − Effect.succeed(googleAddServiceFailure(service, slug, error.message)), | |
| 431 | − OpenApiExtractionError: (error: OpenApiExtractionError) => | |
| 432 | − Effect.succeed(googleAddServiceFailure(service, slug, error.message)), | |
| 638 | + }), | |
| 639 | + ); | |
| 640 | + }).pipe( | |
| 641 | + Effect.catchTags({ | |
| 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(() => | |
| 435 | − Effect.succeed( | |
| 436 | − googleAddServiceFailure(service, slug, "Failed to add Google service"), | |
| 437 | − ), | |
| 438 | − ), | |
| 439 | − ); | |
| 440 | − }, | |
| 712 | + ), | |
| 713 | + OpenApiExtractionError: (error: OpenApiExtractionError) => | |
| 714 | + Effect.succeed( | |
| 715 | + googleAddServiceFailure({ | |
| 716 | + slug: fallbackSlug, | |
| 717 | + presetId: fallbackPresetId, | |
| 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 | 741 | { concurrency: 1 }, |
| 442 | 742 | ); |
| 443 | 743 | |