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
3 changes: 3 additions & 0 deletions packages/plugins/google-discovery/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Schema } from "effect";
import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared";
import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "../sdk/errors";
import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
import { GoogleDiscoveryAnnotationPolicy } from "../sdk/types";

export { HttpApiSchema };

Expand Down Expand Up @@ -52,6 +53,7 @@ const AddSourcePayload = Schema.Struct({
credentials: Schema.optional(DiscoveryCredentialsPayload),
namespace: Schema.optional(Schema.String),
auth: AuthPayload,
annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy),
});

const AddSourceResponse = Schema.Struct({
Expand All @@ -62,6 +64,7 @@ const AddSourceResponse = Schema.Struct({
const UpdateSourcePayload = Schema.Struct({
name: Schema.optional(Schema.String),
auth: Schema.optional(AuthPayload),
annotationPolicy: Schema.optional(Schema.NullOr(GoogleDiscoveryAnnotationPolicy)),
});

const UpdateSourceResponse = Schema.Struct({
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/google-discovery/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
yield* ext.updateSource(path.namespace, path.scopeId, {
name: payload.name,
auth: payload.auth,
annotationPolicy: payload.annotationPolicy,
});
return { updated: true };
}),
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/google-discovery/src/sdk/binding-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import {
GoogleDiscoveryMethodBinding,
GoogleDiscoveryStoredSourceData,
type GoogleDiscoveryAnnotationPolicy,
type GoogleDiscoveryAuth,
type GoogleDiscoveryCredentialValue,
type GoogleDiscoveryFetchCredentials,
Expand Down Expand Up @@ -272,6 +273,7 @@ export interface GoogleDiscoveryStore {
update: {
readonly name?: string;
readonly auth?: import("./types").GoogleDiscoveryAuth;
readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy | null;
},
) => Effect.Effect<void, StorageFailure>;
readonly removeSource: (sourceId: string, scope: string) => Effect.Effect<void, StorageFailure>;
Expand Down Expand Up @@ -435,11 +437,21 @@ export const makeGoogleDiscoveryStore = (
);
if (!row) return;
const auth = update.auth ?? columnsToAuth(row);
const existingConfig = yield* hydrateStoredSourceData(row, sourceId, scope);
const nextConfig = {
...existingConfig,
auth,
...(update.annotationPolicy !== undefined
? { annotationPolicy: update.annotationPolicy ?? undefined }
: {}),
};
const encoded = stripExtractedFields(decodeJsonObject(encodeStoredSourceData(nextConfig)));
yield* fuma.use("google_discovery_source.updateManyByScopedId", (db) =>
db.updateMany("google_discovery_source", {
where: (b) => b.and(b("id", "=", sourceId), b("scope_id", "=", scope)),
set: {
name: update.name ?? decodeString(row.name),
config: toJsonRecord(encoded),
updated_at: new Date(),
...authToColumns(auth),
},
Expand Down
52 changes: 52 additions & 0 deletions packages/plugins/google-discovery/src/sdk/invoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "@effect/vitest";

import { annotationsForOperation } from "./invoke";

// ---------------------------------------------------------------------------
// Pure tests for the Google Discovery annotation helper. Exercises the
// default (POST / PUT / PATCH / DELETE) policy plus per-source overrides
// supplied via `GoogleDiscoveryAnnotationPolicy.requireApprovalFor`.
// ---------------------------------------------------------------------------

describe("annotationsForOperation", () => {
it("applies the default policy when no override is supplied", () => {
const post = annotationsForOperation("post", "/foo", undefined);
expect(post.requiresApproval).toBe(true);
expect(post.approvalDescription).toBe("POST /foo");

const get = annotationsForOperation("get", "/foo", undefined);
expect(get.requiresApproval).toBeUndefined();
expect(get.approvalDescription).toBeUndefined();
});

it("honors overrides that include GET", () => {
const result = annotationsForOperation("get", "/foo", {
requireApprovalFor: ["get"],
});
expect(result.requiresApproval).toBe(true);
expect(result.approvalDescription).toBe("GET /foo");
});

it("honors overrides that exclude POST", () => {
const result = annotationsForOperation("post", "/foo", {
requireApprovalFor: ["get"],
});
expect(result.requiresApproval).toBeUndefined();
expect(result.approvalDescription).toBeUndefined();
});

it("treats an empty override as approval-for-nothing", () => {
const result = annotationsForOperation("delete", "/foo", {
requireApprovalFor: [],
});
expect(result.requiresApproval).toBeUndefined();
expect(result.approvalDescription).toBeUndefined();
});

it("treats a null policy the same as undefined (fall back to defaults)", () => {
const post = annotationsForOperation("post", "/foo", null);
expect(post.requiresApproval).toBe(true);
const get = annotationsForOperation("get", "/foo", null);
expect(get.requiresApproval).toBeUndefined();
});
});
5 changes: 4 additions & 1 deletion packages/plugins/google-discovery/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ const errorMessageFromUnknown = (cause: unknown): string => {
export const annotationsForOperation = (
method: string,
pathTemplate: string,
policy?: { readonly requireApprovalFor?: readonly string[] } | null,
): { requiresApproval?: boolean; approvalDescription?: string } => {
if (SAFE_METHODS.has(method.toLowerCase())) return {};
const m = method.toLowerCase();
const requiresApproval = policy?.requireApprovalFor?.includes(m) ?? !SAFE_METHODS.has(m);
if (!requiresApproval) return {};
return {
requiresApproval: true,
approvalDescription: `${method.toUpperCase()} ${pathTemplate}`,
Expand Down
19 changes: 17 additions & 2 deletions packages/plugins/google-discovery/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { extractGoogleDiscoveryManifest } from "./document";
import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke";
import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors";
import {
GoogleDiscoveryAnnotationPolicy,
GoogleDiscoveryAuth,
GoogleDiscoveryFetchCredentials,
GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema,
Expand Down Expand Up @@ -123,6 +124,7 @@ export interface GoogleDiscoveryUpdateSourceInput {
/** Rewrite the source's auth — typically after a successful
* re-authenticate, to point at a freshly minted Connection. */
readonly auth?: GoogleDiscoveryAuth;
readonly annotationPolicy?: GoogleDiscoveryAnnotationPolicy | null;
}

const GoogleDiscoveryProbeInputSchema = Schema.Struct({
Expand Down Expand Up @@ -154,13 +156,15 @@ const GoogleDiscoveryAddSourceInputSchema = Schema.Struct({
credentials: Schema.optional(GoogleDiscoveryFetchCredentials),
namespace: Schema.optional(Schema.String),
auth: GoogleDiscoveryAuth,
annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy),
});
const GoogleDiscoveryStaticAddSourceInputSchema = Schema.Struct({
name: Schema.String,
discoveryUrl: Schema.String,
credentials: Schema.optional(GoogleDiscoveryFetchCredentials),
namespace: Schema.optional(Schema.String),
auth: GoogleDiscoveryAuth,
annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy),
});
export type GoogleDiscoveryProbeInput = typeof GoogleDiscoveryProbeInputSchema.Type;
export type GoogleDiscoveryAddSourceInput = typeof GoogleDiscoveryAddSourceInputSchema.Type;
Expand All @@ -186,6 +190,7 @@ const GoogleDiscoveryGetSourceOutputSchema = Schema.Struct({
const GoogleDiscoveryConfigureInputSchema = Schema.Struct({
name: Schema.optional(Schema.String),
auth: Schema.optional(GoogleDiscoveryAuth),
annotationPolicy: Schema.optional(Schema.NullOr(GoogleDiscoveryAnnotationPolicy)),
});
const GoogleDiscoveryConfigureSourceInputSchema = Schema.Struct({
source: Schema.Struct({
Expand Down Expand Up @@ -495,6 +500,7 @@ const makeGoogleDiscoveryPluginExtension = (ctx: PluginCtx<GoogleDiscoveryStore>
rootUrl: manifest.rootUrl,
servicePath: manifest.servicePath,
auth: input.auth,
annotationPolicy: input.annotationPolicy,
});
const toolCount = yield* registerManifest(
ctx,
Expand Down Expand Up @@ -529,6 +535,7 @@ const makeGoogleDiscoveryPluginExtension = (ctx: PluginCtx<GoogleDiscoveryStore>
ctx.storage.updateSourceMeta(namespace, scope, {
name: input.name?.trim() || undefined,
auth: input.auth,
annotationPolicy: input.annotationPolicy,
}),
});

Expand Down Expand Up @@ -670,15 +677,23 @@ export const googleDiscoveryPlugin = definePlugin(() => ({
const scopes = new Set<string>();
for (const row of toolRows) scopes.add(decodeString(row.scope_id));
const byScope = new Map<string, ReadonlyMap<string, GoogleDiscoveryMethodBinding>>();
const policyByScope = new Map<string, GoogleDiscoveryAnnotationPolicy | undefined>();
for (const scope of scopes) {
const bindings = yield* typedCtx.storage.getBindingsForSource(sourceId, scope);
byScope.set(scope, bindings);
const source = yield* typedCtx.storage.getSource(sourceId, scope);
policyByScope.set(scope, source?.config.annotationPolicy);
}
const out: Record<string, ToolAnnotations> = {};
for (const row of toolRows) {
const binding = byScope.get(decodeString(row.scope_id))?.get(row.id);
const scope = decodeString(row.scope_id);
const binding = byScope.get(scope)?.get(row.id);
if (binding) {
out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate);
out[row.id] = annotationsForOperation(
binding.method,
binding.pathTemplate,
policyByScope.get(scope),
);
}
}
return out;
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/google-discovery/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export const GoogleDiscoveryHttpMethod = Schema.Literals([
]);
export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethod.Type;

export const GoogleDiscoveryAnnotationPolicy = Schema.Struct({
requireApprovalFor: Schema.optional(Schema.Array(GoogleDiscoveryHttpMethod)),
}).annotate({ identifier: "GoogleDiscoveryAnnotationPolicy" });
export type GoogleDiscoveryAnnotationPolicy = typeof GoogleDiscoveryAnnotationPolicy.Type;

export const GoogleDiscoveryParameterLocation = Schema.Literals(["path", "query", "header"]);
export type GoogleDiscoveryParameterLocation = typeof GoogleDiscoveryParameterLocation.Type;

Expand Down Expand Up @@ -98,6 +103,7 @@ export const GoogleDiscoveryStoredSourceData = Schema.Struct({
name: Schema.String,
discoveryUrl: Schema.String,
credentials: Schema.optional(GoogleDiscoveryFetchCredentials),
annotationPolicy: Schema.optional(GoogleDiscoveryAnnotationPolicy),
service: Schema.String,
version: Schema.String,
rootUrl: Schema.String,
Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/graphql/src/api/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { InternalError, ScopeId } from "@executor-js/sdk/shared";

import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
import {
AnnotationPolicy,
GraphqlConfiguredValueInput,
ConfiguredGraphqlCredentialValue,
GraphqlCredentialInput,
Expand All @@ -22,6 +23,7 @@ export const StoredSourceSchema = Schema.Struct({
headers: Schema.Record(Schema.String, ConfiguredGraphqlCredentialValue),
queryParams: Schema.Record(Schema.String, ConfiguredGraphqlCredentialValue),
auth: GraphqlSourceAuth,
annotationPolicy: Schema.optional(AnnotationPolicy),
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -49,6 +51,7 @@ const AddSourcePayload = Schema.Struct({
headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)),
queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)),
oauth2: Schema.optional(OAuth2SourceConfig),
annotationPolicy: Schema.optional(AnnotationPolicy),
credentials: Schema.optional(
Schema.Struct({
scope: ScopeId,
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/graphql/src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra
headers: payload.headers,
queryParams: payload.queryParams,
oauth2: payload.oauth2,
annotationPolicy: payload.annotationPolicy,
credentials: payload.credentials,
});
return {
Expand Down
28 changes: 21 additions & 7 deletions packages/plugins/graphql/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type StoredOperation,
} from "./store";
import {
AnnotationPolicy,
ExtractedField,
GraphqlConfiguredValueInput as GraphqlConfiguredValueInputSchema,
GRAPHQL_OAUTH_CONNECTION_SLOT,
Expand Down Expand Up @@ -114,6 +115,7 @@ export interface GraphqlSourceConfig {
readonly queryParams?: Record<string, GraphqlConfiguredValueInput>;
/** Optional OAuth2 credential used as a Bearer token for every request. */
readonly oauth2?: OAuth2SourceConfig;
readonly annotationPolicy?: AnnotationPolicy;
/** Initial credential bindings used while adding and introspecting this source. */
readonly credentials?: GraphqlInitialCredentialsInput;
}
Expand All @@ -134,6 +136,7 @@ const StaticAddSourceInputSchema = Schema.Struct({
headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)),
queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)),
oauth2: Schema.optional(OAuth2SourceConfig),
annotationPolicy: Schema.optional(AnnotationPolicy),
credentials: Schema.optional(GraphqlInitialCredentialsInputSchema),
});
const SourceConfigureInputSchema = Schema.Struct({
Expand All @@ -142,6 +145,7 @@ const SourceConfigureInputSchema = Schema.Struct({
headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)),
queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)),
auth: Schema.optional(GraphqlSourceAuthInputSchema),
annotationPolicy: Schema.optional(Schema.NullOr(AnnotationPolicy)),
});
const StaticConfigureSourceInputSchema = Schema.Struct({
source: Schema.Struct({
Expand Down Expand Up @@ -245,6 +249,7 @@ export interface GraphqlConfigureSourceInput {
readonly headers?: Record<string, GraphqlCredentialInput>;
readonly queryParams?: Record<string, GraphqlCredentialInput>;
readonly auth?: GraphqlSourceAuthInput;
readonly annotationPolicy?: AnnotationPolicy | null;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -399,11 +404,16 @@ const prepareOperations = (
});
};

const annotationsFor = (binding: OperationBinding): ToolAnnotations => {
if (binding.kind === "mutation") {
const annotationsFor = (
binding: OperationBinding,
policy: AnnotationPolicy | undefined,
): ToolAnnotations => {
const requiresApproval =
policy?.requireApprovalFor?.includes(binding.kind) ?? binding.kind === "mutation";
if (requiresApproval) {
return {
requiresApproval: true,
approvalDescription: `mutation ${binding.fieldName}`,
approvalDescription: `${binding.kind} ${binding.fieldName}`,
};
}
return {};
Expand Down Expand Up @@ -907,6 +917,7 @@ const makeGraphqlExtension = (
headers: canonicalHeaders,
queryParams: canonicalQueryParams,
auth,
annotationPolicy: config.annotationPolicy,
};

const storedOps: StoredOperation[] = prepared.map((p) => ({
Expand Down Expand Up @@ -1004,6 +1015,7 @@ const makeGraphqlExtension = (
headers: canonicalHeaders?.values,
queryParams: canonicalQueryParams?.values,
auth: canonicalAuth?.auth,
annotationPolicy: input.annotationPolicy,
});
if (affectedPrefixes.length > 0 || directBindings.length > 0) {
yield* ctx.credentialBindings.replaceForSource({
Expand Down Expand Up @@ -1294,16 +1306,18 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {
const ops = yield* ctx.storage.listOperationsBySource(sourceId, scope);
const byId = new Map<string, OperationBinding>();
for (const op of ops) byId.set(op.toolId, op.binding);
return [scope, byId] as const;
const source = yield* ctx.storage.getSource(sourceId, scope);
return [scope, { bindings: byId, policy: source?.annotationPolicy }] as const;
}),
{ concurrency: "unbounded" },
);
const byScope = new Map<string, Map<string, OperationBinding>>(entries);
const byScope = new Map(entries);

const out: Record<string, ToolAnnotations> = {};
for (const row of toolRows as readonly ToolRow[]) {
const binding = byScope.get(row.scope_id)?.get(row.id);
if (binding) out[row.id] = annotationsFor(binding);
const entry = byScope.get(row.scope_id);
const binding = entry?.bindings.get(row.id);
if (binding) out[row.id] = annotationsFor(binding, entry?.policy);
}
return out;
}),
Expand Down
Loading
Loading