Skip to content

Commit 69d2ed8

Browse files
committed
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.
1 parent 0fdd8fe commit 69d2ed8

7 files changed

Lines changed: 243 additions & 3 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@executor-js/api": "workspace:*",
2424
"@executor-js/emulate": "^0.7.5",
2525
"@executor-js/mcporter": "^0.11.4",
26+
"@executor-js/plugin-google": "workspace:*",
2627
"@executor-js/plugin-graphql": "workspace:*",
2728
"@executor-js/plugin-mcp": "workspace:*",
2829
"@executor-js/plugin-microsoft": "workspace:*",
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Health checks for the Google provider plugin. Provider integrations wire the
2+
// OpenAPI health-check backing and auto-configure a default identity check
3+
// (People API `people.get`) at add time, so a connection reports alive/expired +
4+
// identity out of the box. Here we pin the auto-default + the typed candidate the
5+
// editor would show; the probe itself is the shared OpenAPI path exercised by
6+
// health-checks.ts.
7+
import { randomBytes } from "node:crypto";
8+
import { createServer } from "node:http";
9+
10+
import { Effect } from "effect";
11+
import { expect } from "@effect/vitest";
12+
import type { HttpApiClient } from "effect/unstable/httpapi";
13+
import { composePluginApi } from "@executor-js/api/server";
14+
import { googleHttpPlugin } from "@executor-js/plugin-google/api";
15+
import { IntegrationSlug } from "@executor-js/sdk/shared";
16+
17+
import { scenario } from "../src/scenario";
18+
import { Api, Target } from "../src/services";
19+
20+
const api = composePluginApi([googleHttpPlugin()] as const);
21+
type Client = HttpApiClient.ForApi<typeof api>;
22+
23+
const newSlug = (prefix: string) =>
24+
IntegrationSlug.make(`${prefix}-${randomBytes(4).toString("hex")}`);
25+
26+
/** A minimal Google Discovery document for the People API, served locally so
27+
* `addBundle` (which fetches the discovery URL) is hermetic. `people.get` is the
28+
* canonical identity call; its response carries `emailAddresses[].value`. */
29+
const peopleDiscoveryDoc = (): string =>
30+
JSON.stringify({
31+
kind: "discovery#restDescription",
32+
name: "people",
33+
version: "v1",
34+
title: "People API",
35+
rootUrl: "https://people.example.com/",
36+
servicePath: "",
37+
auth: {
38+
oauth2: {
39+
scopes: {
40+
"https://www.googleapis.com/auth/userinfo.email": { description: "See your email" },
41+
},
42+
},
43+
},
44+
resources: {
45+
people: {
46+
methods: {
47+
get: {
48+
id: "people.people.get",
49+
httpMethod: "GET",
50+
path: "v1/{resourceName}",
51+
scopes: ["https://www.googleapis.com/auth/userinfo.email"],
52+
parameters: {
53+
resourceName: { location: "path", required: true, type: "string" },
54+
personFields: { location: "query", type: "string" },
55+
},
56+
response: { $ref: "Person" },
57+
},
58+
},
59+
},
60+
},
61+
schemas: {
62+
Person: {
63+
id: "Person",
64+
type: "object",
65+
properties: {
66+
resourceName: { type: "string" },
67+
emailAddresses: { type: "array", items: { $ref: "EmailAddress" } },
68+
names: { type: "array", items: { $ref: "Name" } },
69+
},
70+
},
71+
EmailAddress: {
72+
id: "EmailAddress",
73+
type: "object",
74+
properties: { value: { type: "string" } },
75+
},
76+
Name: { id: "Name", type: "object", properties: { displayName: { type: "string" } } },
77+
},
78+
});
79+
80+
/** Serve the People discovery doc at a `/people/`-containing path (so the plugin
81+
* recognizes the bundle as containing the People API). */
82+
const servePeopleDiscovery = () =>
83+
Effect.acquireRelease(
84+
Effect.callback<{ readonly discoveryUrl: string; readonly close: () => void }>((resume) => {
85+
const doc = peopleDiscoveryDoc();
86+
const server = createServer((request, response) => {
87+
if ((request.url ?? "").includes("/apis/people/")) {
88+
response.writeHead(200, { "content-type": "application/json" });
89+
response.end(doc);
90+
return;
91+
}
92+
response.writeHead(404, { "content-type": "application/json" });
93+
response.end(JSON.stringify({ error: "not_found" }));
94+
});
95+
server.listen(0, "127.0.0.1", () => {
96+
const address = server.address();
97+
const port = typeof address === "object" && address ? address.port : 0;
98+
resume(
99+
Effect.succeed({
100+
discoveryUrl: `http://127.0.0.1:${port}/discovery/v1/apis/people/v1/rest`,
101+
close: () => {
102+
server.close();
103+
server.closeAllConnections();
104+
},
105+
}),
106+
);
107+
});
108+
}),
109+
(server) => Effect.sync(server.close),
110+
);
111+
112+
scenario(
113+
"Health checks · adding a Google People bundle auto-configures the identity check",
114+
{},
115+
Effect.scoped(
116+
Effect.gen(function* () {
117+
const target = yield* Target;
118+
const { client: makeClient } = yield* Api;
119+
const identity = yield* target.newIdentity();
120+
const client: Client = yield* makeClient(api, identity);
121+
const discovery = yield* servePeopleDiscovery();
122+
const slug = newSlug("hc-google");
123+
124+
yield* Effect.ensuring(
125+
Effect.gen(function* () {
126+
yield* client.google.addBundle({
127+
payload: { urls: [discovery.discoveryUrl], slug: String(slug) },
128+
});
129+
130+
// The People identity call is offered as a candidate, with its typed
131+
// response fields (so the editor's identity picker lists the email).
132+
const candidates = yield* client.integrations.healthCheckCandidates({ params: { slug } });
133+
const peopleGet = candidates.find((candidate) => candidate.method === "get");
134+
if (!peopleGet) return yield* Effect.die("People bundle exposed no GET candidate");
135+
expect(
136+
(peopleGet.responseFields ?? []).map((field) => field.path),
137+
"the email identity field is projected from the response schema",
138+
).toContain("emailAddresses.0.value");
139+
140+
// Adding the bundle auto-wrote the default identity health check: the
141+
// People identity call, with the required pinned args and email field.
142+
const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
143+
expect(stored?.operation, "the default check targets the People identity call").toBe(
144+
peopleGet.operation,
145+
);
146+
expect(stored?.identityField, "the default reads the email field").toBe(
147+
"emailAddresses.0.value",
148+
);
149+
expect(stored?.args, "the People call's required args are pinned").toEqual({
150+
resourceName: "people/me",
151+
personFields: "names,emailAddresses",
152+
});
153+
}),
154+
client.google.removeBundle({ params: { slug: String(slug) } }).pipe(Effect.ignore),
155+
);
156+
}),
157+
),
158+
);

