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 createOwnership,
13 encodeOwnership,
14 hasOwnershipMarker,
15 listDependencies,
16 locationParent,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 sameJson,
23 sameText,
24 toPhysicalId,
25 updateMaskOf,
26} from "./internal.ts";
27
28export type DependencyEntity = {
29 /**
30 * Operation resource name
31 * `projects/{project}/locations/{location}/apis/{api}/versions/{version}/operations/{operation}`.
32 */
33 operationResourceName?: string;
34 /**
35 * External API resource name
36 * `projects/{project}/locations/{location}/externalApis/{externalApi}`.
37 */
38 externalApiResourceName?: string;
39};
40
41export type DependencyProps = {
42 /**
43 * Dependency id (the `{dependency}` segment of
44 * `projects/{project}/locations/{location}/dependencies/{dependency}`).
45 * If omitted, a unique id is generated. Immutable — changing it
46 * replaces the dependency.
47 */
48 dependencyId?: string;
49 /**
50 * Location (`us-central1`, …). Immutable — changing it replaces the
51 * dependency.
52 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
53 */
54 location?: string;
55 /**
56 * Human-readable description. Dependencies have no labels field, so
57 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
58 * from attributes.
59 */
60 description?: string;
61 /**
62 * Consumer entity. Immutable — changing it replaces the dependency.
63 */
64 consumer: DependencyEntity;
65 /**
66 * Supplier entity. Immutable — changing it replaces the dependency.
67 */
68 supplier: DependencyEntity;
69 /**
70 * User-defined attributes keyed by attribute resource name.
71 */
72 attributes?: AttributeValuesMap;
73};
74
75export type Dependency = Resource<
76 "GCP.ApiHub.Dependency",
77 DependencyProps,
78 {
79 /** Full resource name. */
80 name: string;
81 /** Dependency id (last path segment). */
82 dependencyId: string;
83 /** Project id. */
84 project: string;
85 /** Location id. */
86 location: string;
87 /** User description with the Alchemy ownership prefix stripped. */
88 description: string | undefined;
89 /** Consumer entity. */
90 consumer: DependencyEntity | undefined;
91 /** Supplier entity. */
92 supplier: DependencyEntity | undefined;
93 /** Discovery mode. */
94 discoveryMode: string | undefined;
95 /** State. */
96 state: string | undefined;
97 /** Error detail if the system detected a problem. */
98 errorDetail: apihub.GoogleCloudApihubV1DependencyErrorDetail | undefined;
99 /** User-defined attributes. */
100 attributes: AttributeValuesMap | undefined;
101 /** RFC3339 creation timestamp. */
102 createTime: string | undefined;
103 /** RFC3339 last-update timestamp. */
104 updateTime: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A directed dependency in API Hub from a consumer operation (or external
112 * API) to a supplier operation (or external API).
113 *
114 * Location, id, consumer, and supplier are immutable. Description updates
115 * in place. Dependencies have no labels field — Alchemy stamps ownership
116 * into the description.
117 *
118 * ### Creating a Dependency
119 * **Example:** Operation to operation
120 * ```typescript
121 * const dependency = yield* GCP.ApiHub.Dependency("Calls", {
122 * consumer: { operationResourceName: listPets.name },
123 * supplier: { operationResourceName: getPet.name },
124 * description: "listPets calls getPet",
125 * });
126 * ```
127 *
128 * @resource
129 * @category ApiHub
130 */
131export const Dependency = Resource<Dependency>("GCP.ApiHub.Dependency");
132
133const resourceName = (
134 project: string,
135 location: string,
136 dependencyId: string,
137) => `${locationParent(project, location)}/dependencies/${dependencyId}`;
138
139const toEntity = (
140 entity: apihub.GoogleCloudApihubV1DependencyEntityReference | undefined,
141): DependencyEntity | undefined => {
142 if (entity === undefined) return undefined;
143 return {
144 operationResourceName: entity.operationResourceName,
145 externalApiResourceName: entity.externalApiResourceName,
146 };
147};
148
149const toAttrs = (
150 dependency: apihub.GoogleCloudApihubV1Dependency,
151 project: string,
152 region: string,
153): Dependency["Attributes"] => {
154 const name = dependency.name ?? "";
155 const parsed = parseResourceName(name, "dependencies", region);
156 return {
157 name,
158 dependencyId: parsed.id,
159 project: parsed.project || project,
160 location: parsed.location,
161 description: parseOwnership(dependency.description).text,
162 consumer: toEntity(dependency.consumer),
163 supplier: toEntity(dependency.supplier),
164 discoveryMode:
165 dependency.discoveryMode === undefined
166 ? undefined
167 : `${dependency.discoveryMode}`,
168 state: dependency.state === undefined ? undefined : `${dependency.state}`,
169 errorDetail: dependency.errorDetail,
170 attributes: dependency.attributes,
171 createTime: dependency.createTime,
172 updateTime: dependency.updateTime,
173 };
174};
175
176const getByName = (name: string) =>
177 name.length === 0
178 ? Effect.succeed(undefined)
179 : apihub
180 .getProjectsLocationsDependencies({ name })
181 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
182
183const entityOf = (
184 entity: DependencyEntity,
185): apihub.GoogleCloudApihubV1DependencyEntityReference => ({
186 operationResourceName: entity.operationResourceName,
187 externalApiResourceName: entity.externalApiResourceName,
188});
189
190export const DependencyProvider = () =>
191 Provider.succeed(Dependency, {
192 stables: ["name", "dependencyId", "project", "location", "createTime"],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 const env = yield* GcpEnvironment.current;
197 return replaceOnIdentity({
198 previousId: olds?.dependencyId ?? output?.dependencyId,
199 nextId: news.dependencyId ?? olds?.dependencyId ?? output?.dependencyId,
200 previousLocation: normalizeLocation(
201 olds?.location ?? output?.location,
202 env.region,
203 ),
204 nextLocation: normalizeLocation(
205 news.location ?? olds?.location ?? output?.location,
206 env.region,
207 ),
208 extra:
209 !sameJson(
210 news.consumer ?? olds?.consumer,
211 olds?.consumer ?? output?.consumer,
212 ) ||
213 !sameJson(
214 news.supplier ?? olds?.supplier,
215 olds?.supplier ?? output?.supplier,
216 ),
217 });
218 }),
219
220 read: Effect.fn(function* ({ id, olds, output }) {
221 const env = yield* GcpEnvironment.current;
222 const location = normalizeLocation(
223 olds?.location ?? output?.location,
224 env.region,
225 );
226 const dependencyId = yield* toPhysicalId(
227 id,
228 olds?.dependencyId,
229 output?.dependencyId,
230 );
231 const name =
232 output?.name ?? resourceName(env.project, location, dependencyId);
233 const existing = yield* getByName(name);
234 if (existing === undefined) return undefined;
235 const attrs = toAttrs(existing, env.project, env.region);
236 return (yield* ownedByAlchemy(id, existing.description))
237 ? attrs
238 : Unowned(attrs);
239 }),
240
241 list: () =>
242 Effect.gen(function* () {
243 const env = yield* GcpEnvironment.current;
244 const items = yield* listDependencies(
245 locationParent(env.project, env.region),
246 );
247 return items
248 .filter((item) => hasOwnershipMarker(item.description))
249 .map((item) => toAttrs(item, env.project, env.region));
250 }),
251
252 reconcile: Effect.fn(function* ({ id, news, output }) {
253 const env = yield* GcpEnvironment.current;
254 const location = normalizeLocation(
255 news.location ?? output?.location,
256 env.region,
257 );
258 const parent = locationParent(env.project, location);
259 const dependencyId = yield* toPhysicalId(
260 id,
261 news.dependencyId,
262 output?.dependencyId,
263 );
264 const name =
265 output?.name ?? resourceName(env.project, location, dependencyId);
266 const ownership = yield* createOwnership(id);
267 const description = encodeOwnership(ownership, news.description);
268 const consumer = entityOf(news.consumer);
269 const supplier = entityOf(news.supplier);
270
271 let current = yield* getByName(name);
272
273 if (current === undefined) {
274 const created = yield* apihub
275 .createProjectsLocationsDependencies({
276 parent,
277 dependencyId,
278 body: {
279 description,
280 consumer,
281 supplier,
282 attributes: news.attributes,
283 },
284 })
285 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
286 current = created ?? undefined;
287 }
288
289 if (current === undefined) {
290 return yield* new ApihubNotResolved({ name });
291 }
292
293 const currentName = current.name ?? name;
294 const descriptionChanged = !sameText(current.description, description);
295
296 if (descriptionChanged) {
297 current = yield* apihub.patchProjectsLocationsDependencies({
298 name: currentName,
299 updateMask: updateMaskOf("description"),
300 body: {
301 name: currentName,
302 description,
303 },
304 });
305 }
306
307 return toAttrs(current, env.project, env.region);
308 }),
309
310 delete: Effect.fn(function* ({ output }) {
311 yield* apihub
312 .deleteProjectsLocationsDependencies({ name: output.name })
313 .pipe(Effect.catchTag("NotFound", () => Effect.void));
314 }),
315 });