diff --git a/.eslintrc.js b/.eslintrc.js index 40ff3836..0999a0af 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,6 +6,7 @@ module.exports = { node: true }, rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'consistent-return': 'off', 'class-methods-use-this': 'off', 'no-plusplus': 'off', diff --git a/lib/gavel.js b/lib/gavel.js index 55c27c4e..353cc79b 100644 --- a/lib/gavel.js +++ b/lib/gavel.js @@ -1,14 +1,17 @@ const { HttpRequest, ExpectedHttpRequest } = require('./model/http-request'); const { HttpResponse, ExpectedHttpResponse } = require('./model/http-response'); - -const { validate, isValid, isValidatable } = require('./validate'); +const { isValid, isValidatable } = require('./validate'); +const validate = require('./next/validate'); module.exports = { + // next + validate, + + // legacy HttpRequest, HttpResponse, ExpectedHttpRequest, ExpectedHttpResponse, - validate, isValid, isValidatable }; diff --git a/lib/next/test/integration/validateMessage.test.js b/lib/next/test/integration/validateMessage.test.js new file mode 100644 index 00000000..8ee36dd6 --- /dev/null +++ b/lib/next/test/integration/validateMessage.test.js @@ -0,0 +1,348 @@ +const { assert } = require('chai'); +const { validateMessage } = require('../../validateMessage'); + +describe('validateMessage', () => { + describe('with matching requests', () => { + const request = { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: '{ "foo": "bar" }' + }; + const result = validateMessage(request, request); + + it('returns validation result object', () => { + assert.isObject(result); + }); + + it('contains all validatable keys', () => { + assert.hasAllKeys(result, ['headers', 'body']); + }); + + describe('headers', () => { + it('has "HeadersJsonExample" validator', () => { + assert.propertyVal(result.headers, 'validator', 'HeadersJsonExample'); + }); + + it('has "application/vnd.apiary.http-headers+json" real headers type', () => { + assert.propertyVal( + result.headers, + 'realType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has "application/vnd.apiary.http-headers+json" expected headers type', () => { + assert.propertyVal( + result.headers, + 'expectedType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has no errors', () => { + assert.lengthOf(result.headers.results, 0); + }); + }); + + describe('body', () => { + it('has "JsonExample" validator', () => { + assert.propertyVal(result.body, 'validator', 'JsonExample'); + }); + + it('has "application/json" real body type', () => { + assert.propertyVal(result.body, 'realType', 'application/json'); + }); + + it('has "application/json" expected body type', () => { + assert.propertyVal(result.body, 'expectedType', 'application/json'); + }); + + it('has no errors', () => { + assert.lengthOf(result.body.results, 0); + }); + }); + }); + + describe('with non-matching requests', () => { + const result = validateMessage( + { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: '{ "lookHere": "foo" }' + }, + { + method: 'PUT', + headers: '', + body: '2' + } + ); + + it('returns validation result object', () => { + assert.isObject(result); + }); + + it('contains all validatable keys', () => { + assert.hasAllKeys(result, ['headers', 'body']); + }); + + describe('method', () => { + // See https://github.com/apiaryio/gavel.js/issues/158 + it.skip('compares methods'); + }); + + describe('headers', () => { + it('has "HeadersJsonExample" validator', () => { + assert.propertyVal(result.headers, 'validator', 'HeadersJsonExample'); + }); + + it('has "application/vnd.apiary.http-headers+json" as real type', () => { + assert.propertyVal( + result.headers, + 'realType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has "application/vnd.apiary.http-headers+json" expected type', () => { + assert.propertyVal( + result.headers, + 'expectedType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has no errors', () => { + assert.lengthOf(result.headers.results, 0); + }); + }); + + describe('body', () => { + it('has "JsonExample" validator', () => { + assert.propertyVal(result.body, 'validator', 'JsonExample'); + }); + + it('has "application/json" real type', () => { + assert.propertyVal(result.body, 'realType', 'application/json'); + }); + + it('has "application/json" expected type', () => { + assert.propertyVal(result.body, 'expectedType', 'application/json'); + }); + + describe('produces an error', () => { + it('exactly one error', () => { + assert.lengthOf(result.body.results, 1); + }); + + it('has "error" severity', () => { + assert.propertyVal(result.body.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.propertyVal( + result.body.results[0], + 'message', + `At '' Invalid type: object (expected integer)` + ); + }); + }); + }); + }); + + describe('with matching responses', () => { + const response = { + statusCode: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: '{ "foo": "bar" }' + }; + const result = validateMessage(response, response); + + it('returns validation result object', () => { + assert.isObject(result); + }); + + it('contains all validatable keys', () => { + assert.hasAllKeys(result, ['statusCode', 'headers', 'body']); + }); + + describe('statusCode', () => { + it('has "TextDiff" validator', () => { + assert.propertyVal(result.statusCode, 'validator', 'TextDiff'); + }); + + it('has "text/vnd.apiary.status-code" real type', () => { + assert.propertyVal( + result.statusCode, + 'realType', + 'text/vnd.apiary.status-code' + ); + }); + + it('has "text/vnd.apiary.status-code" expected type', () => { + assert.propertyVal( + result.statusCode, + 'expectedType', + 'text/vnd.apiary.status-code' + ); + }); + + it('has no errors', () => { + assert.lengthOf(result.statusCode.results, 0); + }); + }); + + describe('headers', () => { + it('has "HeadersJsonExample" validator', () => { + assert.propertyVal(result.headers, 'validator', 'HeadersJsonExample'); + }); + + it('has "application/vnd.apiary.http-headers+json" real type', () => { + assert.propertyVal( + result.headers, + 'realType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has "application/vnd.apiary.http-headers+json" expected type', () => { + assert.propertyVal( + result.headers, + 'expectedType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has no errors', () => { + assert.lengthOf(result.headers.results, 0); + }); + }); + + describe('body', () => { + it('has "JsonExample" validator', () => { + assert.propertyVal(result.body, 'validator', 'JsonExample'); + }); + + it('has "application/json" real type', () => { + assert.propertyVal(result.body, 'realType', 'application/json'); + }); + + it('has "application/json" expected type', () => { + assert.propertyVal(result.body, 'expectedType', 'application/json'); + }); + + it('has no errors', () => { + assert.lengthOf(result.body.results, 0); + }); + }); + }); + + describe('with non-matching responses', () => { + const realResponse = { + statusCode: 400, + headers: { + 'Content-Type': 'application/json' + } + }; + const expectedResponse = { + statusCode: 200, + headers: { + 'Accept-Language': 'en-US' + } + }; + const result = validateMessage(realResponse, expectedResponse); + + it('returns validation result object', () => { + assert.isObject(result); + }); + + it('contains all validatable keys', () => { + assert.hasAllKeys(result, ['statusCode', 'headers']); + }); + + describe('statusCode', () => { + it('has "TextDiff" validator', () => { + assert.propertyVal(result.statusCode, 'validator', 'TextDiff'); + }); + + it('has "text/vnd.apiary.status-code" real type', () => { + assert.propertyVal( + result.statusCode, + 'realType', + 'text/vnd.apiary.status-code' + ); + }); + + it('has "text/vnd.apiary.status-code" expected type', () => { + assert.propertyVal( + result.statusCode, + 'expectedType', + 'text/vnd.apiary.status-code' + ); + }); + + describe('produces an error', () => { + it('exactly one error', () => { + assert.lengthOf(result.statusCode.results, 1); + }); + + it('has "error" severity', () => { + assert.propertyVal(result.statusCode.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.propertyVal( + result.statusCode.results[0], + 'message', + 'Real and expected data does not match.' + ); + }); + }); + }); + + describe('headers', () => { + it('has "HeadersJsonExample" validator', () => { + assert.propertyVal(result.headers, 'validator', 'HeadersJsonExample'); + }); + + it('has "application/vnd.apiary.http-headers+json" real type', () => { + assert.propertyVal( + result.headers, + 'realType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has "application/vnd.apiary.http-headers+json" real type', () => { + assert.propertyVal( + result.headers, + 'realType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + describe('produces an error', () => { + it('exactly one error', () => { + assert.lengthOf(result.headers.results, 1); + }); + + it('has "error" severity', () => { + assert.propertyVal(result.headers.results[0], 'severity', 'error'); + }); + + it('includes missing header in the message', () => { + assert.propertyVal( + result.headers.results[0], + 'message', + `At '/accept-language' Missing required property: accept-language` + ); + }); + }); + }); + }); +}); diff --git a/lib/next/test/unit/units/normalize/normalizeHeaders.test.js b/lib/next/test/unit/units/normalize/normalizeHeaders.test.js new file mode 100644 index 00000000..647a32e0 --- /dev/null +++ b/lib/next/test/unit/units/normalize/normalizeHeaders.test.js @@ -0,0 +1,45 @@ +const { assert } = require('chai'); +const { + normalizeHeaders +} = require('../../../../units/normalize/normalizeHeaders'); + +describe('normalizeHeaders', () => { + describe('when given nothing', () => { + const headers = normalizeHeaders(undefined); + + it('coerces to empty object', () => { + assert.deepEqual(headers, {}); + }); + }); + + describe('when given headers object', () => { + const headers = { + 'Accept-Language': 'en-US, en', + 'Content-Type': 'application/json', + 'Content-Length': 128 + }; + const normalizedHeaders = normalizeHeaders(headers); + + it('lowercases the keys', () => { + const expectedKeys = Object.keys(headers).map((key) => key.toLowerCase()); + assert.hasAllKeys(normalizedHeaders, expectedKeys); + }); + + it('lowercases the values', () => { + const expectedValues = Object.values(headers).map((value) => + typeof value === 'string' ? value.toLowerCase() : value + ); + assert.deepEqual(Object.values(normalizedHeaders), expectedValues); + }); + }); + + describe('when given non-object headers', () => { + const unsupportedTypes = [['string', 'foo'], ['number', 2]]; + + unsupportedTypes.forEach(([typeName, dataType]) => { + it(`when given ${typeName}`, () => { + assert.throw(() => normalizeHeaders(dataType)); + }); + }); + }); +}); diff --git a/lib/next/test/unit/units/normalize/normalizeStatusCode.test.js b/lib/next/test/unit/units/normalize/normalizeStatusCode.test.js new file mode 100644 index 00000000..6cac3908 --- /dev/null +++ b/lib/next/test/unit/units/normalize/normalizeStatusCode.test.js @@ -0,0 +1,30 @@ +const { assert } = require('chai'); +const { + normalizeStatusCode +} = require('../../../../units/normalize/normalizeStatusCode'); + +describe('normalizeStatusCode', () => { + describe('when given a string', () => { + const normalized = normalizeStatusCode(' 400 '); + + it('returns a string', () => { + assert.isString(normalized); + }); + + it('trims the value', () => { + assert.equal(normalized, '400'); + }); + }); + + describe('when given falsy value', () => { + const values = [null, undefined]; + + values.forEach((value) => { + const normalized = normalizeStatusCode(value); + + it(`returns empty string when given ${value}`, () => { + assert.equal(normalized, ''); + }); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateBody.test.js b/lib/next/test/unit/units/validateBody.test.js new file mode 100644 index 00000000..2449439c --- /dev/null +++ b/lib/next/test/unit/units/validateBody.test.js @@ -0,0 +1,351 @@ +const { assert } = require('chai'); +const { validateBody } = require('../../../units/validateBody'); + +describe('validateBody', () => { + describe('when given unsupported body type', () => { + const scenarios = [ + { name: 'number', value: 5 }, + { name: 'array', value: ['foo', 'bar'] }, + { name: 'object', value: { foo: 'bar' } }, + { name: 'null', value: null }, + { name: 'undefined', value: undefined } + ]; + + scenarios.forEach(({ name, value }) => { + it(`throws when given ${name}`, () => { + assert.throw(() => validateBody({ body: value }, { body: value })); + }); + }); + }); + + describe('when given supported body type', () => { + describe('with explicit "Content-Type" header', () => { + describe('application/json', () => { + describe('with matching body type', () => { + const res = validateBody( + { + body: '{ "foo": "bar" }', + headers: { 'content-type': 'application/json' } + }, + { + body: '{ "foo": "bar" }' + } + ); + + it('has "JsonExample" validator', () => { + assert.propertyVal(res, 'validator', 'JsonExample'); + }); + + it('has "application/json" real type', () => { + assert.propertyVal(res, 'realType', 'application/json'); + }); + + it('has "application/json" expected type', () => { + assert.propertyVal(res, 'expectedType', 'application/json'); + }); + + it('has no errors', () => { + assert.lengthOf(res.results, 0); + }); + }); + + describe('with non-matching body type', () => { + const res = validateBody( + { + body: 'foo', + headers: { 'content-type': 'application/json' } + }, + { body: '{ "foo": "bar" }' } + ); + + it('has no validator', () => { + assert.propertyVal(res, 'validator', null); + }); + + it('fallbacks to "text/plain" real type', () => { + assert.propertyVal(res, 'realType', 'text/plain'); + }); + + it('has "application/json" expected type', () => { + assert.propertyVal(res, 'expectedType', 'application/json'); + }); + + describe('produces content-type error', () => { + it('has "error" severity', () => { + assert.propertyVal(res.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.match( + res.results[0].message, + /^Can't validate: real body 'Content-Type' header is 'application\/json' but body is not a parseable JSON:/ + ); + }); + }); + }); + }); + + describe('application/hal+json', () => { + describe('with matching body type', () => { + const res = validateBody( + { + body: '{ "foo": "bar" }', + headers: { + 'content-type': 'application/hal+json' + } + }, + { + body: '{ "foo": "bar" }' + } + ); + + it('has "JsonExample" validator', () => { + assert.propertyVal(res, 'validator', 'JsonExample'); + }); + + it('has "application/hal+json" real type', () => { + assert.propertyVal(res, 'realType', 'application/hal+json'); + }); + + it('has "application/json" expected type', () => { + assert.propertyVal(res, 'expectedType', 'application/json'); + }); + + it('has no errors', () => { + assert.lengthOf(res.results, 0); + }); + }); + + describe('with non-matching body type', () => { + const res = validateBody( + { + body: 'text', + headers: { + 'content-type': 'application/hal+json' + } + }, + { + body: 'text' + } + ); + + it('has no validator', () => { + assert.propertyVal(res, 'validator', null); + }); + + it('fallbacks to "text/plain" real type', () => { + assert.propertyVal(res, 'realType', 'text/plain'); + }); + + it('has "text/plain" expected type', () => { + assert.propertyVal(res, 'expectedType', 'text/plain'); + }); + + describe('produces error', () => { + it('has "error" severity', () => { + assert.propertyVal(res.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.match( + res.results[0].message, + /^Can't validate: real body 'Content-Type' header is 'application\/hal\+json' but body is not a parseable JSON:/ + ); + }); + }); + }); + }); + }); + + describe('without explicit "Content-Type" header', () => { + describe('text/plain', () => { + describe('with matching bodies', () => { + const res = validateBody({ body: 'foo' }, { body: 'foo' }); + + it('has "TextDiff" validator', () => { + assert.propertyVal(res, 'validator', 'TextDiff'); + }); + + it('has text/plain real type', () => { + assert.propertyVal(res, 'realType', 'text/plain'); + }); + + it('has "text/plain" expected type', () => { + assert.propertyVal(res, 'expectedType', 'text/plain'); + }); + + it('has no errors', () => { + assert.lengthOf(res.results, 0); + }); + }); + + describe('with non-matching bodies', () => { + const res = validateBody({ body: 'foo ' }, { body: 'bar' }); + + it('has "TextDiff" validator', () => { + assert.propertyVal(res, 'validator', 'TextDiff'); + }); + + it('has "text/plain" real type', () => { + assert.propertyVal(res, 'realType', 'text/plain'); + }); + + it('has "text/plain" expected type', () => { + assert.propertyVal(res, 'expectedType', 'text/plain'); + }); + + describe('produces validation error', () => { + it('exactly one error', () => { + assert.lengthOf(res.results, 1); + }); + + it('with "error" severity', () => { + assert.propertyVal(res.results[0], 'severity', 'error'); + }); + + it('with explanatory message', () => { + assert.hasAnyKeys(res.results[0], 'message'); + assert.propertyVal( + res.results[0], + 'message', + 'Real and expected data does not match.' + ); + }); + }); + }); + }); + + describe('application/json', () => { + describe('with matching bodies', () => { + const res = validateBody( + { body: '{ "foo": "bar" }' }, + { body: '{ "foo": "bar" }' } + ); + + it('has "JsonExample" validator', () => { + assert.propertyVal(res, 'validator', 'JsonExample'); + }); + + it('has "application/json" real type', () => { + assert.propertyVal(res, 'realType', 'application/json'); + }); + + it('has "application/json" expected type', () => { + assert.propertyVal(res, 'expectedType', 'application/json'); + }); + + it('has no errors', () => { + assert.lengthOf(res.results, 0); + }); + }); + + describe('with non-matching bodies', () => { + const res = validateBody( + { body: '{ "foo": "bar" }' }, + { body: '{ "bar": null }' } + ); + + it('has "JsonExample" validator', () => { + assert.propertyVal(res, 'validator', 'JsonExample'); + }); + + it('has "application/json" real type', () => { + assert.propertyVal(res, 'realType', 'application/json'); + }); + + it('has "application/json" expected type', () => { + assert.propertyVal(res, 'expectedType', 'application/json'); + }); + + describe('produces validation errors', () => { + it('exactly one error', () => { + assert.lengthOf(res.results, 1); + }); + + it('has "error" severity', () => { + assert.propertyVal(res.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.propertyVal( + res.results[0], + 'message', + `At '/bar' Missing required property: bar` + ); + }); + }); + }); + }); + + describe('application/schema+json', () => { + describe('with matching bodies', () => { + const res = validateBody( + { body: '{ "foo": "bar" }' }, + { + bodySchema: { + required: ['foo'] + } + } + ); + + it('has "JsonSchema" validator', () => { + assert.propertyVal(res, 'validator', 'JsonSchema'); + }); + + it('has "application/json" real type', () => { + assert.propertyVal(res, 'realType', 'application/json'); + }); + + it('has "application/schema+json" expected type', () => { + assert.propertyVal(res, 'expectedType', 'application/schema+json'); + }); + + it('has no errors', () => { + assert.lengthOf(res.results, 0); + }); + }); + + describe('with non-matching bodies', () => { + const res = validateBody( + { body: '{ "oneTwoThree": "bar" }' }, + { + bodySchema: { + required: ['doe'] + } + } + ); + + it('has "JsonSchema" validator', () => { + assert.propertyVal(res, 'validator', 'JsonSchema'); + }); + + it('has "application/json" real type', () => { + assert.propertyVal(res, 'realType', 'application/json'); + }); + + it('has "application/schema+json" expected type', () => { + assert.propertyVal(res, 'expectedType', 'application/schema+json'); + }); + + describe('produces an error', () => { + it('exactly one error', () => { + assert.lengthOf(res.results, 1); + }); + + it('has "error" severity', () => { + assert.propertyVal(res.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.propertyVal( + res.results[0], + 'message', + `At '/doe' Missing required property: doe` + ); + }); + }); + }); + }); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateBody/getBodySchemaType.test.js b/lib/next/test/unit/units/validateBody/getBodySchemaType.test.js new file mode 100644 index 00000000..adc102e2 --- /dev/null +++ b/lib/next/test/unit/units/validateBody/getBodySchemaType.test.js @@ -0,0 +1,71 @@ +const { assert } = require('chai'); +const mediaTyper = require('media-typer'); +const { getBodySchemaType } = require('../../../../units/validateBody'); + +describe('getBodySchemaType', () => { + describe('when given non-string schema', () => { + const values = [ + ['number', 123], + ['object', { foo: 'bar' }], + ['array', [1, 2, 3]], + ['null', null], + ['undefined', undefined] + ]; + + values.forEach(([name, value]) => { + describe(`which is ${name}`, () => { + const [error, mediaType] = getBodySchemaType(value); + + it('returns "application/schema+json" media type', () => { + assert.deepEqual( + mediaType, + mediaTyper.parse('application/schema+json') + ); + }); + + it('has no errors', () => { + assert.isNull(error); + }); + }); + }); + }); + + describe('when given JSON', () => { + const jsonSchemas = [ + ['object', '{ "foo": "bar" }'], + ['array', '[1, 2, 3]'] + ]; + + jsonSchemas.forEach(([name, jsonSchema]) => { + describe(`that contains ${name}`, () => { + const [error, mediaType] = getBodySchemaType(jsonSchema); + + it('returns "application/schema+json" media type', () => { + assert.deepEqual( + mediaType, + mediaTyper.parse('application/schema+json') + ); + }); + + it('returns no errors', () => { + assert.isNull(error); + }); + }); + }); + }); + + describe('when given non-JSON string', () => { + const [error, mediaType] = getBodySchemaType('foo'); + + it('returns no media type', () => { + assert.isNull(mediaType); + }); + + it('returns parsing error', () => { + assert.match( + error, + /^Can't validate: expected body JSON Schema is not a parseable JSON:/ + ); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateBody/getBodyType.test.js b/lib/next/test/unit/units/validateBody/getBodyType.test.js new file mode 100644 index 00000000..d9ba8dd3 --- /dev/null +++ b/lib/next/test/unit/units/validateBody/getBodyType.test.js @@ -0,0 +1,78 @@ +const { assert } = require('chai'); +const mediaTyper = require('media-typer'); +const { getBodyType } = require('../../../../units/validateBody'); + +const jsonTypes = ['application/json', 'application/schema+json']; +const nonJsonTypes = ['text/plain']; + +describe('getBodyType', () => { + describe('when given JSON-like content type', () => { + jsonTypes.forEach((jsonType) => { + describe(`when given "${jsonType}" content type`, () => { + describe('and parsable JSON body', () => { + const res = getBodyType('{ "foo": "bar" }', jsonType, 'real'); + + it('returns no errors', () => { + assert.isNull(res[0]); + }); + + it(`returns "${jsonType}" media type`, () => { + assert.deepEqual(res[1], mediaTyper.parse(jsonType)); + }); + }); + + describe('and non-parsable JSON body', () => { + const res = getBodyType('abc', jsonType, 'real'); + + it('returns parsing error', () => { + assert.match( + res[0], + new RegExp( + /* eslint-disable no-useless-escape */ + `^Can't validate: real body 'Content-Type' header is \'${jsonType.replace( + /(\/|\+)/g, + '\\$1' + )}\' but body is not a parseable JSON:` + /* eslint-enable no-useless-escape */ + ) + ); + }); + + it('uses "text/plain" as fallback media type', () => { + assert.deepEqual(res[1], mediaTyper.parse('text/plain')); + }); + }); + }); + }); + }); + + describe('when given non-JSON content type', () => { + nonJsonTypes.forEach((nonJsonType) => { + describe(`when given "${nonJsonType}" content type`, () => { + describe('and parsable json body', () => { + const res = getBodyType('{ "foo": "bar" }', nonJsonType, 'real'); + + it('returns no errors', () => { + assert.isNull(res[0]); + }); + + it('coerces to "application/json" media type', () => { + assert.deepEqual(res[1], mediaTyper.parse('application/json')); + }); + }); + + describe('and a non-JSON body', () => { + const res = getBodyType('abc', nonJsonType, 'real'); + + it('returns no errors', () => { + assert.isNull(res[0]); + }); + + it(`returns "${nonJsonType}" media type`, () => { + assert.deepEqual(res[1], mediaTyper.parse(nonJsonType)); + }); + }); + }); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateBody/getBodyValidator.test.js b/lib/next/test/unit/units/validateBody/getBodyValidator.test.js new file mode 100644 index 00000000..16bf3d2e --- /dev/null +++ b/lib/next/test/unit/units/validateBody/getBodyValidator.test.js @@ -0,0 +1,66 @@ +const { assert } = require('chai'); +const mediaTyper = require('media-typer'); +const { getBodyValidator } = require('../../../../units/validateBody'); + +const getMediaTypes = (real, expected) => { + return [real, expected].map(mediaTyper.parse); +}; + +describe('getBodyValidator', () => { + describe('when given known media type', () => { + const knownContentTypes = [ + { + contentTypes: ['application/json', 'application/json'], + expectedValidator: 'JsonExample' + }, + { + contentTypes: ['application/json', 'application/schema+json'], + expectedValidator: 'JsonSchema' + }, + { + contentTypes: ['text/plain', 'text/plain'], + expectedValidator: 'TextDiff' + } + ]; + + knownContentTypes.forEach(({ contentTypes, expectedValidator }) => { + const [realContentType, expectedContentType] = contentTypes; + const [real, expected] = getMediaTypes( + realContentType, + expectedContentType + ); + + describe(`${realContentType} + ${expectedContentType}`, () => { + const [error, validator] = getBodyValidator(real, expected); + + it('returns no error', () => { + assert.isNull(error); + }); + + it(`returns "${expectedValidator}" validator`, () => { + assert.equal(validator.name, expectedValidator); + }); + }); + }); + }); + + // describe('when given unknown media type', () => { + // const unknownContentTypes = [['text/html', 'text/xml']]; + + // unknownContentTypes.forEach((contentTypes) => { + // const [realContentType, expectedContentType] = contentTypes; + // const [real, expected] = getMediaTypes( + // realContentType, + // expectedContentType + // ); + + // describe(`${realContentType} + ${expectedContentType}`, () => { + // const [error, validator] = getBodyValidator(real, expected); + + // it('...', () => { + // console.log({ error, validator }); + // }); + // }); + // }); + // }); +}); diff --git a/lib/next/test/unit/units/validateBody/isJson.test.js b/lib/next/test/unit/units/validateBody/isJson.test.js new file mode 100644 index 00000000..54ec2008 --- /dev/null +++ b/lib/next/test/unit/units/validateBody/isJson.test.js @@ -0,0 +1,29 @@ +const { assert } = require('chai'); +const mediaTyper = require('media-typer'); +const { isJson } = require('../../../../units/validateBody'); + +describe('isJson', () => { + describe('returns true', () => { + it('when given valid JSON media type', () => { + assert.isTrue(isJson(mediaTyper.parse('application/json'))); + }); + + it('when given JSON-compatible media type', () => { + assert.isTrue(isJson(mediaTyper.parse('application/schema+json'))); + }); + }); + + describe('returns false', () => { + it('when given non-JSON media type', () => { + assert.isFalse(isJson(mediaTyper.parse('text/plain'))); + }); + + it('when given rubbish', () => { + assert.isFalse(isJson('abc')); + }); + + it('when given null', () => { + assert.isFalse(isJson(null)); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateBody/isJsonContentType.test.js b/lib/next/test/unit/units/validateBody/isJsonContentType.test.js new file mode 100644 index 00000000..10d7f0f5 --- /dev/null +++ b/lib/next/test/unit/units/validateBody/isJsonContentType.test.js @@ -0,0 +1,28 @@ +const { assert } = require('chai'); +const { isJsonContentType } = require('../../../../units/validateBody'); + +describe('isJsonContentType', () => { + describe('returns true', () => { + const jsonTypes = ['application/json', 'application/schema+json']; + + jsonTypes.forEach((contentType) => { + it(`when given ${contentType}`, () => { + assert.isTrue(isJsonContentType(contentType)); + }); + }); + }); + + describe('returns false', () => { + const nonJsonTypes = ['application/xml', 'text/plain']; + + nonJsonTypes.forEach((contentType) => { + it(`when given ${contentType}`, () => { + assert.isFalse(isJsonContentType(contentType)); + }); + }); + + it('when given rubbish', () => { + assert.isFalse(isJsonContentType('foo')); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateBody/isJsonSchema.test.js b/lib/next/test/unit/units/validateBody/isJsonSchema.test.js new file mode 100644 index 00000000..09f8ff86 --- /dev/null +++ b/lib/next/test/unit/units/validateBody/isJsonSchema.test.js @@ -0,0 +1,25 @@ +const { assert } = require('chai'); +const mediaTyper = require('media-typer'); +const { isJsonSchema } = require('../../../../units/validateBody'); + +describe('isJsonSchema', () => { + describe('returns true', () => { + it('when given a JSON schema media type', () => { + assert.isTrue(isJsonSchema(mediaTyper.parse('application/schema+json'))); + }); + }); + + describe('returns false', () => { + it('when given json media type', () => { + assert.isFalse(isJsonSchema(mediaTyper.parse('application/json'))); + }); + + it('when given rubbish', () => { + assert.isFalse(isJsonSchema('abc')); + }); + + it('when given null', () => { + assert.isFalse(isJsonSchema(null)); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateHeaders.test.js b/lib/next/test/unit/units/validateHeaders.test.js new file mode 100644 index 00000000..530a6884 --- /dev/null +++ b/lib/next/test/unit/units/validateHeaders.test.js @@ -0,0 +1,160 @@ +const { assert } = require('chai'); +const { validateHeaders } = require('../../../units/validateHeaders'); + +describe('validateHeaders', () => { + describe('given matching headers', () => { + const res = validateHeaders( + { + headers: { + 'content-type': 'application/json', + connection: 'keep-alive' + } + }, + { + headers: { + 'content-type': 'application/json', + connection: 'keep-alive' + } + } + ); + + it('has "HeadersJsonExample" validator', () => { + assert.propertyVal(res, 'validator', 'HeadersJsonExample'); + }); + + it('has "application/vnd.apiary.http-headers+json" real type', () => { + assert.propertyVal( + res, + 'realType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has "application/vnd.apiary.http-headers+json" expected type', () => { + assert.propertyVal( + res, + 'expectedType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has no errors', () => { + assert.deepPropertyVal(res, 'results', []); + }); + }); + + describe('given non-matching headers', () => { + const res = validateHeaders( + { + headers: { + connection: 'keep-alive' + } + }, + { + headers: { + 'accept-language': 'en-US,us', + 'content-type': 'application/json', + connection: 'keep-alive' + } + } + ); + + it('has "HeadersJsonExample" validator', () => { + assert.propertyVal(res, 'validator', 'HeadersJsonExample'); + }); + + it('has "application/vnd.apiary.http-headers+json" real type', () => { + assert.propertyVal( + res, + 'realType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + it('has "application/vnd.apiary.http-headers+json" expected type', () => { + assert.propertyVal( + res, + 'expectedType', + 'application/vnd.apiary.http-headers+json' + ); + }); + + describe('produces errors', () => { + const missingHeaders = ['accept-language', 'content-type']; + + it('for two missing headers', () => { + assert.lengthOf(res.results, missingHeaders.length); + }); + + describe('for each missing header', () => { + missingHeaders.forEach((headerName, index) => { + describe(headerName, () => { + it('has "error" severity', () => { + assert.propertyVal(res.results[index], 'severity', 'error'); + }); + + it('has pointer to header name', () => { + assert.propertyVal( + res.results[index], + 'pointer', + `/${headerName}` + ); + }); + + it('has explanatory message', () => { + assert.propertyVal( + res.results[index], + 'message', + `At '/${headerName}' Missing required property: ${headerName}` + ); + }); + }); + }); + }); + }); + }); + + describe('given non-json headers', () => { + const res = validateHeaders( + { + headers: 'foo' + }, + { + headers: 'bar' + } + ); + + it('has no validator', () => { + assert.propertyVal(res, 'validator', null); + }); + + it('has no real type', () => { + assert.propertyVal(res, 'realType', null); + }); + + it('has no expected type', () => { + assert.propertyVal(res, 'expectedType', null); + }); + + describe('produces an error', () => { + it('has one error', () => { + assert.lengthOf(res.results, 1); + }); + + it('has "error" severity', () => { + assert.propertyVal(res.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.propertyVal( + res.results[0], + 'message', + `No validator found for real data media type +"null" +and expected data media type +"null".` + ); + }); + }); + }); +}); diff --git a/lib/next/test/unit/units/validateStatusCode.test.js b/lib/next/test/unit/units/validateStatusCode.test.js new file mode 100644 index 00000000..651fc121 --- /dev/null +++ b/lib/next/test/unit/units/validateStatusCode.test.js @@ -0,0 +1,72 @@ +const { assert } = require('chai'); +const { validateStatusCode } = require('../../../units/validateStatusCode'); + +describe('validateStatusCode', () => { + describe('given matching status codes', () => { + const result = validateStatusCode( + { + statusCode: '200' + }, + { + statusCode: '200' + } + ); + + it('has "TextDiff" validator', () => { + assert.propertyVal(result, 'validator', 'TextDiff'); + }); + + it('has "text/vnd.apiary.status-code" expected type', () => { + assert.propertyVal(result, 'expectedType', 'text/vnd.apiary.status-code'); + }); + + it('has "text/vnd.apiary.status-code" real type', () => { + assert.propertyVal(result, 'realType', 'text/vnd.apiary.status-code'); + }); + + it('has no errors', () => { + assert.deepPropertyVal(result, 'results', []); + }); + }); + + describe('given non-matching status codes', () => { + const result = validateStatusCode( + { + statusCode: '200' + }, + { + statusCode: '400' + } + ); + + it('has "TextDiff" validator', () => { + assert.propertyVal(result, 'validator', 'TextDiff'); + }); + + it('has "text/vnd.apiary.status-code" expected type', () => { + assert.propertyVal(result, 'expectedType', 'text/vnd.apiary.status-code'); + }); + + it('has "text/vnd.apiary.status-code" real type', () => { + assert.propertyVal(result, 'realType', 'text/vnd.apiary.status-code'); + }); + + describe('produces error', () => { + it('exactly one error', () => { + assert.lengthOf(result.results, 1); + }); + + it('has "error" severity', () => { + assert.propertyVal(result.results[0], 'severity', 'error'); + }); + + it('has explanatory message', () => { + assert.propertyVal( + result.results[0], + 'message', + 'Real and expected data does not match.' + ); + }); + }); + }); +}); diff --git a/lib/next/test/unit/utils/evolve.test.js b/lib/next/test/unit/utils/evolve.test.js new file mode 100644 index 00000000..8c759a1b --- /dev/null +++ b/lib/next/test/unit/utils/evolve.test.js @@ -0,0 +1,82 @@ +const { assert } = require('chai'); +const evolve = require('../../../utils/evolve'); + +const multiply = (a) => (n) => n * a; +const unexpectedTypes = [ + ['number', 5], + ['string', 'foo'], + ['function', () => null], + ['null', null], + ['undefined', undefined] +]; + +describe('evolve', () => { + describe('evolves a given object', () => { + const res = evolve({ + a: multiply(2), + c: () => null + })({ + a: 2, + b: 'foo' + }); + + it('returns object', () => { + assert.isObject(res); + }); + + it('evolves matching properties', () => { + assert.propertyVal(res, 'a', 4); + }); + + it('bypasses properties not in schema', () => { + assert.propertyVal(res, 'b', 'foo'); + }); + + it('ignores properties not in data', () => { + assert.notProperty(res, 'c'); + }); + }); + + describe('evolves a given array', () => { + const res = evolve({ + 0: multiply(2), + 1: multiply(3), + 3: multiply(4) + })([1, 2, 3]); + + it('returns array', () => { + assert.isArray(res); + }); + + it('evolves matching keys', () => { + assert.propertyVal(res, 0, 2); + assert.propertyVal(res, 1, 6); + }); + + it('bypasses keys not in schema', () => { + assert.propertyVal(res, 2, 3); + }); + + it('ignores properties not in data', () => { + assert.notProperty(res, 3); + }); + }); + + describe('throws when given unexpected schema', () => { + unexpectedTypes + .concat([['array', [1, 2]]]) + .forEach(([typeName, dataType]) => { + it(`when given ${typeName}`, () => { + assert.throw(() => evolve(dataType)({})); + }); + }); + }); + + describe('throws when given unexpected data', () => { + unexpectedTypes.forEach(([typeName, dataType]) => { + it(`when given ${typeName}`, () => { + assert.throw(() => evolve({ a: () => null })(dataType)); + }); + }); + }); +}); diff --git a/lib/next/units/normalize/index.js b/lib/next/units/normalize/index.js new file mode 100644 index 00000000..b5c0a9d9 --- /dev/null +++ b/lib/next/units/normalize/index.js @@ -0,0 +1,10 @@ +const evolve = require('../../utils/evolve'); +const { normalizeStatusCode } = require('./normalizeStatusCode'); +const { normalizeHeaders } = require('./normalizeHeaders'); + +const normalize = evolve({ + statusCode: normalizeStatusCode, + headers: normalizeHeaders +}); + +module.exports = { normalize }; diff --git a/lib/next/units/normalize/normalizeHeaders.js b/lib/next/units/normalize/normalizeHeaders.js new file mode 100644 index 00000000..a6c2b1b9 --- /dev/null +++ b/lib/next/units/normalize/normalizeHeaders.js @@ -0,0 +1,38 @@ +const normalizeStringValue = (value) => { + return value.toLowerCase(); +}; + +/** + * Normalizes the given headers. + * @param {Object} headers + * @returns {Object} + */ +const normalizeHeaders = (headers) => { + if (!headers) { + return {}; + } + + const headersType = typeof headers; + const isHeadersNull = headers == null; + + if (isHeadersNull || headersType !== 'object') { + throw new Error( + `Can't validate: expected "headers" to be an Object, but got: ${ + isHeadersNull ? 'null' : headersType + }.` + ); + } + + return Object.keys(headers).reduce( + (normalizedHeaders, name) => ({ + ...normalizedHeaders, + [name.toLowerCase()]: + typeof headers[name] === 'string' + ? normalizeStringValue(headers[name]) + : headers[name] + }), + {} + ); +}; + +module.exports = { normalizeHeaders }; diff --git a/lib/next/units/normalize/normalizeStatusCode.js b/lib/next/units/normalize/normalizeStatusCode.js new file mode 100644 index 00000000..4673856c --- /dev/null +++ b/lib/next/units/normalize/normalizeStatusCode.js @@ -0,0 +1,5 @@ +function normalizeStatusCode(value) { + return value == null ? '' : String(value).trim(); +} + +module.exports = { normalizeStatusCode }; diff --git a/lib/next/units/validateBody.js b/lib/next/units/validateBody.js new file mode 100644 index 00000000..71718354 --- /dev/null +++ b/lib/next/units/validateBody.js @@ -0,0 +1,234 @@ +const jph = require('json-parse-helpfulerror'); +const mediaTyper = require('media-typer'); +const contentTypeUtils = require('content-type'); + +const { TextDiff } = require('../../../lib/validators/text-diff'); +const { JsonExample } = require('../../../lib/validators/json-example'); +const { JsonSchema } = require('../../../lib/validators/json-schema'); + +function isPlainText(mediaType) { + return mediaType.type === 'text' && mediaType.subtype === 'plain'; +} + +function isJson(mediaType) { + if (!mediaType) { + return false; + } + + return ( + (mediaType.type === 'application' && mediaType.subtype === 'json') || + mediaType.suffix === 'json' + ); +} + +function isJsonSchema(mediaType) { + if (!mediaType) { + return false; + } + + return ( + mediaType.type === 'application' && + mediaType.subtype === 'schema' && + mediaType.suffix === 'json' + ); +} + +/** + * Parses a given content-type header into media type. + * @param {string} contentType + * @returns {[Error, MediaType]} + */ +function parseContentType(contentType) { + try { + const { type } = contentTypeUtils.parse(`${contentType}`); + return mediaTyper.parse(type); + } catch (error) { + return null; + } +} + +/** + * Determines if a given 'Content-Type' header contains JSON. + * @param {string} contentType + * @returns {boolean} + */ +function isJsonContentType(contentType) { + const mediaType = parseContentType(contentType); + return mediaType ? isJson(mediaType) : false; +} + +/** + * Returns a tuple of error and body media type based + * on the given body and normalized headers. + * @param {string} body + * @param {Object} headers + * @param {'real'|'expected'} bodyType + * @returns {[error, bodyType]} + */ +function getBodyType(body, contentType, httpMessageOrigin) { + const hasJsonContentType = isJsonContentType(contentType); + + try { + jph.parse(body); + const bodyMediaType = parseContentType( + hasJsonContentType ? contentType : 'application/json' + ); + + return [null, bodyMediaType]; + } catch (parsingError) { + const fallbackMediaType = mediaTyper.parse('text/plain'); + const error = hasJsonContentType + ? `Can't validate: ${httpMessageOrigin} body 'Content-Type' header is '${contentType}' \ +but body is not a parseable JSON: +${parsingError.message}` + : null; + + return [error, fallbackMediaType]; + } +} + +/** + * Returns a tuple of error and schema media type + * based on given body schema. + * @param {string} bodySchema + * @returns {[error, schemaType]} + */ +function getBodySchemaType(bodySchema) { + const jsonSchemaType = mediaTyper.parse('application/schema+json'); + + if (typeof bodySchema !== 'string') { + return [null, jsonSchemaType]; + } + + try { + jph.parse(bodySchema); + return [null, jsonSchemaType]; + } catch (exception) { + const error = `Can't validate: expected body JSON Schema is not a parseable JSON:\n${ + exception.message + }`; + + return [error, null]; + } +} + +/** + * Returns a body validator class based on the given + * real and expected body media types. + * @param {MediaType} realType + * @param {MediaType} expectedType + * @returns {Validator} + */ +function getBodyValidator(realType, expectedType) { + const both = (predicate) => (real, expected) => { + return [real, expected].every(predicate); + }; + + const validators = [ + [TextDiff, both(isPlainText)], + // List JsonSchema first, because weak predicate of JsonExample + // would resolve on "application/schema+json" media type too. + [ + JsonSchema, + (real, expected) => { + return isJson(real) && isJsonSchema(expected); + } + ], + [JsonExample, both(isJson)] + ]; + + const validator = validators.find(([_name, predicate]) => { + return predicate(realType, expectedType); + }); + + return [null, validator[0]]; +} + +/** + * Validates given bodies of transaction elements. + * @param {Object} real + * @param {Object} expected + */ +function validateBody(real, expected) { + const results = []; + const bodyType = typeof real.body; + + if (bodyType !== 'string') { + throw new Error(`Expected HTTP body to be a String, but got: ${bodyType}`); + } + + const [realTypeError, realType] = getBodyType( + real.body, + real.headers && real.headers['content-type'], + 'real' + ); + const [expectedTypeError, expectedType] = expected.bodySchema + ? getBodySchemaType(expected.bodySchema) + : getBodyType( + expected.body, + expected.headers && expected.headers['content-type'], + 'expected' + ); + + if (realTypeError) { + results.push({ + message: realTypeError, + severity: 'error' + }); + } + + if (expectedTypeError) { + results.push({ + message: expectedTypeError, + severity: 'error' + }); + } + + const hasErrors = results.some((result) => + ['error'].includes(result.severity) + ); + + // Skipping body validation in case errors during + // real/expected body type definition. + const [validatorError, ValidatorClass] = hasErrors + ? [null, null] + : getBodyValidator(realType, expectedType); + + if (validatorError) { + results.push({ + message: validatorError, + severity: 'error' + }); + } + + const usesJsonSchema = ValidatorClass && ValidatorClass.name === 'JsonSchema'; + const validator = + ValidatorClass && + new ValidatorClass( + real.body, + usesJsonSchema ? expected.bodySchema : expected.body + ); + const rawData = validator && validator.validate(); + const validatorResults = validator ? validator.evaluateOutputToResults() : []; + results.push(...validatorResults); + + return { + validator: ValidatorClass && ValidatorClass.name, + realType: mediaTyper.format(realType), + expectedType: mediaTyper.format(expectedType), + rawData, + results + }; +} + +module.exports = { + validateBody, + + isJson, + isJsonSchema, + isJsonContentType, + parseContentType, + getBodyType, + getBodySchemaType, + getBodyValidator +}; diff --git a/lib/next/units/validateHeaders.js b/lib/next/units/validateHeaders.js new file mode 100644 index 00000000..27f530cb --- /dev/null +++ b/lib/next/units/validateHeaders.js @@ -0,0 +1,53 @@ +const { HeadersJsonExample } = require('../../validators/headers-json-example'); + +const APIARY_JSON_HEADER_TYPE = 'application/vnd.apiary.http-headers+json'; + +function getHeadersType(headers) { + return headers instanceof Object && !Array.isArray(headers) + ? APIARY_JSON_HEADER_TYPE + : null; +} + +/** + * Validates given real and expected transaction elements. + * @param {Object} real + * @param {Object} expected + */ +function validateHeaders(real, expected) { + const realType = getHeadersType(real.headers); + const expectedType = getHeadersType(expected.headers); + const results = []; + + const hasJsonHeaders = + realType === APIARY_JSON_HEADER_TYPE && + expectedType === APIARY_JSON_HEADER_TYPE; + + const validator = hasJsonHeaders + ? new HeadersJsonExample(real.headers, expected.headers) + : null; + const rawData = validator && validator.validate(); + + if (validator) { + results.push(...validator.evaluateOutputToResults()); + } else { + results.push({ + message: `\ +No validator found for real data media type +"${realType}" +and expected data media type +"${expectedType}".\ +`, + severity: 'error' + }); + } + + return { + validator: validator && 'HeadersJsonExample', + realType, + expectedType, + rawData, + results + }; +} + +module.exports = { validateHeaders }; diff --git a/lib/next/units/validateStatusCode.js b/lib/next/units/validateStatusCode.js new file mode 100644 index 00000000..e668a500 --- /dev/null +++ b/lib/next/units/validateStatusCode.js @@ -0,0 +1,23 @@ +const { TextDiff } = require('../../validators/text-diff'); + +const APIARY_STATUS_CODE_TYPE = 'text/vnd.apiary.status-code'; + +/** + * Validates given real and expected status codes. + * @param {Object} real + * @param {number} expected + */ +function validateStatusCode(real, expected) { + const validator = new TextDiff(real.statusCode, expected.statusCode); + const rawData = validator.validate(); + + return { + validator: 'TextDiff', + realType: APIARY_STATUS_CODE_TYPE, + expectedType: APIARY_STATUS_CODE_TYPE, + rawData, + results: validator.evaluateOutputToResults() + }; +} + +module.exports = { validateStatusCode }; diff --git a/lib/next/utils/evolve.js b/lib/next/utils/evolve.js new file mode 100644 index 00000000..cd2bdb6e --- /dev/null +++ b/lib/next/utils/evolve.js @@ -0,0 +1,44 @@ +/** + * Applies a given evolution schema to the given data Object. + * Properties not present in schema are bypassed. + * Properties not present in data are ignored. + * @param {Object} schema + * @param {Object|Array} data + * @returns {Object} + */ +const evolve = (schema) => (data) => { + const dataType = typeof data; + const schemaType = typeof schema; + const isArray = Array.isArray(data); + const result = isArray ? [] : {}; + + if (!(schema !== null && schemaType === 'object' && !Array.isArray(schema))) { + throw new Error( + `Failed to evolve: expected transformations schema to be an object, but got: ${schemaType}` + ); + } + + if (dataType !== 'object') { + throw new Error( + `Failed to evolve: expected data to be an instance of array or object, but got: ${dataType}` + ); + } + + return Object.keys(data).reduce((acc, key) => { + const value = data[key]; + const transform = schema[key]; + const transformType = typeof transform; + /* eslint-disable no-nested-ternary */ + const nextValue = + transformType === 'function' + ? transform(value) + : transform && transformType === 'object' + ? evolve(transform)(value) + : value; + /* eslint-enable no-nested-ternary */ + + return isArray ? acc.concat(nextValue) : { ...acc, [key]: nextValue }; + }, result); +}; + +module.exports = evolve; diff --git a/lib/next/validate.js b/lib/next/validate.js new file mode 100644 index 00000000..14a6859a --- /dev/null +++ b/lib/next/validate.js @@ -0,0 +1,33 @@ +const { validateMessage } = require('./validateMessage'); + +/** + * Validates the given HTTP messages pair and returns + * a legacy-compliant validation results. + * @param {Object} real + * @param {Object} expected + * @param {'request'|'response'} type + * @param {(error: Error, result: Object) => void} callback + */ +function validate(real, expected, type, callback) { + if (type !== 'request' && type !== 'response') { + throw new Error( + `Can't validate: expected transaction "type" to be "request" or "response", but got: ${type}.` + ); + } + + let results; + + try { + results = validateMessage(real, expected); + } catch (error) { + callback(error, null); + return; + } + + callback(null, { + version: '2', + ...results + }); +} + +module.exports = validate; diff --git a/lib/next/validateMessage.js b/lib/next/validateMessage.js new file mode 100644 index 00000000..172d4582 --- /dev/null +++ b/lib/next/validateMessage.js @@ -0,0 +1,30 @@ +const isset = require('../utils/isset'); +const { normalize } = require('./units/normalize'); +const { validateStatusCode } = require('./units/validateStatusCode'); +const { validateHeaders } = require('./units/validateHeaders'); +const { validateBody } = require('./units/validateBody'); + +function validateMessage(realMessage, expectedMessage) { + const real = normalize(realMessage); + const expected = normalize(expectedMessage); + const results = {}; + + if (real.statusCode) { + results.statusCode = validateStatusCode(real, expected); + } + + if (real.headers && expected.headers) { + results.headers = validateHeaders(real, expected); + } + + if ( + isset(real.body) && + (isset(expected.body) || isset(expected.bodySchema)) + ) { + results.body = validateBody(real, expected); + } + + return results; +} + +module.exports = { validateMessage }; diff --git a/lib/utils/isset.js b/lib/utils/isset.js index 4ea1ae64..b3384fe3 100644 --- a/lib/utils/isset.js +++ b/lib/utils/isset.js @@ -1,5 +1,5 @@ function isset(value) { - return typeof value !== 'undefined' && value !== null; + return value != null; } module.exports = isset; diff --git a/lib/validate.js b/lib/validate.js index 184612fc..2c6ea453 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -19,15 +19,22 @@ function proxy(validatableObject, method, cb) { * @param {'request'|'response'} type */ function getValidatableObject(real, expected, type) { + let request; + let response; + switch (type) { case 'request': - const request = new HttpRequest(real); + request = new HttpRequest(real); request.expected = new ExpectedHttpRequest(expected); return request; case 'response': - const response = new HttpResponse(real); + response = new HttpResponse(real); response.expected = new ExpectedHttpResponse(expected); return response; + default: + throw new Error( + `Can't validate: expected HTTP message type to be "request" or "response", but got: ${type}.` + ); } } diff --git a/package-lock.json b/package-lock.json index 6a38f22e..24be9a15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4023,9 +4023,9 @@ } }, "gavel-spec": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/gavel-spec/-/gavel-spec-1.2.1.tgz", - "integrity": "sha512-XTlQXl7CG/nhh2bNWocmbyTB1RACOBxNgaJcgynu47d9EFoXCGSsQKNjNxclv41cqI6fsifuttyTGZBHKzQIiQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/gavel-spec/-/gavel-spec-1.2.3.tgz", + "integrity": "sha512-cfxxJ5TTM1ojKBY7q4t0B/e7Q8i9AV7qlbuE2eKIPilmfy1Ct+lN/FM+U18s4iFFr9SC09nLNBnIb7qOBk2djg==", "dev": true }, "get-assigned-identifiers": { diff --git a/package.json b/package.json index 55f1cefa..99a36d3c 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,15 @@ }, "scripts": { "lint": "eslint lib/**/*.js test/**/*.js", - "test": "npm run test:server && npm run test:browser && npm run test:features", + "test": "npm run test:next && npm run test:server && npm run test:browser && npm run test:features", + "test:next": "mocha \"lib/next/test/**/*.test.js\"", "test:server": "mocha \"test/unit/**/*-test.js\"", "test:server:coverage": "nyc npm run test:server", "test:browser": "mochify \"test/unit/**/*.js\"", "test:features": "node scripts/cucumber.js", "coveralls": "nyc --reporter=text-lcov npm run test:server | coveralls", "ci:lint": "npm run lint", - "ci:test": "npm run coveralls && npm run test:browser && npm run test:features", + "ci:test": "npm run coveralls && npm run test:next && npm run test:browser && npm run test:features", "ci:release": "semantic-release", "precommit": "lint-staged" }, @@ -55,7 +56,7 @@ "eslint-config-airbnb-base": "13.1.0", "eslint-config-prettier": "4.3.0", "eslint-plugin-import": "2.17.2", - "gavel-spec": "1.2.1", + "gavel-spec": "1.2.3", "husky": "2.3.0", "lint-staged": "8.1.7", "mocha": "6.1.4", diff --git a/test/unit/validate-test.js b/test/unit/validate-test.js index 4e7961d7..d1d6fd1a 100644 --- a/test/unit/validate-test.js +++ b/test/unit/validate-test.js @@ -1,6 +1,6 @@ const { assert } = require('chai'); const clone = require('clone'); -const { validate, isValid, isValidatable } = require('../../lib/validate'); +const { validate, isValid, isValidatable } = require('../../lib/gavel'); describe('Gavel proxies to functions with callbacks', () => { // Examples from README.md @@ -45,7 +45,7 @@ describe('Gavel proxies to functions with callbacks', () => { describe('isValidatable', () => { describe('when I provide data from README (good objects)', () => { ['response', 'request'].forEach((variant) => { - describe('for two cloned #{variant}s', () => { + describe(`for two cloned ${variant}s`, () => { let results = null; let error = null; @@ -195,7 +195,7 @@ describe('Gavel proxies to functions with callbacks', () => { describe('validate', () => { describe('when I provide data', () => { ['response', 'request'].forEach((variant) => { - describe('for two cloned #{variant}s', () => { + describe(`for two cloned ${variant}s`, () => { let results = null; let error = null; @@ -217,7 +217,7 @@ describe('Gavel proxies to functions with callbacks', () => { it('should results be an object', () => assert.isObject(results)); }); - describe('for similar #{variant}s', () => { + describe(`for similar ${variant}s`, () => { let results = null; let error = null; @@ -239,7 +239,7 @@ describe('Gavel proxies to functions with callbacks', () => { it('should results be an object', () => assert.isObject(results)); }); - describe('for different #{variant}s', () => { + describe(`for different ${variant}s`, () => { let results = null; let error = null;