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 AttributeValues,
12 type AttributeValuesMap,
13 type Documentation,
14 MAX_DISPLAY_NAME_LENGTH,
15 createOwnership,
16 encodeContents,
17 encodeOwnershipLine,
18 hasOwnershipMarker,
19 listApis,
20 listChildResources,
21 listSpecs,
22 listVersions,
23 normalizeLocation,
24 openApiSpecType,
25 ownedByAlchemy,
26 parseOwnership,
27 parseResourceName,
28 replaceOnIdentity,
29 sameJson,
30 sameText,
31 toPhysicalId,
32 updateMaskOf,
33} from "./internal.ts";
34
35export type SpecContents = {
36 /**
37 * Spec document. Raw UTF-8 is base64-encoded before it is sent.
38 */
39 contents: string;
40 /**
41 * MIME type (`application/yaml`, `application/json`, …).
42 */
43 mimeType: string;
44};
45
46export type ApisVersionsSpecProps = {
47 /**
48 * Parent version. Full name
49 * `projects/{project}/locations/{location}/apis/{api}/versions/{version}`.
50 * Immutable — changing it replaces the spec.
51 */
52 version: string;
53 /**
54 * Spec id (the `{spec}` segment of
55 * `.../versions/{version}/specs/{spec}`). If omitted, a unique id is
56 * generated. Immutable — changing it replaces the spec.
57 */
58 specId?: string;
59 /**
60 * Location used when parsing parent names.
61 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
62 */
63 location?: string;
64 /**
65 * Display name (often the file name). Specs have no labels field, so
66 * Alchemy stamps ownership into this field for `list` / nuke.
67 */
68 displayName?: string;
69 /**
70 * URI of the spec in an external version-control system.
71 */
72 sourceUri?: string;
73 /**
74 * Spec document. Required on create when `sourceUri` is omitted.
75 * Input-only — subsequent updates send a new document when this field
76 * changes.
77 */
78 contents?: SpecContents;
79 /**
80 * Spec type. Defaults to the system OpenAPI enum when `contents` is
81 * provided.
82 */
83 specType?: AttributeValues;
84 /**
85 * OpenAPI parsing mode.
86 */
87 parsingMode?:
88 | "PARSING_MODE_UNSPECIFIED"
89 | "RELAXED"
90 | "STRICT"
91 | (string & {});
92 /**
93 * External documentation.
94 */
95 documentation?: Documentation;
96 /**
97 * User-defined attributes keyed by attribute resource name.
98 */
99 attributes?: AttributeValuesMap;
100};
101
102export type ApisVersionsSpec = Resource<
103 "GCP.ApiHub.ApisVersionsSpec",
104 ApisVersionsSpecProps,
105 {
106 /** Full resource name. */
107 name: string;
108 /** Spec id (last path segment). */
109 specId: string;
110 /** Parent version resource name. */
111 version: string;
112 /** Project id. */
113 project: string;
114 /** Location id. */
115 location: string;
116 /** Display name with the Alchemy ownership prefix stripped. */
117 displayName: string | undefined;
118 /** Source URI. */
119 sourceUri: string | undefined;
120 /** Spec type. */
121 specType: AttributeValues | undefined;
122 /** Parsed spec details. */
123 details: apihub.GoogleCloudApihubV1SpecDetails | undefined;
124 /** Documentation. */
125 documentation: Documentation | undefined;
126 /** User-defined attributes. */
127 attributes: AttributeValuesMap | undefined;
128 /** RFC3339 creation timestamp. */
129 createTime: string | undefined;
130 /** RFC3339 last-update timestamp. */
131 updateTime: string | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A spec attached to an API Hub version. Adding an OpenAPI spec parses
139 * operations onto the version.
140 *
141 * Parent version and id are immutable. Display name, source URI, contents,
142 * spec type, and attributes update in place. Specs have no labels field —
143 * Alchemy stamps ownership into the display name.
144 *
145 * ### Creating a Spec
146 * **Example:** OpenAPI YAML
147 * ```typescript
148 * const spec = yield* GCP.ApiHub.ApisVersionsSpec("OpenApi", {
149 * version: version.name,
150 * displayName: "openapi.yaml",
151 * contents: {
152 * contents: "openapi: 3.0.0\ninfo:\n title: pets\n version: 1.0.0\npaths: {}\n",
153 * mimeType: "application/yaml",
154 * },
155 * });
156 * ```
157 *
158 * @resource
159 * @category ApiHub
160 */
161export const ApisVersionsSpec = Resource<ApisVersionsSpec>(
162 "GCP.ApiHub.ApisVersionsSpec",
163);
164
165const resourceName = (version: string, specId: string) =>
166 `${version}/specs/${specId}`;
167
168const toAttrs = (
169 spec: apihub.GoogleCloudApihubV1Spec,
170 project: string,
171 region: string,
172): ApisVersionsSpec["Attributes"] => {
173 const name = spec.name ?? "";
174 const parsed = parseResourceName(name, "specs", region);
175 return {
176 name,
177 specId: parsed.id,
178 version: parsed.parent,
179 project: parsed.project || project,
180 location: parsed.location,
181 displayName: parseOwnership(spec.displayName).text,
182 sourceUri: spec.sourceUri,
183 specType: spec.specType,
184 details: spec.details,
185 documentation: spec.documentation,
186 attributes: spec.attributes,
187 createTime: spec.createTime,
188 updateTime: spec.updateTime,
189 };
190};
191
192const getByName = (name: string) =>
193 name.length === 0
194 ? Effect.succeed(undefined)
195 : apihub
196 .getProjectsLocationsApisVersionsSpecs({ name })
197 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
198
199export const ApisVersionsSpecProvider = () =>
200 Provider.succeed(ApisVersionsSpec, {
201 stables: ["name", "specId", "version", "project", "location", "createTime"],
202
203 diff: Effect.fn(function* ({ news, olds, output }) {
204 if (!isResolved(news)) return undefined;
205 const env = yield* GcpEnvironment.current;
206 return replaceOnIdentity({
207 previousId: olds?.specId ?? output?.specId,
208 nextId: news.specId ?? olds?.specId ?? output?.specId,
209 previousLocation: normalizeLocation(
210 olds?.location ?? output?.location,
211 env.region,
212 ),
213 nextLocation: normalizeLocation(
214 news.location ?? olds?.location ?? output?.location,
215 env.region,
216 ),
217 previousParent: olds?.version ?? output?.version,
218 nextParent: news.version ?? olds?.version ?? output?.version,
219 });
220 }),
221
222 read: Effect.fn(function* ({ id, olds, output }) {
223 const env = yield* GcpEnvironment.current;
224 const version = olds?.version ?? output?.version ?? "";
225 const specId = yield* toPhysicalId(id, olds?.specId, output?.specId);
226 const name = output?.name ?? resourceName(version, specId);
227 const existing = yield* getByName(name);
228 if (existing === undefined) return undefined;
229 const attrs = toAttrs(existing, env.project, env.region);
230 return (yield* ownedByAlchemy(id, existing.displayName))
231 ? attrs
232 : Unowned(attrs);
233 }),
234
235 list: () =>
236 Effect.gen(function* () {
237 const env = yield* GcpEnvironment.current;
238 const apis = yield* listApis(
239 `projects/${env.project}/locations/${env.region}`,
240 );
241 const versions = yield* listChildResources(apis, listVersions);
242 const specs = yield* listChildResources(versions, listSpecs);
243 return specs
244 .filter((item) => hasOwnershipMarker(item.displayName))
245 .map((item) => toAttrs(item, env.project, env.region));
246 }),
247
248 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
249 const env = yield* GcpEnvironment.current;
250 const version = news.version.replace(/\/+$/, "");
251 const specId = yield* toPhysicalId(id, news.specId, output?.specId);
252 const name = output?.name ?? resourceName(version, specId);
253 const ownership = yield* createOwnership(id);
254 const displayName = encodeOwnershipLine(
255 ownership,
256 news.displayName ?? specId,
257 MAX_DISPLAY_NAME_LENGTH,
258 );
259 const location = normalizeLocation(
260 news.location ?? output?.location,
261 env.region,
262 );
263 const specType =
264 news.specType ??
265 (news.contents ? openApiSpecType(env.project, location) : undefined);
266 const encodedContents =
267 news.contents === undefined
268 ? undefined
269 : {
270 contents: yield* encodeContents(news.contents.contents),
271 mimeType: news.contents.mimeType,
272 };
273
274 let current = yield* getByName(name);
275
276 if (current === undefined) {
277 const created = yield* apihub
278 .createProjectsLocationsApisVersionsSpecs({
279 parent: version,
280 specId,
281 body: {
282 displayName,
283 sourceUri: news.sourceUri,
284 contents: encodedContents,
285 specType,
286 parsingMode: news.parsingMode,
287 documentation: news.documentation,
288 attributes: news.attributes,
289 },
290 })
291 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
292 current = created ?? undefined;
293 }
294
295 if (current === undefined) {
296 return yield* new ApihubNotResolved({ name });
297 }
298
299 const currentName = current.name ?? name;
300 const displayChanged = !sameText(current.displayName, displayName);
301 const sourceChanged = !sameText(current.sourceUri, news.sourceUri);
302 const contentsChanged =
303 news.contents !== undefined && !sameJson(news.contents, olds?.contents);
304 const typeChanged =
305 specType !== undefined && !sameJson(current.specType, specType);
306 const attributesChanged = !sameJson(current.attributes, news.attributes);
307
308 if (
309 displayChanged ||
310 sourceChanged ||
311 contentsChanged ||
312 (contentsChanged && typeChanged) ||
313 attributesChanged
314 ) {
315 current = yield* apihub.patchProjectsLocationsApisVersionsSpecs({
316 name: currentName,
317 updateMask: updateMaskOf(
318 displayChanged ? "display_name" : undefined,
319 sourceChanged ? "source_uri" : undefined,
320 contentsChanged ? "contents" : undefined,
321 contentsChanged || typeChanged ? "spec_type" : undefined,
322 attributesChanged ? "attributes" : undefined,
323 ),
324 body: {
325 name: currentName,
326 displayName,
327 sourceUri: news.sourceUri,
328 contents: contentsChanged ? encodedContents : undefined,
329 specType: contentsChanged || typeChanged ? specType : undefined,
330 attributes: news.attributes,
331 },
332 });
333 }
334
335 return toAttrs(current, env.project, env.region);
336 }),
337
338 delete: Effect.fn(function* ({ output }) {
339 yield* apihub
340 .deleteProjectsLocationsApisVersionsSpecs({ name: output.name })
341 .pipe(Effect.catchTag("NotFound", () => Effect.void));
342 }),
343 });