1import * as apihub from "@distilled.cloud/gcp/apihub_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 ApihubNotResolved,
13 DEFAULT_PLUGIN_ACTIONS,
14 encodeOwnership,
15 hasOwnershipMarker,
16 locationParent,
17 MAX_PLUGIN_DISPLAY_NAME_LENGTH,
18 normalizeLocation,
19 ownedByAlchemy,
20 ownershipLabels,
21 parseName,
22 parseOwnership,
23 replaceOnIdentity,
24 sameJson,
25 sameText,
26 toPhysicalId,
27 waitUntilGone,
28} from "./internal.ts";
29import { waitForDeleteOperation } from "./internal.ts";
30
31type PluginActionConfig = apihub.GoogleCloudApihubV1PluginActionConfig;
32type PluginCategory = apihub.GoogleCloudApihubV1PluginPluginCategoryEnum;
33type PluginGatewayType = apihub.GoogleCloudApihubV1PluginGatewayTypeEnum;
34type HostingService = apihub.GoogleCloudApihubV1HostingService;
35type ConfigTemplate = apihub.GoogleCloudApihubV1ConfigTemplate;
36type AttributeValues = apihub.GoogleCloudApihubV1AttributeValues;
37type Documentation = apihub.GoogleCloudApihubV1Documentation;
38
39export type PluginProps = {
40 /**
41 * Plugin id (the `{plugin}` segment of
42 * `projects/{project}/locations/{location}/plugins/{plugin}`). If
43 * omitted, a unique id is generated. Immutable — changing it replaces
44 * the plugin.
45 */
46 pluginId?: string;
47 /**
48 * Location of the API Hub instance (`us-central1`, …). Immutable —
49 * changing it replaces the plugin.
50 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * Display name. Max 50 characters.
55 */
56 displayName?: string;
57 /**
58 * Human-readable description. Plugins have no labels field, so Alchemy
59 * stamps ownership into a `[alchemy …]` prefix and strips it from
60 * attributes. The API has no patch for plugins, so changing description
61 * (or other body fields) replaces the plugin.
62 */
63 description?: string;
64 /**
65 * Documentation URI that explains how to set up the plugin.
66 */
67 documentation?: Documentation;
68 /**
69 * Category of the plugin.
70 * @default "API_PRODUCER"
71 */
72 pluginCategory?: PluginCategory | (string & {});
73 /**
74 * Gateway type for on-ramp plugins.
75 */
76 gatewayType?: PluginGatewayType | (string & {});
77 /**
78 * Actions supported by the plugin. Required by the API; a default
79 * on-demand `sync-metadata` action is used when omitted.
80 */
81 actionsConfig?: PluginActionConfig[];
82 /**
83 * Hosting service URI for user-defined plugins.
84 */
85 hostingService?: HostingService;
86 /**
87 * Configuration template (auth + additional variables).
88 */
89 configTemplate?: ConfigTemplate;
90 /**
91 * Plugin type (`system-plugin-type` attribute).
92 */
93 type?: AttributeValues;
94 /**
95 * When false, disable the plugin after it exists. User-owned plugins
96 * created via the plugin framework manage state at the instance level.
97 * @default true
98 */
99 enabled?: boolean;
100};
101
102export type Plugin = Resource<
103 "GCP.ApiHub.Plugin",
104 PluginProps,
105 {
106 /** Full resource name. */
107 name: string;
108 /** Plugin id (last path segment). */
109 pluginId: string;
110 /** Project id. */
111 project: string;
112 /** Location id. */
113 location: string;
114 /** Display name. */
115 displayName: string | undefined;
116 /** User description with the Alchemy ownership prefix stripped. */
117 description: string | undefined;
118 /** Documentation. */
119 documentation: Documentation | undefined;
120 /** Category. */
121 pluginCategory: string | undefined;
122 /** Gateway type. */
123 gatewayType: string | undefined;
124 /** Action configuration. */
125 actionsConfig: PluginActionConfig[] | undefined;
126 /** Hosting service. */
127 hostingService: HostingService | undefined;
128 /** Config template. */
129 configTemplate: ConfigTemplate | undefined;
130 /** Plugin type attribute. */
131 type: AttributeValues | undefined;
132 /** Ownership type (`SYSTEM_OWNED` or `USER_OWNED`). */
133 ownershipType: string | undefined;
134 /** Plugin state. */
135 state: string | undefined;
136 /** RFC3339 creation timestamp. */
137 createTime: string | undefined;
138 /** RFC3339 last-update timestamp. */
139 updateTime: string | undefined;
140 },
141 never,
142 Providers
143>;
144
145/**
146 * An API Hub plugin — a user-owned or system-owned connector that
147 * publishes API metadata into Hub.
148 *
149 * Plugins have no labels and no patch RPC, so Alchemy stamps ownership
150 * into the description and treats body-field changes as replacements.
151 * Enable/disable syncs in place. Location and plugin id are identity.
152 *
153 * ### Creating a Plugin
154 * **Example:** Generated id with the default action
155 * ```typescript
156 * const plugin = yield* GCP.ApiHub.Plugin("OnRamp", {
157 * displayName: "on-ramp",
158 * description: "custom collector",
159 * });
160 * ```
161 *
162 * **Example:** Named plugin with an explicit action
163 * ```typescript
164 * const plugin = yield* GCP.ApiHub.Plugin("OnRamp", {
165 * pluginId: "orders-onramp",
166 * displayName: "orders on-ramp",
167 * pluginCategory: "API_PRODUCER",
168 * actionsConfig: [
169 * {
170 * id: "sync-metadata",
171 * displayName: "Sync metadata",
172 * description: "pull specs",
173 * triggerMode: "API_HUB_ON_DEMAND_TRIGGER",
174 * },
175 * ],
176 * });
177 * ```
178 *
179 * @resource
180 * @category ApiHub
181 */
182export const Plugin = Resource<Plugin>("GCP.ApiHub.Plugin");
183
184const resourceName = (project: string, location: string, pluginId: string) =>
185 `${locationParent(project, location)}/plugins/${pluginId}`;
186
187const toAttrs = (
188 plugin: apihub.GoogleCloudApihubV1Plugin,
189 project: string,
190 region: string,
191) => {
192 const name = plugin.name ?? "";
193 const parsed = parseName(name, "plugins", region);
194 const { text } = parseOwnership(plugin.description);
195 return {
196 name,
197 pluginId: parsed.id,
198 project: parsed.project || project,
199 location: parsed.location,
200 displayName: plugin.displayName,
201 description: text,
202 documentation: plugin.documentation,
203 pluginCategory: plugin.pluginCategory,
204 gatewayType: plugin.gatewayType,
205 actionsConfig: plugin.actionsConfig,
206 hostingService: plugin.hostingService,
207 configTemplate: plugin.configTemplate,
208 type: plugin.type,
209 ownershipType: plugin.ownershipType,
210 state: plugin.state,
211 createTime: plugin.createTime,
212 updateTime: plugin.updateTime,
213 };
214};
215
216const getByName = (name: string) =>
217 name.length === 0
218 ? Effect.succeed(undefined)
219 : apihub
220 .getProjectsLocationsPlugins({ name })
221 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
222
223const listAt = (parent: string, project: string, region: string) =>
224 apihub.listProjectsLocationsPlugins.pages({ parent, pageSize: 1000 }).pipe(
225 Stream.flatMap((page) => Stream.fromIterable(page.plugins ?? [])),
226 Stream.filter((item) => hasOwnershipMarker(item.description)),
227 Stream.map((item) => toAttrs(item, project, region)),
228 Stream.runCollect,
229 Effect.map((chunk) => Array.from(chunk)),
230 Effect.catchTag("NotFound", () => Effect.succeed([])),
231 );
232
233const actionsOf = (news: PluginProps) =>
234 news.actionsConfig ?? DEFAULT_PLUGIN_ACTIONS;
235
236const categoryOf = (news: PluginProps) => news.pluginCategory ?? "API_PRODUCER";
237
238const displayNameOf = (news: PluginProps, pluginId: string) =>
239 (news.displayName ?? pluginId).slice(0, MAX_PLUGIN_DISPLAY_NAME_LENGTH);
240
241export const PluginProvider = () =>
242 Provider.succeed(Plugin, {
243 stables: ["name", "pluginId", "project", "location", "createTime"],
244
245 diff: Effect.fn(function* ({ news, olds, output }) {
246 if (!isResolved(news)) return undefined;
247 const env = yield* GcpEnvironment.current;
248 const extra =
249 olds !== undefined &&
250 (!sameText(news.displayName, olds.displayName) ||
251 !sameText(news.description, olds.description) ||
252 !sameJson(news.documentation, olds.documentation) ||
253 !sameJson(news.actionsConfig, olds.actionsConfig) ||
254 !sameText(news.pluginCategory, olds.pluginCategory) ||
255 !sameText(news.gatewayType, olds.gatewayType) ||
256 !sameJson(news.hostingService, olds.hostingService) ||
257 !sameJson(news.configTemplate, olds.configTemplate) ||
258 !sameJson(news.type, olds.type));
259 return replaceOnIdentity({
260 previousId: olds?.pluginId ?? output?.pluginId,
261 nextId: news.pluginId ?? olds?.pluginId ?? output?.pluginId,
262 previousLocation: normalizeLocation(
263 olds?.location ?? output?.location,
264 env.region,
265 ),
266 nextLocation: normalizeLocation(
267 news.location ?? olds?.location ?? output?.location,
268 env.region,
269 ),
270 extra,
271 });
272 }),
273
274 read: Effect.fn(function* ({ id, olds, output }) {
275 const env = yield* GcpEnvironment.current;
276 const pluginId = yield* toPhysicalId(
277 id,
278 olds?.pluginId,
279 output?.pluginId,
280 );
281 const location = normalizeLocation(
282 olds?.location ?? output?.location,
283 env.region,
284 );
285 const name =
286 output?.name ?? resourceName(env.project, location, pluginId);
287 const existing = yield* getByName(name);
288 if (existing === undefined) return undefined;
289 const attrs = toAttrs(existing, env.project, env.region);
290 return (yield* ownedByAlchemy(id, existing.description))
291 ? attrs
292 : Unowned(attrs);
293 }),
294
295 list: () =>
296 Effect.gen(function* () {
297 const env = yield* GcpEnvironment.current;
298 return yield* listAt(
299 locationParent(env.project, env.region),
300 env.project,
301 env.region,
302 );
303 }),
304
305 reconcile: Effect.fn(function* ({ id, news, output }) {
306 const env = yield* GcpEnvironment.current;
307 const location = normalizeLocation(
308 news.location ?? output?.location,
309 env.region,
310 );
311 const pluginId = yield* toPhysicalId(id, news.pluginId, output?.pluginId);
312 const name = resourceName(env.project, location, pluginId);
313 const ownership = yield* ownershipLabels(id);
314 const description = encodeOwnership(ownership, news.description);
315 const displayName = displayNameOf(news, pluginId);
316 const parent = locationParent(env.project, location);
317
318 let current = yield* getByName(output?.name ?? name);
319
320 if (current === undefined) {
321 const created = yield* apihub
322 .createProjectsLocationsPlugins({
323 parent,
324 pluginId,
325 body: {
326 displayName,
327 description,
328 documentation: news.documentation,
329 pluginCategory: categoryOf(news),
330 gatewayType: news.gatewayType,
331 actionsConfig: actionsOf(news),
332 hostingService: news.hostingService,
333 configTemplate: news.configTemplate,
334 type: news.type,
335 },
336 })
337 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
338 current = created ?? (yield* getByName(name));
339 }
340
341 if (current === undefined) {
342 return yield* new ApihubNotResolved({ name });
343 }
344
345 const currentName = current.name ?? name;
346 const desiredEnabled = news.enabled !== false;
347 const currentlyEnabled = current.state !== "DISABLED";
348 if (desiredEnabled !== currentlyEnabled) {
349 current = desiredEnabled
350 ? yield* apihub.enableProjectsLocationsPlugins({
351 name: currentName,
352 body: {},
353 })
354 : yield* apihub.disableProjectsLocationsPlugins({
355 name: currentName,
356 body: {},
357 });
358 }
359
360 return toAttrs(current, env.project, env.region);
361 }),
362
363 delete: Effect.fn(function* ({ output }) {
364 const operation = yield* apihub
365 .deleteProjectsLocationsPlugins({ name: output.name })
366 .pipe(
367 Effect.retry({
368 while: (error) => error._tag === "Conflict",
369 times: 8,
370 schedule: Schedule.spaced("2 seconds"),
371 }),
372 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
373 );
374 if (operation !== undefined) {
375 yield* waitForDeleteOperation(operation);
376 }
377 yield* waitUntilGone(getByName(output.name), output.name);
378 }),
379 });