packages/plugins/google/src/sdk/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Option, Schema } from "effect";
2+
import { HealthCheckSpec } from "@executor-js/sdk/core";
23
import { AuthenticationSchema, type Authentication } from "@executor-js/plugin-openapi";
34

45
export const GoogleIntegrationConfigSchema = Schema.Struct({
@@ -9,6 +10,9 @@ export const GoogleIntegrationConfigSchema = Schema.Struct({
910
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
1011
queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
1112
authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
13+
// The declared health check survives the plugin's own decode (updateBundle
14+
// read-modify-write); the OpenAPI backing reads/writes it via the base config.
15+
healthCheck: Schema.optional(HealthCheckSpec),
1216
});
1317

1418
export type GoogleIntegrationConfig = Omit<

packages/plugins/google/src/sdk/plugin.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,25 @@ import {
1111
mergeAuthTemplates,
1212
sha256Hex,
1313
type AuthMethodDescriptor,
14+
type HealthCheckSpec,
1415
type Integration,
1516
type IntegrationConfig,
1617
type IntegrationRecord,
1718
type PluginCtx,
1819
} from "@executor-js/sdk/core";
1920
import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
2021
import {
22+
checkHealthOpenApi,
2123
compileOpenApiSpec,
24+
describeHealthCheckOpenApi,
2225
invokeOpenApiBackedTool,
26+
listHealthCheckCandidatesOpenApi,
2327
makeDefaultOpenapiStore,
2428
normalizeOpenApiAuthInputs,
2529
openApiStoredOperationsFromCompiled,
2630
resolveOpenApiBackedAnnotations,
2731
resolveOpenApiBackedTools,
32+
setHealthCheckOpenApi,
2833
type Authentication,
2934
type AuthenticationInput,
3035
type OpenapiStore,
@@ -38,6 +43,35 @@ import {
3843
import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
3944
import { googleOpenApiBundlePreset } from "./presets";
4045

46+
/** The default health check for a Google bundle: the People API identity call
47+
* (`people.get` with the required `resourceName`/`personFields` pinned), when
48+
* the bundle includes the People API. People API is the canonical Google
49+
* identity endpoint; if it isn't bundled, no default is written (the editor
50+
* remains available). The user can adjust the identity field via the editor. */
51+
const defaultGoogleHealthCheck = (
52+
urls: readonly string[],
53+
definitions: readonly {
54+
readonly toolPath: string;
55+
readonly operation: { readonly method: string; readonly pathTemplate: string };
56+
}[],
57+
): HealthCheckSpec | undefined => {
58+
const hasPeopleApi = urls.some((url) => url.includes("/people/"));
59+
if (!hasPeopleApi) return undefined;
60+
const peopleGet = definitions.find(
61+
(def) =>
62+
def.operation.method.toLowerCase() === "get" &&
63+
(def.toolPath === "people.people.get" ||
64+
def.operation.pathTemplate === "/v1/{+resourceName}"),
65+
);
66+
return peopleGet
67+
? {
68+
operation: peopleGet.toolPath,
69+
args: { resourceName: "people/me", personFields: "names,emailAddresses" },
70+
identityField: "emailAddresses.0.value",
71+
}
72+
: undefined;
73+
};
74+
4175
export interface GoogleBundleConfig {
4276
readonly urls: readonly string[];
4377
readonly slug?: string;
@@ -133,17 +167,22 @@ const makeGooglePluginExtension = (
133167
}
134168

135169
const specHash = yield* sha256Hex(conversion.specText);
170+
// Default the health check to the People API identity call
171+
// (`people.get` with `resourceName=people/me`) when the bundle includes
172+
// the People API, so connections report alive/expired + identity out of the
173+
// box. The user can adjust the operation / identity field via the editor.
174+
const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions);
136175
const integrationConfig: GoogleIntegrationConfig = {
137176
specHash,
138177
googleDiscoveryUrls: urls,
139178
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
140179
...(conversion.authenticationTemplate
141180
? { authenticationTemplate: conversion.authenticationTemplate }
142181
: {}),
182+
...(defaultHealthCheck ? { healthCheck: defaultHealthCheck } : {}),
143183
};
144184

145185
yield* ctx.storage.putSpec(specHash, conversion.specText);
146-
yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
147186

148187
yield* ctx.transaction(
149188
Effect.gen(function* () {
@@ -184,7 +223,6 @@ const makeGooglePluginExtension = (
184223

185224
const specHash = yield* sha256Hex(conversion.specText);
186225
yield* ctx.storage.putSpec(specHash, conversion.specText);
187-
yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
188226

189227
const nextConfig: GoogleIntegrationConfig = {
190228
...current,
@@ -321,6 +359,23 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
321359
toolRows,
322360
}),
323361

362+
// Health checks reuse the OpenAPI backing (same store + config superset). The
363+
// People API identity call is auto-defaulted at addBundle when present, and the
364+
// user can adjust the operation / identity field via the editor.
365+
describeHealthCheck: describeHealthCheckOpenApi,
366+
listHealthCheckCandidates: (input) =>
367+
listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }),
368+
setHealthCheck: (input) =>
369+
setHealthCheckOpenApi({ ctx: input.ctx, integration: input.integration, spec: input.spec }),
370+
checkHealth: (input) =>
371+
checkHealthOpenApi({
372+
ctx: input.ctx,
373+
integration: input.integration,
374+
credential: input.credential,
375+
spec: input.spec,
376+
httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,
377+
}),
378+
324379
removeConnection: () => Effect.void,
325380

326381
detect: ({ ctx, url }) =>

packages/plugins/microsoft/src/sdk/graph.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Effect, Option, Schema } from "effect";
22
import type { Layer } from "effect";
33
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
44

5-
import { AuthTemplateSlug } from "@executor-js/sdk/core";
5+
import { AuthTemplateSlug, HealthCheckSpec } from "@executor-js/sdk/core";
66
import {
77
AuthenticationSchema,
88
OpenApiParseError,
@@ -95,6 +95,7 @@ const MicrosoftGraphIntegrationConfigSchema = Schema.Struct({
9595
microsoftGraphAuthorizationUrl: Schema.optional(Schema.String),
9696
microsoftGraphTokenUrl: Schema.optional(Schema.String),
9797
microsoftGraphClientCredentialsTokenUrl: Schema.optional(Schema.String),
98+
healthCheck: Schema.optional(HealthCheckSpec),
9899
});
99100

100101
const decodeMicrosoftConfig = Schema.decodeUnknownOption(MicrosoftGraphIntegrationConfigSchema);

packages/plugins/microsoft/src/sdk/plugin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import {
1919
} from "@executor-js/sdk/core";
2020
import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
2121
import {
22+
checkHealthOpenApi,
2223
compileAndPersistOpenApiSpecStreaming,
24+
describeHealthCheckOpenApi,
25+
listHealthCheckCandidatesOpenApi,
26+
setHealthCheckOpenApi,
2327
decodeOpenApiIntegrationConfig,
2428
invokeOpenApiBackedTool,
2529
makeDefaultOpenapiStore,
@@ -373,6 +377,22 @@ export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) =
373377
toolRows,
374378
}),
375379

380+
// Health checks reuse the OpenAPI backing (same store + config superset). The
381+
// user picks the identity operation (e.g. GET /me) via the editor.
382+
describeHealthCheck: describeHealthCheckOpenApi,
383+
listHealthCheckCandidates: (input) =>
384+
listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }),
385+
setHealthCheck: (input) =>
386+
setHealthCheckOpenApi({ ctx: input.ctx, integration: input.integration, spec: input.spec }),
387+
checkHealth: (input) =>
388+
checkHealthOpenApi({
389+
ctx: input.ctx,
390+
integration: input.integration,
391+
credential: input.credential,
392+
spec: input.spec,
393+
httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,
394+
}),
395+
376396
removeConnection: () => Effect.void,
377397

378398
detect: ({ url }) =>

0 commit comments

Comments
 (0)