469 lines · 15.8 KB · TypeScript
RawDownload
import { Effect } from "effect";import type { Layer } from "effect";import { HttpClient } from "effect/unstable/http"; import {  IntegrationAlreadyExistsError,  IntegrationDetectionResult,  IntegrationNotFoundError,  IntegrationSlug,  definePlugin,  mergeAuthTemplates,  sha256Hex,  type AuthMethodDescriptor,  type HealthCheckSpec,  type Integration,  type IntegrationConfig,  type IntegrationRecord,  type PluginCtx,} from "@executor-js/sdk/core";import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";import {  checkHealthOpenApi,  compileOpenApiSpec,  invokeOpenApiBackedTool,  listHealthCheckCandidatesOpenApi,  makeDefaultOpenapiStore,  normalizeOpenApiAuthInputs,  OpenApiParseError,  openApiStoredOperationsFromCompiled,  resolveOpenApiBackedAnnotations,  resolveOpenApiBackedTools,  type Authentication,  type AuthenticationInput,  type OpenapiStore,} from "@executor-js/plugin-openapi"; import {  convertGoogleDiscoveryBundleToOpenApi,  fetchGoogleDiscoveryDocument,  normalizeGoogleDiscoveryUrl,} from "./discovery";import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";import {  googleOAuthConsentScopesForPreset,  googleOpenApiBundlePreset,  googlePhotosOpenApiBundlePreset,  googlePhotosOpenApiPresets,} from "./presets"; const GOOGLE_OAUTH2_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest"; /** The default health check for a Google bundle: prefer the lightweight *  OAuth2 userinfo identity call, which every new Google bundle includes. *  Older bundles may only have People API, so keep that fallback. */export const defaultGoogleHealthCheck = (  urls: readonly string[],  definitions: readonly {    readonly toolPath: string;    readonly operation: { readonly method: string; readonly pathTemplate: string };  }[],): HealthCheckSpec | undefined => {  const userinfoGet = definitions.find(    (def) =>      def.operation.method.toLowerCase() === "get" &&      (def.toolPath === "oauth2.userinfo.get" ||        def.operation.pathTemplate === "/oauth2/v2/userinfo"),  );  if (userinfoGet) {    return {      operation: userinfoGet.toolPath,      identityField: "email",    };  }   const hasPeopleApi = urls.some((url) => url.includes("/people/"));  if (!hasPeopleApi) return undefined;  const peopleGet = definitions.find(    (def) =>      def.operation.method.toLowerCase() === "get" &&      (def.toolPath === "people.people.get" ||        def.operation.pathTemplate === "/v1/{+resourceName}"),  );  return peopleGet    ? {        operation: peopleGet.toolPath,        args: { resourceName: "people/me", personFields: "names,emailAddresses" },        identityField: "emailAddresses.0.value",      }    : undefined;}; export interface GoogleBundleConfig {  readonly urls: readonly string[];  readonly slug?: string;  readonly name?: string;  readonly description?: string;  readonly baseUrl?: string;} export interface GoogleConfigureInput {  readonly authenticationTemplate: readonly AuthenticationInput[];  readonly mode?: "merge" | "replace";} export interface GoogleUpdateInput {  readonly urls?: readonly string[];} export interface GoogleUpdateResult {  readonly slug: IntegrationSlug;  readonly toolCount: number;  readonly addedTools: readonly string[];  readonly removedTools: readonly string[];} export interface GooglePluginOptions {  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;} const DEFAULT_GOOGLE_SLUG = "google"; const googlePhotosBundlePresetIdByUrl = new Map(  googlePhotosOpenApiPresets.flatMap((preset) =>    preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],  ),); const googlePhotosBundleConsentScopes = (  urls: readonly string[],): readonly string[] | undefined => {  const normalized = new Set(urls);  const presetIds = [...googlePhotosBundlePresetIdByUrl.entries()].flatMap(([url, presetId]) =>    normalized.has(url) ? [presetId] : [],  );  return presetIds.length > 0    ? presetIds.flatMap((presetId) => googleOAuthConsentScopesForPreset(presetId))    : undefined;}; const fetchGoogleBundleConversion = (  urls: readonly string[],  httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,) =>  Effect.forEach(    urls,    (url) =>      fetchGoogleDiscoveryDocument(url).pipe(        Effect.provide(httpClientLayer),        Effect.map((documentText) => ({ discoveryUrl: url, documentText })),      ),    { concurrency: 4 },  ).pipe(    Effect.flatMap((documents) => {      const consentScopes = googlePhotosBundleConsentScopes(urls);      return convertGoogleDiscoveryBundleToOpenApi({        documents,        ...(consentScopes ? { consentScopes } : {}),      });    }),  ); const uniqueUrls = (urls: readonly string[]): readonly string[] => [  ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])),]; const googleBundleUrlsWithIdentity = (  urls: readonly string[],): Effect.Effect<readonly string[], OpenApiParseError> =>  Effect.gen(function* () {    const normalized: string[] = [];    for (const url of urls) {      const discoveryUrl = normalizeGoogleDiscoveryUrl(url);      if (!discoveryUrl) {        return yield* new OpenApiParseError({          message:            "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint",        });      }      normalized.push(discoveryUrl);    }    return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]);  }); const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {  const config = decodeGoogleIntegrationConfig(record.config);  if (!config) return [];  return (config.authenticationTemplate ?? []).map(    (template: Authentication): AuthMethodDescriptor => {      if (template.kind === "oauth2") {        return {          id: String(template.slug),          label: "OAuth2",          kind: "oauth",          template: String(template.slug),          oauth: {            authorizationUrl: template.authorizationUrl,            tokenUrl: template.tokenUrl,            scopes: template.scopes,          },        };      }      return describeApiKeyAuthMethod(template);    },  );}; const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {  const config = decodeGoogleIntegrationConfig(record.config);  return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };}; const makeGooglePluginExtension = (  options: GooglePluginOptions | undefined,  ctx: PluginCtx<OpenapiStore>,) => {  const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;   const addBundle = (config: GoogleBundleConfig) =>    Effect.gen(function* () {      const urls = yield* googleBundleUrlsWithIdentity(config.urls);      const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);      const compiled = yield* compileOpenApiSpec(conversion.specText);      const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG);       const existing = yield* ctx.core.integrations.get(slug);      if (existing) {        return yield* new IntegrationAlreadyExistsError({ slug });      }       const specHash = yield* sha256Hex(conversion.specText);      const integrationConfig: GoogleIntegrationConfig = {        specHash,        googleDiscoveryUrls: urls,        ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),        ...(conversion.authenticationTemplate          ? { authenticationTemplate: conversion.authenticationTemplate }          : {}),      };       yield* ctx.storage.putSpec(specHash, conversion.specText);      yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));       yield* ctx.transaction(        Effect.gen(function* () {          yield* ctx.core.integrations.register({            slug,            name: config.name?.trim() || "Google",            description: config.description ?? "Google APIs",            config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig,            canRemove: true,            canRefresh: true,          });          yield* ctx.storage.putOperations(            String(slug),            openApiStoredOperationsFromCompiled(String(slug), compiled),          );        }),      );       // Default the health check to the light OAuth2 userinfo identity call      // added to every new bundle. Older bundles without oauth2/v2 can still      // fall back to the People API identity operation.      const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions);      if (defaultHealthCheck) {        yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck);      }       return { slug, toolCount: compiled.definitions.length };    });   const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) =>    Effect.gen(function* () {      const slug = IntegrationSlug.make(rawSlug);      const record = yield* ctx.core.integrations.get(slug);      const current = record ? decodeGoogleIntegrationConfig(record.config) : null;      if (!record || !current) {        return yield* new IntegrationNotFoundError({ slug });      }       const urls = yield* googleBundleUrlsWithIdentity(        input?.urls ?? current.googleDiscoveryUrls ?? [],      );      const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);      const compiled = yield* compileOpenApiSpec(conversion.specText);       const previousOperations = yield* ctx.storage.listOperations(rawSlug);      const previousNames = new Set(previousOperations.map((op) => op.toolName));      const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));       const specHash = yield* sha256Hex(conversion.specText);      yield* ctx.storage.putSpec(specHash, conversion.specText);      yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));       const nextConfig: GoogleIntegrationConfig = {        ...current,        specHash,        googleDiscoveryUrls: urls,      };       yield* ctx.transaction(        Effect.gen(function* () {          yield* ctx.core.integrations.update(slug, {            config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig,          });          yield* ctx.storage.putOperations(            rawSlug,            openApiStoredOperationsFromCompiled(rawSlug, compiled),          );        }),      );       const connections = yield* ctx.connections.list({ integration: slug });      yield* Effect.forEach(        connections,        (connection) =>          ctx.connections            .refresh({              owner: connection.owner,              integration: connection.integration,              name: connection.name,            })            .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),        { discard: true },      ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));       return {        slug,        toolCount: compiled.definitions.length,        addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),        removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),      };    });   return {    addBundle,    updateBundle,    removeBundle: (slug: string) =>      ctx.transaction(        Effect.gen(function* () {          yield* ctx.storage.removeOperations(slug);          yield* ctx.core.integrations            .remove(IntegrationSlug.make(slug))            .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));        }),      ),    getIntegration: (slug: string) =>      ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(        Effect.map((record) =>          record            ? ({                slug: record.slug,                description: record.description,                kind: record.kind,                canRemove: record.canRemove,                canRefresh: record.canRefresh,              } as Integration)            : null,        ),      ),    getConfig: (slug: string) =>      ctx.core.integrations        .get(IntegrationSlug.make(slug))        .pipe(          Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)),        ),    configure: (slug: string, input: GoogleConfigureInput) =>      ctx.transaction(        Effect.gen(function* () {          const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));          if (!record) return [] as readonly Authentication[];          const current = decodeGoogleIntegrationConfig(record.config);          if (!current) return [] as readonly Authentication[];           const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);          const merged =            input.mode === "replace"              ? incoming              : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);           const next: GoogleIntegrationConfig = {            ...current,            authenticationTemplate: merged,          };           yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {            config: next satisfies GoogleIntegrationConfig as IntegrationConfig,          });           return merged;        }),      ),  };}; export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>; export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({  id: "google" as const,  packageName: "@executor-js/plugin-google",  integrationPresets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset],  storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),   extension: (ctx: PluginCtx<OpenapiStore>) => makeGooglePluginExtension(options, ctx),   describeAuthMethods: describeGoogleAuthMethods,  describeIntegrationDisplay: describeGoogleIntegrationDisplay,   resolveTools: ({ integration, config, storage }) =>    resolveOpenApiBackedTools({ integration, config, storage }),   invokeTool: ({ ctx, toolRow, credential, args }) => {    const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;    return invokeOpenApiBackedTool({      ctx,      toolRow,      credential,      args,      httpClientLayer,    });  },   resolveAnnotations: ({ ctx, integration, toolRows }) =>    resolveOpenApiBackedAnnotations({      ctx,      integration: String(integration),      toolRows,    }),   // Health checks reuse the OpenAPI backing (same store). The People API  // identity call is auto-defaulted at addBundle when present; core owns the  // stored spec, the user adjusts it via the editor.  listHealthCheckCandidates: (input) =>    listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }),  checkHealth: (input) =>    checkHealthOpenApi({      ctx: input.ctx,      integration: input.integration,      credential: input.credential,      spec: input.spec,      httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,    }),   removeConnection: () => Effect.void,   detect: ({ ctx, url }) =>    Effect.gen(function* () {      const trimmed = url.trim();      const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed);      if (!trimmed || !discoveryUrl) return null;      const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;      const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe(        Effect.provide(httpClientLayer),        Effect.flatMap((documentText) =>          convertGoogleDiscoveryBundleToOpenApi({            documents: [{ discoveryUrl, documentText }],          }),        ),        Effect.catch(() => Effect.succeed(null)),      );      if (!conversion) return null;      return IntegrationDetectionResult.make({        kind: "google",        confidence: "high",        endpoint: discoveryUrl,        name: conversion.title,        slug: DEFAULT_GOOGLE_SLUG,      });    }),}));