Skip to content

Instantly share code, notes, and snippets.

@markgarrigan
Last active December 19, 2024 17:28
Show Gist options
  • Save markgarrigan/3a34b3886eec5be7a310b1d3ee8c6784 to your computer and use it in GitHub Desktop.
Save markgarrigan/3a34b3886eec5be7a310b1d3ee8c6784 to your computer and use it in GitHub Desktop.
Dynamic API Generation
/**
* Generates association routes for a model.
* @param {object} router - Express Router instance.
* @param {object} model - Sequelize model instance.
* @param {string} modelName - Singular name of the model.
* @param {object} associations - Associations for the model.
* @param {string} service - Service name.
*/
function generateAssociationRoutes(router, model, modelName, associations, service) {
for (const [associationName, association] of Object.entries(associations)) {
const pluralAssociationName = pluralize(association.target.name.toLowerCase());
// Define route paths
const basePath = `/${service}/${modelName.toLowerCase()}/:id/${pluralAssociationName}`;
// GET: Retrieve associated models
router.get(
basePath,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${modelName} not found` });
}
const associatedModels = await instance[`get${associationName}`]();
res.json(associatedModels);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
// POST: Add an associated model
router.post(
basePath,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${modelName} not found` });
}
const associatedModel = await association.target.findByPk(req.body.associatedId);
if (!associatedModel) {
return res.status(404).json({ error: `${association.target.name} not found` });
}
await instance[`add${associationName}`](associatedModel);
res.status(204).send();
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
// DELETE: Remove an associated model
router.delete(
`${basePath}/:associatedId`,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${modelName} not found` });
}
const associatedModel = await association.target.findByPk(req.params.associatedId);
if (!associatedModel) {
return res.status(404).json({ error: `${association.target.name} not found` });
}
await instance[`remove${associationName}`](associatedModel);
res.status(204).send();
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
}
}
/**
* Generates CRUD and association routes for a model.
* @param {object} router - Express Router instance.
* @param {object} model - Sequelize model instance.
* @param {string} pluralModelName - Pluralized name of the model.
* @param {object} attributes - Model attributes.
* @param {object} service - Service name.
*/
function generateCRUDRoutes(router, model, pluralModelName, attributes, service) {
const validationRules = generateValidationRules(attributes);
// CRUD Routes (Existing)
router.post(
'/',
validationRules.create,
handleValidationErrors,
async (req, res) => {
try {
const modelInstance = await model.create(req.body);
res.status(201).json(modelInstance);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
router.get('/', async (req, res) => {
try {
const scopes = parseScopes(req.query.scopes);
const data = await model.scope(scopes).findAll();
res.json(data);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
router.get(
'/:id',
validationRules.idParam,
handleValidationErrors,
async (req, res) => {
try {
const scopes = parseScopes(req.query.scopes);
const data = await model.scope(scopes).findByPk(req.params.id);
if (!data) {
return res.status(404).json({ error: `${model.name} not found` });
}
res.json(data);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
router.put(
'/:id',
[...validationRules.idParam, ...validationRules.update],
handleValidationErrors,
async (req, res) => {
try {
const scopes = parseScopes(req.query.scopes);
const instance = await model.scope(scopes).findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${model.name} not found` });
}
await instance.update(req.body);
res.json(instance);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
router.delete(
'/:id',
validationRules.idParam,
handleValidationErrors,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${model.name} not found` });
}
await instance.destroy();
res.status(204).send();
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
// Association Routes (New)
addAssociationRoutes(router, model, pluralModelName, service);
}
/**
* Adds association routes for a model.
* @param {object} router - Express Router instance.
* @param {object} model - Sequelize model instance.
* @param {string} pluralModelName - Pluralized name of the model.
* @param {string} service - Service name.
*/
function addAssociationRoutes(router, model, pluralModelName, service) {
const associations = model.associations;
Object.entries(associations).forEach(([associationName, association]) => {
const associatedModelName = pluralize(association.target.name.toLowerCase());
// GET associated models
router.get(
`/:id/${associatedModelName}`,
param('id').isInt().withMessage('ID must be an integer'),
handleValidationErrors,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${model.name} not found` });
}
const associated = await instance[`get${associationName}`]();
res.json(associated);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
// POST to associate an existing model
router.post(
`/:id/${associatedModelName}`,
param('id').isInt().withMessage('ID must be an integer'),
body('associatedId').isInt().withMessage('Associated ID must be an integer'),
handleValidationErrors,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${model.name} not found` });
}
const associatedInstance = await association.target.findByPk(req.body.associatedId);
if (!associatedInstance) {
return res.status(404).json({ error: `${association.target.name} not found` });
}
await instance[`add${associationName}`](associatedInstance);
res.status(204).send();
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
// DELETE to disassociate an existing model
router.delete(
`/:id/${associatedModelName}/:associatedId`,
[
param('id').isInt().withMessage('ID must be an integer'),
param('associatedId').isInt().withMessage('Associated ID must be an integer'),
],
handleValidationErrors,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${model.name} not found` });
}
const associatedInstance = await association.target.findByPk(req.params.associatedId);
if (!associatedInstance) {
return res.status(404).json({ error: `${association.target.name} not found` });
}
await instance[`remove${associationName}`](associatedInstance);
res.status(204).send();
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
});
}
/**
* Generates OpenAPI schema from Sequelize model attributes.
* @param {object} attributes - Sequelize model attributes.
* @returns {object} OpenAPI schema.
*/
function generateModelSchema(attributes) {
const properties = {};
const required = [];
Object.entries(attributes).forEach(([name, details]) => {
const sequelizeType = details.type.constructor.key;
if (sequelizeType === 'JSON') {
// Handle JSON type explicitly
properties[name] = {
type: 'object',
additionalProperties: true, // Allow any structure by default
};
// Optionally, customize for specific structures
if (details.validate && details.validate.structure) {
properties[name].properties = details.validate.structure.properties || {};
properties[name].required = details.validate.structure.required || [];
}
} else {
// Map other types using the helper function
properties[name] = { type: mapSequelizeTypeToOpenAPI(sequelizeType) };
}
if (!details.allowNull) {
required.push(name);
}
});
return { type: 'object', properties, required };
}
/**
* Generates OpenAPI paths for a model, including CRUD and association routes.
* @param {string} service - Name of the service.
* @param {string} modelName - Singular name of the model.
* @param {string} pluralModelName - Pluralized name of the model.
* @param {object} associations - Sequelize associations for the model.
* @returns {object} OpenAPI paths for the model.
*/
function generatePathsForModel(service, modelName, pluralModelName, associations) {
const paths = {};
const tag = `${service} - ${modelName}`;
// CRUD routes
paths[`/${service}/${pluralModelName}`] = {
get: {
tags: [tag],
summary: `Retrieve all ${pluralModelName}`,
description: `Fetch all ${pluralModelName} from the ${service} service.`,
responses: {
200: {
description: `A list of ${pluralModelName}.`,
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: `#/components/schemas/${modelName}` },
},
},
},
},
},
},
post: {
tags: [tag],
summary: `Create a new ${modelName}`,
description: `Create a new ${modelName} in the ${service} service.`,
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` },
},
},
},
responses: {
201: { description: `${modelName} created successfully.` },
400: { description: `Invalid request.` },
},
},
};
paths[`/${service}/${pluralModelName}/{id}`] = {
get: {
tags: [tag],
summary: `Retrieve a single ${modelName}`,
description: `Fetch a single ${modelName} from the ${service} service by ID.`,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: `ID of the ${modelName} to retrieve.`,
schema: { type: 'integer' },
},
],
responses: {
200: {
description: `The requested ${modelName}.`,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` },
},
},
},
404: { description: `${modelName} not found.` },
},
},
put: {
tags: [tag],
summary: `Update a ${modelName}`,
description: `Update an existing ${modelName} in the ${service} service by ID.`,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: `ID of the ${modelName} to update.`,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` },
},
},
},
responses: {
200: { description: `${modelName} updated successfully.` },
404: { description: `${modelName} not found.` },
},
},
delete: {
tags: [tag],
summary: `Delete a ${modelName}`,
description: `Delete an existing ${modelName} in the ${service} service by ID.`,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: `ID of the ${modelName} to delete.`,
schema: { type: 'integer' },
},
],
responses: {
204: { description: `${modelName} deleted successfully.` },
404: { description: `${modelName} not found.` },
},
},
};
// Association routes
Object.entries(associations).forEach(([associationName, association]) => {
const associatedModelName = association.target.name;
const pluralAssociatedModelName = pluralize(associatedModelName.toLowerCase());
paths[`/${service}/${pluralModelName}/{id}/${pluralAssociatedModelName}`] = {
get: {
tags: [tag],
summary: `Retrieve associated ${pluralAssociatedModelName}`,
description: `Fetch associated ${pluralAssociatedModelName} for a specific ${modelName}.`,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: `ID of the ${modelName} to fetch associated ${pluralAssociatedModelName}.`,
schema: { type: 'integer' },
},
],
responses: {
200: {
description: `A list of associated ${pluralAssociatedModelName}.`,
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: `#/components/schemas/${associatedModelName}` },
},
},
},
},
},
},
post: {
tags: [tag],
summary: `Associate ${associatedModelName} with ${modelName}`,
description: `Associate an existing ${associatedModelName} with a specific ${modelName}.`,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: `ID of the ${modelName} to associate with.`,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
associatedId: {
type: 'integer',
description: `ID of the ${associatedModelName} to associate.`,
},
},
required: ['associatedId'],
},
},
},
},
responses: {
204: { description: `${associatedModelName} associated successfully.` },
404: { description: `${associatedModelName} or ${modelName} not found.` },
},
},
delete: {
tags: [tag],
summary: `Disassociate ${associatedModelName} from ${modelName}`,
description: `Disassociate an existing ${associatedModelName} from a specific ${modelName}.`,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: `ID of the ${modelName}.`,
schema: { type: 'integer' },
},
{
name: 'associatedId',
in: 'path',
required: true,
description: `ID of the ${associatedModelName} to disassociate.`,
schema: { type: 'integer' },
},
],
responses: {
204: { description: `${associatedModelName} disassociated successfully.` },
404: { description: `${associatedModelName} or ${modelName} not found.` },
},
},
};
});
return paths;
}
async function generateRoutesAndOpenAPISpec(servicesDir) {
const services = fs
.readdirSync(servicesDir)
.filter(service => fs.lstatSync(path.join(servicesDir, service)).isDirectory());
const openAPISpec = {
...config.openapi,
paths: {},
components: {
schemas: {},
},
tags: [],
};
const serviceRoutes = services.map(service => {
const configPath = path.join(servicesDir, service, 'config', 'database.js');
const modelsDir = path.join(servicesDir, service, 'database', 'models');
const definitionsPath = path.join(servicesDir, service, 'config', 'definitions.js');
const serviceIndexPath = path.join(servicesDir, service, 'index.js');
const controllersPath = path.join(servicesDir, service, 'controllers', 'index.js');
if (!fs.existsSync(configPath) || !fs.existsSync(modelsDir)) return null;
const serviceConfig = require(path.resolve(configPath)).default;
const sequelize = new Sequelize(serviceConfig);
const models = {};
// Step 1: Initialize all models
fs.readdirSync(modelsDir)
.filter(file => file.endsWith('.js') && file !== 'index.js')
.forEach(file => {
const modelPath = path.join(modelsDir, file);
const modelFactory = require(path.resolve(modelPath));
const model = modelFactory(sequelize, DataTypes);
models[model.name] = model;
// Add model schema to OpenAPI spec
openAPISpec.components.schemas[model.name] = generateModelSchema(model.rawAttributes);
});
// Step 2: Apply associations
const associationsPath = path.join(modelsDir, 'index.js');
if (fs.existsSync(associationsPath)) {
const applyAssociations = require(path.resolve(associationsPath));
applyAssociations(models);
}
// Step 3: Load and add component definitions
if (fs.existsSync(definitionsPath)) {
const definitions = require(path.resolve(definitionsPath));
Object.assign(openAPISpec.components.schemas, definitions);
}
// Step 4: Generate routes for each model
const router = require('express').Router();
Object.values(models).forEach(model => {
const pluralModelName = pluralize(model.name.toLowerCase());
// Generate OpenAPI paths for CRUD and association routes
const crudPaths = generatePathsForModel(service, model.name, pluralModelName, model.associations);
Object.assign(openAPISpec.paths, crudPaths);
// Add tags for the service-model combination
openAPISpec.tags.push({
name: `${service} - ${model.name}`,
description: `Endpoints for ${model.name} in the ${service} service.`,
});
// Generate CRUD and association routes
const attributes = model.rawAttributes;
const associations = model.associations;
generateCRUDRoutes({
router,
model,
pluralModelName,
attributes,
associations,
service,
});
});
// Step 5: Handle custom non-model routes
if (fs.existsSync(serviceIndexPath)) {
const { api } = require(path.resolve(serviceIndexPath));
const controllers = require(path.resolve(controllersPath));
if (api) {
Object.entries(api.paths || {}).forEach(([path, methods]) => {
Object.entries(methods).forEach(([method, config]) => {
const controller = controllers[config.controller];
if (!controller) {
throw new Error(`Controller ${config.controller} not found for path ${path}`);
}
const expressPath = `/${service}${path}`;
const validationSchema = config.requestBody?.schema;
const validationMiddleware = validationSchema
? validateRequestBody(validationSchema)
: (req, res, next) => next();
router[method](expressPath, validationMiddleware, controller);
// Add to OpenAPI spec
openAPISpec.paths[expressPath] = openAPISpec.paths[expressPath] || {};
openAPISpec.paths[expressPath][method] = {
summary: config.summary || '',
description: config.description || '',
tags: [service],
requestBody: config.requestBody
? {
required: true,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${config.requestBody.schema}` },
},
},
}
: undefined,
responses: config.responses || {
200: { description: 'Success' },
},
};
});
});
}
}
return app => {
app.use(`/${service}`, router);
};
});
return { openAPISpec, serviceRoutes: serviceRoutes.flat().filter(Boolean) };
}
/**
* Generates validation rules for Express routes based on Sequelize attributes.
* @param {object} attributes - Sequelize model attributes.
* @returns {object} Validation rules for CRUD operations.
*/
function generateValidationRules(attributes) {
const createRules = [];
const updateRules = [];
const idParamRule = [param('id').isInt().withMessage('ID must be an integer')];
Object.entries(attributes).forEach(([fieldName, details]) => {
const rule = body(fieldName);
// Handle allowNull and notEmpty validations
if (!details.allowNull) {
rule.notEmpty().withMessage(`${fieldName} is required`);
}
// Switch by data type
switch (details.type.constructor.key) {
case 'STRING':
rule.isString().withMessage(`${fieldName} must be a string`);
// Apply built-in Sequelize validators
if (details.validate) {
Object.entries(details.validate).forEach(([validatorName, validatorValue]) => {
switch (validatorName) {
case 'is':
if (Array.isArray(validatorValue)) {
rule.matches(new RegExp(validatorValue[0], validatorValue[1])).withMessage(`${fieldName} does not match the required format`);
} else {
rule.matches(validatorValue).withMessage(`${fieldName} does not match the required format`);
}
break;
case 'not':
if (Array.isArray(validatorValue)) {
rule.not().matches(new RegExp(validatorValue[0], validatorValue[1])).withMessage(`${fieldName} matches a forbidden format`);
} else {
rule.not().matches(validatorValue).withMessage(`${fieldName} matches a forbidden format`);
}
break;
case 'isEmail':
if (validatorValue) rule.isEmail().withMessage(`${fieldName} must be a valid email`);
break;
case 'isUrl':
if (validatorValue) rule.isURL().withMessage(`${fieldName} must be a valid URL`);
break;
case 'isIP':
if (validatorValue) rule.isIP().withMessage(`${fieldName} must be a valid IP address`);
break;
case 'isIPv4':
if (validatorValue) rule.isIP(4).withMessage(`${fieldName} must be a valid IPv4 address`);
break;
case 'isIPv6':
if (validatorValue) rule.isIP(6).withMessage(`${fieldName} must be a valid IPv6 address`);
break;
case 'isAlpha':
if (validatorValue) rule.isAlpha().withMessage(`${fieldName} must only contain letters`);
break;
case 'isAlphanumeric':
if (validatorValue) rule.isAlphanumeric().withMessage(`${fieldName} must only contain letters and numbers`);
break;
case 'isNumeric':
if (validatorValue) rule.isNumeric().withMessage(`${fieldName} must only contain numbers`);
break;
case 'isInt':
if (validatorValue) rule.isInt().withMessage(`${fieldName} must be an integer`);
break;
case 'isFloat':
if (validatorValue) rule.isFloat().withMessage(`${fieldName} must be a floating-point number`);
break;
case 'isDecimal':
if (validatorValue) rule.isDecimal().withMessage(`${fieldName} must be a decimal number`);
break;
case 'isLowercase':
if (validatorValue) rule.isLowercase().withMessage(`${fieldName} must be lowercase`);
break;
case 'isUppercase':
if (validatorValue) rule.isUppercase().withMessage(`${fieldName} must be uppercase`);
break;
case 'len':
rule.isLength({ min: validatorValue[0], max: validatorValue[1] }).withMessage(`${fieldName} must be between ${validatorValue[0]} and ${validatorValue[1]} characters long`);
break;
case 'isIn':
rule.isIn(validatorValue).withMessage(`${fieldName} must be one of: ${validatorValue[0].join(', ')}`);
break;
case 'notIn':
rule.not().isIn(validatorValue).withMessage(`${fieldName} must not be one of: ${validatorValue[0].join(', ')}`);
break;
case 'equals':
rule.equals(validatorValue).withMessage(`${fieldName} must equal ${validatorValue}`);
break;
case 'contains':
rule.contains(validatorValue).withMessage(`${fieldName} must contain ${validatorValue}`);
break;
case 'notContains':
rule.not().contains(validatorValue).withMessage(`${fieldName} must not contain ${validatorValue}`);
break;
case 'isUUID':
rule.isUUID(validatorValue).withMessage(`${fieldName} must be a valid UUID version ${validatorValue}`);
break;
case 'isDate':
if (validatorValue) rule.isISO8601().withMessage(`${fieldName} must be a valid date`);
break;
case 'isAfter':
rule.isAfter(validatorValue).withMessage(`${fieldName} must be after ${validatorValue}`);
break;
case 'isBefore':
rule.isBefore(validatorValue).withMessage(`${fieldName} must be before ${validatorValue}`);
break;
case 'max':
rule.isFloat({ max: validatorValue }).withMessage(`${fieldName} must be less than or equal to ${validatorValue}`);
break;
case 'min':
rule.isFloat({ min: validatorValue }).withMessage(`${fieldName} must be greater than or equal to ${validatorValue}`);
break;
case 'isCreditCard':
if (validatorValue) rule.isCreditCard().withMessage(`${fieldName} must be a valid credit card number`);
break;
default:
if (typeof validatorValue === 'function') {
rule.custom(validatorValue).withMessage(`${fieldName} failed custom validation`);
}
}
});
}
break;
}
// Add rules to create and update
createRules.push(rule);
updateRules.push(rule.optional());
});
return {
create: createRules,
update: updateRules,
idParam: idParamRule,
};
}
/**
* Middleware to handle validation errors from express-validator.
* @param {object} req - Express request object.
* @param {object} res - Express response object.
* @param {function} next - Next middleware function.
*/
function handleValidationErrors(req, res, next) {
const { validationResult } = require('express-validator');
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
module.exports = {
openapi: '3.0.0',
info: {
title: 'My API Documentation',
description: 'Automatically generated API documentation.',
version: '1.0.0',
contact: {
name: 'API Support',
url: 'https://example.com/support',
email: '[email protected]',
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
servers: [
{ url: 'https://api.example.com/v1', description: 'Production server' },
{ url: 'https://api-staging.example.com/v1', description: 'Staging server' },
],
};
const fs = require('fs');
const path = require('path');
const { Sequelize, DataTypes } = require('sequelize');
const pluralize = require('pluralize');
const config = require('./config'); // OpenAPI metadata
/**
* Generates routes and OpenAPI spec based on services and their models.
* @param {string} servicesDir - Path to the services directory.
* @returns {object} Contains OpenAPI spec and route registration functions.
*/
async function generateRoutesAndOpenAPISpec(servicesDir) {
const services = fs
.readdirSync(servicesDir)
.filter(service => fs.lstatSync(path.join(servicesDir, service)).isDirectory());
const openAPISpec = {
...config, // Merge OpenAPI metadata
paths: {},
components: {
schemas: {},
},
tags: [], // Initialize top-level tags
};
const serviceRoutes = services.map(service => {
const configPath = path.join(servicesDir, service, 'config', 'database.js');
const modelsDir = path.join(servicesDir, service, 'database', 'models');
if (!fs.existsSync(configPath) || !fs.existsSync(modelsDir)) return null;
// Load Sequelize configuration and initialize instance
const serviceConfig = require(`./${configPath}`).default;
const sequelize = new Sequelize(serviceConfig);
const models = fs
.readdirSync(modelsDir)
.filter(file => file.endsWith('.js') && file !== 'index.js')
.map(file => {
const modelPath = path.join(modelsDir, file);
// Dynamically load and initialize the model
const modelFactory = require(`./${modelPath}`);
const model = modelFactory(sequelize, DataTypes);
const modelName = model.name;
const pluralModelName = pluralize(modelName.toLowerCase());
const attributes = model.rawAttributes;
// Add model schema to OpenAPI components
openAPISpec.components.schemas[modelName] = generateModelSchema(attributes);
// Generate CRUD paths for OpenAPI
const paths = generatePathsForModel(service, modelName, pluralModelName);
Object.assign(openAPISpec.paths, paths);
// Add tag to top-level OpenAPI tags
const tag = `${service} - ${modelName}`;
if (!openAPISpec.tags.some(t => t.name === tag)) {
openAPISpec.tags.push({ name: tag, description: `Endpoints for ${modelName} in ${service}.` });
}
// Return a function to register routes for this model
return app => {
const router = require('express').Router();
// CRUD routes
generateCRUDRoutes(router, model, pluralModelName, attributes, service);
app.use(`/${service}/${pluralModelName}`, router);
};
});
return models;
});
return { openAPISpec, serviceRoutes: serviceRoutes.flat().filter(Boolean) };
}
/**
* Generates OpenAPI paths for a model.
* @param {string} service - Name of the service.
* @param {string} modelName - Singular name of the model.
* @param {string} pluralModelName - Pluralized name of the model.
* @returns {object} OpenAPI paths for the model.
*/
function generatePathsForModel(service, modelName, pluralModelName) {
const tag = `${service} - ${modelName}`;
return {
[`/${service}/${pluralModelName}`]: {
get: {
tags: [tag],
summary: `Retrieve all ${pluralModelName}`,
description: `Fetch all ${pluralModelName} from the ${service} service.`,
parameters: [
{
name: 'scopes',
in: 'query',
description: 'Comma-separated list of scopes to apply to the query.',
required: false,
schema: { type: 'string' },
},
],
responses: {
200: {
description: `A list of ${pluralModelName}.`,
content: {
'application/json': {
schema: {
type: 'array',
items: { $ref: `#/components/schemas/${modelName}` },
},
},
},
},
},
},
post: {
tags: [tag],
summary: `Create a new ${modelName}`,
description: `Create a new ${modelName} in the ${service} service.`,
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` },
},
},
},
responses: {
201: { description: `${modelName} created successfully.` },
400: { description: `Invalid request.` },
},
},
},
[`/${service}/${pluralModelName}/{id}`]: {
get: {
tags: [tag],
summary: `Retrieve a single ${modelName}`,
description: `Fetch a single ${modelName} from the ${service} service by ID.`,
parameters: [
{
name: 'id',
in: 'path',
description: `ID of the ${modelName} to retrieve.`,
required: true,
schema: { type: 'integer' },
},
{
name: 'scopes',
in: 'query',
description: 'Comma-separated list of scopes to apply to the query.',
required: false,
schema: { type: 'string' },
},
],
responses: {
200: {
description: `The requested ${modelName}.`,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` },
},
},
},
404: { description: `${modelName} not found.` },
},
},
put: {
tags: [tag],
summary: `Update a ${modelName}`,
description: `Update an existing ${modelName} in the ${service} service by ID.`,
parameters: [
{
name: 'id',
in: 'path',
description: `ID of the ${modelName} to update.`,
required: true,
schema: { type: 'integer' },
},
],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: `#/components/schemas/${modelName}` },
},
},
},
responses: {
200: { description: `${modelName} updated successfully.` },
404: { description: `${modelName} not found.` },
},
},
delete: {
tags: [tag],
summary: `Delete a ${modelName}`,
description: `Delete an existing ${modelName} in the ${service} service by ID.`,
parameters: [
{
name: 'id',
in: 'path',
description: `ID of the ${modelName} to delete.`,
required: true,
schema: { type: 'integer' },
},
],
responses: {
204: { description: `${modelName} deleted successfully.` },
404: { description: `${modelName} not found.` },
},
},
},
};
}
const { body, param } = require('express-validator');
/**
* Generates CRUD routes for a model and registers them with the router.
* @param {object} router - Express Router instance.
* @param {object} model - Sequelize model instance.
* @param {string} pluralModelName - Pluralized name of the model.
* @param {object} attributes - Model attributes.
* @param {string} service - Service name.
*/
function generateCRUDRoutes(router, model, pluralModelName, attributes, service) {
const validationRules = generateValidationRules(attributes);
router.post(
'/',
validationRules.create,
handleValidationErrors,
async (req, res) => {
try {
const modelInstance = await model.create(req.body);
res.status(201).json(modelInstance);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
router.get('/', async (req, res) => {
try {
const scopes = parseScopes(req.query.scopes);
const data = await model.scope(scopes).findAll();
res.json(data);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
router.get(
'/:id',
validationRules.idParam,
handleValidationErrors,
async (req, res) => {
try {
const scopes = parseScopes(req.query.scopes);
const data = await model.scope(scopes).findByPk(req.params.id);
if (!data) {
return res.status(404).json({ error: `${model.name} not found` });
}
res.json(data);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
router.put(
'/:id',
[...validationRules.idParam, ...validationRules.update],
handleValidationErrors,
async (req, res) => {
try {
const scopes = parseScopes(req.query.scopes);
const instance = await model.scope(scopes).findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${model.name} not found` });
}
await instance.update(req.body);
res.json(instance);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
router.delete(
'/:id',
validationRules.idParam,
handleValidationErrors,
async (req, res) => {
try {
const instance = await model.findByPk(req.params.id);
if (!instance) {
return res.status(404).json({ error: `${model.name} not found` });
}
await instance.destroy();
res.status(204).send();
} catch (error) {
res.status(400).json({ error: error.message });
}
}
);
}
/**
* Parses a scopes query parameter into an array of scopes for Sequelize.
* @param {string | undefined} scopesQuery - Comma-separated list of scopes.
* @returns {Array<string>} Array of scopes to apply.
*/
function parseScopes(scopesQuery) {
if (!scopesQuery) return ['defaultScope']; // Apply default scope if none is provided
return scopesQuery.split(',').map(scope => scope.trim());
}
/**
* Generates OpenAPI schema from Sequelize model attributes.
* @param {object} attributes - Sequelize model attributes.
* @returns {object} OpenAPI schema.
*/
function generateModelSchema(attributes) {
const properties = {};
const required = [];
Object.entries(attributes).forEach(([name, details]) => {
properties[name] = { type: mapSequelizeTypeToOpenAPI(details.type.constructor.key) };
if (!details.allowNull) required.push(name);
});
return { type: 'object', properties, required };
}
/**
* Maps Sequelize data type to OpenAPI type.
* @param {string} sequelizeType - Sequelize type key.
* @returns {string} OpenAPI type.
*/
function mapSequelizeTypeToOpenAPI(sequelizeType) {
const typeMapping = {
STRING: 'string',
INTEGER: 'integer',
BOOLEAN: 'boolean',
FLOAT: 'number',
DATE: 'string', // Optionally add "format: date-time"
};
return typeMapping[sequelizeType] || 'string';
}
// Example usage:
(async () => {
const { openAPISpec, serviceRoutes } = await generateRoutesAndOpenAPISpec('./services');
// Example Express app
const express = require('express');
const app = express();
app.use(express.json());
// Register service-specific routes
serviceRoutes.forEach(registerRoutes => registerRoutes(app));
// Serve OpenAPI spec
app.get('/openapi.json', (req, res) => res.json(openAPISpec));
app.listen(3000, () => console.log('Server running on port 3000'));
})();
/**
* Synchronizes Sequelize models with the database and applies associations.
* @param {string} servicesDir - Path to the services directory.
* @returns {Promise<void>} Resolves when all services are synchronized.
*/
async function syncDatabases(servicesDir) {
const services = fs
.readdirSync(servicesDir)
.filter(service => fs.lstatSync(path.join(servicesDir, service)).isDirectory());
const startTime = Date.now();
const updateLog = (message) => {
process.stdout.write(`\r${message} `.padEnd(process.stdout.columns));
};
try {
updateLog('Starting database synchronization...');
for (const service of services) {
const configPath = path.join(servicesDir, service, 'config', 'database.js');
const modelsDir = path.join(servicesDir, service, 'database', 'models');
if (!fs.existsSync(configPath) || !fs.existsSync(modelsDir)) {
updateLog(`Skipping service: ${service} (missing config or models directory)`);
continue;
}
// Load Sequelize configuration and initialize instance
const serviceConfig = require(`./${configPath}`).default;
const sequelize = new Sequelize(serviceConfig);
// Initialize models
const models = {};
fs.readdirSync(modelsDir)
.filter(file => file.endsWith('.js') && file !== 'index.js')
.forEach(file => {
const modelPath = path.join(modelsDir, file);
const modelFactory = require(`./${modelPath}`);
const model = modelFactory(sequelize, DataTypes);
models[model.name] = model;
});
// Apply associations from index.js
const associationsPath = path.join(modelsDir, 'index.js');
if (fs.existsSync(associationsPath)) {
const applyAssociations = require(`./${associationsPath}`);
applyAssociations(models);
}
// Synchronize the models with the database
updateLog(`Synchronizing database for service: ${service}`);
await sequelize.sync({ alter: true }); // Use { force: true } for development purposes
}
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(2);
updateLog(`Database synchronization completed in ${elapsedTime} seconds.\n`);
} catch (error) {
updateLog('Database synchronization failed.\n');
console.error(error);
}
}
/**
* Converts a string to title case.
* @param {string} str - The string to convert.
* @returns {string} The title-cased string.
*/
function toTitleCase(str) {
return str
.toLowerCase()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Middleware to validate request body against a schema.
* @param {string} schemaName - The name of the schema to validate against.
* @returns {Function} Express middleware.
*/
function validateRequestBody(schemaName) {
return (req, res, next) => {
const schema = openAPISpec.components.schemas[schemaName];
if (!schema) {
return res.status(500).json({ error: `Schema ${schemaName} not found` });
}
// Validate request body (use a library like Ajv or Joi for validation)
const Ajv = require('ajv');
const ajv = new Ajv();
const validate = ajv.compile(schema);
if (!validate(req.body)) {
return res.status(400).json({ error: 'Invalid request body', details: validate.errors });
}
next();
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment