Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Epic] Swagger / OpenApi #349

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 5 additions & 1 deletion apps/dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"private": true,
"scripts": {
"backend:dev": "PORT=8080 API_URL=http://localhost:8080 leemons-runner --env services/**/*.service.js",
"backend:test": "NODE_ENV=test yarn backend:dev",
"backend:test:nats": "NODE_ENV=test TRANSPORTER=NATS yarn backend:dev",
"backend:dev:hot": "PORT=8080 API_URL=http://localhost:8080 leemons-runner --env --hot services/**/*.service.js",
"backend:dev:nats": "TRANSPORTER=NATS yarn backend:dev",
"backend:dev:nats:hot": "TRANSPORTER=NATS yarn backend:dev:hot",
Expand All @@ -13,10 +15,12 @@
"front:build:dev": "NODE_ENV=development API_URL=https://api.leemons.dev NODE_OPTIONS=\"--max-old-space-size=8192\" leemonsFront build",
"front:build": "NODE_ENV=production PORT=3000 leemonsFront build -m ../..",
"front:preview": "leemonsFront preview",
"front": "yarn front:build && yarn front:preview"
"front": "yarn front:build && yarn front:preview",
"leemons-openapi": "leemons-openapi"
},
"leemons": {},
"dependencies": {
"@leemons/openapi": "0.0.40",
"@leemons/runner": "0.0.71",
"colord": "^2.9.3",
"dotenv": "^16.0.3",
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
],
"scripts": {
"start:app": "yarn workspace leemons-app backend:dev",
"start:test": "yarn workspace leemons-app backend:test:nats",
"done": "echo Done ✨",
"check": "start-server-and-test 'PORT=8080 yarn start:app' http-get://localhost:8080/api/gateway/list-aliases 'yarn done'",
"prepare": "husky install"
"prepare": "husky install",
"leemons-openapi": "yarn workspace leemons-app leemons-openapi"
},
"packageManager": "[email protected]",
"devDependencies": {
Expand Down
18 changes: 12 additions & 6 deletions packages/leemons-deployment-manager/src/mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const {
getPluginNameFromServiceName,
getPluginNameWithVersionIfHaveFromServiceName,
} = require('@leemons/service-name-parser');
const { createOpenapiSchemas } = require('@leemons/openapi');
const { isCoreService } = require('./isCoreService');
const { getDeploymentID } = require('./getDeploymentID');
const { ctxCall } = require('./ctxCall');
Expand Down Expand Up @@ -104,12 +105,17 @@ module.exports = function ({
},
hooks: {
after: {
'*': function (ctx, res) {
if (!CONTROLLED_HTTP_STATUS_CODE.includes(ctx.meta.$statusCode)) {
ctx.meta.$statusCode = 200;
}
return res;
},
'*': [
async (ctx, res) => {
if (!CONTROLLED_HTTP_STATUS_CODE.includes(ctx.meta.$statusCode)) {
ctx.meta.$statusCode = 200;
}
if (process.env.NODE_ENV === 'test') {
await createOpenapiSchemas({ res, ctx });
}
return res;
},
],
},
before: {
'*': [
Expand Down
10 changes: 10 additions & 0 deletions packages/leemons-openapi/bin/createOpenapiFiles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env node

const path = require('path');

const { createOpenapiFiles } = require('../lib/createOpenapiFiles');

const rootDir = process.argv[2] || path.join(__dirname, '..', '..', '..');

console.log('Creating openapi files from', rootDir);
createOpenapiFiles(rootDir);
51 changes: 51 additions & 0 deletions packages/leemons-openapi/createOpenapiSchemas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const jsonSchemaGenerator = require('json-schema-generator');

const { getControllerPath, prepareControllerFile } = require('./lib/controllers');
const { prepareOpenapiFile } = require('./lib/openapi');
const { buildServicePath } = require('./lib/services');

/**
* Decomposes the action name into its components
* @param {string} actionName - The action name
* @returns {Array} The components of the action name
*/
function decomposeActionName(actionName) {
const [version, plugin, service, controller] = actionName.split('.');
return [version, plugin, service, controller];
}

/**
* Creates the openapi request & response schemas
* @param {Object} params - The parameters
* @param {Object} params.res - The response
* @param {Object} params.ctx - The context
*/
async function createOpenapiSchemas({ res, ctx }) {
const { TESTING, NODE_ENV } = process.env;
const isTesting = TESTING || NODE_ENV === 'test' || process.env.testing;
if (isTesting && ctx.action.rest) {
const actionName = ctx.action.name;
const responseSchema = jsonSchemaGenerator(res);
const requestSchema = jsonSchemaGenerator(ctx.params);
try {
const [, plugin, service, controller] = decomposeActionName(actionName);

const serviceFilePath = buildServicePath({ plugin, service });
const controllerFilePath = getControllerPath(serviceFilePath, service);

prepareControllerFile({ controllerFilePath, service, controller, ctx });

await prepareOpenapiFile({
controllerFilePath,
service,
controller,
responseSchema,
requestSchema,
});
} catch (error) {
ctx.logger.error(`ERROR Openapi: ${ctx.action.name} - ${error.message}`);
}
}
}

module.exports = { createOpenapiSchemas };
9 changes: 9 additions & 0 deletions packages/leemons-openapi/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const mixin = require('./mixin');
const { createOpenapiSchemas } = require('./createOpenapiSchemas');
const { createOpenapiFiles } = require('./lib/createOpenapiFiles');

module.exports = {
LeemonsOpenApiMixin: mixin,
createOpenapiSchemas,
createOpenapiFiles,
};
186 changes: 186 additions & 0 deletions packages/leemons-openapi/lib/controllers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
const os = require('os');
const path = require('path');
const espree = require('espree');
const estraverse = require('estraverse');
const prettier = require('prettier');

const { readFile } = require('./files');
const { writeFile } = require('./files');
const { findServiceFile } = require('./services');
const { prettierOptions } = require('./prettierOptions');

/**
* Parses the code into an Abstract Syntax Tree (AST)
* @param {string} code - The code to parse
* @returns {Object} The AST of the code
*/
function parseCodeToAst(code) {
return espree.parse(code, { range: true, ecmaVersion: 12, sourceType: 'module' });
}

/**
* Finds all the controllers in a given file
* @param {string} controllerFilePath - The path of the controller file
* @returns {Array} An array of the names of the controllers
*/
function findControllers(controllerFilePath) {
const code = readFile(controllerFilePath);

const regex = /(\w+Rest):\s\{/g; // Note the 'g' flag for global search
return [...code.matchAll(regex)].map((match) => match[1]);
}

/**
* Searches for the controller property in the AST
* @param {Object} ast - The AST to search in
* @param {string} controller - The controller to search for
* @returns {Object|null} The controller object or null if not found
*/
function searchController(ast, controller) {
let controllerObj = null;
estraverse.traverse(ast, {
enter(node) {
if (node.type === 'Property' && node.key.name === controller) {
controllerObj = node;
}
},
});
return controllerObj;
}

/**
* Checks if the controller object has an openapi property
* @param {Object} controllerObj - The controller object
* @returns {boolean} True if the openapi property exists, false otherwise
*/
function hasOpenApiProperty(controllerObj) {
return controllerObj.value.properties.some((prop) => prop.key.name === 'openapi');
}

/**
* Adds a require statement to the code
* @param {string} code - The code to add the require statement to
* @param {string} service - The service
* @param {string} controller - The controller
* @returns {string} The code with the added require statement
*/
function addRequireStatement(code, service, controller) {
const requireStatement = `const ${controller} = require('./openapi/${service}/${controller}');`;

if (!code.includes(requireStatement)) {
const updatedCode = code.replace(`/** @type {ServiceSchema} */${os.EOL}`, '');

return updatedCode.replace(
'module.exports',
`${requireStatement}${os.EOL}/** @type {ServiceSchema} */${os.EOL}module.exports`
);
}
return code;
}

/**
* Adds the openapi property to the controller in the code
* @param {string} code - The code to add the openapi property to
* @param {string} controller - The controller
* @returns {string} The code with the added openapi property
* @throws {Error} If the controller line is not found.
*/
function addOpenApi(code, controller) {
const regex = new RegExp(`(${controller}:\\s*\\{)`, 'g');
const match = regex.exec(code);
if (match) {
const newLine = `${match[1]}${os.EOL} openapi: ${controller}.openapi,`;
return code.replace(regex, newLine);
}
throw new Error('Controller line not found');
}

/**
* Finds the import line in the file
* @param {string} filePath - The path of the file
* @param {string} service - The service to find
* @returns {string|null} The import line or null if not found
* @throws {Error} If the service file is not found or an error occurs while reading the file.
*/
function findImportLine(filePath, service) {
let fileContent = null;

try {
fileContent = readFile(filePath);
} catch (error) {
if (error.code === 'ENOENT') {
// If the service name does not match the service file name
// We search for a service in the plugin that has a 'name' matching the service name
const serviceFilePath = findServiceFile(path.dirname(filePath), service);
if (!serviceFilePath) throw new Error('Service File not found');
return findImportLine(serviceFilePath, service);
}
throw new Error(`Read file Error: ${error}`);
}

const lines = fileContent.split(os.EOL);
return lines.find((line) => line.includes(`require('./`) && line.endsWith(`.rest');`));
}

/**
* Gets the path of the imported file
* @param {string} importLine - The import line
* @param {string} currentFilePath - The path of the current file
* @returns {string|null} The path of the imported file or null if not found
*/
function getImportedFilePath(importLine, currentFilePath) {
const match = importLine.match(/require\('(.*)'\)/);
if (match && match[1]) {
return path.resolve(path.dirname(currentFilePath), `${match[1]}.js`);
}
return null;
}

/**
* Gets the path of the controller
* @param {string} filePath - The path of the file
* @param {string} service - The service
* @returns {string} The path of the controller
* @throws {Error} If the import line of rest actions is not found
*/
function getControllerPath(filePath, service) {
const importLine = findImportLine(filePath, service);
if (!importLine) throw Error('import line of rest actions not found');
return getImportedFilePath(importLine, filePath);
}

/**
* Prepares the controller file
* @param {Object} params - The parameters
* @param {string} params.controllerFilePath - The path of the controller file
* @param {string} params.service - The service
* @param {string} params.controller - The controller
* @param {Object} params.ctx - The context
* @throws {Error} If the controller is not found or the openapi property already exists
*/
function prepareControllerFile({ controllerFilePath, service, controller, ctx }) {
let code = readFile(controllerFilePath);
const ast = parseCodeToAst(code);
const controllerObj = searchController(ast, controller);
if (!controllerObj)
throw new Error(`Openapi: ${controllerFilePath} Controller "${controller}" not found`);

if (controllerObj && !hasOpenApiProperty(controllerObj)) {
code = addRequireStatement(code, service, controller);
code = addOpenApi(code, controller);
writeFile(controllerFilePath, prettier.format(code, prettierOptions));
} else {
const message = `Openapi: ${controllerFilePath} - ${controller}: Property "openapi" already exists, I can't replace it`;
if (ctx) {
ctx.logger.warn(message);
} else {
console.warn('\x1b[31m', message, '\x1b[37m');
}
}
}

module.exports = {
findControllers,
getControllerPath,
prepareControllerFile,
};
Loading