High definition PHP structures with JSON-schema based validation.
Supported schemas:
composer require swaggest/json-schema
Structure definition can be done either with json-schema or with
PHP class extending Swaggest\JsonSchema\Structure\ClassStructure
Define your json-schema
$schemaJson = <<<'JSON'
{
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"orders": {
"type": "array",
"items": {
"$ref": "#/definitions/order"
}
}
},
"required":["id"],
"definitions": {
"order": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"price": {
"type": "number"
},
"updated": {
"type": "string",
"format": "date-time"
}
},
"required":["id"]
}
}
}
JSON;Load it
use Swaggest\JsonSchema\Schema;
$schema = Schema::import(json_decode($schemaJson));Validate data
$schema->in(json_decode(<<<'JSON'
{
"id": 1,
"name":"John Doe",
"orders":[
{
"id":1
},
{
"price":1.0
}
]
}
JSON
)); // Exception: Required property missing: id at #->properties:orders->items[1]->#/definitions/orderYou can also call Schema::import on string uri to schema json data.
$schema = Schema::import('http://localhost:1234/my_schema.json');Or with boolean argument.
$schema = Schema::import(true); // permissive schema, always validates
$schema = Schema::import(false); // restrictive schema, always invalidatesWith complex schemas it may be hard to find out what's wrong with your data. Exception message can look like:
No valid results for oneOf {
0: Enum failed, enum: ["a"], data: "f" at #->properties:root->patternProperties[^[a-zA-Z0-9_]+$]:zoo->oneOf[0]
1: Enum failed, enum: ["b"], data: "f" at #->properties:root->patternProperties[^[a-zA-Z0-9_]+$]:zoo->oneOf[1]
2: No valid results for anyOf {
0: Enum failed, enum: ["c"], data: "f" at #->properties:root->patternProperties[^[a-zA-Z0-9_]+$]:zoo->oneOf[2]->$ref[#/cde]->anyOf[0]
1: Enum failed, enum: ["d"], data: "f" at #->properties:root->patternProperties[^[a-zA-Z0-9_]+$]:zoo->oneOf[2]->$ref[#/cde]->anyOf[1]
2: Enum failed, enum: ["e"], data: "f" at #->properties:root->patternProperties[^[a-zA-Z0-9_]+$]:zoo->oneOf[2]->$ref[#/cde]->anyOf[2]
} at #->properties:root->patternProperties[^[a-zA-Z0-9_]+$]:zoo->oneOf[2]->$ref[#/cde]
} at #->properties:root->patternProperties[^[a-zA-Z0-9_]+$]:zoo
For ambiguous schemas defined with oneOf/anyOf message is indented multi-line string.
Processing path is a combination of schema and data pointers. You can use InvalidValue->getSchemaPointer()
and InvalidValue->getDataPointer() to extract schema/data pointer.
You can receive Schema instance that failed validation with InvalidValue->getFailedSubSchema.
You can build error tree using InvalidValue->inspect().
/**
* @property int $quantity PHPDoc defined dynamic properties will be validated on every set
*/
class User extends ClassStructure
{
/* Native (public) properties will be validated only on import and export of structure data */
/** @var int */
public $id;
public $name;
/** @var Order[] */
public $orders;
/** @var UserInfo */
public $info;
/**
* @param Properties|static $properties
* @param Schema $ownerSchema
*/
public static function setUpProperties($properties, Schema $ownerSchema)
{
// You can add custom meta to your schema
$dbTable = new DbTable;
$dbTable->tableName = 'users';
$ownerSchema->addMeta($dbTable);
// Setup property schemas
$properties->id = Schema::integer();
$properties->id->addMeta(new DbId($dbTable)); // You can add meta to property.
$properties->name = Schema::string();
// You can embed structures to main level with nested schemas
$properties->info = UserInfo::schema()->nested();
// You can set default value for property
$defaultOptions = new UserOptions();
$defaultOptions->autoLogin = true;
$defaultOptions->groupName = 'guest';
// UserOptions::schema() is safe to change as it is protected with lazy cloning
$properties->options = UserOptions::schema()->setDefault(UserOptions::export($defaultOptions));
// Dynamic (phpdoc-defined) properties can be used as well
$properties->quantity = Schema::integer();
$properties->quantity->minimum = 0;
// Property can be any complex structure
$properties->orders = Schema::create();
$properties->orders->items = Order::schema();
$ownerSchema->required = array(self::names()->id);
}
}
class UserInfo extends ClassStructure {
public $firstName;
public $lastName;
public $birthDay;
/**
* @param Properties|static $properties
* @param Schema $ownerSchema
*/
public static function setUpProperties($properties, Schema $ownerSchema)
{
$properties->firstName = Schema::string();
$properties->lastName = Schema::string();
$properties->birthDay = Schema::string();
}
}
class UserOptions extends ClassStructure
{
public $autoLogin;
public $groupName;
/**
* @param Properties|static $properties
* @param Schema $ownerSchema
*/
public static function setUpProperties($properties, Schema $ownerSchema)
{
$properties->autoLogin = Schema::boolean();
$properties->groupName = Schema::string();
}
}
class Order implements ClassStructureContract
{
use ClassStructureTrait; // You can use trait if you can't/don't want to extend ClassStructure
const FANCY_MAPPING = 'fAnCy'; // You can create additional mapping namespace
public $id;
public $userId;
public $dateTime;
public $price;
/**
* @param Properties|static $properties
* @param Schema $ownerSchema
*/
public static function setUpProperties($properties, Schema $ownerSchema)
{
// Add some meta data to your schema
$dbMeta = new DbTable();
$dbMeta->tableName = 'orders';
$ownerSchema->addMeta($dbMeta);
// Define properties
$properties->id = Schema::integer();
$properties->userId = User::properties()->id; // referencing property of another schema keeps meta
$properties->dateTime = Schema::string();
$properties->dateTime->format = Format::DATE_TIME;
$properties->price = Schema::number();
$ownerSchema->setFromRef('#/definitions/order');
// Define default mapping if any.
$ownerSchema->addPropertyMapping('date_time', Order::names()->dateTime);
// Use mapped name references after the default mapping was configured.
$names = self::names($ownerSchema->properties);
$ownerSchema->required = array(
$names->id,
$names->dateTime, // "date_time"
$names->price
);
// Define additional mapping
$ownerSchema->addPropertyMapping('DaTe_TiMe', Order::names()->dateTime, self::FANCY_MAPPING);
$ownerSchema->addPropertyMapping('Id', Order::names()->id, self::FANCY_MAPPING);
$ownerSchema->addPropertyMapping('PrIcE', Order::names()->price, self::FANCY_MAPPING);
}
}Validation of dynamic properties is performed on set, this can help to find source of invalid data at cost of some performance drop
$user = new User();
$user->quantity = -1; // Exception: Value more than 0 expected, -1 receivedValidation of native properties is performed only on import/export
$user = new User();
$user->quantity = 10;
User::export($user); // Exception: Required property missing: idError messages provide a path to invalid data
$user = new User();
$user->id = 1;
$user->name = 'John Doe';
$order = new Order();
$order->dateTime = (new \DateTime())->format(DATE_RFC3339);
$user->orders[] = $order;
User::export($user); // Exception: Required property missing: id at #->properties:orders->items[0]Nested structures allow you to make composition: flatten several objects in one and separate back. For nested structures that exceed a depth of the default 200 recursion limit, submit a Context maxNestLevel of an appropriate amount.
$user = new User();
$user->id = 1;
$info = new UserInfo();
$info->firstName = 'John';
$info->lastName = 'Doe';
$info->birthDay = '1970-01-01';
$user->info = $info;
$json = <<<JSON
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"birthDay": "1970-01-01"
}
JSON;
$exported = User::export($user);
$this->assertSame($json, json_encode($exported, JSON_PRETTY_PRINT));
$imported = User::import(json_decode($json));
$this->assertSame('John', $imported->info->firstName);
$this->assertSame('Doe', $imported->info->lastName);You can also use \Swaggest\JsonSchema\Structure\Composition to dynamically create schema compositions. This can be
helpful to deal with results of database query on joined data.
$schema = new Composition(UserInfo::schema(), Order::schema());
$json = <<<JSON
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"price": 2.66
}
JSON;
$object = $schema->import(json_decode($json));
// Get particular object with `pick` accessor
$info = UserInfo::pick($object);
$order = Order::pick($object);
// Data is imported objects of according classes
$this->assertTrue($order instanceof Order);
$this->assertTrue($info instanceof UserInfo);
$this->assertSame(1, $order->id);
$this->assertSame('John', $info->firstName);
$this->assertSame('Doe', $info->lastName);
$this->assertSame(2.66, $order->price);If property names of PHP objects should be different from raw data you can call ->addPropertyMapping on owner schema.
// Define default mapping if any
$ownerSchema->addPropertyMapping('date_time', Order::names()->dateTime);
// Define additional mapping
$ownerSchema->addPropertyMapping('DaTe_TiMe', Order::names()->dateTime, self::FANCY_MAPPING);
$ownerSchema->addPropertyMapping('Id', Order::names()->id, self::FANCY_MAPPING);
$ownerSchema->addPropertyMapping('PrIcE', Order::names()->price, self::FANCY_MAPPING);It will affect data mapping:
$order = new Order();
$order->id = 1;
$order->dateTime = '2015-10-28T07:28:00Z';
$order->price = 2.2;
$exported = Order::export($order);
$json = <<<JSON
{
"id": 1,
"date_time": "2015-10-28T07:28:00Z",
"price": 2.2
}
JSON;
$this->assertSame($json, json_encode($exported, JSON_PRETTY_PRINT));
$imported = Order::import(json_decode($json));
$this->assertSame('2015-10-28T07:28:00Z', $imported->dateTime);You can have multiple mapping namespaces, controlling with mapping property of Context
$options = new Context();
$options->mapping = Order::FANCY_MAPPING;
$exported = Order::export($order, $options);
$json = <<<JSON
{
"Id": 1,
"DaTe_TiMe": "2015-10-28T07:28:00Z",
"PrIcE": 2.2
}
JSON;
$this->assertSame($json, json_encode($exported, JSON_PRETTY_PRINT));
$imported = Order::import(json_decode($json), $options);
$this->assertSame('2015-10-28T07:28:00Z', $imported->dateTime);You can create your own pre-processor implementing Swaggest\JsonSchema\DataPreProcessor.
Meta is a way to complement Schema with your own data. You can keep and retrieve it.
You can store it.
$dbMeta = new DbTable();
$dbMeta->tableName = 'orders';
$ownerSchema->addMeta($dbMeta);And get back.
// Retrieving meta
$dbTable = DbTable::get(Order::schema());
$this->assertSame('orders', $dbTable->tableName);If you want to tolerate invalid data or improve mapping performance you can specify skipValidation flag in
processing Context
$schema = Schema::object();
$schema->setProperty('one', Schema::integer());
$schema->properties->one->minimum = 5;
$options = new Context();
$options->skipValidation = true;
$res = $schema->in(json_decode('{"one":4}'), $options);
$this->assertSame(4, $res->one);If you want to map data to a different class you can register mapping at top level of your importer structure. Additionally, you can use the option property, maxNestLevel to increase your depth beyond the default 200 which may be useful for certain complex Schema's.
class CustomSwaggerSchema extends SwaggerSchema
{
public static function import($data, ?Context $options = null)
{
if ($options === null) {
$options = new Context();
$options->maxNestLevel = 500;
}
$options->objectItemClassMapping[Schema::className()] = CustomSchema::className();
return parent::import($data, $options);
}
}Or specify it in processing context
$context = new Context();
$context->objectItemClassMapping[Schema::className()] = CustomSchema::className();
$schema = SwaggerSchema::schema()->in(json_decode(
file_get_contents(__DIR__ . '/../../../../spec/petstore-swagger.json')
), $context);
$this->assertInstanceOf(CustomSchema::className(), $schema->definitions['User']);Some code quality best practices are deliberately violated here
(
see 
Those violations are secured by comprehensive test coverage:
- draft-04, draft-06, draft-07 of JSON-Schema-Test-Suite
- test cases (excluding
$dataand few tests) of epoberezkin/ajv (a mature js implementation)
Issues and pull requests are welcome!
Development supported by JetBrains.