1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 fromAttributes,
13 toAttributes,
14 userAttributeList,
15 type Attribute,
16} from "./ownership.ts";
17import {
18 lastSegment,
19 orgParent,
20 resolveOrgId,
21 sameJson,
22 sortedStrings,
23 toPhysicalId,
24} from "./operations.ts";
25
26const MAX_NAME_LENGTH = 255;
27
28export type ApiproductProps = {
29 /**
30 * Apigee organization id. Defaults to the GCP project id. Immutable.
31 */
32 organizationId?: string;
33 /**
34 * Internal API product id (`A-Z0-9._-$ %`). If omitted, a unique name
35 * is generated. Immutable.
36 */
37 apiproductId?: string;
38 /**
39 * Name shown in the UI and developer portal.
40 */
41 displayName?: string;
42 /**
43 * Human-readable description shown to portal developers.
44 */
45 description?: string;
46 /**
47 * Environments this product is bound to. Empty allows every
48 * environment.
49 */
50 environments?: string[];
51 /**
52 * API proxy names this product is bound to. Empty allows every proxy.
53 */
54 proxies?: string[];
55 /**
56 * API resource paths bundled in the product.
57 */
58 apiResources?: string[];
59 /**
60 * How API keys are approved: `auto` or `manual`.
61 */
62 approvalType?: string;
63 /**
64 * Request quota (number of messages per interval).
65 */
66 quota?: string;
67 /**
68 * Quota interval.
69 */
70 quotaInterval?: string;
71 /**
72 * Quota time unit (`minute`, `hour`, `day`, `month`).
73 */
74 quotaTimeUnit?: string;
75 /**
76 * OAuth scopes validated at runtime.
77 */
78 scopes?: string[];
79 /**
80 * Customer attributes (`public` / `private` / `internal` access, etc.).
81 * Alchemy ownership attributes are merged in automatically.
82 */
83 attributes?: Attribute[];
84 /**
85 * Parent Space resource id, if any.
86 */
87 space?: string;
88};
89
90export type Apiproduct = Resource<
91 "GCP.Apigee.Apiproduct",
92 ApiproductProps,
93 {
94 /** Full resource name `organizations/{org}/apiproducts/{product}`. */
95 name: string;
96 /** Internal API product id. */
97 apiproductId: string;
98 /** Organization id. */
99 organizationId: string;
100 /** Project id. */
101 project: string;
102 /** Display name. */
103 displayName: string | undefined;
104 /** Human-readable description. */
105 description: string | undefined;
106 /** Bound environments. */
107 environments: string[];
108 /** Bound API proxies. */
109 proxies: string[];
110 /** Bundled API resources. */
111 apiResources: string[];
112 /** Key approval type. */
113 approvalType: string | undefined;
114 /** Quota limit. */
115 quota: string | undefined;
116 /** Quota interval. */
117 quotaInterval: string | undefined;
118 /** Quota time unit. */
119 quotaTimeUnit: string | undefined;
120 /** OAuth scopes. */
121 scopes: string[];
122 /** User attributes (Alchemy ownership attributes stripped). */
123 attributes: Attribute[];
124 /** Parent Space id, if any. */
125 space: string | undefined;
126 /** Creation time in milliseconds since epoch. */
127 createdAt: string | undefined;
128 /** Last modification time in milliseconds since epoch. */
129 lastModifiedAt: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * An Apigee API product — a bundle of proxies, resources, quota, and
137 * metadata delivered to developers.
138 *
139 * Products have no labels field. Alchemy stamps ownership into
140 * attributes (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) so
141 * `list` / nuke can find them. The internal name
142 * is immutable.
143 *
144 * ### Creating an API Product
145 * **Example:** Generated name, auto-approved
146 * ```typescript
147 * const product = yield* GCP.Apigee.Apiproduct("Public", {
148 * displayName: "Public APIs",
149 * approvalType: "auto",
150 * });
151 * ```
152 *
153 * **Example:** Bound to proxies and a quota
154 * ```typescript
155 * const product = yield* GCP.Apigee.Apiproduct("Orders", {
156 * displayName: "Orders",
157 * environments: ["prod"],
158 * proxies: ["orders-v1"],
159 * approvalType: "auto",
160 * quota: "1000",
161 * quotaInterval: "1",
162 * quotaTimeUnit: "hour",
163 * });
164 * ```
165 *
166 * @resource
167 * @category Apigee
168 */
169export const Apiproduct = Resource<Apiproduct>("GCP.Apigee.Apiproduct");
170
171export class ApiproductNotResolved extends Data.TaggedError(
172 "GCP.Apigee.ApiproductNotResolved",
173)<{
174 name: string;
175}> {}
176
177const resourceName = (organizationId: string, apiproductId: string) =>
178 `${orgParent(organizationId)}/apiproducts/${apiproductId}`;
179
180const toAttrs = (
181 product: apigee.GoogleCloudApigeeV1ApiProduct,
182 project: string,
183 organizationId: string,
184) => {
185 const apiproductId = lastSegment(product.name ?? "");
186 const attributes = userAttributeList(product.attributes);
187 return {
188 name: resourceName(organizationId, apiproductId),
189 apiproductId,
190 organizationId,
191 project,
192 displayName: product.displayName,
193 description: product.description,
194 environments: sortedStrings(product.environments),
195 proxies: sortedStrings(product.proxies),
196 apiResources: sortedStrings(product.apiResources),
197 approvalType: product.approvalType,
198 quota: product.quota,
199 quotaInterval: product.quotaInterval,
200 quotaTimeUnit: product.quotaTimeUnit,
201 scopes: sortedStrings(product.scopes),
202 attributes,
203 space: product.space,
204 createdAt: product.createdAt,
205 lastModifiedAt: product.lastModifiedAt,
206 };
207};
208
209const getByName = (name: string) =>
210 apigee
211 .getOrganizationsApiproducts({ name })
212 .pipe(
213 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
214 Effect.succeed(undefined),
215 ),
216 );
217
218const toBody = (
219 apiproductId: string,
220 props: ApiproductProps,
221 description: string | undefined,
222 attributes: apigee.GoogleCloudApigeeV1Attribute[],
223): apigee.GoogleCloudApigeeV1ApiProduct => ({
224 name: apiproductId,
225 displayName: props.displayName ?? apiproductId,
226 description,
227 environments: props.environments,
228 proxies: props.proxies,
229 apiResources: props.apiResources,
230 approvalType: props.approvalType,
231 quota: props.quota,
232 quotaInterval: props.quotaInterval,
233 quotaTimeUnit: props.quotaTimeUnit,
234 scopes: props.scopes,
235 attributes,
236 space: props.space,
237});
238
239export const ApiproductProvider = () =>
240 Provider.succeed(Apiproduct, {
241 stables: ["name", "apiproductId", "organizationId", "project", "createdAt"],
242
243 diff: Effect.fn(function* ({ news, olds, output }) {
244 if (!isResolved(news)) return undefined;
245 const previousId = olds?.apiproductId ?? output?.apiproductId;
246 const previousOrg = olds?.organizationId ?? output?.organizationId;
247 if (
248 (previousId !== undefined &&
249 news.apiproductId !== undefined &&
250 news.apiproductId !== previousId) ||
251 (previousOrg !== undefined &&
252 news.organizationId !== undefined &&
253 news.organizationId !== previousOrg)
254 ) {
255 return { action: "replace" as const, deleteFirst: false };
256 }
257 return undefined;
258 }),
259
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const env = yield* GcpEnvironment.current;
262 const organizationId =
263 olds?.organizationId ??
264 output?.organizationId ??
265 (yield* resolveOrgId(env.project));
266 const apiproductId = yield* toPhysicalId(
267 id,
268 olds?.apiproductId,
269 output?.apiproductId,
270 MAX_NAME_LENGTH,
271 );
272 const name = output?.name ?? resourceName(organizationId, apiproductId);
273 const existing = yield* getByName(name);
274 if (existing === undefined) return undefined;
275 const attrs = toAttrs(existing, env.project, organizationId);
276 const { labels } = fromAttributes(existing.attributes);
277 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
278 }),
279
280 list: () =>
281 Effect.gen(function* () {
282 const env = yield* GcpEnvironment.current;
283 const organizationId = yield* resolveOrgId(env.project);
284 const page = yield* apigee
285 .listOrganizationsApiproducts({
286 parent: orgParent(organizationId),
287 expand: true,
288 count: "1000",
289 })
290 .pipe(
291 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
292 Effect.succeed({ apiProduct: [] }),
293 ),
294 );
295 return (page.apiProduct ?? [])
296 .filter(
297 (product) =>
298 fromAttributes(product.attributes).labels["alchemy-id"] !==
299 undefined,
300 )
301 .map((product) => toAttrs(product, env.project, organizationId));
302 }),
303
304 reconcile: Effect.fn(function* ({ id, news, output }) {
305 const env = yield* GcpEnvironment.current;
306 const organizationId =
307 news.organizationId ??
308 output?.organizationId ??
309 (yield* resolveOrgId(env.project));
310 const apiproductId = yield* toPhysicalId(
311 id,
312 news.apiproductId,
313 output?.apiproductId,
314 MAX_NAME_LENGTH,
315 );
316 const name = resourceName(organizationId, apiproductId);
317 const ownership = yield* createInternalLabels(id);
318 const desiredDescription = news.description;
319 const desiredAttributes = toAttributes(ownership, news.attributes);
320 const body = toBody(
321 apiproductId,
322 news,
323 desiredDescription,
324 desiredAttributes,
325 );
326
327 let current = yield* getByName(output?.name ?? name);
328
329 if (current === undefined) {
330 const created = yield* apigee
331 .createOrganizationsApiproducts({
332 parent: orgParent(organizationId),
333 body,
334 })
335 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
336 current = created ?? undefined;
337 }
338
339 if (current === undefined) {
340 return yield* new ApiproductNotResolved({ name });
341 }
342
343 const needsUpdate =
344 (current.displayName ?? "") !== (body.displayName ?? "") ||
345 (current.description ?? "") !== (desiredDescription ?? "") ||
346 (current.approvalType ?? "") !== (news.approvalType ?? "") ||
347 (current.quota ?? "") !== (news.quota ?? "") ||
348 (current.quotaInterval ?? "") !== (news.quotaInterval ?? "") ||
349 (current.quotaTimeUnit ?? "") !== (news.quotaTimeUnit ?? "") ||
350 (current.space ?? "") !== (news.space ?? "") ||
351 !sameJson(
352 sortedStrings(current.environments),
353 sortedStrings(news.environments),
354 ) ||
355 !sameJson(
356 sortedStrings(current.proxies),
357 sortedStrings(news.proxies),
358 ) ||
359 !sameJson(
360 sortedStrings(current.apiResources),
361 sortedStrings(news.apiResources),
362 ) ||
363 !sameJson(sortedStrings(current.scopes), sortedStrings(news.scopes)) ||
364 !sameJson(current.attributes ?? [], desiredAttributes);
365
366 if (needsUpdate) {
367 current = yield* apigee.updateOrganizationsApiproducts({
368 name,
369 body,
370 });
371 }
372
373 return toAttrs(current, env.project, organizationId);
374 }),
375
376 delete: Effect.fn(function* ({ output }) {
377 yield* apigee
378 .deleteOrganizationsApiproducts({ name: output.name })
379 .pipe(
380 Effect.catchTag(
381 ["NotFound", "ApigeeResourceNotFound"],
382 () => Effect.void,
383 ),
384 );
385 }),
386 });