@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
866 lines (760 loc) • 22.5 kB
text/typescript
import * as fs from "node:fs/promises";
import * as YAML from "yaml";
import {
errorFormatter,
findFiles,
formatErrorPath,
resolveDeepestIssue,
} from "@embeddable.com/sdk-utils";
import { DIMENSION_TYPES, MEASURE_TYPES } from "@embeddable.com/core";
import { z } from "zod";
import ora from "ora";
import { checkNodeVersion } from "./utils";
import { ResolvedEmbeddableConfig } from "./defineConfig";
export type ValidationIssue = {
filePath: string;
message: string;
line?: number;
column?: number;
path?: string;
};
export function formatIssue(issue: ValidationIssue): string {
return `${issue.filePath}: ${issue.message}`;
}
const CUBE_YAML_FILE_REGEX = /^(.*)\.cube\.ya?ml$/;
const SECURITY_CONTEXT_FILE_REGEX = /^(.*)\.sc\.ya?ml$/;
const CLIENT_CONTEXT_FILE_REGEX = /^(.*)\.cc\.ya?ml$/;
const EMBEDDABLE_FILE_REGEX = /^(.*)\.embeddable\.ya?ml$/;
export default async (ctx: ResolvedEmbeddableConfig) => {
checkNodeVersion();
const spinnerValidate = ora("Data model validation...").start();
const cubeFilesList = await findFiles(
ctx.client.modelsSrc || ctx.client.srcDir,
CUBE_YAML_FILE_REGEX,
);
const securityContextFilesList = await findFiles(
ctx.client.presetsSrc || ctx.client.srcDir,
SECURITY_CONTEXT_FILE_REGEX,
);
const clientContextFilesList = await findFiles(
ctx.client.presetsSrc || ctx.client.srcDir,
CLIENT_CONTEXT_FILE_REGEX,
);
const dataModelErrors = await dataModelsValidation(cubeFilesList);
if (dataModelErrors.length) {
spinnerValidate.fail("One or more cube.yaml files are invalid:");
dataModelErrors.forEach((errorMessage) =>
spinnerValidate.info(errorMessage),
);
process.exit(1);
}
spinnerValidate.succeed("Data model validation completed");
const securityContextErrors = await securityContextValidation(
securityContextFilesList,
);
const clientContextErrors = await clientContextValidation(
clientContextFilesList,
);
if (securityContextErrors.length) {
spinnerValidate.fail("One or more security context files are invalid:");
securityContextErrors.forEach((errorMessage) =>
spinnerValidate.info(errorMessage),
);
process.exit(1);
}
if (clientContextErrors.length) {
spinnerValidate.fail("One or more client context files are invalid:");
clientContextErrors.forEach((errorMessage) =>
spinnerValidate.info(errorMessage),
);
process.exit(1);
}
let embeddableIssues: ValidationIssue[] = [];
if (ctx.pushEmbeddables) {
const embeddableFilesList = await findFiles(
ctx.client.srcDir,
EMBEDDABLE_FILE_REGEX,
);
embeddableIssues = await embeddableValidation(embeddableFilesList);
if (embeddableIssues.length) {
spinnerValidate.fail("One or more embeddable.yml files are invalid:");
embeddableIssues.forEach((issue) =>
spinnerValidate.info(formatIssue(issue)),
);
process.exit(1);
}
}
return (
dataModelErrors.length === 0 &&
securityContextErrors.length === 0 &&
clientContextErrors.length === 0 &&
embeddableIssues.length === 0
);
};
export async function dataModelsValidation(filesList: [string, string][]) {
const errors: string[] = [];
for (const [_, filePath] of filesList) {
const fileContentRaw = await fs.readFile(filePath, "utf8");
try {
const cube = YAML.parse(fileContentRaw);
if (!cube?.cubes && !cube?.views) {
return [`${filePath}: At least one cubes or views must be defined`];
}
const cubeModelSafeParse = cubeModelSchema.safeParse(cube);
const viewModelSafeParse = viewModelSchema.safeParse(cube);
if (cube.cubes && !cubeModelSafeParse.success) {
errorFormatter(cubeModelSafeParse.error.issues).forEach((error) => {
errors.push(`${filePath}: ${error}`);
});
}
if (cube.views && !viewModelSafeParse.success) {
errorFormatter(viewModelSafeParse.error.issues).forEach((error) => {
errors.push(`${filePath}: ${error}`);
});
}
} catch (e: any) {
errors.push(`${filePath}: ${e.message}`);
}
}
return errors;
}
export async function securityContextValidation(filesList: [string, string][]) {
const errors: string[] = [];
const nameSet = new Set<string>();
for (const [_, filePath] of filesList) {
const fileContentRaw = await fs.readFile(filePath, "utf8");
const cube = YAML.parse(fileContentRaw);
cube.forEach((item: { name: string }) => {
if (nameSet.has(item.name)) {
errors.push(
`${filePath}: security context with name "${item.name}" already exists`,
);
} else {
nameSet.add(item.name);
}
});
const safeParse = securityContextSchema.safeParse(cube);
if (!safeParse.success) {
errorFormatter(safeParse.error.issues).forEach((error) => {
errors.push(`${filePath}: ${error}`);
});
}
}
return errors;
}
export async function clientContextValidation(filesList: [string, string][]) {
const errors: string[] = [];
const nameSet = new Set<string>();
for (const [_, filePath] of filesList) {
const fileContentRaw = await fs.readFile(filePath, "utf8");
const cube = YAML.parse(fileContentRaw);
cube.forEach((item: { name: string }) => {
if (nameSet.has(item.name)) {
errors.push(
`${filePath}: client context with name "${item.name}" already exists`,
);
} else {
nameSet.add(item.name);
}
});
const safeParse = clientContextSchema.safeParse(cube);
if (!safeParse.success) {
errorFormatter(safeParse.error.issues).forEach((error) => {
errors.push(`${filePath}: ${error}`);
});
}
}
return errors;
}
const cubeModelSchema = z
.object({
cubes: z
.object({
name: z.string(),
dimensions: z
.object({
name: z.string(),
type: z.enum(DIMENSION_TYPES),
})
.array()
.optional(),
measures: z
.object({
name: z.string(),
type: z.enum(MEASURE_TYPES),
})
.array()
.optional(),
})
.array()
.min(1),
})
.refine(
(data) =>
data.cubes.every(
(cube) => cube.dimensions?.length || cube.measures?.length,
),
{
message: "At least one measure or dimension must be defined",
path: ["cubes"],
},
);
const viewModelSchema = z.object({
views: z
.object({
name: z.string(),
cubes: z
.object({
join_path: z.string(),
})
.array(),
})
.array()
.min(1),
});
const securityContextSchema = z.array(
z
.object({
name: z.string(),
securityContext: z.object({}), // can be any object
environment: z.string().optional(),
useQueryRewrite: z.boolean().optional(),
dataProvider: z.string().optional(),
roles: z.array(z.string()).optional(),
filters: z.array(z.object({})).optional(),
})
.strict(),
);
const clientContextSchema = z.array(
z
.object({
name: z.string(),
clientContext: z.object({}), // can be any object
variables: z.record(z.any()).optional(), // variables key-value pairs
canvas: z.object({}).optional(),
})
.strict(),
);
// --- Embeddable YAML schemas ---
const valueTypeEnum = z.enum(["VALUE", "VARIABLE"]);
const sourceTypeEnum = z.enum(["EVENT_PROPERTY", "VARIABLE"]);
const positionSchema = z
.object({
x: z.number(),
y: z.number(),
})
.strict();
const dimensionsSchema = z
.object({
width: z.number(),
height: z.number(),
})
.strict();
const nestedInputSchema = z
.object({
input: z.string(),
value: z.any(),
valueType: valueTypeEnum,
parentValue: z.any().optional(),
})
.strict();
const filterSchema = z
.object({
member: z.string(),
operator: z.string(),
value: z.any().optional(),
valueType: valueTypeEnum.optional(),
})
.strict();
const inputConfigSchema = z
.object({
dataset: z.string().optional(),
filters: z.array(filterSchema).optional(),
limit: z.number().optional(),
order: z
.array(
z
.object({
member: z.string(),
direction: z.enum(["asc", "desc"]),
})
.strict(),
)
.optional(),
inputs: z.array(nestedInputSchema).optional(),
})
.strict();
const inputSchema = z
.object({
input: z.string(),
inputType: z.string(),
value: z.any().optional(),
valueType: valueTypeEnum,
array: z.boolean().optional(),
config: inputConfigSchema.optional(),
})
.strict();
const setVariableConfigSchema = z
.object({
variable: z.string(),
sourceType: sourceTypeEnum,
sourceValue: z.string(),
})
.strict();
const drilldownVariableOverrideSchema = z
.object({
variable: z.string(),
sourceType: sourceTypeEnum,
sourceValue: z.string(),
})
.strict();
const drilldownConfigSchema = z
.object({
embeddable: z.string(),
variableOverrides: z.array(drilldownVariableOverrideSchema).optional(),
})
.strict();
const eventSchema = z.discriminatedUnion("action", [
z
.object({
event: z.string(),
action: z.literal("SET_VARIABLE"),
config: setVariableConfigSchema,
})
.strict(),
z
.object({
event: z.string(),
action: z.literal("DRILLDOWN"),
config: drilldownConfigSchema,
})
.strict(),
]);
const widgetSchema = z
.object({
component: z.string(),
position: positionSchema,
dimensions: dimensionsSchema,
inputs: z.array(inputSchema).optional(),
events: z.array(eventSchema).optional(),
})
.strict();
const variableSchema = z
.object({
name: z.string(),
type: z.string(),
array: z.boolean().optional(),
defaultValue: z.any().optional(),
operation: z.enum(["NO_FILTER", "VALUE"]).optional(),
})
.strict();
const datasetSchema = z
.object({
name: z.string(),
model: z.string(),
filters: z.array(filterSchema).optional(),
})
.strict();
const templateInputSchema = z
.object({
input: z.string(),
inputType: z.string().optional(),
value: z.any().optional(),
array: z.boolean().optional(),
valueType: valueTypeEnum.optional(),
config: inputConfigSchema.optional(),
visible: z.boolean().optional(),
})
.strict();
const templateSchema = z
.object({
key: z.string(),
name: z.string(),
component: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
inputs: z.array(templateInputSchema).optional(),
})
.strict();
const starterCanvasWidgetSchema = z
.object({
template: z.string(),
position: positionSchema.optional(),
dimensions: dimensionsSchema.optional(),
inputs: z.array(templateInputSchema).optional(),
})
.strict();
const customCanvasSchema = z
.object({
datasets: z
.array(
z
.object({
dataset: z.string(),
})
.strict(),
)
.optional(),
templates: z.array(templateSchema).optional(),
starterCanvas: z
.object({
widgets: z.array(starterCanvasWidgetSchema).optional(),
})
.strict()
.optional(),
})
.strict();
const embeddableItemSchema = z
.object({
name: z.string(),
title: z.string().optional(),
description: z.string().optional(),
variables: z.array(variableSchema).optional(),
datasets: z.array(datasetSchema).optional(),
widgets: z.array(widgetSchema).optional(),
customCanvas: customCanvasSchema.optional(),
})
.strict();
const embeddableFileSchema = z
.object({
embeddables: z.array(embeddableItemSchema).min(1),
})
.strict();
type EmbeddableItem = z.infer<typeof embeddableItemSchema>;
type WidgetItem = z.infer<typeof widgetSchema>;
type NamedSegmentConfig = {
arrayKey: string;
displayLabel: string;
nameField: string;
};
const NAMED_SEGMENT_CONFIGS: NamedSegmentConfig[] = [
{ arrayKey: "embeddables", displayLabel: "Embeddable", nameField: "name" },
{ arrayKey: "widgets", displayLabel: "widget", nameField: "component" },
{ arrayKey: "inputs", displayLabel: "input", nameField: "input" },
];
function resolveIndexedSegment(
arrayKey: string,
index: number,
parentContext: unknown,
): { displayText: string; childContext: unknown } | null {
const config = NAMED_SEGMENT_CONFIGS.find((c) => c.arrayKey === arrayKey);
if (!config) return null;
const array = (parentContext as Record<string, unknown>)?.[arrayKey];
const item = Array.isArray(array)
? (array[index] as Record<string, unknown>)
: undefined;
const resolvedName = item?.[config.nameField];
const displayText =
typeof resolvedName === "string"
? `${config.displayLabel} '${resolvedName}'`
: `${arrayKey}[${index}]`;
return { displayText, childContext: item };
}
function formatEmbeddableIssuePath(
issuePath: (string | number)[],
rootData: unknown,
): string {
const pathSegments: string[] = [];
let currentContext: unknown = rootData;
for (let position = 0; position < issuePath.length; position++) {
const currentKey = issuePath[position];
const nextElement = issuePath[position + 1];
if (typeof currentKey === "string" && typeof nextElement === "number") {
const resolved = resolveIndexedSegment(
currentKey,
nextElement,
currentContext,
);
if (resolved) {
pathSegments.push(resolved.displayText);
currentContext = resolved.childContext;
position++;
continue;
}
}
pathSegments.push(String(currentKey));
currentContext = (currentContext as Record<string, unknown>)?.[
String(currentKey)
];
}
return pathSegments.join(", ");
}
type RefContext = {
filePath: string;
variableNames: Set<string>;
datasetNames: Set<string>;
templateNames: Set<string>;
};
function validateDatasetRefs(
emb: EmbeddableItem,
ctx: RefContext,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (const ds of emb.datasets ?? []) {
for (const f of ds.filters ?? []) {
if (f.valueType === "VARIABLE" && !ctx.variableNames.has(f.value)) {
issues.push({
filePath: ctx.filePath,
message: `dataset "${ds.name}" references undefined variable "${f.value}"`,
});
}
}
}
return issues;
}
function validateWidgetInputRefs(
widget: WidgetItem,
ctx: RefContext,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (const inp of widget.inputs ?? []) {
if (inp.valueType === "VARIABLE" && !ctx.variableNames.has(inp.value)) {
issues.push({
filePath: ctx.filePath,
message: `widget "${widget.component}" input "${inp.input}" references undefined variable "${inp.value}"`,
});
}
if (
inp.inputType === "dataset" &&
inp.valueType === "VALUE" &&
inp.value &&
!ctx.datasetNames.has(inp.value)
) {
issues.push({
filePath: ctx.filePath,
message: `widget "${widget.component}" input "${inp.input}" references undefined dataset "${inp.value}"`,
});
}
}
return issues;
}
function validateWidgetEventRefs(
widget: WidgetItem,
ctx: RefContext,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (const evt of widget.events ?? []) {
if (evt.action !== "SET_VARIABLE") continue;
const config = evt.config as z.infer<typeof setVariableConfigSchema>;
if (!ctx.variableNames.has(config.variable)) {
issues.push({
filePath: ctx.filePath,
message: `widget "${widget.component}" event "${evt.event}" references undefined variable "${config.variable}"`,
});
}
}
return issues;
}
function widgetsOverlap(a: WidgetItem, b: WidgetItem): boolean {
if (
!Number.isFinite(a.dimensions.width) ||
!Number.isFinite(a.dimensions.height) ||
!Number.isFinite(b.dimensions.width) ||
!Number.isFinite(b.dimensions.height) ||
a.dimensions.width <= 0 ||
a.dimensions.height <= 0 ||
b.dimensions.width <= 0 ||
b.dimensions.height <= 0
) {
return false;
}
const aRight = a.position.x + a.dimensions.width;
const bRight = b.position.x + b.dimensions.width;
const aBottom = a.position.y + a.dimensions.height;
const bBottom = b.position.y + b.dimensions.height;
return (
a.position.x < bRight &&
aRight > b.position.x &&
a.position.y < bBottom &&
aBottom > b.position.y
);
}
function validateWidgetOverlaps(
emb: EmbeddableItem,
filePath: string,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const widgets = emb.widgets ?? [];
for (let i = 0; i < widgets.length; i++) {
for (let j = i + 1; j < widgets.length; j++) {
const first = widgets[i];
const second = widgets[j];
if (!widgetsOverlap(first, second)) {
continue;
}
issues.push({
filePath,
message:
`embeddable "${emb.name}" widgets "${first.component}" and "${second.component}" overlap. ` +
`"${first.component}" occupies x ${first.position.x}-${first.position.x + first.dimensions.width}, y ${first.position.y}-${first.position.y + first.dimensions.height}; ` +
`"${second.component}" occupies x ${second.position.x}-${second.position.x + second.dimensions.width}, y ${second.position.y}-${second.position.y + second.dimensions.height}.`,
});
}
}
return issues;
}
function validateCustomCanvasRefs(
emb: EmbeddableItem,
ctx: RefContext,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (const ref of emb.customCanvas?.datasets ?? []) {
if (!ctx.datasetNames.has(ref.dataset)) {
issues.push({
filePath: ctx.filePath,
message: `customCanvas references undefined dataset "${ref.dataset}"`,
});
}
}
for (const sw of emb.customCanvas?.starterCanvas?.widgets ?? []) {
if (!ctx.templateNames.has(sw.template)) {
issues.push({
filePath: ctx.filePath,
message: `starterCanvas references undefined template "${sw.template}"`,
});
}
}
return issues;
}
function validateTemplateKeys(
emb: EmbeddableItem,
filePath: string,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const seen = new Set<string>();
for (const t of emb.customCanvas?.templates ?? []) {
if (seen.has(t.key)) {
issues.push({
filePath,
message: `customCanvas has duplicate template key "${t.key}"`,
});
} else {
seen.add(t.key);
}
}
return issues;
}
function validateEmbeddableRefs(
emb: EmbeddableItem,
filePath: string,
): ValidationIssue[] {
const templateKeyIssues = validateTemplateKeys(emb, filePath);
const ctx: RefContext = {
filePath,
variableNames: new Set((emb.variables ?? []).map((v) => v.name)),
datasetNames: new Set((emb.datasets ?? []).map((d) => d.name)),
templateNames: new Set(
(emb.customCanvas?.templates ?? []).map((t) => t.key),
),
};
return [
...templateKeyIssues,
...validateDatasetRefs(emb, ctx),
...(emb.widgets ?? []).flatMap((widget) => [
...validateWidgetInputRefs(widget, ctx),
...validateWidgetEventRefs(widget, ctx),
]),
...validateWidgetOverlaps(emb, filePath),
...validateCustomCanvasRefs(emb, ctx),
];
}
function yamlParseIssues(
doc: YAML.Document.Parsed,
filePath: string,
): ValidationIssue[] {
return doc.errors.map((err) => {
const linePos = err.linePos?.[0];
return {
filePath,
message: err.message,
line: linePos?.line,
column: linePos?.col,
};
});
}
function locateZodIssue(
doc: YAML.Document.Parsed,
lineCounter: YAML.LineCounter,
zodPath: (string | number)[],
): { line?: number; column?: number } {
if (zodPath.length === 0) return {};
const node = doc.getIn(zodPath, true) as
| { range?: [number, number, number] }
| undefined;
if (!node?.range || typeof node.range[0] !== "number") return {};
const pos = lineCounter.linePos(node.range[0]);
return { line: pos.line, column: pos.col };
}
function schemaIssues(
zError: z.ZodError,
doc: YAML.Document.Parsed,
lineCounter: YAML.LineCounter,
parsed: unknown,
filePath: string,
): ValidationIssue[] {
return zError.issues.map((zIssue) => {
const unwrapped = resolveDeepestIssue(zIssue);
const formattedPath = formatEmbeddableIssuePath(unwrapped.path, parsed);
const message = formattedPath
? `${formattedPath}: ${unwrapped.message}`
: unwrapped.message;
return {
filePath,
message,
path: formatErrorPath(unwrapped.path) || undefined,
...locateZodIssue(doc, lineCounter, unwrapped.path),
};
});
}
function duplicateNameAndRefIssues(
embeddables: z.infer<typeof embeddableFileSchema>["embeddables"],
filePath: string,
nameSet: Set<string>,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (const emb of embeddables) {
if (nameSet.has(emb.name)) {
issues.push({
filePath,
message: `embeddable with name "${emb.name}" already exists`,
});
} else {
nameSet.add(emb.name);
}
issues.push(...validateEmbeddableRefs(emb, filePath));
}
return issues;
}
async function validateEmbeddableFile(
filePath: string,
nameSet: Set<string>,
): Promise<ValidationIssue[]> {
try {
const fileContentRaw = await fs.readFile(filePath, "utf8");
const lineCounter = new YAML.LineCounter();
const doc = YAML.parseDocument(fileContentRaw, { lineCounter });
if (doc.errors.length) return yamlParseIssues(doc, filePath);
const parsed = doc.toJS();
const safeParse = embeddableFileSchema.safeParse(parsed);
if (!safeParse.success) {
return schemaIssues(safeParse.error, doc, lineCounter, parsed, filePath);
}
return duplicateNameAndRefIssues(
safeParse.data.embeddables,
filePath,
nameSet,
);
} catch (e: any) {
return [{ filePath, message: e.message }];
}
}
export async function embeddableValidation(
filesList: [string, string][],
): Promise<ValidationIssue[]> {
const nameSet = new Set<string>();
const all: ValidationIssue[] = [];
for (const [, filePath] of filesList) {
all.push(...(await validateEmbeddableFile(filePath, nameSet)));
}
return all;
}