1import * as apihub from "@distilled.cloud/gcp/apihub_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 {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12 stripInternalLabels,
13} from "../Labels.ts";
14import {
15 waitForOperation as waitForLongRunning,
16 type LongRunningOperation,
17} from "../Operation.ts";
18
19export type AttributeValues = apihub.GoogleCloudApihubV1AttributeValues;
20export type AttributeValuesMap = apihub.GoogleCloudApihubV1AttributeValuesMap;
21export type Documentation = apihub.GoogleCloudApihubV1Documentation;
22
23export const MAX_ID_LENGTH = 63;
24export const MAX_INSTANCE_ID_LENGTH = 40;
25export const MAX_DISPLAY_NAME_LENGTH = 64;
26
27export class ApihubNotResolved extends Data.TaggedError(
28 "GCP.ApiHub.ResourceNotResolved",
29)<{
30 name: string;
31}> {}
32
33export class ApihubStillExists extends Data.TaggedError(
34 "GCP.ApiHub.StillExists",
35)<{
36 name: string;
37}> {}
38
39export const lastSegment = (value: string) => {
40 const trimmed = value.replace(/\/+$/, "");
41 const parts = trimmed.split("/");
42 return parts[parts.length - 1] || trimmed;
43};
44
45export const locationOf = (name: string, fallback: string) => {
46 const parts = name.split("/");
47 const index = parts.indexOf("locations");
48 return index >= 0 ? (parts[index + 1] ?? fallback) : fallback;
49};
50
51export const parentOf = (name: string) => {
52 const parts = name.split("/").filter((part) => part.length > 0);
53 return parts.slice(0, -2).join("/");
54};
55
56export const locationParent = (project: string, location: string) =>
57 `projects/${project}/locations/${location}`;
58
59export const normalizeLocation = (
60 location: string | undefined,
61 defaultLocation: string,
62) => lastSegment(location ?? defaultLocation).toLowerCase();
63
64export const parseResourceName = (
65 name: string,
66 collection: string,
67 defaultLocation: string,
68) => {
69 const parts = name.split("/").filter((part) => part.length > 0);
70 const collectionAt = parts.lastIndexOf(collection);
71 const locationsAt = parts.lastIndexOf("locations");
72 const projectsAt = parts.lastIndexOf("projects");
73 const pluginsAt = parts.lastIndexOf("plugins");
74 return {
75 project:
76 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
77 location:
78 locationsAt >= 0 && parts[locationsAt + 1]
79 ? parts[locationsAt + 1]!
80 : defaultLocation,
81 id:
82 collectionAt >= 0 && parts[collectionAt + 1]
83 ? parts[collectionAt + 1]!
84 : lastSegment(name),
85 pluginId:
86 pluginsAt >= 0 && parts[pluginsAt + 1] ? parts[pluginsAt + 1]! : "",
87 plugin:
88 pluginsAt >= 0 ? parts.slice(0, pluginsAt + 2).join("/") : undefined,
89 parent:
90 collectionAt > 0
91 ? parts.slice(0, collectionAt).join("/")
92 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
93 };
94};
95
96export const expandParent = (
97 value: string,
98 project: string,
99 location: string,
100 collection: string,
101) => {
102 if (value.includes("/")) return value.replace(/\/+$/, "");
103 return `projects/${project}/locations/${location}/${collection}/${value}`;
104};
105
106export const rfc1035 = (name: string, maxLength = MAX_ID_LENGTH): string => {
107 let next = name
108 .toLowerCase()
109 .replace(/[^a-z0-9-]/g, "-")
110 .replace(/-+/g, "-")
111 .replace(/^-+|-+$/g, "");
112 if (!/^[a-z]/.test(next)) next = `a${next}`;
113 next = next.slice(0, maxLength).replace(/-+$/g, "");
114 if (next.length === 0) return "apihub";
115 if (next.length < 4) next = `${next}xxxx`.slice(0, maxLength);
116 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, maxLength - 1)}0`;
117 return next.slice(0, maxLength);
118};
119
120export const toPhysicalId = (
121 id: string,
122 explicit: string | undefined,
123 existing: string | undefined,
124 maxLength = MAX_ID_LENGTH,
125) =>
126 Effect.gen(function* () {
127 if (explicit !== undefined) return explicit;
128 if (existing !== undefined) return existing;
129 return rfc1035(
130 yield* createPhysicalName({ id, maxLength, lowercase: true }),
131 maxLength,
132 );
133 });
134
135export const userLabels = (
136 labels: Record<string, string | undefined> | null | undefined,
137): Record<string, string> => stripInternalLabels(tagRecord(labels));
138
139export const hasAlchemyLabelMap = (
140 labels: Record<string, string | undefined> | null | undefined,
141) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
142
143const markerOf = (labels: Record<string, string>) =>
144 `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
145
146const fitMarker = (labels: Record<string, string>, maxLength: number) => {
147 let stack = labels[alchemyLabelKeys.stack] ?? "x";
148 let stage = labels[alchemyLabelKeys.stage] ?? "x";
149 let id = labels[alchemyLabelKeys.id] ?? "x";
150 let marker = markerOf({
151 [alchemyLabelKeys.stack]: stack,
152 [alchemyLabelKeys.stage]: stage,
153 [alchemyLabelKeys.id]: id,
154 });
155 while (
156 marker.length > maxLength &&
157 (stack.length > 1 || stage.length > 1 || id.length > 1)
158 ) {
159 if (stack.length >= stage.length && stack.length >= id.length) {
160 stack = stack.slice(0, -1);
161 } else if (stage.length >= id.length) {
162 stage = stage.slice(0, -1);
163 } else {
164 id = id.slice(0, -1);
165 }
166 marker = markerOf({
167 [alchemyLabelKeys.stack]: stack,
168 [alchemyLabelKeys.stage]: stage,
169 [alchemyLabelKeys.id]: id,
170 });
171 }
172 return marker.slice(0, maxLength);
173};
174
175export const encodeOwnership = (
176 labels: Record<string, string>,
177 text: string | undefined,
178): string => {
179 const marker = fitMarker(labels, 8000);
180 const trimmed = text?.trim();
181 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
182};
183
184export const encodeOwnershipLine = (
185 labels: Record<string, string>,
186 text: string | undefined,
187 maxLength = MAX_DISPLAY_NAME_LENGTH,
188): string => {
189 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
190 if (!trimmed) return fitMarker(labels, maxLength);
191 const minMarker = 24;
192 const reserved = Math.min(
193 trimmed.length + 1,
194 Math.max(0, maxLength - minMarker),
195 );
196 const marker = fitMarker(labels, maxLength - reserved);
197 return `${marker} ${trimmed}`.slice(0, maxLength);
198};
199
200export const parseOwnership = (
201 text: string | undefined,
202): {
203 labels: Record<string, string>;
204 text: string | undefined;
205} => {
206 if (!text?.includes("[alchemy ")) {
207 return { labels: {}, text };
208 }
209 const start = text.indexOf("[alchemy ");
210 const end = text.indexOf("]", start);
211 if (end < 0) return { labels: {}, text };
212 const labels: Record<string, string> = {};
213 for (const part of text.slice(start + "[alchemy ".length, end).split(/\s+/)) {
214 const eq = part.indexOf("=");
215 if (eq > 0) {
216 labels[part.slice(0, eq)] = part.slice(eq + 1);
217 }
218 }
219 const before = text.slice(0, start).trim();
220 const after = text.slice(end + 1).replace(/^[\s\n]+/, "");
221 const rest = [before, after].filter((part) => part.length > 0).join("\n");
222 return { labels, text: rest.length > 0 ? rest : undefined };
223};
224
225export const hasOwnershipMarker = (text: string | undefined) =>
226 Object.keys(parseOwnership(text).labels).some((key) =>
227 key.startsWith("alchemy-"),
228 );
229
230const prefixMatch = (expected: string, observed: string) =>
231 expected === observed ||
232 expected.startsWith(observed) ||
233 observed.startsWith(expected);
234
235export const ownedByAlchemy = (id: string, text: string | undefined) =>
236 Effect.gen(function* () {
237 const expected = yield* createInternalLabels(id);
238 const { labels } = parseOwnership(text);
239 if (!hasOwnershipMarker(text)) return false;
240 const exact = yield* hasAlchemyLabels(id, labels);
241 if (exact) return true;
242 return (
243 prefixMatch(
244 expected[alchemyLabelKeys.stack] ?? "",
245 labels[alchemyLabelKeys.stack] ?? "",
246 ) &&
247 prefixMatch(
248 expected[alchemyLabelKeys.stage] ?? "",
249 labels[alchemyLabelKeys.stage] ?? "",
250 ) &&
251 prefixMatch(
252 expected[alchemyLabelKeys.id] ?? "",
253 labels[alchemyLabelKeys.id] ?? "",
254 )
255 );
256 });
257
258export const createOwnership = (id: string) => createInternalLabels(id);
259
260export const canonical = (value: unknown): unknown => {
261 if (value === undefined || value === null) return undefined;
262 if (typeof value === "boolean" || typeof value === "number") return value;
263 if (typeof value === "string") {
264 return value.length === 0 ? undefined : value;
265 }
266 if (Array.isArray(value)) {
267 const items = value.map(canonical).filter((item) => item !== undefined);
268 return items.length === 0 ? undefined : items;
269 }
270 if (typeof value === "object") {
271 const entries = Object.entries(value as Record<string, unknown>)
272 .map(([key, item]) => [key, canonical(item)] as const)
273 .filter(([, item]) => item !== undefined)
274 .sort(([left], [right]) => left.localeCompare(right));
275 if (entries.length === 0) return undefined;
276 return Object.fromEntries(entries);
277 }
278 return undefined;
279};
280
281export const fingerprint = (value: unknown): string =>
282 JSON.stringify(canonical(value) ?? null);
283
284export const sameJson = (left: unknown, right: unknown) =>
285 fingerprint(left) === fingerprint(right);
286
287export const sameText = (left: string | undefined, right: string | undefined) =>
288 (left ?? "") === (right ?? "");
289
290export const sameStringList = (
291 left: readonly string[] | undefined,
292 right: readonly string[] | undefined,
293) =>
294 JSON.stringify([...(left ?? [])].sort()) ===
295 JSON.stringify([...(right ?? [])].sort());
296
297export const updateMaskOf = (...fields: Array<string | undefined>) =>
298 fields.filter((field): field is string => field !== undefined).join(",");
299
300export const replaceOnIdentity = (input: {
301 previousId: string | undefined;
302 nextId: string | undefined;
303 previousLocation: string;
304 nextLocation: string;
305 extra?: boolean;
306 previousParent?: string;
307 nextParent?: string;
308}) => {
309 const parentChanged =
310 (input.previousParent ?? "") !== "" &&
311 (input.nextParent ?? "") !== "" &&
312 (input.previousParent ?? "") !== (input.nextParent ?? "");
313 const replace =
314 (input.extra ?? false) ||
315 parentChanged ||
316 (input.previousId !== undefined &&
317 input.nextId !== undefined &&
318 input.nextId !== input.previousId) ||
319 input.previousLocation !== input.nextLocation;
320 if (!replace) return undefined;
321 const samePhysical =
322 input.previousLocation === input.nextLocation &&
323 !parentChanged &&
324 input.previousId !== undefined &&
325 input.nextId === input.previousId;
326 return {
327 action: "replace" as const,
328 deleteFirst: samePhysical,
329 };
330};
331
332export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
333 get: Effect.Effect<A, E, R>,
334 name: string,
335) =>
336 get.pipe(
337 Effect.filterOrFail(
338 (value): value is Exclude<A, undefined> => value !== undefined,
339 () => new ApihubNotResolved({ name }),
340 ),
341 Effect.retry({
342 while: (error) => error._tag === "GCP.ApiHub.ResourceNotResolved",
343 times: 8,
344 schedule: Schedule.spaced("1 second"),
345 }),
346 );
347
348export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
349 get: Effect.Effect<A, E, R>,
350 name: string,
351) =>
352 get.pipe(
353 Effect.filterOrFail(
354 (value) => value === undefined,
355 () => new ApihubStillExists({ name }),
356 ),
357 Effect.retry({
358 while: (error) => error._tag === "GCP.ApiHub.StillExists",
359 times: 10,
360 schedule: Schedule.spaced("1 second"),
361 }),
362 Effect.asVoid,
363 );
364
365export const collectPages = <Page, Item, E, R>(
366 stream: Stream.Stream<Page, E, R>,
367 pick: (page: Page) => readonly Item[] | undefined,
368) =>
369 stream.pipe(
370 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
371 Stream.runCollect,
372 Effect.map((chunk) => Array.from(chunk)),
373 );
374
375export const emptyOnMissing = <A, E extends { readonly _tag: string }, R>(
376 effect: Effect.Effect<A, E, R>,
377 fallback: A,
378) =>
379 effect.pipe(
380 Effect.catchIf(
381 (error) => error._tag === "NotFound",
382 () => Effect.succeed(fallback),
383 ),
384 );
385
386export const listItems = <Page, Item, E extends { readonly _tag: string }, R>(
387 pages: Stream.Stream<Page, E, R>,
388 pick: (page: Page) => readonly Item[] | undefined,
389) => emptyOnMissing(collectPages(pages, pick), [] as Item[]);
390
391export const listApis = (parent: string) =>
392 listItems(
393 apihub.listProjectsLocationsApis.pages({ parent, pageSize: 1000 }),
394 (page) => page.apis,
395 );
396
397export const listVersions = (api: string) =>
398 listItems(
399 apihub.listProjectsLocationsApisVersions.pages({
400 parent: api,
401 pageSize: 1000,
402 }),
403 (page) => page.versions,
404 );
405
406export const listOperations = (version: string) =>
407 listItems(
408 apihub.listProjectsLocationsApisVersionsOperations.pages({
409 parent: version,
410 pageSize: 1000,
411 }),
412 (page) => page.apiOperations,
413 );
414
415export const listSpecs = (version: string) =>
416 listItems(
417 apihub.listProjectsLocationsApisVersionsSpecs.pages({
418 parent: version,
419 pageSize: 1000,
420 }),
421 (page) => page.specs,
422 );
423
424export const listAttributes = (parent: string) =>
425 listItems(
426 apihub.listProjectsLocationsAttributes.pages({ parent, pageSize: 1000 }),
427 (page) => page.attributes,
428 );
429
430export const listCurations = (parent: string) =>
431 listItems(
432 apihub.listProjectsLocationsCurations.pages({ parent, pageSize: 1000 }),
433 (page) => page.curations,
434 );
435
436export const listDependencies = (parent: string) =>
437 listItems(
438 apihub.listProjectsLocationsDependencies.pages({ parent, pageSize: 1000 }),
439 (page) => page.dependencies,
440 );
441
442export const listChildResources = <A, E, R>(
443 parents: readonly { name?: string }[],
444 list: (name: string) => Effect.Effect<A[], E, R>,
445) =>
446 Effect.forEach(
447 parents.filter((parent) => (parent.name ?? "").length > 0),
448 (parent) => list(parent.name!),
449 { concurrency: 4 },
450 ).pipe(Effect.map((groups) => groups.flat()));
451
452export const encodeContents = (contents: string) =>
453 Effect.sync(() => Buffer.from(contents, "utf8").toString("base64"));
454
455export const openApiSpecType = (
456 project: string,
457 location: string,
458): apihub.GoogleCloudApihubV1AttributeValues => ({
459 attribute: `projects/${project}/locations/${location}/attributes/system-spec-type`,
460 enumValues: {
461 values: [{ id: "openapi", displayName: "OpenAPI Spec" }],
462 },
463});
464
465export const ownershipLabels = createInternalLabels;
466export const parseName = parseResourceName;
467export const MAX_LONG_ID_LENGTH = 128;
468export const MAX_PLUGIN_DISPLAY_NAME_LENGTH = 50;
469export const MAX_PLUGIN_INSTANCE_DISPLAY_NAME_LENGTH = 255;
470
471export const DEFAULT_PLUGIN_ACTIONS: apihub.GoogleCloudApihubV1PluginActionConfigList =
472 [
473 {
474 id: "sync-metadata",
475 displayName: "Sync metadata",
476 description: "Sync API metadata into API hub",
477 triggerMode: "API_HUB_ON_DEMAND_TRIGGER",
478 },
479 ];
480
481export const DEFAULT_DEPLOYMENT_TYPE: apihub.GoogleCloudApihubV1AttributeValues =
482 {
483 enumValues: { values: [{ id: "apigee" }] },
484 };
485
486export const projectIdOf = (value: string | undefined, fallback = "") =>
487 lastSegment(value && value.length > 0 ? value : fallback);
488
489export const projectNameOf = (value: string | undefined, fallback = "") => {
490 const raw = value && value.length > 0 ? value : fallback;
491 return raw.startsWith("projects/") ? raw : `projects/${lastSegment(raw)}`;
492};
493
494export class ApihubInstanceFailed extends Data.TaggedError(
495 "GCP.ApiHub.InstanceFailed",
496)<{
497 name: string;
498 state?: string;
499 message?: string;
500}> {}
501
502/** API hub instance provisioning takes up to ~15 minutes. */
503const OPERATION_BUDGET = "20 minutes";
504
505/**
506 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
507 * concurrent create won the race; reconcile observes the resource next.
508 */
509export const waitForOperation = (operation: LongRunningOperation) =>
510 waitForLongRunning(
511 operation,
512 (name) => apihub.getProjectsLocationsOperations({ name }),
513 {
514 budget: OPERATION_BUDGET,
515 },
516 ).pipe(
517 Effect.catchIf(
518 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
519 () => Effect.succeed(operation),
520 ),
521 );
522
523/**
524 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
525 * means the resource is already gone.
526 */
527export const waitForDeleteOperation = (operation: LongRunningOperation) =>
528 waitForOperation(operation).pipe(
529 Effect.catchIf(
530 (error) =>
531 error._tag === "NotFound" ||
532 (error._tag === "GCP.OperationFailed" && error.code === 5),
533 () => Effect.succeed(operation),
534 ),
535 );