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 { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import { orgIdOf, orgParent } from "./ownership.ts";
12
13const MAX_NAME_LENGTH = 255;
14
15export type SitesApicategoryProps = {
16 /**
17 * Portal site id (the `{site}` segment of
18 * `organizations/{org}/sites/{site}`). Required. Immutable — changing
19 * it replaces the category.
20 */
21 siteId: string;
22 /**
23 * Apigee organization id. Defaults to the stack GCP project. Immutable —
24 * changing it replaces the category.
25 */
26 organization?: string;
27 /**
28 * Server-assigned category UUID. Set when adopting an existing category.
29 * Immutable — changing it replaces the category.
30 */
31 categoryId?: string;
32 /**
33 * User-facing category name shown on the portal. If omitted, a unique
34 * name is generated from the stack, stage, and logical id.
35 */
36 name?: string;
37};
38
39export type SitesApicategory = Resource<
40 "GCP.Apigee.SitesApicategory",
41 SitesApicategoryProps,
42 {
43 /** Full resource name `organizations/{org}/sites/{site}/apicategories/{id}`. */
44 name: string;
45 /** Server-assigned category UUID. */
46 categoryId: string;
47 /** Apigee organization id. */
48 organization: string;
49 /** Portal site id. */
50 siteId: string;
51 /** User-facing category name. */
52 categoryName: string | undefined;
53 /** Last-modified time in milliseconds since epoch. */
54 updateTime: string | undefined;
55 },
56 never,
57 Providers
58>;
59
60/**
61 * An API category on an Apigee integrated portal. Catalog items can be
62 * tagged with categories so portal users can browse by topic.
63 *
64 * API categories have no labels field and the name is shown to portal
65 * users, so Alchemy finds its category by name: `read` reports a
66 * category as owned only when it carries the generated name (an explicit
67 * `name` is reported as unowned — adopt it with `--adopt`), and nuke
68 * cannot discover categories. `siteId` and `organization` are identity —
69 * changing either replaces the category. The display name updates in
70 * place.
71 *
72 * ### Creating an API Category
73 * **Example:** Named category
74 * ```typescript
75 * const category = yield* GCP.Apigee.SitesApicategory("Payments", {
76 * siteId: portal.siteId,
77 * name: "Payments",
78 * });
79 * ```
80 *
81 * ### Updating an API Category
82 * **Example:** Rename
83 * ```typescript
84 * const category = yield* GCP.Apigee.SitesApicategory("Payments", {
85 * siteId: portal.siteId,
86 * name: "Billing",
87 * });
88 * ```
89 *
90 * @resource
91 * @category Apigee
92 */
93export const SitesApicategory = Resource<SitesApicategory>(
94 "GCP.Apigee.SitesApicategory",
95);
96
97export class SitesApicategoryNotResolved extends Data.TaggedError(
98 "GCP.Apigee.SitesApicategoryNotResolved",
99)<{
100 name: string;
101}> {}
102
103const siteParent = (org: string, siteId: string) =>
104 `${orgParent(org)}/sites/${siteId}`;
105
106const resourceName = (org: string, siteId: string, categoryId: string) =>
107 `${siteParent(org, siteId)}/apicategories/${categoryId}`;
108
109const toDisplayName = (
110 id: string,
111 name: string | undefined,
112 existing?: string,
113) =>
114 Effect.gen(function* () {
115 if (name !== undefined) return name;
116 if (existing !== undefined) return existing;
117 return yield* createPhysicalName({
118 id,
119 maxLength: MAX_NAME_LENGTH,
120 lowercase: true,
121 });
122 });
123
124const unwrap = (
125 response: apigee.GoogleCloudApigeeV1ApiCategoryResponse | undefined,
126): apigee.GoogleCloudApigeeV1ApiCategory | undefined => response?.data;
127
128const toAttrs = (
129 category: apigee.GoogleCloudApigeeV1ApiCategory,
130 org: string,
131 siteId: string,
132) => {
133 const categoryId = category.id ?? "";
134 return {
135 name: categoryId ? resourceName(org, siteId, categoryId) : "",
136 categoryId,
137 organization: org,
138 siteId: category.siteId ?? siteId,
139 categoryName: category.name,
140 updateTime: category.updateTime,
141 };
142};
143
144const getByName = (name: string) =>
145 apigee.getOrganizationsSitesApicategories({ name }).pipe(
146 Effect.map((response) => unwrap(response)),
147 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
148 Effect.succeed(undefined),
149 ),
150 );
151
152const listBySite = (org: string, siteId: string) =>
153 apigee
154 .listOrganizationsSitesApicategories({
155 parent: siteParent(org, siteId),
156 })
157 .pipe(
158 Effect.map((page) => page.data ?? []),
159 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
160 Effect.succeed([]),
161 ),
162 );
163
164const findByDisplayName = (org: string, siteId: string, displayName: string) =>
165 listBySite(org, siteId).pipe(
166 Effect.map((categories) =>
167 categories.find((category) => category.name === displayName),
168 ),
169 );
170
171export const SitesApicategoryProvider = () =>
172 Provider.succeed(SitesApicategory, {
173 stables: ["name", "categoryId", "organization", "siteId"],
174
175 diff: Effect.fn(function* ({ news, olds, output }) {
176 if (!isResolved(news)) return undefined;
177 const previousSite = olds?.siteId ?? output?.siteId;
178 const siteChanged =
179 previousSite !== undefined && news.siteId !== previousSite;
180 const previousOrg = olds?.organization ?? output?.organization;
181 const orgChanged =
182 previousOrg !== undefined &&
183 news.organization !== undefined &&
184 orgIdOf(news.organization, previousOrg) !== previousOrg;
185 const previousId = olds?.categoryId ?? output?.categoryId;
186 const idChanged =
187 previousId !== undefined &&
188 news.categoryId !== undefined &&
189 news.categoryId !== previousId;
190 if (siteChanged || orgChanged || idChanged) {
191 return { action: "replace" as const, deleteFirst: false };
192 }
193 return undefined;
194 }),
195
196 read: Effect.fn(function* ({ id, olds, output }) {
197 const env = yield* GcpEnvironment.current;
198 const org = orgIdOf(
199 olds?.organization ?? output?.organization,
200 env.project,
201 );
202 const siteId = olds?.siteId ?? output?.siteId;
203 if (siteId === undefined) return undefined;
204 const categoryId = olds?.categoryId ?? output?.categoryId;
205 const name =
206 output?.name ??
207 (categoryId !== undefined
208 ? resourceName(org, siteId, categoryId)
209 : undefined);
210 const generated = yield* toDisplayName(id, undefined);
211 const displayName = olds?.name ?? output?.categoryName ?? generated;
212 const existing =
213 name !== undefined
214 ? yield* getByName(name)
215 : yield* findByDisplayName(org, siteId, displayName);
216 if (existing === undefined) return undefined;
217 const attrs = toAttrs(existing, org, siteId);
218 return existing.name === generated ? attrs : Unowned(attrs);
219 }),
220
221 reconcile: Effect.fn(function* ({ id, news, output }) {
222 const env = yield* GcpEnvironment.current;
223 const org = orgIdOf(
224 news.organization ?? output?.organization,
225 env.project,
226 );
227 const siteId = news.siteId;
228 const displayName = yield* toDisplayName(
229 id,
230 news.name,
231 output?.categoryName,
232 );
233 const desiredName = displayName;
234
235 let current =
236 output?.name !== undefined
237 ? yield* getByName(output.name)
238 : news.categoryId !== undefined
239 ? yield* getByName(resourceName(org, siteId, news.categoryId))
240 : yield* findByDisplayName(org, siteId, desiredName);
241
242 if (current === undefined) {
243 const created = yield* apigee
244 .createOrganizationsSitesApicategories({
245 parent: siteParent(org, siteId),
246 body: {
247 name: desiredName,
248 siteId,
249 },
250 })
251 .pipe(
252 Effect.map((response) => unwrap(response)),
253 Effect.catchTag("Conflict", () =>
254 findByDisplayName(org, siteId, desiredName),
255 ),
256 );
257 current = created ?? undefined;
258 }
259
260 if (current === undefined || !(current.id ?? "").length) {
261 return yield* new SitesApicategoryNotResolved({
262 name: siteParent(org, siteId),
263 });
264 }
265
266 const resource = resourceName(org, siteId, current.id ?? "");
267 if ((current.name ?? "") !== desiredName) {
268 const patched = yield* apigee.patchOrganizationsSitesApicategories({
269 name: resource,
270 body: {
271 name: desiredName,
272 siteId,
273 id: current.id,
274 },
275 });
276 current = unwrap(patched) ?? current;
277 }
278
279 return toAttrs(current, org, siteId);
280 }),
281
282 delete: Effect.fn(function* ({ output }) {
283 yield* apigee
284 .deleteOrganizationsSitesApicategories({ name: output.name })
285 .pipe(
286 Effect.catchTag(
287 ["NotFound", "ApigeeResourceNotFound"],
288 () => Effect.void,
289 ),
290 );
291 }),
292 });