Validation
Foreword
Validating a field
` When it comes to make sure about the incoming data the package provides a simple approach around validation, To learn and understand the package validation mechanism we will look at a real world example and expand on it.
So what we are going to do is validating the following putative HTTP field temperature.
- temperature
-
The field is defined as a List. meaning it can contain multiple temperature
definitions as items. Each temperature entry MUST contain a temperature value express in Celsius.
The temperature has the following required parameters
date,longitudeandlatitudeand an optionallocationparameter which is a human-readable name of the location where the temperature was read. Thelocationcan be astringor adisplaystring. The latitude and longitude are express asdecimalvalues. You will find below an example of such HTTP field.
temperature: 18.3;location=%"lagos";date=@1731573026;longitude=6.418;latitude=3.389, 12.8;date=@1730894400;longitude=6.418;latitude=3.389
Parsing the field
Since we learn that it is a list we can go ahead and parse it as usual:
use Bakame\Http\StructuredFields\OuterList;
$fieldLine = '18.3;location=%"lagos";date=@1731573026;longitude=6.418;latitude=3.389, 12.8;date=@1730894400;longitude=6.418;latitude=3.389';
$field = OuterList::fromHttpValue($fieldLine);
count($field); // returns 2 entries.
$field->first()->value(); // returns 18.3
$field->last()->value(); // returns 12.8
So far so good, the field is successfully parsed by the package.
Validating each entry separately
Each entry can be validated separately using a callback on the getBy* methods
attached to any container. Here we are using a List container, so we can do
the following:
$temperature1 = $field->getByIndex(
index: 1,
validate: fn (Item|InnerList $member) => $member instanceof Item
);
If the validate callback returns true then it means that the value accessed by the field validate
the expected constraint otherwise it failed the constraint and a Violation exception is thrown.
If you omit the validate argument or do not pass it (which is the default) the value will get
returned as is without any check.
In my example the constraint state that the return value MUST be a Item so if I indeed have
a List but the second member of that list is an InnerList instead an Violation
exception will be thrown.
The Violation exception thrown will have a generic message stating that the field failed
validation. But you can adapt the error message if you want. To do so, instead of
returning false on error you can return a template string.
$temperature1 = $field->getByIndex(
index: 1,
validate: function (Item|InnerList $member): bool|string {
if ($member instanceof Item) {
return 'The field `{index}`; `{value}` failed.';
}
return true;
});
// will generate the following message
// The field `1`; `12.8;date=@1730894400;longitude=6.418;latitude=3.389` failed.
The template string can return the incoming data if needed for logging. It supports the following variables:
{index}the member index{value}the member value in its serialized version{key}the member name (only available withDictionaryandParameters)
Now that we know how to discriminate between an InnerList and a Item we want to validate
the Item entry.
Validating the Item value
To validate the expected value of an Item you need to provide a callback to the Item::value method.
The callback behave exactly how the callback from the container was described. The only difference
is that the expected value of the callback is one of the eight value types.
Our field definition states:
Each temperature entry MUST contain a temperature value express in Celsius.
So it means that the Item value must be a decimal. Let’s use the Type enum
to quickly validate that information
use Bakame\Http\StructuredFields\Type;
$value = $member->value(Type::Decimal->supports(...));
The Type enum contains a supports method which returns true if the submitted value
is of the specified value type; otherwise it will return false. Again if we need
a more specific error message in our Violation exception we can change the code
to something more meaningful.
use Bakame\Http\StructuredFields\Type;
$value = $member
->value(
function (mixed $value): bool|string {
if (!Type::Decimal->supports($value)) {
return "The value '{value}' failed the RFC validation.";
}
return true;
}
);
// the following exception will be thrown
// new Violation("The value 'foo' failed the RFC validation.");
Validating the Item parameters.
Checking for allowed names
Before validating the content of the Parameters container we need to make
sure the container contains the proper data. That all the allowed names are
present. To do so we can use the Parameters::allowedNames method. This
method expects a list of names. If other names not present in the
list are found in the container the method will return false. If we
go back to our definition. We know that the allowed parameters names attached
to the item are: location, longitude, latitude and date
use Bakame\Http\StructuredFields\Validation\Violation;
if (!$member->parameters()->allowedKeys(['location', 'longitude', 'latitude', 'date'])) {
throw new Violation('The parameters contains extra names that are not allowed.');
}
Validating single parameters
The parameterByName and parameterByIndex methods can be used to validate a parameter value.
Since in our field there is no mention of offset, we will use the ::parameterByKey method.
Let’s try to validate the longitude parameter
Because parameters are optional by default and the longitude parameter is required we must
require its presence. So to fully validate the parameter we need to do the following
$member->parameterByKey(
name: 'longitude',
validate: fn (mixed $value) => match (true) {
Type::Decimal->supports($value) => true,
default => "The `{key}` '{value}' failed the validation check."
},
required: true,
);
Validating the complete Parameter container
We could iterate the same type of code for each parameter separately but the code
would quickly become complex. So to avoid repetition, the package
introduces a ParametersValidator.
To instantiate this class you just need to call its new static method.
use Bakame\Http\StructuredFields\Validation\ParametersValidator;
$parametersValidator = ParametersValidator::new()
This class can aggregate all the rules for a parameter container, applies them all at once and returns a result you can use to quickly know whether your parameters do meet all the criteria.
Going back to the HTTP field definitions we can translate the requirements and create the
following ParametersValidator.
We need to make sure about the allowed names for that. the class has a filterByCriteria which
expects the Parameters container as its sole argument.
$parametersValidator = ParametersValidator::new()
->filterByCriteria(function (Parameters $parameters): bool|string {
return $parameters->alloweKeys(['location', 'longitude', 'latitude', 'date']);
});
The ParametersValidator class is immutable so each added rules returns a new instance.
Then we can add all the name checks using an associative array where each entry index
will be the parameter name and each entry value will also be an array which takes
the parameters of the parameterByName method. For instance for the longitude parameter
we did earlier we end up with the following entries.
use Bakame\Http\StructuredFields\Type;
$parametersValidator = ->filterByKeys([
'longitude' => [
'validate' => function (mixed $value) {
if (!Type::Decimal->supports($value)) {
return "The `{key}` '{value}' failed the validation check.";
}
return true;
},
'required' => true,
],
]);
We can do the same for all the other names, the available parameters are:
validate: the callback used for validation;nullby defaultrequired: a boolean telling whether the parameter presence is required;falseby defaultdefault: the default value if the parameter is optional;nullby default.
if we put together the class to validate our parameters we end up with the following code.
use Bakame\Http\StructuredFields\Parameters;
use Bakame\Http\StructuredFields\Type;
use Bakame\Http\StructuredFields\Validation\ParametersValidator;
$parametersValidator = ParametersValidator::new()
->filterByCriteria(
fn (Parameters $parameters): bool|string => $parameters
->allowedKeys(['location', 'longitude', 'latitude', 'date'])
)
->filterByKeys([
'location' => [
'validate' => fn (mixed $value) => Type::fromVariable($value)->isOneOf(Type::String, Type::DisplayString),
],
'longitude' => [
'validate' => function (mixed $value) {
if (!Type::Decimal->supports($value)) {
return "The `{key}` '{value}' failed the validation check.";
}
return true;
},
'required' => true,
],
'latitude' => [
'validate' => function (mixed $value) {
if (!Type::Decimal->supports($value)) {
return "The `{key}` '{value}' failed the validation check.";
}
return true;
},
'required' => true,
],
'date' => [
'validate' => function (mixed $value) {
if (!Type::Date->supports($value)) {
return "The `{key}` '{value}' is not a valid date";
}
return true;
},
'required' => true,
]
]);
We can now validate the parameters by calling the ParametersValidator::validate method:
$validation = $parametersValidator->validate($members->parameters());
if ($validation->isFailed()) {
throw $validation->errors->toException();
// throws a Violation exception whose error messages contains all the error messages found.
}
The $result is a Result class which tells whether the validation was successfully
performed or not. In case of errors, the class exposes a ViolationList collection via its
public readonly property errors which contains all the Violation exceptions triggered
during the validation process. In case of success, the class will return the filtered
data via it’s public readonly property data.
$validation = $parametersValidator->validate($members->parameters());
if ($validation->isSucces()) {
$parameters = $validation->data->all();
$parameters['longitude']; // 6.418
$parameters['location']; // null
$parameters['date']; // new DateTimeImmutable('@1730894400');
}
A filterByIndices method exists and behave exactly as the filterByKeys method.
There are two differences when it is used:
- The callback parameters are different (they match those of
parameterByIndex) - The returned parameters data in case of success is different
$validation = $parametersValidator->validate($members->parameters());
if ($validation->isSucces()) {
$parameters = $validation->data->all();
$parameters[0]; // returns ['longitude', 6.418]
$parameters[1]; // returns ['location', null];
$parameters[2]; // returns ['date', new DateTimeImmutable('@1730894400')];
}
Validating the full Item
Now that we have validated the parameters and the item value. It would be nice to
validate the Item once with all the rules. To do so, let’s use the ItemValidator
class.
use Bakame\Http\StructuredFields\Validation\ItemValidator;
use Bakame\Http\StructuredFields\Validation\ParametersValidator;
use Bakame\Http\StructuredFields\Type;
$itemValidator = ItemValidator::new()
->value(
function (mixed $value) {
if (!Type::Decimal->supports($value)) {
return "The value '{value}' failed the RFC validation.";
}
return true;
}
)
->parameters($parametersValidator);
$result = $itemValidator->value($member);
And just like with the ParametersValidator we get a Validation\Result DTO.
In case of failure we have the same behaviour.
$validation = $itemValidator->validate($members->parameters());
if ($validation->isFailed()) {
throw $validation->errors->toException();
// throws a Violation exception whose error messages contains all the error messages found.
}
In case of success, the return data is slightly different:
$validation = $itemValidator->validate($members->parameters());
if ($validation->isSuccess()) {
$itemValue = $validation->data->value; // returns 12.8
$parameters = $validation->data->parameters->all();
$parameters['longitude']; // 6.418
$parameters['location']; // null
$parameters['date']; // new DateTimeImmutable('@1730894400');
}
So now let’s go back to where we started.
$temperatureValidator = function (Item|InnerList $member) use ($itemValidator): bool|string {
if (!$member instanceof Item) {
return 'The field `{index}`; `{value}` failed.';
}
return $itemValidator($member);
};
$temperature1 = $field->getByIndex(index: 1, validate: $temparatureValidator);
The ParametersValidator and the ItemValidator are invokable, so we can use them
directly with the getBy* methods. So once you have configured your validator in a
class it becomes easier to reuse it to validate your data.
To show how this can be achieved you can check the codebase from HTTP Cache Status