Files
bknd/app/src/data/fields/JsonSchemaField.ts
dswbx 5c3eeb7642 fix json schema validation initialization
ensure `getJsonSchema` handles both object and non-object outputs to prevent errors during validation initialization. this improves robustness when handling edge cases in schema configurations.
2025-11-20 21:11:28 +01:00

133 lines
3.7 KiB
TypeScript

import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
import { objectToJsLiteral } from "bknd/utils";
import type { EntityManager } from "data/entities";
import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s } from "bknd/utils";
export const jsonSchemaFieldConfigSchema = s
.strictObject({
schema: s.any({ type: "object" }),
ui_schema: s.any({ type: "object" }),
default_from_schema: s.boolean(),
...baseFieldConfigSchema.properties,
})
.partial();
export type JsonSchemaFieldConfig = s.Static<typeof jsonSchemaFieldConfigSchema>;
export class JsonSchemaField<
Required extends true | false = false,
TypeOverride = object,
> extends Field<JsonSchemaFieldConfig, TypeOverride, Required> {
override readonly type = "jsonschema";
private validator: Validator;
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
super(name, config);
// make sure to hand over clean json
const schema = this.getJsonSchema();
this.validator = new Validator(
typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {},
);
}
protected getSchema() {
return jsonSchemaFieldConfigSchema;
}
getJsonSchema(): JsonSchema {
return this.config?.schema as JsonSchema;
}
getJsonUiSchema() {
return this.config.ui_schema ?? {};
}
override isValid(value: any, context: TActionContext = "update"): boolean {
const parentValid = super.isValid(value, context);
if (parentValid) {
// already checked in parent
if (!this.isRequired() && (!value || typeof value !== "object")) {
return true;
}
const result = this.validator.validate(value);
return result.valid;
}
return false;
}
override getValue(value: any, context: TRenderContext): any {
switch (context) {
case "form":
if (value === null) return "";
return value;
case "table":
if (value === null) return null;
return JSON.stringify(value);
case "submit":
break;
}
return value;
}
override transformRetrieve(value: any): any {
const val = super.transformRetrieve(value);
if (val === null) {
if (this.config.default_from_schema) {
try {
return s.fromSchema(this.getJsonSchema()).template();
} catch (e) {
return null;
}
} else if (this.hasDefault()) {
return this.getDefault();
}
}
return val;
}
override async transformPersist(
_value: any,
em: EntityManager<any>,
context: TActionContext,
): Promise<string | undefined> {
const value = await super.transformPersist(_value, em, context);
if (this.nullish(value)) return value;
if (!this.isValid(value)) {
throw new TransformPersistFailedException(this.name, value);
}
if (!value || typeof value !== "object") return this.getDefault();
return JSON.stringify(value);
}
override toJsonSchema() {
const schema = this.getJsonSchema() ?? { type: "object" };
return this.toSchemaWrapIfRequired(
s.fromSchema({
default: this.getDefault(),
...schema,
}),
);
}
override toType(): TFieldTSType {
return {
...super.toType(),
import: [{ package: "json-schema-to-ts", name: "FromSchema" }],
type: `FromSchema<${objectToJsLiteral(this.getJsonSchema(), 2, 1)}>`,
};
}
}