1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { orgIdOf, orgParent } from "./ownership.ts";
13
14const MAX_TITLE_LENGTH = 255;
15
16export type SitesApidocProps = {
17 /**
18 * Portal site id (the `{site}` segment of
19 * `organizations/{org}/sites/{site}`). Required. Immutable — changing
20 * it replaces the catalog item.
21 */
22 siteId: string;
23 /**
24 * Name of the backing API product. Required and immutable — a portal
25 * may have only one catalog item per API product. Changing it replaces
26 * the catalog item.
27 */
28 apiProductName: string;
29 /**
30 * Apigee organization id. Defaults to the stack GCP project. Immutable —
31 * changing it replaces the catalog item.
32 */
33 organization?: string;
34 /**
35 * Server-assigned catalog item id. Set when adopting an existing item.
36 * Immutable — changing it replaces the catalog item.
37 */
38 apiDocId?: string;
39 /**
40 * User-facing catalog title. Max 255 characters. If omitted, a unique
41 * title is generated from the stack, stage, and logical id.
42 */
43 title?: string;
44 /**
45 * Catalog item description shown on the portal (max 10,000
46 * characters).
47 */
48 description?: string;
49 /**
50 * When true, the catalog item is published to the portal.
51 * @default false
52 */
53 published?: boolean;
54 /**
55 * When true, anonymous users may view the catalog item.
56 * @default false
57 */
58 anonAllowed?: boolean;
59 /**
60 * When true, a callback URL is required when the API product is enabled
61 * in a developer app.
62 * @default false
63 */
64 requireCallbackUrl?: boolean;
65 /**
66 * Image URL or portal file path shown in the catalog.
67 */
68 imageUrl?: string;
69 /**
70 * IDs of API categories this catalog item belongs to.
71 */
72 categoryIds?: string[];
73};
74
75export type SitesApidoc = Resource<
76 "GCP.Apigee.SitesApidoc",
77 SitesApidocProps,
78 {
79 /** Full resource name `organizations/{org}/sites/{site}/apidocs/{id}`. */
80 name: string;
81 /** Server-assigned catalog item id. */
82 apiDocId: string;
83 /** Apigee organization id. */
84 organization: string;
85 /** Portal site id. */
86 siteId: string;
87 /** Backing API product name. */
88 apiProductName: string;
89 /** User-facing title. */
90 title: string;
91 /** Catalog item description. */
92 description: string | undefined;
93 /** Whether the catalog item is published. */
94 published: boolean;
95 /** Whether anonymous users may view the item. */
96 anonAllowed: boolean;
97 /** Whether a callback URL is required. */
98 requireCallbackUrl: boolean;
99 /** Catalog image URL, if set. */
100 imageUrl: string | undefined;
101 /** Category ids. */
102 categoryIds: string[];
103 /** Last-modified time in milliseconds since epoch. */
104 modified: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * An Apigee integrated-portal catalog item (`apidoc`). Catalog items
112 * present API documentation and link a portal to a backing API product.
113 *
114 * API docs have no labels field and every text field is shown to portal
115 * users, so Alchemy finds its item by API product: `read` reports an item
116 * as owned only when it carries the generated title (an explicit `title`
117 * is reported as unowned — adopt it with `--adopt`), and nuke cannot
118 * discover items. `siteId`, `organization`, and
119 * `apiProductName` are identity — changing any of them replaces the item.
120 * Title, description, publish flags, image, and categories update in
121 * place.
122 *
123 * ### Creating a Catalog Item
124 * **Example:** Draft item for an API product
125 * ```typescript
126 * const doc = yield* GCP.Apigee.SitesApidoc("Checkout", {
127 * siteId: portal.siteId,
128 * apiProductName: product.name,
129 * title: "Checkout API",
130 * description: "place orders",
131 * });
132 * ```
133 *
134 * ### Updating a Catalog Item
135 * **Example:** Publish and recategorize
136 * ```typescript
137 * const doc = yield* GCP.Apigee.SitesApidoc("Checkout", {
138 * siteId: portal.siteId,
139 * apiProductName: product.name,
140 * title: "Checkout API",
141 * description: "place orders",
142 * published: true,
143 * categoryIds: [payments.categoryId],
144 * });
145 * ```
146 *
147 * @resource
148 * @category Apigee
149 */
150export const SitesApidoc = Resource<SitesApidoc>("GCP.Apigee.SitesApidoc");
151
152export class SitesApidocNotResolved extends Data.TaggedError(
153 "GCP.Apigee.SitesApidocNotResolved",
154)<{
155 name: string;
156}> {}
157
158const siteParent = (org: string, siteId: string) =>
159 `${orgParent(org)}/sites/${siteId}`;
160
161const resourceName = (org: string, siteId: string, apiDocId: string) =>
162 `${siteParent(org, siteId)}/apidocs/${apiDocId}`;
163
164const toTitle = (id: string, title: string | undefined, existing?: string) =>
165 Effect.gen(function* () {
166 if (title !== undefined) return title.slice(0, MAX_TITLE_LENGTH);
167 if (existing !== undefined) return existing.slice(0, MAX_TITLE_LENGTH);
168 return (yield* createPhysicalName({
169 id,
170 maxLength: MAX_TITLE_LENGTH,
171 lowercase: true,
172 })).slice(0, MAX_TITLE_LENGTH);
173 });
174
175const unwrap = (
176 response: apigee.GoogleCloudApigeeV1ApiDocResponse | undefined,
177): apigee.GoogleCloudApigeeV1ApiDoc | undefined => response?.data;
178
179const categoryIdsOf = (ids: ReadonlyArray<string> | undefined) =>
180 [...(ids ?? [])].filter((id) => id.length > 0).sort();
181
182const sameIds = (
183 left: ReadonlyArray<string> | undefined,
184 right: ReadonlyArray<string> | undefined,
185) =>
186 JSON.stringify(categoryIdsOf(left)) === JSON.stringify(categoryIdsOf(right));
187
188const toAttrs = (
189 doc: apigee.GoogleCloudApigeeV1ApiDoc,
190 org: string,
191 siteId: string,
192) => {
193 const apiDocId = doc.id ?? "";
194 return {
195 name: apiDocId ? resourceName(org, siteId, apiDocId) : "",
196 apiDocId,
197 organization: org,
198 siteId: doc.siteId ?? siteId,
199 apiProductName: doc.apiProductName ?? "",
200 title: doc.title ?? "",
201 description: doc.description,
202 published: doc.published === true,
203 anonAllowed: doc.anonAllowed === true,
204 requireCallbackUrl: doc.requireCallbackUrl === true,
205 imageUrl: doc.imageUrl,
206 categoryIds: categoryIdsOf(doc.categoryIds),
207 modified: doc.modified,
208 };
209};
210
211const getByName = (name: string) =>
212 apigee.getOrganizationsSitesApidocs({ name }).pipe(
213 Effect.map((response) => unwrap(response)),
214 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
215 Effect.succeed(undefined),
216 ),
217 );
218
219const listBySite = (org: string, siteId: string) =>
220 apigee.listOrganizationsSitesApidocs
221 .pages({
222 parent: siteParent(org, siteId),
223 pageSize: 100,
224 })
225 .pipe(
226 Stream.flatMap((page) => Stream.fromIterable(page.data ?? [])),
227 Stream.runCollect,
228 Effect.map((chunk) => Array.from(chunk)),
229 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
230 Effect.succeed([]),
231 ),
232 );
233
234const findByProduct = (org: string, siteId: string, apiProductName: string) =>
235 listBySite(org, siteId).pipe(
236 Effect.map((docs) =>
237 docs.find((doc) => doc.apiProductName === apiProductName),
238 ),
239 );
240
241const toBody = (
242 news: SitesApidocProps,
243 title: string,
244 description: string | undefined,
245): apigee.GoogleCloudApigeeV1ApiDoc => ({
246 apiProductName: news.apiProductName,
247 title,
248 description,
249 published: news.published === true ? true : undefined,
250 anonAllowed: news.anonAllowed === true ? true : undefined,
251 requireCallbackUrl: news.requireCallbackUrl === true ? true : undefined,
252 imageUrl: news.imageUrl,
253 categoryIds: news.categoryIds,
254});
255
256export const SitesApidocProvider = () =>
257 Provider.succeed(SitesApidoc, {
258 stables: ["name", "apiDocId", "organization", "siteId", "apiProductName"],
259
260 diff: Effect.fn(function* ({ news, olds, output }) {
261 if (!isResolved(news)) return undefined;
262 const previousSite = olds?.siteId ?? output?.siteId;
263 const siteChanged =
264 previousSite !== undefined && news.siteId !== previousSite;
265 const previousOrg = olds?.organization ?? output?.organization;
266 const orgChanged =
267 previousOrg !== undefined &&
268 news.organization !== undefined &&
269 orgIdOf(news.organization, previousOrg) !== previousOrg;
270 const previousProduct = olds?.apiProductName ?? output?.apiProductName;
271 const productChanged =
272 previousProduct !== undefined &&
273 news.apiProductName !== previousProduct;
274 const previousId = olds?.apiDocId ?? output?.apiDocId;
275 const idChanged =
276 previousId !== undefined &&
277 news.apiDocId !== undefined &&
278 news.apiDocId !== previousId;
279 if (siteChanged || orgChanged || productChanged || idChanged) {
280 return { action: "replace" as const, deleteFirst: false };
281 }
282 return undefined;
283 }),
284
285 read: Effect.fn(function* ({ id, olds, output }) {
286 const env = yield* GcpEnvironment.current;
287 const org = orgIdOf(
288 olds?.organization ?? output?.organization,
289 env.project,
290 );
291 const siteId = olds?.siteId ?? output?.siteId;
292 if (siteId === undefined) return undefined;
293 const apiDocId = olds?.apiDocId ?? output?.apiDocId;
294 const apiProductName = olds?.apiProductName ?? output?.apiProductName;
295 const name =
296 output?.name ??
297 (apiDocId !== undefined
298 ? resourceName(org, siteId, apiDocId)
299 : undefined);
300 const existing =
301 name !== undefined
302 ? yield* getByName(name)
303 : apiProductName !== undefined
304 ? yield* findByProduct(org, siteId, apiProductName)
305 : undefined;
306 if (existing === undefined) return undefined;
307 const attrs = toAttrs(existing, org, siteId);
308 const generated = yield* toTitle(id, undefined);
309 return attrs.title === generated ? attrs : Unowned(attrs);
310 }),
311
312 reconcile: Effect.fn(function* ({ id, news, output }) {
313 const env = yield* GcpEnvironment.current;
314 const org = orgIdOf(
315 news.organization ?? output?.organization,
316 env.project,
317 );
318 const siteId = news.siteId;
319 const title = yield* toTitle(id, news.title, output?.title);
320 const desiredDescription = news.description;
321
322 let current =
323 output?.name !== undefined
324 ? yield* getByName(output.name)
325 : news.apiDocId !== undefined
326 ? yield* getByName(resourceName(org, siteId, news.apiDocId))
327 : yield* findByProduct(org, siteId, news.apiProductName);
328
329 if (current === undefined) {
330 const created = yield* apigee
331 .createOrganizationsSitesApidocs({
332 parent: siteParent(org, siteId),
333 body: toBody(news, title, desiredDescription),
334 })
335 .pipe(
336 Effect.map((response) => unwrap(response)),
337 Effect.catchTag("Conflict", () =>
338 findByProduct(org, siteId, news.apiProductName),
339 ),
340 );
341 current = created ?? undefined;
342 }
343
344 if (current === undefined || !(current.id ?? "").length) {
345 return yield* new SitesApidocNotResolved({
346 name: siteParent(org, siteId),
347 });
348 }
349
350 const desiredPublished = news.published === true;
351 const desiredAnon = news.anonAllowed === true;
352 const desiredCallback = news.requireCallbackUrl === true;
353 const changed =
354 (current.title ?? "") !== title ||
355 (current.description ?? "") !== (desiredDescription ?? "") ||
356 (current.published === true) !== desiredPublished ||
357 (current.anonAllowed === true) !== desiredAnon ||
358 (current.requireCallbackUrl === true) !== desiredCallback ||
359 (current.imageUrl ?? "") !== (news.imageUrl ?? "") ||
360 !sameIds(current.categoryIds, news.categoryIds);
361
362 if (changed) {
363 const updated = yield* apigee.updateOrganizationsSitesApidocs({
364 name: resourceName(org, siteId, current.id ?? ""),
365 body: toBody(news, title, desiredDescription),
366 });
367 current = unwrap(updated) ?? current;
368 }
369
370 return toAttrs(current, org, siteId);
371 }),
372
373 delete: Effect.fn(function* ({ output }) {
374 yield* apigee
375 .deleteOrganizationsSitesApidocs({ name: output.name })
376 .pipe(
377 Effect.catchTag(
378 ["NotFound", "ApigeeResourceNotFound"],
379 () => Effect.void,
380 ),
381 );
382 }),
383 });