Generar la definición de Open API de tu API de forma automática en Node.js

Otra de las necesidades que tenía para la API que tuve que usar en mi PoC es que la misma generara su especificación de Open API, anteriormente conocido como Swagger, de la forma más automatizada posible. En otros lenguajes, como en .NET, esto es prácticamente transparente para ti, usando la librería Swashbuckle. En Node.js he utilizado el módulo swagger-autogen para este fin. En este artículo te cuento cómo.

Instalación de los módulos

En el caso que me ocupa, una API servida por el framework Express, he utilizado la librería swagger-autogen y swagger-ui-express para posteriormente navegar por la especificación generada.

npm install swagger-autogen swagger-ui-express 

Anotaciones para swagger-autogen

Si bien es cierto que este módulo te ayuda a generar el archivo final, este necesita de anotaciones en las acciones de tu API para entender bien qué es lo que hacen, lo que devuelven, etcétera. En el archivo InvoiceController.js de mi API tienes un ejemplo de cómo usarlo:

const express = require('express');
const router = express.Router();
const { INVOICES } = require('../util/data');
router.get('/invoices', (req, res) => {
    /*#swagger.tags = ['Invoices']
    ##swagger.operationId = 'getInvoices'
    #swagger.description = 'Endpoint to return invoices'
    #swagger.responses[200] = {
        description: 'Invoices returned successfully',       
        schema: { $ref: '#/definitions/Invoices' }       
    }
    */
    console.log(INVOICES);
    res.json(INVOICES);
});
router.get('/invoices/:id', (req, res) => {
    /*#swagger.tags = ['Invoices']
    ##swagger.operationId = 'getInvoiceById'
    #swagger.parameters['id'] = {
        in: 'path',
        description: 'Invoice ID',
        required: true,
        type: 'integer'
    }
    #swagger.responses[200] = {
        description: 'Invoice returned successfully',
        schema: { $ref: '#/definitions/Invoice' }
    }
    #swagger.responses[404] = {
        description: 'Invoice not found'
    }
    */
    const invoice = INVOICES.find(invoice => invoice.id === parseInt(req.params.id));
    if (!invoice) return res.status(404).send('The invoice with the given ID was not found.');
    res.json(invoice);
});
router.post('/invoices', (req, res) => {
    /*#swagger.tags = ['Invoices']
    ##swagger.operationId = 'createInvoice'
    #swagger.parameters['newInvoice'] = {
        in: 'body',
        description: 'Invoice to be created',
        required: true,
        schema: { $ref: '#/definitions/Invoice' }
    }
    #swagger.responses[201] = {
        description: 'Invoice created successfully',
        schema: { $ref: '#/definitions/Invoice' }
    }
    */
    console.log(req.body);
    const invoice = {
        id: INVOICES.length + 1,
        date: req.body.date,
        accountName: req.body.accountName,
        contact: req.body.contact,
        price: req.body.price,
        status: req.body.status
    };
    INVOICES.push(invoice);
    res.status(201).json(invoice);
});
router.put('/invoices/:id', (req, res) => {
    /*#swagger.tags = ['Invoices']
    ##swagger.operationId = 'updateInvoice'
    #swagger.parameters['id'] = {
        in: 'path',
        description: 'Invoice ID',
        required: true,
        type: 'integer'
    }
    #swagger.parameters['updatedInvoice'] = {
        in: 'body',
        description: 'Invoice to be updated',
        required: true,
        schema: { $ref: '#/definitions/Invoice' }
    }
    #swagger.responses[200] = {
        description: 'Invoice updated successfully',
        schema: { $ref: '#/definitions/Invoice' }
    }
    #swagger.responses[404] = {
        description: 'Invoice not found'
    }
    */
    const invoice = INVOICES.find(invoice => invoice.id === parseInt(req.params.id));
    if (!invoice) return res.status(404).send('The invoice with the given ID was not found.');
    invoice.date = req.body.date;
    invoice.accountName = req.body.accountName;
    invoice.contact = req.body.contact;
    invoice.price = req.body.price;
    invoice.status = req.body.status;
    res.json(invoice);
});
router.delete('/invoices/:id', (req, res) => {
    /*#swagger.tags = ['Invoices']
    ##swagger.operationId = 'deleteInvoice'
    
    #swagger.parameters['id'] = {
        in: 'path',
        description: 'Invoice ID',
        required: true,
        type: 'integer'
    }
    #swagger.responses[200] = {
        description: 'Invoice deleted successfully'
    }
    #swagger.responses[404] = {
        description: 'Invoice not found'
    }
    */
    const invoice = INVOICES.find(invoice => invoice.id === parseInt(req.params.id));
    if (!invoice) return res.status(404).send('The invoice with the given ID was not found.');
    const index = INVOICES.indexOf(invoice);
    INVOICES.splice(index, 1);
    res.json(invoice);
});
module.exports = router;

