A fast, easy and powerful framework for API costruction with node and express
Main Features:
Efesto provides a large amount of facilitations like:
- Easy and clean way to write API documentation throughout swagger
- Easy way to validate requests (body, headers etc...)
- Enable caching throughout redis
- Easy way to authenticate your endpoints
- Support for ABAC
- Easy error handling
- Enhance your endpoints structure only with the file/folder positioning
- Full TypeScript support
Main Dependencies:
Installation and Setup:
Install the efesto package into your project using:
npm i efesto@tridente-neptuni
in your app file use efesto as a middleware:
...
import efesto from "efesto"
const app = express();
app.use("/api/v1", efesto(efestoConfiguration))
efesto configuration object is structured like this:
Parameter | Required | Description | Default |
---|
authMiddleware | ✅ | the authentication middleware - read more | / |
errorMiddleware | ✅ | the middleware which handles errors - read more | / |
isProduction | ✅ | Enables the production mode: the watch will be on pointes js (dist) files | / |
options | ❌ | Efesto's options object | undefined |
Options
Parameter | Description | Default |
---|
absoluteDirRoutes | Path where Efesto will find the files to parse | "/v1/routes" |
relativeDirSwaggerDeclarationsPath | Folder where Efesto will create the swagger .yaml files | "swagger-declarations" |
config | The Efesto's configuration object | undefined |
Config Object
Configuration
The efesto configuration is pretty easy:
You just need to create a baseIndex.json
file with the base configuration for swagger (servers, authentication, ecc...) in this file you can also specify general schemas
:thought_balloon: In order to generate a Swagger UI we suggest you use @apidevtools/swagger-parser bundling all the declarations in an unique .json
file and, using swagger-ui-express, generate the swagger UI
Authentication Middleware
The authentication middleware it's just a simple middleware that must be given to Efesto in order to authenticate all the requests, if you do not want to authenticate the requests declare a function like this:
(req: Request, res: Response, next: NextFunction) => {
next();
};
In your authentication middleware you can do whatever you want, remember that whatever you put inside the request can be retrieved in all the efesto middlewares
Error Middleware
The error middleware it's a middleware where you can handle all the errors and exceptions that must be given to Efesto.
If you don't have any error to handle you can simply pass a function like this:
(req: Request, res: Response, next: NextFunction) => {
next();
};
Usage
:information_source: The following explanation assumes that you set "swagger-declarations"
as relativeDirSwaggerDeclarationsPath
, "/v1/routes"
as absoluteDirRoutes
and declared, on express, "/api/v1"
as main path for Efesto
Declaring endpoints
In order to declare an Efesto's endpoint you just need to create a file inside the "/v1/routes"
directory so, creating an index.ts
or index.js
(depending on your isProduction
configuration value) in /v1/routes/users
will automatically generate the path /api/v1/users
on your server.
In the same way creating an anyname.ts
file in the same folder will result in the generation of the path /api/v1/users/anyname
Declaring methods
this is an Efesto's file example:
class ModelName extends BaseApiService {
constructor() {
super(__filename);
}
swaggerModel: SwaggerModel = {
modelName: "ModelReference",
schemas: [
{
name: "User",
properties: {
id: "number",
name: "string",
surname: "string",
},
},
],
};
_getSwagger: SwaggerOptions = {
operationId: "getUsers",
cache: {
key: "`users`",
expiresInSeconds: 600,
},
responses: {
200: {
content: {
"application/json": {
schema: {
type: "array",
items: "@User",
},
},
},
},
},
};
async _get(req: express.Request, res: express.Response, next: express.NextFunction) {
return res.sendStatus(200);
}
}
export default ModelName;
Now let's analyze the example above in code chunks in order to understand the structure of the file:
First of all the file must export by default a class which extends BaseApiService
(the class name does not matter) having as super()
constructor function the file name (you can access it throughout the NodeJs variable __filename
)
class ModelName extends BaseApiService {
constructor(){
super(__filename);
}
...
}
export default ModelName
The swaggerModel
property inside the class is used to specify the "category" (for example if the endpoint contains methods to control users the best "category" may be "users"
or "user"
) of the endpoint which have to be specified in the modelName
attribute of swaggerModel
. Specify the modelName is highly recommended as the absence of this attribute will result in grouping all the endpoints under the n-a
category.
In the swaggerModel
property can also be specified swagger models, the peculiarity of those models (as all the other Efesto's models) is that, unlike in swagger, models are shared in all the Efesto project. All the models must be specified in the schemas
attribute of swaggerModel
class ModelName extends BaseApiService {
...
swaggerModel: SwaggerModel = {
modelName: "ModelReference",
schemas: [
{
name: "User",
properties: {
id: {
type: "number"
},
name: {
type: "string"
},
surname: {
type: "string"
},
},
},
],
};
...
}
export default ModelName
Every method (POST
, PUT
, DELETE
, etc...) must have both a swagger declaration (_<method>Swagger
) and a corresponding function (can be asynchronous or synchronous) (_<method>(req, res, next)
) this function will be the middleware that will be triggered when a request will be done at the endpoint using, as method, <method>
.
class ModelName extends BaseApiService {
...
_getSwagger: SwaggerOptions = {
operationId: "getUsers",
responses: {
200: {
content: {
"application/json": {
schema: {
type: "array",
items: "@User",
},
},
},
},
},
};
async _get(req: express.Request, res: express.Response, next: express.NextFunction) {
return res.sendStatus(200);
}
...
}
export default ModelName
As you can see in the above snippet, using the @
notation (@<schemaName>
) will result in the automatic binding and parsing between the declared schema reference and the actual declaration path in the .yaml
file.
This will be the declaration inside the .yaml
file:
$ref: index.yaml#/components/schemas/User
Dynamic paths
Efesto supports dynamic endpoints throughout parameters in paths:
for example, having this file structure:
v1/
├─ routes/
├─ users/
├─ [userId].ts
will result in a path like this: /api/v1/users/[userId]
where userId
can be a string
or number
depending on the dynamicParameterType
value. This means that, in the [userId]
path request.params
will have userId
eg:
if someone contacts your server using this route: api/v1/users/620d1be516cd481f1131169d
class ModelName extends BaseApiService {
async _get(req: express.Request, res: express.Response, next: express.NextFunction) {
console.log(req.params.userId);
return res.sendStatus(200);
}
}
export default ModelName;
the value of req.params.userId
will be 620d1be516cd481f1131169d
Request Validation
Efesto uses express-validator for validate your requests:
you can declare the validation inside the Efesto's files:
class ModelName extends BaseApiService {
...
_putValidation = [check("name").isString()]
...
}
export default ModelName
in this example the validation will search in the request if name
is present and it's a string
, for further information (also about the error handling) read the express-validator documentation.
Overriding Authentication
Due to the fact that not every endpoint requires the same authentication you can override the default authentication in every method:
class ModelName extends BaseApiService {
...
_getOverrideAuth = (req: Request, res: Response, next: NextFunction)=> {
next()
}
...
}
export default ModelName
in this case the GET
endpoint authentication will be overrode by the new middleware
Using permissions (ABAC)
If you are using ABAC in your project you can specify the general permission in the swagger specification. This will not only be written in the swagger files but will be also checked before executing requests.
Be sure to configure correctly Efesto adding inside the config object the required data in order to make the permissions handling work properly
The required data is contained in the ABAC configuration object:
ABAC configuration object
the ABAC configuration object is structured as follows:
property | type | description | default |
---|
actions | string[] | all the available actions | |
models | string[] | all the available models | |
checkPermissionBeforeResolver | boolean | if true check permissions before executing the endpoint method | true |
reqAbilityField | string | the request field where efesto can find the permission object | ability |
Once you have defined all the properties you will be able to use ABAC in Efesto.
ABAC usage
:information_source: The following instruction are written assuming that the reqAbilityField
parameter is set to ability
. Furthermore in order to make the ABAC work correctly the use of the @casl/ability library is highly recommended
First of all, in your authentication middleware, (or in any other middleware) create the abilities
This is how you can declare required permission:
class ModelName extends BaseApiService {
...
_getSwagger: SwaggerOptions = {
operationId: "getUsers",
permission: ["readAll", "Users"],
responses: {
200: {
content: {
"application/json": {
schema: {
type: "array",
items: "@User",
},
},
},
},
},
};
...
}
export default ModelName
in this case, before executing the method Efesto will check if the client has the required permission. If not a ForbiddenError will be raised.
Using Cache
Efesto has an embedded caching system based on Redis.
In order to enable this feature you need to configure Redis when instancing Efesto.
this.app.use(
"/api/v1",
efesto({
...
options: {
...
config: {
redis: {
host: "127.0.0.1"
port: 9091
defaultExpiresInSeconds: 600
},
},
},
})
);
Once you declared this data Efesto's caching system is ready to go!
In order to cache an endpoint response you just need to declare the corresponding Redis key:
_getSwagger: SwaggerOptions = {
...
cache: {
key: "`users`",
},
...
};
Remember that the key attribute must contain a string that has to be evaluated so using backticks in it ("``"
) is required.
Furthermore the key has access to the req
object (the request) so you can create a dynamic key based on the request data.
Assuming that you have inside your request an user
object composed like this:
{
id: "622b8386f96d33fa0c24428b",
name: "Foo",
surname: "Bar"
}
In order to have a cache based on the user in the request you will have to describe the key like this:
_getSwagger: SwaggerOptions = {
...
cache: {
key: "`user-${req.user.id}-operationid`",
},
...
};
that will result (in this specific case) in the following key: user-622b8386f96d33fa0c24428b-operationid
.
If the cached data for the endpoint is found in redis Efesto will skip the method returning the cached data.
you can also specify the specific TTL for the endpoint cache:
_getSwagger: SwaggerOptions = {
...
cache: {
key: "`user-${req.user.id}-operationid`",
expiresInSeconds: 60
},
...
};
In order to purge the cache you have to specify which endpoint invalidates the cached data using the purge
attribute:
_postSwagger: SwaggerOptions = {
...
purge: ["`users`"]
...
};
the purge attribute accepts an array of Redis keys that will be deleted on the endpoint call.
Both key
and purge
parameters are optional and support all Redis operators
Uploading files
In case you need to upload files throughout your APIs Efesto uses multer to handle file uploads.
In order to upload files you have to declare the content of the request body as "multipart/form-data"
and enable the multer flag:
class ModelName extends BaseApiService {
...
_postSwagger: SwaggerOptions = {
...
requestBody:{
content:{
"multipart/form-data":{
schema:{
type: "object",
properties:{
file: {
type: "string",
format: "binary"
}
}
}
}
}
}
};
_postMulter: boolean = true;
...
}
using this configuration the file will be accessible in the request:
async _post(req: Request, res: Response, next: NextFunction){
const file = req.file
...
}
Efesto accepts by default single file uploads, in order to enable the ability of upload multiple files at once you need to set the property MultipleMulter
in the endpoint declaration:
class ModelName extends BaseApiService {
...
_postSwagger: SwaggerOptions = {
...
requestBody:{
content:{
"multipart/form-data":{
schema:{
type: "object",
properties:{
files: {
type: "array",
items:{
type: "string",
format: "binary"
}
}
}
}
}
}
}
};
_postMulter: boolean = true;
_postMultipleMulter: boolean = true
...
}
using this configuration the files will be accessible in the request:
async _post(req: Request, res: Response, next: NextFunction){
const files = req.files
...
}
Shorthands
Writing documentation in Swagger can sometimes be redundant and confusing. For this reason Efesto has a bunch of shorthands that can lighten up the documentation writing:
-
Schemas reference and sharing | @Schema
As told before Efesto, unlike swagger, shares all the declared schemas in the whole project and can be accessible using the @
followed by the schema name.
:warning: This is also the suggested way to refer to a schema due to the fact that, otherwise, you will need to specify in the $ref
property the .yaml
generated file path using the Swagger syntax
Example:
class ModelName extends BaseApiService {
constructor() {
super(__filename);
}
swaggerModel: SwaggerModel = {
modelName: "ModelReference",
schemas: [
{
name: "User",
properties: {
id: {
type:"number"
},
name: {
type:"string"
},
surname: {
type:"string"
},
},
},
],
};
_postSwagger: SwaggerOptions = {
...
responses:{
200: {
content:{
"application/json":{
schema:"@User"
}
}
}
}
}
}
-
Formats | type::format
In order to declare a format you can use the following syntax:
...
properties: {
date: {
type: "string::date-time"
},
},
...
-
Examples | type|example
In case you need to declare an example you can easily declare with this syntax:
...
properties: {
name: {
type: "string|Joe"
},
},
...
:information_source: This last two shorthands (format and type) are chainable together
-
Quick Type Declaration | propertyName: "type"
In the documentation each property must have a declared type and it's rare that this type will have a format or an example associated with it.
OpenAPI documentation forces you to declare a type
property for each attribute you add to the documentation making it pretty redundant.
That's why Efesto introduces also a shorthand to quickly declare a property type so:
...
properties: {
name: {
type: "string"
},
},
...
can be declared as:
...
properties: {
name: "string"
},
...
-
Quick Content Declaration | requestBody: "type|@Schema"
/ <statusCode>: "type|@Schema"
Each endpoint has a also a corresponding response and, sometimes also a request body; declaring them as required in the OpenAPI specification can affect your code cleanliness.
Assuming that you are writing a login endpoint using the standard OpenAPI specification format you should end up with a snippet like this:
class ModelName extends BaseApiService {
constructor() {
super(__filename);
}
swaggerModel: SwaggerModel = {
modelName: "ModelReference",
schemas: [
{
name: "User",
properties: {
id: {
type:"number"
},
name: {
type:"string"
},
surname: {
type:"string"
},
},
},
{
name: "UserLoginRequestBody",
properties:{
username: {
type: "string"
},
password:{
type: "string"
}
}
}
],
};
_postSwagger: SwaggerOptions = {
operationId: "login",
requestBody:{
content:{
"application/json":{
schema: "@UserLoginRequestBody"
}
}
}
responses: {
200: {
content: {
"application/json": {
schema: "@User",
},
},
},
},
};
}
just applying the above documented shorthands the code can be cleaned a little bit like this:
class ModelName extends BaseApiService {
constructor() {
super(__filename);
}
swaggerModel: SwaggerModel = {
modelName: "ModelReference",
schemas: [
{
name: "User",
properties: {
id: "number",
name: "string",
surname: "string",
},
},
{
name: "UserLoginRequestBody",
properties:{
username: "string",
password:"string"
}
}
],
};
_postSwagger: SwaggerOptions = {
operationId: "login",
requestBody:{
content:{
"application/json":{
schema: "@UserLoginRequestBody"
}
}
}
responses: {
200: {
content: {
"application/json": {
schema: "@User",
},
},
},
},
};
}
but as you can see the request body and the response are extremely long and could be written in a more efficient way:
class ModelName extends BaseApiService {
constructor() {
super(__filename);
}
swaggerModel: SwaggerModel = {
modelName: "ModelReference",
schemas: [
{
name: "User",
properties: {
id: "number",
name: "string",
surname: "string",
},
},
{
name: "UserLoginRequestBody",
properties:{
username: "string",
password:"string"
}
}
],
};
_postSwagger: SwaggerOptions = {
operationId: "login",
requestBody: "@UserLoginRequestBody"
responses: {
200: "@User"
},
};
}
in this way the verbose response declaration can be skipped and the response will be by default "application/json"
and will have as response content (in this specific case) an object of type User
. This will clean up your code and will help you to make it more readable and understandable.
:information_source: Note that this shorthand can also be used inside the schema declaration of a parameter:
_postSwagger: SwaggerOptions = {
operationId: "login",
parameters: [
{in: "query", name: "exampleParam", schema: "string" }
]
requestBody: "@UserLoginRequestBody"
responses: {
200: "@User"
},
};
instead of:
_postSwagger: SwaggerOptions = {
operationId: "login",
parameters: [
{in: "query", name: "exampleParam", schema: {type: "string"} }
]
requestBody: "@UserLoginRequestBody"
responses: {
200: "@User"
},
};
With this information you should now be ready to go using Efesto!
Have a nice forging! :heart: