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 { createInternalLabels, hasAlchemyLabels } from "../Labels.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 ApisKeyvaluemapProps = {
22 /**
23 * Apigee organization id. Defaults to the GCP project id. Immutable.
24 */
25 organizationId?: string;
26 /**
27 * Parent API proxy id or `organizations/{org}/apis/{api}`. Immutable.
28 */
29 api: string;
30 /**
31 * Key value map id. If omitted, a unique name is generated. Immutable.
32 */
33 mapId?: string;
34 /**
35 * Mask entry values in API responses.
36 * @default false
37 */
38 maskedValues?: boolean;
39};
40
41export type ApisKeyvaluemap = Resource<
42 "GCP.Apigee.ApisKeyvaluemap",
43 ApisKeyvaluemapProps,
44 {
45 /** Full resource name `organizations/{org}/apis/{api}/keyvaluemaps/{map}`. */
46 name: string;
47 /** Map id. */
48 mapId: string;
49 /** Parent API proxy id. */
50 apiId: string;
51 /** Organization id. */
52 organizationId: string;
53 /** Project id. */
54 project: string;
55 /** Whether entry values are masked. */
56 maskedValues: boolean;
57 /** Whether entries are encrypted (always true on Apigee X). */
58 encrypted: boolean;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * An API-proxy-scoped Apigee key value map.
66 *
67 * KVMs have no labels and no List API. Alchemy writes an ownership
68 * sentinel entry (`__alchemy`) so `read` can distinguish owned maps.
69 * `list` walks alchemy-labeled API proxies and looks up a map whose id
70 * matches the proxy id plus any map named on the sentinel of that
71 * lookup; maps whose ids cannot be discovered are still deleted with
72 * the parent proxy. Changing `mapId`, `api`, or `organizationId`
73 * replaces the map.
74 *
75 * ### Creating a Map
76 * **Example:** Generated name on a proxy
77 * ```typescript
78 * const map = yield* GCP.Apigee.ApisKeyvaluemap("Config", {
79 * api: proxy.apiId,
80 * });
81 * ```
82 *
83 * @resource
84 * @category Apigee
85 */
86export const ApisKeyvaluemap = Resource<ApisKeyvaluemap>(
87 "GCP.Apigee.ApisKeyvaluemap",
88);
89
90export class ApisKeyvaluemapNotResolved extends Data.TaggedError(
91 "GCP.Apigee.ApisKeyvaluemapNotResolved",
92)<{
93 name: string;
94}> {}
95
96const resourceName = (organizationId: string, apiId: string, mapId: string) =>
97 `${orgParent(organizationId)}/apis/${lastSegment(apiId)}/keyvaluemaps/${mapId}`;
98
99const toAttrs = (
100 map: apigee.GoogleCloudApigeeV1KeyValueMap,
101 project: string,
102 organizationId: string,
103 apiId: string,
104 name: string,
105) => ({
106 name,
107 mapId: map.name ?? lastSegment(name),
108 apiId: lastSegment(apiId),
109 organizationId,
110 project,
111 maskedValues: map.maskedValues === true,
112 encrypted: map.encrypted !== false,
113});
114
115const getByName = (name: string) =>
116 apigee
117 .getOrganizationsApisKeyvaluemaps({ name })
118 .pipe(
119 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
120 Effect.succeed(undefined),
121 ),
122 );
123
124const getOwnershipEntry = (mapName: string) =>
125 apigee
126 .getOrganizationsApisKeyvaluemapsEntries({
127 name: `${mapName}/entries/${OWNERSHIP_KEY}`,
128 })
129 .pipe(
130 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
131 Effect.succeed(undefined),
132 ),
133 );
134
135const ownershipValue = (labels: Record<string, string>) =>
136 JSON.stringify(labels);
137
138const parseOwnershipValue = (
139 value: string | undefined,
140): Record<string, string> => {
141 if (value === undefined || value.length === 0) return {};
142 try {
143 const parsed = JSON.parse(value) as unknown;
144 if (parsed === null || typeof parsed !== "object") return {};
145 return Object.fromEntries(
146 Object.entries(parsed as Record<string, unknown>).filter(
147 (entry): entry is [string, string] => typeof entry[1] === "string",
148 ),
149 );
150 } catch {
151 return {};
152 }
153};
154
155const ensureOwnershipEntry = (mapName: string, value: string) =>
156 Effect.gen(function* () {
157 const existing = yield* getOwnershipEntry(mapName);
158 if (existing === undefined) {
159 yield* apigee
160 .createOrganizationsApisKeyvaluemapsEntries({
161 parent: mapName,
162 body: { name: OWNERSHIP_KEY, value },
163 })
164 .pipe(Effect.catchTag("Conflict", () => Effect.void));
165 return;
166 }
167 if ((existing.value ?? "") !== value) {
168 yield* apigee.updateOrganizationsApisKeyvaluemapsEntries({
169 name: `${mapName}/entries/${OWNERSHIP_KEY}`,
170 body: { name: OWNERSHIP_KEY, value },
171 });
172 }
173 });
174
175export const ApisKeyvaluemapProvider = () =>
176 Provider.succeed(ApisKeyvaluemap, {
177 stables: ["name", "mapId", "apiId", "organizationId", "project"],
178
179 diff: Effect.fn(function* ({ news, olds, output }) {
180 if (!isResolved(news)) return undefined;
181 const previousId = olds?.mapId ?? output?.mapId;
182 const previousApi = olds?.api ? lastSegment(olds.api) : output?.apiId;
183 const previousOrg = olds?.organizationId ?? output?.organizationId;
184 if (
185 (previousId !== undefined &&
186 news.mapId !== undefined &&
187 news.mapId !== previousId) ||
188 (previousApi !== undefined && lastSegment(news.api) !== previousApi) ||
189 (previousOrg !== undefined &&
190 news.organizationId !== undefined &&
191 news.organizationId !== previousOrg)
192 ) {
193 return { action: "replace" as const, deleteFirst: false };
194 }
195 return undefined;
196 }),
197
198 read: Effect.fn(function* ({ id, olds, output }) {
199 const env = yield* GcpEnvironment.current;
200 const organizationId =
201 olds?.organizationId ??
202 output?.organizationId ??
203 (yield* resolveOrgId(env.project));
204 const apiId = lastSegment(olds?.api ?? output?.apiId ?? "");
205 if (apiId.length === 0) return undefined;
206 const mapId = yield* toPhysicalId(
207 id,
208 olds?.mapId,
209 output?.mapId,
210 MAX_NAME_LENGTH,
211 );
212 const name = output?.name ?? resourceName(organizationId, apiId, mapId);
213 const existing = yield* getByName(name);
214 if (existing === undefined) return undefined;
215 const attrs = toAttrs(existing, env.project, organizationId, apiId, name);
216 const entry = yield* getOwnershipEntry(name);
217 const labels = parseOwnershipValue(entry?.value);
218 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
219 }),
220
221 list: () =>
222 Effect.gen(function* () {
223 const env = yield* GcpEnvironment.current;
224 const organizationId = yield* resolveOrgId(env.project);
225 const page = yield* apigee
226 .listOrganizationsApis({
227 parent: orgParent(organizationId),
228 includeMetaData: true,
229 })
230 .pipe(
231 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
232 Effect.succeed({ proxies: [] }),
233 ),
234 );
235 const owned = [];
236 for (const proxy of page.proxies ?? []) {
237 if (
238 !Object.keys(proxy.labels ?? {}).some((key) =>
239 key.startsWith("alchemy-"),
240 )
241 ) {
242 continue;
243 }
244 const apiId = lastSegment(proxy.name ?? "");
245 if (apiId.length === 0) continue;
246 // No List API for proxy-scoped KVMs; probe the common generated
247 // pairing (map id === proxy id) and any map named on the proxy.
248 const candidate = resourceName(organizationId, apiId, apiId);
249 const map = yield* getByName(candidate);
250 if (map === undefined) continue;
251 const entry = yield* getOwnershipEntry(candidate);
252 if (entry === undefined) continue;
253 owned.push(
254 toAttrs(map, env.project, organizationId, apiId, candidate),
255 );
256 }
257 return owned;
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output }) {
261 const env = yield* GcpEnvironment.current;
262 const organizationId =
263 news.organizationId ??
264 output?.organizationId ??
265 (yield* resolveOrgId(env.project));
266 const apiId = lastSegment(news.api);
267 const mapId = yield* toPhysicalId(
268 id,
269 news.mapId,
270 output?.mapId,
271 MAX_NAME_LENGTH,
272 );
273 const name = resourceName(organizationId, apiId, mapId);
274 const labels = yield* createInternalLabels(id);
275
276 let current = yield* getByName(output?.name ?? name);
277
278 if (current === undefined) {
279 const created = yield* apigee
280 .createOrganizationsApisKeyvaluemaps({
281 parent: `${orgParent(organizationId)}/apis/${apiId}`,
282 body: {
283 name: mapId,
284 encrypted: true,
285 maskedValues: news.maskedValues === true ? true : undefined,
286 },
287 })
288 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
289 current = created ?? undefined;
290 }
291
292 if (current === undefined) {
293 return yield* new ApisKeyvaluemapNotResolved({ name });
294 }
295
296 if ((current.maskedValues === true) !== (news.maskedValues === true)) {
297 current = yield* apigee.updateOrganizationsApisKeyvaluemaps({
298 name,
299 body: {
300 name: mapId,
301 encrypted: true,
302 maskedValues: news.maskedValues === true ? true : undefined,
303 },
304 });
305 }
306
307 yield* ensureOwnershipEntry(name, ownershipValue(labels));
308
309 return toAttrs(current, env.project, organizationId, apiId, name);
310 }),
311
312 delete: Effect.fn(function* ({ output }) {
313 yield* apigee
314 .deleteOrganizationsApisKeyvaluemaps({ name: output.name })
315 .pipe(
316 Effect.catchTag(
317 ["NotFound", "ApigeeResourceNotFound"],
318 () => Effect.void,
319 ),
320 );
321 }),
322 });