Como puedes ver, todas las anotaciones vienen precedidas con #swagger. Puedes ver aquí más información de todo lo que puedes incluir.

Generar tu swagger.json

Una vez que tengas descritas las acciones de tu API con las anotaciones anteriores, lo último que queda es generar tu swagger.json. Para ello, en el mismo proyecto, hay otro archivo llamado swagger.js como el siguiente:

const swaggerAutogen = require('swagger-autogen')();
// Get host dynamically
const host = process.env.WEBSITE_HOSTNAME || 'localhost:3000';
const doc = {
    info: {
        version: "1.0.0",
        title: "Contoso API",
        description: "Contoso API Information",
        contact: {
            name: "Gisela Torres",
        }
    },
    host: host,
    basePath: "/",
    schemes: ['http', 'https'],
    consumes: ['application/json'],
    produces: ['application/json'],
    externalDocs: {
        description: "swagger.json",
        url: `http://${host}/swagger.json`
    },
    definitions: {
        Invoice: {
            id: 1,
            date: "2021-05-05",
            accountName: "Franecki Group",
            contact: "Cassidy.Lemke81@yahoo.com",
            price: 380.00,
            status: "Paid"
        },
        Invoices: [{
            id: 1,
            date: "2021-05-05",
            accountName: "Franecki Group",
            contact: "Cassidy.Lemke81@yahoo.com",
            price: 380.00,
            status: "Paid"
        }],
        Accounts: [{
            id: 1,
            accountName: "WingTip Toys",
            primaryContact: "Bart Friday",
            contactEmail: "b.friday@wingtiptoys.com"
        }],
        SupportCases: [{
            id: 1,
            name: "Lynne Robbins",
            address: "3800 148th Ave NE",
            phone: "425-555-1234",
            email: "lrobbins@proseware.com",
            notes: "Financial Advisory"
        }]
    }
};
const outputFile = './swagger.json';
swaggerAutogen(outputFile, ['./routes.js'], doc);

En este ocurren dos cosas: en primer lugar importo la librería de swagger-autogen y recupero de forma dinámica el nombre del host, que en mi caso puede ser local o puede estar hospedado en Azure App Service. De esta forma me aseguro que la definición queda actualizada en ambos entornos. Por otro lado, la variable doc tiene la información general de la API, así como las definiciones de los objetos que devuelve la misma (Invoice(s), Accounts, SupportCases). Con todo ello, llamo al método swaggerAutogen al cual le paso mi archivo routes.js para que «investigue» todas las rutas definidas y el resultado de todo ello lo guarde en swagger.json. Para ejecutar este puedes hacerlo de la siguiente manera:

node swagger.js

También tengo definida esta misma acción en el package.json con el nombre swagger-autogen:

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node ./swagger.js && node index.js",
    "swagger-autogen": "node ./swagger.js"
  },

Si todo está correcto tendremos algo como lo siguiente:

