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 type { Providers } from "../Providers.ts";
10import {
11 attributesToRecord,
12 childName,
13 createOwnership,
14 defaultOrgName,
15 desiredAttributes,
16 lastSegment,
17 letterPrefixedId,
18 listOrgNames,
19 orgIdOf,
20 orgNameOf,
21 ownedBy,
22 recordToAttributes,
23 sameRecord,
24 sameStringList,
25 userAttributes,
26} from "./operations.ts";
27
28export type DevelopersAppProps = {
29 /**
30 * Apigee organization id or `organizations/{org}`. Defaults to the
31 * current GCP project id. Immutable — changing it replaces the app.
32 */
33 organization?: string;
34 /**
35 * Parent developer email or `organizations/{org}/developers/{email}`.
36 * Immutable — changing it replaces the app.
37 */
38 developer: string;
39 /**
40 * App name (the `{app}` segment of
41 * `organizations/{org}/developers/{email}/apps/{app}`). If omitted, a
42 * unique name is generated. Immutable — changing it replaces the app.
43 */
44 appName?: string;
45 /**
46 * OAuth 2.0 callback URL.
47 */
48 callbackUrl?: string;
49 /**
50 * API products associated with the app.
51 */
52 apiProducts?: string[];
53 /**
54 * Scopes to apply to the app. Must already exist on associated API
55 * products.
56 */
57 scopes?: string[];
58 /**
59 * Credential status (`approved` or `revoked`).
60 */
61 status?: string;
62 /**
63 * Expiration time in milliseconds for the auto-generated consumer key.
64 * `-1` (default) never expires. Immutable after create.
65 */
66 keyExpiresIn?: string;
67 /**
68 * Developer app family.
69 */
70 appFamily?: string;
71 /**
72 * Custom attributes. Alchemy ownership attributes are merged in
73 * automatically.
74 */
75 attributes?: Record<string, string>;
76};
77
78export type DevelopersApp = Resource<
79 "GCP.Apigee.DevelopersApp",
80 DevelopersAppProps,
81 {
82 /** Full resource name `organizations/{org}/developers/{email}/apps/{app}`. */
83 name: string;
84 /** App name (last path segment). */
85 appName: string;
86 /** Server-generated UUID. */
87 appId: string | undefined;
88 /** Parent developer email. */
89 developer: string;
90 /** Parent developer id. */
91 developerId: string | undefined;
92 /** Apigee organization id. */
93 organization: string;
94 /** OAuth callback URL. */
95 callbackUrl: string | undefined;
96 /** Associated API products. */
97 apiProducts: string[];
98 /** Scopes. */
99 scopes: string[];
100 /** Credential status. */
101 status: string | undefined;
102 /** Key expiration in milliseconds. */
103 keyExpiresIn: string | undefined;
104 /** Developer app family. */
105 appFamily: string | undefined;
106 /** User attributes (Alchemy ownership attributes stripped). */
107 attributes: Record<string, string>;
108 /** Creation time in milliseconds since epoch. */
109 createdAt: string | undefined;
110 /** Last modification time in milliseconds since epoch. */
111 lastModifiedAt: string | undefined;
112 },
113 never,
114 Providers
115>;
116
117/**
118 * An Apigee app owned by a developer, used to obtain API keys.
119 *
120 * Apigee developer apps have no labels field, so Alchemy stamps ownership
121 * into custom attributes for `list` / nuke. Name and parent developer are
122 * identity — changing `appName` or `developer` replaces the app. Callback
123 * URL, products, scopes, status, and attributes update in place.
124 *
125 * ### Creating a Developer App
126 * **Example:** App under an existing developer
127 * ```typescript
128 * const app = yield* GCP.Apigee.DevelopersApp("Portal", {
129 * developer: developer.email,
130 * callbackUrl: "https://example.com/callback",
131 * });
132 * ```
133 *
134 * **Example:** Named app with attributes
135 * ```typescript
136 * const app = yield* GCP.Apigee.DevelopersApp("Portal", {
137 * developer: "ada@example.com",
138 * appName: "portal-app",
139 * attributes: { team: "platform" },
140 * });
141 * ```
142 *
143 * @resource
144 * @category Apigee
145 */
146export const DevelopersApp = Resource<DevelopersApp>(
147 "GCP.Apigee.DevelopersApp",
148);
149
150export class DevelopersAppNotResolved extends Data.TaggedError(
151 "GCP.Apigee.DevelopersAppNotResolved",
152)<{
153 name: string;
154}> {}
155
156const developerEmailOf = (developer: string) => {
157 if (developer.includes("/developers/")) {
158 return lastSegment(developer);
159 }
160 return developer;
161};
162
163const developerNameOf = (organization: string, developer: string) =>
164 childName(orgNameOf(organization), "developers", developerEmailOf(developer));
165
166const resourceName = (
167 organization: string,
168 developer: string,
169 appName: string,
170) => `${developerNameOf(organization, developer)}/apps/${appName}`;
171
172const toAttrs = (
173 app: apigee.GoogleCloudApigeeV1DeveloperApp,
174 organization: string,
175 developer: string,
176) => {
177 const appName = app.name ?? "";
178 const email = developerEmailOf(developer);
179 return {
180 name: resourceName(organization, email, appName),
181 appName,
182 appId: app.appId,
183 developer: email,
184 developerId: app.developerId,
185 organization: orgIdOf(organization),
186 callbackUrl: app.callbackUrl,
187 apiProducts: app.apiProducts ?? [],
188 scopes: app.scopes ?? [],
189 status: app.status,
190 keyExpiresIn: app.keyExpiresIn,
191 appFamily: app.appFamily,
192 attributes: userAttributes(attributesToRecord(app.attributes)),
193 createdAt: app.createdAt,
194 lastModifiedAt: app.lastModifiedAt,
195 };
196};
197
198const getByName = (name: string) =>
199 apigee
200 .getOrganizationsDevelopersApps({ name })
201 .pipe(
202 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
203 Effect.succeed(undefined),
204 ),
205 );
206
207const listDeveloperEmails = (organization: string) =>
208 apigee
209 .listOrganizationsDevelopers({
210 parent: organization,
211 expand: true,
212 count: "1000",
213 })
214 .pipe(
215 Effect.map((page) =>
216 (page.developer ?? [])
217 .map((developer) => developer.email ?? "")
218 .filter((email) => email.length > 0),
219 ),
220 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
221 Effect.succeed([] as string[]),
222 ),
223 );
224
225export const DevelopersAppProvider = () =>
226 Provider.succeed(DevelopersApp, {
227 stables: [
228 "name",
229 "appName",
230 "appId",
231 "developer",
232 "organization",
233 "createdAt",
234 ],
235
236 diff: Effect.fn(function* ({ news, olds, output }) {
237 if (!isResolved(news)) return undefined;
238 const previousName = olds?.appName ?? output?.appName;
239 const previousDeveloper = olds?.developer ?? output?.developer;
240 const previousOrg = olds?.organization ?? output?.organization;
241 const nameChanged =
242 previousName !== undefined &&
243 news.appName !== undefined &&
244 news.appName !== previousName;
245 const developerChanged =
246 previousDeveloper !== undefined &&
247 developerEmailOf(news.developer) !==
248 developerEmailOf(previousDeveloper);
249 const orgChanged =
250 previousOrg !== undefined &&
251 news.organization !== undefined &&
252 orgIdOf(news.organization) !== orgIdOf(previousOrg);
253 if (nameChanged || developerChanged || orgChanged) {
254 return {
255 action: "replace" as const,
256 deleteFirst: nameChanged && !developerChanged && !orgChanged,
257 };
258 }
259 return undefined;
260 }),
261
262 read: Effect.fn(function* ({ id, olds, output }) {
263 const env = yield* GcpEnvironment.current;
264 const organization = defaultOrgName(
265 env.project,
266 olds?.organization ?? output?.organization,
267 );
268 const developer = olds?.developer ?? output?.developer;
269 if (developer === undefined) return undefined;
270 const appName = yield* letterPrefixedId(
271 id,
272 olds?.appName,
273 output?.appName,
274 255,
275 );
276 const name =
277 output?.name ?? resourceName(organization, developer, appName);
278 const existing = yield* getByName(name);
279 if (existing === undefined) return undefined;
280 const attrs = toAttrs(existing, organization, developer);
281 return (yield* ownedBy(id, attributesToRecord(existing.attributes)))
282 ? attrs
283 : Unowned(attrs);
284 }),
285
286 list: () =>
287 Effect.gen(function* () {
288 const orgs = yield* listOrgNames();
289 const rows: DevelopersApp["Attributes"][] = [];
290 for (const organization of orgs) {
291 const emails = yield* listDeveloperEmails(organization);
292 for (const email of emails) {
293 const page = yield* apigee
294 .listOrganizationsDevelopersApps({
295 parent: developerNameOf(organization, email),
296 expand: true,
297 count: "1000",
298 })
299 .pipe(
300 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
301 Effect.succeed({
302 app: [] as apigee.GoogleCloudApigeeV1DeveloperApp[],
303 }),
304 ),
305 );
306 for (const app of page.app ?? []) {
307 const labels = attributesToRecord(app.attributes);
308 if (
309 Object.keys(labels).some((key) => key.startsWith("alchemy-"))
310 ) {
311 rows.push(toAttrs(app, organization, email));
312 }
313 }
314 }
315 }
316 return rows;
317 }),
318
319 reconcile: Effect.fn(function* ({ id, news, output }) {
320 const env = yield* GcpEnvironment.current;
321 const organization = defaultOrgName(env.project, news.organization);
322 const developer = developerEmailOf(news.developer);
323 const appName = yield* letterPrefixedId(
324 id,
325 news.appName,
326 output?.appName,
327 255,
328 );
329 const parent = developerNameOf(organization, developer);
330 const name = `${parent}/apps/${appName}`;
331 const ownership = yield* createOwnership(id);
332 const attributes = desiredAttributes(news.attributes, ownership);
333
334 let current = yield* getByName(output?.name ?? name);
335
336 if (current === undefined) {
337 const created = yield* apigee
338 .createOrganizationsDevelopersApps({
339 parent,
340 body: {
341 name: appName,
342 callbackUrl: news.callbackUrl,
343 apiProducts: news.apiProducts,
344 scopes: news.scopes,
345 status: news.status,
346 keyExpiresIn: news.keyExpiresIn,
347 appFamily: news.appFamily,
348 attributes: recordToAttributes(attributes),
349 },
350 })
351 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
352 current = created ?? undefined;
353 }
354
355 if (current === undefined) {
356 return yield* new DevelopersAppNotResolved({ name });
357 }
358
359 const observedAttributes = attributesToRecord(current.attributes);
360 const callbackChanged =
361 (current.callbackUrl ?? "") !== (news.callbackUrl ?? "");
362 const productsChanged = !sameStringList(
363 current.apiProducts,
364 news.apiProducts,
365 );
366 const scopesChanged = !sameStringList(current.scopes, news.scopes);
367 const statusChanged =
368 news.status !== undefined && (current.status ?? "") !== news.status;
369 const familyChanged =
370 (current.appFamily ?? "") !== (news.appFamily ?? "");
371 const attributesChanged = !sameRecord(observedAttributes, attributes);
372
373 if (
374 callbackChanged ||
375 productsChanged ||
376 scopesChanged ||
377 statusChanged ||
378 familyChanged ||
379 attributesChanged
380 ) {
381 current = yield* apigee.updateOrganizationsDevelopersApps({
382 name,
383 body: {
384 name: appName,
385 callbackUrl: news.callbackUrl,
386 apiProducts: news.apiProducts,
387 scopes: news.scopes,
388 status: news.status ?? current.status,
389 appFamily: news.appFamily,
390 attributes: recordToAttributes(attributes),
391 },
392 });
393 }
394
395 return toAttrs(current, organization, developer);
396 }),
397
398 delete: Effect.fn(function* ({ output }) {
399 yield* apigee
400 .deleteOrganizationsDevelopersApps({ name: output.name })
401 .pipe(
402 Effect.catchTag(
403 ["NotFound", "ApigeeResourceNotFound"],
404 () => Effect.void,
405 ),
406 );
407 }),
408 });