1import * as Credentials from "@distilled.cloud/aws/Credentials";
2import * as Region from "@distilled.cloud/aws/Region";
3import * as SigV4 from "@distilled.cloud/aws/SigV4";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Binding from "../../Binding.ts";
8import * as Output from "../../Output.ts";
9import { isBindingHost } from "../Lambda/Function.ts";
10import {
11 GraphQL,
12 GraphQLApiError,
13 type GraphQLClient,
14 type GraphQLRequest,
15 type GraphQLResult,
16} from "./GraphQL.ts";
17import type { GraphqlApi } from "./GraphqlApi.ts";
18
19/**
20 * HTTP implementation of the {@link GraphQL} binding. The AppSync data
21 * plane has no SDK operation — each request is a SigV4-signed POST
22 * (service `"appsync"`) to the API's `graphqlUrl`, made with the host
23 * Function's own credentials. Grants `appsync:GraphQL` on every field of
24 * the bound API (`{apiArn}/types/*/fields/*`).
25 */
26export const GraphQLHttp = Layer.effect(
27 GraphQL,
28 Effect.gen(function* () {
29 // Resolve the ambient distilled context once at layer construction —
30 // credentials themselves are re-read per request (SSO/STS rotate).
31 const services = yield* Effect.context<
32 Credentials.Credentials | Region.Region
33 >();
34
35 return Effect.fn(function* (api: GraphqlApi) {
36 // Outputs yield DEFERRED effects — resolving them here registers the
37 // attribute on the host environment; re-yield per invocation below.
38 const GraphqlUrl = yield* api.graphqlUrl;
39
40 if (!globalThis.__ALCHEMY_RUNTIME__) {
41 const host = yield* Binding.Host;
42 if (isBindingHost(host)) {
43 yield* host.bind`Allow(${host}, AWS.AppSync.GraphQL(${api}))`({
44 policyStatements: [
45 {
46 Effect: "Allow",
47 Action: ["appsync:GraphQL"],
48 Resource: [Output.interpolate`${api.apiArn}/types/*/fields/*`],
49 },
50 ],
51 });
52 }
53 }
54
55 const execute = Effect.fn(`AWS.AppSync.GraphQL(${api.LogicalId})`)(
56 function* (request: GraphQLRequest) {
57 const url = new URL(yield* GraphqlUrl);
58 const body = JSON.stringify({
59 query: request.query,
60 variables: request.variables,
61 operationName: request.operationName,
62 });
63
64 // Sign for the API's own region, parsed from its endpoint.
65 const { credentials, region } = yield* Effect.gen(function* () {
66 const credentials = yield* yield* Credentials.Credentials;
67 const region =
68 /\.appsync-api\.([a-z0-9-]+)\.amazonaws\.com$/.exec(
69 url.hostname,
70 )?.[1] ?? (yield* yield* Region.Region);
71 return { credentials, region };
72 }).pipe(Effect.provideContext(services));
73
74 const signed = yield* SigV4.sign({
75 method: "POST",
76 url: url.toString(),
77 headers: { "content-type": "application/json" },
78 body,
79 accessKeyId: Redacted.value(credentials.accessKeyId),
80 secretAccessKey: credentials.secretAccessKey,
81 sessionToken: credentials.sessionToken,
82 service: "appsync",
83 region,
84 allHeaders: true,
85 });
86
87 const toError = (status: number) => (cause: unknown) =>
88 new GraphQLApiError({
89 status,
90 body: cause instanceof Error ? cause.message : String(cause),
91 });
92
93 const response = yield* Effect.tryPromise({
94 try: () =>
95 fetch(signed.url, {
96 method: signed.method,
97 headers: signed.headers,
98 body,
99 }),
100 catch: toError(0),
101 });
102 const text = yield* Effect.tryPromise({
103 try: () => response.text(),
104 catch: toError(response.status),
105 });
106 if (response.status < 200 || response.status >= 300) {
107 return yield* Effect.fail(
108 new GraphQLApiError({ status: response.status, body: text }),
109 );
110 }
111 return yield* Effect.try({
112 try: () => JSON.parse(text) as GraphQLResult,
113 catch: toError(response.status),
114 });
115 },
116 );
117
118 return { execute } as GraphQLClient;
119 });
120 }),
121);