{
  "swagger": "2.0",
  "info": {
    "version": "1.0.0",
    "title": "Contoso API",
    "description": "Contoso API Information",
    "contact": {
      "name": "Gisela Torres"
    }
  },
  "host": "localhost:3000",
  "basePath": "/",
  "schemes": [
    "http",
    "https"
  ],
  "consumes": [
    "application/json"
  ],
  "produces": [
    "application/json"
  ],
  "paths": {
    "/swagger.json": {
      "get": {
        "description": "",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/invoices": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "description": "Endpoint to return invoices",
        "operationId": "getInvoices",
        "responses": {
          "200": {
            "description": "Invoices returned successfully",
            "schema": {
              "$ref": "#/definitions/Invoices"
            }
          }
        }
      },
      "post": {
        "tags": [
          "Invoices"
        ],
        "description": "",
        "operationId": "createInvoice",
        "parameters": [
          {
            "name": "newInvoice",
            "in": "body",
            "description": "Invoice to be created",
            "required": true,
            "schema": {
              "$ref": "#/definitions/Invoice"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Invoice created successfully",
            "schema": {
              "$ref": "#/definitions/Invoice"
            }
          }
        }
      }
    },
    "/invoices/{id}": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "description": "",
        "operationId": "getInvoiceById",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "type": "integer",
            "description": "Invoice ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice returned successfully",
            "schema": {
              "$ref": "#/definitions/Invoice"
            }
          },
          "404": {
            "description": "Invoice not found"
          }
        }
      },
      "put": {
        "tags": [
          "Invoices"
        ],
        "description": "",
        "operationId": "updateInvoice",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "type": "integer",
            "description": "Invoice ID"
          },
          {
            "name": "updatedInvoice",
            "in": "body",
            "description": "Invoice to be updated",
            "required": true,
            "schema": {
              "$ref": "#/definitions/Invoice"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice updated successfully",
            "schema": {
              "$ref": "#/definitions/Invoice"
            }
          },
          "404": {
            "description": "Invoice not found"
          }
        }
      },
      "delete": {
        "tags": [
          "Invoices"
        ],
        "description": "",
        "operationId": "deleteInvoice",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "type": "integer",
            "description": "Invoice ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice deleted successfully"
          },
          "404": {
            "description": "Invoice not found"
          }
        }
      }
    },
    "/accounts": {
      "get": {
        "tags": [
          "Accounts"
        ],
        "description": "Endpoint to return accounts",
        "operationId": "getAccounts",
        "responses": {
          "200": {
            "description": "Accounts returned successfully",
            "schema": {
              "$ref": "#/definitions/Accounts"
            }
          }
        }
      }
    },
    "/support": {
      "get": {
        "tags": [
          "Support"
        ],
        "description": "Endpoint to return support cases",
        "operationId": "getCases",
        "responses": {
          "200": {
            "description": "Cases returned successfully",
            "schema": {
              "$ref": "#/definitions/SupportCases"
            }
          }
        }
      }
    }
  },
  "definitions": {
    "Invoice": {
      "type": "object",
      "properties": {
        "id": {
          "type": "number",
          "example": 1
        },
        "date": {
          "type": "string",
          "example": "2021-05-05"
        },
        "accountName": {
          "type": "string",
          "example": "Franecki Group"
        },
        "contact": {
          "type": "string",
          "example": "Cassidy.Lemke81@yahoo.com"
        },
        "price": {
          "type": "number",
          "example": 380
        },
        "status": {
          "type": "string",
          "example": "Paid"
        }
      }
    },
    "Invoices": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": {
            "type": "number",
            "example": 1
          },
          "date": {
            "type": "string",
            "example": "2021-05-05"
          },
          "accountName": {
            "type": "string",
            "example": "Franecki Group"
          },
          "contact": {
            "type": "string",
            "example": "Cassidy.Lemke81@yahoo.com"
          },
          "price": {
            "type": "number",
            "example": 380
          },
          "status": {
            "type": "string",
            "example": "Paid"
          }
        }
      }
    },
    "Accounts": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": {
            "type": "number",
            "example": 1
          },
          "accountName": {
            "type": "string",
            "example": "WingTip Toys"
          },
          "primaryContact": {
            "type": "string",
            "example": "Bart Friday"
          },
          "contactEmail": {
            "type": "string",
            "example": "b.friday@wingtiptoys.com"
          }
        }
      }
    },
    "SupportCases": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "id": {
            "type": "number",
            "example": 1
          },
          "name": {
            "type": "string",
            "example": "Lynne Robbins"
          },
          "address": {
            "type": "string",
            "example": "3800 148th Ave NE"
          },
          "phone": {
            "type": "string",
            "example": "425-555-1234"
          },
          "email": {
            "type": "string",
            "example": "lrobbins@proseware.com"
          },
          "notes": {
            "type": "string",
            "example": "Financial Advisory"
          }
        }
      }
    }
  },
  "externalDocs": {
    "description": "swagger.json",
    "url": "http://localhost:3000/swagger.json"
  }
}

Generar swagger.json una vez desplegado en Azure App Service

Si quieres generar el archivo una vez desplegada la API en App Service, para que el valor del host sea el correcto, puedes crear un archivo llamado .deployment con el siguiente contenido:

[config]
command = bash deploy.sh

Este a su vez, llamará a deploy.sh que únicamente borra el swagger.json anterior y ejecuta el script definido en el package.json:

#!/bin/bash
echo $WEBSITE_SITE_NAME
# Generate swagger.json
rm swagger.json
npm run swagger-autogen
##################################################################################################################################
echo "Finished successfully."

Utilizar el swagger.json generado en la interfaz de Swagger

Por último, si quieres visualizar el resultado en la interfaz de Swagger lo único que te falta es utilizar el otro módulo que instalaste en el index.js:

// Modules
const express = require('express');
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./swagger.json');
const router = require('./routes');
const data = require('./util/data');
data.init();
// Create a port
const port = process.env.PORT || 3000;
// Create an instance of express
const app = express();
// body-parser
app.use(express.json());
app.use(router);
const options = {
    explorer: true,
    swaggerOptions: {
        url: 'swagger.json'
    }
};
app.get("/swagger.json", (req, res) => res.json(swaggerDocument));
app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerDocument, options));
// Listen on port
app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

Este ejemplo lo tienes en mi GitHub.

¡Saludos!