Skip to content

Commit

Permalink
chore: use ESM (#240)
Browse files Browse the repository at this point in the history
* init changes

* update loaders

* update example app

* update import statements in cli spec

* move test files to fixtures

* move v3 test files to fixtures

* refactor existing tests for utils

* add named exports to utils

* refactor examples tests

* refactor examples tests

* reading json files

* update existing test suites

* use createRequire()

* remove airbnb-base because no babel and transpilation

* start refactor definition loader

* feat: complete loadDefinition

* start deleting the cli

* start moving the cli to examples

* remove commander

* retry ci

* retry ci

* still inconsistent behavior, unfortunately

* run without caching

* upgrade deps

* remove unused files

* move validation in one place

* refactor organize function

* make spec operations transparent to library

* refactor spec.prepare() tests

* add spec.clean() tests

* move encoding test at its implementation

* add some coverage for the finalize method

* Making main function async

* Add some coverage

* 100% on utilities, yey

* add tests for spec.extract()

* improve coverage

* Update landing page readme

* full coverage, on paper

* move loadDefinition

* Update docs
kalinchernev authored Feb 17, 2021
1 parent 32540d1 commit 6431a56
Showing 59 changed files with 1,421 additions and 1,739 deletions.
10 changes: 9 additions & 1 deletion .eslintrc.js → .eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
module.exports = {
root: true,
env: {
es6: true,
node: true,
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 12,
},
extends: [
'airbnb-base',
'eslint:recommended',
'plugin:prettier/recommended',
'plugin:jest/recommended',
],
18 changes: 10 additions & 8 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -4,14 +4,14 @@ about: Create a report to help us improve
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:

1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
@@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]

- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]

**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]

- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]

**Additional context**
Add any other context about the problem here.
1 change: 0 additions & 1 deletion .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@ about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
17 changes: 2 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: CI
on: [push, pull_request]
on: [push]

jobs:
audit:
@@ -16,24 +16,11 @@ jobs:

strategy:
matrix:
node-version: [12.x, 14.x]
node-version: [12.x, 14.x, 15.x]

steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run tests
run: yarn test

