1import * as apihub from "@distilled.cloud/gcp/apihub_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
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 type { Providers } from "../Providers.ts";
10import {
11 ApihubNotResolved,
12 encodeOwnership,
13 hasOwnershipMarker,
14 locationParent,
15 MAX_LONG_ID_LENGTH,
16 normalizeLocation,
17 ownedByAlchemy,
18 ownershipLabels,
19 parseName,
20 parseOwnership,
21 replaceOnIdentity,
22 sameJson,
23 sameStringList,
24 sameText,
25 toPhysicalId,
26 updateMaskOf,
27} from "./internal.ts";
28
29type AttributeValuesMap = apihub.GoogleCloudApihubV1AttributeValuesMap;
30type Documentation = apihub.GoogleCloudApihubV1Documentation;
31
32export type ExternalApiProps = {
33 /**
34 * External API id (the `{externalApi}` segment of
35 * `projects/{project}/locations/{location}/externalApis/{externalApi}`).
36 * If omitted, a unique id is generated. Immutable — changing it replaces
37 * the External API.
38 */
39 externalApiId?: string;
40 /**
41 * Location of the API Hub instance (`us-central1`, …). Immutable —
42 * changing it replaces the External API.
43 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
44 */
45 location?: string;
46 /**
47 * Display name. Max 63 characters.
48 */
49 displayName?: string;
50 /**
51 * Human-readable description. External APIs have no labels field, so
52 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
53 * from attributes.
54 */
55 description?: string;
56 /**
57 * Documentation URI.
58 */
59 documentation?: Documentation;
60 /**
61 * Endpoints on which this API is accessible.
62 */
63 endpoints?: string[];
64 /**
65 * Paths served by this API.
66 */
67 paths?: string[];
68 /**
69 * User-defined attributes keyed by attribute resource name.
70 */
71 attributes?: AttributeValuesMap;
72};
73
74export type ExternalApi = Resource<
75 "GCP.ApiHub.ExternalApi",
76 ExternalApiProps,
77 {
78 /** Full resource name. */
79 name: string;
80 /** External API id (last path segment). */
81 externalApiId: string;
82 /** Project id. */
83 project: string;
84 /** Location id. */
85 location: string;
86 /** Display name. */
87 displayName: string | undefined;
88 /** User description with the Alchemy ownership prefix stripped. */
89 description: string | undefined;
90 /** Documentation. */
91 documentation: Documentation | undefined;
92 /** Endpoints. */
93 endpoints: string[];
94 /** Paths. */
95 paths: string[];
96 /** User-defined attributes. */
97 attributes: AttributeValuesMap | undefined;
98 /** RFC3339 creation timestamp. */
99 createTime: string | undefined;
100 /** RFC3339 last-update timestamp. */
101 updateTime: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * An API Hub External API — a third-party API modeled so it can be
109 * referenced from dependencies.
110 *
111 * External APIs have no labels, so Alchemy stamps ownership into the
112 * description for `list` / nuke. Location and id are identity — changing
113 * them replaces the resource. Display name, description, documentation,
114 * endpoints, and paths update in place.
115 *
116 * ### Creating an External API
117 * **Example:** Generated id
118 * ```typescript
119 * const stripe = yield* GCP.ApiHub.ExternalApi("Stripe", {
120 * displayName: "Stripe",
121 * endpoints: ["https://api.stripe.com"],
122 * paths: ["/v1/charges"],
123 * });
124 * ```
125 *
126 * **Example:** Named External API
127 * ```typescript
128 * const stripe = yield* GCP.ApiHub.ExternalApi("Stripe", {
129 * externalApiId: "stripe-prod",
130 * displayName: "Stripe",
131 * description: "payments",
132 * endpoints: ["https://api.stripe.com"],
133 * });
134 * ```
135 *
136 * ### Updating an External API
137 * **Example:** Change description and paths
138 * ```typescript
139 * const stripe = yield* GCP.ApiHub.ExternalApi("Stripe", {
140 * displayName: "Stripe",
141 * description: "payments (updated)",
142 * paths: ["/v1/charges", "/v1/customers"],
143 * });
144 * ```
145 *
146 * @resource
147 * @category ApiHub
148 */
149export const ExternalApi = Resource<ExternalApi>("GCP.ApiHub.ExternalApi");
150
151const resourceName = (
152 project: string,
153 location: string,
154 externalApiId: string,
155) => `${locationParent(project, location)}/externalApis/${externalApiId}`;
156
157const toAttrs = (
158 api: apihub.GoogleCloudApihubV1ExternalApi,
159 project: string,
160 region: string,
161) => {
162 const name = api.name ?? "";
163 const parsed = parseName(name, "externalApis", region);
164 const { text } = parseOwnership(api.description);
165 return {
166 name,
167 externalApiId: parsed.id,
168 project: parsed.project || project,
169 location: parsed.location,
170 displayName: api.displayName,
171 description: text,
172 documentation: api.documentation,
173 endpoints: [...(api.endpoints ?? [])],
174 paths: [...(api.paths ?? [])],
175 attributes: api.attributes,
176 createTime: api.createTime,
177 updateTime: api.updateTime,
178 };
179};
180
181const getByName = (name: string) =>
182 name.length === 0
183 ? Effect.succeed(undefined)
184 : apihub
185 .getProjectsLocationsExternalApis({ name })
186 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
187
188const listAt = (parent: string, project: string, region: string) =>
189 apihub.listProjectsLocationsExternalApis
190 .pages({ parent, pageSize: 1000 })
191 .pipe(
192 Stream.flatMap((page) => Stream.fromIterable(page.externalApis ?? [])),
193 Stream.filter((item) => hasOwnershipMarker(item.description)),
194 Stream.map((item) => toAttrs(item, project, region)),
195 Stream.runCollect,
196 Effect.map((chunk) => Array.from(chunk)),
197 Effect.catchTag("NotFound", () => Effect.succeed([])),
198 );
199
200export const ExternalApiProvider = () =>
201 Provider.succeed(ExternalApi, {
202 stables: ["name", "externalApiId", "project", "location", "createTime"],
203
204 diff: Effect.fn(function* ({ news, olds, output }) {
205 if (!isResolved(news)) return undefined;
206 const env = yield* GcpEnvironment.current;
207 return replaceOnIdentity({
208 previousId: olds?.externalApiId ?? output?.externalApiId,
209 nextId:
210 news.externalApiId ?? olds?.externalApiId ?? output?.externalApiId,
211 previousLocation: normalizeLocation(
212 olds?.location ?? output?.location,
213 env.region,
214 ),
215 nextLocation: normalizeLocation(
216 news.location ?? olds?.location ?? output?.location,
217 env.region,
218 ),
219 });
220 }),
221
222 read: Effect.fn(function* ({ id, olds, output }) {
223 const env = yield* GcpEnvironment.current;
224 const externalApiId = yield* toPhysicalId(
225 id,
226 olds?.externalApiId,
227 output?.externalApiId,
228 MAX_LONG_ID_LENGTH,
229 );
230 const location = normalizeLocation(
231 olds?.location ?? output?.location,
232 env.region,
233 );
234 const name =
235 output?.name ?? resourceName(env.project, location, externalApiId);
236 const existing = yield* getByName(name);
237 if (existing === undefined) return undefined;
238 const attrs = toAttrs(existing, env.project, env.region);
239 return (yield* ownedByAlchemy(id, existing.description))
240 ? attrs
241 : Unowned(attrs);
242 }),
243
244 list: () =>
245 Effect.gen(function* () {
246 const env = yield* GcpEnvironment.current;
247 return yield* listAt(
248 locationParent(env.project, env.region),
249 env.project,
250 env.region,
251 );
252 }),
253
254 reconcile: Effect.fn(function* ({ id, news, output }) {
255 const env = yield* GcpEnvironment.current;
256 const location = normalizeLocation(
257 news.location ?? output?.location,
258 env.region,
259 );
260 const externalApiId = yield* toPhysicalId(
261 id,
262 news.externalApiId,
263 output?.externalApiId,
264 MAX_LONG_ID_LENGTH,
265 );
266 const name = resourceName(env.project, location, externalApiId);
267 const ownership = yield* ownershipLabels(id);
268 const description = encodeOwnership(ownership, news.description);
269 const displayName = (news.displayName ?? externalApiId).slice(0, 63);
270 const parent = locationParent(env.project, location);
271
272 let current = yield* getByName(output?.name ?? name);
273
274 if (current === undefined) {
275 const created = yield* apihub
276 .createProjectsLocationsExternalApis({
277 parent,
278 externalApiId,
279 body: {
280 displayName,
281 description,
282 documentation: news.documentation,
283 endpoints: news.endpoints,
284 paths: news.paths,
285 attributes: news.attributes,
286 },
287 })
288 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
289 current = created ?? (yield* getByName(name));
290 }
291
292 if (current === undefined) {
293 return yield* new ApihubNotResolved({ name });
294 }
295
296 const currentName = current.name ?? name;
297 const observed = parseOwnership(current.description).text;
298 const displayChanged = !sameText(current.displayName, displayName);
299 const descriptionChanged = !sameText(observed, news.description);
300 const documentationChanged = !sameJson(
301 current.documentation,
302 news.documentation,
303 );
304 const endpointsChanged = !sameStringList(
305 current.endpoints,
306 news.endpoints,
307 );
308 const pathsChanged = !sameStringList(current.paths, news.paths);
309
310 const updateMask = updateMaskOf(
311 displayChanged ? "display_name" : undefined,
312 descriptionChanged ? "description" : undefined,
313 documentationChanged ? "documentation" : undefined,
314 endpointsChanged ? "endpoints" : undefined,
315 pathsChanged ? "paths" : undefined,
316 );
317
318 if (updateMask.length > 0) {
319 current = yield* apihub.patchProjectsLocationsExternalApis({
320 name: currentName,
321 updateMask,
322 body: {
323 name: currentName,
324 displayName,
325 description,
326 documentation: news.documentation,
327 endpoints: news.endpoints,
328 paths: news.paths,
329 },
330 });
331 }
332
333 return toAttrs(current, env.project, env.region);
334 }),
335
336 delete: Effect.fn(function* ({ output }) {
337 yield* apihub
338 .deleteProjectsLocationsExternalApis({ name: output.name })
339 .pipe(Effect.catchTag("NotFound", () => Effect.void));
340 }),
341 });