161 lines
4.3 KiB
TypeScript
161 lines
4.3 KiB
TypeScript
|
|
import { ClarizenApiError } from "../errors.js";
|
||
|
|
import { ENDPOINTS } from "../endpoints.js";
|
||
|
|
import type { HttpClient } from "../http.js";
|
||
|
|
import {
|
||
|
|
filterStandardTypeNames,
|
||
|
|
sanitizeEntityDescription,
|
||
|
|
} from "./filter.js";
|
||
|
|
import type {
|
||
|
|
DescribeMetadataResult,
|
||
|
|
EntityMetadataEntry,
|
||
|
|
FetchMetadataCatalogOptions,
|
||
|
|
ListEntitiesResult,
|
||
|
|
MetadataCatalog,
|
||
|
|
MetadataFlag,
|
||
|
|
} from "./types.js";
|
||
|
|
|
||
|
|
const DEFAULT_FLAGS: MetadataFlag[] = ["fields", "relations"];
|
||
|
|
const DEFAULT_BATCH_SIZE = 20;
|
||
|
|
|
||
|
|
const DESCRIBE_METADATA_PATH =
|
||
|
|
ENDPOINTS.find(
|
||
|
|
(e) => e.namespace === "metadata" && e.operation === "DescribeMetadata",
|
||
|
|
)?.path ?? "/V2.0/services/metadata/describeMetadata";
|
||
|
|
|
||
|
|
const LIST_ENTITIES_PATH =
|
||
|
|
ENDPOINTS.find(
|
||
|
|
(e) => e.namespace === "metadata" && e.operation === "ListEntities",
|
||
|
|
)?.path ?? "/V2.0/services/metadata/listEntities";
|
||
|
|
|
||
|
|
function describeMetadataQuery(
|
||
|
|
typeNames: string[] | undefined,
|
||
|
|
flags: MetadataFlag[],
|
||
|
|
): Record<string, string | string[] | undefined> {
|
||
|
|
const query: Record<string, string | string[] | undefined> = {
|
||
|
|
flags,
|
||
|
|
};
|
||
|
|
if (typeNames !== undefined && typeNames.length > 0) {
|
||
|
|
query.typeNames = typeNames;
|
||
|
|
}
|
||
|
|
return query;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function listEntities(http: HttpClient): Promise<string[]> {
|
||
|
|
const result = await http.request<ListEntitiesResult>(LIST_ENTITIES_PATH, {
|
||
|
|
method: "GET",
|
||
|
|
});
|
||
|
|
return result.typeNames ?? [];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function describeMetadata(
|
||
|
|
http: HttpClient,
|
||
|
|
typeNames: string[] | undefined,
|
||
|
|
flags: MetadataFlag[],
|
||
|
|
): Promise<EntityMetadataEntry[]> {
|
||
|
|
const result = await http.request<DescribeMetadataResult>(
|
||
|
|
DESCRIBE_METADATA_PATH,
|
||
|
|
{
|
||
|
|
method: "GET",
|
||
|
|
query: describeMetadataQuery(typeNames, flags),
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
const entries: EntityMetadataEntry[] = [];
|
||
|
|
for (const desc of result.entityDescriptions ?? []) {
|
||
|
|
const entry = sanitizeEntityDescription(desc);
|
||
|
|
if (entry) {
|
||
|
|
entries.push(entry);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return entries;
|
||
|
|
}
|
||
|
|
|
||
|
|
function chunk<T>(items: T[], size: number): T[][] {
|
||
|
|
const chunks: T[][] = [];
|
||
|
|
for (let i = 0; i < items.length; i += size) {
|
||
|
|
chunks.push(items.slice(i, i + size));
|
||
|
|
}
|
||
|
|
return chunks;
|
||
|
|
}
|
||
|
|
|
||
|
|
function mergeEntries(
|
||
|
|
batches: EntityMetadataEntry[][],
|
||
|
|
): EntityMetadataEntry[] {
|
||
|
|
const byType = new Map<string, EntityMetadataEntry>();
|
||
|
|
for (const batch of batches) {
|
||
|
|
for (const entry of batch) {
|
||
|
|
byType.set(entry.typeName, entry);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return [...byType.values()].sort((a, b) =>
|
||
|
|
a.typeName.localeCompare(b.typeName),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function needsBatchedFetch(error: unknown): boolean {
|
||
|
|
if (error instanceof ClarizenApiError) {
|
||
|
|
return (
|
||
|
|
error.errorCode === "MissingArgument" ||
|
||
|
|
error.errorCode === "InvalidArgument"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (error instanceof Error) {
|
||
|
|
const msg = error.message.toLowerCase();
|
||
|
|
return msg.includes("missing") || msg.includes("typenames");
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchBatched(
|
||
|
|
http: HttpClient,
|
||
|
|
flags: MetadataFlag[],
|
||
|
|
batchSize: number,
|
||
|
|
log: (message: string) => void,
|
||
|
|
): Promise<EntityMetadataEntry[]> {
|
||
|
|
log("listEntities + batched describeMetadata...");
|
||
|
|
const rawNames = await listEntities(http);
|
||
|
|
const typeNames = filterStandardTypeNames(rawNames);
|
||
|
|
log(` ${typeNames.length} standard entities (${rawNames.length} total)`);
|
||
|
|
|
||
|
|
const batches = chunk(typeNames, batchSize);
|
||
|
|
const batchResults: EntityMetadataEntry[][] = [];
|
||
|
|
|
||
|
|
for (let i = 0; i < batches.length; i++) {
|
||
|
|
const batch = batches[i]!;
|
||
|
|
log(` batch ${i + 1}/${batches.length} (${batch.length} types)...`);
|
||
|
|
batchResults.push(await describeMetadata(http, batch, flags));
|
||
|
|
}
|
||
|
|
|
||
|
|
return mergeEntries(batchResults);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function fetchMetadataCatalog(
|
||
|
|
http: HttpClient,
|
||
|
|
options: FetchMetadataCatalogOptions = {},
|
||
|
|
): Promise<MetadataCatalog> {
|
||
|
|
const flags = options.flags ?? DEFAULT_FLAGS;
|
||
|
|
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
||
|
|
const log = options.onProgress ?? (() => {});
|
||
|
|
|
||
|
|
let entries: EntityMetadataEntry[];
|
||
|
|
|
||
|
|
try {
|
||
|
|
log("describeMetadata (all entities)...");
|
||
|
|
entries = await describeMetadata(http, undefined, flags);
|
||
|
|
if (entries.length === 0) {
|
||
|
|
entries = await fetchBatched(http, flags, batchSize, log);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
if (needsBatchedFetch(error)) {
|
||
|
|
entries = await fetchBatched(http, flags, batchSize, log);
|
||
|
|
} else {
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
fetchedAt: new Date().toISOString(),
|
||
|
|
entities: entries,
|
||
|
|
};
|
||
|
|
}
|