1import * as apigateway from "@distilled.cloud/gcp/apigateway_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 type ApiConfigFile,
19 type ApiConfigGrpcServiceDefinition,
20 type ApiConfigOpenApiDocument,
21 DEFAULT_LOCATION,
22 encodeFiles,
23 encodeGrpcServices,
24 encodeOpenApiDocuments,
25 expandApi,
26 fieldMask,
27 hasAlchemyLabelMap,
28 listApis,
29 listChildResources,
30 listConfigs,
31 locationParent,
32 parseName,
33 replaceOnIdentity,
34 resourceName,
35 sameJson,
36 sameText,
37 toPhysicalId,
38 userLabels,
39 waitForOperation,
40 ResourceFailed,
41 waitUntilExists,
42 waitUntilGone,
43} from "./internal.ts";
44
45export type {
46 ApiConfigFile,
47 ApiConfigGrpcServiceDefinition,
48 ApiConfigOpenApiDocument,
49};
50
51export type ApisConfigProps = {
52 /**
53 * Parent API. Full name
54 * `projects/{project}/locations/global/apis/{api}` or the API id.
55 * Immutable — changing it replaces the config.
56 */
57 api: string;
58 /**
59 * Config id (the `{apiConfig}` segment of
60 * `.../apis/{api}/configs/{apiConfig}`). If omitted, a unique RFC1035
61 * id is generated. Immutable — changing it replaces the config.
62 */
63 apiConfigId?: string;
64 /**
65 * User-visible display name. Defaults to the config id.
66 */
67 displayName?: string;
68 /**
69 * OpenAPI specification documents. Mutually exclusive with
70 * `grpcServices`. Immutable — changing them replaces the config.
71 */
72 openapiDocuments?: readonly ApiConfigOpenApiDocument[];
73 /**
74 * gRPC service definitions. Mutually exclusive with `openapiDocuments`.
75 * Immutable — changing them replaces the config.
76 */
77 grpcServices?: readonly ApiConfigGrpcServiceDefinition[];
78 /**
79 * Service Configuration files used with gRPC service definitions.
80 * Immutable — changing them replaces the config.
81 */
82 managedServiceConfigs?: readonly ApiConfigFile[];
83 /**
84 * IAM service account Gateways should use to authenticate to backends.
85 * Email (`{account}@{project}.iam.gserviceaccount.com`) or full resource
86 * name. Immutable — changing it replaces the config.
87 */
88 gatewayServiceAccount?: string;
89 /**
90 * User labels. Alchemy ownership labels are merged in automatically.
91 */
92 labels?: Record<string, string>;
93};
94
95export type ApisConfig = Resource<
96 "GCP.ApiGateway.ApisConfig",
97 ApisConfigProps,
98 {
99 /** Full resource name. */
100 name: string;
101 /** Config id (last path segment). */
102 apiConfigId: string;
103 /** Parent API resource name. */
104 api: string;
105 /** Project id. */
106 project: string;
107 /** Location id (`global`). */
108 location: string;
109 /** User-visible display name. */
110 displayName: string | undefined;
111 /** Service account Gateways use to call backends. */
112 gatewayServiceAccount: string | undefined;
113 /** Associated Service Config id. */
114 serviceConfigId: string | undefined;
115 /** Server-reported config state. */
116 state: string | undefined;
117 /** User labels (Alchemy ownership labels stripped). */
118 labels: Record<string, string>;
119 /** RFC3339 creation timestamp. */
120 createTime: string | undefined;
121 /** RFC3339 last-update timestamp. */
122 updateTime: string | undefined;
123 },
124 never,
125 Providers
126>;
127
128/**
129 * An API Gateway API config — OpenAPI or gRPC settings for a managed
130 * API that Gateways serve.
131 *
132 * Parent API, config id, documents, gRPC definitions, and gateway
133 * service account are immutable. Display name and labels update in
134 * place. Configs live in `locations/global`.
135 *
136 * ### Creating an API Config
137 * **Example:** OpenAPI config
138 * ```typescript
139 * const config = yield* GCP.ApiGateway.ApisConfig("V1", {
140 * api: api.name,
141 * displayName: "v1",
142 * openapiDocuments: [
143 * {
144 * document: {
145 * path: "openapi.yaml",
146 * contents: openApi,
147 * },
148 * },
149 * ],
150 * labels: { env: "test" },
151 * });
152 * ```
153 *
154 * **Example:** Named config
155 * ```typescript
156 * const config = yield* GCP.ApiGateway.ApisConfig("V1", {
157 * api: api.name,
158 * apiConfigId: "v1",
159 * openapiDocuments: [
160 * {
161 * document: {
162 * path: "openapi.yaml",
163 * contents: openApi,
164 * },
165 * },
166 * ],
167 * });
168 * ```
169 *
170 * @resource
171 * @category ApiGateway
172 */
173export const ApisConfig = Resource<ApisConfig>("GCP.ApiGateway.ApisConfig");
174
175const toAttrs = (
176 config: apigateway.ApigatewayApiConfig,
177 project: string,
178 api?: string,
179) => {
180 const parsed = parseName(config.name ?? "", "configs");
181 // API Gateway reports names with the project number; keep the project-id
182 // form the parent API is addressed by so names are stable and comparable.
183 const parent =
184 api !== undefined && api.length > 0
185 ? api
186 : parsed.parent.replace(/^projects\/\d+\//, `projects/${project}/`);
187 const name =
188 config.name === undefined || config.name.length === 0
189 ? ""
190 : `${parent}/configs/${parsed.id}`;
191 return {
192 name,
193 apiConfigId: parsed.id,
194 api: parent,
195 project: parseName(parent, "apis").project || project,
196 location: parsed.location || DEFAULT_LOCATION,
197 displayName: config.displayName,
198 gatewayServiceAccount: config.gatewayServiceAccount,
199 serviceConfigId: config.serviceConfigId,
200 state: config.state,
201 labels: userLabels(config.labels),
202 createTime: config.createTime,
203 updateTime: config.updateTime,
204 };
205};
206
207const getByName = (name: string) =>
208 name.length === 0
209 ? Effect.succeed(undefined)
210 : apigateway
211 .getProjectsLocationsApisConfigs({ name, view: "FULL" })
212 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
213
214const documentsChanged = (
215 news: ApisConfigProps,
216 olds: ApisConfigProps | undefined,
217) =>
218 (news.openapiDocuments !== undefined &&
219 olds?.openapiDocuments !== undefined &&
220 !sameJson(news.openapiDocuments, olds.openapiDocuments)) ||
221 (news.grpcServices !== undefined &&
222 olds?.grpcServices !== undefined &&
223 !sameJson(news.grpcServices, olds.grpcServices)) ||
224 (news.managedServiceConfigs !== undefined &&
225 olds?.managedServiceConfigs !== undefined &&
226 !sameJson(news.managedServiceConfigs, olds.managedServiceConfigs));
227
228export const ApisConfigProvider = () =>
229 Provider.succeed(ApisConfig, {
230 stables: [
231 "name",
232 "apiConfigId",
233 "api",
234 "project",
235 "location",
236 "createTime",
237 ],
238
239 diff: Effect.fn(function* ({ news, olds, output }) {
240 if (!isResolved(news)) return undefined;
241 const env = yield* GcpEnvironment.current;
242 const accountChanged =
243 (olds?.gatewayServiceAccount ?? output?.gatewayServiceAccount) !==
244 undefined &&
245 (news.gatewayServiceAccount ?? "") !==
246 (olds?.gatewayServiceAccount ?? output?.gatewayServiceAccount ?? "");
247 return replaceOnIdentity({
248 previousId: olds?.apiConfigId ?? output?.apiConfigId,
249 nextId: news.apiConfigId ?? olds?.apiConfigId ?? output?.apiConfigId,
250 previousParent:
251 olds?.api !== undefined
252 ? expandApi(olds.api, env.project)
253 : output?.api,
254 nextParent: expandApi(news.api, env.project),
255 extra: documentsChanged(news, olds) || accountChanged,
256 });
257 }),
258
259 read: Effect.fn(function* ({ id, olds, output }) {
260 const env = yield* GcpEnvironment.current;
261 const apiConfigId = yield* toPhysicalId(
262 id,
263 olds?.apiConfigId,
264 output?.apiConfigId,
265 );
266 const api =
267 olds?.api !== undefined
268 ? expandApi(olds.api, env.project)
269 : (output?.api ?? "");
270 const name = output?.name ?? (api ? resourceName(api, apiConfigId) : "");
271 const existing = yield* getByName(name);
272 if (existing === undefined) return undefined;
273 const attrs = toAttrs(existing, env.project, api);
274 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
275 ? attrs
276 : Unowned(attrs);
277 }),
278
279 list: () =>
280 Effect.gen(function* () {
281 const env = yield* GcpEnvironment.current;
282 const apis = yield* listApis(locationParent(env.project));
283 const configs = yield* listChildResources(apis, listConfigs);
284 return configs
285 .filter((item) => hasAlchemyLabelMap(item.labels))
286 .map((item) => toAttrs(item, env.project));
287 }),
288
289 reconcile: Effect.fn(function* ({ id, news, output }) {
290 const env = yield* GcpEnvironment.current;
291 const api = expandApi(news.api, env.project);
292 const apiConfigId = yield* toPhysicalId(
293 id,
294 news.apiConfigId,
295 output?.apiConfigId,
296 );
297 const name = output?.name ?? resourceName(api, apiConfigId);
298 const desiredLabels = {
299 ...toLabels(news.labels),
300 ...(yield* createInternalLabels(id)),
301 };
302 const displayName = news.displayName ?? apiConfigId;
303 const openapiDocuments = yield* encodeOpenApiDocuments(
304 news.openapiDocuments,
305 );
306 const grpcServices = yield* encodeGrpcServices(news.grpcServices);
307 const managedServiceConfigs = yield* encodeFiles(
308 news.managedServiceConfigs,
309 );
310
311 let current = yield* getByName(output?.name ?? name);
312
313 if (current === undefined) {
314 const created = yield* apigateway
315 .createProjectsLocationsApisConfigs({
316 parent: api,
317 apiConfigId,
318 body: {
319 displayName,
320 labels: desiredLabels,
321 gatewayServiceAccount: news.gatewayServiceAccount,
322 openapiDocuments,
323 grpcServices,
324 managedServiceConfigs,
325 },
326 })
327 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
328 if (created !== undefined) {
329 yield* waitForOperation(created);
330 }
331 current = yield* waitUntilExists(getByName(name), name);
332 }
333
334 if ((current.state ?? "").toUpperCase() === "FAILED") {
335 return yield* new ResourceFailed({
336 name: current.name ?? name,
337 state: current.state ?? "FAILED",
338 });
339 }
340
341 const observedLabels = tagRecord(current.labels);
342 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
343 const labelsChanged = upsert.length > 0 || removed.length > 0;
344 const displayChanged = !sameText(current.displayName, displayName);
345 const mask = fieldMask([
346 labelsChanged && "labels",
347 displayChanged && "displayName",
348 ]);
349
350 if (mask.length > 0) {
351 const operation = yield* apigateway.patchProjectsLocationsApisConfigs({
352 name: current.name ?? name,
353 updateMask: mask,
354 body: {
355 name: current.name ?? name,
356 displayName,
357 labels: desiredLabels,
358 },
359 });
360 yield* waitForOperation(operation);
361 current = yield* waitUntilExists(
362 getByName(current.name ?? name),
363 current.name ?? name,
364 );
365 if ((current.state ?? "").toUpperCase() === "FAILED") {
366 return yield* new ResourceFailed({
367 name: current.name ?? name,
368 state: current.state ?? "FAILED",
369 });
370 }
371 }
372
373 return toAttrs(current, env.project, api);
374 }),
375
376 delete: Effect.fn(function* ({ output }) {
377 const operation = yield* apigateway
378 .deleteProjectsLocationsApisConfigs({ name: output.name })
379 .pipe(
380 Effect.retry({
381 while: (error) => error._tag === "Conflict",
382 times: 8,
383 schedule: Schedule.spaced("2 seconds"),
384 }),
385 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
386 );
387 if (operation !== undefined) {
388 yield* waitForOperation(operation, { notFoundOk: true });
389 }
390 yield* waitUntilGone(getByName(output.name), output.name);
391 }),
392 });