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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 stripInternalLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 lastSegment,
20 orgParent,
21 resolveOrgId,
22 toPhysicalId,
23} from "./operations.ts";
24
25const MAX_NAME_LENGTH = 255;
26
27export type ApiProps = {
28 /**
29 * Apigee organization id. Defaults to the GCP project id. Immutable.
30 */
31 organizationId?: string;
32 /**
33 * API proxy id (`A-Za-z0-9._-`). If omitted, a unique name is
34 * generated. Immutable.
35 */
36 apiId?: string;
37 /**
38 * User labels. Alchemy ownership labels are merged in automatically.
39 */
40 labels?: Record<string, string>;
41 /**
42 * Parent Space resource id. Only applied on create.
43 */
44 space?: string;
45};
46
47export type Api = Resource<
48 "GCP.Apigee.Api",
49 ApiProps,
50 {
51 /** Full resource name `organizations/{org}/apis/{api}`. */
52 name: string;
53 /** API proxy id. */
54 apiId: string;
55 /** Organization id. */
56 organizationId: string;
57 /** Project id. */
58 project: string;
59 /** User labels (Alchemy ownership labels stripped). */
60 labels: Record<string, string>;
61 /** Parent Space id, if any. */
62 space: string | undefined;
63 /** Latest revision id. */
64 latestRevisionId: string | undefined;
65 /** Defined revisions. */
66 revision: string[];
67 /** API proxy type. */
68 apiProxyType: string | undefined;
69 /** Whether the proxy is read-only (archive-generated). */
70 readOnly: boolean;
71 /** Creation time in milliseconds since epoch. */
72 createdAt: string | undefined;
73 /** Last modification time in milliseconds since epoch. */
74 lastModifiedAt: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * An Apigee API proxy. The proxy is not serving traffic until a revision
82 * is deployed to an environment.
83 *
84 * Labels (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) stamp
85 * ownership so `list` / nuke can find proxies. Changing `apiId` or
86 * `organizationId` replaces the proxy.
87 *
88 * ### Creating a Proxy
89 * **Example:** Generated name
90 * ```typescript
91 * const proxy = yield* GCP.Apigee.Api("Orders", {});
92 * ```
93 *
94 * **Example:** Explicit id and labels
95 * ```typescript
96 * const proxy = yield* GCP.Apigee.Api("Orders", {
97 * apiId: "orders-v1",
98 * labels: { env: "prod" },
99 * });
100 * ```
101 *
102 * @resource
103 * @category Apigee
104 */
105export const Api = Resource<Api>("GCP.Apigee.Api");
106
107export class ApiNotResolved extends Data.TaggedError(
108 "GCP.Apigee.ApiNotResolved",
109)<{
110 name: string;
111}> {}
112
113const resourceName = (organizationId: string, apiId: string) =>
114 `${orgParent(organizationId)}/apis/${apiId}`;
115
116const userLabels = (
117 labels: Record<string, string | undefined> | null | undefined,
118): Record<string, string> => stripInternalLabels(tagRecord(labels));
119
120const toAttrs = (
121 proxy: apigee.GoogleCloudApigeeV1ApiProxy,
122 project: string,
123 organizationId: string,
124) => {
125 const apiId = lastSegment(proxy.name ?? "");
126 return {
127 name: resourceName(organizationId, apiId),
128 apiId,
129 organizationId,
130 project,
131 labels: userLabels(proxy.labels),
132 space: proxy.space,
133 latestRevisionId: proxy.latestRevisionId,
134 revision: [...(proxy.revision ?? [])],
135 apiProxyType: proxy.apiProxyType,
136 readOnly: proxy.readOnly === true,
137 createdAt: proxy.metaData?.createdAt,
138 lastModifiedAt: proxy.metaData?.lastModifiedAt,
139 };
140};
141
142const getByName = (name: string) =>
143 apigee
144 .getOrganizationsApis({ name })
145 .pipe(
146 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
147 Effect.succeed(undefined),
148 ),
149 );
150
151export const ApiProvider = () =>
152 Provider.succeed(Api, {
153 stables: ["name", "apiId", "organizationId", "project", "createdAt"],
154
155 diff: Effect.fn(function* ({ news, olds, output }) {
156 if (!isResolved(news)) return undefined;
157 const previousId = olds?.apiId ?? output?.apiId;
158 const previousOrg = olds?.organizationId ?? output?.organizationId;
159 if (
160 (previousId !== undefined &&
161 news.apiId !== undefined &&
162 news.apiId !== previousId) ||
163 (previousOrg !== undefined &&
164 news.organizationId !== undefined &&
165 news.organizationId !== previousOrg)
166 ) {
167 return { action: "replace" as const, deleteFirst: false };
168 }
169 return undefined;
170 }),
171
172 read: Effect.fn(function* ({ id, olds, output }) {
173 const env = yield* GcpEnvironment.current;
174 const organizationId =
175 olds?.organizationId ??
176 output?.organizationId ??
177 (yield* resolveOrgId(env.project));
178 const apiId = yield* toPhysicalId(
179 id,
180 olds?.apiId,
181 output?.apiId,
182 MAX_NAME_LENGTH,
183 );
184 const name = output?.name ?? resourceName(organizationId, apiId);
185 const existing = yield* getByName(name);
186 if (existing === undefined) return undefined;
187 const attrs = toAttrs(existing, env.project, organizationId);
188 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
189 ? attrs
190 : Unowned(attrs);
191 }),
192
193 list: () =>
194 Effect.gen(function* () {
195 const env = yield* GcpEnvironment.current;
196 const organizationId = yield* resolveOrgId(env.project);
197 const page = yield* apigee
198 .listOrganizationsApis({
199 parent: orgParent(organizationId),
200 includeMetaData: true,
201 })
202 .pipe(
203 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
204 Effect.succeed({ proxies: [] }),
205 ),
206 );
207 return (page.proxies ?? [])
208 .filter((proxy) =>
209 Object.keys(proxy.labels ?? {}).some((key) =>
210 key.startsWith("alchemy-"),
211 ),
212 )
213 .map((proxy) => toAttrs(proxy, env.project, organizationId));
214 }),
215
216 reconcile: Effect.fn(function* ({ id, news, output }) {
217 const env = yield* GcpEnvironment.current;
218 const organizationId =
219 news.organizationId ??
220 output?.organizationId ??
221 (yield* resolveOrgId(env.project));
222 const apiId = yield* toPhysicalId(
223 id,
224 news.apiId,
225 output?.apiId,
226 MAX_NAME_LENGTH,
227 );
228 const name = resourceName(organizationId, apiId);
229 const desiredLabels = {
230 ...toLabels(news.labels),
231 ...(yield* createInternalLabels(id)),
232 };
233
234 let current = yield* getByName(output?.name ?? name);
235
236 if (current === undefined) {
237 yield* apigee
238 .createOrganizationsApis({
239 parent: orgParent(organizationId),
240 name: apiId,
241 space: news.space,
242 })
243 .pipe(Effect.catchTag("Conflict", () => Effect.void));
244 current = yield* getByName(name);
245 }
246
247 if (current === undefined) {
248 return yield* new ApiNotResolved({ name });
249 }
250
251 const observedLabels = tagRecord(current.labels);
252 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
253 const labelsChanged = upsert.length > 0 || removed.length > 0;
254
255 if (labelsChanged) {
256 current = yield* apigee.patchOrganizationsApis({
257 name,
258 updateMask: "labels",
259 body: {
260 labels: desiredLabels,
261 },
262 });
263 }
264
265 return toAttrs(current, env.project, organizationId);
266 }),
267
268 delete: Effect.fn(function* ({ output }) {
269 yield* apigee
270 .deleteOrganizationsApis({ name: output.name })
271 .pipe(
272 Effect.catchTag(
273 ["NotFound", "ApigeeResourceNotFound"],
274 () => Effect.void,
275 ),
276 );
277 }),
278 });