1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 lastSegment,
12 orgParent,
13 organizationFromName,
14 sameJson,
15 toResourceId,
16} from "./names.ts";
17import {
18 commentsHaveOwnership,
19 createInternalLabels,
20 encodeComments,
21 hasAlchemyLabels,
22 parseComments,
23} from "./ownership.ts";
24
25const MAX_NAME_LENGTH = 64;
26const DEFAULT_METRICS: apigee.GoogleCloudApigeeV1CustomReportMetric[] = [
27 { name: "message_count", function: "sum" },
28];
29
30export type ReportMetric = {
31 /** Metric name, for example `message_count`. */
32 name: string;
33 /** Aggregate function, for example `sum`. */
34 function?: string;
35};
36
37export type ReportPropertyValue = {
38 /** Attribute key. */
39 name?: string;
40 /** Attribute value. */
41 value?: string;
42};
43
44export type ReportProperty = {
45 /** Property name. */
46 property?: string;
47 /** Property values. */
48 value?: ReportPropertyValue[];
49};
50
51export type ReportProps = {
52 /**
53 * Apigee organization id. Defaults to the current GCP project id.
54 * Immutable — changing it replaces the report.
55 */
56 organization?: string;
57 /**
58 * Report id (the `{report}` segment of
59 * `organizations/{org}/reports/{report}`). If omitted, a unique name
60 * is generated from the stack, stage, and logical id. Immutable —
61 * changing it replaces the report.
62 */
63 reportId?: string;
64 /**
65 * Display name shown in the Apigee UI.
66 */
67 displayName?: string;
68 /**
69 * Metrics to chart. Defaults to `sum(message_count)`.
70 */
71 metrics?: ReportMetric[];
72 /**
73 * Dimensions to group by, for example `["apiproxy"]`.
74 */
75 dimensions?: string[];
76 /**
77 * Filter expression.
78 */
79 filter?: string;
80 /**
81 * Aggregation time unit (`second`, `minute`, `hour`, `day`, `week`,
82 * `month`).
83 */
84 timeUnit?: string;
85 /**
86 * Chart type for the UI.
87 */
88 chartType?: string;
89 /**
90 * Sort columns.
91 */
92 sortByCols?: string[];
93 /**
94 * Sort order (`asc` or `desc`).
95 */
96 sortOrder?: string;
97 /**
98 * Extra UI metadata properties.
99 */
100 properties?: ReportProperty[];
101 /**
102 * User comments. Alchemy ownership is stored as a leading
103 * `[alchemy …]` comment and stripped from attributes. Reports have no
104 * labels field.
105 */
106 comments?: string[];
107 /**
108 * User tags.
109 */
110 tags?: string[];
111};
112
113export type Report = Resource<
114 "GCP.Apigee.Report",
115 ReportProps,
116 {
117 /** Full resource name `organizations/{org}/reports/{report}`. */
118 name: string;
119 /** Report id (last path segment). */
120 reportId: string;
121 /** Apigee organization id. */
122 organization: string;
123 /** Display name. */
124 displayName: string | undefined;
125 /** Metrics. */
126 metrics: ReportMetric[];
127 /** Dimensions. */
128 dimensions: string[];
129 /** Filter expression. */
130 filter: string | undefined;
131 /** Aggregation time unit. */
132 timeUnit: string | undefined;
133 /** Chart type. */
134 chartType: string | undefined;
135 /** Sort columns. */
136 sortByCols: string[];
137 /** Sort order. */
138 sortOrder: string | undefined;
139 /** UI metadata properties. */
140 properties: ReportProperty[];
141 /** User comments with the Alchemy ownership comment stripped. */
142 comments: string[];
143 /** Tags. */
144 tags: string[];
145 /** Environment name reported by the API, if any. */
146 environment: string | undefined;
147 /** Creation time in milliseconds since epoch. */
148 createdAt: string | undefined;
149 /** Last modification time in milliseconds since epoch. */
150 lastModifiedAt: string | undefined;
151 },
152 never,
153 Providers
154>;
155
156/**
157 * An Apigee custom analytics report.
158 *
159 * Custom reports have no labels field, so Alchemy stamps ownership into
160 * a leading comments entry (`[alchemy …]`) for `list` / nuke. Name and
161 * organization are identity — changing them replaces the report.
162 * Metrics, dimensions, filter, chart type, and comments update in
163 * place.
164 *
165 * ### Creating a Report
166 * **Example:** Generated name, message count by proxy
167 * ```typescript
168 * const report = yield* GCP.Apigee.Report("Traffic", {
169 * metrics: [{ name: "message_count", function: "sum" }],
170 * dimensions: ["apiproxy"],
171 * });
172 * ```
173 *
174 * **Example:** Named report with a filter
175 * ```typescript
176 * const report = yield* GCP.Apigee.Report("Errors", {
177 * reportId: "app-errors",
178 * displayName: "application errors",
179 * metrics: [{ name: "error_count", function: "sum" }],
180 * dimensions: ["apiproxy"],
181 * filter: "response_status_code ge 400",
182 * });
183 * ```
184 *
185 * @resource
186 * @category Apigee
187 */
188export const Report = Resource<Report>("GCP.Apigee.Report");
189
190export class ReportNotResolved extends Data.TaggedError(
191 "GCP.Apigee.ReportNotResolved",
192)<{
193 name: string;
194}> {}
195
196const resourceName = (organization: string, reportId: string) =>
197 `${orgParent(organization)}/reports/${reportId}`;
198
199const reportIdOf = (report: apigee.GoogleCloudApigeeV1CustomReport) =>
200 lastSegment(report.name ?? "");
201
202const metricsOf = (
203 metrics: readonly { name?: string; function?: string }[] | undefined,
204): ReportMetric[] => {
205 const mapped = (metrics ?? DEFAULT_METRICS)
206 .filter(
207 (metric): metric is { name: string; function?: string } =>
208 metric.name !== undefined && metric.name.length > 0,
209 )
210 .map((metric) => ({
211 name: metric.name,
212 function: metric.function,
213 }));
214 return mapped.length > 0
215 ? mapped
216 : [{ name: "message_count", function: "sum" }];
217};
218
219const toAttrs = (
220 report: apigee.GoogleCloudApigeeV1CustomReport,
221 organization: string,
222) => {
223 const reportId = reportIdOf(report);
224 const name = report.name?.includes("/")
225 ? report.name
226 : resourceName(organization, reportId);
227 const parsed = parseComments(report.comments);
228 return {
229 name,
230 reportId,
231 organization:
232 report.organization ?? organizationFromName(name) ?? organization,
233 displayName: report.displayName,
234 metrics: metricsOf(report.metrics),
235 dimensions: [...(report.dimensions ?? [])],
236 filter: report.filter,
237 timeUnit: report.timeUnit,
238 chartType: report.chartType,
239 sortByCols: [...(report.sortByCols ?? [])],
240 sortOrder: report.sortOrder,
241 properties: (report.properties ?? []).map((property) => ({
242 property: property.property,
243 value: property.value ? [...property.value] : undefined,
244 })),
245 comments: parsed.comments,
246 tags: [...(report.tags ?? [])],
247 environment: report.environment,
248 createdAt: report.createdAt,
249 lastModifiedAt: report.lastModifiedAt,
250 };
251};
252
253const getByName = (name: string) =>
254 apigee
255 .getOrganizationsReports({ name })
256 .pipe(
257 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
258 Effect.succeed(undefined),
259 ),
260 );
261
262const toBody = (
263 news: ReportProps,
264 reportId: string,
265 comments: string[],
266): apigee.GoogleCloudApigeeV1CustomReport => ({
267 name: reportId,
268 displayName: news.displayName,
269 metrics: metricsOf(news.metrics),
270 dimensions: news.dimensions,
271 filter: news.filter,
272 timeUnit: news.timeUnit,
273 chartType: news.chartType,
274 sortByCols: news.sortByCols,
275 sortOrder: news.sortOrder,
276 properties: news.properties?.map((property) => ({
277 property: property.property,
278 value: property.value,
279 })),
280 comments,
281 tags: news.tags,
282});
283
284export const ReportProvider = () =>
285 Provider.succeed(Report, {
286 stables: ["name", "reportId", "organization", "createdAt"],
287
288 diff: Effect.fn(function* ({ news, olds, output }) {
289 if (!isResolved(news)) return undefined;
290 const previousId = olds?.reportId ?? output?.reportId;
291 const previousOrg = olds?.organization ?? output?.organization;
292 if (
293 (previousId !== undefined &&
294 news.reportId !== undefined &&
295 news.reportId !== previousId) ||
296 (previousOrg !== undefined &&
297 news.organization !== undefined &&
298 news.organization !== previousOrg)
299 ) {
300 return { action: "replace" as const, deleteFirst: true };
301 }
302 return undefined;
303 }),
304
305 read: Effect.fn(function* ({ id, olds, output }) {
306 const env = yield* GcpEnvironment.current;
307 const organization =
308 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
309 const reportId = yield* toResourceId(
310 id,
311 olds?.reportId,
312 output?.reportId,
313 MAX_NAME_LENGTH,
314 );
315 const name = output?.name ?? resourceName(organization, reportId);
316 const existing = yield* getByName(name);
317 if (existing === undefined) return undefined;
318 const attrs = toAttrs(existing, organization);
319 const { labels } = parseComments(existing.comments);
320 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
321 }),
322
323 list: () =>
324 Effect.gen(function* () {
325 const env = yield* GcpEnvironment.current;
326 const page = yield* apigee
327 .listOrganizationsReports({
328 parent: orgParent(env.project),
329 expand: true,
330 })
331 .pipe(
332 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
333 Effect.succeed({
334 qualifier: [] as apigee.GoogleCloudApigeeV1CustomReport[],
335 }),
336 ),
337 );
338 return (page.qualifier ?? [])
339 .filter((report) => commentsHaveOwnership(report.comments))
340 .map((report) => toAttrs(report, env.project));
341 }),
342
343 reconcile: Effect.fn(function* ({ id, news, output }) {
344 const env = yield* GcpEnvironment.current;
345 const organization =
346 news.organization ?? output?.organization ?? env.project;
347 const reportId = yield* toResourceId(
348 id,
349 news.reportId,
350 output?.reportId,
351 MAX_NAME_LENGTH,
352 );
353 const name = resourceName(organization, reportId);
354 const ownership = yield* createInternalLabels(id);
355 const desiredComments = encodeComments(ownership, news.comments);
356
357 let current = yield* getByName(output?.name ?? name);
358
359 if (current === undefined) {
360 const created = yield* apigee
361 .createOrganizationsReports({
362 parent: orgParent(organization),
363 body: toBody(news, reportId, desiredComments),
364 })
365 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
366 current = created ?? undefined;
367 }
368
369 if (current === undefined) {
370 return yield* new ReportNotResolved({ name });
371 }
372
373 const desired = toBody(news, reportId, desiredComments);
374 const changed =
375 (current.displayName ?? "") !== (desired.displayName ?? "") ||
376 !sameJson(metricsOf(current.metrics), metricsOf(desired.metrics)) ||
377 !sameJson(current.dimensions ?? [], desired.dimensions ?? []) ||
378 (current.filter ?? "") !== (desired.filter ?? "") ||
379 (current.timeUnit ?? "") !== (desired.timeUnit ?? "") ||
380 (current.chartType ?? "") !== (desired.chartType ?? "") ||
381 !sameJson(current.sortByCols ?? [], desired.sortByCols ?? []) ||
382 (current.sortOrder ?? "") !== (desired.sortOrder ?? "") ||
383 !sameJson(current.properties ?? [], desired.properties ?? []) ||
384 !sameJson(current.comments ?? [], desiredComments) ||
385 !sameJson(current.tags ?? [], desired.tags ?? []);
386
387 if (changed) {
388 current = yield* apigee.updateOrganizationsReports({
389 name: current.name?.includes("/") ? current.name : name,
390 body: desired,
391 });
392 }
393
394 return toAttrs(current, organization);
395 }),
396
397 delete: Effect.fn(function* ({ output }) {
398 yield* apigee
399 .deleteOrganizationsReports({ name: output.name })
400 .pipe(
401 Effect.catchTag(
402 ["NotFound", "ApigeeResourceNotFound"],
403 () => Effect.void,
404 ),
405 );
406 }),
407 });