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