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 createOwnership,
14 defaultOrgName,
15 desiredAttributes,
16 lastSegment,
17 listOrgNames,
18 orgIdOf,
19 orgNameOf,
20 ownedBy,
21 recordToAttributes,
22 sameRecord,
23 userAttributes,
24} from "./operations.ts";
25
26export type DeveloperProps = {
27 /**
28 * Apigee organization id or `organizations/{org}`. Defaults to the
29 * current GCP project id. Immutable — changing it replaces the developer.
30 */
31 organization?: string;
32 /**
33 * Developer email (unique within the organization, lowercase). If omitted,
34 * a unique `{physical}@alchemy.example` address is generated. Immutable —
35 * changing it replaces the developer.
36 */
37 email?: string;
38 /**
39 * First name.
40 * @default "Alchemy"
41 */
42 firstName?: string;
43 /**
44 * Last name.
45 * @default "Developer"
46 */
47 lastName?: string;
48 /**
49 * User name. If omitted, the local-part of `email` is used.
50 */
51 userName?: string;
52 /**
53 * Developer app family.
54 */
55 appFamily?: string;
56 /**
57 * Access type.
58 */
59 accessType?: string;
60 /**
61 * Custom attributes (name/value pairs, max 18). Alchemy ownership
62 * attributes (`alchemy-stack`, `alchemy-stage`, `alchemy-id`) are merged
63 * in automatically.
64 */
65 attributes?: Record<string, string>;
66};
67
68export type Developer = Resource<
69 "GCP.Apigee.Developer",
70 DeveloperProps,
71 {
72 /** Full resource name `organizations/{org}/developers/{email}`. */
73 name: string;
74 /** Developer email. */
75 email: string;
76 /** Apigee organization id. */
77 organization: string;
78 /** Server-generated developer id. */
79 developerId: string | undefined;
80 /** First name. */
81 firstName: string | undefined;
82 /** Last name. */
83 lastName: string | undefined;
84 /** User name. */
85 userName: string | undefined;
86 /** Developer app family. */
87 appFamily: string | undefined;
88 /** Access type. */
89 accessType: string | undefined;
90 /** User attributes (Alchemy ownership attributes stripped). */
91 attributes: Record<string, string>;
92 /** Status (`active` or `inactive`). */
93 status: string | undefined;
94 /** Associated app names. */
95 apps: string[];
96 /** Associated company names. */
97 companies: string[];
98 /** Creation time in milliseconds since epoch. */
99 createdAt: string | undefined;
100 /** Last modification time in milliseconds since epoch. */
101 lastModifiedAt: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * An Apigee developer that can register apps and obtain API keys.
109 *
110 * Apigee developers have no labels field, so Alchemy stamps ownership into
111 * custom attributes for `list` / nuke. Email is identity — changing `email`
112 * replaces the developer. Name, attributes, and access type update in place.
113 *
114 * ### Creating a Developer
115 * **Example:** Generated email
116 * ```typescript
117 * const developer = yield* GCP.Apigee.Developer("Owner", {
118 * firstName: "Ada",
119 * lastName: "Lovelace",
120 * });
121 * ```
122 *
123 * **Example:** Explicit email and attributes
124 * ```typescript
125 * const developer = yield* GCP.Apigee.Developer("Owner", {
126 * email: "ada@example.com",
127 * firstName: "Ada",
128 * lastName: "Lovelace",
129 * userName: "ada",
130 * attributes: { team: "platform" },
131 * });
132 * ```
133 *
134 * @resource
135 * @category Apigee
136 */
137export const Developer = Resource<Developer>("GCP.Apigee.Developer");
138
139export class DeveloperNotResolved extends Data.TaggedError(
140 "GCP.Apigee.DeveloperNotResolved",
141)<{
142 name: string;
143}> {}
144
145const resourceName = (organization: string, email: string) =>
146 `${orgNameOf(organization)}/developers/${email}`;
147
148const toEmail = (id: string, email: string | undefined, existing?: string) =>
149 Effect.gen(function* () {
150 if (email !== undefined) return email.toLowerCase();
151 if (existing !== undefined) return existing.toLowerCase();
152 const generated = yield* createPhysicalName({
153 id,
154 maxLength: 48,
155 lowercase: true,
156 });
157 return `${generated}@alchemy.example`;
158 });
159
160const toAttrs = (
161 developer: apigee.GoogleCloudApigeeV1Developer,
162 organization: string,
163) => {
164 const email = (developer.email ?? "").toLowerCase();
165 return {
166 name: resourceName(organization, email),
167 email,
168 organization: orgIdOf(organization),
169 developerId: developer.developerId,
170 firstName: developer.firstName,
171 lastName: developer.lastName,
172 userName: developer.userName,
173 appFamily: developer.appFamily,
174 accessType: developer.accessType,
175 attributes: userAttributes(attributesToRecord(developer.attributes)),
176 status: developer.status,
177 apps: developer.apps ?? [],
178 companies: developer.companies ?? [],
179 createdAt: developer.createdAt,
180 lastModifiedAt: developer.lastModifiedAt,
181 };
182};
183
184const getByName = (name: string) =>
185 apigee
186 .getOrganizationsDevelopers({ name })
187 .pipe(
188 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
189 Effect.succeed(undefined),
190 ),
191 );
192
193export const DeveloperProvider = () =>
194 Provider.succeed(Developer, {
195 stables: ["name", "email", "organization", "developerId", "createdAt"],
196
197 diff: Effect.fn(function* ({ news, olds, output }) {
198 if (!isResolved(news)) return undefined;
199 const previousEmail = olds?.email ?? output?.email;
200 const previousOrg = olds?.organization ?? output?.organization;
201 const emailChanged =
202 previousEmail !== undefined &&
203 news.email !== undefined &&
204 news.email.toLowerCase() !== previousEmail.toLowerCase();
205 const orgChanged =
206 previousOrg !== undefined &&
207 news.organization !== undefined &&
208 orgIdOf(news.organization) !== orgIdOf(previousOrg);
209 if (emailChanged || orgChanged) {
210 return {
211 action: "replace" as const,
212 deleteFirst: emailChanged && !orgChanged,
213 };
214 }
215 return undefined;
216 }),
217
218 read: Effect.fn(function* ({ id, olds, output }) {
219 const env = yield* GcpEnvironment.current;
220 const organization = defaultOrgName(
221 env.project,
222 olds?.organization ?? output?.organization,
223 );
224 const email = yield* toEmail(id, olds?.email, output?.email);
225 const name = output?.name ?? resourceName(organization, email);
226 const existing = yield* getByName(name);
227 if (existing === undefined) return undefined;
228 const attrs = toAttrs(existing, organization);
229 return (yield* ownedBy(id, attributesToRecord(existing.attributes)))
230 ? attrs
231 : Unowned(attrs);
232 }),
233
234 list: () =>
235 Effect.gen(function* () {
236 const orgs = yield* listOrgNames();
237 const rows: Developer["Attributes"][] = [];
238 for (const organization of orgs) {
239 const page = yield* apigee
240 .listOrganizationsDevelopers({
241 parent: organization,
242 expand: true,
243 count: "1000",
244 })
245 .pipe(
246 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
247 Effect.succeed({
248 developer: [] as apigee.GoogleCloudApigeeV1Developer[],
249 }),
250 ),
251 );
252 for (const developer of page.developer ?? []) {
253 const labels = attributesToRecord(developer.attributes);
254 if (Object.keys(labels).some((key) => key.startsWith("alchemy-"))) {
255 rows.push(toAttrs(developer, organization));
256 }
257 }
258 }
259 return rows;
260 }),
261
262 reconcile: Effect.fn(function* ({ id, news, output }) {
263 const env = yield* GcpEnvironment.current;
264 const organization = defaultOrgName(env.project, news.organization);
265 const email = yield* toEmail(id, news.email, output?.email);
266 const name = resourceName(organization, email);
267 const firstName = news.firstName ?? "Alchemy";
268 const lastName = news.lastName ?? "Developer";
269 const userName =
270 news.userName ?? lastSegment(email.split("@")[0] ?? email);
271 const ownership = yield* createOwnership(id);
272 const attributes = desiredAttributes(news.attributes, ownership);
273
274 let current = yield* getByName(output?.name ?? name);
275
276 if (current === undefined) {
277 const created = yield* apigee
278 .createOrganizationsDevelopers({
279 parent: organization,
280 body: {
281 email,
282 firstName,
283 lastName,
284 userName,
285 appFamily: news.appFamily,
286 accessType: news.accessType,
287 attributes: recordToAttributes(attributes),
288 },
289 })
290 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
291 current = created ?? undefined;
292 }
293
294 if (current === undefined) {
295 return yield* new DeveloperNotResolved({ name });
296 }
297
298 const observed = {
299 firstName: current.firstName ?? "",
300 lastName: current.lastName ?? "",
301 userName: current.userName ?? "",
302 appFamily: current.appFamily ?? "",
303 accessType: current.accessType ?? "",
304 attributes: attributesToRecord(current.attributes),
305 };
306 const desired = {
307 firstName,
308 lastName,
309 userName,
310 appFamily: news.appFamily ?? "",
311 accessType: news.accessType ?? "",
312 attributes,
313 };
314 const changed =
315 observed.firstName !== desired.firstName ||
316 observed.lastName !== desired.lastName ||
317 observed.userName !== desired.userName ||
318 observed.appFamily !== desired.appFamily ||
319 observed.accessType !== desired.accessType ||
320 !sameRecord(observed.attributes, desired.attributes);
321
322 if (changed) {
323 current = yield* apigee.updateOrganizationsDevelopers({
324 name,
325 body: {
326 email,
327 firstName,
328 lastName,
329 userName,
330 appFamily: news.appFamily,
331 accessType: news.accessType,
332 attributes: recordToAttributes(attributes),
333 },
334 });
335 }
336
337 return toAttrs(current, organization);
338 }),
339
340 delete: Effect.fn(function* ({ output }) {
341 yield* apigee
342 .deleteOrganizationsDevelopers({ name: output.name })
343 .pipe(
344 Effect.catchTag(
345 ["NotFound", "ApigeeResourceNotFound"],
346 () => Effect.void,
347 ),
348 );
349 }),
350 });