1import * as apihub from "@distilled.cloud/gcp/apihub_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 ApihubNotResolved,
11 createOwnership,
12 encodeOwnership,
13 hasOwnershipMarker,
14 listCurations,
15 locationParent,
16 normalizeLocation,
17 ownedByAlchemy,
18 parseOwnership,
19 parseResourceName,
20 replaceOnIdentity,
21 sameJson,
22 sameText,
23 toPhysicalId,
24 updateMaskOf,
25} from "./internal.ts";
26
27export type ApplicationIntegrationEndpoint = {
28 /**
29 * REST URI that triggers an Application Integration workflow.
30 * Format:
31 * `https://integrations.googleapis.com/v1/projects/{project}/locations/{location}/integrations/{integration}:execute`
32 */
33 uri: string;
34 /** API trigger id of the Application Integration workflow. */
35 triggerId: string;
36};
37
38export type CurationEndpoint = {
39 /** Application Integration endpoint invoked for curation. */
40 applicationIntegrationEndpointDetails: ApplicationIntegrationEndpoint;
41};
42
43export type CurationProps = {
44 /**
45 * Curation id (the `{curation}` segment of
46 * `projects/{project}/locations/{location}/curations/{curation}`). If
47 * omitted, a unique id is generated. Immutable — changing it replaces
48 * the curation.
49 */
50 curationId?: string;
51 /**
52 * Location (`us-central1`, …). Immutable — changing it replaces the
53 * curation.
54 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
55 */
56 location?: string;
57 /**
58 * Display name. Defaults to the curation id.
59 */
60 displayName?: string;
61 /**
62 * Human-readable description. Curations have no labels field, so Alchemy
63 * stamps ownership into a `[alchemy …]` prefix and strips it from
64 * attributes.
65 */
66 description?: string;
67 /**
68 * Endpoint invoked with API metadata. Immutable — changing it replaces
69 * the curation.
70 */
71 endpoint: CurationEndpoint;
72};
73
74export type Curation = Resource<
75 "GCP.ApiHub.Curation",
76 CurationProps,
77 {
78 /** Full resource name. */
79 name: string;
80 /** Curation id (last path segment). */
81 curationId: 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 /** Endpoint. */
91 endpoint: CurationEndpoint | undefined;
92 /** Plugin instances using this curation. */
93 pluginInstanceActions: apihub.GoogleCloudApihubV1PluginInstanceActionID[];
94 /** Last execution state. */
95 lastExecutionState: string | undefined;
96 /** Last execution error code. */
97 lastExecutionErrorCode: string | undefined;
98 /** Last execution error message. */
99 lastExecutionErrorMessage: string | undefined;
100 /** RFC3339 creation timestamp. */
101 createTime: string | undefined;
102 /** RFC3339 last-update timestamp. */
103 updateTime: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A curation resource in API Hub. Plugin instances invoke the endpoint
111 * with API metadata and receive curated metadata back.
112 *
113 * Location, id, and endpoint are immutable. Display name and description
114 * update in place. Curations have no labels field — Alchemy stamps
115 * ownership into the description.
116 *
117 * ### Creating a Curation
118 * **Example:** Application Integration endpoint
119 * ```typescript
120 * const curation = yield* GCP.ApiHub.Curation("Curate", {
121 * displayName: "curate-apis",
122 * endpoint: {
123 * applicationIntegrationEndpointDetails: {
124 * uri: "https://integrations.googleapis.com/v1/projects/my-project/locations/us-central1/integrations/curate:execute",
125 * triggerId: "api_trigger/curate",
126 * },
127 * },
128 * });
129 * ```
130 *
131 * @resource
132 * @category ApiHub
133 */
134export const Curation = Resource<Curation>("GCP.ApiHub.Curation");
135
136const resourceName = (project: string, location: string, curationId: string) =>
137 `${locationParent(project, location)}/curations/${curationId}`;
138
139const toEndpoint = (
140 endpoint: apihub.GoogleCloudApihubV1Endpoint | undefined,
141): CurationEndpoint | undefined => {
142 const details = endpoint?.applicationIntegrationEndpointDetails;
143 if (details?.uri === undefined || details.triggerId === undefined) {
144 return undefined;
145 }
146 return {
147 applicationIntegrationEndpointDetails: {
148 uri: details.uri,
149 triggerId: details.triggerId,
150 },
151 };
152};
153
154const toAttrs = (
155 curation: apihub.GoogleCloudApihubV1Curation,
156 project: string,
157 region: string,
158) => {
159 const name = curation.name ?? "";
160 const parsed = parseResourceName(name, "curations", region);
161 return {
162 name,
163 curationId: parsed.id,
164 project: parsed.project || project,
165 location: parsed.location,
166 displayName: curation.displayName,
167 description: parseOwnership(curation.description).text,
168 endpoint: toEndpoint(curation.endpoint),
169 pluginInstanceActions: curation.pluginInstanceActions ?? [],
170 lastExecutionState: curation.lastExecutionState,
171 lastExecutionErrorCode: curation.lastExecutionErrorCode,
172 lastExecutionErrorMessage: curation.lastExecutionErrorMessage,
173 createTime: curation.createTime,
174 updateTime: curation.updateTime,
175 };
176};
177
178const getByName = (name: string) =>
179 name.length === 0
180 ? Effect.succeed(undefined)
181 : apihub
182 .getProjectsLocationsCurations({ name })
183 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
184
185export const CurationProvider = () =>
186 Provider.succeed(Curation, {
187 stables: ["name", "curationId", "project", "location", "createTime"],
188
189 diff: Effect.fn(function* ({ news, olds, output }) {
190 if (!isResolved(news)) return undefined;
191 const env = yield* GcpEnvironment.current;
192 return replaceOnIdentity({
193 previousId: olds?.curationId ?? output?.curationId,
194 nextId: news.curationId ?? olds?.curationId ?? output?.curationId,
195 previousLocation: normalizeLocation(
196 olds?.location ?? output?.location,
197 env.region,
198 ),
199 nextLocation: normalizeLocation(
200 news.location ?? olds?.location ?? output?.location,
201 env.region,
202 ),
203 extra: !sameJson(
204 news.endpoint ?? olds?.endpoint,
205 olds?.endpoint ?? output?.endpoint,
206 ),
207 });
208 }),
209
210 read: Effect.fn(function* ({ id, olds, output }) {
211 const env = yield* GcpEnvironment.current;
212 const location = normalizeLocation(
213 olds?.location ?? output?.location,
214 env.region,
215 );
216 const curationId = yield* toPhysicalId(
217 id,
218 olds?.curationId,
219 output?.curationId,
220 );
221 const name =
222 output?.name ?? resourceName(env.project, location, curationId);
223 const existing = yield* getByName(name);
224 if (existing === undefined) return undefined;
225 const attrs = toAttrs(existing, env.project, env.region);
226 return (yield* ownedByAlchemy(id, existing.description))
227 ? attrs
228 : Unowned(attrs);
229 }),
230
231 list: () =>
232 Effect.gen(function* () {
233 const env = yield* GcpEnvironment.current;
234 const items = yield* listCurations(
235 locationParent(env.project, env.region),
236 );
237 return items
238 .filter((item) => hasOwnershipMarker(item.description))
239 .map((item) => toAttrs(item, env.project, env.region));
240 }),
241
242 reconcile: Effect.fn(function* ({ id, news, output }) {
243 const env = yield* GcpEnvironment.current;
244 const location = normalizeLocation(
245 news.location ?? output?.location,
246 env.region,
247 );
248 const parent = locationParent(env.project, location);
249 const curationId = yield* toPhysicalId(
250 id,
251 news.curationId,
252 output?.curationId,
253 );
254 const name =
255 output?.name ?? resourceName(env.project, location, curationId);
256 const ownership = yield* createOwnership(id);
257 const description = encodeOwnership(ownership, news.description);
258 const displayName = news.displayName ?? curationId;
259 const endpoint: apihub.GoogleCloudApihubV1Endpoint = {
260 applicationIntegrationEndpointDetails:
261 news.endpoint.applicationIntegrationEndpointDetails,
262 };
263
264 let current = yield* getByName(name);
265
266 if (current === undefined) {
267 const created = yield* apihub
268 .createProjectsLocationsCurations({
269 parent,
270 curationId,
271 body: {
272 displayName,
273 description,
274 endpoint,
275 },
276 })
277 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
278 current = created ?? undefined;
279 }
280
281 if (current === undefined) {
282 return yield* new ApihubNotResolved({ name });
283 }
284
285 const currentName = current.name ?? name;
286 const displayChanged = !sameText(current.displayName, displayName);
287 const descriptionChanged = !sameText(current.description, description);
288
289 if (displayChanged || descriptionChanged) {
290 current = yield* apihub.patchProjectsLocationsCurations({
291 name: currentName,
292 updateMask: updateMaskOf(
293 displayChanged ? "display_name" : undefined,
294 descriptionChanged ? "description" : undefined,
295 ),
296 body: {
297 name: currentName,
298 displayName,
299 description,
300 },
301 });
302 }
303
304 return toAttrs(current, env.project, env.region);
305 }),
306
307 delete: Effect.fn(function* ({ output }) {
308 yield* apihub
309 .deleteProjectsLocationsCurations({ name: output.name })
310 .pipe(Effect.catchTag("NotFound", () => Effect.void));
311 }),
312 });