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 type AttributeValuesMap,
12 type Documentation,
13 createOwnership,
14 encodeOwnership,
15 hasOwnershipMarker,
16 listApis,
17 listChildResources,
18 listOperations,
19 listVersions,
20 normalizeLocation,
21 ownedByAlchemy,
22 parseOwnership,
23 parseResourceName,
24 replaceOnIdentity,
25 sameJson,
26 sameText,
27 toPhysicalId,
28 updateMaskOf,
29} from "./internal.ts";
30
31export type HttpOperation = {
32 /** HTTP method. Required on create. */
33 method?:
34 | "METHOD_UNSPECIFIED"
35 | "GET"
36 | "PUT"
37 | "POST"
38 | "DELETE"
39 | "OPTIONS"
40 | "HEAD"
41 | "PATCH"
42 | "TRACE"
43 | (string & {});
44 /** Path relative to the server endpoint. Required on create. */
45 path?: {
46 path?: string;
47 description?: string;
48 };
49};
50
51export type OperationDetails = {
52 /** HTTP operation. Required on create when not using MCP. */
53 httpOperation?: HttpOperation;
54 /** Description of the operation. Alchemy stamps ownership here. */
55 description?: string;
56 /** When true, the operation is deprecated. */
57 deprecated?: boolean;
58 /** External documentation. */
59 documentation?: Documentation;
60 /** MCP tool details. */
61 mcpTool?: apihub.GoogleCloudApihubV1McpTool;
62};
63
64export type ApisVersionsOperationProps = {
65 /**
66 * Parent version. Full name
67 * `projects/{project}/locations/{location}/apis/{api}/versions/{version}`.
68 * Immutable — changing it replaces the operation.
69 */
70 version: string;
71 /**
72 * Operation id (the `{operation}` segment of
73 * `.../versions/{version}/operations/{operation}`). If omitted, a unique
74 * id is generated. Immutable — changing it replaces the operation.
75 */
76 apiOperationId?: string;
77 /**
78 * Location used when parsing parent names.
79 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
80 */
81 location?: string;
82 /**
83 * Operation details. `httpOperation.method` and `httpOperation.path.path`
84 * are required on create. Description is stamped with `[alchemy …]`
85 * because operations have no labels field.
86 */
87 details: OperationDetails;
88 /**
89 * User-defined attributes keyed by attribute resource name.
90 */
91 attributes?: AttributeValuesMap;
92};
93
94export type ApisVersionsOperation = Resource<
95 "GCP.ApiHub.ApisVersionsOperation",
96 ApisVersionsOperationProps,
97 {
98 /** Full resource name. */
99 name: string;
100 /** Operation id (last path segment). */
101 apiOperationId: string;
102 /** Parent version resource name. */
103 version: string;
104 /** Project id. */
105 project: string;
106 /** Location id. */
107 location: string;
108 /** Spec that produced this operation, if parsed from a spec. */
109 spec: string | undefined;
110 /** Operation details with the Alchemy ownership prefix stripped. */
111 details: OperationDetails | undefined;
112 /** User-defined attributes. */
113 attributes: AttributeValuesMap | undefined;
114 /** RFC3339 creation timestamp. */
115 createTime: string | undefined;
116 /** RFC3339 last-update timestamp. */
117 updateTime: string | undefined;
118 },
119 never,
120 Providers
121>;
122
123/**
124 * An API operation in an API Hub version. Operations can be created only
125 * when the version has no spec-parsed operations.
126 *
127 * Parent version and id are immutable. Description, documentation, HTTP
128 * path/method, deprecation, and attributes update in place. Operations
129 * have no labels field — Alchemy stamps ownership into
130 * `details.description`.
131 *
132 * ### Creating an Operation
133 * **Example:** GET /pets
134 * ```typescript
135 * const version = yield* GCP.ApiHub.ApisVersion("V1", { api: api.name });
136 * const operation = yield* GCP.ApiHub.ApisVersionsOperation("ListPets", {
137 * version: version.name,
138 * details: {
139 * httpOperation: { method: "GET", path: { path: "/pets" } },
140 * description: "list pets",
141 * },
142 * });
143 * ```
144 *
145 * @resource
146 * @category ApiHub
147 */
148export const ApisVersionsOperation = Resource<ApisVersionsOperation>(
149 "GCP.ApiHub.ApisVersionsOperation",
150);
151
152const resourceName = (version: string, apiOperationId: string) =>
153 `${version}/operations/${apiOperationId}`;
154
155const toDetails = (
156 details: apihub.GoogleCloudApihubV1OperationDetails | undefined,
157): OperationDetails | undefined => {
158 if (details === undefined) return undefined;
159 const parsed = parseOwnership(details.description);
160 return {
161 httpOperation: details.httpOperation,
162 description: parsed.text,
163 deprecated: details.deprecated,
164 documentation: details.documentation,
165 mcpTool: details.mcpTool,
166 };
167};
168
169const toAttrs = (
170 operation: apihub.GoogleCloudApihubV1ApiOperation,
171 project: string,
172 region: string,
173): ApisVersionsOperation["Attributes"] => {
174 const name = operation.name ?? "";
175 const parsed = parseResourceName(name, "operations", region);
176 return {
177 name,
178 apiOperationId: parsed.id,
179 version: parsed.parent,
180 project: parsed.project || project,
181 location: parsed.location,
182 spec: operation.spec,
183 details: toDetails(operation.details),
184 attributes: operation.attributes,
185 createTime: operation.createTime,
186 updateTime: operation.updateTime,
187 };
188};
189
190const getByName = (name: string) =>
191 name.length === 0
192 ? Effect.succeed(undefined)
193 : apihub
194 .getProjectsLocationsApisVersionsOperations({ name })
195 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
196
197const detailsOf = (
198 news: ApisVersionsOperationProps,
199 description: string,
200): apihub.GoogleCloudApihubV1OperationDetails => ({
201 httpOperation: news.details.httpOperation,
202 description,
203 deprecated: news.details.deprecated,
204 documentation: news.details.documentation,
205 mcpTool: news.details.mcpTool,
206});
207
208export const ApisVersionsOperationProvider = () =>
209 Provider.succeed(ApisVersionsOperation, {
210 stables: [
211 "name",
212 "apiOperationId",
213 "version",
214 "project",
215 "location",
216 "createTime",
217 ],
218
219 diff: Effect.fn(function* ({ news, olds, output }) {
220 if (!isResolved(news)) return undefined;
221 const env = yield* GcpEnvironment.current;
222 return replaceOnIdentity({
223 previousId: olds?.apiOperationId ?? output?.apiOperationId,
224 nextId:
225 news.apiOperationId ?? olds?.apiOperationId ?? output?.apiOperationId,
226 previousLocation: normalizeLocation(
227 olds?.location ?? output?.location,
228 env.region,
229 ),
230 nextLocation: normalizeLocation(
231 news.location ?? olds?.location ?? output?.location,
232 env.region,
233 ),
234 previousParent: olds?.version ?? output?.version,
235 nextParent: news.version ?? olds?.version ?? output?.version,
236 });
237 }),
238
239 read: Effect.fn(function* ({ id, olds, output }) {
240 const env = yield* GcpEnvironment.current;
241 const version = olds?.version ?? output?.version ?? "";
242 const apiOperationId = yield* toPhysicalId(
243 id,
244 olds?.apiOperationId,
245 output?.apiOperationId,
246 );
247 const name = output?.name ?? resourceName(version, apiOperationId);
248 const existing = yield* getByName(name);
249 if (existing === undefined) return undefined;
250 const attrs = toAttrs(existing, env.project, env.region);
251 return (yield* ownedByAlchemy(id, existing.details?.description))
252 ? attrs
253 : Unowned(attrs);
254 }),
255
256 list: () =>
257 Effect.gen(function* () {
258 const env = yield* GcpEnvironment.current;
259 const apis = yield* listApis(
260 `projects/${env.project}/locations/${env.region}`,
261 );
262 const versions = yield* listChildResources(apis, listVersions);
263 const operations = yield* listChildResources(versions, listOperations);
264 return operations
265 .filter((item) => hasOwnershipMarker(item.details?.description))
266 .map((item) => toAttrs(item, env.project, env.region));
267 }),
268
269 reconcile: Effect.fn(function* ({ id, news, output }) {
270 const env = yield* GcpEnvironment.current;
271 const version = news.version.replace(/\/+$/, "");
272 const apiOperationId = yield* toPhysicalId(
273 id,
274 news.apiOperationId,
275 output?.apiOperationId,
276 );
277 const name = output?.name ?? resourceName(version, apiOperationId);
278 const ownership = yield* createOwnership(id);
279 const description = encodeOwnership(ownership, news.details.description);
280 const details = detailsOf(news, description);
281
282 let current = yield* getByName(name);
283
284 if (current === undefined) {
285 const created = yield* apihub
286 .createProjectsLocationsApisVersionsOperations({
287 parent: version,
288 apiOperationId,
289 body: {
290 details,
291 attributes: news.attributes,
292 },
293 })
294 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
295 current = created ?? undefined;
296 }
297
298 if (current === undefined) {
299 return yield* new ApihubNotResolved({ name });
300 }
301
302 const currentName = current.name ?? name;
303 const descriptionChanged = !sameText(
304 current.details?.description,
305 description,
306 );
307 const docsChanged = !sameJson(
308 current.details?.documentation,
309 news.details.documentation,
310 );
311 const pathChanged = !sameJson(
312 current.details?.httpOperation?.path,
313 news.details.httpOperation?.path,
314 );
315 const methodChanged = !sameText(
316 current.details?.httpOperation?.method,
317 news.details.httpOperation?.method,
318 );
319 const deprecatedChanged =
320 (current.details?.deprecated === true) !==
321 (news.details.deprecated === true);
322 const mcpChanged = !sameJson(
323 current.details?.mcpTool,
324 news.details.mcpTool,
325 );
326 const attributesChanged = !sameJson(current.attributes, news.attributes);
327
328 if (
329 descriptionChanged ||
330 docsChanged ||
331 pathChanged ||
332 methodChanged ||
333 deprecatedChanged ||
334 mcpChanged ||
335 attributesChanged
336 ) {
337 current = yield* apihub.patchProjectsLocationsApisVersionsOperations({
338 name: currentName,
339 updateMask: updateMaskOf(
340 descriptionChanged ? "details.description" : undefined,
341 docsChanged ? "details.documentation" : undefined,
342 pathChanged ? "details.http_operation.path" : undefined,
343 methodChanged ? "details.http_operation.method" : undefined,
344 deprecatedChanged ? "details.deprecated" : undefined,
345 mcpChanged ? "details.mcp_tool" : undefined,
346 attributesChanged ? "attributes" : undefined,
347 ),
348 body: {
349 name: currentName,
350 details,
351 attributes: news.attributes,
352 },
353 });
354 }
355
356 return toAttrs(current, env.project, env.region);
357 }),
358
359 delete: Effect.fn(function* ({ output }) {
360 yield* apihub
361 .deleteProjectsLocationsApisVersionsOperations({ name: output.name })
362 .pipe(Effect.catchTag("NotFound", () => Effect.void));
363 }),
364 });