diff --git a/packages/plugins/google-discovery/src/sdk/invoke.ts b/packages/plugins/google-discovery/src/sdk/invoke.ts index fd52de59e..939648a11 100644 --- a/packages/plugins/google-discovery/src/sdk/invoke.ts +++ b/packages/plugins/google-discovery/src/sdk/invoke.ts @@ -1,12 +1,13 @@ -import { Effect, Layer, Option, Schema } from "effect"; +import { Effect, Layer, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; -import type { PluginCtx, StorageFailure } from "@executor-js/sdk/core"; +import { resolveSecretBackedMap, type PluginCtx, type StorageFailure } from "@executor-js/sdk/core"; import { GoogleDiscoveryInvocationError, GoogleDiscoveryOAuthError } from "./errors"; import type { GoogleDiscoveryStore } from "./binding-store"; import { GoogleDiscoveryInvocationResult, + type GoogleDiscoveryFetchCredentials, type GoogleDiscoveryParameter, type GoogleDiscoveryStoredSourceData, } from "./types"; @@ -59,7 +60,9 @@ const replacePathParameters = (input: { }): Effect.Effect => Effect.gen(function* () { let failure: GoogleDiscoveryInvocationError | undefined; - const resolved = input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { + const resolved = input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, rawName: string) => { + const allowReserved = rawName.startsWith("+"); + const name = allowReserved ? rawName.slice(1) : rawName; const parameter = input.parameters.find( (entry) => entry.location === "path" && entry.name === name, ); @@ -73,7 +76,7 @@ const replacePathParameters = (input: { } return ""; } - return encodeURIComponent(values[0]!); + return allowReserved ? encodeURI(values[0]!) : encodeURIComponent(values[0]!); }); if (failure) return yield* failure; return resolved; @@ -90,6 +93,68 @@ const isJsonContentType = (contentType: string | null | undefined): boolean => { ); }; +const resolveInvocationCredentials = ( + ctx: PluginCtx, + credentials: GoogleDiscoveryFetchCredentials | undefined, +): Effect.Effect< + { readonly headers: Record; readonly queryParams: Record }, + GoogleDiscoveryInvocationError | StorageFailure +> => + Effect.gen(function* () { + const headers = yield* resolveSecretBackedMap({ + values: credentials?.headers, + getSecret: ctx.secrets.get, + onMissing: (name) => + new GoogleDiscoveryInvocationError({ + message: `Secret not found for header "${name}"`, + statusCode: Option.none(), + }), + onError: (_error, name) => + new GoogleDiscoveryInvocationError({ + message: `Secret not found for header "${name}"`, + statusCode: Option.none(), + }), + }).pipe( + Effect.mapError((err) => + Predicate.isTagged("SecretOwnedByConnectionError")(err) + ? new GoogleDiscoveryInvocationError({ + message: "Secret resolution failed", + statusCode: Option.none(), + cause: err, + }) + : err, + ), + ); + const queryParams = yield* resolveSecretBackedMap({ + values: credentials?.queryParams, + getSecret: ctx.secrets.get, + onMissing: (name) => + new GoogleDiscoveryInvocationError({ + message: `Secret not found for query parameter "${name}"`, + statusCode: Option.none(), + }), + onError: (_error, name) => + new GoogleDiscoveryInvocationError({ + message: `Secret not found for query parameter "${name}"`, + statusCode: Option.none(), + }), + }).pipe( + Effect.mapError((err) => + Predicate.isTagged("SecretOwnedByConnectionError")(err) + ? new GoogleDiscoveryInvocationError({ + message: "Secret resolution failed", + statusCode: Option.none(), + cause: err, + }) + : err, + ), + ); + return { + headers: headers ?? {}, + queryParams: queryParams ?? {}, + }; + }); + // --------------------------------------------------------------------------- // HTTP request builder / executor // --------------------------------------------------------------------------- @@ -102,6 +167,8 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { source: GoogleDiscoveryStoredSourceData; args: Record; authorizationHeader?: string; + credentialHeaders?: Record; + credentialQueryParams?: Record; }) { const client = yield* HttpClient.HttpClient; @@ -112,6 +179,10 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { }); const requestUrl = new URL(resolvedPath.replace(/^\//, ""), resolveBaseUrl(input.source)); + for (const [name, value] of Object.entries(input.credentialQueryParams ?? {})) { + requestUrl.searchParams.append(name, value); + } + for (const parameter of input.parameters) { if (parameter.location === "path") continue; const values = stringValuesFromParameter(input.args[parameter.name], parameter.repeated); @@ -144,6 +215,10 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { ); } + for (const [name, value] of Object.entries(input.credentialHeaders ?? {})) { + request = HttpClientRequest.setHeader(request, name, value); + } + if (input.authorizationHeader) { request = HttpClientRequest.setHeader(request, "Authorization", input.authorizationHeader); } @@ -222,6 +297,7 @@ export const invokeGoogleDiscoveryTool = (input: { }); } const source = stored.config; + const credentials = yield* resolveInvocationCredentials(input.ctx, source.credentials); const authHeader = source.auth.kind === "oauth2" @@ -245,6 +321,8 @@ export const invokeGoogleDiscoveryTool = (input: { source, args: (input.args ?? {}) as Record, authorizationHeader: authHeader, + credentialHeaders: credentials.headers, + credentialQueryParams: credentials.queryParams, }).pipe(Effect.provide(layer)) as Effect.Effect< GoogleDiscoveryInvocationResult, GoogleDiscoveryInvocationError, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.test.ts b/packages/plugins/google-discovery/src/sdk/plugin.test.ts index 87a377d9c..bb031b0da 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.test.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.test.ts @@ -28,6 +28,8 @@ const DiscoveryFixtureJson = Schema.Record(Schema.String, Schema.Unknown); const fixtureJson = Schema.decodeUnknownSync(Schema.fromJsonString(DiscoveryFixtureJson))( fixtureText, ); +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null ? (value as Record) : {}; // --------------------------------------------------------------------------- // Test HTTP server — serves the discovery document and echoes API calls. @@ -64,16 +66,33 @@ const startServer = (): Promise => body, }); - if (url === "/$discovery/rest?version=v3") { + if (url.startsWith("/$discovery/rest?")) { const address = server.address(); if (!address || typeof address === "string") { response.statusCode = 500; response.end(); return; } + const resources = asRecord(fixtureJson.resources); + const files = asRecord(resources.files); + const methods = asRecord(files.methods); + const get = asRecord(methods.get); const dynamicFixture = JSON.stringify({ ...fixtureJson, rootUrl: `http://127.0.0.1:${address.port}/`, + resources: { + ...resources, + files: { + ...files, + methods: { + ...methods, + get: { + ...get, + path: "files/{+fileId}", + }, + }, + }, + }, }); response.statusCode = 200; response.setHeader("content-type", "application/json"); @@ -388,6 +407,14 @@ describe("Google Discovery plugin", () => { // A connection wraps the access token (+ optional refresh) and // the invoke path resolves via ctx.connections.accessToken. const connectionId = ConnectionId.make("google-discovery-oauth2-test"); + yield* executor.secrets.set( + SetSecretInput.make({ + id: SecretId.make("google-ads-developer-token"), + scope: "test-scope" as SetSecretInput["scope"], + name: "Google Ads Developer Token", + value: "developer-token-value", + }), + ); yield* executor.connections.create( CreateConnectionInput.make({ id: connectionId, @@ -422,6 +449,14 @@ describe("Google Discovery plugin", () => { clientSecretSecretId: null, scopes: ["https://www.googleapis.com/auth/drive.readonly"], }, + credentials: { + headers: { + "developer-token": { secretId: "google-ads-developer-token" }, + }, + queryParams: { + sourceCredential: "source-value", + }, + }, }); expect(result.toolCount).toBe(2); @@ -458,8 +493,10 @@ describe("Google Discovery plugin", () => { ); expect(apiRequest).toBeDefined(); expect(apiRequest!.headers.authorization).toBe("Bearer secret-token"); + expect(apiRequest!.headers["developer-token"]).toBe("developer-token-value"); expect(apiRequest!.url).toContain("fields=id%2Cname"); expect(apiRequest!.url).toContain("prettyPrint=true"); + expect(apiRequest!.url).toContain("sourceCredential=source-value"); }), );