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
JavaScript
;
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
});