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 type { Providers } from "../Providers.ts";
11import {
12 lastSegment,
13 orgParent,
14 resolveOrgId,
15 toPhysicalId,
16} from "./operations.ts";
17
18const MAX_NAME_LENGTH = 255;
19const OWNERSHIP_KEY = "__alchemy";
20
21export type ApisKeyvaluemapsEntryProps = {
22 /**
23 * Apigee organization id. Defaults to the GCP project id. Immutable.
24 */
25 organizationId?: string;
26 /**
27 * Parent API proxy id. Immutable.
28 */
29 api: string;
30 /**
31 * Parent key value map id. Immutable.
32 */
33 map: string;
34 /**
35 * Entry key. If omitted, a unique name is generated. Immutable.
36 */
37 entryId?: string;
38 /**
39 * Value stored for this key.
40 */
41 value: string;
42};
43
44export type ApisKeyvaluemapsEntry = Resource<
45 "GCP.Apigee.ApisKeyvaluemapsEntry",
46 ApisKeyvaluemapsEntryProps,
47 {
48 /** Full resource name `.../keyvaluemaps/{map}/entries/{entry}`. */
49 name: string;
50 /** Entry key. */
51 entryId: string;
52 /** Parent map id. */
53 mapId: string;
54 /** Parent API proxy id. */
55 apiId: string;
56 /** Organization id. */
57 organizationId: string;
58 /** Project id. */
59 project: string;
60 /** Stored value. */
61 value: string;
62 },
63 never,
64 Providers
65>;
66
67/**
68 * A key/value entry in an API-proxy-scoped Apigee key value map.
69 *
70 * Entries have no labels. Alchemy treats entries inside a map that
71 * carries the `__alchemy` sentinel as owned so `list` / nuke can find
72 * them. Changing `entryId`, `map`, `api`, or `organizationId` replaces
73 * the entry. The reserved key `__alchemy` is used for map ownership and
74 * is not a user entry.
75 *
76 * ### Creating an Entry
77 * **Example:** Put a config value
78 * ```typescript
79 * const entry = yield* GCP.Apigee.ApisKeyvaluemapsEntry("Timeout", {
80 * api: proxy.apiId,
81 * map: map.mapId,
82 * entryId: "timeout-ms",
83 * value: "5000",
84 * });
85 * ```
86 *
87 * @resource
88 * @category Apigee
89 */
90export const ApisKeyvaluemapsEntry = Resource<ApisKeyvaluemapsEntry>(
91 "GCP.Apigee.ApisKeyvaluemapsEntry",
92);
93
94export class ApisKeyvaluemapsEntryNotResolved extends Data.TaggedError(
95 "GCP.Apigee.ApisKeyvaluemapsEntryNotResolved",
96)<{
97 name: string;
98}> {}
99
100const resourceName = (
101 organizationId: string,
102 apiId: string,
103 mapId: string,
104 entryId: string,
105) =>
106 `${orgParent(organizationId)}/apis/${lastSegment(apiId)}/keyvaluemaps/${lastSegment(mapId)}/entries/${entryId}`;
107
108const mapName = (organizationId: string, apiId: string, mapId: string) =>
109 `${orgParent(organizationId)}/apis/${lastSegment(apiId)}/keyvaluemaps/${lastSegment(mapId)}`;
110
111const toAttrs = (
112 entry: apigee.GoogleCloudApigeeV1KeyValueEntry,
113 project: string,
114 organizationId: string,
115 apiId: string,
116 mapId: string,
117 name: string,
118) => ({
119 name,
120 entryId: lastSegment(entry.name ?? name),
121 mapId: lastSegment(mapId),
122 apiId: lastSegment(apiId),
123 organizationId,
124 project,
125 value: entry.value ?? "",
126});
127
128const getByName = (name: string) =>
129 apigee
130 .getOrganizationsApisKeyvaluemapsEntries({ name })
131 .pipe(
132 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
133 Effect.succeed(undefined),
134 ),
135 );
136
137const mapIsOwned = (parent: string) =>
138 apigee
139 .getOrganizationsApisKeyvaluemapsEntries({
140 name: `${parent}/entries/${OWNERSHIP_KEY}`,
141 })
142 .pipe(
143 Effect.map(() => true),
144 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
145 Effect.succeed(false),
146 ),
147 );
148
149export const ApisKeyvaluemapsEntryProvider = () =>
150 Provider.succeed(ApisKeyvaluemapsEntry, {
151 stables: ["name", "entryId", "mapId", "apiId", "organizationId", "project"],
152
153 diff: Effect.fn(function* ({ news, olds, output }) {
154 if (!isResolved(news)) return undefined;
155 const previousId = olds?.entryId ?? output?.entryId;
156 const previousMap = olds?.map ? lastSegment(olds.map) : output?.mapId;
157 const previousApi = olds?.api ? lastSegment(olds.api) : output?.apiId;
158 const previousOrg = olds?.organizationId ?? output?.organizationId;
159 if (
160 (previousId !== undefined &&
161 news.entryId !== undefined &&
162 news.entryId !== previousId) ||
163 (previousMap !== undefined && lastSegment(news.map) !== previousMap) ||
164 (previousApi !== undefined && lastSegment(news.api) !== previousApi) ||
165 (previousOrg !== undefined &&
166 news.organizationId !== undefined &&
167 news.organizationId !== previousOrg)
168 ) {
169 return { action: "replace" as const, deleteFirst: true };
170 }
171 return undefined;
172 }),
173
174 read: Effect.fn(function* ({ id, olds, output }) {
175 const env = yield* GcpEnvironment.current;
176 const organizationId =
177 olds?.organizationId ??
178 output?.organizationId ??
179 (yield* resolveOrgId(env.project));
180 const apiId = lastSegment(olds?.api ?? output?.apiId ?? "");
181 const mapId = lastSegment(olds?.map ?? output?.mapId ?? "");
182 if (apiId.length === 0 || mapId.length === 0) return undefined;
183 const entryId = yield* toPhysicalId(
184 id,
185 olds?.entryId,
186 output?.entryId,
187 MAX_NAME_LENGTH,
188 );
189 const name =
190 output?.name ?? resourceName(organizationId, apiId, mapId, entryId);
191 const existing = yield* getByName(name);
192 if (existing === undefined) return undefined;
193 const attrs = toAttrs(
194 existing,
195 env.project,
196 organizationId,
197 apiId,
198 mapId,
199 name,
200 );
201 const owned = yield* mapIsOwned(mapName(organizationId, apiId, mapId));
202 return owned ? attrs : Unowned(attrs);
203 }),
204
205 list: () =>
206 Effect.gen(function* () {
207 const env = yield* GcpEnvironment.current;
208 const organizationId = yield* resolveOrgId(env.project);
209 const page = yield* apigee
210 .listOrganizationsApis({
211 parent: orgParent(organizationId),
212 includeMetaData: true,
213 })
214 .pipe(
215 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
216 Effect.succeed({ proxies: [] }),
217 ),
218 );
219 const owned = [];
220 for (const proxy of page.proxies ?? []) {
221 if (
222 !Object.keys(proxy.labels ?? {}).some((key) =>
223 key.startsWith("alchemy-"),
224 )
225 ) {
226 continue;
227 }
228 const apiId = lastSegment(proxy.name ?? "");
229 if (apiId.length === 0) continue;
230 const parent = mapName(organizationId, apiId, apiId);
231 if (!(yield* mapIsOwned(parent))) continue;
232 const entries = yield* apigee.listOrganizationsApisKeyvaluemapsEntries
233 .pages({
234 parent,
235 pageSize: 100,
236 })
237 .pipe(
238 Stream.flatMap((item) =>
239 Stream.fromIterable(item.keyValueEntries ?? []),
240 ),
241 Stream.filter(
242 (entry) => lastSegment(entry.name ?? "") !== OWNERSHIP_KEY,
243 ),
244 Stream.map((entry) => {
245 const name =
246 entry.name ??
247 resourceName(
248 organizationId,
249 apiId,
250 apiId,
251 lastSegment(entry.name ?? ""),
252 );
253 return toAttrs(
254 entry,
255 env.project,
256 organizationId,
257 apiId,
258 apiId,
259 name.includes("/")
260 ? name
261 : resourceName(organizationId, apiId, apiId, name),
262 );
263 }),
264 Stream.runCollect,
265 Effect.map((chunk) => Array.from(chunk)),
266 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
267 Effect.succeed([]),
268 ),
269 );
270 owned.push(...entries);
271 }
272 return owned;
273 }),
274
275 reconcile: Effect.fn(function* ({ id, news, output }) {
276 const env = yield* GcpEnvironment.current;
277 const organizationId =
278 news.organizationId ??
279 output?.organizationId ??
280 (yield* resolveOrgId(env.project));
281 const apiId = lastSegment(news.api);
282 const mapId = lastSegment(news.map);
283 const entryId = yield* toPhysicalId(
284 id,
285 news.entryId,
286 output?.entryId,
287 MAX_NAME_LENGTH,
288 );
289 const name = resourceName(organizationId, apiId, mapId, entryId);
290 const parent = mapName(organizationId, apiId, mapId);
291
292 let current = yield* getByName(output?.name ?? name);
293
294 if (current === undefined) {
295 const created = yield* apigee
296 .createOrganizationsApisKeyvaluemapsEntries({
297 parent,
298 body: { name: entryId, value: news.value },
299 })
300 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
301 current = created ?? undefined;
302 }
303
304 if (current === undefined) {
305 return yield* new ApisKeyvaluemapsEntryNotResolved({ name });
306 }
307
308 if ((current.value ?? "") !== news.value) {
309 current = yield* apigee.updateOrganizationsApisKeyvaluemapsEntries({
310 name,
311 body: { name: entryId, value: news.value },
312 });
313 }
314
315 return toAttrs(current, env.project, organizationId, apiId, mapId, name);
316 }),
317
318 delete: Effect.fn(function* ({ output }) {
319 if (output.entryId === OWNERSHIP_KEY) return;
320 yield* apigee
321 .deleteOrganizationsApisKeyvaluemapsEntries({ name: output.name })
322 .pipe(
323 Effect.catchTag(
324 ["NotFound", "ApigeeResourceNotFound"],
325 () => Effect.void,
326 ),
327 );
328 }),
329 });