Skip to content

swaggest/php-json-schema

Folders and files

NameName
Last commit message
Last commit date

Latest commit

1f3a77a · Dec 22, 2024
Dec 22, 2024
Sep 9, 2019
Jun 17, 2021
Dec 22, 2024
Sep 17, 2021
Apr 24, 2019
Jul 27, 2022
Sep 9, 2019
May 20, 2018
Aug 17, 2022
Mar 4, 2018
Sep 17, 2021
Dec 22, 2024
Dec 22, 2024
Dec 22, 2024
Sep 21, 2020
Jan 29, 2017

Repository files navigation

Swaggest JSON-schema implementation for PHP

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!

Development supported by JetBrains.

About

High definition PHP structures with JSON-schema based validation

Topics

Resources

License

Stars

Watchers

Forks

Contributors 14