UNPKG

digesto

Version:

Digesto is an experimental Node.js/TypeScript library that quickly spins up a dynamic, RESTful backend from a simple YAML, NO manual server coding required.

825 lines (800 loc) 24.8 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { bootstrap: () => bootstrap }); module.exports = __toCommonJS(index_exports); var import_reflect_metadata = require("reflect-metadata"); var import_zod5 = require("zod"); var import_node_server = require("@hono/node-server"); // src/datasource.ts var import_config = require("dotenv/config"); var import_typeorm = require("typeorm"); // src/app/config/env.config.ts var env = { db: { host: process.env.DIGESTO_DATABASE_HOST ?? "localhost", port: Number(process.env.DIGESTO_DATABASE_PORT) || 5432, username: process.env.DIGESTO_DATABASE_USERNAME, password: process.env.DIGESTO_DATABASE_PASSWORD, database: process.env.DIGESTO_DATABASE_NAME } }; // src/datasource.ts var Database = class _Database { static instance; constructor() { } static getInstance() { if (!_Database.instance) { console.log("Setting up database connection..."); console.log(env.db.database ? `Using "${env.db.database}" database...` : "No database has been found."); _Database.instance = new import_typeorm.DataSource({ type: "postgres", host: env.db.host, port: env.db.port, username: env.db.username, password: env.db.password, database: env.db.database, synchronize: true, migrationsRun: true, logging: true, migrations: ["./migrations/*.js"] }); } return _Database.instance; } static async loadEntities(entities) { _Database.getInstance(); _Database.instance.setOptions({ entities }); if (!_Database.instance.isInitialized) { await _Database.instance.initialize(); } } }; // src/app/app.ts var import_hono2 = require("hono"); var import_cors = require("hono/cors"); var import_logger = require("hono/logger"); // src/app/routes/entity.routes.ts var import_hono = require("hono"); // src/app/middlewares/validate-request-schema.middleware.ts var import_factory = require("hono/factory"); function validateSchema(property, schema) { return (0, import_factory.createMiddleware)(async (c, next) => { let dataToValidate; if (property === "body") { dataToValidate = await c.req.json(); } if (property === "params") { dataToValidate = c.req.param(); } if (property === "query") { dataToValidate = c.req.query(); } schema.parse(dataToValidate); await next(); }); } // src/app/schemas/entity.schema.ts var import_zod = require("zod"); var tableNameParam = import_zod.z.string({ invalid_type_error: "Entity table name is required.", required_error: "Entity table name is required." }); var idParam = import_zod.z.string({ invalid_type_error: "Entity ID is required.", required_error: "Entity ID is required." }); var getAllEntitySchema = import_zod.z.object({ tableName: tableNameParam }).strict(); var getOneEntitySchema = import_zod.z.object({ id: idParam, tableName: tableNameParam }).strict(); var createEntitySchema = getAllEntitySchema; var updateEntitySchema = getOneEntitySchema; var deleteEntitySchema = getOneEntitySchema; var getAllQueryParamsSchema = import_zod.z.object({ page: import_zod.z.string().regex(/^\d+$/, "Page must be a valid positive integer").optional(), perPage: import_zod.z.string().regex(/^\d+$/, "Per page must be a valid positive integer").optional() }); // src/app/routes/entity.routes.ts function entityRoutes(entityService) { const router = new import_hono.Hono(); router.get( "/:tableName", validateSchema("params", getAllEntitySchema), validateSchema("query", getAllQueryParamsSchema), async (c) => { const query = c.req.query(); const tableName = c.req.param("tableName"); const results = await entityService.getAll(tableName, query); return c.json(results); } ); router.get( "/:tableName/:id", validateSchema("params", getOneEntitySchema), async (c) => { const tableName = c.req.param("tableName"); const itemId = c.req.param("id"); const item = await entityService.getOne(tableName, itemId); return c.json(item); } ); router.post( "/:tableName", validateSchema("params", createEntitySchema), async (c) => { const tableName = c.req.param("tableName"); const body = await c.req.json(); const savedItem = await entityService.create(tableName, body); c.status(201); return c.json(savedItem); } ); router.put( "/:tableName/:id", validateSchema("params", updateEntitySchema), async (c) => { const tableName = c.req.param("tableName"); const itemId = c.req.param("id"); const body = await c.req.json(); const updatedItem = await entityService.update(tableName, itemId, body); return c.json(updatedItem); } ); router.delete( "/:tableName/:id", validateSchema("params", deleteEntitySchema), async (c) => { const tableName = c.req.param("tableName"); const itemId = c.req.param("id"); const result = await entityService.delete(tableName, itemId); return c.json(result); } ); return router; } // src/app/errors/http.errors.ts var HttpError = class _HttpError extends Error { statusCode; constructor(message, statusCode) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, _HttpError.prototype); } }; var NotFoundError = class _NotFoundError extends HttpError { constructor(message = "Not Found") { super(message, 404); this.name = "NotFoundError"; Object.setPrototypeOf(this, _NotFoundError.prototype); } }; // src/app/app.ts var import_zod2 = require("zod"); function createApp(services) { const app = new import_hono2.Hono(); app.use("*", (0, import_logger.logger)()); app.use("/api/*", (0, import_cors.cors)()); app.get("/", (c) => { return c.text("Hello Hono!"); }); const entityRouter = entityRoutes(services.entityService); app.route("/api/collections", entityRouter); app.onError((err, c) => { if (err instanceof HttpError) { c.status(err.statusCode); return c.json({ message: err.message, error: err.name, statusCode: err.statusCode }); } if (err instanceof import_zod2.z.ZodError) { c.status(400); return c.json({ message: err.errors, error: "ValidationError", statusCode: 400 }); } console.error("Something goes wrong", err); c.status(500); return c.json({ message: err.message ?? "Internal server error", error: "InternalServerError", statusCode: 500 }); }); return app; } // src/app/repositories/entity.repository.ts var EntityRepository = class { dataSource; constructor(dataSource) { this.dataSource = dataSource; } /** * Gets the repository for the given table name. */ getRepository(tableName) { const entityMetadata = this.dataSource.entityMetadatas.find( (item) => item.tableName === tableName ); if (!entityMetadata) { throw new NotFoundError(`Entity "${tableName}" not found`); } return this.dataSource.getRepository(entityMetadata.target); } /** * Retrieves a list of rows from a table, limited by `limit`. */ findAll(tableName) { return this.getRepository(tableName).createQueryBuilder(tableName); } /** * Retrieves a single row by ID. */ async findOne(tableName, id) { return this.getRepository(tableName).createQueryBuilder(tableName).where(`${tableName}.id = :id`, { id }).getOne(); } /** * Creates and saves a new entity row. */ async create(tableName, data) { const repository = this.getRepository(tableName); const newItem = repository.create(data); return repository.save(newItem); } /** * Saves an existing entity row (for updates). */ async save(tableName, item) { return this.getRepository(tableName).save(item); } /** * Deletes a row by ID. */ async delete(tableName, id) { const result = await this.getRepository(tableName).delete(id); return result.affected !== 0; } }; // src/app/repositories/yml.repository.ts var fs = __toESM(require("fs"), 1); var yaml = __toESM(require("js-yaml"), 1); var YmlRepository = class { // constructor() {} readFile(location) { const fileContent = fs.readFileSync(location, "utf-8"); const ymlContent = yaml.load(fileContent); return ymlContent; } }; // src/app/services/entity.service.ts var EntityService = class { constructor(entityRepository, schemaValidationService, hashingService, paginationService) { this.entityRepository = entityRepository; this.schemaValidationService = schemaValidationService; this.hashingService = hashingService; this.paginationService = paginationService; } /** * Retrieves a list of entities from the specified table, limited by `limit`. */ async getAll(tableName, queryParams) { const page = Number.parseInt(queryParams.page ?? "") || 1; const perPage = Number.parseInt(queryParams.perPage ?? "") || 10; const query = this.entityRepository.findAll(tableName); query.orderBy(`${tableName}.id`, "DESC"); return this.paginationService.paginate({ page, perPage, query }); } /** * Retrieves a single entity by its ID. */ async getOne(tableName, id) { const item = await this.entityRepository.findOne(tableName, id); if (!item) { throw new NotFoundError("Item not found"); } return item; } /** * Creates a new entity in the specified table. */ async create(tableName, data) { this.schemaValidationService.validateTableSchema(tableName, data); this.hashingService.hashSensitiveColumns(tableName, data); return this.entityRepository.create(tableName, data); } /** * Updates an existing entity by its ID. */ async update(tableName, id, data) { const existingItem = await this.getOne(tableName, id); const updatedItem = { ...existingItem, ...data }; this.schemaValidationService.validateTableSchema(tableName, updatedItem); this.hashingService.hashSensitiveColumns(tableName, updatedItem); return this.entityRepository.save(tableName, updatedItem); } /** * Deletes an entity by its ID. */ async delete(tableName, id) { await this.getOne(tableName, id); const success = await this.entityRepository.delete(tableName, id); if (!success) { throw new Error("Delete operation failed"); } return { ok: true }; } }; // src/app/schemas/yml.schema.ts var import_zod3 = require("zod"); var columnTypeSchema = import_zod3.z.union([ import_zod3.z.literal("string"), import_zod3.z.literal("textarea"), import_zod3.z.literal("richText"), import_zod3.z.literal("int"), import_zod3.z.literal("number"), import_zod3.z.literal("decimal"), import_zod3.z.literal("url"), import_zod3.z.literal("email"), import_zod3.z.literal("date"), import_zod3.z.literal("timestamp"), import_zod3.z.literal("boolean"), import_zod3.z.literal("password"), import_zod3.z.literal("select") ]); var columnValidationOptionsSchema = import_zod3.z.object({ required: import_zod3.z.boolean().optional(), min: import_zod3.z.number().optional(), max: import_zod3.z.number().optional() }); var baseColumnSchema = import_zod3.z.object({ type: columnTypeSchema, length: import_zod3.z.number().optional(), primary: import_zod3.z.boolean().optional(), generated: import_zod3.z.union([ import_zod3.z.literal(true), import_zod3.z.literal("uuid"), import_zod3.z.literal("rowid"), import_zod3.z.literal("increment"), import_zod3.z.undefined() ]), validation: columnValidationOptionsSchema.optional() }); var columnSchema = baseColumnSchema.extend({ options: import_zod3.z.array(import_zod3.z.string()).optional() }).refine( (data) => data.type !== "select" || data.options && data.options.length > 0, { message: 'A list of "options" is required and must be a non-empty array for columns of type "select".', path: ["options"] } ).refine((data) => data.type !== "select" || data.generated === void 0, { message: '"generated" is not allowed for columns of type "select".', path: ["generated"] }).refine((data) => data.type !== "select" || data.primary === void 0, { message: '"primary" is not allowed for columns of type "select".', path: ["primary"] }); var tableSchema = import_zod3.z.object({ tableName: import_zod3.z.string(), columns: import_zod3.z.record(columnSchema) }); var ymlSchema = import_zod3.z.object({ name: import_zod3.z.string(), tables: import_zod3.z.record(tableSchema) }); // src/app/services/yml.service.ts var YmlService = class { fileLocation = `${process.cwd()}/backend/api.yml`; ymlRepository; parsedYmlContent = null; constructor(ymlRepository) { this.ymlRepository = ymlRepository; } load({ force } = { force: true }) { if (!force && !!this.parsedYmlContent) { return this.parsedYmlContent; } const ymlContent = this.ymlRepository.readFile(this.fileLocation); const parsedYmlContent = ymlSchema.parse(ymlContent); this.parsedYmlContent = parsedYmlContent; return parsedYmlContent; } get parsedContent() { return this.parsedYmlContent; } }; // src/app/utils/build-entities.ts var import_typeorm2 = require("typeorm"); // src/app/errors/schema.errors.ts var UnsupportedColumnTypeError = class _UnsupportedColumnTypeError extends Error { constructor(yamlType) { const message = `Unsupported column type: "${yamlType}"`; super(message); Object.setPrototypeOf(this, _UnsupportedColumnTypeError.prototype); } }; var SelectOptionsMissingError = class extends Error { constructor() { const message = `You need to provide options for the "select" column type`; super(message); Object.setPrototypeOf(this, UnsupportedColumnTypeError.prototype); } }; // src/app/utils/build-entities.ts function buildEntitySchemas(ymlContent) { const { tables } = ymlContent; const schemas = []; for (const entityKey in tables) { const tableDef = tables[entityKey]; const tableName = tableDef.tableName || entityKey.toLowerCase(); const columns = {}; for (const propKey in tableDef.columns) { const propDef = tableDef.columns[propKey]; const options = { name: propKey, type: mapYamlTypeToTypeORM(propDef.type), primary: !!propDef.primary, generated: propDef.generated, length: propDef.length, nullable: !propDef.primary // TODO: Review this behavior }; if (propDef.type === "select") { options.enum = propDef.options; options.enumName = `${propKey}_enum`; } columns[propKey] = options; } const entitySchema = new import_typeorm2.EntitySchema({ name: entityKey, tableName, columns }); schemas.push(entitySchema); } return schemas; } function mapYamlTypeToTypeORM(yamlType) { switch (yamlType) { case "string": case "email": case "url": case "password": { return "varchar"; } case "number": case "decimal": { return "float"; } case "int": { return "int"; } case "date": { return "date"; } case "timestamp": { return "timestamp"; } case "textarea": case "richText": { return "text"; } case "boolean": { return "boolean"; } case "select": { return "enum"; } default: { throw new UnsupportedColumnTypeError(yamlType); } } } // src/app/utils/zod-schema-builder.ts var import_zod4 = require("zod"); function mapBaseTypeToZod(type, options) { switch (type) { case "int": { return import_zod4.z.number().int(); } case "number": case "decimal": { return import_zod4.z.number(); } case "string": case "textarea": case "richText": { return import_zod4.z.string(); } case "email": { return import_zod4.z.string().email(); } case "url": { return import_zod4.z.string().url(); } case "password": { return import_zod4.z.string().min(6); } case "date": case "timestamp": { return import_zod4.z.preprocess((arg) => { if (typeof arg === "string" || arg instanceof Date) { return new Date(arg); } }, import_zod4.z.date()); } case "boolean": { return import_zod4.z.boolean(); } case "select": { if (!options?.selectOptions?.length) { throw new SelectOptionsMissingError(); } return import_zod4.z.string().refine((val) => options.selectOptions.includes(val), { message: `Value must be one of the following options: ${options.selectOptions.join( ", " )}` }); } default: { return import_zod4.z.any(); } } } function applyStringValidation(schema, validations) { const { min, max } = validations; if (typeof min === "number") { schema = schema.min(min, { message: `Must be at least ${min} characters` }); } if (typeof max === "number") { schema = schema.max(max, { message: `Must be at most ${max} characters` }); } return schema; } function applyNumberValidation(schema, validations) { const { min, max } = validations; if (typeof min === "number") { schema = schema.min(min, { message: `Must be at least ${min}` }); } if (typeof max === "number") { schema = schema.max(max, { message: `Must be at most ${max}` }); } return schema; } function applyValidations(schema, validations) { if (schema instanceof import_zod4.z.ZodString) { schema = applyStringValidation(schema, validations); } if (schema instanceof import_zod4.z.ZodNumber) { schema = applyNumberValidation(schema, validations); } if (!validations.required) { schema = schema.optional(); } return schema; } function getZodForColumn(columnDef) { let schema = mapBaseTypeToZod(columnDef.type, { selectOptions: columnDef.options ?? [] }); if (columnDef.validation) { schema = applyValidations(schema, columnDef.validation); } return schema; } function buildTableSchema(tableDef) { const columns = tableDef.columns; const shape = {}; for (const [columnName, columnDef] of Object.entries(columns)) { shape[columnName] = getZodForColumn(columnDef); } return import_zod4.z.object(shape).strict(); } // src/app/services/schema-validation.service.ts var SchemaValidationService = class { constructor(ymlService) { this.ymlService = ymlService; this.loadTablesSchemaValidation(); } tableSchemas = null; loadTablesSchemaValidation() { if (!this.ymlService.parsedContent) { return; } const tableSchemas = {}; for (const tableDef of Object.values( this.ymlService.parsedContent.tables )) { tableSchemas[tableDef.tableName] = buildTableSchema(tableDef); } this.tableSchemas = tableSchemas; } getTableSchemaByTableName(tableName) { const ymlContent = this.ymlService.parsedContent; return Object.values(ymlContent?.tables ?? {}).find( (item) => item.tableName === tableName ); } validateTableSchema(tableName, data, options) { this.ymlService.load(); if (!this.tableSchemas || !this.ymlService.parsedContent) { return; } let zodSchema = this.tableSchemas[tableName]; if (!zodSchema) { return; } const tableSchema2 = this.getTableSchemaByTableName(tableName); let omitFields = options?.omitFields ?? {}; if (tableSchema2) { const omitAutoGeneratedFields = Object.entries( tableSchema2.columns ).reduce((acc, curr) => { const [columnName, columnOptions] = curr; if (columnOptions.generated) { return { ...acc, [columnName]: true }; } return acc; }, {}); omitFields = { ...omitAutoGeneratedFields, ...omitFields }; } if (Object.keys(omitFields ?? {}).length > 0) { zodSchema = zodSchema.omit(omitFields); } zodSchema.parse(data); } }; // src/app/services/hashing.service.ts var bcrypt = __toESM(require("bcrypt"), 1); var HashingService = class { constructor(ymlService) { this.ymlService = ymlService; } hashSensitiveColumns(tableName, data) { const tableSchema2 = this.getTableSchemaByTableName(tableName); if (!tableSchema2) { throw new Error(`Table schema for ${tableName} not found.`); } const sensitiveFields = Object.entries(tableSchema2.columns).filter( ([_, item]) => item.type === "password" ); if (sensitiveFields.length === 0) { return data; } const saltRounds = 10; sensitiveFields.forEach(([fieldName]) => { const salt = bcrypt.genSaltSync(saltRounds); const hash = bcrypt.hashSync(data[fieldName], salt); data[fieldName] = hash; }); return data; } getTableSchemaByTableName(tableName) { const ymlContent = this.ymlService.parsedContent; return Object.values(ymlContent?.tables ?? {}).find( (item) => item.tableName === tableName ); } }; // src/app/services/pagination.service.ts var PaginationService = class { async paginate({ query, page, perPage }) { const totalItems = await query.getCount(); const totalPages = Math.ceil(totalItems / perPage); if (totalPages > 0 && page > totalPages) { page = totalPages; } const offset = Math.max((page - 1) * perPage, 0); const results = await query.skip(offset).take(perPage).getMany(); return { results, meta: { currentPage: page, perPage, totalPages, totalItems } }; } }; // src/index.ts async function bootstrap() { try { const paginationService = new PaginationService(); const ymlRepository = new YmlRepository(); const ymlService = new YmlService(ymlRepository); const ymlContent = ymlService.load(); const schemaValidationService = new SchemaValidationService(ymlService); const hashingService = new HashingService(ymlService); const entities = buildEntitySchemas(ymlContent); await Database.loadEntities(entities); const dataSource = Database.getInstance(); const entityRepository = new EntityRepository(dataSource); const entityService = new EntityService( entityRepository, schemaValidationService, hashingService, paginationService ); const services = { entityService, ymlService, schemaValidationService, hashingService, paginationService }; const app = createApp(services); const port = Number(process.env.DIGESTO_SERVER_PORT) || 5555; (0, import_node_server.serve)({ fetch: app.fetch, port }, (info) => { console.log(`Server running at http://localhost:${info.port}`); }); } catch (error) { if (error instanceof import_zod5.z.ZodError) { console.error("Error loading the YML file"); console.error(error.errors); return; } if (error instanceof UnsupportedColumnTypeError) { console.error("Error loading the YML file"); console.error(error.message); return; } console.error("Failed to start the server", error); } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { bootstrap });