# publish:
# name: publish
# needs: tests
# runs-on: ubuntu-latest
# if: github.event_name == 'push' && github.ref == 'refs/heads/master'
# steps:
# - uses: actions/checkout@v2
# - name: Publish
# uses: mikeal/merge-release@master
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
1 change: 0 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
@@ -7,7 +7,6 @@
.c9
.nvm
.eslintrc.js
external.jsdoc
examples/
docs/
jsdoc/
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
lts/erbium
lts/fermium
5 changes: 3 additions & 2 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
test/files/v2/wrong_syntax.json
test/files/v2/wrong_syntax.yaml
test/fixtures/wrong/example.js
test/fixtures/wrong/example.json
test/fixtures/wrong/example.yaml
12 changes: 3 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ app.get('/', (req, res) => {
The library will take the contents of `@openapi` (or `@swagger`) with the following configuration:

```javascript
const swaggerJsdoc = require('swagger-jsdoc');
import swaggerJsdoc from 'swagger-jsdoc';

const options = {
definition: {
@@ -47,15 +47,9 @@ The resulting `openapiSpecification` will be a [swagger tools](https://swagger.i

![swagger-jsdoc example screenshot](./docs/screenshot.png)

## Node.js version requirements, CommonJS and ESM
## System requirements

`swagger-jsdoc` 6.x requires Node.js 12.x and above. When using the CLI, the library will attempt to load the definition file in several formats: `.js`, `.cjs`, `.yaml` (or `.yml`) and `.json`.

The example above follows the CommonJS format, which will work when you do not have `"type": "module"` in your `package.json`.

However, if you're using ESM and have `"type": "module"`, then please change the extension to `.cjs`.

Definition files in `.js` and ESM will be supported in `swagger-jsdoc` 7.x.
Notes on CJS and ESM.

## Installation

86 changes: 0 additions & 86 deletions bin/swagger-jsdoc.js

This file was deleted.

61 changes: 0 additions & 61 deletions docs/CLI.md

This file was deleted.

14 changes: 8 additions & 6 deletions docs/CONCEPTS.md
Original file line number Diff line number Diff line change
@@ -12,10 +12,10 @@ Parts of the specification can be placed in annotated JSDoc comments in non-comp

Other parts of the specification can be directly written in YAML files. These are usually parts containing static definitions which are referenced from jsDoc comments parameters, components, anchors, etc. which are not so relevant to the API implementation.

Given the following definition `swaggerDefinition.cjs`:
Given the following definition `definition.js`:

```javascript
module.exports = {
export default {
info: {
title: 'Hello World',
version: '1.0.0',
@@ -24,20 +24,22 @@ module.exports = {
};
```

The end `swaggerSpecification` will be a result of following:
The end `openapiSpecification` will be a result of following:

```javascript
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerDefinition = require('./swaggerDefinition');
import swaggerJsdoc from 'swagger-jsdoc';
import definition from './definition.js';

const options = {
swaggerDefinition,
definition,
apis: ['./src/routes*.js'],
};

const swaggerSpecification = swaggerJsdoc(options);
```

Please note that it's also possible to use CommonJS syntax with `require` and `module.exports` for the example above.

## File selection patterns

`swagger-jsdoc` uses [node glob](https://github.com/isaacs/node-glob) for discovering your input files. You can use patterns such as `*.js` to select all javascript files or `**/*.js` to select all javascript files in sub-folders recursively.
6 changes: 3 additions & 3 deletions docs/FIRST-STEPS.md
Original file line number Diff line number Diff line change
@@ -7,10 +7,10 @@ The default target specification is 2.0. This provides backwards compatibility f
In order to create a specification compatibile with 3.0 or higher, i.e. the so called OpenAPI, set this information in the `swaggerDefinition`:

```diff
const swaggerJsdoc = require('swagger-jsdoc');
import swaggerJsdoc from 'swagger-jsdoc';

const options = {
swaggerDefinition: {
definition: {
+ openapi: '3.0.0',
info: {
title: 'Hello World',
@@ -20,7 +20,7 @@ const options = {
apis: ['./src/routes*.js'],
};

const swaggerSpecification = swaggerJsdoc(options);
const openapiSpecification = swaggerJsdoc(options);
```

## Annotating source code
1 change: 0 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
@@ -3,7 +3,6 @@
Quick-start:

- [First steps](./FIRST-STEPS.md)
- [CLI](./CLI.md)
- [Examples](../examples)

Before you submit an issue:
78 changes: 38 additions & 40 deletions examples/app/app.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
/* istanbul ignore file */
/* eslint import/no-extraneous-dependencies: 0 */

// Dependencies
const express = require('express');
const bodyParser = require('body-parser');
const routes = require('./routes');
const routes2 = require('./routes2');
const swaggerJsdoc = require('../..');
import express from 'express';
import bodyParser from 'body-parser';
import { setup as setupRoute1 } from './routes.js';
import { setup as setupRoute2 } from './routes2.js';
import swaggerJsdoc from '../../index.js';

async function surveSwaggerSpecification(req, res) {
// Swagger definition
// You can set every attribute except paths and swagger
// https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md
const swaggerDefinition = {
info: {
// API informations (required)
title: 'Hello World', // Title (required)
version: '1.0.0', // Version (required)
description: 'A sample API', // Description (optional)
},
host: `localhost:${PORT}`, // Host (optional)
basePath: '/', // Base path (optional)
};
// Options for the swagger docs
const options = {
// Import swaggerDefinitions
swaggerDefinition,
// Path to the API docs
// Note that this path is relative to the current directory from which the Node.js is ran, not the application itself.
apis: ['./examples/app/routes*.js', './examples/app/parameters.yaml'],
};
const swaggerSpec = await swaggerJsdoc(options);

// And here we go, we serve it.
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
}

const PORT = process.env.PORT || 3000;

@@ -20,41 +48,11 @@ app.use(
})
);

// Swagger definition
// You can set every attribute except paths and swagger
// https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md
const swaggerDefinition = {
info: {
// API informations (required)
title: 'Hello World', // Title (required)
version: '1.0.0', // Version (required)
description: 'A sample API', // Description (optional)
},
host: `localhost:${PORT}`, // Host (optional)
basePath: '/', // Base path (optional)
};

// Options for the swagger docs
const options = {
// Import swaggerDefinitions
swaggerDefinition,
// Path to the API docs
// Note that this path is relative to the current directory from which the Node.js is ran, not the application itself.
apis: ['./examples/app/routes*.js', './examples/app/parameters.yaml'],
};

// Initialize swagger-jsdoc -> returns validated swagger spec in json format
const swaggerSpec = swaggerJsdoc(options);

// Serve swagger docs the way you like (Recommendation: swagger-tools)
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
app.get('/api-docs.json', surveSwaggerSpecification);

// Set up the routes
routes.setup(app);
routes2.setup(app);
setupRoute1(app);
setupRoute2(app);

// Start the server
const server = app.listen(PORT, () => {
@@ -64,4 +62,4 @@ const server = app.listen(PORT, () => {
console.log('Example app listening at http://%s:%s', host, port);
});

module.exports = { app, server };
export { app, server };
7 changes: 5 additions & 2 deletions examples/app/app.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const request = require('supertest');
const { app, server } = require('./app');
import { createRequire } from 'module';
import request from 'supertest';
import { app, server } from './app.js';

const require = createRequire(import.meta.url);
const swaggerSpec = require('./swagger-spec.json');

describe('Example application written in swagger specification (v2)', () => {
2 changes: 1 addition & 1 deletion examples/app/routes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* istanbul ignore file */

// Sets up the routes.
module.exports.setup = (app) => {
export const setup = (app) => {
/**
* @swagger
* /:
2 changes: 1 addition & 1 deletion examples/app/routes2.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* istanbul ignore file */

module.exports.setup = (app) => {
export const setup = (app) => {
/**
* @swagger
* /hello:
47 changes: 47 additions & 0 deletions examples/cli/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import { promises as fs } from 'fs';
import { pathToFileURL } from 'url';
import { loadDefinition } from './utils.js';

import swaggerJsdoc from '../../index.js';

/**
* Handle CLI arguments in your preferred way.
* @see https://nodejs.org/en/knowledge/command-line/how-to-parse-command-line-arguments/
*/
const args = process.argv.slice(2);

/**
* Extract definition
* Pass an absolute specifier with file:/// to the loader.
* The relative and bare specifiers would be based on assumptions which are not stable.
* For example, if path from cli `examples/app/parameters.*` goes in, it will be assumed as bare, which is wrong.
*/
const definitionUrl = pathToFileURL(
args.splice(
args.findIndex((i) => i === '--definition'),
2
)[1] // Definition file is always only one.
);

// Because "Parsing error: Cannot use keyword 'await' outside an async function"
(async () => {
/**
* We're using an example module loader which you can swap with your own implemenentation.
*/
const swaggerDefinition = await loadDefinition(definitionUrl.href);

// Extract apis
// remove --apis flag
args.splice(0, 1);
// the rest of this example can be treated as the contents of the --apis
const apis = args;

// Use the library
const spec = await swaggerJsdoc({ swaggerDefinition, apis });

// Save specification place and format
await fs.writeFile('swagger.json', JSON.stringify(spec, null, 2));

console.log('Specification has been created successfully!');
})();
103 changes: 103 additions & 0 deletions examples/cli/cli.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { promises as fs } from 'fs';
import { createRequire } from 'module';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
import { loadDefinition } from './utils.js';

const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
const cli = `${__dirname}/cli.js`;

describe('Example command line application', () => {
it('should produce results matching reference specification', () => {
const { stderr, stdout } = spawnSync(cli, [
'--definition',
'examples/swaggerDefinition/example.js',
'--apis',
'examples/app/parameters.*',
'examples/app/route*',
]);
expect(stderr.toString()).toBe('');
expect(stdout.toString()).toBe(
'Specification has been created successfully!\n'
);
const refSpec = require('./reference-specification.json');
const resSpec = require(`${process.cwd()}/swagger.json`);
expect(resSpec).toEqual(refSpec);
});

afterAll(async () => {
await fs.unlink(`${process.cwd()}/swagger.json`);
});
});

describe('loadDefinition', () => {
const example = '../swaggerDefinition/example';

it('should throw on bad input', async () => {
await expect(loadDefinition('bad/path/to/nowhere')).rejects.toThrow(
'Definition file should be any of the following: .js, .mjs, .cjs, .json, .yml, .yaml'
);
});

it('should support .json', async () => {
const def = resolve(__dirname, `${example}.json`);
const result = await loadDefinition(def);
expect(result).toEqual({
info: {
title: 'Hello World',
version: '1.0.0',
description: 'A sample API',
},
});
});

it('should support .yaml', async () => {
const def = resolve(__dirname, `${example}.yaml`);
const result = await loadDefinition(def);
expect(result).toEqual({
info: {
title: 'Hello World',
version: '1.0.0',
description: 'A sample API',
},
});
});

it('should support .js', async () => {
const def = resolve(__dirname, `${example}.js`);
const result = await loadDefinition(def);
expect(result).toEqual({
info: {
title: 'Hello World',
version: '1.0.0',
description: 'A sample API',
},
});
});

it('should support .cjs', async () => {
const def = resolve(__dirname, `${example}.cjs`);
const result = await loadDefinition(def);
expect(result).toEqual({
info: {
title: 'Hello World',
version: '1.0.0',
description: 'A sample API',
},
});
});

it('should support .mjs', async () => {
const def = resolve(__dirname, `${example}.mjs`);
const result = await loadDefinition(def);
expect(result).toEqual({
info: {
title: 'Hello World',
version: '1.0.0',
description: 'A sample API',
},
});
});
});
126 changes: 126 additions & 0 deletions examples/cli/reference-specification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
{
"info": {
"title": "Hello World",
"version": "1.0.0",
"description": "A sample API"
},
"swagger": "2.0",
"paths": {
"/login": {
"post": {
"description": "Login to the application",
"tags": ["Users", "Login"],
"produces": ["application/json"],
"parameters": [
{
"$ref": "#/parameters/username"
},
{
"name": "password",
"description": "User's password.",
"in": "formData",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "login",
"schema": {
"type": "object",
"$ref": "#/definitions/Login"
}
}
}
}
},
"/hello": {
"get": {
"description": "Returns the homepage",
"responses": {
"200": {
"description": "hello world"
}
}
}
},
"/": {
"get": {
"description": "Returns the homepage",
"responses": {
"200": {
"description": "hello world"
}
}
}
},
"/users": {
"get": {
"description": "Returns users",
"tags": ["Users"],
"produces": ["application/json"],
"responses": {
"200": {
"description": "users"
}
}
},
"post": {
"description": "Returns users",
"tags": ["Users"],
"produces": ["application/json"],
"parameters": [
{
"$ref": "#/parameters/username"
}
],
"responses": {
"200": {
"description": "users"
}
}
}
}
},
"definitions": {
"Login": {
"required": ["username", "password"],
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"responses": {},
"parameters": {
"username": {
"name": "username",
"description": "Username to use for login.",
"in": "formData",
"required": true,
"type": "string"
}
},
"securityDefinitions": {},
"tags": [
{
"name": "Users",
"description": "User management and login"
},
{
"name": "Login",
"description": "Login"
},
{
"name": "Accounts",
"description": "Accounts"
}
]
}
49 changes: 49 additions & 0 deletions examples/cli/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createRequire } from 'module';
import { extname } from 'path';
import { promises as fs } from 'fs';
import yaml from 'yaml';

/**
* @param {string} definitionPath path to the swaggerDefinition
*/
export async function loadDefinition(definitionPath) {
const loadModule = async () => {
const esmodule = await import(definitionPath);
return esmodule.default;
};
const loadCJS = () => {
const require = createRequire(import.meta.url);
return require(definitionPath);
};
const loadJson = async () => {
const fileContents = await fs.readFile(definitionPath);
return JSON.parse(fileContents);
};
const loadYaml = async () => {
const fileContents = await fs.readFile(definitionPath);
return yaml.parse(String(fileContents));
};

const LOADERS = {
'.js': loadModule,
'.mjs': loadModule,
'.cjs': loadCJS,
'.json': loadJson,
'.yml': loadYaml,
'.yaml': loadYaml,
};

const loader = LOADERS[extname(definitionPath)];

if (loader === undefined) {
throw new Error(
`Definition file should be any of the following: ${Object.keys(
LOADERS
).join(', ')}`
);
}

const result = await loader();

return result;
}
9 changes: 6 additions & 3 deletions examples/extensions/extensions.spec.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
const swaggerJsdoc = require('../..');
import { createRequire } from 'module';
import swaggerJsdoc from '../../index.js';

const require = createRequire(import.meta.url);
const referenceSpecification = require('./reference-specification.json');

describe('Example for using extensions', () => {
it('should support x-webhooks', () => {
const result = swaggerJsdoc({
it('should support x-webhooks', async () => {
const result = await swaggerJsdoc({
swaggerDefinition: {
info: {
title: 'Example with extensions',
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
/* istanbul ignore file */

const host = `http://${process.env.IP}:${process.env.PORT}`;

module.exports = {
export default {
info: {
// API informations (required)
title: 'Hello World', // Title (required)
version: '1.0.0', // Version (required)
description: 'A sample API', // Description (optional)
},
host, // Host (optional)
basePath: '/', // Base path (optional)
};
File renamed without changes.
10 changes: 10 additions & 0 deletions examples/swaggerDefinition/example.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/* istanbul ignore file */

export default {
info: {
// API informations (required)
title: 'Hello World', // Title (required)
version: '1.0.0', // Version (required)
description: 'A sample API', // Description (optional)
},
};
File renamed without changes.
30 changes: 30 additions & 0 deletions examples/yaml-anchors-aliases/example.js
Original file line number Diff line number Diff line change
@@ -14,4 +14,34 @@ module.exports = (app) => {
* x-amazon-apigateway-integration: *default-integration
*/
app.get('/aws', () => {});

/**
* @swagger
* /richie-rich:
* get:
* summary: another route
* description: contains a reference in the same file
* security: []
* responses:
* 200:
* description: OK
* x-amazon-another-integration: *another-integration
*/
app.get('/richie-rich', () => {});
};

/**
* The following annotation is an example of a jsdoc containing a yaml cotaining an anchor.
* The place should not be relevant, and that's why it's later than its usage.
*
* @swagger
* x-amazon-another-example:
* another-integration: &another-integration
* type: object
* x-amazon-another-example:
* httpMethod: POST
* passthroughBehavior: when_no_match
* type: aws_proxy
* uri: 'irrelevant'
*
*/
17 changes: 17 additions & 0 deletions examples/yaml-anchors-aliases/reference-specification.json
Original file line number Diff line number Diff line change
@@ -18,6 +18,23 @@
}
}
}
},
"/richie-rich": {
"get": {
"summary": "another route",
"description": "contains a reference in the same file",
"security": [],
"responses": { "200": { "description": "OK" } },
"x-amazon-another-integration": {
"type": "object",
"x-amazon-another-example": {
"httpMethod": "POST",
"passthroughBehavior": "when_no_match",
"type": "aws_proxy",
"uri": "irrelevant"
}
}
}
}
},
"definitions": {},
9 changes: 6 additions & 3 deletions examples/yaml-anchors-aliases/yaml-anchors-aliases.spec.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
const swaggerJsdoc = require('../..');
import { createRequire } from 'module';
import swaggerJsdoc from '../../index.js';

const require = createRequire(import.meta.url);
const referenceSpecification = require('./reference-specification.json');

describe('Example for using anchors and aliases in YAML documents', () => {
it('should handle references in a separate YAML file', () => {
const result = swaggerJsdoc({
it('should handle references in a separate YAML file', async () => {
const result = await swaggerJsdoc({
swaggerDefinition: {
info: {
title: 'Example with anchors and aliases',
26 changes: 25 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1 +1,25 @@
module.exports = require('./src/lib');
import { prepare, extract, organize, finalize } from './src/specification.js';
import { validateOptions } from './src/utils.js';

/**
* Main function
* @param {object} options - Configuration options
* @param {string} options.encoding Optional, passed to read file function options. Defaults to 'utf8'.
* @param {string} options.format Optional, defaults to '.json' - target file format '.yml' or '.yaml'.
* @param {object} options.swaggerDefinition
* @param {object} options.definition
* @param {array} options.apis
* @returns {object|string} Output specification as json or yaml
*/
const lib = async (options) => {
validateOptions(options);

const spec = prepare(options);
const parts = await extract(options);

organize(spec, parts);

return finalize(spec, options);
};

export default lib;
34 changes: 15 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,44 +1,43 @@
{
"name": "swagger-jsdoc",
"description": "Generates swagger doc based on JSDoc",
"version": "6.0.6",
"version": "7.0.0-rc.1",
"engines": {
"node": ">=12.0.0"
},
"scripts": {
"start": "node examples/app/app.js",
"lint": "eslint .",
"test:lint": "eslint .",
"test:js": "jest --verbose",
"test:js": "NODE_OPTIONS=--experimental-vm-modules jest --no-cache",
"test": "run-p test:* -cn"
},
"main": "index.js",
"bin": {
"swagger-jsdoc": "./bin/swagger-jsdoc.js"
"type": "module",
"exports": "./index.js",
"jest": {
"transform": {}
},
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"swagger-parser": "10.0.2",
"yaml": "2.0.0-1"
},
"devDependencies": {
"body-parser": "1.19.0",
"eslint": "7.14.0",
"eslint-config-airbnb-base": "14.2.1",
"eslint-config-prettier": "6.15.0",
"eslint": "7.19.0",
"eslint-config-prettier": "7.2.0",
"eslint-loader": "4.0.2",
"eslint-plugin-import": "2.22.1",
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-prettier": "3.1.4",
"eslint-plugin-jest": "24.1.3",
"eslint-plugin-prettier": "3.3.1",
"express": "4.17.1",
"husky": "4.3.0",
"jest": "^26.6.1",
"lint-staged": "10.5.2",
"husky": "4.3.8",
"jest": "26.6.3",
"lint-staged": "10.5.4",
"npm-run-all": "4.1.5",
"prettier": "2.2.0",
"supertest": "6.0.1"
"prettier": "2.2.1",
"supertest": "6.1.3"
},
"license": "MIT",
"homepage": "https://github.com/Surnet/swagger-jsdoc",
@@ -55,9 +54,6 @@
"bugs": {
"url": "https://github.com/Surnet/swagger-jsdoc/issues"
},
"resolutions": {
"minimist": ">=1.2.3"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
31 changes: 0 additions & 31 deletions src/lib.js

This file was deleted.

158 changes: 85 additions & 73 deletions src/specification.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
const doctrine = require('doctrine');
const parser = require('swagger-parser');
const YAML = require('yaml');
import doctrine from 'doctrine';
import parser from 'swagger-parser';
import YAML from 'yaml';

const {
import {
hasEmptyProperty,
convertGlobPaths,
extractAnnotations,
extractYamlFromJsDoc,
isTagPresentInTags,
} = require('./utils');
} from './utils.js';

/**
* Prepare the swagger/openapi specification object.
* @see https://github.com/OAI/OpenAPI-Specification/tree/master/versions
* @param {object} definition - The `definition` or `swaggerDefinition` from options.
* @param {object} options The library input options.
* @returns {object} swaggerObject
*/
function prepare(definition) {
export function prepare(options) {
let version;
const swaggerObject = JSON.parse(JSON.stringify(definition));
const swaggerObject = options.swaggerDefinition || options.definition;
const specificationTemplate = {
v2: [
'paths',
@@ -59,7 +59,7 @@ function prepare(definition) {
* @param {object} obj
* @param {string} ext
*/
function format(swaggerObject, ext) {
export function format(swaggerObject, ext) {
if (ext === '.yml' || ext === '.yaml') {
return YAML.stringify(swaggerObject);
}
@@ -72,7 +72,7 @@ function format(swaggerObject, ext) {
* @param {object} swaggerObject
* @returns {object} swaggerObject
*/
function clean(swaggerObject) {
export function clean(swaggerObject) {
for (const prop of [
'definitions',
'responses',
@@ -93,7 +93,7 @@ function clean(swaggerObject) {
* @param {object} swaggerObject - Swagger object from parsing the api files.
* @returns {object} The specification.
*/
function finalize(swaggerObject, options) {
export function finalize(swaggerObject, options) {
let specification = swaggerObject;

parser.parse(swaggerObject, (err, api) => {
@@ -106,76 +106,97 @@ function finalize(swaggerObject, options) {
specification = clean(specification);
}

return format(specification, options.format);
if (options && options.format) {
specification = format(specification, options.format);
}

return specification;
}

/**
* @param {object} swaggerObject
* @param {object} annotation
* @param {string} property
* @param {Array<object>} annotations
* @returns {object} swaggerObject
*/
function organize(swaggerObject, annotation, property) {
// Root property on purpose.
// @see https://github.com/OAI/OpenAPI-Specification/blob/master/proposals/002_Webhooks.md#proposed-solution
if (property === 'x-webhooks') {
swaggerObject[property] = annotation[property];
}
export function organize(swaggerObject, annotations) {
for (const annotation of annotations) {
for (const property in annotation) {
// Root property on purpose.
// @see https://github.com/OAI/OpenAPI-Specification/blob/master/proposals/002_Webhooks.md#proposed-solution
if (property === 'x-webhooks') {
swaggerObject[property] = annotation[property];
}

// Other extensions can be in varying places depending on different vendors and opinions.
// The following return makes it so that they are not put in `paths` in the last case.
// New specific extensions will need to be handled on case-by-case if to be included in `paths`.
if (property.startsWith('x-')) return;

const commonProperties = [
'components',
'consumes',
'produces',
'paths',
'schemas',
'securityDefinitions',
'responses',
'parameters',
'definitions',
];

if (commonProperties.includes(property)) {
for (const definition of Object.keys(annotation[property])) {
swaggerObject[property][definition] = {
...swaggerObject[property][definition],
...annotation[property][definition],
};
}
} else if (property === 'tags') {
const { tags } = annotation;
// Other extensions can be in varying places depending on different vendors and opinions.
// The following return makes it so that they are not put in `paths` in the last case.
// New specific extensions will need to be handled on case-by-case if to be included in `paths`.
if (property.startsWith('x-')) continue;

const commonProperties = [
'components',
'consumes',
'produces',
'paths',
'schemas',
'securityDefinitions',
'responses',
'parameters',
'definitions',
];

if (commonProperties.includes(property)) {
for (const definition of Object.keys(annotation[property])) {
swaggerObject[property][definition] = {
...swaggerObject[property][definition],
...annotation[property][definition],
};
}
} else if (property === 'tags') {
if (swaggerObject.tags === undefined) {
swaggerObject.tags = [];
}
const { tags } = annotation;

if (Array.isArray(tags)) {
for (const tag of tags) {
if (!isTagPresentInTags(tag, swaggerObject.tags)) {
swaggerObject.tags.push(tag);
if (Array.isArray(tags)) {
for (const tag of tags) {
if (!isTagPresentInTags(tag, swaggerObject.tags)) {
swaggerObject.tags.push(tag);
}
}
} else if (!isTagPresentInTags(tags, swaggerObject.tags)) {
swaggerObject.tags.push(tags);
}
} else {
// Paths which are not defined as "paths" property, starting with a slash "/"
swaggerObject.paths[property] = {
...swaggerObject.paths[property],
...annotation[property],
};
}
} else if (!isTagPresentInTags(tags, swaggerObject.tags)) {
swaggerObject.tags.push(tags);
}
} else {
// Paths which are not defined as "paths" property, starting with a slash "/"
swaggerObject.paths[property] = {
...swaggerObject.paths[property],
...annotation[property],
};
}

return swaggerObject;
}

/**
* @param {object} options
* @returns {object} swaggerObject
*/
function build(options) {
export async function extract(options) {
if (
!options ||
!options.apis ||
options.apis.length === 0 ||
Array.isArray(options.apis) === false
) {
throw new Error(
'Bad input parameter: options is required, as well as options.apis[]'
);
}

YAML.defaultOptions.keepCstNodes = true;

// Get input definition and prepare the specification's skeleton
const definition = options.swaggerDefinition || options.definition;
const specification = prepare(definition);
const yamlDocsAnchors = new Map();
const yamlDocsErrors = [];
const yamlDocsReady = [];
@@ -184,7 +205,7 @@ function build(options) {
const {
yaml: yamlAnnotations,
jsdoc: jsdocAnnotations,
} = extractAnnotations(filePath, options.encoding);
} = await extractAnnotations(filePath, options.encoding);

if (yamlAnnotations.length) {
for (const annotation of yamlAnnotations) {
@@ -269,14 +290,5 @@ function build(options) {
}
}

for (const document of yamlDocsReady) {
const parsedDoc = document.toJSON();
for (const property in parsedDoc) {
organize(specification, parsedDoc, property);
}
}

return finalize(specification, options);
return yamlDocsReady.map((doc) => doc.toJSON());
}

module.exports = { prepare, build, organize, finalize, format };
87 changes: 43 additions & 44 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
import { promises as fs } from 'fs';
import { extname } from 'path';
import glob from 'glob';

/**
* Converts an array of globs to full paths
* @param {array} globs - Array of globs and/or normal paths
* @return {array} Array of fully-qualified paths
*/
function convertGlobPaths(globs) {
export function convertGlobPaths(globs) {
return globs
.map((globString) => glob.sync(globString))
.reduce((previous, current) => previous.concat(current), []);
@@ -18,7 +18,9 @@ function convertGlobPaths(globs) {
* @param {object} obj - the object to check
* @returns {boolean}
*/
function hasEmptyProperty(obj) {
export function hasEmptyProperty(obj) {
if (!obj) return;

return Object.keys(obj)
.map((key) => obj[key])
.every(
@@ -33,7 +35,7 @@ function hasEmptyProperty(obj) {
* @param {object} jsDocComment - Single item of JSDoc comments from doctrine.parse
* @returns {array} YAML parts
*/
function extractYamlFromJsDoc(jsDocComment) {
export function extractYamlFromJsDoc(jsDocComment) {
const yamlParts = [];

for (const tag of jsDocComment.tags) {
@@ -49,9 +51,9 @@ function extractYamlFromJsDoc(jsDocComment) {
* @param {string} filePath
* @returns {{jsdoc: array, yaml: array}} JSDoc comments and Yaml files
*/
function extractAnnotations(filePath, encoding = 'utf8') {
const fileContent = fs.readFileSync(filePath, { encoding });
const ext = path.extname(filePath);
export async function extractAnnotations(filePath, encoding = 'utf8') {
const fileContent = await fs.readFile(filePath, { encoding });
const ext = extname(filePath);
const jsDocRegex = /\/\*\*([\s\S]*?)\*\//gm;
const csDocRegex = /###([\s\S]*?)###/gm;
const yaml = [];
@@ -88,51 +90,48 @@ function extractAnnotations(filePath, encoding = 'utf8') {

/**
* @param {object} tag
* @param {string} tag.name
* @param {array} tags
* @returns {boolean}
*/
function isTagPresentInTags(tag, tags) {
export function isTagPresentInTags(tag, tags) {
const match = tags.find((targetTag) => tag.name === targetTag.name);
if (match) return true;

return false;
}

/**
* Get an object of the definition file configuration.
* @param {string} defPath
* @param {object} swaggerDefinition
* @param {object} options
* @returns {object} the original input if valid, throws otherwise
*/
function loadDefinition(defPath, swaggerDefinition) {
const resolvedPath = path.resolve(defPath);
const extName = path.extname(resolvedPath);

// eslint-disable-next-line
const loadCjs = () => require(resolvedPath);
const loadJson = () => JSON.parse(swaggerDefinition);
// eslint-disable-next-line
const loadYaml = () => require('yaml').parse(swaggerDefinition);

const LOADERS = {
'.js': loadCjs, // on purpose, to allow throwing by nodejs and .cjs suggestion
'.cjs': loadCjs,
'.json': loadJson,
'.yml': loadYaml,
'.yaml': loadYaml,
};

const loader = LOADERS[extName];

if (loader === undefined) {
throw new Error('Definition file should be .cjs, .json, .yml or .yaml');
export function validateOptions(options) {
if (!options) {
throw new Error(`'options' parameter is required!`);
}

return loader();
}
if (!options.swaggerDefinition && !options.definition) {
throw new Error(
`'options.swaggerDefinition' or 'options.definition' is required!`
);
}

const def = options.swaggerDefinition || options.definition;

module.exports.convertGlobPaths = convertGlobPaths;
module.exports.hasEmptyProperty = hasEmptyProperty;
module.exports.extractYamlFromJsDoc = extractYamlFromJsDoc;
module.exports.extractAnnotations = extractAnnotations;
module.exports.isTagPresentInTags = isTagPresentInTags;
module.exports.loadDefinition = loadDefinition;
if (!def.info) {
throw new Error(
`Swagger definition ('options.swaggerDefinition') should contain an info object!`
);
}

if (!('title' in def.info) || !('version' in def.info)) {
throw new Error(
`Swagger definition info object ('options.swaggerDefinition.info') requires title and version properties!`
);
}

if (!options.apis || !Array.isArray(options.apis)) {
throw new Error(`'options.apis' is required and it should be an array!`);
}

return options;
}
85 changes: 0 additions & 85 deletions test/__snapshots__/cli.spec.js.snap

This file was deleted.

130 changes: 0 additions & 130 deletions test/cli.spec.js

This file was deleted.

17 changes: 0 additions & 17 deletions test/files/v2/deprecated_routes.js

This file was deleted.

5 changes: 0 additions & 5 deletions test/files/v2/external/one.yml

This file was deleted.

5 changes: 0 additions & 5 deletions test/files/v2/external/two.yml

This file was deleted.

21 changes: 0 additions & 21 deletions test/files/v2/swaggerObject.json

This file was deleted.

9 changes: 0 additions & 9 deletions test/files/v2/wrong_definition.cjs

This file was deleted.

8 changes: 0 additions & 8 deletions test/files/v2/wrong_syntax.json

This file was deleted.

3 changes: 0 additions & 3 deletions test/files/v3/README.md

This file was deleted.

63 changes: 0 additions & 63 deletions test/files/v3/callback/api.js

This file was deleted.

83 changes: 0 additions & 83 deletions test/files/v3/callback/openapi.json

This file was deleted.

47 changes: 0 additions & 47 deletions test/files/v3/links/api.js

This file was deleted.

65 changes: 0 additions & 65 deletions test/files/v3/links/openapi.json

This file was deleted.

141 changes: 0 additions & 141 deletions test/files/v3/petstore/api.js

This file was deleted.

156 changes: 0 additions & 156 deletions test/files/v3/petstore/openapi.json

This file was deleted.

Empty file removed test/fixtures/empty-file.js
Empty file.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -8,5 +8,5 @@ module.exports = (app) => {
* - foo
* bar
*/
app.get('/invalid_yaml', () => {});
app.get('/invalid', () => {});
};
File renamed without changes.
205 changes: 0 additions & 205 deletions test/lib.spec.js

This file was deleted.

422 changes: 371 additions & 51 deletions test/specification.spec.js

Large diffs are not rendered by default.

279 changes: 241 additions & 38 deletions test/utils.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
/* eslint no-unused-expressions: 0 */
import {
extractAnnotations,
extractYamlFromJsDoc,
hasEmptyProperty,
isTagPresentInTags,
validateOptions,
} from '../src/utils.js';

const utils = require('../src/utils');
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));

describe('Utilities module', () => {
describe('hasEmptyProperty', () => {
@@ -11,81 +19,276 @@ describe('Utilities module', () => {
const validB = { foo: ['¯_(ツ)_/¯'] };
const validC = { foo: '¯_(ツ)_/¯' };

expect(utils.hasEmptyProperty(invalidA)).toBe(true);
expect(utils.hasEmptyProperty(invalidB)).toBe(true);
expect(utils.hasEmptyProperty(validA)).toBe(false);
expect(utils.hasEmptyProperty(validB)).toBe(false);
expect(utils.hasEmptyProperty(validC)).toBe(false);
expect(hasEmptyProperty(invalidA)).toBe(true);
expect(hasEmptyProperty(invalidB)).toBe(true);
expect(hasEmptyProperty(validA)).toBe(false);
expect(hasEmptyProperty(validB)).toBe(false);
expect(hasEmptyProperty(validC)).toBe(false);
});
});

describe('extractAnnotations', () => {
it('should extract jsdoc comments by default', () => {
describe('extractYamlFromJsDoc', () => {
it('should not handle false cases', () => {
expect(
utils.extractAnnotations(require.resolve('../examples/app/routes2.js'))
).toEqual({
extractYamlFromJsDoc({
description: '',
tags: [
{
title: 'coverage',
description: 'for else path',
},
],
})
).toEqual([]);
});

it('should handle items annotated by @swagger', () => {
const example = {
description: '',
tags: [
{
title: 'swagger',
description:
'/:\n get:\n description: Returns the homepage\n responses:\n 200:\n description: hello world',
},
],
};
const result = extractYamlFromJsDoc(example);
expect(result).toEqual([
'/:\n' +
' get:\n' +
' description: Returns the homepage\n' +
' responses:\n' +
' 200:\n' +
' description: hello world',
]);
});

it('should handle items annotated by @openapi', () => {
const example = {
description: '',
tags: [
{
title: 'openapi',
description:
'/:\n get:\n description: Returns the homepage\n responses:\n 200:\n description: hello world',
},
],
};
const result = extractYamlFromJsDoc(example);
expect(result).toEqual([
'/:\n' +
' get:\n' +
' description: Returns the homepage\n' +
' responses:\n' +
' 200:\n' +
' description: hello world',
]);
});
});

describe('extractAnnotations', () => {
it('should extract jsdoc comments by default', async () => {
expect.assertions(1);
const result = await extractAnnotations(
resolve(__dirname, '../examples/app/routes2.js')
);
expect(result).toEqual({
yaml: [],
jsdoc: [
'/**\n * @swagger\n * /hello:\n * get:\n * description: Returns the homepage\n * responses:\n * 200:\n * description: hello world\n */',
],
});
});

it('should extract data from YAML files', () => {
expect(
utils.extractAnnotations(
require.resolve('../examples/app/parameters.yaml')
)
).toEqual({
it('should extract data from YAML files', async () => {
let result = await extractAnnotations(
resolve(__dirname, '../examples/app/parameters.yaml')
);
expect(result).toEqual({
yaml: [
'parameters:\n username:\n name: username\n description: Username to use for login.\n in: formData\n required: true\n type: string\n',
],
jsdoc: [],
});

expect(
utils.extractAnnotations(
require.resolve('../examples/app/parameters.yml')
)
).toEqual({
result = await extractAnnotations(
resolve(__dirname, '../examples/app/parameters.yml')
);
expect(result).toEqual({
yaml: [
'parameters:\n username:\n name: username\n description: Username to use for login.\n in: formData\n required: true\n type: string\n',
],
jsdoc: [],
});
});

it('should extract jsdoc comments from coffeescript files/syntax', () => {
expect(
utils.extractAnnotations(
require.resolve('../examples/app/route.coffee')
)
).toEqual({
it('should extract jsdoc comments from coffeescript files/syntax', async () => {
const result = await extractAnnotations(
resolve(__dirname, '../examples/app/route.coffee')
);
expect(result).toEqual({
yaml: [],
jsdoc: [
'/**\n* @swagger\n* /login:\n* post:\n* description: Login to the application\n* produces:\n* - application/json\n*/',
],
});
});

it('should return empty arrays from empty coffeescript files/syntax', () => {
expect(
utils.extractAnnotations(
require.resolve('./fixtures/empty-file.coffee')
)
).toEqual({
it('should return empty arrays from empty coffeescript files/syntax', async () => {
const result = await extractAnnotations(
resolve(__dirname, './fixtures/empty/example.coffee')
);
expect(result).toEqual({
yaml: [],
jsdoc: [],
});
});

it('should extract jsdoc comments from empty javascript files/syntax', () => {
expect(
utils.extractAnnotations(require.resolve('./fixtures/empty-file.js'))
).toEqual({
it('should return empty arrays from empty javascript files/syntax', async () => {
const result = await extractAnnotations(
resolve(__dirname, './fixtures/empty/example.js')
);
expect(result).toEqual({
yaml: [],
jsdoc: [],
});
});

it('should respect custom encoding', async () => {
const regular = await extractAnnotations(
resolve(__dirname, './fixtures/non-utf-file.js')
);
expect(regular).toEqual({
yaml: [],
jsdoc: [
'/**\n' +
' * @swagger\n' +
' * /no-utf8:\n' +
' * get:\n' +
' * description: 𝗵ĕŀḷ𝙤 ẘợ𝙧ḻď\n' +
' * responses:\n' +
' * 200:\n' +
' * description: ꞎǒɼ𝙚ᶆ ịⲣŝừɱ\n' +
' */',
],
});

const encoded = await extractAnnotations(
resolve(__dirname, './fixtures/non-utf-file.js'),
'ascii'
);
expect(encoded).toEqual({
yaml: [],
jsdoc: [
'/**\n' +
' * @swagger\n' +
' * /no-utf8:\n' +
' * get:\n' +
" * description: p\u001d\u00175D\u0015E\u0000a87p\u001d\u0019$ a:\u0018a;#p\u001d\u0019'a8;D\u000f\n" +
' * responses:\n' +
' * 200:\n' +
' * description: j\u001e\u000eG\u0012I<p\u001d\u0019\u001aa6\u0006 a;\u000bb2#E\u001da;+I1\n' +
' */',
],
});
});
});

describe('isTagPresentInTags', () => {
it(`should be true when it's true`, () => {
expect(
isTagPresentInTags(
{ name: 'Users', description: 'User management and login' },
[
{
name: 'Users',
description: 'User management and login',
},
]
)
).toBe(true);
});

it(`should be false when it's false`, () => {
expect(
isTagPresentInTags(
{ name: 'User', description: 'User management and login' },
[
{
name: 'Users',
description: 'User management and login',
},
]
)
).toBe(false);
});
});

describe('validateOptions', () => {
it('should throw on empty input', () => {
expect(() => {
validateOptions();
}).toThrow("'options' parameter is required!");
});

it('should throw on bad input', () => {
expect(() => {
validateOptions({});
}).toThrow(
`'options.swaggerDefinition' or 'options.definition' is required!`
);
});

it(`should throw on missing 'info' property`, () => {
expect(() => {
const options = { swaggerDefinition: {} };
validateOptions(options);
}).toThrow(
`Swagger definition ('options.swaggerDefinition') should contain an info object!`
);
});

it(`should throw on missing 'title' and 'version' properties in the info object`, () => {
expect(() => {
validateOptions({ swaggerDefinition: { info: {} } });
}).toThrow(
`Swagger definition info object ('options.swaggerDefinition.info') requires title and version properties!`
);

expect(() => {
validateOptions({ swaggerDefinition: { info: { title: '' } } });
}).toThrow(
`Swagger definition info object ('options.swaggerDefinition.info') requires title and version properties!`
);

expect(() => {
validateOptions({ swaggerDefinition: { info: { version: '' } } });
}).toThrow(
`Swagger definition info object ('options.swaggerDefinition.info') requires title and version properties!`
);
});

it(`should throw on missing 'apis' property`, () => {
expect(() => {
validateOptions({
swaggerDefinition: { info: { version: '', title: '' } },
});
}).toThrow(`'options.apis' is required and it should be an array!`);
});

it('should return original options on valid input', () => {
let options = {
swaggerDefinition: { info: { version: '', title: '' } },
apis: [],
};
expect(validateOptions(options)).toEqual(options);

options = {
definition: { info: { version: '', title: '' } },
apis: [],
};
expect(validateOptions(options)).toEqual(options);
});
});
});
349 changes: 161 additions & 188 deletions yarn.lock

Large diffs are not rendered by default.

0 comments on commit 6431a56

Please sign in to comment.