swaggest / php-json-schema

High definition PHP structures with JSON-schema based validation
MIT License
451 stars 51 forks source link

[Question]How to use Refs correctly? #98

Closed abicur closed 4 years ago

abicur commented 4 years ago

Good day! I have two related tasks. The first is to generate json schema from php code and the second is to validate and import json into php classes. A schema can have several levels of nesting. One of the conditions for the generated scheme is to use common parts using the definitions section and refs. Let look to example:

class Address extends ClassStructure
{
    public $city;
    public $street;
    public $addition;

    public static function setUpProperties($properties, Schema $ownerSchema)
    {
        $properties->city = Schema::string();
        $properties->city->minLength = 2;
        $properties->street = Schema::string();

        $properties->addition = Schema::object()->setRef('#/definitions/Addition');

        $ownerSchema->type = 'object';
        $ownerSchema->title = 'Address';
        $ownerSchema->id = 'Address';
        $ownerSchema->setRequired(['city', 'street', 'addition']);
        $ownerSchema->setDefinitions(['Addition' => Addition::schema()]);
    }
}

class Addition extends ClassStructure
{
    public $data;
    public $number;

    public static function setUpProperties($properties, Schema $ownerSchema)
    {
        $properties->data = Schema::string();
        $properties->data->maxLength = 255;
        $properties->number = Schema::integer();
        $properties->number->maximum = 100;

        $ownerSchema->title = 'Addition';
        $ownerSchema->type = 'object';
        $ownerSchema->id = 'Addition';
        $ownerSchema->setRequired(['data', 'number']);
    }
}

echo json_encode(Address::schema(), JSON_PRETTY_PRINT);

By using this code I can get at output schema:

{
  "id": "Address",
  "title": "Address",
  "required": [
    "city",
    "street",
    "addition"
  ],
  "definitions": {
    "Addition": {
      "id": "Addition",
      "title": "Addition",
      "required": [
        "data",
        "number"
      ],
      "properties": {
        "data": {
          "maxLength": 255,
          "type": "string"
        },
        "number": {
          "maximum": 100,
          "type": "integer"
        }
      },
      "type": "object"
    }
  },
  "properties": {
    "city": {
      "minLength": 2,
      "type": "string"
    },
    "street": {
      "type": "string"
    },
    "addition": {
      "type": "object",
      "$ref": "#\/definitions\/Addition"
    }
  },
  "type": "object"
}

Looks good. But if I try to perform validation using the same classes I get unexpected behavior.


$data = json_decode('{"city": "test", "street": "some street", "addition": {"data": "", "number": "99"}}');
$address = Address::import($data); //There is no exception here because additions is not validated

If I change Address class from: $properties->addition = Schema::object()->setRef('#/definitions/Addition'); to: $properties->addition = Addition::schema(); Then validation will work correctly, but generated schema will not use refs:

{
    "id": "Address",
    "title": "Address",
    "required": [
        "city",
        "street",
        "addition"
    ],
    "definitions": {
        "Addition": {
            "id": "Addition",
            "title": "Addition",
            "required": [
                "data",
                "number"
            ],
            "properties": {
                "data": {
                    "maxLength": 255,
                    "type": "string"
                },
                "number": {
                    "maximum": 100,
                    "type": "integer"
                }
            },
            "type": "object"
        }
    },
    "properties": {
        "city": {
            "minLength": 2,
            "type": "string"
        },
        "street": {
            "type": "string"
        },
        "addition": {
            "id": "Addition",
            "title": "Addition",
            "required": [
                "data",
                "number"
            ],
            "properties": {
                "data": {
                    "maxLength": 255,
                    "type": "string"
                },
                "number": {
                    "maximum": 100,
                    "type": "integer"
                }
            },
            "type": "object"
        }
    },
    "type": "object"
}

I think I missed some easy way to close both my needs. Please tell me where to look. Thank you in advance for your help.

vearutop commented 4 years ago

