Last active
December 19, 2024 17:28
-
-
Save markgarrigan/3a34b3886eec5be7a310b1d3ee8c6784 to your computer and use it in GitHub Desktop.
Dynamic API Generation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 }); | |
} | |
} | |
); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 }); | |
} | |
} | |
); | |
}); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 }; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) }; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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, | |
}; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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(); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }, | |
], | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')); | |
})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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(' '); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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