1import * as apigateway from "@distilled.cloud/gcp/apigateway_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { stripInternalLabels } from "../Labels.ts";
9import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
10
11export const DEFAULT_LOCATION = "global";
12export const MAX_NAME_LENGTH = 63;
13export const MIN_NAME_LENGTH = 4;
14
15export type ApiConfigFile = {
16 /** File path (for example `openapi.yaml`). */
17 path: string;
18 /**
19 * UTF-8 file contents. Alchemy base64-encodes them for the API Gateway
20 * wire format.
21 */
22 contents: string;
23};
24
25export type ApiConfigOpenApiDocument = {
26 /** OpenAPI Specification document file. */
27 document: ApiConfigFile;
28};
29
30export type ApiConfigGrpcServiceDefinition = {
31 /** Uncompiled proto sources associated with the descriptor set. */
32 source?: readonly ApiConfigFile[];
33 /** File descriptor set generated by protoc. */
34 fileDescriptorSet?: ApiConfigFile;
35};
36
37export class ResourceNotResolved extends Data.TaggedError(
38 "GCP.ApiGateway.NotResolved",
39)<{
40 name: string;
41}> {}
42
43export class ResourceStillExists extends Data.TaggedError(
44 "GCP.ApiGateway.StillExists",
45)<{
46 name: string;
47}> {}
48
49export class ResourceNotReady extends Data.TaggedError(
50 "GCP.ApiGateway.NotReady",
51)<{
52 name: string;
53 state: string;
54}> {}
55
56export class ResourceFailed extends Data.TaggedError("GCP.ApiGateway.Failed")<{
57 name: string;
58 state: string;
59}> {}
60
61export const lastSegment = (value: string) => {
62 const trimmed = value.replace(/\/+$/, "");
63 const parts = trimmed.split("/");
64 return parts[parts.length - 1] || trimmed;
65};
66
67export const rfc1035 = (name: string, fallback = "apigw"): string => {
68 let next = name
69 .toLowerCase()
70 .replace(/[^a-z0-9-]/g, "-")
71 .replace(/-+/g, "-")
72 .replace(/^-+|-+$/g, "");
73 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "a"}${next}`;
74 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
75 if (next.length === 0) return fallback;
76 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
77 if (next.length < MIN_NAME_LENGTH) {
78 next = `${next}${"x".repeat(MIN_NAME_LENGTH)}`.slice(0, MIN_NAME_LENGTH);
79 }
80 return next.slice(0, MAX_NAME_LENGTH);
81};
82
83export const locationParent = (project: string) =>
84 `projects/${project}/locations/${DEFAULT_LOCATION}`;
85
86export const expandApi = (value: string, project: string) => {
87 const trimmed = value.replace(/\/+$/, "");
88 if (trimmed.includes("/apis/")) return trimmed;
89 return `${locationParent(project)}/apis/${trimmed}`;
90};
91
92export const resourceName = (api: string, apiConfigId: string) =>
93 `${api}/configs/${apiConfigId}`;
94
95export const toPhysicalId = (
96 id: string,
97 explicit: string | undefined,
98 existing: string | undefined,
99 fallback = "apigw",
100) =>
101 Effect.gen(function* () {
102 if (explicit !== undefined) return explicit;
103 if (existing !== undefined) return existing;
104 return rfc1035(
105 yield* createPhysicalName({
106 id,
107 maxLength: MAX_NAME_LENGTH,
108 lowercase: true,
109 }),
110 fallback,
111 );
112 });
113
114export const parseName = (name: string, collection: string) => {
115 const parts = name.split("/").filter((part) => part.length > 0);
116 const collectionAt = parts.lastIndexOf(collection);
117 const locationsAt = parts.lastIndexOf("locations");
118 const projectsAt = parts.lastIndexOf("projects");
119 return {
120 project:
121 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
122 location:
123 locationsAt >= 0 && parts[locationsAt + 1]
124 ? parts[locationsAt + 1]!
125 : DEFAULT_LOCATION,
126 id:
127 collectionAt >= 0 && parts[collectionAt + 1]
128 ? parts[collectionAt + 1]!
129 : lastSegment(name),
130 parent:
131 collectionAt > 0
132 ? parts.slice(0, collectionAt).join("/")
133 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
134 };
135};
136
137export const userLabels = (
138 labels: Record<string, string | undefined> | null | undefined,
139): Record<string, string> => stripInternalLabels(tagRecord(labels));
140
141export const hasAlchemyLabelMap = (
142 labels: Record<string, string | undefined> | null | undefined,
143) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
144
145export const sameText = (left: string | undefined, right: string | undefined) =>
146 (left ?? "") === (right ?? "");
147
148export const canonical = (value: unknown): unknown => {
149 if (value === undefined || value === null) return undefined;
150 if (typeof value === "boolean" || typeof value === "number") return value;
151 if (typeof value === "string") {
152 return value.length === 0 ? undefined : value;
153 }
154 if (Array.isArray(value)) {
155 const items = value.map(canonical).filter((item) => item !== undefined);
156 return items.length === 0 ? undefined : items;
157 }
158 if (typeof value === "object") {
159 const entries = Object.entries(value as Record<string, unknown>)
160 .map(([key, item]) => [key, canonical(item)] as const)
161 .filter(([, item]) => item !== undefined)
162 .sort(([left], [right]) => left.localeCompare(right));
163 if (entries.length === 0) return undefined;
164 return Object.fromEntries(entries);
165 }
166 return undefined;
167};
168
169export const fingerprint = (value: unknown): string =>
170 JSON.stringify(canonical(value) ?? null);
171
172export const sameJson = (left: unknown, right: unknown) =>
173 fingerprint(left) === fingerprint(right);
174
175export const fieldMask = (fields: Array<string | false | undefined>) =>
176 fields
177 .filter((field): field is string => typeof field === "string")
178 .join(",");
179
180export const replaceOnIdentity = (input: {
181 previousId: string | undefined;
182 nextId: string | undefined;
183 previousParent?: string;
184 nextParent?: string;
185 extra?: boolean;
186}) => {
187 const parentChanged =
188 (input.previousParent ?? "") !== "" &&
189 (input.nextParent ?? "") !== "" &&
190 (input.previousParent ?? "") !== (input.nextParent ?? "");
191 const replace =
192 (input.extra ?? false) ||
193 parentChanged ||
194 (input.previousId !== undefined &&
195 input.nextId !== undefined &&
196 input.nextId !== input.previousId);
197 if (!replace) return undefined;
198 const samePhysical =
199 !parentChanged &&
200 input.previousId !== undefined &&
201 input.nextId === input.previousId;
202 return {
203 action: "replace" as const,
204 deleteFirst: samePhysical,
205 };
206};
207
208export const encodeContents = (contents: string) =>
209 Effect.sync(() => Buffer.from(contents, "utf8").toString("base64"));
210
211export const encodeFile = (file: ApiConfigFile) =>
212 Effect.gen(function* () {
213 return {
214 path: file.path,
215 contents: yield* encodeContents(file.contents),
216 };
217 });
218
219export const encodeOpenApiDocuments = (
220 documents: readonly ApiConfigOpenApiDocument[] | undefined,
221) =>
222 documents === undefined
223 ? Effect.succeed(undefined)
224 : Effect.forEach(documents, (document) =>
225 Effect.gen(function* () {
226 return { document: yield* encodeFile(document.document) };
227 }),
228 );
229
230export const encodeFiles = (files: readonly ApiConfigFile[] | undefined) =>
231 files === undefined
232 ? Effect.succeed(undefined)
233 : Effect.forEach(files, encodeFile);
234
235export const encodeGrpcServices = (
236 services: readonly ApiConfigGrpcServiceDefinition[] | undefined,
237) =>
238 services === undefined
239 ? Effect.succeed(undefined)
240 : Effect.forEach(services, (service) =>
241 Effect.gen(function* () {
242 return {
243 source: yield* encodeFiles(service.source),
244 fileDescriptorSet: service.fileDescriptorSet
245 ? yield* encodeFile(service.fileDescriptorSet)
246 : undefined,
247 };
248 }),
249 );
250
251/**
252 * Wait for an API Gateway long-running operation. API configs take several
253 * minutes to roll out. An operation that finished with `ALREADY_EXISTS` (6)
254 * is a lost create race; `notFoundOk` also accepts `NOT_FOUND` (5).
255 */
256export const waitForOperation = (
257 operation: apigateway.ApigatewayOperation,
258 options?: { notFoundOk?: boolean },
259) =>
260 waitForGcpOperation(
261 operation,
262 (name) =>
263 apigateway.getProjectsLocationsOperations({ name }).pipe(
264 Effect.catchTag("NotFound", (error) =>
265 options?.notFoundOk === true
266 ? Effect.succeed<apigateway.ApigatewayOperation>({
267 name,
268 done: true,
269 })
270 : Effect.fail(error),
271 ),
272 ),
273 { budget: "20 minutes" },
274 ).pipe(
275 Effect.catchIf(
276 (error) =>
277 error._tag === "GCP.OperationFailed" &&
278 (error.code === 6 ||
279 (options?.notFoundOk === true && error.code === 5)),
280 () => Effect.void,
281 ),
282 Effect.asVoid,
283 );
284
285export const waitUntilExists = <A, E, R>(
286 get: Effect.Effect<A | undefined, E, R>,
287 name: string,
288): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
289 get.pipe(
290 Effect.filterOrFail(
291 (value): value is NonNullable<A> => value != null,
292 () => new ResourceNotResolved({ name }),
293 ),
294 Effect.retry({
295 while: (error) => error instanceof ResourceNotResolved,
296 times: 8,
297 schedule: Schedule.spaced("1 second"),
298 }),
299 );
300
301export const waitUntilGone = <A, E, R>(
302 get: Effect.Effect<A | undefined, E, R>,
303 name: string,
304): Effect.Effect<void, E | ResourceStillExists, R> =>
305 get.pipe(
306 Effect.filterOrFail(
307 (value) => value === undefined,
308 () => new ResourceStillExists({ name }),
309 ),
310 Effect.retry({
311 while: (error) => error instanceof ResourceStillExists,
312 times: 10,
313 schedule: Schedule.spaced("3 seconds"),
314 }),
315 Effect.asVoid,
316 );
317
318const FAILED_STATES = new Set(["FAILED"]);
319const READY_STATES = new Set(["ACTIVE"]);
320
321export const waitUntilReady = <A, E, R>(
322 get: Effect.Effect<A | undefined, E, R>,
323 name: string,
324 stateOf: (value: NonNullable<A>) => string | undefined,
325 options?: {
326 times?: number;
327 interval?: `${number} seconds`;
328 },
329): Effect.Effect<
330 NonNullable<A>,
331 E | ResourceNotResolved | ResourceFailed | ResourceNotReady,
332 R
333> =>
334 get.pipe(
335 Effect.filterOrFail(
336 (value): value is NonNullable<A> => value != null,
337 () => new ResourceNotResolved({ name }),
338 ),
339 Effect.filterOrFail(
340 (value) => !FAILED_STATES.has((stateOf(value) ?? "").toUpperCase()),
341 (value) =>
342 new ResourceFailed({
343 name,
344 state: (stateOf(value) ?? "").toUpperCase(),
345 }),
346 ),
347 Effect.filterOrFail(
348 (value) => READY_STATES.has((stateOf(value) ?? "").toUpperCase()),
349 (value) =>
350 new ResourceNotReady({
351 name,
352 state: (stateOf(value) ?? "").toUpperCase(),
353 }),
354 ),
355 Effect.retry({
356 while: (error) =>
357 error instanceof ResourceNotReady ||
358 error instanceof ResourceNotResolved,
359 times: options?.times ?? 10,
360 schedule: Schedule.spaced(options?.interval ?? "8 seconds"),
361 }),
362 );
363
364const emptyList = <A>() => Effect.succeed<A[]>([]);
365
366export const collectPages = <Page, Item, E, R>(
367 pages: Stream.Stream<Page, E, R>,
368 items: (page: Page) => readonly Item[] | null | undefined,
369) =>
370 pages.pipe(
371 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
372 Stream.runCollect,
373 Effect.map((chunk) => Array.from(chunk)),
374 );
375
376export const listApis = (parent: string) =>
377 parent.length === 0
378 ? emptyList<apigateway.ApigatewayApi>()
379 : collectPages(
380 apigateway.listProjectsLocationsApis.pages({
381 parent,
382 pageSize: 1000,
383 }),
384 (page) => page.apis,
385 ).pipe(
386 Effect.catchTag("NotFound", () =>
387 emptyList<apigateway.ApigatewayApi>(),
388 ),
389 );
390
391export const listConfigs = (parent: string) =>
392 parent.length === 0
393 ? emptyList<apigateway.ApigatewayApiConfig>()
394 : collectPages(
395 apigateway.listProjectsLocationsApisConfigs.pages({
396 parent,
397 pageSize: 1000,
398 }),
399 (page) => page.apiConfigs,
400 ).pipe(
401 Effect.catchTag("NotFound", () =>
402 emptyList<apigateway.ApigatewayApiConfig>(),
403 ),
404 );
405
406export const listChildResources = <A, E, R>(
407 parents: readonly { name?: string }[],
408 list: (name: string) => Effect.Effect<A[], E, R>,
409) =>
410 Effect.forEach(
411 parents.filter((parent) => (parent.name ?? "").length > 0),
412 (parent) => list(parent.name!),
413 { concurrency: 4 },
414 ).pipe(Effect.map((groups) => groups.flat()));