feat(foundry): add composable subject detail profiles
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
const IDENTIFIER = /^[a-z][a-z0-9._:-]{1,159}$/;
|
||||
const FIELD = /^[a-z][a-z0-9_.-]{0,127}$/;
|
||||
const SEMVER = /^\d+\.\d+\.\d+$/;
|
||||
const SECRET_LIKE = /(?:token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key|imei|phone|decrypt|address|raw[_-]?params?)/i;
|
||||
const PROFILE_KEYS = new Set(["id", "version", "title", "semanticTypes", "defaultTabId", "tabs"]);
|
||||
const TAB_KEYS = new Set(["id", "label", "emptyMessage", "sections"]);
|
||||
const SECTION_KEYS = new Set(["id", "label", "fields"]);
|
||||
const FIELD_KEYS = new Set(["id", "aspectId", "source", "field", "label", "format", "unit", "allowedReadingIds"]);
|
||||
const SOURCES = new Set(["fact", "attribute", "geometry", "context"]);
|
||||
const FORMATS = new Set([
|
||||
"text", "number", "timestamp", "boolean", "coordinate",
|
||||
"signal_state", "movement_state", "telemetry_readings",
|
||||
]);
|
||||
const FACT_FIELDS = new Set(["sourceId", "semanticType", "observedAt", "receivedAt", "presentationStatus"]);
|
||||
const GEOMETRY_FIELDS = new Set(["latitude", "longitude"]);
|
||||
const CONTEXT_FIELDS = new Set(["dataProductId", "bindingId"]);
|
||||
|
||||
export function normalizeMapSubjectDetailProfile(value) {
|
||||
object(value, "invalid_map_subject_detail_profile");
|
||||
onlyKeys(value, PROFILE_KEYS, "invalid_map_subject_detail_profile_fields");
|
||||
const id = identifier(value.id, "invalid_map_subject_detail_profile_id");
|
||||
const version = text(value.version, 32, "invalid_map_subject_detail_profile_version");
|
||||
if (!SEMVER.test(version)) fail("invalid_map_subject_detail_profile_version");
|
||||
const title = safeLabel(value.title, 120, "invalid_map_subject_detail_profile_title");
|
||||
const semanticTypes = identifiers(value.semanticTypes, 1, 8, "invalid_map_subject_detail_profile_semantic_types");
|
||||
if (!Array.isArray(value.tabs) || value.tabs.length < 1 || value.tabs.length > 12) {
|
||||
fail("invalid_map_subject_detail_profile_tabs");
|
||||
}
|
||||
const tabs = value.tabs.map(normalizeTab);
|
||||
unique(tabs.map((tab) => tab.id), "duplicate_map_subject_detail_profile_tab_id");
|
||||
const defaultTabId = identifier(value.defaultTabId, "invalid_map_subject_detail_profile_default_tab");
|
||||
if (!tabs.some((tab) => tab.id === defaultTabId)) fail("map_subject_detail_profile_default_tab_not_found");
|
||||
const fieldIds = tabs.flatMap((tab) => tab.sections.flatMap((section) => section.fields.map((field) => field.id)));
|
||||
unique(fieldIds, "duplicate_map_subject_detail_profile_field_id");
|
||||
return { id, version, title, semanticTypes, defaultTabId, tabs };
|
||||
}
|
||||
|
||||
export function normalizeMapSubjectDetailProfiles(value) {
|
||||
if (!Array.isArray(value) || value.length > 32) fail("invalid_map_subject_detail_profiles");
|
||||
const profiles = value.map(normalizeMapSubjectDetailProfile);
|
||||
unique(profiles.map((profile) => profile.id), "duplicate_map_subject_detail_profile_id");
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function normalizeTab(value) {
|
||||
object(value, "invalid_map_subject_detail_profile_tab");
|
||||
onlyKeys(value, TAB_KEYS, "invalid_map_subject_detail_profile_tab_fields");
|
||||
if (!Array.isArray(value.sections) || value.sections.length > 12) fail("invalid_map_subject_detail_profile_sections");
|
||||
const sections = value.sections.map(normalizeSection);
|
||||
unique(sections.map((section) => section.id), "duplicate_map_subject_detail_profile_section_id");
|
||||
return {
|
||||
id: identifier(value.id, "invalid_map_subject_detail_profile_tab_id"),
|
||||
label: safeLabel(value.label, 80, "invalid_map_subject_detail_profile_tab_label"),
|
||||
emptyMessage: safeLabel(value.emptyMessage, 240, "invalid_map_subject_detail_profile_empty_message"),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSection(value) {
|
||||
object(value, "invalid_map_subject_detail_profile_section");
|
||||
onlyKeys(value, SECTION_KEYS, "invalid_map_subject_detail_profile_section_fields");
|
||||
if (!Array.isArray(value.fields) || value.fields.length > 32) fail("invalid_map_subject_detail_profile_section_fields");
|
||||
return {
|
||||
id: identifier(value.id, "invalid_map_subject_detail_profile_section_id"),
|
||||
label: safeLabel(value.label, 80, "invalid_map_subject_detail_profile_section_label"),
|
||||
fields: value.fields.map(normalizeField),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeField(value) {
|
||||
object(value, "invalid_map_subject_detail_profile_field");
|
||||
onlyKeys(value, FIELD_KEYS, "invalid_map_subject_detail_profile_field_fields");
|
||||
const source = text(value.source, 32, "invalid_map_subject_detail_profile_field_source");
|
||||
const format = text(value.format, 32, "invalid_map_subject_detail_profile_field_format");
|
||||
if (!SOURCES.has(source)) fail("invalid_map_subject_detail_profile_field_source");
|
||||
if (!FORMATS.has(format)) fail("invalid_map_subject_detail_profile_field_format");
|
||||
const field = sourceField(source, value.field);
|
||||
if (source === "attribute" && SECRET_LIKE.test(field)) fail("map_subject_detail_profile_restricted_field");
|
||||
const allowedReadingIds = value.allowedReadingIds === undefined
|
||||
? []
|
||||
: fields(value.allowedReadingIds, 0, 256, "invalid_map_subject_detail_profile_reading_ids");
|
||||
if (allowedReadingIds.some((item) => SECRET_LIKE.test(item))) fail("map_subject_detail_profile_restricted_reading_id");
|
||||
if (format === "telemetry_readings" && (source !== "attribute" || field !== "sensor_readings")) {
|
||||
fail("map_subject_detail_profile_readings_source_invalid");
|
||||
}
|
||||
if (format !== "telemetry_readings" && allowedReadingIds.length) {
|
||||
fail("map_subject_detail_profile_reading_ids_not_allowed");
|
||||
}
|
||||
if (source === "geometry" && format !== "coordinate") fail("map_subject_detail_profile_geometry_format_invalid");
|
||||
const unit = value.unit === undefined ? undefined : safeLabel(value.unit, 24, "invalid_map_subject_detail_profile_field_unit");
|
||||
return {
|
||||
id: identifier(value.id, "invalid_map_subject_detail_profile_field_id"),
|
||||
aspectId: identifier(value.aspectId ?? "primary", "invalid_map_subject_detail_profile_aspect_id"),
|
||||
source,
|
||||
field,
|
||||
label: safeLabel(value.label, 100, "invalid_map_subject_detail_profile_field_label"),
|
||||
format,
|
||||
...(unit ? { unit } : {}),
|
||||
...(format === "telemetry_readings" ? { allowedReadingIds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sourceField(source, value) {
|
||||
const normalized = text(value, 128, "invalid_map_subject_detail_profile_field_name");
|
||||
if (source === "fact" && !FACT_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_fact_field");
|
||||
if (source === "geometry" && !GEOMETRY_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_geometry_field");
|
||||
if (source === "context" && !CONTEXT_FIELDS.has(normalized)) fail("invalid_map_subject_detail_profile_context_field");
|
||||
if (source === "attribute" && !FIELD.test(normalized)) fail("invalid_map_subject_detail_profile_attribute_field");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function onlyKeys(value, allowed, code) {
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) fail(code);
|
||||
}
|
||||
|
||||
function object(value, code) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code);
|
||||
}
|
||||
|
||||
function identifier(value, code) {
|
||||
const normalized = text(value, 160, code);
|
||||
if (!IDENTIFIER.test(normalized)) fail(code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function identifiers(value, min, max, code) {
|
||||
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
|
||||
const normalized = value.map((item) => identifier(item, code));
|
||||
unique(normalized, code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function fields(value, min, max, code) {
|
||||
if (!Array.isArray(value) || value.length < min || value.length > max) fail(code);
|
||||
const normalized = value.map((item) => {
|
||||
const result = text(item, 128, code);
|
||||
if (!FIELD.test(result)) fail(code);
|
||||
return result;
|
||||
});
|
||||
unique(normalized, code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function safeLabel(value, max, code) {
|
||||
const normalized = text(value, max, code);
|
||||
if (SECRET_LIKE.test(normalized)) fail(code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function text(value, max, code) {
|
||||
if (typeof value !== "string") fail(code);
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > max) fail(code);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function unique(values, code) {
|
||||
if (new Set(values).size !== values.length) fail(code);
|
||||
}
|
||||
|
||||
function fail(code) {
|
||||
throw Object.assign(new Error(code), { statusCode: 400 });
|
||||
}
|
||||
Reference in New Issue
Block a user