1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import { fileURLToPath } from "node:url";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const resolverHandlerPath = fileURLToPath(
15 new URL("./resolver-handler.ts", import.meta.url),
16);
17
18/**
19 * The unit-resolver code: forward the field name + args to the Lambda
20 * data source and surface the invocation result as the field value.
21 */
22const LAMBDA_RESOLVER_CODE = `
23export function request(ctx) {
24 return {
25 operation: "Invoke",
26 payload: { field: ctx.info.fieldName, args: ctx.args },
27 };
28}
29export function response(ctx) {
30 return ctx.result;
31}
32`;
33
34/** Pipeline step: same Lambda invocation, packaged as an AppSync Function. */
35const PIPELINE_STEP_CODE = LAMBDA_RESOLVER_CODE;
36
37/** Pipeline resolver: run the steps and return the last step's result. */
38const PIPELINE_RESOLVER_CODE = `
39export function request(ctx) {
40 return {};
41}
42export function response(ctx) {
43 return ctx.prev.result;
44}
45`;
46
47const SCHEMA = `
48type Query {
49 add(a: Int!, b: Int!): Int!
50 greet(name: String!): String!
51 double(n: Int!): Int!
52}
53schema { query: Query }
54`;
55
56/** Fresh AppSync endpoints/keys propagate within a few seconds. */
57const edgePropagationRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
58 effect.pipe(
59 Effect.retry({
60 schedule: Schedule.max([
61 Schedule.exponential(500).pipe(
62 Schedule.modifyDelay(({ duration }) =>
63 Effect.succeed(
64 Duration.isGreaterThan(duration, Duration.seconds(5))
65 ? Duration.seconds(5)
66 : duration,
67 ),
68 ),
69 ),
70 Schedule.recurs(10),
71 ]),
72 }),
73 );
74
75const graphql = (url: string, apiKey: string, query: string) =>
76 HttpClient.execute(
77 HttpClientRequest.post(url).pipe(
78 HttpClientRequest.setHeader("x-api-key", apiKey),
79 HttpClientRequest.bodyJsonUnsafe({ query }),
80 ),
81 );
82
83test.provider(
84 "GraphQL API over a Lambda data source (unit + pipeline resolvers, api key auth)",
85 (stack) =>
86 Effect.gen(function* () {
87 yield* stack.destroy();
88
89 const out = yield* stack.deploy(
90 Effect.gen(function* () {
91 const fn = yield* AWS.Lambda.Function("ResolverFn", {
92 main: resolverHandlerPath,
93 handler: "handler",
94 isExternal: true,
95 functionUrl: false,
96 });
97 const api = yield* AWS.AppSync.GraphqlApi("E2EApi", {
98 schema: SCHEMA,
99 });
100 const lambdaDS = yield* AWS.AppSync.DataSource("LambdaDS", {
101 api,
102 type: "AWS_LAMBDA",
103 lambdaConfig: { lambdaFunctionArn: fn.functionArn },
104 });
105 // UNIT resolvers with APPSYNC_JS runtime.
106 yield* AWS.AppSync.Resolver("AddResolver", {
107 api,
108 typeName: "Query",
109 fieldName: "add",
110 dataSource: lambdaDS,
111 code: LAMBDA_RESOLVER_CODE,
112 });
113 yield* AWS.AppSync.Resolver("GreetResolver", {
114 api,
115 typeName: "Query",
116 fieldName: "greet",
117 dataSource: lambdaDS,
118 code: LAMBDA_RESOLVER_CODE,
119 });
120 // PIPELINE resolver running one AppSync Function step.
121 const step = yield* AWS.AppSync.Function("InvokeStep", {
122 api,
123 dataSource: lambdaDS,
124 code: PIPELINE_STEP_CODE,
125 });
126 yield* AWS.AppSync.Resolver("DoubleResolver", {
127 api,
128 typeName: "Query",
129 fieldName: "double",
130 kind: "PIPELINE",
131 pipelineFunctionIds: [step.functionId],
132 code: PIPELINE_RESOLVER_CODE,
133 });
134 const key = yield* AWS.AppSync.ApiKey("Key", { api });
135 return {
136 apiId: api.apiId,
137 url: api.graphqlUrl,
138 apiKey: key.id,
139 };
140 }),
141 );
142
143 const apiKey = Redacted.value(out.apiKey);
144 expect(out.url).toContain("appsync-api");
145 expect(apiKey).toMatch(/^da2-/);
146
147 // 1. Unit resolver: the result is computed inside the Lambda.
148 const add = yield* edgePropagationRetry(
149 graphql(out.url, apiKey, "query { add(a: 2, b: 3) }").pipe(
150 Effect.flatMap((response) =>
151 response.status === 200
152 ? response.json
153 : Effect.fail(new Error(`add returned ${response.status}`)),
154 ),
155 Effect.flatMap((body: any) =>
156 body?.data?.add === undefined
157 ? Effect.fail(new Error(`no data.add: ${JSON.stringify(body)}`))
158 : Effect.succeed(body),
159 ),
160 ),
161 );
162 expect((add as any).data.add).toBe(5);
163
164 // 2. Unit resolver with a string result.
165 const greet = (yield* graphql(
166 out.url,
167 apiKey,
168 'query { greet(name: "Alchemy") }',
169 ).pipe(Effect.flatMap((response) => response.json))) as any;
170 expect(greet.data.greet).toBe("Hello, Alchemy! (from Lambda)");
171
172 // 3. Pipeline resolver: the step's Lambda result flows through
173 // ctx.prev.result.
174 const double = (yield* graphql(
175 out.url,
176 apiKey,
177 "query { double(n: 21) }",
178 ).pipe(Effect.flatMap((response) => response.json))) as any;
179 expect(double.data.double).toBe(42);
180
181 // 4. Requests without the api key are rejected.
182 const unauthorized = yield* HttpClient.execute(
183 HttpClientRequest.post(out.url).pipe(
184 HttpClientRequest.bodyJsonUnsafe({
185 query: "query { add(a: 1, b: 1) }",
186 }),
187 ),
188 );
189 expect(unauthorized.status).toBeGreaterThanOrEqual(401);
190
191 yield* stack.destroy();
192 }),
193 {
194 tags: [
195 "provider:aws",
196 "provider:aws:appsync",
197 "provider:aws:lambda",
198 "live",
199 ],
200 timeout: 600_000,
201 },
202);