opis / json-schema

JSON Schema validator for PHP
https://opis.io/json-schema
Apache License 2.0
568 stars 58 forks source link

How to add custom validators / keywords? #143

Closed germanbisurgi closed 1 month ago

germanbisurgi commented 1 month ago

Is it possible to define custom validators / keywords and register then into the validator? I found out that it is possible to use custom formats but in my case i need to add a custom keyword alongside" format" Any examples or documentation about it? Thanx in advance :-)

sorinsarca commented 1 month ago

In your case, I would recommend using filters (you can pass parameters to filters).

https://opis.io/json-schema/2.x/php-filter.html

https://opis.io/json-schema/2.x/filters.html

germanbisurgi commented 1 month ago

Hi @sorinsarca, thanx for your reply,

this is cool but i notice that filters are a little limited. For example i can not display a custom error message. I was thinking more of creating a custom keyword class and then register it somehow into the validator. For Example this custom validator that sums every array item property "amount" and display an error if the sum is not 100:

<?php

namespace project\modules\frontend\helper;

use Opis\JsonSchema\{Keywords\ErrorTrait, ValidationContext, Keyword, Schema};
use Opis\JsonSchema\Errors\ValidationError;

class AmountSumKeyword implements Keyword
{
    use ErrorTrait;

    protected float $expectedSum = 100;

    /**
     * @inheritDoc
     */
    public function validate(ValidationContext $context, Schema $schema): ?ValidationError
    {
        $data = $context->currentData();

        if (!is_array($data)) {
            return null;
        }

        $actualSum = 0;

        foreach ($data as $item) {
            if (is_object($item) && property_exists($item, 'amount') && is_numeric($item->amount)) {
                $actualSum += $item->amount;
            }
        }

        if ($actualSum === $this->expectedSum) {
            return null;
        }

        return $this->error($schema, $context, 'amountSum', "The sum of the 'amount' properties must equal {expected}, but found {actual}", [
            'expected' => $this->expectedSum,
            'actual' => $actualSum,
        ]);
    }
}
sorinsarca commented 1 month ago

If you need a custom error message in the filter just throw a \Opis\JsonSchema\Errors\CustomError exception.

Example

<?php

use Opis\JsonSchema\Validator;
use Opis\JsonSchema\Resolvers\FilterResolver;
use Opis\JsonSchema\Errors\CustomError;

$validator = new Validator();

/** @var FilterResolver $filters */
$filters = $validator->parser()->getFilterResolver();

$modulo = function (float $value, array $args): bool {
    $divisor = $args['divisor'] ?? 1;
    $reminder = $args['reminder'] ?? 0;
    $ok = $value % $divisor == $reminder;
    if (!$ok) {
       throw new CustomError("My custom error message");
    }
    return true;
};

// Register our modulo filter
$filters->registerCallable("number", "modulo", $modulo);
sorinsarca commented 1 month ago

In your case it should be something like this:

Schema

{
   "type": "array",
   "$filters": {
       "$func": "check-sum",
       "$vars": {
           "sum": 42,
           "error": "Sum of the items must be the answer to all"
       }
   }
}

Implementation

<?php

use Opis\JsonSchema\Validator;
use Opis\JsonSchema\Resolvers\FilterResolver;
use Opis\JsonSchema\Errors\CustomError;

$validator = new Validator();

/** @var FilterResolver $filters */
$filters = $validator->parser()->getFilterResolver();

$check_sum_filter= function (array $data, array $args): bool {

    $actualSum = 0;
    foreach ($data as $item) {
        if (is_object($item) && property_exists($item, 'amount') && is_numeric($item->amount)) {
            $actualSum += $item->amount;
        }
    }

    $expectedSum = $args["sum"];

    if ($expectedSum !== $actualSum) {
       throw new CustomError($args["error"] ?? "Sum doesn't match", ["expected" => $expectedSum, "actual" => $actualSum]);
    }
    return true;
};

// Register our check-sum filter
$filters->registerCallable("array", "check-sum", $check_sum_filter);

LE: you can also have multiple filters

{
   "$filters": [
      {"$func": "a", "$vars": {"value": 1}},
      {"$func": "b"}
   ]
}
germanbisurgi commented 1 month ago

Thank you, i'll try this tomorrow :-)

Great work by the way!

germanbisurgi commented 1 month ago

This works but have an issue. When adding $filters to a schema will trigger an error if the filter were not registered before validation. Is there a way to tell the validator to use the filter if it exists? if not, can this be implemented? I can try to make a Pull Request for this if necessary.

my schema:

{
  "type": "number",
  "$filters": {
    "$func": "prime"
  }
}
sorinsarca commented 1 month ago

No, you must have the filters registered before parsing the schema. Is there any reason you can't register the filters 3 lines of code earlier?

germanbisurgi commented 1 month ago

In my case i am sending an AJAX request to an endpoint that performs the validation with Opis. I was testing the scenario where i'm using filters in the schema but without registering the filter in the validator. This results in an error that prevents the other validation errors to be send along in the response. It would be nice to have the behaviour where when the validator doesn't have the filter it just says it's fine because json-schema is permissive by default.

sorinsarca commented 1 month ago

If you want to disable filters set allowFilters to false (this will ignore the $filters keywords from schema) https://opis.io/json-schema/2.x/php-loader.html#parser-options Otherwise, you'll have to register them. This behavior will not change.

germanbisurgi commented 1 month ago

I understand, thanks for the help :-)