I have a JSON Schema file describing my API. It consists of some definitions as well as some vestigial parts from codegen that I'd like to ignore (the properties and required fields):
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CreateBook": {
"properties": {
"title": {"type": "string"},
"author": {"type": "string"},
"numPages": {"type": "number"}
},
"required": ["title", "author"]
},
"CreateShelf": {
"properties": {
"books": {"type": "array", "items": {"type": "string"}}
},
"required": ["books"]
}
},
"properties": {
"/api/create-book": {
"properties": {"type": {"post": {"$ref": "#/definitions/CreateBook"}}},
"required": ["post"],
"type": "object"
},
"/api/create-shelf": {
"properties": {"type": {"post": {"$ref": "#/definitions/CreateShelf"}}},
"required": ["post"],
"type": "object"
}
},
"required": ["/api/create-book", "/api/create-shelf"],
"type": "object"
}
I'd like to validate requests according to the definitions. I'd like to completely ignore the properties and required fields, which describe the shape of the API itself, not the individual requests.
Given what I expect to be a CreateBook request and this JSON schema, how should I validate it?
Here's what I tried:
const ajv = new Ajv();
const validate = ajv.compile(jsonSchema);
const body = {
author: 'Roald Dahl',
numPages: 234,
// missing title
};
if (!validate(body, '#/definitions/CreateBook')) {
console.log(validate.errors);
}
This logs:
[
{
keyword: 'required',
dataPath: '#/definitions/CreateBook',
schemaPath: '#/required',
params: { missingProperty: '/api/create-book' },
message: "should have required property '/api/create-book'"
}
]
So it's ignoring the dataPath parameter ('#/definitions/CreateBook'). What's the right way to do this? Do I need to create a new schema for every request type?
If you use addSchema instead of compile to compile the schema, you can specify a fragment.
const ajv = new Ajv();
ajv.addSchema(jsonSchema);
const validate = ajv.getSchema("#/definitions/CreateBook");
const body = {
author: 'Roald Dahl',
numPages: 234,
// missing title
};
if (!validate(body)) {
console.log(validate.errors);
}
I have code that correctly validates an article returned from an endpoint that returns single articles. I'm pretty sure it's working correctly as it gives a validation error when I deliberately don't include a required field in the article.
I also have this code that tries to validate an array of articles returned from an endpoint that returns an array of articles. However, I'm pretty sure that isn't working correctly, as it always says the data is valid, even when I deliberately don't include a required field in the articles.
How do I correctly validate an array of data against the schema?
The full test code is below as a standalone runnable test. Both of the tests should fail, however only one of them does.
<?php
declare(strict_types=1);
error_reporting(E_ALL);
require_once __DIR__ . '/vendor/autoload.php';
// Return the definition of the schema, either as an array
// or a PHP object
function getSchema($asArray = false)
{
$schemaJson = <<< 'JSON'
{
"swagger": "2.0",
"info": {
"termsOfService": "http://swagger.io/terms/",
"version": "1.0.0",
"title": "Example api"
},
"paths": {
"/articles": {
"get": {
"tags": [
"article"
],
"summary": "Find all articles",
"description": "Returns a list of articles",
"operationId": "getArticleById",
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "successful operation",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Article"
}
}
}
},
"parameters": [
]
}
},
"/articles/{articleId}": {
"get": {
"tags": [
"article"
],
"summary": "Find article by ID",
"description": "Returns a single article",
"operationId": "getArticleById",
"produces": [
"application/json"
],
"parameters": [
{
"name": "articleId",
"in": "path",
"description": "ID of article to return",
"required": true,
"type": "integer",
"format": "int64"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": {
"$ref": "#/definitions/Article"
}
}
}
}
}
},
"definitions": {
"Article": {
"type": "object",
"required": [
"id",
"title"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"title": {
"type": "string",
"description": "The title for the link of the article"
}
}
}
},
"schemes": [
"http"
],
"host": "example.com",
"basePath": "/",
"tags": [],
"securityDefinitions": {
},
"security": [
{
"ApiKeyAuth": []
}
]
}
JSON;
return json_decode($schemaJson, $asArray);
}
// Extract the schema of the 200 response of an api endpoint.
function getSchemaForPath($path)
{
$swaggerData = getSchema(true);
if (isset($swaggerData["paths"][$path]['get']["responses"][200]['schema']) !== true) {
echo "response not defined";
exit(-1);
}
return $swaggerData["paths"][$path]['get']["responses"][200]['schema'];
}
// JsonSchema needs to know about the ID used for the top-level
// schema apparently.
function aliasSchema($prefix, $schemaForPath)
{
$aliasedSchema = [];
foreach ($schemaForPath as $key => $value) {
if ($key === '$ref') {
$aliasedSchema[$key] = $prefix . $value;
}
else if (is_array($value) === true) {
$aliasedSchema[$key] = aliasSchema($prefix, $value);
}
else {
$aliasedSchema[$key] = $value;
}
}
return $aliasedSchema;
}
// Test the data matches the schema.
function testDataMatches($endpointData, $schemaForPath)
{
// Setup the top level schema and get a validator from it.
$schemaStorage = new \JsonSchema\SchemaStorage();
$id = 'file://example';
$swaggerClass = getSchema(false);
$schemaStorage->addSchema($id, $swaggerClass);
$factory = new \JsonSchema\Constraints\Factory($schemaStorage);
$jsonValidator = new \JsonSchema\Validator($factory);
// Alias the schema for the endpoint, so JsonSchema can work with it.
$schemaForPath = aliasSchema($id, $schemaForPath);
// Validate the things
$jsonValidator->check($endpointData, (object)$schemaForPath);
// Process the result
if ($jsonValidator->isValid()) {
echo "The supplied JSON validates against the schema definition: " . \json_encode($schemaForPath) . " \n";
return;
}
$messages = [];
$messages[] = "End points does not validate. Violations:\n";
foreach ($jsonValidator->getErrors() as $error) {
$messages[] = sprintf("[%s] %s\n", $error['property'], $error['message']);
}
$messages[] = "Data: " . \json_encode($endpointData, JSON_PRETTY_PRINT);
echo implode("\n", $messages);
echo "\n";
}
// We have two data sets to test. A list of articles.
$articleListJson = <<< JSON
[
{
"id": 19874
},
{
"id": 19873
}
]
JSON;
$articleListData = json_decode($articleListJson);
// A single article
$articleJson = <<< JSON
{
"id": 19874
}
JSON;
$articleData = json_decode($articleJson);
// This passes, when it shouldn't as none of the articles have a title
testDataMatches($articleListData, getSchemaForPath("/articles"));
// This fails correctly, as it is correct for it to fail to validate, as the article doesn't have a title
testDataMatches($articleData, getSchemaForPath("/articles/{articleId}"));
The minimal composer.json is:
{
"require": {
"justinrainbow/json-schema": "^5.2"
}
}
Edit-2: 22nd May
I have been digging further turns out that the issue is because of your top level conversion to object
$jsonValidator->check($endpointData, (object)$schemaForPath);
You shouldn't have just done that and it would have all worked
$jsonValidator->check($endpointData, $schemaForPath);
So it doesn't seem to be a bug it was just a wrong usage. If you just remove (object) and run the code
$ php test.php
End points does not validate. Violations:
[[0].title] The property title is required
[[1].title] The property title is required
Data: [
{
"id": 19874
},
{
"id": 19873
}
]
End points does not validate. Violations:
[title] The property title is required
Data: {
"id": 19874
}
Edit-1
To fix the original code you would need to update the CollectionConstraints.php
/**
* Validates the items
*
* #param array $value
* #param \stdClass $schema
* #param JsonPointer|null $path
* #param string $i
*/
protected function validateItems(&$value, $schema = null, JsonPointer $path = null, $i = null)
{
if (is_array($schema->items) && array_key_exists('$ref', $schema->items)) {
$schema->items = $this->factory->getSchemaStorage()->resolveRefSchema((object)$schema->items);
var_dump($schema->items);
};
if (is_object($schema->items)) {
This will handle your use case for sure but if you don't prefer changing code from the dependency then use my original answer
Original Answer
The library has a bug/limitation that in src/JsonSchema/Constraints/CollectionConstraint.php they don't resolve a $ref variable as such. If I updated your code like below
// Alias the schema for the endpoint, so JsonSchema can work with it.
$schemaForPath = aliasSchema($id, $schemaForPath);
if (array_key_exists('items', $schemaForPath))
{
$schemaForPath['items'] = $factory->getSchemaStorage()->resolveRefSchema((object)$schemaForPath['items']);
}
// Validate the things
$jsonValidator->check($endpointData, (object)$schemaForPath);
and run it again, I get the exceptions needed
$ php test2.php
End points does not validate. Violations:
[[0].title] The property title is required
[[1].title] The property title is required
Data: [
{
"id": 19874
},
{
"id": 19873
}
]
End points does not validate. Violations:
[title] The property title is required
Data: {
"id": 19874
}
You either need to fix the CollectionConstraint.php or open an issue with developer of the repo. Or else manually replace your $ref in the whole schema, like had shown above. My code will resolve the issue specific to your schema, but fixing any other schema should not be a big issue
EDIT: Important thing here is that provided schema document is instance of Swagger Schema, which employs extended subset of JSON Schema to define some cases of request and response. Swagger 2.0 Schema itself can be validated by its JSON Schema, but it can not act as a JSON Schema for API Response structure directly.
In case entity schema is compatible with standard JSON Schema you can perform validation with general purpose validator, but you have to provide all relevant definitions, it can be easy when you have absolute references, but more complicated for local (relative) references that start with #/. IIRC they must be defined in the local schema.
The problem here is that you are trying to use schema references detached from resolution scope. I've added id to make references absolute, therefore not requiring being in scope.
"$ref": "http://example.com/my-schema#/definitions/Article"
The code below works well.
<?php
require_once __DIR__ . '/vendor/autoload.php';
$swaggerSchemaData = json_decode(<<<'JSON'
{
"id": "http://example.com/my-schema",
"swagger": "2.0",
"info": {
"termsOfService": "http://swagger.io/terms/",
"version": "1.0.0",
"title": "Example api"
},
"paths": {
"/articles": {
"get": {
"tags": [
"article"
],
"summary": "Find all articles",
"description": "Returns a list of articles",
"operationId": "getArticleById",
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "successful operation",
"schema": {
"type": "array",
"items": {
"$ref": "http://example.com/my-schema#/definitions/Article"
}
}
}
},
"parameters": [
]
}
},
"/articles/{articleId}": {
"get": {
"tags": [
"article"
],
"summary": "Find article by ID",
"description": "Returns a single article",
"operationId": "getArticleById",
"produces": [
"application/json"
],
"parameters": [
{
"name": "articleId",
"in": "path",
"description": "ID of article to return",
"required": true,
"type": "integer",
"format": "int64"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": {
"$ref": "http://example.com/my-schema#/definitions/Article"
}
}
}
}
}
},
"definitions": {
"Article": {
"type": "object",
"required": [
"id",
"title"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"title": {
"type": "string",
"description": "The title for the link of the article"
}
}
}
},
"schemes": [
"http"
],
"host": "example.com",
"basePath": "/",
"tags": [],
"securityDefinitions": {
},
"security": [
{
"ApiKeyAuth": []
}
]
}
JSON
);
$schemaStorage = new \JsonSchema\SchemaStorage();
$schemaStorage->addSchema('http://example.com/my-schema', $swaggerSchemaData);
$factory = new \JsonSchema\Constraints\Factory($schemaStorage);
$validator = new \JsonSchema\Validator($factory);
$schemaData = $swaggerSchemaData->paths->{"/articles"}->get->responses->{"200"}->schema;
$data = json_decode('[{"id":1},{"id":2,"title":"Title2"}]');
$validator->validate($data, $schemaData);
var_dump($validator->isValid()); // bool(false)
$data = json_decode('[{"id":1,"title":"Title1"},{"id":2,"title":"Title2"}]');
$validator->validate($data, $schemaData);
var_dump($validator->isValid()); // bool(true)
I'm not sure I fully understand your code here, but I have an idea based on some assumptions.
Assuming $typeForEndPointis the schema you're using for validation, your item key word needs to be an object rather than an array.
The items key word can be an array or an object. If it's an object, that schema is applicable to every item in the array. If it is an array, each item in that array is applicable to the item in the same position as the array being validated.
This means you're only validating the first item in the array.
If "items" is a schema, validation succeeds if all elements in the
array successfully validate against that schema.
If "items" is an array of schemas, validation succeeds if each element
of the instance validates against the schema at the same position, if
any.
https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-01#section-6.4.1
jsonValidator don't like mixed of object and array association,
You can use either:
$jsonValidator->check($endpointData, $schemaForPath);
or
$jsonValidator->check($endpointData, json_decode(json_encode($schemaForPath)));
var schema = {
"id": "test.json#",
"definitions": {
"body": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
}
},
"request": {
"properties": {
"user": {
"$ref": "#/definitions/body"
}
}
}
},
"typeA": {
"$ref": "#/definitions/request"
},
"typeB": {
"allOf": [
{"$ref": "#/definitions/request"},
{"required": ["name"]} // Should require the name field in the user object.
]
}
}
var Ajv = require('ajv')
var ajv = Ajv({
schemas: [schema]
})
var data = {
user: {
"name": "tom",
"age": 1
}
}
var valid = ajv.validate({$ref: "test.json#/typeA"}, data)
console.log('VALID', valid) // Returns true.
var valid = ajv.validate({$ref: "test.json#/typeB"}, data)
console.log('VALID', valid) // Returns false.
I'm new to JSON schemas. I'd like to reuse the definitions-request-schema in typeA and typeB. typeB should require the name field in the user-object. How do I achieve this? I know why my solution above is not working. Required should be something like "user.name".
Thanks you for your help.
I have a simple JSON schema:
{
"properties": {
"name": {
"type": "string"
}
},
"type": "object"
}
It requires that name property is a string. This schema does not restrict additional properties, e.g.
{
name: 'foo',
url: 'http://foo'/
}
The latter is a valid input.
Is there a way to set a property value format requirement based on a conditional property name match?, e.g. any property that contains url string in it must correspond to the following schema:
{
"type": "string",
"format": "url"
}
Therefore, an input:
{
name: 'foo',
location_url: 'not-a-valid-url'
}
would cause an error because location_url does not contain a valid URL?
I'd imagine, a schema for something like this would look like:
{
"properties": {
"name": {
"type": "string"
}
},
"matchProperties": {
"/url/i": {
"type": "string",
"format": "url"
}
}
"type": "object"
}
where matchProperties is a keyword I made up.
Is there a way to set a custom message for json schema (tv4) for when it fails at a oneOf field?
I saw that there was an issue opened for custom messages about a year ago here and here but is there a way to make this work for something like this?
{
"id": "code",
"description": "Schema for request.body - pin for logging into the bank",
"oneOf": [
{
"type": "string",
"pattern": "^.*\\S.*$"
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"pattern": "^(encrypted|not_encrypted)$"
},
"value": {
"type": "string",
"pattern": "^.*\\S.*$"
}
}
}
],
"messages": {
"oneOf": "Code does not match schema"
}
}
as opposed to just seeing Data does not match any schemas from \"oneOf\", you could see Code does not match schema
[EDIT]
I am reading through the tv4 code as best as I can and I am seeing a lot about custom error messages and codes, e.g.
defineError: function (codeName, codeNumber, defaultMessage) {
if (typeof codeName !== 'string' || !/^[A-Z]+(_[A-Z]+)*$/.test(codeName)) {
throw new Error('Code name must be a string in UPPER_CASE_WITH_UNDERSCORES');
}
if (typeof codeNumber !== 'number' || codeNumber%1 !== 0 || codeNumber < 10000) {
throw new Error('Code number must be an integer > 10000');
}
if (typeof ErrorCodes[codeName] !== 'undefined') {
throw new Error('Error already defined: ' + codeName + ' as ' + ErrorCodes[codeName]);
}
if (typeof ErrorCodeLookup[codeNumber] !== 'undefined') {
throw new Error('Error code already used: ' + ErrorCodeLookup[codeNumber] + ' as ' + codeNumber);
}
ErrorCodes[codeName] = codeNumber;
ErrorCodeLookup[codeNumber] = codeName;
ErrorMessagesDefault[codeName] = ErrorMessagesDefault[codeNumber] = defaultMessage;
for (var langCode in languages) {
var language = languages[langCode];
if (language[codeName]) {
language[codeNumber] = language[codeNumber] || language[codeName];
}
}
},
I can add my own error with it's own status code (via this function) by one simple line tv4.defineError('MY_CUSTOM_CODE_ERROR', 999999, "Hello World, you have a custom code error");. How do I associate this error with this specific json schema? And if I'm completely in the wrong place, then somebody also point that out please
I stumbled upon your post from GitHub, trying to find a solution for adding custom message to "anyOf", "oneOf" clauses. Here is what worked for me:
See here
tv4.setErrorReporter(function (error, data, schema) {
// Last component of schemaPath, which *most* of the time is the keyword!
var lsP = error.schemaPath.split('/').splice(-1);
return schema.messages && schema.messages[lsP];
});
and your schema definition goes like this:
{
"id": "code",
"description": "Schema for request.body - pin for logging into the bank",
"oneOf": [
{
"type": "string",
"pattern": "^.*\\S.*$"
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"pattern": "^(encrypted|not_encrypted)$"
},
"value": {
"type": "string",
"pattern": "^.*\\S.*$"
}
}
}
],
"messages": {
"oneOf": "Code does not match schema"
}
}