1import * as apihub from "@distilled.cloud/gcp/apihub_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 ApihubNotResolved,
11 createOwnership,
12 encodeOwnership,
13 hasOwnershipMarker,
14 listApis,
15 locationParent,
16 normalizeLocation,
17 ownedByAlchemy,
18 parseOwnership,
19 parseResourceName,
20 replaceOnIdentity,
21 sameJson,
22 sameText,
23 toPhysicalId,
24 updateMaskOf,
25 type AttributeValues,
26 type AttributeValuesMap,
27 type Documentation,
28} from "./internal.ts";
29
30export type Owner = {
31 /** Owner display name. */
32 displayName?: string;
33 /** Owner email. Required by the API when `owner` is set. */
34 email?: string;
35};
36
37export type ApiProps = {
38 /**
39 * API id (the `{api}` segment of
40 * `projects/{project}/locations/{location}/apis/{api}`). If omitted, a
41 * unique id is generated. Immutable — changing it replaces the API.
42 */
43 apiId?: string;
44 /**
45 * Location (`us-central1`, …). Must match the ApiHub instance location.
46 * Immutable — changing it replaces the API.
47 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
48 */
49 location?: string;
50 /**
51 * Display name. Defaults to the API id.
52 */
53 displayName?: string;
54 /**
55 * Human-readable description. APIs have no labels field, so Alchemy
56 * stamps ownership into a `[alchemy …]` prefix and strips it from
57 * attributes.
58 */
59 description?: string;
60 /**
61 * Unique fingerprint for this API resource.
62 */
63 fingerprint?: string;
64 /**
65 * Owner of the API.
66 */
67 owner?: Owner;
68 /**
69 * External documentation.
70 */
71 documentation?: Documentation;
72 /**
73 * Selected version resource name
74 * `projects/{project}/locations/{location}/apis/{api}/versions/{version}`.
75 */
76 selectedVersion?: string;
77 /**
78 * User-defined attributes keyed by attribute resource name.
79 */
80 attributes?: AttributeValuesMap;
81 /** System attribute `system-target-user`. */
82 targetUser?: AttributeValues;
83 /** System attribute `system-business-unit`. */
84 businessUnit?: AttributeValues;
85 /** System attribute `system-maturity-level`. */
86 maturityLevel?: AttributeValues;
87 /** System attribute `system-team`. */
88 team?: AttributeValues;
89 /** System attribute `system-api-style`. */
90 apiStyle?: AttributeValues;
91 /** System attribute `system-api-requirements`. */
92 apiRequirements?: AttributeValues;
93 /** System attribute `system-api-functional-requirements`. */
94 apiFunctionalRequirements?: AttributeValues;
95 /** System attribute `system-api-technical-requirements`. */
96 apiTechnicalRequirements?: AttributeValues;
97};
98
99export type Api = Resource<
100 "GCP.ApiHub.Api",
101 ApiProps,
102 {
103 /** Full resource name. */
104 name: string;
105 /** API id (last path segment). */
106 apiId: string;
107 /** Project id. */
108 project: string;
109 /** Location id. */
110 location: string;
111 /** Display name. */
112 displayName: string | undefined;
113 /** User description with the Alchemy ownership prefix stripped. */
114 description: string | undefined;
115 /** Fingerprint. */
116 fingerprint: string | undefined;
117 /** Owner. */
118 owner: Owner | undefined;
119 /** Documentation. */
120 documentation: Documentation | undefined;
121 /** Selected version resource name. */
122 selectedVersion: string | undefined;
123 /** Version resource names. */
124 versions: string[];
125 /** User-defined attributes. */
126 attributes: AttributeValuesMap | undefined;
127 /** RFC3339 creation timestamp. */
128 createTime: string | undefined;
129 /** RFC3339 last-update timestamp. */
130 updateTime: string | undefined;
131 },
132 never,
133 Providers
134>;
135
136/**
137 * An API resource in API Hub. Versions, specs, and operations hang off it.
138 *
139 * Location and id are immutable. Display name, description, owner,
140 * documentation, fingerprint, selected version, and attributes update in
141 * place. APIs have no labels field — Alchemy stamps ownership into the
142 * description so `list` / nuke can find them.
143 *
144 * ### Creating an API
145 * **Example:** Generated id
146 * ```typescript
147 * const api = yield* GCP.ApiHub.Api("Pets", {
148 * displayName: "pets",
149 * description: "Pet store API",
150 * });
151 * ```
152 *
153 * **Example:** Named API with an owner
154 * ```typescript
155 * const api = yield* GCP.ApiHub.Api("Pets", {
156 * apiId: "pets",
157 * displayName: "pets",
158 * owner: { email: "apihub@example.com", displayName: "platform" },
159 * });
160 * ```
161 *
162 * @resource
163 * @category ApiHub
164 */
165export const Api = Resource<Api>("GCP.ApiHub.Api");
166
167const resourceName = (project: string, location: string, apiId: string) =>
168 `${locationParent(project, location)}/apis/${apiId}`;
169
170const toAttrs = (
171 api: apihub.GoogleCloudApihubV1Api,
172 project: string,
173 region: string,
174): Api["Attributes"] => {
175 const name = api.name ?? "";
176 const parsed = parseResourceName(name, "apis", region);
177 const description = parseOwnership(api.description).text;
178 return {
179 name,
180 apiId: parsed.id,
181 project: parsed.project || project,
182 location: parsed.location,
183 displayName: api.displayName,
184 description,
185 fingerprint: api.fingerprint,
186 owner:
187 api.owner === undefined
188 ? undefined
189 : {
190 displayName: api.owner.displayName,
191 email: api.owner.email,
192 },
193 documentation: api.documentation,
194 selectedVersion: api.selectedVersion,
195 versions: [...(api.versions ?? [])],
196 attributes: api.attributes,
197 createTime: api.createTime,
198 updateTime: api.updateTime,
199 };
200};
201
202const getByName = (name: string) =>
203 name.length === 0
204 ? Effect.succeed(undefined)
205 : apihub
206 .getProjectsLocationsApis({ name })
207 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
208
209const bodyOf = (
210 news: ApiProps,
211 displayName: string,
212 description: string,
213): apihub.GoogleCloudApihubV1Api => ({
214 displayName,
215 description,
216 fingerprint: news.fingerprint,
217 owner: news.owner,
218 documentation: news.documentation,
219 selectedVersion: news.selectedVersion,
220 attributes: news.attributes,
221 targetUser: news.targetUser,
222 businessUnit: news.businessUnit,
223 maturityLevel: news.maturityLevel,
224 team: news.team,
225 apiStyle: news.apiStyle,
226 apiRequirements: news.apiRequirements,
227 apiFunctionalRequirements: news.apiFunctionalRequirements,
228 apiTechnicalRequirements: news.apiTechnicalRequirements,
229});
230
231export const ApiProvider = () =>
232 Provider.succeed(Api, {
233 stables: ["name", "apiId", "project", "location", "createTime"],
234
235 diff: Effect.fn(function* ({ news, olds, output }) {
236 if (!isResolved(news)) return undefined;
237 const env = yield* GcpEnvironment.current;
238 return replaceOnIdentity({
239 previousId: olds?.apiId ?? output?.apiId,
240 nextId: news.apiId ?? olds?.apiId ?? output?.apiId,
241 previousLocation: normalizeLocation(
242 olds?.location ?? output?.location,
243 env.region,
244 ),
245 nextLocation: normalizeLocation(
246 news.location ?? olds?.location ?? output?.location,
247 env.region,
248 ),
249 });
250 }),
251
252 read: Effect.fn(function* ({ id, olds, output }) {
253 const env = yield* GcpEnvironment.current;
254 const location = normalizeLocation(
255 olds?.location ?? output?.location,
256 env.region,
257 );
258 const apiId = yield* toPhysicalId(id, olds?.apiId, output?.apiId);
259 const name = output?.name ?? resourceName(env.project, location, apiId);
260 const existing = yield* getByName(name);
261 if (existing === undefined) return undefined;
262 const attrs = toAttrs(existing, env.project, env.region);
263 return (yield* ownedByAlchemy(id, existing.description))
264 ? attrs
265 : Unowned(attrs);
266 }),
267
268 list: () =>
269 Effect.gen(function* () {
270 const env = yield* GcpEnvironment.current;
271 const items = yield* listApis(locationParent(env.project, env.region));
272 return items
273 .filter((item) => hasOwnershipMarker(item.description))
274 .map((item) => toAttrs(item, env.project, env.region));
275 }),
276
277 reconcile: Effect.fn(function* ({ id, news, output }) {
278 const env = yield* GcpEnvironment.current;
279 const location = normalizeLocation(
280 news.location ?? output?.location,
281 env.region,
282 );
283 const parent = locationParent(env.project, location);
284 const apiId = yield* toPhysicalId(id, news.apiId, output?.apiId);
285 const name = output?.name ?? resourceName(env.project, location, apiId);
286 const ownership = yield* createOwnership(id);
287 const description = encodeOwnership(ownership, news.description);
288 const displayName = news.displayName ?? apiId;
289 const desired = bodyOf(news, displayName, description);
290
291 let current = yield* getByName(name);
292
293 if (current === undefined) {
294 const created = yield* apihub
295 .createProjectsLocationsApis({
296 parent,
297 apiId,
298 body: desired,
299 })
300 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
301 current = created ?? undefined;
302 }
303
304 if (current === undefined) {
305 return yield* new ApihubNotResolved({ name });
306 }
307
308 const currentName = current.name ?? name;
309 const displayChanged = !sameText(current.displayName, displayName);
310 const descriptionChanged = !sameText(current.description, description);
311 const fingerprintChanged = !sameText(
312 current.fingerprint,
313 news.fingerprint,
314 );
315 const ownerChanged = !sameJson(current.owner, news.owner);
316 const docsChanged = !sameJson(current.documentation, news.documentation);
317 const selectedChanged = !sameText(
318 current.selectedVersion,
319 news.selectedVersion,
320 );
321 const attributesChanged = !sameJson(current.attributes, news.attributes);
322 const targetChanged = !sameJson(current.targetUser, news.targetUser);
323 const unitChanged = !sameJson(current.businessUnit, news.businessUnit);
324 const maturityChanged = !sameJson(
325 current.maturityLevel,
326 news.maturityLevel,
327 );
328 const teamChanged = !sameJson(current.team, news.team);
329 const styleChanged = !sameJson(current.apiStyle, news.apiStyle);
330 const requirementsChanged = !sameJson(
331 current.apiRequirements,
332 news.apiRequirements,
333 );
334 const functionalChanged = !sameJson(
335 current.apiFunctionalRequirements,
336 news.apiFunctionalRequirements,
337 );
338 const technicalChanged = !sameJson(
339 current.apiTechnicalRequirements,
340 news.apiTechnicalRequirements,
341 );
342
343 if (
344 displayChanged ||
345 descriptionChanged ||
346 fingerprintChanged ||
347 ownerChanged ||
348 docsChanged ||
349 selectedChanged ||
350 attributesChanged ||
351 targetChanged ||
352 unitChanged ||
353 maturityChanged ||
354 teamChanged ||
355 styleChanged ||
356 requirementsChanged ||
357 functionalChanged ||
358 technicalChanged
359 ) {
360 current = yield* apihub.patchProjectsLocationsApis({
361 name: currentName,
362 updateMask: updateMaskOf(
363 displayChanged ? "display_name" : undefined,
364 descriptionChanged ? "description" : undefined,
365 fingerprintChanged ? "fingerprint" : undefined,
366 ownerChanged ? "owner" : undefined,
367 docsChanged ? "documentation" : undefined,
368 selectedChanged ? "selected_version" : undefined,
369 attributesChanged ? "attributes" : undefined,
370 targetChanged ? "target_user" : undefined,
371 unitChanged ? "business_unit" : undefined,
372 maturityChanged ? "maturity_level" : undefined,
373 teamChanged ? "team" : undefined,
374 styleChanged ? "api_style" : undefined,
375 requirementsChanged ? "api_requirements" : undefined,
376 functionalChanged ? "api_functional_requirements" : undefined,
377 technicalChanged ? "api_technical_requirements" : undefined,
378 ),
379 body: { name: currentName, ...desired },
380 });
381 }
382
383 return toAttrs(current, env.project, env.region);
384 }),
385
386 delete: Effect.fn(function* ({ output }) {
387 yield* apihub
388 .deleteProjectsLocationsApis({ name: output.name, force: true })
389 .pipe(Effect.catchTag("NotFound", () => Effect.void));
390 }),
391 });