import { Effect, Layer, Option } from "effect";import { HttpClient, HttpClientRequest } from "@effect/platform"; import { GraphqlInvocationError } from "./errors";import { type HeaderValue, type OperationBinding, InvocationResult,} from "./types"; // ---------------------------------------------------------------------------// Header resolution — resolves secret refs at invocation time// --------------------------------------------------------------------------- export const resolveHeaders = ( headers: Record<string, HeaderValue>, secrets: { readonly get: (id: string) => Effect.Effect<string | null, unknown> },): Effect.Effect<Record<string, string>> => Effect.gen(function* () { const entries = Object.entries(headers); // Resolve secret-backed headers in parallel. Missing / failing // lookups drop the header rather than fail the invocation, same // as the serial version. const values = yield* Effect.all( entries.map(([name, value]) => typeof value === "string" ? Effect.succeed<{ readonly name: string; readonly value: string | null }>({ name, value, }) : secrets.get(value.secretId).pipe( Effect.catchAll(() => Effect.succeed<string | null>(null)), Effect.map((secret) => ({ name, value: secret === null ? null : value.prefix ? `${value.prefix}${secret}` : secret, })), ), ), { concurrency: "unbounded" }, ); const resolved: Record<string, string> = {}; for (const { name, value } of values) { if (value !== null) resolved[name] = value; } return resolved; }); // ---------------------------------------------------------------------------// Response helpers// --------------------------------------------------------------------------- const isJsonContentType = (ct: string | null | undefined): boolean => { if (!ct) return false; const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? ""; return ( normalized === "application/json" || normalized.includes("+json") || normalized.includes("json") );}; // ---------------------------------------------------------------------------// Public API — execute a GraphQL operation// --------------------------------------------------------------------------- export const invoke = Effect.fn("GraphQL.invoke")(function* ( operation: OperationBinding, args: Record<string, unknown>, endpoint: string, resolvedHeaders: Record<string, string>,) { const client = yield* HttpClient.HttpClient; // Build the GraphQL request body const variables: Record<string, unknown> = {}; for (const varName of operation.variableNames) { if (args[varName] !== undefined) { variables[varName] = args[varName]; } } // Also pick up any variables from a "variables" container if (typeof args.variables === "object" && args.variables !== null) { Object.assign(variables, args.variables); } let request = HttpClientRequest.post(endpoint).pipe( HttpClientRequest.setHeader("Content-Type", "application/json"), HttpClientRequest.bodyUnsafeJson({ query: operation.operationString, variables: Object.keys(variables).length > 0 ? variables : undefined, }), ); for (const [name, value] of Object.entries(resolvedHeaders)) { request = HttpClientRequest.setHeader(request, name, value); } const response = yield* client.execute(request).pipe( Effect.mapError( (err) => new GraphqlInvocationError({ message: `GraphQL request failed: ${err.message}`, statusCode: Option.none(), cause: err, }), ), ); const status = response.status; const contentType = response.headers["content-type"] ?? null; const body: unknown = isJsonContentType(contentType) ? yield* response.json.pipe(Effect.catchAll(() => response.text)) : yield* response.text; // GraphQL responses are always 200 with { data, errors } const gqlBody = body as { data?: unknown; errors?: unknown[] } | null; const hasErrors = Array.isArray(gqlBody?.errors) && gqlBody.errors.length > 0; return new InvocationResult({ status, data: gqlBody?.data ?? null, errors: hasErrors ? gqlBody!.errors : null, });}); // ---------------------------------------------------------------------------// Invoke a GraphQL operation with a provided HttpClient layer// --------------------------------------------------------------------------- export const invokeWithLayer = ( operation: OperationBinding, args: Record<string, unknown>, endpoint: string, resolvedHeaders: Record<string, string>, httpClientLayer: Layer.Layer<HttpClient.HttpClient>,) => invoke(operation, args, endpoint, resolvedHeaders).pipe( Effect.provide(httpClientLayer), Effect.mapError((err) => err instanceof GraphqlInvocationError ? err : new GraphqlInvocationError({ message: err instanceof Error ? err.message : String(err), statusCode: Option.none(), cause: err, }), ), );
import { Effect, Layer, Option } from "effect";
import { HttpClient, HttpClientRequest } from "@effect/platform";
import { GraphqlInvocationError } from "./errors";
import {
type HeaderValue,
type OperationBinding,
InvocationResult,
} from "./types";
// ---------------------------------------------------------------------------
// Header resolution — resolves secret refs at invocation time
// ---------------------------------------------------------------------------
export const resolveHeaders = (
headers: Record<string, HeaderValue>,
secrets: { readonly get: (id: string) => Effect.Effect<string | null, unknown> },
): Effect.Effect<Record<string, string>> =>
Effect.gen(function* () {
const entries = Object.entries(headers);
// Resolve secret-backed headers in parallel. Missing / failing
// lookups drop the header rather than fail the invocation, same
// as the serial version.
const values = yield* Effect.all(
entries.map(([name, value]) =>
typeof value === "string"
? Effect.succeed<{ readonly name: string; readonly value: string | null }>({
name,
value,
})
: secrets.get(value.secretId).pipe(
Effect.catchAll(() => Effect.succeed<string | null>(null)),
Effect.map((secret) => ({
name,
value:
secret === null
? null
: value.prefix
? `${value.prefix}${secret}`
: secret,
})),
),
),
{ concurrency: "unbounded" },
);
const resolved: Record<string, string> = {};
for (const { name, value } of values) {
if (value !== null) resolved[name] = value;
}
return resolved;
});
// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------
const isJsonContentType = (ct: string | null | undefined): boolean => {
if (!ct) return false;
const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? "";
return (
normalized === "application/json" || normalized.includes("+json") || normalized.includes("json")
);
};
// ---------------------------------------------------------------------------
// Public API — execute a GraphQL operation
// ---------------------------------------------------------------------------
export const invoke = Effect.fn("GraphQL.invoke")(function* (
operation: OperationBinding,
args: Record<string, unknown>,
endpoint: string,
resolvedHeaders: Record<string, string>,
) {
const client = yield* HttpClient.HttpClient;
// Build the GraphQL request body
const variables: Record<string, unknown> = {};
for (const varName of operation.variableNames) {
if (args[varName] !== undefined) {
variables[varName] = args[varName];
}
}
// Also pick up any variables from a "variables" container
if (typeof args.variables === "object" && args.variables !== null) {
Object.assign(variables, args.variables);
}
let request = HttpClientRequest.post(endpoint).pipe(
HttpClientRequest.setHeader("Content-Type", "application/json"),
HttpClientRequest.bodyUnsafeJson({
query: operation.operationString,
variables: Object.keys(variables).length > 0 ? variables : undefined,
}),
);
for (const [name, value] of Object.entries(resolvedHeaders)) {
request = HttpClientRequest.setHeader(request, name, value);
}
const response = yield* client.execute(request).pipe(
Effect.mapError(
(err) =>
new GraphqlInvocationError({
message: `GraphQL request failed: ${err.message}`,
statusCode: Option.none(),
cause: err,
}),
),
);
const status = response.status;
const contentType = response.headers["content-type"] ?? null;
const body: unknown = isJsonContentType(contentType)
? yield* response.json.pipe(Effect.catchAll(() => response.text))
: yield* response.text;
// GraphQL responses are always 200 with { data, errors }
const gqlBody = body as { data?: unknown; errors?: unknown[] } | null;
const hasErrors = Array.isArray(gqlBody?.errors) && gqlBody.errors.length > 0;
return new InvocationResult({
status,
data: gqlBody?.data ?? null,
errors: hasErrors ? gqlBody!.errors : null,
});
});
// ---------------------------------------------------------------------------
// Invoke a GraphQL operation with a provided HttpClient layer
// ---------------------------------------------------------------------------
export const invokeWithLayer = (
operation: OperationBinding,
args: Record<string, unknown>,
endpoint: string,
resolvedHeaders: Record<string, string>,
httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
) =>
invoke(operation, args, endpoint, resolvedHeaders).pipe(
Effect.provide(httpClientLayer),
Effect.mapError((err) =>
err instanceof GraphqlInvocationError
? err
: new GraphqlInvocationError({
message: err instanceof Error ? err.message : String(err),
statusCode: Option.none(),
cause: err,
}),
),
);