When defining schema with ClassStructure, the referencing is done automatically with PHP own object references, you just need to use SomeStructure::schema(). Definition name is owned by that referenced class and can be customized with setFromRef.

Also when encoding Schema instance directly (json_encode(SomeStructure::schema())), the root schema context is not available, so all references are inlined by value instead of being put under definitions of root schema.

In order to maintain references it is necessary to export schema (Schema::export(SomeStructure::schema())) before encoding.

Here is an example that may fit your needs, hope it helps.

<?php

namespace Somewhere;

use Swaggest\JsonSchema\Schema;
use Swaggest\JsonSchema\Structure\ClassStructure;

require __DIR__ . '/vendor/autoload.php';

class Address extends ClassStructure
{
    public $city;
    public $street;
    public $addition;

    /**
     * @param \Swaggest\JsonSchema\Constraint\Properties|static $properties
     * @param Schema $ownerSchema
     */
    public static function setUpProperties($properties, Schema $ownerSchema)
    {
        $properties->city = Schema::string();
        $properties->city->minLength = 2;
        $properties->street = Schema::string();

        $properties->addition = Addition::schema();

        $ownerSchema->type = Schema::OBJECT;
        $ownerSchema->title = 'Address';
        $names = self::names();
        $ownerSchema->setRequired([$names->city, $names->street, $names->addition]);
    }
}

class Addition extends ClassStructure
{
    public $data;
    public $number;

    /**
     * @param \Swaggest\JsonSchema\Constraint\Properties|static $properties
     * @param Schema $ownerSchema
     */
    public static function setUpProperties($properties, Schema $ownerSchema)
    {
        $properties->data = Schema::string();
        $properties->data->maxLength = 255;
        $properties->number = Schema::integer();
        $properties->number->maximum = 100;

        $ownerSchema->title = 'Addition';
        $ownerSchema->type = Schema::OBJECT;
        $ownerSchema->setRequired([self::names()->data, self::names()->number]);

        // By default fully qualified class name (e.g. "Somewhere\\Addition") is used as definition name.
        // If namespace or class name are unwanted, custom $ref value can be used.
        $ownerSchema->setFromRef('#/definitions/Addition');
    }
}

// Schema export is necessary to rebuild definitions and references as part of resulting JSON.
// JSON encoding schema value directly (without exporting) inlines all references as literal values.
echo json_encode(Schema::export(Address::schema()), JSON_PRETTY_PRINT + JSON_UNESCAPED_SLASHES), PHP_EOL;

$data = json_decode('{"city": "test", "street": "some street", "addition": {"data": "", "number": "99"}}');

// PHP Fatal error:  Uncaught Swaggest\JsonSchema\Exception\TypeException: Integer expected, "99" received at
// #->$ref[#/definitions/Somewhere\Address]->properties:addition->$ref[#/definitions/Addition]->properties:number
$address = Address::import($data);

Result:

{
    "title": "Address",
    "required": [
        "city",
        "street",
        "addition"
    ],
    "properties": {
        "city": {
            "minLength": 2,
            "type": "string"
        },
        "street": {
            "type": "string"
        },
        "addition": {
            "$ref": "#/definitions/Addition"
        }
    },
    "type": "object",
    "definitions": {
        "Addition": {
            "title": "Addition",
            "required": [
                "data",
                "number"
            ],
            "properties": {
                "data": {
                    "maxLength": 255,
                    "type": "string"
                },
                "number": {
                    "maximum": 100,
                    "type": "integer"
                }
            },
            "type": "object"
        }
    }
}

Side notes:

self::names() can help to maintain symbolic references to your properties (with code completion and typo-safety granted by IDE).

You may be misusing id property of schema, in general its purpose is to create reference scopes within schemas and usually id should be an url. There is no need to set id unless it helps to organize custom schema discovery or resolution.

You can also have a look at json-cli to generate PHP classes like these from JSON schema.

abicur commented 4 years ago

Thank you so much for your reply. It was really quite easy. Unfortunately, the documentation does not answer these questions directly. It might be useful to add a similar example to the documentation.