diff --git a/packages/cli/src/cli/dev/dev-server/database.ts b/packages/cli/src/cli/dev/dev-server/database.ts deleted file mode 100644 index 17776b8f..00000000 --- a/packages/cli/src/cli/dev/dev-server/database.ts +++ /dev/null @@ -1,27 +0,0 @@ -import Datastore from "@seald-io/nedb"; -import type { Entity } from "@/core/resources/entity/schema.js"; - -export class Database { - private collections: Map = new Map(); - - load(entities: Entity[]) { - for (const entity of entities) { - this.collections.set(entity.name, new Datastore()); - } - } - - getCollection(name: string): Datastore | undefined { - return this.collections.get(name); - } - - getCollectionNames(): string[] { - return Array.from(this.collections.keys()); - } - - dropAll() { - for (const collection of this.collections.values()) { - collection.remove({}, { multi: true }); - } - this.collections.clear(); - } -} diff --git a/packages/cli/src/cli/dev/dev-server/db/database.ts b/packages/cli/src/cli/dev/dev-server/db/database.ts new file mode 100644 index 00000000..242abe2d --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/db/database.ts @@ -0,0 +1,58 @@ +import Datastore from "@seald-io/nedb"; +import type { Entity } from "@/core/resources/entity/schema.js"; +import { type EntityRecord, Validator } from "./validator.js"; + +export class Database { + private collections: Map = new Map(); + private schemas: Map = new Map(); + private validator: Validator = new Validator(); + + load(entities: Entity[]) { + for (const entity of entities) { + this.collections.set(entity.name, new Datastore()); + this.schemas.set(entity.name, entity); + } + } + + getCollection(name: string): Datastore | undefined { + return this.collections.get(name); + } + + getCollectionNames(): string[] { + return Array.from(this.collections.keys()); + } + + dropAll() { + for (const collection of this.collections.values()) { + collection.remove({}, { multi: true }); + } + this.collections.clear(); + this.schemas.clear(); + } + + validate(entityName: string, record: EntityRecord, partial: boolean = false) { + const schema = this.schemas.get(entityName); + if (!schema) { + throw new Error(`Entity "${entityName}" not found`); + } + + return this.validator.validate(record, schema, partial); + } + + prepareRecord( + entityName: string, + record: EntityRecord, + partial: boolean = false, + ) { + const schema = this.schemas.get(entityName); + if (!schema) { + throw new Error(`Entity "${entityName}" not found`); + } + + const filteredRecord = this.validator.filterFields(record, schema); + if (partial) { + return filteredRecord; + } + return this.validator.applyDefaults(filteredRecord, schema); + } +} diff --git a/packages/cli/src/cli/dev/dev-server/db/validator.ts b/packages/cli/src/cli/dev/dev-server/db/validator.ts new file mode 100644 index 00000000..d6b103b6 --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/db/validator.ts @@ -0,0 +1,230 @@ +import type { + Entity, + PropertyDefinition, +} from "@/core/resources/entity/schema.js"; + +export type EntityRecord = Record; + +type ValidationError = { + error_type: "ValidationError"; + message: string; + request_id: null; + traceback: ""; +}; + +type ValidationResponse = + | { + hasError: false; + } + | { + hasError: true; + error: ValidationError; + }; + +// https://docs.base44.com/developers/backend/resources/entities/entity-schemas#field-types +const fieldTypes = [ + "string", + "integer", + "number", + "boolean", + "array", + "object", +]; + +export class Validator { + /** + * Filter out fields that are not supported by the schema + */ + filterFields(record: EntityRecord, entitySchema: Entity): EntityRecord { + const filteredRecord: EntityRecord = {}; + for (const [key, value] of Object.entries(record)) { + if (entitySchema.properties[key]) { + filteredRecord[key] = value; + } + } + return filteredRecord; + } + + applyDefaults(record: EntityRecord, entitySchema: Entity): EntityRecord { + const result: EntityRecord = {}; + for (const [key, property] of Object.entries(entitySchema.properties)) { + if (property.default !== undefined) { + result[key] = property.default; + } + } + return { + ...result, + ...record, + }; + } + + /** + * Validates whether record is correctly follow the schema + */ + validate( + record: EntityRecord, + entitySchema: Entity, + partial: boolean = false, + ): ValidationResponse { + // Partial validation happening, when user updates existing entity. + // In this case not all data will be passed. + if (!partial) { + const requiredFieldsResponse = this.validateRequiredFields( + record, + entitySchema, + ); + if (requiredFieldsResponse.hasError) { + return requiredFieldsResponse; + } + } + const fieldTypesResponse = this.validateFieldTypes(record, entitySchema); + if (fieldTypesResponse.hasError) { + return fieldTypesResponse; + } + return { + hasError: false, + }; + } + + private createValidationError(message: string): ValidationError { + return { + error_type: "ValidationError", + message, + request_id: null, + traceback: "", + }; + } + + /** + * Validating field types according to documentation + */ + private validateFieldTypes( + record: EntityRecord, + entitySchema: Entity, + ): ValidationResponse { + for (const [key, value] of Object.entries(record)) { + const property = entitySchema.properties[key]; + const result = this.validateValue(value, property, key); + if (result.hasError) return result; + } + + return { + hasError: false, + }; + } + + private validateValue( + value: unknown, + property: PropertyDefinition | undefined, + fieldPath: string, + ): ValidationResponse { + // Silently ignore fields not defined in the schema. + // The expectation is that `filterFields()` will run before. + if (!property) { + return { hasError: false }; + } + + const propertyType = property.type; + if (!fieldTypes.includes(propertyType)) { + return { + hasError: true, + error: this.createValidationError( + `Error in field ${fieldPath}: Unsupported field type ${propertyType}`, + ), + }; + } + + switch (propertyType) { + case "array": + if (!Array.isArray(value)) { + return { + hasError: true, + error: this.createValidationError( + `Error in field ${fieldPath}: Input should be a valid array`, + ), + }; + } + if (property.items) { + for (let i = 0; i < value.length; i++) { + const itemResult = this.validateValue( + value[i], + property.items, + `${fieldPath}[${i}]`, + ); + if (itemResult.hasError) return itemResult; + } + } + break; + case "object": + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) + ) { + return { + hasError: true, + error: this.createValidationError( + `Error in field ${fieldPath}: Input should be a valid object`, + ), + }; + } + if (property.properties) { + for (const [subKey, subValue] of Object.entries( + value as Record, + )) { + if (property.properties[subKey]) { + const subResult = this.validateValue( + subValue, + property.properties[subKey], + `${fieldPath}.${subKey}`, + ); + if (subResult.hasError) return subResult; + } + } + } + break; + case "integer": + if (!Number.isInteger(value)) { + return { + hasError: true, + error: this.createValidationError( + `Error in field ${fieldPath}: Input should be a valid integer`, + ), + }; + } + break; + default: + if (typeof value !== propertyType) { + return { + hasError: true, + error: this.createValidationError( + `Error in field ${fieldPath}: Input should be a valid ${propertyType}`, + ), + }; + } + } + + return { hasError: false }; + } + + private validateRequiredFields( + record: EntityRecord, + entitySchema: Entity, + ): ValidationResponse { + if (entitySchema.required && entitySchema.required.length > 0) { + for (const required of entitySchema.required) { + if (record[required] == null) { + return { + hasError: true, + error: this.createValidationError( + `Error in field ${required}: Field required`, + ), + }; + } + } + } + return { + hasError: false, + }; + } +} diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index a8240cb6..edcd368e 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -10,7 +10,7 @@ import { createDevLogger } from "@/cli/dev/createDevLogger.js"; import { FunctionManager } from "@/cli/dev/dev-server/function-manager.js"; import { createFunctionRouter } from "@/cli/dev/dev-server/routes/functions.js"; import type { ProjectData } from "@/core/project/types.js"; -import { Database } from "./database.js"; +import { Database } from "./db/database.js"; import { type BroadcastEntityEvent, broadcastEntityEvent, diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities.ts b/packages/cli/src/cli/dev/dev-server/routes/entities.ts index eb34f810..96d287e8 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities.ts @@ -3,7 +3,7 @@ import type { Request, RequestHandler, Response, Router } from "express"; import { Router as createRouter, json } from "express"; import { nanoid } from "nanoid"; import type { Logger } from "../../createDevLogger.js"; -import type { Database } from "../database.js"; +import type { Database } from "../db/database.js"; import type { BroadcastEntityEvent, EntityEventType } from "../realtime.js"; interface EntityParams { @@ -207,8 +207,17 @@ export function createEntityRoutes( try { const now = new Date().toISOString(); const { _id, ...body } = req.body; + + const filteredBody = db.prepareRecord(entityName, body); + const validation = db.validate(entityName, filteredBody); + + if (validation.hasError) { + res.status(422).json(validation.error); + return; + } + const record = { - ...body, + ...filteredBody, id: nanoid(), created_date: now, updated_date: now, @@ -239,12 +248,24 @@ export function createEntityRoutes( try { const now = new Date().toISOString(); - const records = req.body.map((item: Record) => ({ - ...item, - id: nanoid(), - created_date: now, - updated_date: now, - })); + const records = []; + + for (const record of req.body) { + const filteredRecord = db.prepareRecord(entityName, record); + const validation = db.validate(entityName, filteredRecord); + + if (validation.hasError) { + res.status(422).json(validation.error); + return; + } + + records.push({ + ...filteredRecord, + id: nanoid(), + created_date: now, + updated_date: now, + }); + } const inserted = stripInternalFields( await collection.insertAsync(records), @@ -266,8 +287,16 @@ export function createEntityRoutes( const { id: _id, created_date: _created_date, ...body } = req.body; try { + const filteredBody = db.prepareRecord(entityName, body, true); + const validation = db.validate(entityName, filteredBody, true); + + if (validation.hasError) { + res.status(422).json(validation.error); + return; + } + const updateData = { - ...body, + ...filteredBody, updated_date: new Date().toISOString(), }; diff --git a/packages/cli/src/core/resources/entity/schema.ts b/packages/cli/src/core/resources/entity/schema.ts index f95ef8f4..52a464aa 100644 --- a/packages/cli/src/core/resources/entity/schema.ts +++ b/packages/cli/src/core/resources/entity/schema.ts @@ -106,8 +106,8 @@ const FieldRLSSchema = z.looseObject({ delete: RLSRuleSchema.optional(), }); -const PropertyDefinitionSchema: z.ZodType = z.looseObject({ - type: z.string().optional(), +const PropertyDefinitionSchema = z.looseObject({ + type: z.string(), title: z.string().optional(), description: z.string().optional(), minLength: z.number().int().min(0).optional(), @@ -122,16 +122,16 @@ const PropertyDefinitionSchema: z.ZodType = z.looseObject({ $ref: z.string().optional(), rls: FieldRLSSchema.optional(), required: z.array(z.string()).optional(), - get items(): z.ZodOptional> { + get items() { return PropertyDefinitionSchema.optional(); }, - get properties(): z.ZodOptional< - z.ZodRecord> - > { + get properties() { return z.record(z.string(), PropertyDefinitionSchema).optional(); }, }); +export type PropertyDefinition = z.infer; + export const EntitySchema = z.looseObject({ type: z.literal("object").default("object"), name: z diff --git a/packages/cli/tests/cli/connectors_list_available.spec.ts b/packages/cli/tests/cli/connectors_list_available.spec.ts index c60bac32..758e9e1a 100644 --- a/packages/cli/tests/cli/connectors_list_available.spec.ts +++ b/packages/cli/tests/cli/connectors_list_available.spec.ts @@ -73,6 +73,7 @@ describe("connectors list-available command", () => { await t.givenLoggedInWithProject(fixture("basic")); t.api.mockAvailableIntegrationsList({ integrations: [{ bad: "data" }], + // biome-ignore lint/suspicious/noExplicitAny: this is a test } as any); const result = await t.run("connectors", "list-available"); diff --git a/packages/cli/tests/scripts/validator.spec.ts b/packages/cli/tests/scripts/validator.spec.ts new file mode 100644 index 00000000..ebc08c54 --- /dev/null +++ b/packages/cli/tests/scripts/validator.spec.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from "vitest"; +import { + type EntityRecord, + Validator, +} from "../../src/cli/dev/dev-server/db/validator.js"; +import type { Entity } from "../../src/core/resources/entity/schema.js"; + +function makeEntity( + properties: Record, + required?: string[], +): Entity { + return { + type: "object", + name: "TestEntity", + properties, + ...(required ? { required } : {}), + } as Entity; +} + +describe("Validator", () => { + const validator = new Validator(); + + describe("filterFields", () => { + it("should keep only fields defined in the schema", () => { + const schema = makeEntity({ + name: { type: "string" }, + age: { type: "integer" }, + }); + const record: EntityRecord = { + name: "Alice", + age: 30, + extra: "should be removed", + }; + + const result = validator.filterFields(record, schema); + + expect(result).toEqual({ name: "Alice", age: 30 }); + }); + + it("should return empty object when record has no matching fields", () => { + const schema = makeEntity({ name: { type: "string" } }); + const record: EntityRecord = { unknown: "value" }; + + expect(validator.filterFields(record, schema)).toEqual({}); + }); + + it("should return empty object for empty record", () => { + const schema = makeEntity({ name: { type: "string" } }); + + expect(validator.filterFields({}, schema)).toEqual({}); + }); + }); + + describe("applyDefaults", () => { + it("should fill in default values for missing fields", () => { + const schema = makeEntity({ + status: { type: "string", default: "active" }, + priority: { type: "integer", default: 1 }, + }); + + const result = validator.applyDefaults({}, schema); + + expect(result).toEqual({ status: "active", priority: 1 }); + }); + + it("record values should override defaults", () => { + const schema = makeEntity({ + status: { type: "string", default: "active" }, + }); + + const result = validator.applyDefaults({ status: "inactive" }, schema); + + expect(result).toEqual({ status: "inactive" }); + }); + + it("should not add defaults for fields without a default", () => { + const schema = makeEntity({ + name: { type: "string" }, + status: { type: "string", default: "active" }, + }); + + const result = validator.applyDefaults({}, schema); + + expect(result).toEqual({ status: "active" }); + }); + }); + + describe("validate", () => { + it("should pass when all required fields are present", () => { + const schema = makeEntity({ name: { type: "string" } }, ["name"]); + + const result = validator.validate({ name: "Alice" }, schema); + + expect(result.hasError).toBe(false); + }); + + it("should fail when a required field is missing", () => { + const schema = makeEntity({ name: { type: "string" } }, ["name"]); + + const result = validator.validate({}, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("name"); + expect(result.error.message).toContain("Field required"); + } + }); + + describe("field types", () => { + it("should pass for valid string field", () => { + const schema = makeEntity({ name: { type: "string" } }); + + const result = validator.validate({ name: "Alice" }, schema); + + expect(result.hasError).toBe(false); + }); + + it("should fail when string field gets a number", () => { + const schema = makeEntity({ name: { type: "string" } }); + + const result = validator.validate({ name: 123 }, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("name"); + expect(result.error.message).toContain("string"); + } + }); + + it("passes for valid boolean field", () => { + const schema = makeEntity({ active: { type: "boolean" } }); + + const result = validator.validate({ active: true }, schema); + + expect(result.hasError).toBe(false); + }); + + it("should fail when boolean field gets a string", () => { + const schema = makeEntity({ active: { type: "boolean" } }); + + const result = validator.validate({ active: "yes" }, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("boolean"); + } + }); + + it("should pass for valid number field", () => { + const schema = makeEntity({ score: { type: "number" } }); + + const result = validator.validate({ score: 3.14 }, schema); + + expect(result.hasError).toBe(false); + }); + + it("should pass for valid integer field", () => { + const schema = makeEntity({ score: { type: "integer" } }); + + const result = validator.validate({ score: 3 }, schema); + + expect(result.hasError).toBe(false); + }); + + it("should fail for invalid integer field", () => { + const schema = makeEntity({ score: { type: "integer" } }); + + const result = validator.validate({ score: 3.3333 }, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("integer"); + } + }); + + it("should pass for valid array field", () => { + const schema = makeEntity({ tags: { type: "array" } }); + + const result = validator.validate({ tags: ["a", "b"] }, schema); + + expect(result.hasError).toBe(false); + }); + + it("should fail when array field gets a non-array", () => { + const schema = makeEntity({ tags: { type: "array" } }); + + const result = validator.validate({ tags: "not-array" }, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("array"); + } + }); + + it("should fail when array field gets a object", () => { + const schema = makeEntity({ tags: { type: "array" } }); + + const result = validator.validate({ tags: {} }, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("array"); + } + }); + + it("should pass for valid object field", () => { + const schema = makeEntity({ meta: { type: "object" } }); + + const result = validator.validate({ meta: { key: "val" } }, schema); + + expect(result.hasError).toBe(false); + }); + + describe("array items validation", () => { + it("should pass for array with valid string items", () => { + const schema = makeEntity({ + tags: { type: "array", items: { type: "string" } }, + }); + + const result = validator.validate({ tags: ["a", "b"] }, schema); + + expect(result.hasError).toBe(false); + }); + + it("should fail when array item has wrong type", () => { + const schema = makeEntity({ + tags: { type: "array", items: { type: "string" } }, + }); + + const result = validator.validate({ tags: ["a", 42] }, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("tags[1]"); + expect(result.error.message).toContain("string"); + } + }); + + // This one is temporary, and is here only because poduction is not there yet. + it("should skip item validation when items is not defined", () => { + const schema = makeEntity({ tags: { type: "array" } }); + + const result = validator.validate( + { tags: [1, "mixed", true] }, + schema, + ); + + expect(result.hasError).toBe(false); + }); + + it("should validate array of objects recursively", () => { + const schema = makeEntity({ + entries: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer" }, + }, + }, + }, + }); + + const passing = validator.validate( + { entries: [{ name: "x", count: 1 }] }, + schema, + ); + expect(passing.hasError).toBe(false); + + const failing = validator.validate( + { entries: [{ name: "x", count: 1.5 }] }, + schema, + ); + expect(failing.hasError).toBe(true); + if (failing.hasError) { + expect(failing.error.message).toContain("entries[0].count"); + expect(failing.error.message).toContain("integer"); + } + }); + }); + + describe("object properties validation", () => { + it("should validate nested object properties", () => { + const schema = makeEntity({ + data: { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer" }, + }, + }, + }); + + const passing = validator.validate( + { data: { name: "x", count: 1 } }, + schema, + ); + expect(passing.hasError).toBe(false); + + const failing = validator.validate({ data: { name: 123 } }, schema); + expect(failing.hasError).toBe(true); + if (failing.hasError) { + expect(failing.error.message).toContain("data.name"); + expect(failing.error.message).toContain("string"); + } + }); + + // This one is temporary, and is here only because poduction is not there yet. + it("should skip property validation when properties is not defined", () => { + const schema = makeEntity({ meta: { type: "object" } }); + + const result = validator.validate( + { meta: { anything: 123, goes: true } }, + schema, + ); + + expect(result.hasError).toBe(false); + }); + + it("should ignore keys not in the schema properties", () => { + const schema = makeEntity({ + data: { + type: "object", + properties: { name: { type: "string" } }, + }, + }); + + const result = validator.validate( + { data: { name: "ok", extra: 999 } }, + schema, + ); + + expect(result.hasError).toBe(false); + }); + }); + + it("should silently ignore fields not defined in the schema", () => { + const schema = makeEntity({ name: { type: "string" } }); + const record: EntityRecord = { name: "Alice", extra: "not in schema" }; + + const result = validator.validate(record, schema); + + expect(result.hasError).toBe(false); + }); + + it("should fail for unsupported field type", () => { + const schema = makeEntity({ field: { type: "date" } }); + + const result = validator.validate({ field: "2024-01-01" }, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("Unsupported field type date"); + } + }); + }); + + describe("partial validation", () => { + it("should skip required field check when partial is true", () => { + const schema = makeEntity({ name: { type: "string" } }, ["name"]); + + const result = validator.validate({}, schema, true); + + expect(result.hasError).toBe(false); + }); + + it("should still validate field types when partial is true", () => { + const schema = makeEntity({ name: { type: "string" } }, ["name"]); + + const result = validator.validate({ name: 123 }, schema, true); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error.message).toContain("string"); + } + }); + }); + + describe("validation error shape", () => { + it("should return properly shaped ValidationError", () => { + const schema = makeEntity({ name: { type: "string" } }, ["name"]); + + const result = validator.validate({}, schema); + + expect(result.hasError).toBe(true); + if (result.hasError) { + expect(result.error).toEqual({ + error_type: "ValidationError", + message: expect.stringContaining("name"), + request_id: null, + traceback: "", + }); + } + }); + }); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d1835967..9712d0a6 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,5 +1,5 @@ +import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; -import { resolve } from "path"; export default defineConfig({ test: {