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 listAttributes,
15 locationParent,
16 normalizeLocation,
17 ownedByAlchemy,
18 parseOwnership,
19 parseResourceName,
20 replaceOnIdentity,
21 sameJson,
22 sameText,
23 toPhysicalId,
24 updateMaskOf,
25} from "./internal.ts";
26
27export type AllowedValue = {
28 /** Allowed value id. Generated from the display name when omitted. */
29 id?: string;
30 /** Display name. Required. */
31 displayName: string;
32 /** Description of the allowed value. */
33 description?: string;
34 /** When true, the value cannot be updated or deleted. */
35 immutable?: boolean;
36};
37
38export type AttributeProps = {
39 /**
40 * Attribute id (the `{attribute}` segment of
41 * `projects/{project}/locations/{location}/attributes/{attribute}`). If
42 * omitted, a unique id is generated. Immutable — changing it replaces
43 * the attribute.
44 */
45 attributeId?: string;
46 /**
47 * Location (`us-central1`, …). Immutable — changing it replaces the
48 * attribute.
49 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
50 */
51 location?: string;
52 /**
53 * Display name. Required.
54 */
55 displayName: string;
56 /**
57 * Human-readable description. Attributes have no labels field, so
58 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
59 * from attributes.
60 */
61 description?: string;
62 /**
63 * Data type. Immutable — changing it replaces the attribute.
64 */
65 dataType:
66 | "DATA_TYPE_UNSPECIFIED"
67 | "ENUM"
68 | "JSON"
69 | "STRING"
70 | "URI"
71 | (string & {});
72 /**
73 * Resource this attribute can attach to. Immutable — changing it
74 * replaces the attribute.
75 */
76 scope:
77 | "SCOPE_UNSPECIFIED"
78 | "API"
79 | "VERSION"
80 | "SPEC"
81 | "API_OPERATION"
82 | "DEPLOYMENT"
83 | "DEPENDENCY"
84 | "DEFINITION"
85 | "EXTERNAL_API"
86 | "PLUGIN"
87 | (string & {});
88 /**
89 * Maximum number of values when associated with a resource (1-20).
90 * Cardinality can only be increased.
91 * @default 1
92 */
93 cardinality?: number;
94 /**
95 * Allowed values when `dataType` is `ENUM`.
96 */
97 allowedValues?: AllowedValue[];
98};
99
100export type Attribute = Resource<
101 "GCP.ApiHub.Attribute",
102 AttributeProps,
103 {
104 /** Full resource name. */
105 name: string;
106 /** Attribute id (last path segment). */
107 attributeId: string;
108 /** Project id. */
109 project: string;
110 /** Location id. */
111 location: string;
112 /** Display name. */
113 displayName: string | undefined;
114 /** User description with the Alchemy ownership prefix stripped. */
115 description: string | undefined;
116 /** Data type. */
117 dataType: string | undefined;
118 /** Scope. */
119 scope: string | undefined;
120 /** Cardinality. */
121 cardinality: number | undefined;
122 /** Allowed enum values. */
123 allowedValues: AllowedValue[];
124 /** Whether the attribute is mandatory. */
125 mandatory: boolean;
126 /** Definition type (`SYSTEM_DEFINED` or `USER_DEFINED`). */
127 definitionType: string | undefined;
128 /** RFC3339 creation timestamp. */
129 createTime: string | undefined;
130 /** RFC3339 last-update timestamp. */
131 updateTime: string | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A user-defined attribute in API Hub. System-defined attributes cannot
139 * be created or deleted through this resource.
140 *
141 * Location, id, data type, and scope are immutable. Display name,
142 * description, allowed values, and cardinality (increase only) update in
143 * place. Attributes have no labels field — Alchemy stamps ownership into
144 * the description.
145 *
146 * ### Creating an Attribute
147 * **Example:** String attribute on APIs
148 * ```typescript
149 * const attribute = yield* GCP.ApiHub.Attribute("OwnerTeam", {
150 * displayName: "owner-team",
151 * dataType: "STRING",
152 * scope: "API",
153 * });
154 * ```
155 *
156 * **Example:** Enum attribute
157 * ```typescript
158 * const attribute = yield* GCP.ApiHub.Attribute("Tier", {
159 * displayName: "tier",
160 * dataType: "ENUM",
161 * scope: "API",
162 * allowedValues: [
163 * { id: "gold", displayName: "gold" },
164 * { id: "silver", displayName: "silver" },
165 * ],
166 * });
167 * ```
168 *
169 * @resource
170 * @category ApiHub
171 */
172export const Attribute = Resource<Attribute>("GCP.ApiHub.Attribute");
173
174const resourceName = (project: string, location: string, attributeId: string) =>
175 `${locationParent(project, location)}/attributes/${attributeId}`;
176
177const toAllowed = (
178 values: apihub.GoogleCloudApihubV1AllowedValueList | undefined,
179): AllowedValue[] =>
180 (values ?? [])
181 .filter((value) => (value.displayName ?? "").length > 0)
182 .map((value) => ({
183 id: value.id,
184 displayName: value.displayName!,
185 description: value.description,
186 immutable: value.immutable,
187 }));
188
189const toAttrs = (
190 attribute: apihub.GoogleCloudApihubV1Attribute,
191 project: string,
192 region: string,
193) => {
194 const name = attribute.name ?? "";
195 const parsed = parseResourceName(name, "attributes", region);
196 return {
197 name,
198 attributeId: parsed.id,
199 project: parsed.project || project,
200 location: parsed.location,
201 displayName: attribute.displayName,
202 description: parseOwnership(attribute.description).text,
203 dataType: attribute.dataType,
204 scope: attribute.scope,
205 cardinality: attribute.cardinality,
206 allowedValues: toAllowed(attribute.allowedValues),
207 mandatory: attribute.mandatory === true,
208 definitionType: attribute.definitionType,
209 createTime: attribute.createTime,
210 updateTime: attribute.updateTime,
211 };
212};
213
214const getByName = (name: string) =>
215 name.length === 0
216 ? Effect.succeed(undefined)
217 : apihub
218 .getProjectsLocationsAttributes({ name })
219 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
220
221export const AttributeProvider = () =>
222 Provider.succeed(Attribute, {
223 stables: [
224 "name",
225 "attributeId",
226 "project",
227 "location",
228 "dataType",
229 "scope",
230 "createTime",
231 ],
232
233 diff: Effect.fn(function* ({ news, olds, output }) {
234 if (!isResolved(news)) return undefined;
235 const env = yield* GcpEnvironment.current;
236 return replaceOnIdentity({
237 previousId: olds?.attributeId ?? output?.attributeId,
238 nextId: news.attributeId ?? olds?.attributeId ?? output?.attributeId,
239 previousLocation: normalizeLocation(
240 olds?.location ?? output?.location,
241 env.region,
242 ),
243 nextLocation: normalizeLocation(
244 news.location ?? olds?.location ?? output?.location,
245 env.region,
246 ),
247 extra:
248 (news.dataType ?? olds?.dataType) !==
249 (olds?.dataType ?? output?.dataType) ||
250 (news.scope ?? olds?.scope) !== (olds?.scope ?? output?.scope),
251 });
252 }),
253
254 read: Effect.fn(function* ({ id, olds, output }) {
255 const env = yield* GcpEnvironment.current;
256 const location = normalizeLocation(
257 olds?.location ?? output?.location,
258 env.region,
259 );
260 const attributeId = yield* toPhysicalId(
261 id,
262 olds?.attributeId,
263 output?.attributeId,
264 );
265 const name =
266 output?.name ?? resourceName(env.project, location, attributeId);
267 const existing = yield* getByName(name);
268 if (existing === undefined) return undefined;
269 const attrs = toAttrs(existing, env.project, env.region);
270 return (yield* ownedByAlchemy(id, existing.description))
271 ? attrs
272 : Unowned(attrs);
273 }),
274
275 list: () =>
276 Effect.gen(function* () {
277 const env = yield* GcpEnvironment.current;
278 const items = yield* listAttributes(
279 locationParent(env.project, env.region),
280 );
281 return items
282 .filter((item) => hasOwnershipMarker(item.description))
283 .map((item) => toAttrs(item, env.project, env.region));
284 }),
285
286 reconcile: Effect.fn(function* ({ id, news, output }) {
287 const env = yield* GcpEnvironment.current;
288 const location = normalizeLocation(
289 news.location ?? output?.location,
290 env.region,
291 );
292 const parent = locationParent(env.project, location);
293 const attributeId = yield* toPhysicalId(
294 id,
295 news.attributeId,
296 output?.attributeId,
297 );
298 const name =
299 output?.name ?? resourceName(env.project, location, attributeId);
300 const ownership = yield* createOwnership(id);
301 const description = encodeOwnership(ownership, news.description);
302 const allowedValues = news.allowedValues;
303 const cardinality = news.cardinality;
304
305 let current = yield* getByName(name);
306
307 if (current === undefined) {
308 const created = yield* apihub
309 .createProjectsLocationsAttributes({
310 parent,
311 attributeId,
312 body: {
313 displayName: news.displayName,
314 description,
315 dataType: news.dataType,
316 scope: news.scope,
317 cardinality,
318 allowedValues,
319 },
320 })
321 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
322 current = created ?? undefined;
323 }
324
325 if (current === undefined) {
326 return yield* new ApihubNotResolved({ name });
327 }
328
329 const currentName = current.name ?? name;
330 const displayChanged = !sameText(current.displayName, news.displayName);
331 const descriptionChanged = !sameText(current.description, description);
332 const allowedChanged = !sameJson(
333 toAllowed(current.allowedValues),
334 allowedValues ?? [],
335 );
336 const cardinalityChanged =
337 cardinality !== undefined && (current.cardinality ?? 1) !== cardinality;
338
339 if (
340 displayChanged ||
341 descriptionChanged ||
342 allowedChanged ||
343 cardinalityChanged
344 ) {
345 current = yield* apihub.patchProjectsLocationsAttributes({
346 name: currentName,
347 updateMask: updateMaskOf(
348 displayChanged ? "display_name" : undefined,
349 descriptionChanged ? "description" : undefined,
350 allowedChanged ? "allowed_values" : undefined,
351 cardinalityChanged ? "cardinality" : undefined,
352 ),
353 body: {
354 name: currentName,
355 displayName: news.displayName,
356 description,
357 allowedValues,
358 cardinality,
359 },
360 });
361 }
362
363 return toAttrs(current, env.project, env.region);
364 }),
365
366 delete: Effect.fn(function* ({ output }) {
367 yield* apihub
368 .deleteProjectsLocationsAttributes({ name: output.name })
369 .pipe(Effect.catchTag("NotFound", () => Effect.void));
370 }),
371 });