diff --git a/.eslintignore b/.eslintignore index 2a8ec1a8b..e2ae93c3d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,7 +4,9 @@ /mocks/*/build /mocks/admin-api-client-data-provider-e2e-vanilla-app/public/js /packages/*/dist +/packages/*/dist-tsc /packages/*/coverage +/test/*/dist /test/core-e2e/src/fixtures /test/core-e2e-legacy/src/fixtures /test/main-e2e/src/fixtures \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2583e32c..6c21bac04 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,7 @@ on: branches: - master - release + - feat-384-openapi pull_request: jobs: get-affected: diff --git a/README.md b/README.md index 0145e8b14..78f808e09 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,10 @@ To check out docs, visit [www.mocks-server.org][website-url]. | --- | --- | --- | | [main] | [![main-status]][main-package] | Main distribution. It includes all plugins preinstalled | | [core] | [![core-status]][core-package] | Pluggable core. It can be used programmatically also | -| [plugin-proxy] | [![plugin-proxy-status]][plugin-proxy-package] | Plugin providing Proxy route handler | -| [plugin-inquirer-cli] | [![plugin-inquirer-cli-status]][plugin-inquirer-cli-package] | Plugin providing an administration interactive CLI | | [plugin-admin-api] | [![plugin-admin-api-status]][plugin-admin-api-package] | Plugin providing an administration REST API | +| [plugin-inquirer-cli] | [![plugin-inquirer-cli-status]][plugin-inquirer-cli-package] | Plugin providing an administration interactive CLI | +| [plugin-openapi] | [![plugin-openapi-status]][plugin-openapi-package] | Plugin allowing to create routes and collections from OpenApi definitions | +| [plugin-proxy] | [![plugin-proxy-status]][plugin-proxy-package] | Plugin providing Proxy route handler | | [admin-api-client] | [![admin-api-client-status]][admin-api-client-package] | API client for [plugin-admin-api] | | [admin-api-client-data-provider] | [![admin-api-client-data-provider-status]][admin-api-client-data-provider-package] | API client for [plugin-admin-api] built using [data-provider] | | [admin-api-paths] | [![admin-api-paths-status]][admin-api-paths-package] | Definition of [plugin-admin-api] routes | @@ -67,17 +68,21 @@ Please read the [contributing guidelines](.github/CONTRIBUTING.md) and [code of [core-status]: https://img.shields.io/npm/v/@mocks-server/core.svg [core-package]: https://npmjs.com/package/@mocks-server/core -[plugin-proxy]: https://github.com/mocks-server/main/tree/master/packages/plugin-proxy -[plugin-proxy-status]: https://img.shields.io/npm/v/@mocks-server/plugin-proxy.svg -[plugin-proxy-package]: https://npmjs.com/package/@mocks-server/plugin-proxy +[plugin-admin-api]: https://github.com/mocks-server/main/tree/master/packages/plugin-admin-api +[plugin-admin-api-status]: https://img.shields.io/npm/v/@mocks-server/plugin-admin-api.svg +[plugin-admin-api-package]: https://npmjs.com/package/@mocks-server/plugin-admin-api [plugin-inquirer-cli]: https://github.com/mocks-server/main/tree/master/packages/plugin-inquirer-cli [plugin-inquirer-cli-status]: https://img.shields.io/npm/v/@mocks-server/plugin-inquirer-cli.svg [plugin-inquirer-cli-package]: https://npmjs.com/package/@mocks-server/plugin-inquirer-cli -[plugin-admin-api]: https://github.com/mocks-server/main/tree/master/packages/plugin-admin-api -[plugin-admin-api-status]: https://img.shields.io/npm/v/@mocks-server/plugin-admin-api.svg -[plugin-admin-api-package]: https://npmjs.com/package/@mocks-server/plugin-admin-api +[plugin-openapi]: https://github.com/mocks-server/main/tree/master/packages/plugin-openapi +[plugin-openapi-status]: https://img.shields.io/npm/v/@mocks-server/plugin-openapi.svg +[plugin-openapi-package]: https://npmjs.com/package/@mocks-server/plugin-openapi + +[plugin-proxy]: https://github.com/mocks-server/main/tree/master/packages/plugin-proxy +[plugin-proxy-status]: https://img.shields.io/npm/v/@mocks-server/plugin-proxy.svg +[plugin-proxy-package]: https://npmjs.com/package/@mocks-server/plugin-proxy [admin-api-client]: https://github.com/mocks-server/main/tree/master/packages/admin-api-client [admin-api-client-status]: https://img.shields.io/npm/v/@mocks-server/admin-api-client.svg diff --git a/codecov.yml b/codecov.yml index b8402b77e..817cdcef4 100644 --- a/codecov.yml +++ b/codecov.yml @@ -43,6 +43,9 @@ coverage: plugin-inquirer-cli: flags: - plugin-inquirer-cli + plugin-openapi: + flags: + - plugin-openapi plugin-proxy: flags: - plugin-proxy @@ -95,6 +98,10 @@ flags: paths: - packages/plugin-inquirer-cli/** carryforward: true + plugin-openapi: + paths: + - packages/plugin-openapi/** + carryforward: true plugin-proxy: paths: - packages/plugin-proxy/** diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index ea96532ab..d60382331 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### Removed +## [1.3.0] - 2022-08-25 + +### Added +- feat: Add 'nullable' property to option. Nullable types are 'string', 'number' and 'boolean' + ## [1.2.1] - 2022-08-11 ### Fixed diff --git a/packages/config/README.md b/packages/config/README.md index 031ec8621..9bef60f06 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -86,6 +86,8 @@ Options can be added to a namespace, or to the root config object. Both `config` Options can be of one of next types: `string`, `boolean`, `number`, `object` or `array`. This library automatically converts the values from command line arguments and environment variables to the expected type when possible. If the conversion is not possible or the validation fails an error is thrown. Validation errors provide enough context to users to let them know the option that failed. This library uses [`ajv`](https://github.com/ajv-validator) and [`better-ajv-errors`](https://github.com/atlassian/better-ajv-errors) for validations. +Types `string`, `boolean`, `number` can be nullable using the option `nullable` property. + Here is an example of how to add an option to the root config object, and then you have information about how the option would be set from different sources: ```js @@ -279,6 +281,21 @@ Examples about how to define options of type `object` from sources: __The contents of the array are also converted to its correspondent type when the `itemsType` option is provided.__ +### __Nullable types__ + +An option can be null when it is set as `nullable`. Nullable types are `string`, `boolean` and `number`. Types `object` and `array` can't be nullable, their value should be set to empty array or empty object instead. + +```js +const config = new Config({ moduleName: "moduleName" }); +const option = config.addOption({ + name: "optionA", + type: "string", + nullable: true, + default: null, +}); +await config.load(); +``` + ## Built-in options The library registers some options that can be used to determine the behavior of the library itself. As the rest of the configuration created by the library, these options can be set using configuration file, environment variables, command line arguments, etc. But there are some of them that can be defined only in some specific sources because they affect to reading that sources or not. @@ -356,6 +373,7 @@ const namespace = config.addNamespace("name"); * __`name`__ _(String)_: Name for the option. * __`description`__ _(String)_: _Optional_. Used in help, traces, etc. * __`type`__ _(String)_. One of _`string`_, _`boolean`_, _`number`_, _`array`_ or _`object`_. Used to apply type validation when loading configuration and in `option.value` setter. + * __`nullable`__ _(Boolean)_. _Optional_. Default is `false`. When `true`, the option value can be set to `null`. It is only supported in types `string`, `number` and `boolean`. * __`itemsType`__ _(String)_. Can be defined only when `type` is `array`. It must be one of _`string`_, _`boolean`_, _`number`_ or _`object`_. * __`default`__ - _Optional_. Default value. Its type depends on the `type` option. * __`extraData`__ - _(Object)_. _Optional_. Useful to store any extra data you want in the option. For example, Mocks Server uses it to define whether an option must be written when creating the configuration scaffold or not. @@ -384,6 +402,7 @@ const rootOption = config.addOption("name2"); * `callback(value)` _(Function)_: Callback to be executed whenever the option value changes. It receives the new value as first argument. * __`name`__: Getter returning the option name. * __`type`__: Getter returning the option type. +* __`nullable`__: Getter returning whether the option is nullable or not. * __`description`__: Getter returning the option description. * __`extraData`__: Getter returning the option extra data. * __`default`__: Getter returning the option default value. diff --git a/packages/config/jest.config.js b/packages/config/jest.config.js index f8bf2543a..6725e8c5b 100644 --- a/packages/config/jest.config.js +++ b/packages/config/jest.config.js @@ -26,7 +26,7 @@ module.exports = { // The glob patterns Jest uses to detect test files testMatch: ["/test/**/*.spec.js"], - // testMatch: ["/test/src/getValidationSchema.spec.js"], + // testMatch: ["/test/src/validate.spec.js"], // The test environment that will be used for testing testEnvironment: "node", diff --git a/packages/config/package.json b/packages/config/package.json index 037bf534a..af743c6fb 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@mocks-server/config", - "version": "1.2.1", + "version": "1.3.0", "description": "Modular configuration provider. Read it from file, environment and arguments", "keywords": [ "configuration", diff --git a/packages/config/sonar-project.properties b/packages/config/sonar-project.properties index f37ba261f..d0f17a39d 100644 --- a/packages/config/sonar-project.properties +++ b/packages/config/sonar-project.properties @@ -1,7 +1,7 @@ sonar.organization=mocks-server sonar.projectKey=mocks-server_main_config sonar.projectName=config -sonar.projectVersion=1.2.1 +sonar.projectVersion=1.3.0 sonar.javascript.file.suffixes=.js sonar.sourceEncoding=UTF-8 diff --git a/packages/config/src/Option.js b/packages/config/src/Option.js index b0b79cad3..0c8455e80 100644 --- a/packages/config/src/Option.js +++ b/packages/config/src/Option.js @@ -10,8 +10,8 @@ const { types, avoidArraysMerge } = require("./types"); class Option { constructor(properties) { this._eventEmitter = new EventEmitter(); - validateOptionAndThrow(properties); this._name = properties.name; + this._nullable = Boolean(properties.nullable); this._extraData = properties.extraData; this._type = properties.type; this._description = properties.description; @@ -20,6 +20,8 @@ class Option { this._value = this._default; this._eventsStarted = false; this._hasBeenSet = false; + + validateOptionAndThrow({ ...properties, nullable: this._nullable }); } get extraData() { @@ -38,6 +40,10 @@ class Option { return this._type; } + get nullable() { + return this._nullable; + } + get itemsType() { return this._itemsType; } @@ -64,7 +70,7 @@ class Option { } _validateAndThrow(value) { - validateValueTypeAndThrow(value, this._type, this._itemsType); + validateValueTypeAndThrow(value, this._type, this._nullable, this._itemsType); } _emitChange(previousValue, value) { diff --git a/packages/config/src/types.js b/packages/config/src/types.js index 2e6c85e6d..872b9c0f7 100644 --- a/packages/config/src/types.js +++ b/packages/config/src/types.js @@ -4,6 +4,7 @@ const types = { BOOLEAN: "boolean", OBJECT: "object", ARRAY: "array", + NULL: "null", }; const FALSY_VALUES = ["false", "0", 0]; diff --git a/packages/config/src/validation.js b/packages/config/src/validation.js index 936214f41..88538c34d 100644 --- a/packages/config/src/validation.js +++ b/packages/config/src/validation.js @@ -6,11 +6,12 @@ const ajv = new Ajv({ allErrors: true }); const { types } = require("./types"); -function enforceDefaultTypeSchema(type, itemsType) { +function enforceDefaultTypeSchema({ type, itemsType, nullable }) { const schema = { properties: { name: { type: types.STRING }, type: { enum: [type] }, + nullable: { enum: [false] }, description: { type: types.STRING }, default: { type, @@ -21,15 +22,20 @@ function enforceDefaultTypeSchema(type, itemsType) { }, }, additionalProperties: false, - required: ["name", "type"], + required: ["name", "type", "nullable"], }; + if (nullable) { + schema.properties.default.type = [type, types.NULL]; + schema.properties.nullable = { enum: [true] }; + } + if (itemsType) { schema.properties.itemsType = { enum: [itemsType] }; schema.properties.default.items = { type: itemsType, }; - schema.required = ["name", "type", "itemsType"]; + schema.required = ["name", "type", "nullable", "itemsType"]; } return schema; @@ -38,15 +44,18 @@ function enforceDefaultTypeSchema(type, itemsType) { const optionSchema = { type: types.OBJECT, oneOf: [ - enforceDefaultTypeSchema(types.NUMBER), - enforceDefaultTypeSchema(types.STRING), - enforceDefaultTypeSchema(types.BOOLEAN), - enforceDefaultTypeSchema(types.OBJECT), - enforceDefaultTypeSchema(types.ARRAY), - enforceDefaultTypeSchema(types.ARRAY, types.NUMBER), - enforceDefaultTypeSchema(types.ARRAY, types.STRING), - enforceDefaultTypeSchema(types.ARRAY, types.BOOLEAN), - enforceDefaultTypeSchema(types.ARRAY, types.OBJECT), + enforceDefaultTypeSchema({ type: types.NUMBER }), + enforceDefaultTypeSchema({ type: types.NUMBER, nullable: true }), + enforceDefaultTypeSchema({ type: types.STRING }), + enforceDefaultTypeSchema({ type: types.STRING, nullable: true }), + enforceDefaultTypeSchema({ type: types.BOOLEAN }), + enforceDefaultTypeSchema({ type: types.BOOLEAN, nullable: true }), + enforceDefaultTypeSchema({ type: types.OBJECT }), + enforceDefaultTypeSchema({ type: types.ARRAY }), + enforceDefaultTypeSchema({ type: types.ARRAY, itemsType: types.NUMBER }), + enforceDefaultTypeSchema({ type: types.ARRAY, itemsType: types.STRING }), + enforceDefaultTypeSchema({ type: types.ARRAY, itemsType: types.BOOLEAN }), + enforceDefaultTypeSchema({ type: types.ARRAY, itemsType: types.OBJECT }), ], }; @@ -133,9 +142,16 @@ function validateSchemaAndThrow(config, schema, validator) { function addNamespaceSchema(namespace, { rootSchema, allowAdditionalProperties }) { const initialSchema = rootSchema || emptySchema({ allowAdditionalProperties }); const schema = namespace.options.reduce((currentSchema, option) => { - currentSchema.properties[option.name] = { - type: option.type, - }; + if (option.nullable) { + currentSchema.properties[option.name] = { + type: [option.type, types.NULL], + }; + } else { + currentSchema.properties[option.name] = { + type: option.type, + }; + } + if (option.itemsType) { currentSchema.properties[option.name].items = { type: option.itemsType, @@ -192,7 +208,10 @@ function validateOptionAndThrow(properties) { validateSchemaAndThrow(properties, optionSchema, optionValidator); } -function validateValueTypeAndThrow(value, type, itemsType) { +function validateValueTypeAndThrow(value, type, nullable, itemsType) { + if (nullable && value === null) { + return; + } typeAndThrowValidators[type](value, itemsType); } diff --git a/packages/config/test/src/validate.spec.js b/packages/config/test/src/validate.spec.js index de425a454..5d65c1d27 100644 --- a/packages/config/test/src/validate.spec.js +++ b/packages/config/test/src/validate.spec.js @@ -14,32 +14,101 @@ describe("validate method", () => { }); describe("when option is created in root", () => { - it("should not pass validation when type is string and value does not match type", async () => { - config = new Config(); - config.addOption({ - name: "fooOption", - type: "string", - default: "default-str", - }); - const validation = config.validate({ - fooOption: 2, + function testTypeValidation({ type, validValue, invalidValue, default: defaultValue }) { + const name = "fooOption"; + + describe(`${type} type`, () => { + it("should not pass validation when value does not match type", async () => { + config = new Config(); + config.addOption({ + name, + type, + default: defaultValue, + }); + const validation = config.validate({ + [name]: invalidValue, + }); + expect(validation.valid).toEqual(false); + expect(validation.errors.length).toEqual(1); + }); + + it("should pass validation when is nullable and value is null", async () => { + config = new Config(); + config.addOption({ + name, + type, + nullable: true, + default: defaultValue, + }); + const validation = config.validate({ + [name]: null, + }); + expect(validation.valid).toEqual(true); + expect(validation.errors).toEqual(null); + }); + + it("should not throw when is nullable and value is set to null", async () => { + config = new Config(); + config.addOption({ + name, + type, + nullable: true, + default: defaultValue, + }); + config.option(name).value = null; + expect(config.option(name).value).toEqual(null); + }); + + it("should pass validation when is nullable string and default value is null", async () => { + config = new Config(); + config.addOption({ + name, + type, + default: null, + nullable: true, + }); + const validation = config.validate({ + [name]: validValue, + }); + expect(validation.valid).toEqual(true); + expect(validation.errors).toEqual(null); + }); + + it("should pass validation when value matches type", async () => { + config = new Config(); + config.addOption({ + name, + type, + default: defaultValue, + }); + const validation = config.validate({ + [name]: validValue, + }); + expect(validation.valid).toEqual(true); + expect(validation.errors).toEqual(null); + }); }); - expect(validation.valid).toEqual(false); - expect(validation.errors.length).toEqual(1); + } + + testTypeValidation({ + type: "string", + invalidValue: 2, + validValue: "2", + default: "default-str", }); - it("should pass validation when type is string and value does not match type", async () => { - config = new Config(); - config.addOption({ - name: "fooOption", - type: "string", - default: "default-str", - }); - const validation = config.validate({ - fooOption: "2", - }); - expect(validation.valid).toEqual(true); - expect(validation.errors).toEqual(null); + testTypeValidation({ + type: "number", + invalidValue: "2", + validValue: 2, + default: 3, + }); + + testTypeValidation({ + type: "boolean", + invalidValue: "2", + validValue: true, + default: false, }); }); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 95e71c266..f3d9f432b 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed ### Removed +## [3.11.0] - 2022-08-25 + +### Added +- feat: Support asynchronies in files. Files now can export a function. In that case, the loader will receive the result of the function. If function returns a promise, it will receive the result of the promise once it is resolved (rejected promises are treated as file load errors). +- Added: Support null value in "from" property in collections + +### Changed + +- chore(deps): Update @mocks-server/config to 1.3.0 + +### Fixed +- fix: Collections and routes validation was throwing when undefined was passed as value + ## [3.10.0] - 2022-08-11 ### Added diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index a9fbbd83f..4022d65d1 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -26,7 +26,7 @@ module.exports = { // The glob patterns Jest uses to detect test files testMatch: ["/test/**/*.spec.js"], - // testMatch: ["/test/**/Server.spec.js"], + // testMatch: ["/test/**/validations.spec.js"], // The test environment that will be used for testing testEnvironment: "node", diff --git a/packages/core/package.json b/packages/core/package.json index f6cb5d9e3..fc3a17f7a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@mocks-server/core", - "version": "3.10.0", + "version": "3.11.0", "description": "Pluggable mock server supporting multiple route variants and mocks", "keywords": [ "mocks", diff --git a/packages/core/sonar-project.properties b/packages/core/sonar-project.properties index 292e226f9..f67bfc890 100644 --- a/packages/core/sonar-project.properties +++ b/packages/core/sonar-project.properties @@ -1,7 +1,7 @@ sonar.organization=mocks-server sonar.projectKey=mocks-server_main_core sonar.projectName=core -sonar.projectVersion=3.10.0 +sonar.projectVersion=3.11.0 sonar.javascript.file.suffixes=.js sonar.sourceEncoding=UTF-8 diff --git a/packages/core/src/files/FilesLoaders.js b/packages/core/src/files/FilesLoaders.js index 5d06caeac..45b6ced7a 100644 --- a/packages/core/src/files/FilesLoaders.js +++ b/packages/core/src/files/FilesLoaders.js @@ -13,7 +13,7 @@ const path = require("path"); const globule = require("globule"); const watch = require("node-watch"); const fsExtra = require("fs-extra"); -const { map, debounce } = require("lodash"); +const { map, debounce, isFunction } = require("lodash"); const isPromise = require("is-promise"); const CollectionsLoader = require("./loaders/Collections"); @@ -144,7 +144,31 @@ class FilesLoaders { return new Promise((resolve, reject) => { try { const content = this._require(filePath); - resolve((content && content.default) || content); + const exportedContent = (content && content.default) || content; + if (isFunction(exportedContent)) { + this._logger.debug( + `Function exported by '${filePath}'. Executing it to return its result` + ); + const exportedContentResult = exportedContent(); + if (isPromise(exportedContentResult)) { + this._logger.debug( + `Function in '${filePath}' returned a promise. Waiting for it to resolve its result` + ); + exportedContentResult + .then((exportedContentPromiseResult) => { + this._logger.silly(`Promise in '${filePath}' was resolved`); + resolve(exportedContentPromiseResult); + }) + .catch((error) => { + this._logger.silly(`Promise in '${filePath}' was rejected`); + reject(error); + }); + } else { + resolve(exportedContentResult); + } + } else { + resolve(exportedContent); + } } catch (error) { reject(error); } diff --git a/packages/core/src/mock/validations.js b/packages/core/src/mock/validations.js index 87face35d..9d8896b9c 100644 --- a/packages/core/src/mock/validations.js +++ b/packages/core/src/mock/validations.js @@ -54,7 +54,7 @@ const collectionsSchema = { type: "string", }, from: { - type: "string", + type: ["string", "null"], }, routesVariants: { type: "array", @@ -74,7 +74,7 @@ const collectionsSchema = { type: "string", }, from: { - type: "string", + type: ["string", "null"], }, routeVariants: { type: "array", @@ -94,7 +94,7 @@ const collectionsSchema = { type: "string", }, from: { - type: "string", + type: ["string", "null"], }, routes: { type: "array", @@ -246,7 +246,7 @@ function customValidationSingleMessage(errors) { } function validationSingleMessage(schema, data, errors) { - const formattedJson = betterAjvErrors(schema, data, errors, { + const formattedJson = betterAjvErrors(schema, data || {}, errors, { format: "js", }); return formattedJson.map((result) => result.error).join(". "); diff --git a/packages/core/test/files/FilesLoaders.spec.js b/packages/core/test/files/FilesLoaders.spec.js index ad84389f1..7681e3541 100644 --- a/packages/core/test/files/FilesLoaders.spec.js +++ b/packages/core/test/files/FilesLoaders.spec.js @@ -488,6 +488,86 @@ describe("FilesLoaders", () => { ]); }); + it("should call to its load function passing the result when file exports a function", async () => { + libsMocks.stubs.globule.find.returns(["foo/foo-file.js"]); + const spy = sandbox.spy(); + filesLoader = new FilesLoaders(pluginMethods, { + requireCache, + require: () => () => ["foo-content"], + }); + filesLoader._pathOption = pathOption; + filesLoader._watchOption = watchOption; + filesLoader._babelRegisterOption = babelRegisterOption; + filesLoader._babelRegisterOptionsOption = babelRegisterOptionsOption; + filesLoader.createLoader({ + id: "foo", + src: "foo/foo-path/**/*", + onLoad: spy, + }); + await filesLoader.init(); + expect(spy.getCall(0).args[0]).toEqual([ + { path: "foo/foo-file.js", content: ["foo-content"] }, + ]); + }); + + it("should call to its load function passing the promise resolved result when file exports a function returning a promise", async () => { + libsMocks.stubs.globule.find.returns(["foo/foo-file.js"]); + const exportedFunction = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(["foo-promise-content"]); + }, 200); + }); + }; + const spy = sandbox.spy(); + filesLoader = new FilesLoaders(pluginMethods, { + requireCache, + require: () => exportedFunction, + }); + filesLoader._pathOption = pathOption; + filesLoader._watchOption = watchOption; + filesLoader._babelRegisterOption = babelRegisterOption; + filesLoader._babelRegisterOptionsOption = babelRegisterOptionsOption; + filesLoader.createLoader({ + id: "foo", + src: "foo/foo-path/**/*", + onLoad: spy, + }); + await filesLoader.init(); + expect(spy.getCall(0).args[0]).toEqual([ + { path: "foo/foo-file.js", content: ["foo-promise-content"] }, + ]); + }); + + it("should catch the error and pass it as an errored file when the file exports a promise that is rejected", async () => { + const promiseError = new Error("Foo error"); + libsMocks.stubs.globule.find.returns(["foo/foo-file.js"]); + const exportedFunction = () => { + return new Promise((_resolve, reject) => { + setTimeout(() => { + reject(promiseError); + }, 200); + }); + }; + const spy = sandbox.spy(); + filesLoader = new FilesLoaders(pluginMethods, { + requireCache, + require: () => exportedFunction, + }); + filesLoader._pathOption = pathOption; + filesLoader._watchOption = watchOption; + filesLoader._babelRegisterOption = babelRegisterOption; + filesLoader._babelRegisterOptionsOption = babelRegisterOptionsOption; + filesLoader.createLoader({ + id: "foo", + src: "foo/foo-path/**/*", + onLoad: spy, + }); + await filesLoader.init(); + expect(spy.getCall(0).args[0]).toEqual([]); + expect(spy.getCall(0).args[1]).toEqual([{ path: "foo/foo-file.js", error: promiseError }]); + }); + it("should support async onLoad functions", async () => { libsMocks.stubs.globule.find.returns(["foo/foo-path/**"]); const spy = sandbox.spy(); diff --git a/packages/core/test/mock/validations.spec.js b/packages/core/test/mock/validations.spec.js index 98087e295..b297b60ba 100644 --- a/packages/core/test/mock/validations.spec.js +++ b/packages/core/test/mock/validations.spec.js @@ -430,6 +430,12 @@ describe("mocks validations", () => { ).toEqual(null); }); + it("should return error if collection is undefined", () => { + const errors = collectionValidationErrors(); + expect(errors.message).toEqual(expect.stringContaining("type must be object")); + expect(errors.errors.length).toEqual(4); + }); + it("should return error if mock has not id", () => { const errors = collectionValidationErrors({ routes: [], @@ -453,7 +459,7 @@ describe("mocks validations", () => { foo: "foo", }); expect(errors.message).toEqual( - "Collection is invalid: must have required property 'routes'. /from: type must be string. /from: type must be string. /from: type must be string" + "Collection is invalid: must have required property 'routes'. /from: type must be string,null. /from: type must be string,null. /from: type must be string,null" ); }); }); diff --git a/packages/main/CHANGELOG.md b/packages/main/CHANGELOG.md index b978c29ea..5141a479a 100644 --- a/packages/main/CHANGELOG.md +++ b/packages/main/CHANGELOG.md @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed ### Breaking change +## [3.11.0] - 2022-08-25 + +### Added +- feat(#384): Add `@mocks-server/plugin-openapi` to preinstalled plugins + +### Changed +- chore(deps): Update `@mocks-server/core` dependency to 3.11.0 + ## [3.10.0] - 2022-08-11 ### Changed diff --git a/packages/main/package.json b/packages/main/package.json index 327a9daea..8575d150b 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -1,6 +1,6 @@ { "name": "@mocks-server/main", - "version": "3.10.0", + "version": "3.11.0", "description": "Mock Server supporting multiple route variants and mocks", "keywords": [ "mock", @@ -44,6 +44,7 @@ "@mocks-server/core": "workspace:*", "@mocks-server/plugin-admin-api": "workspace:*", "@mocks-server/plugin-inquirer-cli": "workspace:*", + "@mocks-server/plugin-openapi": "workspace:*", "@mocks-server/plugin-proxy": "workspace:*", "deepmerge": "4.2.2" }, diff --git a/packages/main/sonar-project.properties b/packages/main/sonar-project.properties index 962693add..78dc5870d 100644 --- a/packages/main/sonar-project.properties +++ b/packages/main/sonar-project.properties @@ -1,7 +1,7 @@ sonar.organization=mocks-server sonar.projectKey=mocks-server_main sonar.projectName=main -sonar.projectVersion=3.10.0 +sonar.projectVersion=3.11.0 sonar.javascript.file.suffixes=.js sonar.sourceEncoding=UTF-8 diff --git a/packages/main/src/createCore.js b/packages/main/src/createCore.js index 7401e635b..ed284dbc7 100644 --- a/packages/main/src/createCore.js +++ b/packages/main/src/createCore.js @@ -15,6 +15,7 @@ const Core = require("@mocks-server/core"); const PluginProxy = require("@mocks-server/plugin-proxy"); const AdminApi = require("@mocks-server/plugin-admin-api"); const InquirerCli = require("@mocks-server/plugin-inquirer-cli"); +const OpenApi = require("@mocks-server/plugin-openapi").default; const deepMerge = require("deepmerge"); const pkg = require("../package.json"); @@ -26,7 +27,7 @@ const DEFAULT_CONFIG = { readFile: false, }, plugins: { - register: [PluginProxy, AdminApi, InquirerCli], + register: [PluginProxy, AdminApi, InquirerCli, OpenApi], inquirerCli: { enabled: false, }, diff --git a/packages/main/test/createCore.spec.js b/packages/main/test/createCore.spec.js index 1e292da0e..7b8a6b086 100644 --- a/packages/main/test/createCore.spec.js +++ b/packages/main/test/createCore.spec.js @@ -13,6 +13,7 @@ const sinon = require("sinon"); const PluginProxy = require("@mocks-server/plugin-proxy"); const AdminApi = require("@mocks-server/plugin-admin-api"); const InquirerCli = require("@mocks-server/plugin-inquirer-cli"); +const OpenApi = require("@mocks-server/plugin-openapi").default; const CoreMocks = require("./Core.mocks.js"); @@ -42,7 +43,7 @@ describe("createCore method", () => { readFile: false, }, plugins: { - register: [PluginProxy, AdminApi, InquirerCli], + register: [PluginProxy, AdminApi, InquirerCli, OpenApi], inquirerCli: { enabled: false, }, @@ -75,7 +76,7 @@ describe("createCore method", () => { readFile: false, }, plugins: { - register: [PluginProxy, AdminApi, InquirerCli, FooPlugin], + register: [PluginProxy, AdminApi, InquirerCli, OpenApi, FooPlugin], inquirerCli: { enabled: false, }, diff --git a/packages/main/test/start.spec.js b/packages/main/test/start.spec.js index 13c51dfb0..e702b6678 100644 --- a/packages/main/test/start.spec.js +++ b/packages/main/test/start.spec.js @@ -13,6 +13,7 @@ const sinon = require("sinon"); const PluginProxy = require("@mocks-server/plugin-proxy"); const AdminApi = require("@mocks-server/plugin-admin-api"); const InquirerCli = require("@mocks-server/plugin-inquirer-cli"); +const OpenApi = require("@mocks-server/plugin-openapi").default; const CoreMocks = require("./Core.mocks.js"); @@ -38,7 +39,7 @@ describe("start method", () => { expect(coreMocks.stubs.Constructor.mock.calls[0][0]).toEqual({ config: { readArguments: true, readEnvironment: true, readFile: true }, plugins: { - register: [PluginProxy, AdminApi, InquirerCli], + register: [PluginProxy, AdminApi, InquirerCli, OpenApi], inquirerCli: { enabled: true }, }, files: { enabled: true }, diff --git a/packages/plugin-openapi/.gitignore b/packages/plugin-openapi/.gitignore new file mode 100644 index 000000000..319414beb --- /dev/null +++ b/packages/plugin-openapi/.gitignore @@ -0,0 +1,28 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +#environment variables +.env + +# dependencies +/node_modules + +# build +/dist + +# tests +/coverage + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# ides +.idea +.vs diff --git a/packages/plugin-openapi/CHANGELOG.md b/packages/plugin-openapi/CHANGELOG.md new file mode 100644 index 000000000..209f5ebe4 --- /dev/null +++ b/packages/plugin-openapi/CHANGELOG.md @@ -0,0 +1,17 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [unreleased] +### Added +### Changed +### Fixed +### Removed +### BREAKING CHANGE + +## [1.0.0] - 2022-08-25 + +### Added +- feat: First release diff --git a/packages/plugin-openapi/LICENSE b/packages/plugin-openapi/LICENSE new file mode 100644 index 000000000..4a7f8c581 --- /dev/null +++ b/packages/plugin-openapi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-present Javier Brea + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugin-openapi/README.md b/packages/plugin-openapi/README.md new file mode 100644 index 000000000..2fd5fd405 --- /dev/null +++ b/packages/plugin-openapi/README.md @@ -0,0 +1,34 @@ +

Mocks Server logo

+ +

+ Build Status + Coverage + Quality Gate + Downloads + Renovate + License +

+ +--- + +# Mocks Server Plugin OpenApi + +[Mocks Server][website-url] plugin enabling to create routes and collections from OpenApi definitions. + +## Usage + +This plugin is included in the main distribution of the Mocks Server project, so you can refer to the [official documentation website][website-url]. + +## Options + +* __`plugins.openapi.collection.id`__ _(String | Null)_: Id for the collection to be created with __all routes from all OpenAPI documents__. Default is "openapi". When it is set to `null`, no collection will be created. +* __`plugins.openapi.collection.from`__ _(String)_: When provided, the created collection will extend from this one. + +Read more about [how to set options in Mocks Server here](https://www.mocks-server.org/docs/configuration/how-to-change-settings). + +## Contributing + +Contributors are welcome. +Please read the [contributing guidelines](../../.github/CONTRIBUTING.md) and [code of conduct](../../.github/CODE_OF_CONDUCT.md). + +[website-url]: https://www.mocks-server.org diff --git a/packages/plugin-openapi/babel.config.js b/packages/plugin-openapi/babel.config.js new file mode 100644 index 000000000..cef502c56 --- /dev/null +++ b/packages/plugin-openapi/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript"], + plugins: ["babel-plugin-replace-ts-export-assignment"], +}; diff --git a/packages/plugin-openapi/jest.config.js b/packages/plugin-openapi/jest.config.js new file mode 100644 index 000000000..338ca8694 --- /dev/null +++ b/packages/plugin-openapi/jest.config.js @@ -0,0 +1,33 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ["src/**/*.ts"], + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, + + // The glob patterns Jest uses to detect test files + testMatch: ["/test/**/*.spec.js"], + // testMatch: ["/test/**/collections.spec.js"], + + // The test environment that will be used for testing + testEnvironment: "node", +}; diff --git a/packages/plugin-openapi/package.json b/packages/plugin-openapi/package.json new file mode 100644 index 000000000..6d3ff80d5 --- /dev/null +++ b/packages/plugin-openapi/package.json @@ -0,0 +1,48 @@ +{ + "name": "@mocks-server/plugin-openapi", + "version": "1.0.0", + "description": "Mocks server plugin allowing to create routes and collections from OpenApi definitions", + "keywords": [ + "mocks-server-plugin", + "OpenApi", + "mock", + "http", + "rest", + "plugin", + "mocks-server" + ], + "author": "Javier Brea", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/mocks-server/main.git", + "directory": "packages/plugin-openapi" + }, + "homepage": "https://www.mocks-server.org", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test:unit": "jest --runInBand" + }, + "peerDependencies": { + "@mocks-server/core": ">=3.10.0 <4.x" + }, + "dependencies": { + "openapi-types": "12.0.0", + "json-refs": "3.0.15" + }, + "devDependencies": { + "@mocks-server/core": "workspace:*", + "@mocks-server/nested-collections": "workspace:*" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/packages/plugin-openapi/project.json b/packages/plugin-openapi/project.json new file mode 100644 index 000000000..ec37038f6 --- /dev/null +++ b/packages/plugin-openapi/project.json @@ -0,0 +1,5 @@ +{ + "root": "packages/plugin-openapi/", + "projectType": "library", + "tags": ["type:lib"] +} diff --git a/packages/plugin-openapi/sonar-project.properties b/packages/plugin-openapi/sonar-project.properties new file mode 100644 index 000000000..2a1f36ceb --- /dev/null +++ b/packages/plugin-openapi/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.organization=mocks-server +sonar.projectKey=mocks-server_main_plugin-openapi +sonar.projectName=plugin-openapi +sonar.projectVersion=1.0.0 + +sonar.javascript.file.suffixes=.js +sonar.sourceEncoding=UTF-8 +sonar.exclusions=node_modules/**,*.config.js +sonar.test.exclusions=test/**/* +sonar.coverage.exclusions=test/**/* +sonar.cpd.exclusions=test/** +sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.host.url=https://sonarcloud.io diff --git a/packages/plugin-openapi/src/.eslintrc.json b/packages/plugin-openapi/src/.eslintrc.json new file mode 100644 index 000000000..dccaeeb3d --- /dev/null +++ b/packages/plugin-openapi/src/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./packages/plugin-openapi/tsconfig.json" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ] +} diff --git a/packages/plugin-openapi/src/Plugin.ts b/packages/plugin-openapi/src/Plugin.ts new file mode 100644 index 000000000..35bf78fdb --- /dev/null +++ b/packages/plugin-openapi/src/Plugin.ts @@ -0,0 +1,151 @@ +import type { Route, Routes, Collections, Collection, Core, MockLoaders, FilesContents, ConfigOption } from "@mocks-server/core"; + +import { openApiRoutes } from "./openapi"; +import type { OpenApiDefinition } from "./types"; + +const PLUGIN_ID = "openapi"; +const DEFAULT_FOLDER = "openapi"; + +const COLLECTION_NAMESPACE = "collection"; + +const COLLECTION_OPTIONS = [ + { + description: "Name for the collection created from OpenAPI definitions", + name: "id", + type: "string", + nullable: true, + default: "openapi", + }, + { + description: "Name of the collection to extend from", + name: "from", + type: "string" + }, +]; + +interface RoutesAndCollections { + routes: Routes, + collections: Collections, +} + +function getRoutesCollection(routes: Routes, collectionOptions?: OpenApiDefinition.Collection): Collection | null { + if (!collectionOptions) { + return null; + } + return routes.reduce((collection, route: Route) => { + if (route.variants && route.variants.length) { + collection.routes.push(`${route.id}:${route.variants[0].id}`) + } + return collection; + }, { id: collectionOptions.id, from: collectionOptions.from || null, routes: [] } as Collection); +} + +class Plugin { + static get id() { + return PLUGIN_ID; + } + + private _config: Core["config"] + private _logger: Core["logger"] + private _alerts: Core["alerts"] + private _files: Core["files"] + private _loadRoutes: MockLoaders["loadRoutes"] + private _loadCollections: MockLoaders["loadCollections"] + private _documentsAlerts: Core["alerts"] + private _collectionIdOption: ConfigOption + private _collectionFromOption: ConfigOption + + constructor({ logger, alerts, mock, files, config }: Core) { + this._config = config; + this._logger = logger; + this._alerts = alerts; + this._files = files; + + const configCollection = this._config.addNamespace(COLLECTION_NAMESPACE); + [this._collectionIdOption, this._collectionFromOption] = configCollection.addOptions(COLLECTION_OPTIONS); + + this._documentsAlerts = this._alerts.collection("documents"); + + const { loadRoutes, loadCollections } = mock.createLoaders(); + this._loadRoutes = loadRoutes; + this._loadCollections = loadCollections; + this._files.createLoader({ + id: PLUGIN_ID, + src: `${DEFAULT_FOLDER}/**/*`, + onLoad: this._onLoadFiles.bind(this), + }) + } + + async _getRoutesAndCollectionsFromFilesContents(filesContents: FilesContents): Promise { + const openApiRoutesAndCollections = await Promise.all( + filesContents.map((fileDetails) => { + const fileContent = fileDetails.content; + return fileContent.map((openAPIDefinition: OpenApiDefinition.Definition) => { + this._logger.debug(`Creating routes from openApi definition: '${JSON.stringify(openAPIDefinition)}'`); + return openApiRoutes(openAPIDefinition, { + defaultLocation: fileDetails.path, + logger: this._logger, + alerts: this._documentsAlerts + }).then((routes) => { + return { + routes, + collection: getRoutesCollection(routes, openAPIDefinition.collection) + } + }); + }); + }).flat() + ); + + return openApiRoutesAndCollections.reduce((allRoutesAndCollections, definitionRoutesAndCollections) => { + allRoutesAndCollections.routes = allRoutesAndCollections.routes.concat(definitionRoutesAndCollections.routes); + if(definitionRoutesAndCollections.collection) { + allRoutesAndCollections.collections = allRoutesAndCollections.collections.concat(definitionRoutesAndCollections.collection); + } + return allRoutesAndCollections; + }, { routes: [], collections: []}); + } + + private get _defaultCollectionOptions(): OpenApiDefinition.Collection | null { + if(!this._collectionIdOption.value) { + return null; + } + const options = { + id: this._collectionIdOption.value as string, + } as OpenApiDefinition.Collection; + + if(this._collectionFromOption.value) { + options.from = this._collectionFromOption.value as string + } + return options; + } + + async _onLoadFiles(filesContents: FilesContents) { + if (filesContents.length) { + let collectionsToLoad; + this._documentsAlerts.clean(); + const { routes, collections } = await this._getRoutesAndCollectionsFromFilesContents(filesContents); + const folderTrace = `from OpenAPI definitions found in folder '${this._files.path}/${DEFAULT_FOLDER}'`; + + this._logger.debug(`Routes to load from openApi definitions: '${JSON.stringify(routes)}'`); + this._logger.verbose(`Loading ${routes.length} routes ${folderTrace}`); + + this._loadRoutes(routes); + + this._logger.debug(`Collections created from OpenAPI definitions: '${JSON.stringify(collections)}'`); + + if (this._defaultCollectionOptions) { + const defaultCollection = getRoutesCollection(routes, this._defaultCollectionOptions); + this._logger.debug(`Collection created from all OpenAPI definitions: '${JSON.stringify(defaultCollection)}'`); + collectionsToLoad = collections.concat([defaultCollection as Collection]); + } else { + collectionsToLoad = collections; + } + + this._logger.verbose(`Loading ${collectionsToLoad.length} collections ${folderTrace}`); + + this._loadCollections(collectionsToLoad); + } + } +} + +export default Plugin; diff --git a/packages/plugin-openapi/src/constants.ts b/packages/plugin-openapi/src/constants.ts new file mode 100644 index 000000000..84be73ed2 --- /dev/null +++ b/packages/plugin-openapi/src/constants.ts @@ -0,0 +1,10 @@ +export const MOCKS_SERVER_ROUTE_ID = "x-mocks-server-route-id"; +export const MOCKS_SERVER_VARIANT_ID = "x-mocks-server-variant-id"; + +export enum VariantTypes { + JSON = "json", + TEXT = "text", + STATUS = "status" +} + +export const CONTENT_TYPE_HEADER = "Content-Type" diff --git a/packages/plugin-openapi/src/index.ts b/packages/plugin-openapi/src/index.ts new file mode 100644 index 000000000..b45172fe2 --- /dev/null +++ b/packages/plugin-openapi/src/index.ts @@ -0,0 +1,5 @@ +import Plugin from "./Plugin"; +export * from "./types"; +export * from "./openapi"; + +export default Plugin; diff --git a/packages/plugin-openapi/src/mocks-server-core.d.ts b/packages/plugin-openapi/src/mocks-server-core.d.ts new file mode 100644 index 000000000..77fa4577e --- /dev/null +++ b/packages/plugin-openapi/src/mocks-server-core.d.ts @@ -0,0 +1,129 @@ +declare module "@mocks-server/core" { + import type { NestedCollections, Item } from "@mocks-server/nested-collections"; + import type { OpenAPIV3 } from "openapi-types"; + + enum VariantTypes { + JSON = "json", + TEXT = "text", + STATUS = "status" + } + + type RouteVariantTypes = VariantTypes + + interface Logger { + verbose(message: string): void + debug(message: string): void + silly(message: string): void + } + + interface FileContents { + path: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + content: any, + } + + interface FileErrors { + path: string, + error: Error, + } + + type FilesContents = FileContents[] + type FilesErrors = FileErrors[] + + type FilesLoaderOnLoad = (filesContents: FilesContents, filesErrors: FilesErrors) => void + + interface FilesLoaderOptions { + id: string, + src: string, + onLoad: FilesLoaderOnLoad, + } + + interface Files { + createLoader(options: FilesLoaderOptions): void + path: string + } + + interface HTTPHeaders { + [header: string]: string; + } + + interface JsonVariantOptions { + status: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: object | [], + headers?: HTTPHeaders, + } + + interface TextVariantOptions { + status: number, + body: string, + headers?: HTTPHeaders, + } + + interface StatusVariantOptions { + status: number, + } + + interface RouteVariant { + id: string, + type: RouteVariantTypes + options: JsonVariantOptions | TextVariantOptions | StatusVariantOptions, + } + + type RouteVariants = RouteVariant[] | null + + interface Route { + id: string, + url: string, + method: OpenAPIV3.HttpMethods, + variants: RouteVariants, + } + + interface Collection { + id: string, + from: string, + routes: string[], + } + + interface OptionProperties { + description: string, + name: string, + type: string, + default?: unknown, + nullable?: boolean, + } + + interface ConfigOption { + value: unknown + } + + interface Config { + addNamespace(name: string): Config + addOptions(options: OptionProperties[]): ConfigOption[] + } + + type Routes = Route[] + type Collections = Collection[] + + interface MockLoaders { + loadRoutes(routes: Routes): void, + loadCollections(collections: Collections): void + } + + interface Mock { + createLoaders(): MockLoaders + } + + class Alerts extends NestedCollections { + // @ts-expect-error Nested collections must be extended in core + set(id: string, value: string, error: Error): Item + } + + interface Core { + logger: Logger + alerts: Alerts + files: Files + mock: Mock, + config: Config, + } +} diff --git a/packages/plugin-openapi/src/openapi.ts b/packages/plugin-openapi/src/openapi.ts new file mode 100644 index 000000000..8636ba121 --- /dev/null +++ b/packages/plugin-openapi/src/openapi.ts @@ -0,0 +1,252 @@ +import { OpenAPIV3 as OpenAPIV3Object } from "openapi-types"; +import { resolveRefs } from "json-refs"; + +import type { ResolvedRefsResults, UnresolvedRefDetails } from "json-refs"; +import type { Alerts, HTTPHeaders, Routes, RouteVariant, RouteVariants, RouteVariantTypes } from "@mocks-server/core"; +import type { OpenApiDefinition, OpenAPIV3 } from "./types"; + +import { MOCKS_SERVER_ROUTE_ID, MOCKS_SERVER_VARIANT_ID, VariantTypes, CONTENT_TYPE_HEADER } from "./constants"; + +const methods = Object.values(OpenAPIV3Object.HttpMethods); + +function notEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined; +} + +function replaceTemplateInPath(path: string): string { + // /api/users/{userId} -> api/users/:userId + return path.replace(/{(\S*)}/gim, ":$1") +} + +function pathToId(path: string): string { + // /api/users/{userId} -> api-users-userId + return path.replace(/^\//gim, "").replace(/\//gim, "-").replace(/[{}]/gim, "") +} + +function avoidDoubleSlashes(path: string): string { + // /api//users -> /api/users + return path.replace(/\/+/gim,"/"); +} + +function routeUrl(path: string, basePath: string): string { + if(basePath) { + return avoidDoubleSlashes(`${basePath}/${replaceTemplateInPath(path)}`); + } + return replaceTemplateInPath(path); +} + +function routeId(path: string, method: string, mocksServerId?: string): string { + if(mocksServerId) { + return mocksServerId; + } + return `${method}-${pathToId(path)}`; +} + +function isJsonMediaType(mediaType: string): boolean { + return mediaType.includes("application/json"); +} + +function isTextMediaType(mediaType: string): boolean { + return mediaType.includes("text/"); +} + +function openApiResponseBaseVariant(variantType: RouteVariantTypes, code: number, options: { customId?: string, exampleId?: string }): Partial { + let id; + if (options.customId) { + id = options.customId; + } else { + id = options.exampleId ? `${code}-${variantType}-${options.exampleId}` : `${code}-${variantType}` + } + return { + id, + type: variantType, + }; +} + +function replaceCodeWildcards(code: string): string { + return code.replace(/X/gim, "0"); +} + +function hasAnySuccessCode(codes: string[]): boolean { + return !!codes.find((code) => { + return code.startsWith("1") || code.startsWith("2") || code.startsWith("3"); + }); +} + +function getStatusCode(code: string, codes: string[]): number { + const statusCodes = codes.map(replaceCodeWildcards) + if (code === "default") { + return hasAnySuccessCode(statusCodes) ? 400 : 200; + } + return Number(replaceCodeWildcards(code)); +} + +function openApiResponseExampleToVariant(exampleId: string, code: number, variantType: RouteVariantTypes, mediaType: string, openApiResponseExample: OpenAPIV3.ExampleObject, openApiResponseHeaders?: OpenAPIV3.ResponseHeaders): RouteVariant | null { + if(!notEmpty(openApiResponseExample) || !notEmpty(openApiResponseExample.value)) { + return null; + } + + const baseVariant = openApiResponseBaseVariant(variantType, code, { exampleId, customId: openApiResponseExample[MOCKS_SERVER_VARIANT_ID] }); + + return { + ...baseVariant, + options: { + headers: { + ...openApiResponseHeaders, + [CONTENT_TYPE_HEADER]: mediaType, + }, + status: code, + body: openApiResponseExample.value + } + } as RouteVariant; +} + +function openApiResponseNoContentToVariant(code: number, openApiResponse: OpenAPIV3.ResponseObject ): RouteVariant { + const baseVariant = openApiResponseBaseVariant(VariantTypes.STATUS, code, { customId: openApiResponse[MOCKS_SERVER_VARIANT_ID] }); + return { + ...baseVariant, + options: { + headers: openApiResponse.headers as HTTPHeaders, + status: code, + } + } as RouteVariant; +} + +function openApiResponseExamplesToVariants(code: number, variantType: RouteVariantTypes, mediaType: string, openApiResponseMediaType: OpenAPIV3.MediaTypeObject, openApiResponseHeaders?: OpenAPIV3.ResponseHeaders): RouteVariants { + const examples = openApiResponseMediaType.examples; + if(!notEmpty(examples)) { + return null; + } + return Object.keys(examples).map((exampleId: string) => { + return openApiResponseExampleToVariant(exampleId, code, variantType, mediaType, examples[exampleId] as OpenAPIV3.ExampleObject , openApiResponseHeaders); + }).filter(notEmpty); +} + +function openApiResponseMediaToVariants(code: number, mediaType: string, openApiResponseMediaType?: OpenAPIV3.MediaTypeObject, openApiResponseHeaders?: OpenAPIV3.ResponseHeaders): RouteVariants { + if(!notEmpty(openApiResponseMediaType)) { + return null; + } + if(isJsonMediaType(mediaType)) { + return openApiResponseExamplesToVariants(code, VariantTypes.JSON, mediaType, openApiResponseMediaType, openApiResponseHeaders); + } + if(isTextMediaType(mediaType)) { + return openApiResponseExamplesToVariants(code, VariantTypes.TEXT, mediaType, openApiResponseMediaType, openApiResponseHeaders); + } + return null; +} + +function openApiResponseCodeToVariants(code: number, openApiResponse?: OpenAPIV3.ResponseObject): RouteVariants { + if(!notEmpty(openApiResponse)) { + return []; + } + const content = openApiResponse.content; + if(content) { + return Object.keys(content).map((mediaType: string) => { + return openApiResponseMediaToVariants(code, mediaType, content[mediaType], openApiResponse.headers); + }).flat().filter(notEmpty); + } + return [openApiResponseNoContentToVariant(code, openApiResponse)]; +} + +function routeVariants(openApiResponses?: OpenAPIV3.ResponsesObject): RouteVariants { + if(!notEmpty(openApiResponses)) { + return []; + } + const codes = Object.keys(openApiResponses); + + return codes.map((code: string) => { + const response = openApiResponses[code] as OpenAPIV3.ResponseObject; + return openApiResponseCodeToVariants(getStatusCode(code, codes), response); + }).flat().filter(notEmpty); +} + +function getCustomRouteId(openApiOperation: OpenAPIV3.OperationObject): string | undefined { + return openApiOperation[MOCKS_SERVER_ROUTE_ID] || openApiOperation.operationId; +} + +function openApiPathToRoutes(path: string, basePath = "", openApiPathObject?: OpenAPIV3.PathItemObject ): Routes | null { + if(!notEmpty(openApiPathObject)) { + return null; + } + return methods.map(method => { + if(notEmpty(openApiPathObject[method])) { + const openApiOperation = openApiPathObject[method] as OpenAPIV3.OperationObject; + return { + id: routeId(path, method, getCustomRouteId(openApiOperation)), + url: routeUrl(path, basePath), + method, + variants: routeVariants(openApiOperation.responses), + }; + } + }).filter(notEmpty); +} + +function openApiDefinitionToRoutes(openApiDefinition: OpenApiDefinition.Definition): Routes { + const openApiDocument = openApiDefinition.document; + const basePath = openApiDefinition.basePath; + + const paths = openApiDocument.paths || {}; + return Object.keys(paths).map((path: string) => { + return openApiPathToRoutes(path, basePath, paths[path]); + }).flat().filter(notEmpty); +} + +function documentRefsErrors(refsResults: ResolvedRefsResults): Error[] { + const refs = refsResults.refs; + return Object.keys(refs).map((refKey) => { + // @ts-expect-error expression of type 'string' can't be used to index type 'ResolvedRefDetails', but resolvedRefDetails.refs is in fact an object + const ref = refs[refKey] as UnresolvedRefDetails; + if(ref.error) { + return new Error(ref.error); + } + return null; + }).filter(notEmpty) +} + +function addOpenApiRefAlert(alerts: Alerts, error: Error): void { + alerts.set(String(alerts.flat.length), "Error resolving openapi $ref", error); +} + +function resolveDocumentRefs(document: OpenAPIV3.Document, refsOptions: OpenApiDefinition.Refs, { alerts, logger }: OpenApiDefinition.Options): Promise { + return resolveRefs(document, refsOptions).then((res) => { + if (logger) { + logger.silly(`Document with resolved refs: '${JSON.stringify(res)}'`); + } + const refsErrors = documentRefsErrors(res); + if (refsErrors.length) { + if(alerts) { + refsErrors.forEach((error: Error) => { + addOpenApiRefAlert(alerts, error); + }) + } else { + throw new Error(refsErrors.map((error) => error.message).join(". ")); + } + } + return res.resolved as OpenAPIV3.Document; + }).catch((error) => { + if(alerts) { + alerts.set(String(alerts.flat.length), "Error loading openapi definition", error); + return null; + } + return Promise.reject(error); + }); +} + +async function resolveOpenApiDocumentRefs(openApiDefinition: OpenApiDefinition.Definition, { defaultLocation, alerts, logger }: OpenApiDefinition.Options = {}): Promise { + const document = await resolveDocumentRefs(openApiDefinition.document, {location: defaultLocation, ...openApiDefinition.refs}, { alerts, logger }); + if(document) { + return { + ...openApiDefinition, + document, + } + } + return null; +} + +export async function openApiRoutes(openApiDefinition: OpenApiDefinition.Definition, advancedOptions?: OpenApiDefinition.Options): Promise { + const resolvedOpenApiDefinition = await resolveOpenApiDocumentRefs(openApiDefinition, advancedOptions); + if(!resolvedOpenApiDefinition) { + return []; + } + return openApiDefinitionToRoutes(resolvedOpenApiDefinition); +} diff --git a/packages/plugin-openapi/src/types.ts b/packages/plugin-openapi/src/types.ts new file mode 100644 index 000000000..2b051901f --- /dev/null +++ b/packages/plugin-openapi/src/types.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ + +import { MOCKS_SERVER_ROUTE_ID, MOCKS_SERVER_VARIANT_ID } from "./constants"; + +import type { OpenAPIV3 as OriginalOpenApiV3 } from "openapi-types"; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace OpenAPIV3 { + export interface ResponseObject extends OriginalOpenApiV3.ResponseObject { [MOCKS_SERVER_VARIANT_ID]?: string } + export interface ExampleObject extends OriginalOpenApiV3.ExampleObject { [MOCKS_SERVER_VARIANT_ID]?: string } + + + export interface ResponsesObject extends OriginalOpenApiV3.ResponsesObject {} + export interface MediaTypeObject extends OriginalOpenApiV3.MediaTypeObject {} + export interface PathItemObject extends OriginalOpenApiV3.PathItemObject {} + + export type ResponseHeaders = ResponseObject["headers"] + + export interface OperationObject extends OriginalOpenApiV3.OperationObject { [MOCKS_SERVER_ROUTE_ID]?: string } + + // eslint-disable-next-line @typescript-eslint/no-empty-interface + export type Document = OriginalOpenApiV3.Document<{ + [MOCKS_SERVER_VARIANT_ID]?: string + [MOCKS_SERVER_ROUTE_ID]?: string + }> +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace OpenApiDefinition { + export interface Options { + defaultLocation?: string, + // Add alerts type when exported by core + // eslint-disable-next-line @typescript-eslint/no-explicit-any + alerts?: any, + // Add alerts type when exported by core + // eslint-disable-next-line @typescript-eslint/no-explicit-any + logger?: any + } + + export interface Collection { + id: string, + from?: string, + } + + export interface Refs { + location?: string, + subDocPath?: string, + } + + export interface Definition { + basePath: string, + refs?: Refs, + collection?: Collection, + document: OpenAPIV3.Document + } +} diff --git a/packages/plugin-openapi/test/.eslintrc.cjs b/packages/plugin-openapi/test/.eslintrc.cjs new file mode 100644 index 000000000..41e1c0653 --- /dev/null +++ b/packages/plugin-openapi/test/.eslintrc.cjs @@ -0,0 +1,8 @@ +module.exports = { + parser: "@babel/eslint-parser", + parserOptions: { + sourceType: "module", + allowImportExportEverywhere: true, + requireConfigFile: false, + }, +}; diff --git a/packages/plugin-openapi/test/Plugin.spec.js b/packages/plugin-openapi/test/Plugin.spec.js new file mode 100644 index 000000000..6ea7798a9 --- /dev/null +++ b/packages/plugin-openapi/test/Plugin.spec.js @@ -0,0 +1,9 @@ +import Plugin from "../src/Plugin"; + +describe("Plugin", () => { + describe("id", () => { + it("should be openapi", () => { + expect(Plugin.id).toEqual("openapi"); + }); + }); +}); diff --git a/packages/plugin-openapi/test/code-wildcards.spec.js b/packages/plugin-openapi/test/code-wildcards.spec.js new file mode 100644 index 000000000..db6b93cc2 --- /dev/null +++ b/packages/plugin-openapi/test/code-wildcards.spec.js @@ -0,0 +1,114 @@ +import { startServer, fetchJson, fetchText, waitForServer } from "./support/helpers"; + +describe("when openapi codes include have wildcards", () => { + let server; + + describe("when fixture is api-users", () => { + beforeAll(async () => { + server = await startServer("code-wildcards"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "get-users", + url: "/api/users", + method: "get", + delay: null, + variants: ["get-users:200-json-one-user", "get-users:200-json-two-users"], + }, + { + id: "post-users", + url: "/api/users", + method: "post", + delay: null, + variants: ["post-users:201-status", "post-users:400-text-error-message"], + }, + { + id: "get-users-id", + url: "/api/users/:id", + method: "get", + delay: null, + variants: ["get-users-id:404-json-not-found", "get-users-id:200-json-success"], + }, + ]); + }); + }); + + describe("get-users route", () => { + it("should have 200-json-one-user variant available in base collection", async () => { + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in all-users collection", async () => { + await server.mock.collections.select("all-users", { check: true }); + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + + describe("post-users route", () => { + it("should have 201-status variant available in all-users collection", async () => { + const response = await fetchJson("/api/users", { + method: "POST", + }); + expect(response.body).toBe(undefined); + expect(response.status).toEqual(201); + }); + + it("should have 400-text-error-message variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchText("/api/users", { + method: "POST", + }); + expect(response.body).toBe("Bad data"); + expect(response.status).toEqual(400); + }); + }); + + describe("get-users-id route", () => { + it("should have 200-json-success variant available in base collection", async () => { + await server.mock.collections.select("base", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + id: 1, + name: "John Doe", + }); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + code: 404, + message: "Not found", + }); + expect(response.status).toEqual(404); + }); + }); + }); +}); diff --git a/packages/plugin-openapi/test/collections.spec.js b/packages/plugin-openapi/test/collections.spec.js new file mode 100644 index 000000000..08d7c7853 --- /dev/null +++ b/packages/plugin-openapi/test/collections.spec.js @@ -0,0 +1,358 @@ +import { startServer, fetchJson, waitForServer } from "./support/helpers"; + +describe("generated collections", () => { + let server; + + describe("when collection id is provided in definition", () => { + beforeAll(async () => { + server = await startServer("api-users-collection", { + mock: { + collections: { + selected: "users", + }, + }, + }); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("collections", () => { + it("should have created collections with routes from openapi definition", async () => { + expect(server.mock.collections.plain).toEqual([ + { + id: "users", + from: null, + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + routes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + { + id: "openapi", + from: null, + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + routes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + ]); + }); + }); + + describe("get-users route", () => { + it("should have 200-json-one-user variant available in users collection", async () => { + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + + describe("post-users route", () => { + it("should have 201-status variant available in users collection", async () => { + const response = await fetchJson("/api/users", { + method: "POST", + }); + expect(response.body).toBe(undefined); + expect(response.status).toEqual(201); + }); + }); + + describe("get-users-id route", () => { + it("should have 200-json-success variant available in users collection", async () => { + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + id: 1, + name: "John Doe", + }); + expect(response.status).toEqual(200); + }); + }); + }); + + describe("when route has no variants", () => { + beforeAll(async () => { + server = await startServer("api-users-collection-no-variants", { + mock: { + collections: { + selected: "users", + }, + }, + }); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it("should have omitted routes without any variant in collecetions", async () => { + expect(server.mock.collections.plain).toEqual([ + { + id: "users", + from: null, + definedRoutes: ["post-users:201-status", "get-users-id:200-json-success"], + routes: ["post-users:201-status", "get-users-id:200-json-success"], + }, + { + id: "openapi", + from: null, + definedRoutes: ["post-users:201-status", "get-users-id:200-json-success"], + routes: ["post-users:201-status", "get-users-id:200-json-success"], + }, + ]); + }); + }); + + describe("default collection", () => { + beforeAll(async () => { + server = await startServer("multiple-definitions", { + mock: { + collections: { + selected: "openapi", + }, + }, + }); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it("should include routes from all OpenAPI definitions", async () => { + expect(server.mock.collections.plain).toEqual([ + { + id: "openapi", + from: null, + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + "read-users:one-user", + "create-user:success", + "read-user:success", + ], + routes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + "read-users:one-user", + "create-user:success", + "read-user:success", + ], + }, + ]); + }); + }); + + describe("when default collection ID is set to null", () => { + beforeAll(async () => { + server = await startServer("api-users-collection", { + mock: { + collections: { + selected: "users", + }, + }, + plugins: { + openapi: { + collection: { + id: null, + }, + }, + }, + }); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it("should have not created default collection", async () => { + expect(server.mock.collections.plain).toEqual([ + { + id: "users", + from: null, + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + routes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + ]); + }); + }); + + describe("when default collection ID is set in config", () => { + beforeAll(async () => { + server = await startServer("api-users-collection", { + mock: { + collections: { + selected: "users", + }, + }, + plugins: { + openapi: { + collection: { + id: "custom-collection", + }, + }, + }, + }); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it("should have set collection id", async () => { + expect(server.mock.collections.plain).toEqual([ + { + id: "users", + from: null, + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + routes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + { + id: "custom-collection", + from: null, + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + routes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + ]); + }); + }); + + describe("when default collection extends from another collection", () => { + beforeAll(async () => { + server = await startServer("api-users-collection-from", { + mock: { + collections: { + selected: "openapi", + }, + }, + plugins: { + openapi: { + collection: { + from: "users", + }, + }, + }, + }); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it("should include routes from the other collection and have the from property", async () => { + expect(server.mock.collections.plain).toEqual([ + { + id: "base", + from: null, + definedRoutes: ["get-books:success"], + routes: ["get-books:success"], + }, + { + id: "users", + from: "base", + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + routes: [ + "get-books:success", + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + { + id: "openapi", + from: "users", + definedRoutes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + routes: [ + "get-books:success", + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + ]); + }); + + describe("get-users route", () => { + it("should have 200-json-one-user variant available in users collection", async () => { + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + + describe("get-books route", () => { + it("should be also available", async () => { + const response = await fetchJson("/api/books"); + expect(response.body).toEqual([ + { + title: "1984", + author: "George Orwell", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + }); +}); diff --git a/packages/plugin-openapi/test/custom-headers.spec.js b/packages/plugin-openapi/test/custom-headers.spec.js new file mode 100644 index 000000000..095e44286 --- /dev/null +++ b/packages/plugin-openapi/test/custom-headers.spec.js @@ -0,0 +1,91 @@ +import { startServer, fetchJson, fetchText, waitForServer } from "./support/helpers"; + +describe("when openapi response has headers", () => { + let server; + + beforeAll(async () => { + server = await startServer("custom-headers"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "read-users", + url: "/api/users", + method: "get", + delay: null, + variants: ["read-users:one-user", "read-users:two-users"], + }, + { + id: "create-user", + url: "/api/users", + method: "post", + delay: null, + variants: ["create-user:success", "create-user:error"], + }, + ]); + }); + }); + + describe("get-users route", () => { + it("should have response headers in base collection", async () => { + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + expect(response.headers.get("x-custom-header-read-users")).toEqual("read-users-value"); + expect(response.headers.get("Content-Type")).toEqual("application/json; charset=utf-8"); + }); + + it("should have response headers in all-users collection", async () => { + await server.mock.collections.select("all-users", { check: true }); + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ]); + expect(response.status).toEqual(200); + expect(response.headers.get("x-custom-header-read-users")).toEqual("read-users-value"); + expect(response.headers.get("Content-Type")).toEqual("application/json; charset=utf-8"); + }); + }); + + describe("post-users route", () => { + it("should have response headers in all-users collection", async () => { + const response = await fetchJson("/api/users", { + method: "POST", + }); + expect(response.body).toBe(undefined); + expect(response.status).toEqual(201); + expect(response.headers.get("x-custom-header-create-user")).toEqual("create-user-value"); + expect(response.headers.get("x-custom-header-create-user-2")).toEqual("create-user-value-2"); + expect(response.headers.get("Content-Type")).toEqual(null); + }); + + it("should have text/html header in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchText("/api/users", { + method: "POST", + }); + expect(response.body).toEqual("
Error
"); + expect(response.status).toEqual(400); + expect(response.headers.get("Content-Type")).toEqual("text/html; charset=utf-8"); + }); + }); +}); diff --git a/packages/plugin-openapi/test/custom-ids.spec.js b/packages/plugin-openapi/test/custom-ids.spec.js new file mode 100644 index 000000000..5a478cabf --- /dev/null +++ b/packages/plugin-openapi/test/custom-ids.spec.js @@ -0,0 +1,112 @@ +import { startServer, fetchJson, fetchText, waitForServer } from "./support/helpers"; + +describe("when openapi has custom ids", () => { + let server; + + beforeAll(async () => { + server = await startServer("custom-ids"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "read-users", + url: "/api/users", + method: "get", + delay: null, + variants: ["read-users:one-user", "read-users:two-users"], + }, + { + id: "create-user", + url: "/api/users", + method: "post", + delay: null, + variants: ["create-user:success", "create-user:error"], + }, + { + id: "read-user", + url: "/api/users/:id", + method: "get", + delay: null, + variants: ["read-user:success", "read-user:not-found"], + }, + ]); + }); + }); + + describe("get-users route", () => { + it("should have 200-json-one-user variant available in base collection", async () => { + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in all-users collection", async () => { + await server.mock.collections.select("all-users", { check: true }); + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + + describe("post-users route", () => { + it("should have 201-status variant available in all-users collection", async () => { + const response = await fetchJson("/api/users", { + method: "POST", + }); + expect(response.body).toBe(undefined); + expect(response.status).toEqual(201); + }); + + it("should have 400-text-error-message variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchText("/api/users", { + method: "POST", + }); + expect(response.body).toBe("Bad data"); + expect(response.status).toEqual(400); + }); + }); + + describe("get-users-id route", () => { + it("should have 200-json-success variant available in base collection", async () => { + await server.mock.collections.select("base", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + id: 1, + name: "John Doe", + }); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + code: 404, + message: "Not found", + }); + expect(response.status).toEqual(404); + }); + }); +}); diff --git a/packages/plugin-openapi/test/fixtures/api-users-collection-from/collections.js b/packages/plugin-openapi/test/fixtures/api-users-collection-from/collections.js new file mode 100644 index 000000000..19788a99e --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users-collection-from/collections.js @@ -0,0 +1,6 @@ +module.exports = [ + { + id: "base", + routes: ["get-books:success"], + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/api-users-collection-from/openapi/api.js b/packages/plugin-openapi/test/fixtures/api-users-collection-from/openapi/api.js new file mode 100644 index 000000000..98e3253cf --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users-collection-from/openapi/api.js @@ -0,0 +1,12 @@ +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + basePath: "/api", + collection: { + id: "users", + from: "base", + }, + document: openApiDocument, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/api-users-collection-from/routes/routes.js b/packages/plugin-openapi/test/fixtures/api-users-collection-from/routes/routes.js new file mode 100644 index 000000000..6dd28e4c9 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users-collection-from/routes/routes.js @@ -0,0 +1,22 @@ +module.exports = [ + { + id: "get-books", + url: "/api/books", + method: "get", + variants: [ + { + id: "success", + type: "json", + options: { + status: 200, + body: [ + { + title: "1984", + author: "George Orwell", + }, + ], + }, + }, + ], + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/api-users-collection-no-variants/collections.js b/packages/plugin-openapi/test/fixtures/api-users-collection-no-variants/collections.js new file mode 100644 index 000000000..e0a30c5df --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users-collection-no-variants/collections.js @@ -0,0 +1 @@ +module.exports = []; diff --git a/packages/plugin-openapi/test/fixtures/api-users-collection-no-variants/openapi/api.js b/packages/plugin-openapi/test/fixtures/api-users-collection-no-variants/openapi/api.js new file mode 100644 index 000000000..d85ce3307 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users-collection-no-variants/openapi/api.js @@ -0,0 +1,29 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + basePath: "/api", + collection: { + id: "users", + }, + document: deepMerge(openApiDocument, { + paths: { + "/users": { + get: { + responses: { + "200": { + content: { + "application/json": { + examples: undefined, + }, + }, + }, + }, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/api-users-collection/collections.js b/packages/plugin-openapi/test/fixtures/api-users-collection/collections.js new file mode 100644 index 000000000..e0a30c5df --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users-collection/collections.js @@ -0,0 +1 @@ +module.exports = []; diff --git a/packages/plugin-openapi/test/fixtures/api-users-collection/openapi/api.js b/packages/plugin-openapi/test/fixtures/api-users-collection/openapi/api.js new file mode 100644 index 000000000..24b90e20c --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users-collection/openapi/api.js @@ -0,0 +1,11 @@ +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + basePath: "/api", + collection: { + id: "users", + }, + document: openApiDocument, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/api-users/collections.js b/packages/plugin-openapi/test/fixtures/api-users/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/api-users/openapi/api.js b/packages/plugin-openapi/test/fixtures/api-users/openapi/api.js new file mode 100644 index 000000000..a27c01bdf --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/api-users/openapi/api.js @@ -0,0 +1,8 @@ +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + basePath: "/api", + document: openApiDocument, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/code-wildcards/collections.js b/packages/plugin-openapi/test/fixtures/code-wildcards/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/code-wildcards/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/code-wildcards/openapi/api.js b/packages/plugin-openapi/test/fixtures/code-wildcards/openapi/api.js new file mode 100644 index 000000000..93897032a --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/code-wildcards/openapi/api.js @@ -0,0 +1,128 @@ +module.exports = [ + { + basePath: "/api", + document: { + openapi: "3.1.0", + info: { + title: "Testing API", + version: "1.0.0", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/users": { + get: { + summary: "Return all users", + description: "Use it to get current users", + responses: { + default: { + description: "successful operation", + content: { + "application/json": { + examples: { + "one-user": { + summary: "One route", + value: [ + { + id: 1, + name: "John Doe", + }, + ], + }, + "two-users": { + summary: "Two users", + value: [ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ], + }, + }, + }, + }, + }, + }, + }, + post: { + summary: "Create an user", + responses: { + "201": { + description: "successful operation", + }, + default: { + description: "bad data", + content: { + "text/plain": { + examples: { + "error-message": { + summary: "Error message", + value: "Bad data", + }, + }, + }, + }, + }, + }, + }, + }, + "/users/{id}": { + get: { + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + "2XX": { + description: "successful operation", + content: { + "application/json": { + examples: { + success: { + summary: "One user", + value: { + id: 1, + name: "John Doe", + }, + }, + }, + }, + }, + }, + "404": { + description: "user not found", + content: { + "application/json": { + examples: { + "not-found": { + summary: "Not found error", + value: { + code: 404, + message: "Not found", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/custom-headers/collections.js b/packages/plugin-openapi/test/fixtures/custom-headers/collections.js new file mode 100644 index 000000000..2151a20ab --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/custom-headers/collections.js @@ -0,0 +1 @@ +module.exports = require("../custom-ids/collections"); diff --git a/packages/plugin-openapi/test/fixtures/custom-headers/openapi/api.js b/packages/plugin-openapi/test/fixtures/custom-headers/openapi/api.js new file mode 100644 index 000000000..92f881db9 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/custom-headers/openapi/api.js @@ -0,0 +1,49 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../custom-ids/openapi/api")[0].document; + +module.exports = [ + { + basePath: "/api", + document: deepMerge(openApiDocument, { + paths: { + "/users": { + get: { + responses: { + "200": { + headers: { + "x-custom-header-read-users": "read-users-value", + "Content-Type": "foo", + }, + }, + }, + }, + post: { + responses: { + "201": { + headers: { + "x-custom-header-create-user": "create-user-value", + "x-custom-header-create-user-2": "create-user-value-2", + }, + }, + "400": { + content: { + "text/html": { + examples: { + "error-message": { + "x-mocks-server-variant-id": "error", + value: "
Error
", + }, + }, + }, + "text/plain": undefined, + }, + }, + }, + }, + }, + "/users/{id}": undefined, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/custom-ids/collections.js b/packages/plugin-openapi/test/fixtures/custom-ids/collections.js new file mode 100644 index 000000000..04e1fa23e --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/custom-ids/collections.js @@ -0,0 +1,16 @@ +module.exports = [ + { + id: "base", + routes: ["read-users:one-user", "create-user:success", "read-user:success"], + }, + { + id: "all-users", + from: "base", + routes: ["read-users:two-users"], + }, + { + id: "users-error", + from: "base", + routes: ["create-user:error", "read-user:not-found"], + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/custom-ids/openapi/api.js b/packages/plugin-openapi/test/fixtures/custom-ids/openapi/api.js new file mode 100644 index 000000000..4c2622b97 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/custom-ids/openapi/api.js @@ -0,0 +1,82 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + basePath: "/api", + document: deepMerge(openApiDocument, { + paths: { + "/users": { + get: { + "x-mocks-server-route-id": "read-users", + responses: { + "200": { + content: { + "application/json": { + examples: { + "one-user": { + "x-mocks-server-variant-id": "one-user", + }, + "two-users": { + "x-mocks-server-variant-id": "two-users", + }, + }, + }, + }, + }, + }, + }, + post: { + operationId: "create-user", + responses: { + "201": { + "x-mocks-server-variant-id": "success", + }, + "400": { + content: { + "text/plain": { + examples: { + "error-message": { + "x-mocks-server-variant-id": "error", + }, + }, + }, + }, + }, + }, + }, + }, + "/users/{id}": { + get: { + "x-mocks-server-route-id": "read-user", + responses: { + "200": { + content: { + "application/json": { + examples: { + success: { + "x-mocks-server-variant-id": "success", + }, + }, + }, + }, + }, + "404": { + content: { + "application/json": { + examples: { + "not-found": { + "x-mocks-server-variant-id": "not-found", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/empty-example/collections.js b/packages/plugin-openapi/test/fixtures/empty-example/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-example/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/empty-example/openapi/api.js b/packages/plugin-openapi/test/fixtures/empty-example/openapi/api.js new file mode 100644 index 000000000..39da986ab --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-example/openapi/api.js @@ -0,0 +1,31 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": { + post: undefined, + get: { + responses: { + "200": { + content: { + "application/json": { + examples: { + "one-user": undefined, + "two-users": { + value: undefined, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/empty-examples/collections.js b/packages/plugin-openapi/test/fixtures/empty-examples/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-examples/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/empty-examples/openapi/api.js b/packages/plugin-openapi/test/fixtures/empty-examples/openapi/api.js new file mode 100644 index 000000000..6651b44a9 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-examples/openapi/api.js @@ -0,0 +1,26 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": { + post: undefined, + get: { + responses: { + "200": { + content: { + "application/json": { + examples: undefined, + }, + }, + }, + }, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/empty-media/collections.js b/packages/plugin-openapi/test/fixtures/empty-media/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-media/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/empty-media/openapi/api.js b/packages/plugin-openapi/test/fixtures/empty-media/openapi/api.js new file mode 100644 index 000000000..d8c789f69 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-media/openapi/api.js @@ -0,0 +1,24 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": { + post: undefined, + get: { + responses: { + "200": { + content: { + "application/json": undefined, + }, + }, + }, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/empty-method/collections.js b/packages/plugin-openapi/test/fixtures/empty-method/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-method/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/empty-method/openapi/api.js b/packages/plugin-openapi/test/fixtures/empty-method/openapi/api.js new file mode 100644 index 000000000..ab6abbde5 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-method/openapi/api.js @@ -0,0 +1,16 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": { + get: undefined, + post: undefined, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/empty-path/collections.js b/packages/plugin-openapi/test/fixtures/empty-path/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-path/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/empty-path/openapi/api.js b/packages/plugin-openapi/test/fixtures/empty-path/openapi/api.js new file mode 100644 index 000000000..6b42dd979 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-path/openapi/api.js @@ -0,0 +1,13 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": undefined, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/empty-response/collections.js b/packages/plugin-openapi/test/fixtures/empty-response/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-response/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/empty-response/openapi/api.js b/packages/plugin-openapi/test/fixtures/empty-response/openapi/api.js new file mode 100644 index 000000000..14deb2c5d --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-response/openapi/api.js @@ -0,0 +1,25 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": { + post: { + responses: { + "201": undefined, + "400": undefined, + }, + }, + get: { + responses: { + "200": undefined, + }, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/empty-responses/collections.js b/packages/plugin-openapi/test/fixtures/empty-responses/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-responses/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/empty-responses/openapi/api.js b/packages/plugin-openapi/test/fixtures/empty-responses/openapi/api.js new file mode 100644 index 000000000..eb999cf79 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/empty-responses/openapi/api.js @@ -0,0 +1,20 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": { + post: { + responses: undefined, + }, + get: { + responses: undefined, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/multiple-definitions/collections.js b/packages/plugin-openapi/test/fixtures/multiple-definitions/collections.js new file mode 100644 index 000000000..e0a30c5df --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/multiple-definitions/collections.js @@ -0,0 +1 @@ +module.exports = []; diff --git a/packages/plugin-openapi/test/fixtures/multiple-definitions/openapi/api.js b/packages/plugin-openapi/test/fixtures/multiple-definitions/openapi/api.js new file mode 100644 index 000000000..53eba05d6 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/multiple-definitions/openapi/api.js @@ -0,0 +1,13 @@ +const openApiDocument = require("../../../openapi/users"); +const openApiDocumentCustomIds = require("../../custom-ids/openapi/api")[0]; + +module.exports = [ + { + basePath: "/api", + document: openApiDocument, + }, + { + ...openApiDocumentCustomIds, + basePath: "/api-2", + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/no-paths/collections.js b/packages/plugin-openapi/test/fixtures/no-paths/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/no-paths/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/no-paths/openapi/api.js b/packages/plugin-openapi/test/fixtures/no-paths/openapi/api.js new file mode 100644 index 000000000..05c93c9a9 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/no-paths/openapi/api.js @@ -0,0 +1,11 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: undefined, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/refs-files/collections.js b/packages/plugin-openapi/test/fixtures/refs-files/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-files/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/refs-files/openapi-refs/user.json b/packages/plugin-openapi/test/fixtures/refs-files/openapi-refs/user.json new file mode 100644 index 000000000..17929514d --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-files/openapi-refs/user.json @@ -0,0 +1,16 @@ +{ + "description": "successful operation", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "One user", + "value": { + "id": 1, + "name": "John Doe" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/plugin-openapi/test/fixtures/refs-files/openapi-refs/users.json b/packages/plugin-openapi/test/fixtures/refs-files/openapi-refs/users.json new file mode 100644 index 000000000..17fd2b221 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-files/openapi-refs/users.json @@ -0,0 +1,55 @@ +{ + "get": { + "summary": "Return all users", + "description": "Use it to get current users", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "examples": { + "one-user": { + "summary": "One route", + "value": [{ + "id": 1, + "name": "John Doe" + }] + }, + "two-users": { + "summary": "Two users", + "value": [{ + "id": 1, + "name": "John Doe" + }, { + "id": 2, + "name": "Jane Doe" + }] + } + } + } + } + } + } + }, + "post": { + "summary": "Create an user", + "responses": { + "201": { + "description": "successful operation" + }, + "400": { + "description": "bad data", + "content": { + "text/plain": { + "examples": { + "error-message": { + "summary": "Error message", + "value": "Bad data" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/plugin-openapi/test/fixtures/refs-files/openapi/api.js b/packages/plugin-openapi/test/fixtures/refs-files/openapi/api.js new file mode 100644 index 000000000..5c8a404ff --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-files/openapi/api.js @@ -0,0 +1,65 @@ +module.exports = [ + { + basePath: "/api", + document: { + openapi: "3.1.0", + info: { + title: "Testing API", + version: "1.0.0", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/users": { + $ref: "../openapi-refs/users.json", + }, + "/users/{id}": { + get: { + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + "200": { + $ref: "../openapi-refs/user.json", + }, + "404": { + description: "user not found", + content: { + "application/json": { + examples: { + "not-found": { + $ref: "#/components/examples/NotFound", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + examples: { + NotFound: { + summary: "Not found error", + value: { + code: 404, + message: "Not found", + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/refs-remote-server/collections.yaml b/packages/plugin-openapi/test/fixtures/refs-remote-server/collections.yaml new file mode 100644 index 000000000..3c2610085 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-remote-server/collections.yaml @@ -0,0 +1,3 @@ +- id: "base" + routes: + - static:success \ No newline at end of file diff --git a/packages/plugin-openapi/test/fixtures/refs-remote-server/routes/refs.js b/packages/plugin-openapi/test/fixtures/refs-remote-server/routes/refs.js new file mode 100644 index 000000000..1833ccb7e --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-remote-server/routes/refs.js @@ -0,0 +1,17 @@ +const path = require("path"); + +module.exports = [ + { + id: "static", + url: "/", + variants: [ + { + id: "success", + type: "static", + options: { + path: path.resolve(__dirname, "..", "static"), + }, + }, + ], + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/refs-remote-server/static/openapi.json b/packages/plugin-openapi/test/fixtures/refs-remote-server/static/openapi.json new file mode 100644 index 000000000..7344ca8d1 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-remote-server/static/openapi.json @@ -0,0 +1,58 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Testing API", + "version": "1.0.0", + "description": "OpenApi document to create mock for testing purpses", + "contact": { + "email": "info@mocks-server.org" + } + }, + "paths": { + "/users": { + "$ref": "http://127.0.0.1:3200/users.json" + }, + "/users/{id}": { + "get": { + "parameters": [{ + "name": "id", + "in": "path", + "description": "ID the user", + "required": true, + "schema": { + "type": "string" + } + }], + "summary": "Return one user", + "responses": { + "200": { + "$ref": "http://127.0.0.1:3200/user.json" + }, + "404": { + "description": "user not found", + "content": { + "application/json": { + "examples": { + "not-found": { + "$ref": "#/components/examples/NotFound" + } + } + } + } + } + } + } + } + }, + "components": { + "examples": { + "NotFound": { + "summary": "Not found error", + "value": { + "code": 404, + "message": "Not found" + } + } + } + } +} diff --git a/packages/plugin-openapi/test/fixtures/refs-remote-server/static/user.json b/packages/plugin-openapi/test/fixtures/refs-remote-server/static/user.json new file mode 100644 index 000000000..17929514d --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-remote-server/static/user.json @@ -0,0 +1,16 @@ +{ + "description": "successful operation", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "One user", + "value": { + "id": 1, + "name": "John Doe" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/plugin-openapi/test/fixtures/refs-remote-server/static/users.json b/packages/plugin-openapi/test/fixtures/refs-remote-server/static/users.json new file mode 100644 index 000000000..17fd2b221 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-remote-server/static/users.json @@ -0,0 +1,55 @@ +{ + "get": { + "summary": "Return all users", + "description": "Use it to get current users", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "examples": { + "one-user": { + "summary": "One route", + "value": [{ + "id": 1, + "name": "John Doe" + }] + }, + "two-users": { + "summary": "Two users", + "value": [{ + "id": 1, + "name": "John Doe" + }, { + "id": 2, + "name": "Jane Doe" + }] + } + } + } + } + } + } + }, + "post": { + "summary": "Create an user", + "responses": { + "201": { + "description": "successful operation" + }, + "400": { + "description": "bad data", + "content": { + "text/plain": { + "examples": { + "error-message": { + "summary": "Error message", + "value": "Bad data" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/plugin-openapi/test/fixtures/refs-remote/collections.js b/packages/plugin-openapi/test/fixtures/refs-remote/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-remote/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/refs-remote/openapi/api.js b/packages/plugin-openapi/test/fixtures/refs-remote/openapi/api.js new file mode 100644 index 000000000..5e2792428 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs-remote/openapi/api.js @@ -0,0 +1,65 @@ +module.exports = [ + { + basePath: "/api", + document: { + openapi: "3.1.0", + info: { + title: "Testing API", + version: "1.0.0", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/users": { + $ref: "http://127.0.0.1:3200/users.json", + }, + "/users/{id}": { + get: { + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + "200": { + $ref: "http://127.0.0.1:3200/user.json", + }, + "404": { + description: "user not found", + content: { + "application/json": { + examples: { + "not-found": { + $ref: "#/components/examples/NotFound", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + examples: { + NotFound: { + summary: "Not found error", + value: { + code: 404, + message: "Not found", + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/refs/collections.js b/packages/plugin-openapi/test/fixtures/refs/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/refs/openapi/api.js b/packages/plugin-openapi/test/fixtures/refs/openapi/api.js new file mode 100644 index 000000000..b679e7ab6 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/refs/openapi/api.js @@ -0,0 +1,145 @@ +module.exports = [ + { + basePath: "/api", + document: { + openapi: "3.1.0", + info: { + title: "Testing API", + version: "1.0.0", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/users": { + $ref: "#/components/pathItems/Users", + }, + "/users/{id}": { + get: { + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + "200": { + $ref: "#/components/responses/User", + }, + "404": { + description: "user not found", + content: { + "application/json": { + examples: { + "not-found": { + $ref: "#/components/examples/NotFound", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + pathItems: { + Users: { + get: { + summary: "Return all users", + description: "Use it to get current users", + responses: { + "200": { + description: "successful operation", + content: { + "application/json": { + examples: { + "one-user": { + summary: "One route", + value: [ + { + id: 1, + name: "John Doe", + }, + ], + }, + "two-users": { + summary: "Two users", + value: [ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ], + }, + }, + }, + }, + }, + }, + }, + post: { + summary: "Create an user", + responses: { + "201": { + description: "successful operation", + }, + "400": { + description: "bad data", + content: { + "text/plain": { + examples: { + "error-message": { + summary: "Error message", + value: "Bad data", + }, + }, + }, + }, + }, + }, + }, + }, + }, + responses: { + User: { + description: "successful operation", + content: { + "application/json": { + examples: { + success: { + summary: "One user", + value: { + id: 1, + name: "John Doe", + }, + }, + }, + }, + }, + }, + }, + examples: { + NotFound: { + summary: "Not found error", + value: { + code: 404, + message: "Not found", + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/unknown-media/collections.js b/packages/plugin-openapi/test/fixtures/unknown-media/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/unknown-media/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/unknown-media/openapi/api.js b/packages/plugin-openapi/test/fixtures/unknown-media/openapi/api.js new file mode 100644 index 000000000..5f4e4d3c5 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/unknown-media/openapi/api.js @@ -0,0 +1,28 @@ +const deepMerge = require("deepmerge"); + +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: deepMerge(openApiDocument, { + paths: { + "/users": { + post: undefined, + get: { + responses: { + "200": { + content: { + "application/json": undefined, + "application/foo": + openApiDocument.paths["/users"].get.responses["200"].content[ + "application/json" + ], + }, + }, + }, + }, + }, + }, + }), + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/users/collections.js b/packages/plugin-openapi/test/fixtures/users/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/users/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/users/openapi/api.js b/packages/plugin-openapi/test/fixtures/users/openapi/api.js new file mode 100644 index 000000000..03368796e --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/users/openapi/api.js @@ -0,0 +1,7 @@ +const openApiDocument = require("../../../openapi/users"); + +module.exports = [ + { + document: openApiDocument, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/wrong-refs-options/collections.js b/packages/plugin-openapi/test/fixtures/wrong-refs-options/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/wrong-refs-options/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/wrong-refs-options/openapi/api.js b/packages/plugin-openapi/test/fixtures/wrong-refs-options/openapi/api.js new file mode 100644 index 000000000..cc06051f6 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/wrong-refs-options/openapi/api.js @@ -0,0 +1,143 @@ +module.exports = [ + { + basePath: "/api", + refs: { + subDocPath: "dasd", + location: "foo.json", + }, + document: { + openapi: "3.1.0", + info: { + title: "Testing API", + version: "1.0.0", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/users": { + $ref: "#/componednts/pathItems/Users", + }, + "/users/{id}": { + get: { + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + "200": { + $ref: "#/components/responses/User", + }, + "404": { + description: "user not found", + content: { + "application/json": { + examples: { + "not-found": { + $ref: "#/components/examples/NotFound", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + pathItems: { + Users: { + get: { + summary: "Return all users", + description: "Use it to get current users", + responses: { + "200": { + description: "successful operation", + content: { + "application/json": { + examples: { + "one-user": { + $ref: 3, + }, + "two-users": { + summary: "Two users", + value: [ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ], + }, + }, + }, + }, + }, + }, + }, + post: { + summary: "Create an user", + responses: { + "201": { + description: "successful operation", + }, + "400": { + description: "bad data", + content: { + "text/plain": { + examples: { + "error-message": { + summary: "Error message", + value: "Bad data", + }, + }, + }, + }, + }, + }, + }, + }, + }, + responses: { + User: { + description: "successful operation", + content: { + "application/json": { + examples: { + success: { + summary: "One user", + value: { + id: 1, + name: "John Doe", + }, + }, + }, + }, + }, + }, + }, + examples: { + NotFound: { + summary: "Not found error", + value: { + code: 404, + message: "Not found", + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/plugin-openapi/test/fixtures/wrong-refs/collections.js b/packages/plugin-openapi/test/fixtures/wrong-refs/collections.js new file mode 100644 index 000000000..a65111041 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/wrong-refs/collections.js @@ -0,0 +1 @@ +module.exports = require("../../openapi/users-collection"); diff --git a/packages/plugin-openapi/test/fixtures/wrong-refs/openapi/api.js b/packages/plugin-openapi/test/fixtures/wrong-refs/openapi/api.js new file mode 100644 index 000000000..592358ffb --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/wrong-refs/openapi/api.js @@ -0,0 +1,139 @@ +module.exports = [ + { + basePath: "/api", + document: { + openapi: "3.1.0", + info: { + title: "Testing API", + version: "1.0.0", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/users": { + $ref: "#/componednts/pathItems/Users", + }, + "/users/{id}": { + get: { + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + "200": { + $ref: "#/components/responses/User", + }, + "404": { + description: "user not found", + content: { + "application/json": { + examples: { + "not-found": { + $ref: "#/components/examples/NotFound", + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + pathItems: { + Users: { + get: { + summary: "Return all users", + description: "Use it to get current users", + responses: { + "200": { + description: "successful operation", + content: { + "application/json": { + examples: { + "one-user": { + $ref: 3, + }, + "two-users": { + summary: "Two users", + value: [ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ], + }, + }, + }, + }, + }, + }, + }, + post: { + summary: "Create an user", + responses: { + "201": { + description: "successful operation", + }, + "400": { + description: "bad data", + content: { + "text/plain": { + examples: { + "error-message": { + summary: "Error message", + value: "Bad data", + }, + }, + }, + }, + }, + }, + }, + }, + }, + responses: { + User: { + description: "successful operation", + content: { + "application/json": { + examples: { + success: { + summary: "One user", + value: { + id: 1, + name: "John Doe", + }, + }, + }, + }, + }, + }, + }, + examples: { + NotFound: { + summary: "Not found error", + value: { + code: 404, + message: "Not found", + }, + }, + }, + }, + }, + }, +]; diff --git a/packages/plugin-openapi/test/incomplete-openapi.spec.js b/packages/plugin-openapi/test/incomplete-openapi.spec.js new file mode 100644 index 000000000..369487d1e --- /dev/null +++ b/packages/plugin-openapi/test/incomplete-openapi.spec.js @@ -0,0 +1,57 @@ +import { startServer, waitForServer } from "./support/helpers"; + +describe("when openapi has not enough properties", () => { + let server; + + describe("when openapi has no paths property", () => { + beforeAll(async () => { + server = await startServer("no-paths"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it("should have not created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([]); + }); + }); + + function checkOnlyUsersIdRouteIsAvailable(description, fixture) { + describe(`when openapi ${description}`, () => { + beforeAll(async () => { + server = await startServer(fixture); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it("should omit not valid routes", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "get-users-id", + url: "/users/:id", + method: "get", + delay: null, + variants: ["get-users-id:200-json-success", "get-users-id:404-json-not-found"], + }, + ]); + }); + }); + } + + checkOnlyUsersIdRouteIsAvailable("path is empty", "empty-path"); + checkOnlyUsersIdRouteIsAvailable("method is empty", "empty-method"); + checkOnlyUsersIdRouteIsAvailable("method responses is empty", "empty-responses"); + checkOnlyUsersIdRouteIsAvailable("response code is empty", "empty-response"); + checkOnlyUsersIdRouteIsAvailable("response media is empty", "empty-media"); + checkOnlyUsersIdRouteIsAvailable("response media is unknown", "unknown-media"); + checkOnlyUsersIdRouteIsAvailable("response media examples is empty", "empty-examples"); + checkOnlyUsersIdRouteIsAvailable( + "response media example or example value is empty", + "empty-example" + ); +}); diff --git a/packages/plugin-openapi/test/index.spec.js b/packages/plugin-openapi/test/index.spec.js new file mode 100644 index 000000000..13b52c518 --- /dev/null +++ b/packages/plugin-openapi/test/index.spec.js @@ -0,0 +1,9 @@ +import index from "../src/index"; + +import Plugin from "../src/Plugin"; + +describe("index file", () => { + it("should export plugin as default", () => { + expect(index).toBe(Plugin); + }); +}); diff --git a/packages/plugin-openapi/test/openapi-function-errors.spec.js b/packages/plugin-openapi/test/openapi-function-errors.spec.js new file mode 100644 index 000000000..c48679922 --- /dev/null +++ b/packages/plugin-openapi/test/openapi-function-errors.spec.js @@ -0,0 +1,72 @@ +import deepMerge from "deepmerge"; + +import openApiDocument from "./openapi/users"; +import { openApiRoutes } from "../src/index"; + +describe("when function is used and openapi definition is wrong", () => { + describe("when has a wrong ref", () => { + it("should throw an error with message", async () => { + await expect( + openApiRoutes({ + basePath: "/api", + document: deepMerge(openApiDocument, { + paths: { + "/users": { + $ref: "#/foo/pathItems/Users", + }, + }, + }), + }) + ).rejects.toEqual( + new Error("JSON Pointer points to missing location: #/foo/pathItems/Users") + ); + }); + }); + + describe("when has multiple wrong refs", () => { + it("should throw an error with all messages", async () => { + await expect( + openApiRoutes({ + basePath: "/api", + document: deepMerge(openApiDocument, { + paths: { + "/users": { + $ref: "#/foo/pathItems/Users", + }, + "/users/{id}": { + get: { + responses: { + "200": { + $ref: "#/components/foo/User", + }, + }, + }, + }, + }, + }), + }) + ).rejects.toEqual( + new Error( + "JSON Pointer points to missing location: #/foo/pathItems/Users. JSON Pointer points to missing location: #/components/foo/User" + ) + ); + }); + }); + + describe("when options are wrong", () => { + it("should throw an error with message", async () => { + await expect( + openApiRoutes({ + basePath: "/api", + refs: { + subDocPath: "dasd", + location: "foo.json", + }, + document: openApiDocument, + }) + ).rejects.toEqual( + new Error("options.subDocPath must be an Array of path segments or a valid JSON Pointer") + ); + }); + }); +}); diff --git a/packages/plugin-openapi/test/openapi/users-collection.js b/packages/plugin-openapi/test/openapi/users-collection.js new file mode 100644 index 000000000..475324da7 --- /dev/null +++ b/packages/plugin-openapi/test/openapi/users-collection.js @@ -0,0 +1,20 @@ +module.exports = [ + { + id: "base", + routes: [ + "get-users:200-json-one-user", + "post-users:201-status", + "get-users-id:200-json-success", + ], + }, + { + id: "all-users", + from: "base", + routes: ["get-users:200-json-two-users"], + }, + { + id: "users-error", + from: "base", + routes: ["post-users:400-text-error-message", "get-users-id:404-json-not-found"], + }, +]; diff --git a/packages/plugin-openapi/test/openapi/users.js b/packages/plugin-openapi/test/openapi/users.js new file mode 100644 index 000000000..e1d98daa8 --- /dev/null +++ b/packages/plugin-openapi/test/openapi/users.js @@ -0,0 +1,123 @@ +module.exports = { + openapi: "3.1.0", + info: { + version: "1.0.0", + title: "Testing API", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/users": { + get: { + summary: "Return all users", + description: "Use it to get current users", + responses: { + "200": { + description: "successful operation", + content: { + "application/json": { + examples: { + "one-user": { + summary: "One route", + value: [ + { + id: 1, + name: "John Doe", + }, + ], + }, + "two-users": { + summary: "Two users", + value: [ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ], + }, + }, + }, + }, + }, + }, + }, + post: { + summary: "Create an user", + responses: { + "201": { + description: "successful operation", + }, + "400": { + description: "bad data", + content: { + "text/plain": { + examples: { + "error-message": { + summary: "Error message", + value: "Bad data", + }, + }, + }, + }, + }, + }, + }, + }, + "/users/{id}": { + get: { + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + "200": { + description: "successful operation", + content: { + "application/json": { + examples: { + success: { + summary: "One user", + value: { + id: 1, + name: "John Doe", + }, + }, + }, + }, + }, + }, + "404": { + description: "user not found", + content: { + "application/json": { + examples: { + "not-found": { + summary: "Not found error", + value: { + code: 404, + message: "Not found", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugin-openapi/test/refs.spec.js b/packages/plugin-openapi/test/refs.spec.js new file mode 100644 index 000000000..4c90824c1 --- /dev/null +++ b/packages/plugin-openapi/test/refs.spec.js @@ -0,0 +1,305 @@ +import path from "path"; + +import { + startServer, + fetchJson, + fetchText, + waitForServer, + waitForServerUrl, +} from "./support/helpers"; + +import { openApiRoutes } from "../src/index"; + +describe("when openapi has refs", () => { + let server; + + function testRouteDefinitions() { + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "get-users", + url: "/api/users", + method: "get", + delay: null, + variants: ["get-users:200-json-one-user", "get-users:200-json-two-users"], + }, + { + id: "post-users", + url: "/api/users", + method: "post", + delay: null, + variants: ["post-users:201-status", "post-users:400-text-error-message"], + }, + { + id: "get-users-id", + url: "/api/users/:id", + method: "get", + delay: null, + variants: ["get-users-id:200-json-success", "get-users-id:404-json-not-found"], + }, + ]); + }); + }); + } + + function testRoutes() { + describe("get-users route", () => { + it("should have 200-json-one-user variant available in base collection", async () => { + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in all-users collection", async () => { + await server.mock.collections.select("all-users", { check: true }); + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + + describe("post-users route", () => { + it("should have 201-status variant available in all-users collection", async () => { + const response = await fetchJson("/api/users", { + method: "POST", + }); + expect(response.body).toBe(undefined); + expect(response.status).toEqual(201); + }); + + it("should have 400-text-error-message variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchText("/api/users", { + method: "POST", + }); + expect(response.body).toBe("Bad data"); + expect(response.status).toEqual(400); + }); + }); + + describe("get-users-id route", () => { + it("should have 200-json-success variant available in base collection", async () => { + await server.mock.collections.select("base", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + id: 1, + name: "John Doe", + }); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + code: 404, + message: "Not found", + }); + expect(response.status).toEqual(404); + }); + }); + } + + function testValidRefs(fixture) { + describe(`when fixture is ${fixture}`, () => { + beforeAll(async () => { + server = await startServer(fixture); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + testRouteDefinitions(); + testRoutes(); + }); + } + + testValidRefs("refs"); + testValidRefs("refs-files"); + + describe("Remote refs", () => { + let refsServer; + beforeAll(async () => { + refsServer = await startServer("refs-remote-server", { + server: { + port: 3200, + }, + }); + await waitForServer(3200); + }); + + afterAll(async () => { + await refsServer.stop(); + }); + + testValidRefs("refs-remote"); + }); + + describe("Remote full openapi ref using function", () => { + let refsServer; + beforeAll(async () => { + refsServer = await startServer("refs-remote-server", { + server: { + port: 3200, + }, + }); + await waitForServer(3200); + server = await startServer("no-paths"); + await waitForServer(); + const { loadRoutes } = server.mock.createLoaders(); + const routes = await openApiRoutes({ + basePath: "/api", + document: { + $ref: "http://127.0.0.1:3200/openapi.json", + }, + }); + loadRoutes(routes); + await waitForServerUrl("/api/users"); + }); + + afterAll(async () => { + await server.stop(); + await refsServer.stop(); + }); + + testRoutes(); + }); + + describe("file openapi ref using function", () => { + let refsServer; + beforeAll(async () => { + refsServer = await startServer("refs-remote-server", { + server: { + port: 3200, + }, + }); + await waitForServer(3200); + server = await startServer("no-paths"); + await waitForServer(); + const { loadRoutes } = server.mock.createLoaders(); + const routes = await openApiRoutes({ + basePath: "/api", + refs: { + location: path.resolve(__dirname, "refs.spec.js"), + }, + document: { + $ref: "./fixtures/refs-remote-server/static/openapi.json", + }, + }); + loadRoutes(routes); + await waitForServerUrl("/api/users"); + }); + + afterAll(async () => { + await server.stop(); + await refsServer.stop(); + }); + + testRoutes(); + }); + + describe("when fixture has wrong refs", () => { + beforeAll(async () => { + server = await startServer("wrong-refs"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "get-users-id", + url: "/api/users/:id", + method: "get", + delay: null, + variants: ["get-users-id:200-json-success", "get-users-id:404-json-not-found"], + }, + ]); + }); + }); + + describe("alerts", () => { + it("should have added an alert about wrong refs", async () => { + const alert = + server.alerts.find((serverAlert) => serverAlert.id.includes("openapi")) || {}; + expect(alert.id).toEqual("plugins:openapi:documents:0"); + expect(alert.message).toEqual("Error resolving openapi $ref"); + expect(alert.error.message).toEqual( + "JSON Pointer points to missing location: #/componednts/pathItems/Users" + ); + }); + }); + + describe("get-users-id route", () => { + it("should have 200-json-success variant available in base collection", async () => { + await server.mock.collections.select("base", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + id: 1, + name: "John Doe", + }); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + code: 404, + message: "Not found", + }); + expect(response.status).toEqual(404); + }); + }); + }); + + describe("when location option is set wrongly", () => { + beforeAll(async () => { + server = await startServer("wrong-refs-options"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([]); + }); + }); + + describe("alerts", () => { + it("should have added an alert about wrong openapi", async () => { + const alert = + server.alerts.find((serverAlert) => serverAlert.id.includes("openapi")) || {}; + expect(alert.id).toEqual("plugins:openapi:documents:0"); + expect(alert.message).toEqual("Error loading openapi definition"); + expect(alert.error.message).toEqual( + "options.subDocPath must be an Array of path segments or a valid JSON Pointer" + ); + }); + }); + }); +}); diff --git a/packages/plugin-openapi/test/routes.spec.js b/packages/plugin-openapi/test/routes.spec.js new file mode 100644 index 000000000..ca845e00c --- /dev/null +++ b/packages/plugin-openapi/test/routes.spec.js @@ -0,0 +1,202 @@ +import { + startServer, + fetchJson, + fetchText, + waitForServer, + waitForServerUrl, +} from "./support/helpers"; +import openApiDocument from "./openapi/users"; + +import { openApiRoutes } from "../src/index"; + +describe("generated routes", () => { + let server; + + function testRoutes() { + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "get-users", + url: "/api/users", + method: "get", + delay: null, + variants: ["get-users:200-json-one-user", "get-users:200-json-two-users"], + }, + { + id: "post-users", + url: "/api/users", + method: "post", + delay: null, + variants: ["post-users:201-status", "post-users:400-text-error-message"], + }, + { + id: "get-users-id", + url: "/api/users/:id", + method: "get", + delay: null, + variants: ["get-users-id:200-json-success", "get-users-id:404-json-not-found"], + }, + ]); + }); + }); + + describe("get-users route", () => { + it("should have 200-json-one-user variant available in base collection", async () => { + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in all-users collection", async () => { + await server.mock.collections.select("all-users", { check: true }); + const response = await fetchJson("/api/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + + describe("post-users route", () => { + it("should have 201-status variant available in all-users collection", async () => { + const response = await fetchJson("/api/users", { + method: "POST", + }); + expect(response.body).toBe(undefined); + expect(response.status).toEqual(201); + }); + + it("should have 400-text-error-message variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchText("/api/users", { + method: "POST", + }); + expect(response.body).toBe("Bad data"); + expect(response.status).toEqual(400); + }); + }); + + describe("get-users-id route", () => { + it("should have 200-json-success variant available in base collection", async () => { + await server.mock.collections.select("base", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + id: 1, + name: "John Doe", + }); + expect(response.status).toEqual(200); + }); + + it("should have 200-json-two-users variant available in users-error collection", async () => { + await server.mock.collections.select("users-error", { check: true }); + const response = await fetchJson("/api/users/2"); + expect(response.body).toEqual({ + code: 404, + message: "Not found", + }); + expect(response.status).toEqual(404); + }); + }); + } + + describe("when fixture is api-users", () => { + beforeAll(async () => { + server = await startServer("api-users"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + testRoutes(); + }); + + describe("when openapi definition is loaded using function", () => { + beforeAll(async () => { + server = await startServer("no-paths"); + await waitForServer(); + const { loadRoutes } = server.mock.createLoaders(); + + const routes = await openApiRoutes({ + basePath: "/api", + document: openApiDocument, + }); + + loadRoutes(routes); + + await waitForServerUrl("/api/users"); + }); + + afterAll(async () => { + await server.stop(); + }); + + testRoutes(); + }); + + describe("when openapi mock has no basePath", () => { + beforeAll(async () => { + server = await startServer("users"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("routes", () => { + it("should have created routes from openapi document defined in files", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "get-users", + url: "/users", + method: "get", + delay: null, + variants: ["get-users:200-json-one-user", "get-users:200-json-two-users"], + }, + { + id: "post-users", + url: "/users", + method: "post", + delay: null, + variants: ["post-users:201-status", "post-users:400-text-error-message"], + }, + { + id: "get-users-id", + url: "/users/:id", + method: "get", + delay: null, + variants: ["get-users-id:200-json-success", "get-users-id:404-json-not-found"], + }, + ]); + }); + }); + + describe("get-users route", () => { + it("should have 200-json-one-user variant available in base collection", async () => { + const response = await fetchJson("/users"); + expect(response.body).toEqual([ + { + id: 1, + name: "John Doe", + }, + ]); + expect(response.status).toEqual(200); + }); + }); + }); +}); diff --git a/packages/plugin-openapi/test/support/helpers.js b/packages/plugin-openapi/test/support/helpers.js new file mode 100644 index 000000000..096871e98 --- /dev/null +++ b/packages/plugin-openapi/test/support/helpers.js @@ -0,0 +1,130 @@ +import path from "path"; + +import Core from "@mocks-server/core"; +import crossFetch from "cross-fetch"; +import deepMerge from "deepmerge"; +import waitOn from "wait-on"; + +import Plugin from "../../src/index"; + +const DEFAULT_SERVER_PORT = 3100; + +const defaultOptions = { + server: { + port: DEFAULT_SERVER_PORT, + }, + log: "silent", + files: { + watch: false, + }, +}; + +export const defaultRequestOptions = { + method: "get", + headers: { + "Content-Type": "application/json", + }, +}; + +export const FIXTURES_PATH = path.resolve(__dirname, "..", "fixtures"); + +export function fixturesPath(folderName) { + return path.resolve(FIXTURES_PATH, folderName); +} + +export function createCore() { + return new Core({ + config: { + allowUnknownArguments: true, + readFile: false, + readArguments: false, + readEnvironment: false, + }, + plugins: { + register: [Plugin], + }, + }); +} + +export function startExistingCore(core, fixturePath, options = {}) { + return core + .init( + deepMerge.all([ + defaultOptions, + { + files: { + path: fixturesPath(fixturePath), + }, + }, + options, + ]) + ) + .then(() => { + return core.start().then(() => { + return Promise.resolve(core); + }); + }); +} + +export function serverUrl(port, protocol) { + const protocolToUse = protocol || "http"; + return `${protocolToUse}://127.0.0.1:${port || DEFAULT_SERVER_PORT}`; +} + +export function startServer(fixturePath, options = {}) { + return startExistingCore(createCore(), fixturePath, options); +} + +export function doFetch(uri, options = {}) { + const requestOptions = { + ...defaultRequestOptions, + ...options, + }; + if (requestOptions.body) { + requestOptions.body = JSON.stringify(requestOptions.body); + } + + return crossFetch(`${serverUrl(options.port, options.protocol)}${uri}`, { + ...requestOptions, + }).then((res) => { + return res[options.parser]() + .then((processedRes) => ({ + body: processedRes, + status: res.status, + headers: res.headers, + url: res.url, + })) + .catch(() => { + return { status: res.status, headers: res.headers, url: res.url }; + }); + }); +} + +export function doServerFetch(uri, options = {}) { + return doFetch(uri, { + port: DEFAULT_SERVER_PORT, + ...options, + }); +} + +export function fetchJson(uri, options = {}) { + return doServerFetch(uri, { + parser: "json", + ...options, + }); +} + +export function fetchText(uri, options = {}) { + return doServerFetch(uri, { + parser: "text", + ...options, + }); +} + +export function waitForServer(port) { + return waitOn({ resources: [`tcp:127.0.0.1:${port || DEFAULT_SERVER_PORT}`] }); +} + +export function waitForServerUrl(url, options = {}) { + return waitOn({ resources: [`${serverUrl(options.port, options.protocol)}${url}`] }); +} diff --git a/packages/plugin-openapi/tsconfig.json b/packages/plugin-openapi/tsconfig.json new file mode 100644 index 000000000..b108dac1a --- /dev/null +++ b/packages/plugin-openapi/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + "outDir": "./dist", + "declaration": true, + "target": "es6", + "lib": [ + "es2019" + ], + "strict": true, + "strictNullChecks": true, + "esModuleInterop": true, + "moduleResolution": "node", + "module": "CommonJS", + "useDefineForClassFields": true, + "importsNotUsedAsValues": "error", + "forceConsistentCasingInFileNames": true, + "noUnusedParameters": true, + "isolatedModules": true + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5844b4b5..54ef0c946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,12 +300,14 @@ importers: '@mocks-server/core': workspace:* '@mocks-server/plugin-admin-api': workspace:* '@mocks-server/plugin-inquirer-cli': workspace:* + '@mocks-server/plugin-openapi': workspace:* '@mocks-server/plugin-proxy': workspace:* deepmerge: 4.2.2 dependencies: '@mocks-server/core': link:../core '@mocks-server/plugin-admin-api': link:../plugin-admin-api '@mocks-server/plugin-inquirer-cli': link:../plugin-inquirer-cli + '@mocks-server/plugin-openapi': link:../plugin-openapi '@mocks-server/plugin-proxy': link:../plugin-proxy deepmerge: 4.2.2 @@ -350,6 +352,19 @@ importers: devDependencies: '@mocks-server/core': link:../core + packages/plugin-openapi: + specifiers: + '@mocks-server/core': workspace:* + '@mocks-server/nested-collections': workspace:* + json-refs: 3.0.15 + openapi-types: 12.0.0 + dependencies: + json-refs: 3.0.15 + openapi-types: 12.0.0 + devDependencies: + '@mocks-server/core': link:../core + '@mocks-server/nested-collections': link:../nested-collections + packages/plugin-proxy: specifiers: '@mocks-server/core': workspace:* @@ -4650,7 +4665,6 @@ packages: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} dependencies: sprintf-js: 1.0.3 - dev: true /argparse/2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4752,8 +4766,7 @@ packages: dev: true /asap/2.0.6: - resolution: {integrity: sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=} - dev: true + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} /asn1.js/5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} @@ -6148,7 +6161,6 @@ packages: /commander/4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - dev: true /commander/5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} @@ -6180,7 +6192,6 @@ packages: /component-emitter/1.3.0: resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==} - dev: true /compose-function/3.0.3: resolution: {integrity: sha1-ntZ18TzFRQHTCVCkhv9qe6OrGF8=} @@ -6315,6 +6326,10 @@ packages: engines: {node: '>= 0.6'} dev: false + /cookiejar/2.1.3: + resolution: {integrity: sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==} + dev: false + /copy-concurrently/1.0.5: resolution: {integrity: sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==} dependencies: @@ -7073,6 +7088,13 @@ packages: - supports-color dev: true + /dezalgo/1.0.3: + resolution: {integrity: sha512-K7i4zNfT2kgQz3GylDw40ot9GAE47sFZ9EXHFSPP6zONLgH6kWXE0KWJchkbQJLBkRazq4APwZ4OwiFFlT95OQ==} + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dev: false + /diff-sequences/26.6.2: resolution: {integrity: sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==} engines: {node: '>= 10.14.2'} @@ -7940,7 +7962,6 @@ packages: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true - dev: true /esquery/1.4.0: resolution: {integrity: sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==} @@ -8391,6 +8412,10 @@ packages: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-safe-stringify/2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: false + /fast-url-parser/1.1.3: resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} dependencies: @@ -8696,6 +8721,15 @@ packages: combined-stream: 1.0.8 mime-types: 2.1.34 + /formidable/2.0.1: + resolution: {integrity: sha512-rjTMNbp2BpfQShhFbR3Ruk3qk2y9jKpvMW78nJgx8QKtxjDVrwbZG+wvDOmVbifHyOUOQJXxqEy6r0faRrPzTQ==} + dependencies: + dezalgo: 1.0.3 + hexoid: 1.0.0 + once: 1.4.0 + qs: 6.9.3 + dev: false + /forwarded/0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -9035,6 +9069,12 @@ packages: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true + /graphlib/2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + dependencies: + lodash: 4.17.21 + dev: false + /growly/1.3.0: resolution: {integrity: sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=} dev: true @@ -9172,6 +9212,11 @@ packages: resolution: {integrity: sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==} dev: true + /hexoid/1.0.0: + resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} + engines: {node: '>=8'} + dev: false + /hmac-drbg/1.0.1: resolution: {integrity: sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=} dependencies: @@ -11166,7 +11211,6 @@ packages: dependencies: argparse: 1.0.10 esprima: 4.0.1 - dev: true /js-yaml/4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} @@ -11242,6 +11286,23 @@ packages: /json-parse-even-better-errors/2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + /json-refs/3.0.15: + resolution: {integrity: sha512-0vOQd9eLNBL18EGl5yYaO44GhixmImes2wiYn9Z3sag3QnehWrYWlB9AFtMxCL2Bj3fyxgDYkxGFEU/chlYssw==} + engines: {node: '>=0.8'} + hasBin: true + dependencies: + commander: 4.1.1 + graphlib: 2.1.8 + js-yaml: 3.14.1 + lodash: 4.17.21 + native-promise-only: 0.8.1 + path-loader: 1.0.12 + slash: 3.0.0 + uri-js: 4.4.1 + transitivePeerDependencies: + - supports-color + dev: false + /json-schema-traverse/0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -11784,7 +11845,7 @@ packages: dev: true /methods/1.1.2: - resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=} + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} /microevent.ts/0.1.1: @@ -11880,7 +11941,6 @@ packages: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} hasBin: true - dev: true /mimic-fn/2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} @@ -12095,6 +12155,10 @@ packages: - supports-color dev: true + /native-promise-only/0.8.1: + resolution: {integrity: sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==} + dev: false + /native-url/0.2.6: resolution: {integrity: sha512-k4bDC87WtgrdD362gZz6zoiXQrl40kYlBmpfmSjwRO1VU0V5ccwJTlxuE72F6m3V0vc1xOf6n3UCP9QyerRqmA==} dependencies: @@ -12329,12 +12393,8 @@ packages: kind-of: 3.2.2 dev: true - /object-inspect/1.12.0: - resolution: {integrity: sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==} - /object-inspect/1.12.2: resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==} - dev: true /object-is/1.1.5: resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} @@ -12472,6 +12532,10 @@ packages: is-wsl: 2.2.0 dev: true + /openapi-types/12.0.0: + resolution: {integrity: sha512-6Wd9k8nmGQHgCbehZCP6wwWcfXcvinhybUTBatuhjRsCxUIujuYFZc9QnGeae75CyHASewBtxs0HX/qwREReUw==} + dev: false + /opn/5.5.0: resolution: {integrity: sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==} engines: {node: '>=4'} @@ -12753,6 +12817,15 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + /path-loader/1.0.12: + resolution: {integrity: sha512-n7oDG8B+k/p818uweWrOixY9/Dsr89o2TkCm6tOTex3fpdo2+BFDgR+KpB37mGKBRsBAlR8CIJMFN0OEy/7hIQ==} + dependencies: + native-promise-only: 0.8.1 + superagent: 7.1.6 + transitivePeerDependencies: + - supports-color + dev: false + /path-parse/1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true @@ -13756,6 +13829,11 @@ packages: engines: {node: '>=0.6'} dev: true + /qs/6.9.3: + resolution: {integrity: sha512-EbZYNarm6138UKKq46tdx08Yo/q9ZhFoAXAI1meAFd2GtbRDhbZY2WQSICskT0c5q99aFzLG1D4nvTk9tqfXIw==} + engines: {node: '>=0.6'} + dev: false + /qs/6.9.6: resolution: {integrity: sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ==} engines: {node: '>=0.6'} @@ -15037,7 +15115,7 @@ packages: dependencies: call-bind: 1.0.2 get-intrinsic: 1.1.1 - object-inspect: 1.12.0 + object-inspect: 1.12.2 /signal-exit/3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -15065,7 +15143,6 @@ packages: /slash/3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - dev: true /slice-ansi/3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} @@ -15284,8 +15361,7 @@ packages: dev: true /sprintf-js/1.0.3: - resolution: {integrity: sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=} - dev: true + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} /sshpk/1.17.0: resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==} @@ -15596,6 +15672,26 @@ packages: postcss-selector-parser: 3.1.2 dev: true + /superagent/7.1.6: + resolution: {integrity: sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==} + engines: {node: '>=6.4.0 <13 || >=14'} + deprecated: Please downgrade to v7.1.5 if you need IE/ActiveXObject support OR upgrade to v8.0.0 as we no longer support IE and published an incorrect patch version (see https://github.com/visionmedia/superagent/issues/1731) + dependencies: + component-emitter: 1.3.0 + cookiejar: 2.1.3 + debug: 4.3.4 + fast-safe-stringify: 2.1.1 + form-data: 4.0.0 + formidable: 2.0.1 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.10.3 + readable-stream: 3.6.0 + semver: 7.3.7 + transitivePeerDependencies: + - supports-color + dev: false + /supports-color/5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} diff --git a/test/core-e2e/src/async-files.spec.js b/test/core-e2e/src/async-files.spec.js new file mode 100644 index 000000000..67d095849 --- /dev/null +++ b/test/core-e2e/src/async-files.spec.js @@ -0,0 +1,94 @@ +/* +Copyright 2021 Javier Brea + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +*/ + +const { + startCore, + doFetch, + waitForServer, + findAlert, + removeConfigFile, +} = require("./support/helpers"); + +describe("async files", () => { + let core, changeCollection; + + beforeAll(async () => { + core = await startCore("web-tutorial-async"); + await waitForServer(); + changeCollection = (name) => { + core.config.namespace("mock").namespace("collections").option("selected").value = name; + }; + }); + + afterAll(async () => { + removeConfigFile(); + await core.stop(); + }); + + describe("collection by default", () => { + it("should have added an alert about collection was not defined", () => { + expect(findAlert("mock:collections:selected", core.alerts).message).toEqual( + expect.stringContaining("Option 'mock.collections.selected' was not defined") + ); + }); + + it("should serve users under the /api/users path", async () => { + const users = await doFetch("/api/users"); + expect(users.status).toEqual(200); + expect(users.body).toEqual([ + { id: 1, name: "John Doe" }, + { id: 2, name: "Jane Doe" }, + ]); + }); + + it("should serve user 1 under the /api/users/1 path", async () => { + const users = await doFetch("/api/users/1"); + expect(users.status).toEqual(200); + expect(users.body).toEqual({ id: 1, name: "John Doe" }); + }); + + it("should serve user 1 under the /api/users/2 path", async () => { + const users = await doFetch("/api/users/2"); + expect(users.status).toEqual(200); + expect(users.body).toEqual({ id: 1, name: "John Doe" }); + }); + }); + + describe('when changing collection to "user-2"', () => { + beforeAll(() => { + changeCollection("user-2"); + }); + + it("should have removed alert", () => { + expect(findAlert("mock:collections:selected", core.alerts)).toEqual(undefined); + }); + + it("should serve users collection under the /api/users path", async () => { + const users = await doFetch("/api/users"); + expect(users.status).toEqual(200); + expect(users.body).toEqual([ + { id: 1, name: "John Doe" }, + { id: 2, name: "Jane Doe" }, + ]); + }); + + it("should serve user 2 under the /api/users/1 path", async () => { + const users = await doFetch("/api/users/1"); + expect(users.status).toEqual(200); + expect(users.body).toEqual({ id: 2, name: "Jane Doe" }); + }); + + it("should serve user 2 under the /api/users/2 path", async () => { + const users = await doFetch("/api/users/2"); + expect(users.status).toEqual(200); + expect(users.body).toEqual({ id: 2, name: "Jane Doe" }); + }); + }); +}); diff --git a/test/core-e2e/src/fixtures/web-tutorial-async/collections.js b/test/core-e2e/src/fixtures/web-tutorial-async/collections.js new file mode 100644 index 000000000..8af8046c4 --- /dev/null +++ b/test/core-e2e/src/fixtures/web-tutorial-async/collections.js @@ -0,0 +1,26 @@ +/* +Copyright 2021 Javier Brea + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +*/ + +module.exports = [ + { + id: "base", + routes: ["get-users:success", "get-user:1"], + }, + { + id: "user-2", + from: "base", + routes: ["get-user:2"], + }, + { + id: "user-real", + from: "base", + routes: ["get-user:real"], + }, +]; diff --git a/test/core-e2e/src/fixtures/web-tutorial-async/db/users.js b/test/core-e2e/src/fixtures/web-tutorial-async/db/users.js new file mode 100644 index 000000000..32a79271a --- /dev/null +++ b/test/core-e2e/src/fixtures/web-tutorial-async/db/users.js @@ -0,0 +1,32 @@ +/* +Copyright 2021 Javier Brea + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +*/ + +const USERS = [ + { + id: 1, + name: "John Doe", + }, + { + id: 2, + name: "Jane Doe", + }, +]; + +function getUsers() { + return new Promise((resolve) => { + setTimeout(() => { + resolve(USERS); + }, 500); + }); +} + +module.exports = { + getUsers, +}; diff --git a/test/core-e2e/src/fixtures/web-tutorial-async/routes/user.js b/test/core-e2e/src/fixtures/web-tutorial-async/routes/user.js new file mode 100644 index 000000000..fe7e76843 --- /dev/null +++ b/test/core-e2e/src/fixtures/web-tutorial-async/routes/user.js @@ -0,0 +1,60 @@ +/* +Copyright 2021 Javier Brea + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +*/ + +const { getUsers } = require("../db/users"); + +module.exports = async () => { + const users = await getUsers(); + return [ + { + id: "get-user", + url: "/api/users/:id", + method: "get", + variants: [ + { + id: "1", + type: "json", + options: { + status: 200, + body: users[0], + }, + }, + { + id: "2", + type: "json", + options: { + status: 200, + body: users[1], + }, + }, + { + id: "real", + type: "middleware", + options: { + middleware: (req, res) => { + const userId = req.params.id; + const user = users.find((userData) => userData.id === Number(userId)); + if (user) { + res.status(200); + res.send(user); + } else { + res.status(404); + res.send({ + message: "User not found", + }); + } + } + }, + }, + ], + }, + ]; +} + diff --git a/test/core-e2e/src/fixtures/web-tutorial-async/routes/users.js b/test/core-e2e/src/fixtures/web-tutorial-async/routes/users.js new file mode 100644 index 000000000..4537a679f --- /dev/null +++ b/test/core-e2e/src/fixtures/web-tutorial-async/routes/users.js @@ -0,0 +1,45 @@ +/* +Copyright 2021 Javier Brea + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +*/ + +const { getUsers } = require("../db/users"); + +function getUsersRoutes() { + return getUsers().then((users) => { + return [ + { + id: "get-users", + url: "/api/users", + method: "GET", + variants: [ + { + id: "success", + type: "json", + options: { + status: 200, + body: users, + }, + }, + { + id: "error", + type: "json", + options: { + status: 403, + body: { + message: "Bad data", + }, + }, + }, + ], + }, + ]; + }); +} + +module.exports = getUsersRoutes; diff --git a/tsconfig.base.json b/tsconfig.base.json index b0bf4e785..31b82316e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -42,6 +42,9 @@ "@mocks-server/plugin-inquirer-cli": [ "packages/plugin-inquirer-cli/index.js" ], + "@mocks-server/plugin-openapi": [ + "packages/plugin-openapi/dist/index.js" + ], "@mocks-server/plugin-proxy": [ "packages/plugin-proxy/index.js" ] diff --git a/workspace.json b/workspace.json index b012ce244..0a6e4b1dc 100644 --- a/workspace.json +++ b/workspace.json @@ -34,6 +34,7 @@ "plugin-admin-api-swagger-e2e": "test/plugin-admin-api-swagger-e2e", "plugin-inquirer-cli": "packages/plugin-inquirer-cli", "plugin-inquirer-cli-e2e": "test/plugin-inquirer-cli-e2e", + "plugin-openapi": "packages/plugin-openapi", "plugin-proxy": "packages/plugin-proxy" } }