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.
793 lines (768 loc) • 22.4 kB
JavaScript
// src/index.ts
import "reflect-metadata";
import { z as z5 } from "zod";
import { serve } from "@hono/node-server";
// src/datasource.ts
import "dotenv/config";
import { DataSource } from "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 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
import { Hono as Hono2 } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
// src/app/routes/entity.routes.ts
import { Hono } from "hono";
// src/app/middlewares/validate-request-schema.middleware.ts
import { createMiddleware } from "hono/factory";
function validateSchema(property, schema) {
return 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
import { z } from "zod";
var tableNameParam = z.string({
invalid_type_error: "Entity table name is required.",
required_error: "Entity table name is required."
});
var idParam = z.string({
invalid_type_error: "Entity ID is required.",
required_error: "Entity ID is required."
});
var getAllEntitySchema = z.object({
tableName: tableNameParam
}).strict();
var getOneEntitySchema = z.object({
id: idParam,
tableName: tableNameParam
}).strict();
var createEntitySchema = getAllEntitySchema;
var updateEntitySchema = getOneEntitySchema;
var deleteEntitySchema = getOneEntitySchema;
var getAllQueryParamsSchema = z.object({
page: z.string().regex(/^\d+$/, "Page must be a valid positive integer").optional(),
perPage: 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 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
import { z as z2 } from "zod";
function createApp(services) {
const app = new Hono2();
app.use("*", logger());
app.use("/api/*", 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 z2.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
import * as fs from "fs";
import * as yaml from "js-yaml";
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
import { z as z3 } from "zod";
var columnTypeSchema = z3.union([
z3.literal("string"),
z3.literal("textarea"),
z3.literal("richText"),
z3.literal("int"),
z3.literal("number"),
z3.literal("decimal"),
z3.literal("url"),
z3.literal("email"),
z3.literal("date"),
z3.literal("timestamp"),
z3.literal("boolean"),
z3.literal("password"),
z3.literal("select")
]);
var columnValidationOptionsSchema = z3.object({
required: z3.boolean().optional(),
min: z3.number().optional(),
max: z3.number().optional()
});
var baseColumnSchema = z3.object({
type: columnTypeSchema,
length: z3.number().optional(),
primary: z3.boolean().optional(),
generated: z3.union([
z3.literal(true),
z3.literal("uuid"),
z3.literal("rowid"),
z3.literal("increment"),
z3.undefined()
]),
validation: columnValidationOptionsSchema.optional()
});
var columnSchema = baseColumnSchema.extend({
options: z3.array(z3.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 = z3.object({
tableName: z3.string(),
columns: z3.record(columnSchema)
});
var ymlSchema = z3.object({
name: z3.string(),
tables: z3.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
import {
EntitySchema as EntitySchema2
} from "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 EntitySchema2({
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
import { z as z4 } from "zod";
function mapBaseTypeToZod(type, options) {
switch (type) {
case "int": {
return z4.number().int();
}
case "number":
case "decimal": {
return z4.number();
}
case "string":
case "textarea":
case "richText": {
return z4.string();
}
case "email": {
return z4.string().email();
}
case "url": {
return z4.string().url();
}
case "password": {
return z4.string().min(6);
}
case "date":
case "timestamp": {
return z4.preprocess((arg) => {
if (typeof arg === "string" || arg instanceof Date) {
return new Date(arg);
}
}, z4.date());
}
case "boolean": {
return z4.boolean();
}
case "select": {
if (!options?.selectOptions?.length) {
throw new SelectOptionsMissingError();
}
return z4.string().refine((val) => options.selectOptions.includes(val), {
message: `Value must be one of the following options: ${options.selectOptions.join(
", "
)}`
});
}
default: {
return z4.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 z4.ZodString) {
schema = applyStringValidation(schema, validations);
}
if (schema instanceof z4.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 z4.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
import * as bcrypt from "bcrypt";
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;
serve({ fetch: app.fetch, port }, (info) => {
console.log(`Server running at http://localhost:${info.port}`);
});
} catch (error) {
if (error instanceof z5.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);
}
}
export {
bootstrap
};