From 2cbcd9c9cb9f4743a96e24a5cafe08319b724a09 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Tue, 23 Jun 2026 15:22:12 -0700 Subject: [PATCH] feat: health checks for Microsoft Graph and Google Wire the OpenAPI health-check backing into the Microsoft Graph and Google provider plugins (both compile their spec through the OpenAPI machinery and store config in the same shape), so their connections report alive/expired + identity instead of always "unknown". Both plugins now implement the four health-check hooks by delegating to the shared OpenAPI backing, and persist a `healthCheck` field on their own config schema (so it survives the plugin's own decode). Each auto-configures a sensible default at add time: - Microsoft Graph: `GET /me` with identity `userPrincipalName` (always present on /me, unlike `mail`), when the default profile preset is selected. - Google: People API `people.get` with the required args pinned (`resourceName=people/me`, `personFields=names,emailAddresses`) and identity `emailAddresses.0.value`, when the bundle includes the People API. The user can switch the operation/identity from the now-available editor. Covered by e2e: adding a Google People bundle (against a local discovery doc) auto-writes the default identity check and projects the email field as a typed candidate. Graph's spec source is a fixed upstream URL, so its hooks + default are covered through the shared backing path and verified against a live tenant. --- bun.lock | 1 + e2e/package.json | 1 + e2e/scenarios/health-checks-providers.test.ts | 158 ++++++++++++++++++ packages/plugins/google/src/sdk/config.ts | 4 + packages/plugins/google/src/sdk/plugin.ts | 59 ++++++- packages/plugins/microsoft/src/sdk/graph.ts | 3 +- packages/plugins/microsoft/src/sdk/plugin.ts | 20 +++ 7 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 e2e/scenarios/health-checks-providers.test.ts diff --git a/bun.lock b/bun.lock index 1c1557a68..4bd7a6844 100644 --- a/bun.lock +++ b/bun.lock @@ -342,6 +342,7 @@ "@executor-js/api": "workspace:*", "@executor-js/emulate": "^0.7.5", "@executor-js/mcporter": "^0.11.4", + "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", diff --git a/e2e/package.json b/e2e/package.json index f22a9ea39..79536cdb8 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,6 +23,7 @@ "@executor-js/api": "workspace:*", "@executor-js/emulate": "^0.7.5", "@executor-js/mcporter": "^0.11.4", + "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", "@executor-js/plugin-microsoft": "workspace:*", diff --git a/e2e/scenarios/health-checks-providers.test.ts b/e2e/scenarios/health-checks-providers.test.ts new file mode 100644 index 000000000..0369dd3cf --- /dev/null +++ b/e2e/scenarios/health-checks-providers.test.ts @@ -0,0 +1,158 @@ +// Health checks for the Google provider plugin. Provider integrations wire the +// OpenAPI health-check backing and auto-configure a default identity check +// (People API `people.get`) at add time, so a connection reports alive/expired + +// identity out of the box. Here we pin the auto-default + the typed candidate the +// editor would show; the probe itself is the shared OpenAPI path exercised by +// health-checks.ts. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { Effect } from "effect"; +import { expect } from "@effect/vitest"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { googleHttpPlugin } from "@executor-js/plugin-google/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([googleHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +const newSlug = (prefix: string) => + IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`); + +/** A minimal Google Discovery document for the People API, served locally so + * `addBundle` (which fetches the discovery URL) is hermetic. `people.get` is the + * canonical identity call; its response carries `emailAddresses[].value`. */ +const peopleDiscoveryDoc = (): string => + JSON.stringify({ + kind: "discovery#restDescription", + name: "people", + version: "v1", + title: "People API", + rootUrl: "https://people.example.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/userinfo.email": { description: "See your email" }, + }, + }, + }, + resources: { + people: { + methods: { + get: { + id: "people.people.get", + httpMethod: "GET", + path: "v1/{resourceName}", + scopes: ["https://www.googleapis.com/auth/userinfo.email"], + parameters: { + resourceName: { location: "path", required: true, type: "string" }, + personFields: { location: "query", type: "string" }, + }, + response: { $ref: "Person" }, + }, + }, + }, + }, + schemas: { + Person: { + id: "Person", + type: "object", + properties: { + resourceName: { type: "string" }, + emailAddresses: { type: "array", items: { $ref: "EmailAddress" } }, + names: { type: "array", items: { $ref: "Name" } }, + }, + }, + EmailAddress: { + id: "EmailAddress", + type: "object", + properties: { value: { type: "string" } }, + }, + Name: { id: "Name", type: "object", properties: { displayName: { type: "string" } } }, + }, + }); + +/** Serve the People discovery doc at a `/people/`-containing path (so the plugin + * recognizes the bundle as containing the People API). */ +const servePeopleDiscovery = () => + Effect.acquireRelease( + Effect.callback<{ readonly discoveryUrl: string; readonly close: () => void }>((resume) => { + const doc = peopleDiscoveryDoc(); + const server = createServer((request, response) => { + if ((request.url ?? "").includes("/apis/people/")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(doc); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + discoveryUrl: `http://127.0.0.1:${port}/discovery/v1/apis/people/v1/rest`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +scenario( + "Health checks ยท adding a Google People bundle auto-configures the identity check", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client: Client = yield* makeClient(api, identity); + const discovery = yield* servePeopleDiscovery(); + const slug = newSlug("hc-google"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.google.addBundle({ + payload: { urls: [discovery.discoveryUrl], slug: String(slug) }, + }); + + // The People identity call is offered as a candidate, with its typed + // response fields (so the editor's identity picker lists the email). + const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } }); + const peopleGet = candidates.find((candidate) => candidate.method === "get"); + if (!peopleGet) return yield* Effect.die("People bundle exposed no GET candidate"); + expect( + (peopleGet.responseFields ?? []).map((field) => field.path), + "the email identity field is projected from the response schema", + ).toContain("emailAddresses.0.value"); + + // Adding the bundle auto-wrote the default identity health check: the + // People identity call, with the required pinned args and email field. + const stored = yield* client.integrations.healthCheckGet({ params: { slug } }); + expect(stored?.operation, "the default check targets the People identity call").toBe( + peopleGet.operation, + ); + expect(stored?.identityField, "the default reads the email field").toBe( + "emailAddresses.0.value", + ); + expect(stored?.args, "the People call's required args are pinned").toEqual({ + resourceName: "people/me", + personFields: "names,emailAddresses", + }); + }), + client.google.removeBundle({ params: { slug: String(slug) } }).pipe(Effect.ignore), + ); + }), + ), +); diff --git a/packages/plugins/google/src/sdk/config.ts b/packages/plugins/google/src/sdk/config.ts index 6e3b45813..5def71b50 100644 --- a/packages/plugins/google/src/sdk/config.ts +++ b/packages/plugins/google/src/sdk/config.ts @@ -1,4 +1,5 @@ import { Option, Schema } from "effect"; +import { HealthCheckSpec } from "@executor-js/sdk/core"; import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi"; export const GoogleIntegrationConfigSchema = Schema.Struct({ @@ -9,6 +10,9 @@ export const GoogleIntegrationConfigSchema = Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), + // The declared health check survives the plugin's own decode (updateBundle + // read-modify-write); the OpenAPI backing reads/writes it via the base config. + healthCheck: Schema.optional(HealthCheckSpec), }); export type GoogleIntegrationConfig = Omit< diff --git a/packages/plugins/google/src/sdk/plugin.ts b/packages/plugins/google/src/sdk/plugin.ts index 597a30b96..db1365b93 100644 --- a/packages/plugins/google/src/sdk/plugin.ts +++ b/packages/plugins/google/src/sdk/plugin.ts @@ -11,6 +11,7 @@ import { mergeAuthTemplates, sha256Hex, type AuthMethodDescriptor, + type HealthCheckSpec, type Integration, type IntegrationConfig, type IntegrationRecord, @@ -18,13 +19,17 @@ import { } from "@executor-js/sdk/core"; import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; import { + checkHealthOpenApi, compileOpenApiSpec, + describeHealthCheckOpenApi, invokeOpenApiBackedTool, + listHealthCheckCandidatesOpenApi, makeDefaultOpenapiStore, normalizeOpenApiAuthInputs, openApiStoredOperationsFromCompiled, resolveOpenApiBackedAnnotations, resolveOpenApiBackedTools, + setHealthCheckOpenApi, type Authentication, type AuthenticationInput, type OpenapiStore, @@ -38,6 +43,35 @@ import { import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; import { googleOpenApiBundlePreset } from "./presets"; +/** The default health check for a Google bundle: the People API identity call + * (`people.get` with the required `resourceName`/`personFields` pinned), when + * the bundle includes the People API. People API is the canonical Google + * identity endpoint; if it isn't bundled, no default is written (the editor + * remains available). The user can adjust the identity field via the editor. */ +const defaultGoogleHealthCheck = ( + urls: readonly string[], + definitions: readonly { + readonly toolPath: string; + readonly operation: { readonly method: string; readonly pathTemplate: string }; + }[], +): HealthCheckSpec | undefined => { + 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; @@ -133,6 +167,11 @@ const makeGooglePluginExtension = ( } const specHash = yield* sha256Hex(conversion.specText); + // Default the health check to the People API identity call + // (`people.get` with `resourceName=people/me`) when the bundle includes + // the People API, so connections report alive/expired + identity out of the + // box. The user can adjust the operation / identity field via the editor. + const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions); const integrationConfig: GoogleIntegrationConfig = { specHash, googleDiscoveryUrls: urls, @@ -140,10 +179,10 @@ const makeGooglePluginExtension = ( ...(conversion.authenticationTemplate ? { authenticationTemplate: conversion.authenticationTemplate } : {}), + ...(defaultHealthCheck ? { healthCheck: defaultHealthCheck } : {}), }; yield* ctx.storage.putSpec(specHash, conversion.specText); - yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); yield* ctx.transaction( Effect.gen(function* () { @@ -184,7 +223,6 @@ const makeGooglePluginExtension = ( 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, @@ -321,6 +359,23 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ toolRows, }), + // Health checks reuse the OpenAPI backing (same store + config superset). The + // People API identity call is auto-defaulted at addBundle when present, and the + // user can adjust the operation / identity field via the editor. + describeHealthCheck: describeHealthCheckOpenApi, + listHealthCheckCandidates: (input) => + listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }), + setHealthCheck: (input) => + setHealthCheckOpenApi({ ctx: input.ctx, integration: input.integration, spec: input.spec }), + 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 }) => diff --git a/packages/plugins/microsoft/src/sdk/graph.ts b/packages/plugins/microsoft/src/sdk/graph.ts index d9d89cd96..704b1c3d9 100644 --- a/packages/plugins/microsoft/src/sdk/graph.ts +++ b/packages/plugins/microsoft/src/sdk/graph.ts @@ -2,7 +2,7 @@ import { Effect, Option, Schema } from "effect"; import type { Layer } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { AuthTemplateSlug } from "@executor-js/sdk/core"; +import { AuthTemplateSlug, HealthCheckSpec } from "@executor-js/sdk/core"; import { AuthenticationSchema, OpenApiParseError, @@ -95,6 +95,7 @@ const MicrosoftGraphIntegrationConfigSchema = Schema.Struct({ microsoftGraphAuthorizationUrl: Schema.optional(Schema.String), microsoftGraphTokenUrl: Schema.optional(Schema.String), microsoftGraphClientCredentialsTokenUrl: Schema.optional(Schema.String), + healthCheck: Schema.optional(HealthCheckSpec), }); const decodeMicrosoftConfig = Schema.decodeUnknownOption(MicrosoftGraphIntegrationConfigSchema); diff --git a/packages/plugins/microsoft/src/sdk/plugin.ts b/packages/plugins/microsoft/src/sdk/plugin.ts index 854b4396b..9ca6a0f44 100644 --- a/packages/plugins/microsoft/src/sdk/plugin.ts +++ b/packages/plugins/microsoft/src/sdk/plugin.ts @@ -19,7 +19,11 @@ import { } from "@executor-js/sdk/core"; import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; import { + checkHealthOpenApi, compileAndPersistOpenApiSpecStreaming, + describeHealthCheckOpenApi, + listHealthCheckCandidatesOpenApi, + setHealthCheckOpenApi, decodeOpenApiIntegrationConfig, invokeOpenApiBackedTool, makeDefaultOpenapiStore, @@ -373,6 +377,22 @@ export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) = toolRows, }), + // Health checks reuse the OpenAPI backing (same store + config superset). The + // user picks the identity operation (e.g. GET /me) via the editor. + describeHealthCheck: describeHealthCheckOpenApi, + listHealthCheckCandidates: (input) => + listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }), + setHealthCheck: (input) => + setHealthCheckOpenApi({ ctx: input.ctx, integration: input.integration, spec: input.spec }), + 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: ({ url }) =>