feat: add ajv-formats support to jsonSchema assertion (#7897)

* Enhance JSON schema validation by integrating ajv-formats for additional format support in tests and runtime assertions.

* fix: update pre-request script to stop execution instead of running it

* fix: ensure newline at end of file in pre-request script for ping.bru

* refactor: update JSON schema validation tests to assert rejection of invalid formats

* refactor: streamline JSON schema validation by using a default AJV instance and enhance tests for various ajvOptions scenarios

* refactor: update JSON schema tests to use more descriptive property names and improve error handling for invalid formats

* feat: add support for Draft-07 JSON Schema validation and improve error handling for unsupported schema versions

* fix: improve error message for unsupported JSON Schema versions in runtime assertions and tests
This commit is contained in:
sanish chirayath
2026-05-05 11:57:37 +05:30
committed by GitHub
parent 5ced51d163
commit d332d8e6b2
5 changed files with 623 additions and 5 deletions

View File

@@ -8,6 +8,7 @@ const { interpolateString } = require('../interpolate-string');
const { executeQuickJsVm } = require('../sandbox/quickjs');
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
const { expect } = chai;
chai.use(require('chai-string'));
chai.use(function (chai, utils) {
@@ -26,9 +27,30 @@ chai.use(function (chai, utils) {
});
// Custom assertion for JSON Schema validation
const defaultAjv = new Ajv({ allErrors: true });
addFormats(defaultAjv);
const SUPPORTED_SCHEMA_VERSIONS = [
'http://json-schema.org/draft-07/schema#',
'http://json-schema.org/draft-07/schema'
];
chai.use(function (chai) {
chai.Assertion.addMethod('jsonSchema', function (schema, ajvOptions) {
const ajv = new Ajv({ allErrors: true, ...ajvOptions });
if (schema && schema.$schema && !SUPPORTED_SCHEMA_VERSIONS.includes(schema.$schema)) {
this.assert(
false,
`Unsupported JSON Schema version: "${schema.$schema}". Bruno currently only supports Draft-07 (http://json-schema.org/draft-07/schema#). Please update your schema to be Draft-07 compatible and remove the $schema property.`,
`Unsupported JSON Schema version: "${schema.$schema}".`
);
}
let ajv;
if (ajvOptions) {
ajv = new Ajv({ allErrors: true, ...ajvOptions });
addFormats(ajv);
} else {
ajv = defaultAjv;
}
let validate;
try {
validate = ajv.compile(schema);

View File

@@ -15,6 +15,7 @@ const bundleLibraries = async () => {
import * as cryptoJs from 'crypto-js';
import tv4 from "tv4";
import Ajv from "ajv";
import addFormats from "ajv-formats";
globalThis.expect = expect;
globalThis.assert = assert;
globalThis.moment = moment;
@@ -23,6 +24,7 @@ const bundleLibraries = async () => {
globalThis.Buffer = Buffer;
globalThis.tv4 = tv4;
globalThis.Ajv = Ajv;
globalThis.addFormats = addFormats;
globalThis.requireObject = {
...(globalThis.requireObject || {}),
'chai': { expect, assert },
@@ -32,7 +34,8 @@ const bundleLibraries = async () => {
'atob': atob,
'crypto-js': cryptoJs,
'tv4': tv4,
'ajv': Ajv
'ajv': Ajv,
'ajv-formats': addFormats
};
`;

View File

@@ -84,9 +84,29 @@ const addBruShimToContext = (vm, __brunoTestResults) => {
`
(function() {
var Ajv = require('ajv');
var addFormats = require('ajv-formats');
var defaultAjv = new Ajv({ allErrors: true });
addFormats(defaultAjv);
var SUPPORTED_SCHEMA_VERSIONS = [
'http://json-schema.org/draft-07/schema#',
'http://json-schema.org/draft-07/schema'
];
var proto = Object.getPrototypeOf(expect(null));
proto.jsonSchema = function(schema, ajvOptions) {
var ajv = new Ajv(Object.assign({ allErrors: true }, ajvOptions || {}));
if (schema && schema.$schema && !SUPPORTED_SCHEMA_VERSIONS.includes(schema.$schema)) {
this.assert(
false,
'Unsupported JSON Schema version: "' + schema.$schema + '". Bruno currently only supports Draft-07 (http://json-schema.org/draft-07/schema#). Please update your schema to be Draft-07 compatible and remove the $schema property.',
'Unsupported JSON Schema version: "' + schema.$schema + '".'
);
}
var ajv;
if (ajvOptions) {
ajv = new Ajv(Object.assign({ allErrors: true }, ajvOptions));
addFormats(ajv);
} else {
ajv = defaultAjv;
}
var validate;
try {
validate = ajv.compile(schema);

View File

@@ -501,6 +501,40 @@ describe('runtime', () => {
const schema = { type: 'array' };
chai.expect(body).to.not.have.jsonSchema(schema);
});
it('should throw a clear error for unsupported Draft 2020-12 $schema', () => {
const body = { name: 'John' };
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: { name: { type: 'string' } }
};
expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/Unsupported JSON Schema version.*2020-12.*only supports Draft-07/);
});
it('should throw a clear error for unsupported Draft 2019-09 $schema', () => {
const body = { name: 'John' };
const schema = {
$schema: 'https://json-schema.org/draft/2019-09/schema',
type: 'object',
properties: { name: { type: 'string' } }
};
expect(() => chai.expect(body).to.have.jsonSchema(schema)).toThrow(/Unsupported JSON Schema version.*2019-09.*only supports Draft-07/);
});
it('should allow explicit Draft-07 $schema', () => {
const body = { name: 'John', age: 30 };
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name', 'age']
};
chai.expect(body).to.have.jsonSchema(schema);
});
});
});
});

View File

@@ -23,7 +23,27 @@ body:json {
"street": "123 Main St",
"city": "Springfield",
"zip": "62701"
}
},
"website": "https://example.com/john",
"createdAt": "2024-01-15T10:30:00Z",
"birthDate": "1994-05-20",
"loginTime": "10:30:00Z",
"ipv4": "192.168.1.1",
"ipv6": "::1",
"id": "550e8400-e29b-41d4-a716-446655440000",
"encodedData": "SGVsbG8gV29ybGQ=",
"int32Val": 2147483647,
"int64Val": 2147483648,
"floatVal": 3.14,
"doubleVal": 1.7976931348623157e+308,
"duration": "P3Y6M4DT12H30M5S",
"hostname": "example.com",
"regexPattern": "^[a-z]+$",
"jsonPointer": "/foo/bar/0",
"uriRef": "/relative/path",
"uriTemplate": "https://example.com/{user}",
"invalidRegex": "[invalid",
"invalidUriTemplate": "https://example.com/{invalid"
}
}
@@ -166,7 +186,7 @@ tests {
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("jsonSchema with ajvOptions", function() {
test("jsonSchema with ajvOptions - allErrors", function() {
const schema = {
type: 'object',
properties: {
@@ -178,6 +198,525 @@ tests {
expect(res.getBody()).to.have.jsonSchema(schema, { allErrors: true });
});
test("jsonSchema with ajvOptions - format validation with allErrors", function() {
const schema = {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
website: { type: 'string', format: 'uri' }
},
required: ['email', 'website']
};
expect(res.getBody()).to.have.jsonSchema(schema, { allErrors: true });
});
test("jsonSchema with ajvOptions - format rejection with allErrors", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'email' },
age: { type: 'string', format: 'uri' }
},
required: ['name', 'age']
};
expect(res.getBody()).to.not.have.jsonSchema(schema, { allErrors: true });
});
test("jsonSchema with ajvOptions - additionalProperties false", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string' }
},
additionalProperties: false
};
expect(res.getBody()).to.not.have.jsonSchema(schema, { allErrors: true });
});
test("jsonSchema with ajvOptions - coerceTypes allows string as number", function() {
const schema = {
type: 'object',
properties: {
zip: { type: 'integer' }
},
required: ['zip']
};
// zip is "62701" (string) - fails without coercion
expect(res.getBody().address).to.not.have.jsonSchema(schema);
// passes with coerceTypes since "62701" can be coerced to integer
expect(res.getBody().address).to.have.jsonSchema(schema, { coerceTypes: true });
});
test("jsonSchema with ajvOptions - coerceTypes allows number as string", function() {
const schema = {
type: 'object',
properties: {
age: { type: 'string' }
},
required: ['age']
};
// age is 30 (number) - fails without coercion
expect(res.getBody()).to.not.have.jsonSchema(schema);
// passes with coerceTypes since 30 can be coerced to "30"
expect(res.getBody()).to.have.jsonSchema(schema, { coerceTypes: true });
});
test("jsonSchema with ajvOptions - strict false allows unknown keywords", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', customKeyword: true }
},
required: ['name']
};
// unknown keyword "customKeyword" throws in strict mode (default)
expect(() => expect(res.getBody()).to.have.jsonSchema(schema)).to.throw('JSON schema compile error');
// passes with strict: false
expect(res.getBody()).to.have.jsonSchema(schema, { strict: false });
});
// --- ajv-formats: Passing validations ---
test("format: email - valid email", function() {
const schema = {
type: 'object',
properties: {
email: { type: 'string', format: 'email' }
},
required: ['email']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: uri - valid URI", function() {
const schema = {
type: 'object',
properties: {
website: { type: 'string', format: 'uri' }
},
required: ['website']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: date-time - valid ISO 8601 date-time", function() {
const schema = {
type: 'object',
properties: {
createdAt: { type: 'string', format: 'date-time' }
},
required: ['createdAt']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: date - valid date", function() {
const schema = {
type: 'object',
properties: {
birthDate: { type: 'string', format: 'date' }
},
required: ['birthDate']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: time - valid time", function() {
const schema = {
type: 'object',
properties: {
loginTime: { type: 'string', format: 'time' }
},
required: ['loginTime']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: ipv4 - valid IPv4 address", function() {
const schema = {
type: 'object',
properties: {
ipv4: { type: 'string', format: 'ipv4' }
},
required: ['ipv4']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: ipv6 - valid IPv6 address", function() {
const schema = {
type: 'object',
properties: {
ipv6: { type: 'string', format: 'ipv6' }
},
required: ['ipv6']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: uuid - valid UUID", function() {
const schema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' }
},
required: ['id']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: byte - valid base64 string", function() {
const schema = {
type: 'object',
properties: {
encodedData: { type: 'string', format: 'byte' }
},
required: ['encodedData']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: int32 - valid 32-bit integer", function() {
const schema = {
type: 'object',
properties: {
int32Val: { type: 'integer', format: 'int32' }
},
required: ['int32Val']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: int64 - valid 64-bit integer", function() {
const schema = {
type: 'object',
properties: {
int64Val: { type: 'integer', format: 'int64' }
},
required: ['int64Val']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: float - valid float", function() {
const schema = {
type: 'object',
properties: {
floatVal: { type: 'number', format: 'float' }
},
required: ['floatVal']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: double - valid double", function() {
const schema = {
type: 'object',
properties: {
doubleVal: { type: 'number', format: 'double' }
},
required: ['doubleVal']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: duration - valid ISO 8601 duration", function() {
const schema = {
type: 'object',
properties: {
duration: { type: 'string', format: 'duration' }
},
required: ['duration']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: hostname - valid hostname", function() {
const schema = {
type: 'object',
properties: {
hostname: { type: 'string', format: 'hostname' }
},
required: ['hostname']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: regex - valid regex pattern", function() {
const schema = {
type: 'object',
properties: {
regexPattern: { type: 'string', format: 'regex' }
},
required: ['regexPattern']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: json-pointer - valid JSON pointer", function() {
const schema = {
type: 'object',
properties: {
jsonPointer: { type: 'string', format: 'json-pointer' }
},
required: ['jsonPointer']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: uri-reference - valid URI reference", function() {
const schema = {
type: 'object',
properties: {
uriRef: { type: 'string', format: 'uri-reference' }
},
required: ['uriRef']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("format: uri-template - valid URI template", function() {
const schema = {
type: 'object',
properties: {
uriTemplate: { type: 'string', format: 'uri-template' }
},
required: ['uriTemplate']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
test("Multiple formats in one schema", function() {
const schema = {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
website: { type: 'string', format: 'uri' },
createdAt: { type: 'string', format: 'date-time' },
ipv4: { type: 'string', format: 'ipv4' },
id: { type: 'string', format: 'uuid' },
encodedData: { type: 'string', format: 'byte' },
int32Val: { type: 'integer', format: 'int32' },
floatVal: { type: 'number', format: 'float' },
duration: { type: 'string', format: 'duration' },
hostname: { type: 'string', format: 'hostname' }
},
required: ['email', 'website', 'createdAt', 'ipv4', 'id', 'encodedData', 'int32Val', 'floatVal', 'duration', 'hostname']
};
expect(res.getBody()).to.have.jsonSchema(schema);
});
// --- ajv-formats: Failure validations ---
test("format: email - rejects non-email string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'email' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: uri - rejects non-URI string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'uri' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: date-time - rejects plain date string", function() {
const schema = {
type: 'object',
properties: {
birthDate: { type: 'string', format: 'date-time' }
},
required: ['birthDate']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: ipv4 - rejects non-IP string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'ipv4' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: uuid - rejects non-UUID string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'uuid' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: byte - rejects non-base64 string", function() {
const schema = {
type: 'object',
properties: {
email: { type: 'string', format: 'byte' }
},
required: ['email']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: int32 - rejects value exceeding 32-bit range", function() {
const schema = {
type: 'object',
properties: {
int64Val: { type: 'integer', format: 'int32' }
},
required: ['int64Val']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: float - rejects non-number field", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'number', format: 'float' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: duration - rejects non-duration string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'duration' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: hostname - rejects invalid hostname", function() {
const schema = {
type: 'object',
properties: {
email: { type: 'string', format: 'hostname' }
},
required: ['email']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: json-pointer - rejects non-pointer string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'json-pointer' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: date - rejects non-date string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'date' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: time - rejects non-time string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'time' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: ipv6 - rejects non-IPv6 string", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'string', format: 'ipv6' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: int64 - rejects non-integer field", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'integer', format: 'int64' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: double - rejects non-number field", function() {
const schema = {
type: 'object',
properties: {
name: { type: 'number', format: 'double' }
},
required: ['name']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: regex - rejects invalid regex pattern", function() {
const schema = {
type: 'object',
properties: {
invalidRegex: { type: 'string', format: 'regex' }
},
required: ['invalidRegex']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: uri-reference - rejects invalid URI reference string", function() {
const schema = {
type: 'object',
properties: {
invalidRegex: { type: 'string', format: 'uri-reference' }
},
required: ['invalidRegex']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
test("format: uri-template - rejects invalid URI template string", function() {
const schema = {
type: 'object',
properties: {
invalidUriTemplate: { type: 'string', format: 'uri-template' }
},
required: ['invalidUriTemplate']
};
expect(res.getBody()).to.not.have.jsonSchema(schema);
});
// --- Failure validations ---
test("Type mismatch - schema expects array, response is object", function() {