1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as apigateway from "@distilled.cloud/gcp/apigateway_v1";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import { GcpEnvironment } from "@/GCP/Environment";
9import { waitForOperation } from "@/GCP/Operation";
10
11const { test } = Test.make({ providers: GCP.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// API create LRO ~2m14s and delete ~2m51s; config create is similarly
19// slow, so the lifecycle runs past 5 minutes.
20const runLifecycle = !!process.env.GCP_TEST_SLOW && !process.env.FAST;
21const parentOf = (project: string) => `projects/${project}/locations/global`;
22const parentApiId = "alch-apigw-cfg";
23const parentApiNameOf = (project: string) =>
24 `${parentOf(project)}/apis/${parentApiId}`;
25
26const openApi = `swagger: "2.0"
27info:
28 title: alchemy-apigateway-test
29 description: alchemy test
30 version: "1.0.0"
31schemes:
32 - https
33produces:
34 - application/json
35paths:
36 /hello:
37 get:
38 summary: hello
39 operationId: helloGet
40 x-google-backend:
41 address: https://httpbin.org/get
42 responses:
43 "200":
44 description: A successful response
45`;
46
47const waitUntilGone = (name: string) =>
48 apigateway.getProjectsLocationsApisConfigs({ name }).pipe(
49 Effect.as("found" as const),
50 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
51 Effect.repeat({
52 schedule: Schedule.spaced("2 seconds"),
53 until: (status) => status === "gone",
54 times: 10,
55 }),
56 );
57
58const getApi = (name: string) =>
59 apigateway
60 .getProjectsLocationsApis({ name })
61 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
62
63const waitApiActive = (name: string) =>
64 getApi(name).pipe(
65 Effect.repeat({
66 schedule: Schedule.spaced("3 seconds"),
67 until: (api) =>
68 api !== undefined && (api.state === "ACTIVE" || api.state === "FAILED"),
69 times: 10,
70 }),
71 );
72
73const waitOperation = (operation: apigateway.ApigatewayOperation) =>
74 waitForOperation(
75 operation,
76 (name) => apigateway.getProjectsLocationsOperations({ name }),
77 { budget: "10 minutes" },
78 );
79
80const ensureParentApi = Effect.gen(function* () {
81 const { project } = yield* GcpEnvironment.current;
82 const parent = parentOf(project);
83 const parentApiName = parentApiNameOf(project);
84 const existing = yield* getApi(parentApiName);
85 if (existing !== undefined) {
86 if (existing.state !== "ACTIVE") {
87 yield* waitApiActive(parentApiName);
88 }
89 return parentApiName;
90 }
91 const created = yield* apigateway
92 .createProjectsLocationsApis({
93 parent,
94 apiId: parentApiId,
95 body: { displayName: parentApiId },
96 })
97 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
98 if (created !== undefined) {
99 yield* waitOperation(created);
100 }
101 yield* waitApiActive(parentApiName);
102 return parentApiName;
103});
104
105const deleteParentApi = Effect.gen(function* () {
106 const { project } = yield* GcpEnvironment.current;
107 const parentApiName = parentApiNameOf(project);
108 const operation = yield* apigateway
109 .deleteProjectsLocationsApis({ name: parentApiName })
110 .pipe(
111 Effect.retry({
112 while: (error) => error._tag === "Conflict",
113 times: 8,
114 schedule: Schedule.spaced("2 seconds"),
115 }),
116 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
117 );
118 if (operation !== undefined) {
119 yield* waitOperation(operation);
120 }
121});
122
123test.provider(
124 "getProjectsLocationsApisConfigs on a missing config fails with a typed tag",
125 (stack) =>
126 Effect.gen(function* () {
127 const { project } = yield* GcpEnvironment.current;
128 const parent = parentOf(project);
129 yield* stack.destroy();
130
131 const error = yield* Effect.flip(
132 apigateway.getProjectsLocationsApisConfigs({
133 name: `${parent}/apis/missing/configs/alchemy-missing-config`,
134 }),
135 );
136 expect(error._tag).toEqual("NotFound");
137
138 yield* stack.destroy();
139 }).pipe(logLevel),
140 {
141 tags: ["provider:gcp", "provider:gcp:apigateway", "live"],
142 timeout: 90_000,
143 },
144);
145
146test.provider.skipIf(!runLifecycle)(
147 "create, update, and delete an API Gateway API config",
148 (stack) =>
149 Effect.gen(function* () {
150 yield* stack.destroy();
151
152 const api = yield* ensureParentApi;
153
154 const created = yield* stack.deploy(
155 Effect.gen(function* () {
156 return yield* GCP.ApiGateway.ApisConfig("V1", {
157 api,
158 displayName: "v1",
159 openapiDocuments: [
160 {
161 document: {
162 path: "openapi.yaml",
163 contents: openApi,
164 },
165 },
166 ],
167 labels: { env: "test" },
168 });
169 }),
170 );
171
172 expect(created.apiConfigId).toEqual(expect.any(String));
173 expect(created.api).toEqual(api);
174 expect(created.location).toEqual("global");
175 expect(created.displayName).toEqual("v1");
176 expect(created.labels).toMatchObject({ env: "test" });
177 expect(created.name).toEqual(`${api}/configs/${created.apiConfigId}`);
178
179 const fetched = yield* apigateway.getProjectsLocationsApisConfigs({
180 name: created.name,
181 view: "FULL",
182 });
183 // The API reports the project number in names; the resource keeps the
184 // project-id form it was addressed by.
185 expect(fetched.name).toEqual(
186 expect.stringMatching(
187 new RegExp(`/apis/[^/]+/configs/${created.apiConfigId}$`),
188 ),
189 );
190 expect(fetched.displayName).toEqual("v1");
191 expect(fetched.labels?.env).toEqual("test");
192 expect(fetched.labels?.["alchemy-id"]).toEqual(expect.any(String));
193
194 const updated = yield* stack.deploy(
195 Effect.gen(function* () {
196 return yield* GCP.ApiGateway.ApisConfig("V1", {
197 api,
198 apiConfigId: created.apiConfigId,
199 displayName: "v1-prod",
200 openapiDocuments: [
201 {
202 document: {
203 path: "openapi.yaml",
204 contents: openApi,
205 },
206 },
207 ],
208 labels: { env: "prod", team: "gateway" },
209 });
210 }),
211 );
212
213 expect(updated.name).toEqual(created.name);
214 expect(updated.displayName).toEqual("v1-prod");
215 expect(updated.labels).toMatchObject({ env: "prod", team: "gateway" });
216
217 const fetchedUpdate = yield* apigateway.getProjectsLocationsApisConfigs({
218 name: updated.name,
219 });
220 expect(fetchedUpdate.displayName).toEqual("v1-prod");
221 expect(fetchedUpdate.labels?.team).toEqual("gateway");
222
223 yield* stack.destroy();
224 const gone = yield* waitUntilGone(created.name);
225 expect(gone).toEqual("gone");
226 }).pipe(
227 logLevel,
228 Effect.ensuring(
229 stack.destroy().pipe(Effect.andThen(deleteParentApi), Effect.ignore),
230 ),
231 ),
232 {
233 tags: ["provider:gcp", "provider:gcp:apigateway", "live"],
234 timeout: 900_000,
235 retry: 0,
236 },
237);