swaggest/json-schema

High definition PHP structures with JSON-schema based validation

Maintainers

👁 vearutop

Package info

github.com/swaggest/php-json-schema

pkg:composer/swaggest/json-schema

Statistics

Installs: 13 313 964

Dependents: 74

Suggesters: 0

Stars: 488

Open Issues: 26

v0.12.43 2024-12-22 21:18 UTC

Requires (Dev)

Suggests

Provides

None

Conflicts

None

Replaces

None

MIT 1f3a77a382c5d273a0f1fe34be3b8af4060a88cd

  • Viacheslav Poturaev <vearutop.woop@gmail.com>

README

👁 Build Status
👁 codecov
👁 time tracker
👁 Code lines
👁 Comments

High definition PHP structures with JSON-schema based validation.

Supported schemas:

Installation

composer require swaggest/json-schema

Usage

Structure definition can be done either with json-schema or with PHP class extending Swaggest\JsonSchema\Structure\ClassStructure

Validating JSON data against given schema

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/order

You 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 invalidates

Understanding error cause

With 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().

PHP structured classes with validation

/**
 * @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 received

Validation of native properties is performed only on import/export

$user = new User();
$user->quantity = 10;
User::export($user); // Exception: Required property missing: id

Error 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

Nested structures allow you to make composition: flatten several objects in one and separate back.

$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);

Keys mapping

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

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);

Mapping without validation

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);

Overriding mapping classes

If you want to map data to a different class you can register mapping at top level of your importer structure.

class CustomSwaggerSchema extends SwaggerSchema
{
 public static function import($data, ?Context $options = null)
 {
 if ($options === null) {
 $options = new Context();
 }
 $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']);

Code quality and test coverage

Some code quality best practices are deliberately violated here ( see 👁 Scrutinizer Code Quality
) to allow best performance at maintenance cost.

Those violations are secured by comprehensive test coverage:

Contributing

Issues and pull requests are welcome!

👁 Image
👁 Image
👁 Image
👁 Image
👁 Image
👁 Image
👁 Image
👁 Image

Development supported by JetBrains.