From 5ae9ff9663c514ff17663185c1fc9f7ae5edf5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20M=C3=A4kel=C3=A4?= Date: Wed, 3 May 2017 11:56:56 +0300 Subject: [PATCH] MXS-1220: Add simple REST API validator The validator uses Node.js to check that the REST API endpoints conform to the specified schema. --- server/core/test/rest-api/package.json | 19 + .../test/rest-api/test/schema_validation.js | 45 ++ server/core/test/rest-api/utils.js | 422 ++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100644 server/core/test/rest-api/package.json create mode 100644 server/core/test/rest-api/test/schema_validation.js create mode 100644 server/core/test/rest-api/utils.js diff --git a/server/core/test/rest-api/package.json b/server/core/test/rest-api/package.json new file mode 100644 index 000000000..6e24487e1 --- /dev/null +++ b/server/core/test/rest-api/package.json @@ -0,0 +1,19 @@ +{ + "name": "rest-api-tests", + "version": "1.0.0", + "repository": "https://github.com/mariadb-corporation/MaxScale", + "description": "MaxScale REST API tests", + "scripts": { + "test": "mocha" + }, + "author": "", + "license": "SEE LICENSE IN ../../../../LICENSE.txt", + "dependencies": { + "ajv": "^5.0.1", + "chai": "^3.5.0", + "chai-as-promised": "^6.0.0", + "mocha": "^3.3.0", + "request": "^2.81.0", + "request-promise-native": "^1.0.3" + } +} diff --git a/server/core/test/rest-api/test/schema_validation.js b/server/core/test/rest-api/test/schema_validation.js new file mode 100644 index 000000000..f7ff12a31 --- /dev/null +++ b/server/core/test/rest-api/test/schema_validation.js @@ -0,0 +1,45 @@ +require("../utils.js")() + +describe("Resource Collections", function(){ + + var tests = [ + "/servers/", + "/sessions/", + "/services/", + "/monitors/", + "/filters/", + ] + + tests.forEach(function(endpoint){ + it(endpoint + ': resource should be found', function() { + return request(base_url + endpoint) + .should.be.fulfilled + }); + + it(endpoint + ': resource schema should be valid', function() { + return request(base_url + endpoint) + .should.eventually.satisfy(validate) + }); + }) +}); + +describe("Individual Resources", function(){ + + var tests = [ + "/servers/server1", + "/servers/server2", + "/sessions/1", + ] + + tests.forEach(function(endpoint){ + it(endpoint + ': resource should be found', function() { + return request(base_url + endpoint) + .should.be.fulfilled + }); + + it(endpoint + ': resource schema should be valid', function() { + return request(base_url + endpoint) + .should.eventually.satisfy(validate) + }); + }) +}); diff --git a/server/core/test/rest-api/utils.js b/server/core/test/rest-api/utils.js new file mode 100644 index 000000000..dadb26586 --- /dev/null +++ b/server/core/test/rest-api/utils.js @@ -0,0 +1,422 @@ +var json_api_schema = { + "title": "JSON API Schema", + "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org", + "oneOf": [ + { + "$ref": "#/definitions/success" + }, + { + "$ref": "#/definitions/failure" + }, + { + "$ref": "#/definitions/info" + } + ], + "definitions": { + "success": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/data" + }, + "included": { + "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", + "type": "array", + "items": { + "$ref": "#/definitions/resource" + }, + "uniqueItems": true + }, + "meta": { + "$ref": "#/definitions/meta" + }, + "links": { + "description": "Link members related to the primary data.", + "allOf": [ + { + "$ref": "#/definitions/links" + }, + { + "$ref": "#/definitions/pagination" + } + ] + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + } + }, + "additionalProperties": false + }, + "failure": { + "type": "object", + "required": [ + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/error" + }, + "uniqueItems": true + }, + "meta": { + "$ref": "#/definitions/meta" + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + }, + "links": { + "$ref": "#/definitions/links" + } + }, + "additionalProperties": false + }, + "info": { + "type": "object", + "required": [ + "meta" + ], + "properties": { + "meta": { + "$ref": "#/definitions/meta" + }, + "links": { + "$ref": "#/definitions/links" + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + } + }, + "additionalProperties": false + }, + "meta": { + "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", + "type": "object", + "additionalProperties": true + }, + "data": { + "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", + "oneOf": [ + { + "$ref": "#/definitions/resource" + }, + { + "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", + "type": "array", + "items": { + "$ref": "#/definitions/resource" + }, + "uniqueItems": true + }, + { + "description": "null if the request is one that might correspond to a single resource, but doesn't currently.", + "type": "null" + } + ] + }, + "resource": { + "description": "\"Resource objects\" appear in a JSON API document to represent resources.", + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/definitions/attributes" + }, + "relationships": { + "$ref": "#/definitions/relationships" + }, + "links": { + "$ref": "#/definitions/links" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + "links": { + "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", + "type": "object", + "properties": { + "self": { + "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", + "type": "string", + "format": "uri" + }, + "related": { + "$ref": "#/definitions/link" + } + }, + "additionalProperties": true + }, + "link": { + "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", + "oneOf": [ + { + "description": "A string containing the link's URL.", + "type": "string", + "format": "uri" + }, + { + "type": "object", + "required": [ + "href" + ], + "properties": { + "href": { + "description": "A string containing the link's URL.", + "type": "string", + "format": "uri" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + } + ] + }, + "attributes": { + "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", + "type": "object", + "patternProperties": { + "^(?!relationships$|links$)\\w[-\\w_]*$": { + "description": "Attributes may contain any valid JSON value." + } + }, + "additionalProperties": false + }, + "relationships": { + "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", + "type": "object", + "patternProperties": { + "^\\w[-\\w_]*$": { + "properties": { + "links": { + "$ref": "#/definitions/links" + }, + "data": { + "description": "Member, whose value represents \"resource linkage\".", + "oneOf": [ + { + "$ref": "#/definitions/relationshipToOne" + }, + { + "$ref": "#/definitions/relationshipToMany" + } + ] + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "anyOf": [ + { + "required": [ + "data" + ] + }, + { + "required": [ + "meta" + ] + }, + { + "required": [ + "links" + ] + } + ], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "relationshipToOne": { + "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", + "anyOf": [ + { + "$ref": "#/definitions/empty" + }, + { + "$ref": "#/definitions/linkage" + } + ] + }, + "relationshipToMany": { + "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", + "type": "array", + "items": { + "$ref": "#/definitions/linkage" + }, + "uniqueItems": true + }, + "empty": { + "description": "Describes an empty to-one relationship.", + "type": "null" + }, + "linkage": { + "description": "The \"type\" and \"id\" to non-empty members.", + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + "pagination": { + "type": "object", + "properties": { + "first": { + "description": "The first page of data", + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "last": { + "description": "The last page of data", + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "prev": { + "description": "The previous page of data", + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "next": { + "description": "The next page of data", + "oneOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + } + }, + "jsonapi": { + "description": "An object describing the server's implementation", + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + "error": { + "type": "object", + "properties": { + "id": { + "description": "A unique identifier for this particular occurrence of the problem.", + "type": "string" + }, + "links": { + "$ref": "#/definitions/links" + }, + "status": { + "description": "The HTTP status code applicable to this problem, expressed as a string value.", + "type": "string" + }, + "code": { + "description": "An application-specific error code, expressed as a string value.", + "type": "string" + }, + "title": { + "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", + "type": "string" + }, + "detail": { + "description": "A human-readable explanation specific to this occurrence of the problem.", + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "pointer": { + "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", + "type": "string" + }, + "parameter": { + "description": "A string indicating which query parameter caused the error.", + "type": "string" + } + } + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + } + } +} + +function validate_json(data) { + return validate_func(JSON.parse(data)) +} + +module.exports = function() { + this.fs = require("fs") + this.request = require("request-promise-native") + this.chai = require("chai") + this.assert = require("assert") + this.chaiAsPromised = require("chai-as-promised") + chai.use(chaiAsPromised) + this.should = chai.should() + this.expect = chai.expect + this.Ajv = require("ajv") + this.ajv = new Ajv({$data: true, allErrors: true, extendRefs: true, verbose: true}) + this.validate_func = ajv.compile(json_api_schema) + this.validate = validate_json + this.base_url = "http://localhost:8989/v1" +}