Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
158 changes: 158 additions & 0 deletions e2e/scenarios/health-checks-providers.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof api>;

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),
);
}),
),
);
4 changes: 4 additions & 0 deletions packages/plugins/google/src/sdk/config.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<
Expand Down
59 changes: 57 additions & 2 deletions packages/plugins/google/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,25 @@ import {
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,
describeHealthCheckOpenApi,
invokeOpenApiBackedTool,
listHealthCheckCandidatesOpenApi,
makeDefaultOpenapiStore,
normalizeOpenApiAuthInputs,
openApiStoredOperationsFromCompiled,
resolveOpenApiBackedAnnotations,
resolveOpenApiBackedTools,
setHealthCheckOpenApi,
type Authentication,
type AuthenticationInput,
type OpenapiStore,
Expand All @@ -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;
Expand Down Expand Up @@ -133,17 +167,22 @@ 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,
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
...(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* () {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }) =>
Expand Down
3 changes: 2 additions & 1 deletion packages/plugins/microsoft/src/sdk/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions packages/plugins/microsoft/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }) =>
Expand Down
Loading