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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 fromAttributes,
14 toAttributes,
15 userAttributeList,
16 type Attribute,
17} from "./ownership.ts";
18import {
19 lastSegment,
20 orgParent,
21 resolveOrgId,
22 sameJson,
23 sortedStrings,
24 toPhysicalId,
25} from "./operations.ts";
26
27const MAX_NAME_LENGTH = 255;
28
29export type AppgroupsAppProps = {
30 /**
31 * Apigee organization id. Defaults to the GCP project id. Immutable.
32 */
33 organizationId?: string;
34 /**
35 * Parent AppGroup id or `organizations/{org}/appgroups/{appgroup}`.
36 * Immutable.
37 */
38 appgroup: string;
39 /**
40 * App id. If omitted, a unique name is generated. Immutable.
41 */
42 appId?: string;
43 /**
44 * API products associated with the app. An API key is generated for
45 * each product on create.
46 */
47 apiProducts?: string[];
48 /**
49 * App status: `approved` or `revoked`.
50 * @default "approved"
51 */
52 status?: string;
53 /**
54 * OAuth callback URL.
55 */
56 callbackUrl?: string;
57 /**
58 * Consumer key lifetime in seconds. `-1` (default) never expires.
59 * Immutable after create.
60 */
61 keyExpiresIn?: string;
62 /**
63 * OAuth scopes. Must already exist on associated API products.
64 */
65 scopes?: string[];
66 /**
67 * Customer attributes. Alchemy ownership attributes are merged in
68 * automatically so `list` / nuke can find the app.
69 */
70 attributes?: Attribute[];
71};
72
73export type AppgroupsApp = Resource<
74 "GCP.Apigee.AppgroupsApp",
75 AppgroupsAppProps,
76 {
77 /** Full resource name `organizations/{org}/appgroups/{group}/apps/{app}`. */
78 name: string;
79 /** App id. */
80 appId: string;
81 /** Parent AppGroup id. */
82 appgroupId: string;
83 /** Organization id. */
84 organizationId: string;
85 /** Project id. */
86 project: string;
87 /** Associated API products. */
88 apiProducts: string[];
89 /** App status. */
90 status: string | undefined;
91 /** OAuth callback URL. */
92 callbackUrl: string | undefined;
93 /** Consumer key lifetime in seconds. */
94 keyExpiresIn: string | undefined;
95 /** OAuth scopes. */
96 scopes: string[];
97 /** User attributes (Alchemy ownership attributes stripped). */
98 attributes: Attribute[];
99 /** Internal app id. */
100 internalAppId: string | undefined;
101 /** Credentials issued for the app. */
102 credentials: apigee.GoogleCloudApigeeV1Credential[];
103 /** Creation time in milliseconds since epoch. */
104 createdAt: string | undefined;
105 /** Last modification time in milliseconds since epoch. */
106 lastModifiedAt: string | undefined;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * An app belonging to an Apigee AppGroup, with auto-generated API keys
114 * for the associated API products.
115 *
116 * Apps have no labels field. Alchemy stamps ownership into attributes
117 * so `list` / nuke can find them. The app name is immutable.
118 *
119 * ### Creating an App
120 * **Example:** App bound to a product
121 * ```typescript
122 * const app = yield* GCP.Apigee.AppgroupsApp("Mobile", {
123 * appgroup: group.appgroupId,
124 * apiProducts: [product.apiproductId],
125 * callbackUrl: "https://example.com/oauth",
126 * });
127 * ```
128 *
129 * @resource
130 * @category Apigee
131 */
132export const AppgroupsApp = Resource<AppgroupsApp>("GCP.Apigee.AppgroupsApp");
133
134export class AppgroupsAppNotResolved extends Data.TaggedError(
135 "GCP.Apigee.AppgroupsAppNotResolved",
136)<{
137 name: string;
138}> {}
139
140const groupParent = (organizationId: string, appgroup: string) =>
141 `${orgParent(organizationId)}/appgroups/${lastSegment(appgroup)}`;
142
143const resourceName = (
144 organizationId: string,
145 appgroup: string,
146 appId: string,
147) => `${groupParent(organizationId, appgroup)}/apps/${appId}`;
148
149const toAttrs = (
150 app: apigee.GoogleCloudApigeeV1AppGroupApp,
151 project: string,
152 organizationId: string,
153 appgroupId: string,
154) => {
155 const appId = lastSegment(app.name ?? "");
156 const attributes = userAttributeList(app.attributes);
157 return {
158 name: resourceName(organizationId, appgroupId, appId),
159 appId,
160 appgroupId: lastSegment(app.appGroup ?? appgroupId),
161 organizationId,
162 project,
163 apiProducts: sortedStrings(app.apiProducts),
164 status: app.status,
165 callbackUrl: app.callbackUrl,
166 keyExpiresIn: app.keyExpiresIn,
167 scopes: sortedStrings(app.scopes),
168 attributes,
169 internalAppId: app.appId,
170 credentials: [...(app.credentials ?? [])],
171 createdAt: app.createdAt,
172 lastModifiedAt: app.lastModifiedAt,
173 };
174};
175
176const getByName = (name: string) =>
177 apigee
178 .getOrganizationsAppgroupsApps({ name })
179 .pipe(
180 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
181 Effect.succeed(undefined),
182 ),
183 );
184
185export const AppgroupsAppProvider = () =>
186 Provider.succeed(AppgroupsApp, {
187 stables: [
188 "name",
189 "appId",
190 "appgroupId",
191 "organizationId",
192 "project",
193 "internalAppId",
194 "createdAt",
195 "keyExpiresIn",
196 ],
197
198 diff: Effect.fn(function* ({ news, olds, output }) {
199 if (!isResolved(news)) return undefined;
200 const previousId = olds?.appId ?? output?.appId;
201 const previousGroup = olds?.appgroup
202 ? lastSegment(olds.appgroup)
203 : output?.appgroupId;
204 const previousOrg = olds?.organizationId ?? output?.organizationId;
205 if (
206 (previousId !== undefined &&
207 news.appId !== undefined &&
208 news.appId !== previousId) ||
209 (previousGroup !== undefined &&
210 lastSegment(news.appgroup) !== previousGroup) ||
211 (previousOrg !== undefined &&
212 news.organizationId !== undefined &&
213 news.organizationId !== previousOrg)
214 ) {
215 return { action: "replace" as const, deleteFirst: false };
216 }
217 return undefined;
218 }),
219
220 read: Effect.fn(function* ({ id, olds, output }) {
221 const env = yield* GcpEnvironment.current;
222 const organizationId =
223 olds?.organizationId ??
224 output?.organizationId ??
225 (yield* resolveOrgId(env.project));
226 const appgroupId = lastSegment(
227 olds?.appgroup ?? output?.appgroupId ?? "",
228 );
229 if (appgroupId.length === 0) return undefined;
230 const appId = yield* toPhysicalId(
231 id,
232 olds?.appId,
233 output?.appId,
234 MAX_NAME_LENGTH,
235 );
236 const name =
237 output?.name ?? resourceName(organizationId, appgroupId, appId);
238 const existing = yield* getByName(name);
239 if (existing === undefined) return undefined;
240 const attrs = toAttrs(existing, env.project, organizationId, appgroupId);
241 const { labels } = fromAttributes(existing.attributes);
242 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
243 }),
244
245 list: () =>
246 Effect.gen(function* () {
247 const env = yield* GcpEnvironment.current;
248 const organizationId = yield* resolveOrgId(env.project);
249 const groups = yield* apigee.listOrganizationsAppgroups
250 .pages({
251 parent: orgParent(organizationId),
252 pageSize: 1000,
253 })
254 .pipe(
255 Stream.flatMap((page) => Stream.fromIterable(page.appGroups ?? [])),
256 Stream.filter(
257 (group) =>
258 fromAttributes(group.attributes).labels["alchemy-id"] !==
259 undefined,
260 ),
261 Stream.runCollect,
262 Effect.map((chunk) => Array.from(chunk)),
263 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
264 Effect.succeed([]),
265 ),
266 );
267 const apps = [];
268 for (const group of groups) {
269 const appgroupId = lastSegment(group.name ?? "");
270 if (appgroupId.length === 0) continue;
271 const page = yield* apigee.listOrganizationsAppgroupsApps
272 .pages({
273 parent: groupParent(organizationId, appgroupId),
274 pageSize: 1000,
275 })
276 .pipe(
277 Stream.flatMap((item) =>
278 Stream.fromIterable(item.appGroupApps ?? []),
279 ),
280 Stream.filter(
281 (app) =>
282 fromAttributes(app.attributes).labels["alchemy-id"] !==
283 undefined,
284 ),
285 Stream.map((app) =>
286 toAttrs(app, env.project, organizationId, appgroupId),
287 ),
288 Stream.runCollect,
289 Effect.map((chunk) => Array.from(chunk)),
290 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
291 Effect.succeed([]),
292 ),
293 );
294 apps.push(...page);
295 }
296 return apps;
297 }),
298
299 reconcile: Effect.fn(function* ({ id, news, output }) {
300 const env = yield* GcpEnvironment.current;
301 const organizationId =
302 news.organizationId ??
303 output?.organizationId ??
304 (yield* resolveOrgId(env.project));
305 const appgroupId = lastSegment(news.appgroup);
306 const appId = yield* toPhysicalId(
307 id,
308 news.appId,
309 output?.appId,
310 MAX_NAME_LENGTH,
311 );
312 const name = resourceName(organizationId, appgroupId, appId);
313 const ownership = yield* createInternalLabels(id);
314 const desiredAttributes = toAttributes(ownership, news.attributes);
315 const desiredStatus = news.status ?? "approved";
316 const body: apigee.GoogleCloudApigeeV1AppGroupApp = {
317 name: appId,
318 apiProducts: news.apiProducts,
319 callbackUrl: news.callbackUrl,
320 keyExpiresIn: news.keyExpiresIn,
321 scopes: news.scopes,
322 attributes: desiredAttributes,
323 };
324
325 let current = yield* getByName(output?.name ?? name);
326
327 if (current === undefined) {
328 const created = yield* apigee
329 .createOrganizationsAppgroupsApps({
330 parent: groupParent(organizationId, appgroupId),
331 body,
332 })
333 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
334 current = created ?? undefined;
335 }
336
337 if (current === undefined) {
338 return yield* new AppgroupsAppNotResolved({ name });
339 }
340
341 const needsUpdate =
342 (current.callbackUrl ?? "") !== (news.callbackUrl ?? "") ||
343 !sameJson(
344 sortedStrings(current.apiProducts),
345 sortedStrings(news.apiProducts),
346 ) ||
347 !sameJson(sortedStrings(current.scopes), sortedStrings(news.scopes)) ||
348 !sameJson(current.attributes ?? [], desiredAttributes);
349
350 if (needsUpdate) {
351 current = yield* apigee.updateOrganizationsAppgroupsApps({
352 name,
353 body,
354 });
355 }
356
357 if ((current.status ?? "approved") !== desiredStatus) {
358 current = yield* apigee.updateOrganizationsAppgroupsApps({
359 name,
360 action: desiredStatus === "approved" ? "approve" : "revoke",
361 });
362 }
363
364 return toAttrs(current, env.project, organizationId, appgroupId);
365 }),
366
367 delete: Effect.fn(function* ({ output }) {
368 yield* apigee
369 .deleteOrganizationsAppgroupsApps({ name: output.name })
370 .pipe(
371 Effect.catchTag(
372 ["NotFound", "ApigeeResourceNotFound"],
373 () => Effect.void,
374 ),
375 );
376 }),
377 });