1
votes

We are building an api endpoint where precision is required. We want to enforce strict validation on the parameters that are POST/PUT to the server.

If the api user sends a key=value pair that is not supported (eg. we allow the parameters [first_name, last_name] and the user includes an unsupported parameter [country]), we want the validation to fail.

Have tried building a custom validator called allowed_attributes (used as allowed_attributes:attr1,attr2,...), but for it to be usable in a $validationRules array, it has to be applied to the parent of a list of nested/child attributes (...because otherwise our custom validator did not have access to the attributes being validated).

Validator::extend('allowed_attributes', 'App\Validators\AllowedAttributesValidator@validate');

This created issues with other validators, where we then had to anticipate this parent/child structure and code around it, including additional post-validation clean-up of error keys and error message strings.

tl;dr: very dirty, not a clean implementation.

$validationRules = [
  'parent' => 'allowed_attributes:first_name,last_name',
  'parent.first_name' => 'required|string|max:40',
  'parent.last_name' => 'required|string|max:40'
];

$isValid = Validator::make(['parent' => $request], $validationRules);

var_dump("Validation results: " . ($isValid ? "passed" : "failed"));

Any ideas/suggestions on how this can be accomplished more cleanly in laravel, without requiring the use of parent/child relationship to get access to the list of all $request attributes (within the custom validator)?

2

2 Answers

0
votes

It should work for simple key/value pairs with this custom validator:

Validator::extendImplicit('allowed_attributes', function ($attribute, $value, $parameters, $validator) {
    // If the attribute to validate request top level
    if (strpos($attribute, '.') === false) {
        return in_array($attribute, $parameters);
    }

    // If the attribute under validation is an array
    if (is_array($value)) {
        return empty(array_diff_key($value, array_flip($parameters)));
    }

    // If the attribute under validation is an object
    foreach ($parameters as $parameter) {
        if (substr_compare($attribute, $parameter, -strlen($parameter)) === 0) {
            return true;
        }
    }

    return false;
});

The validator logic is pretty simple:

  • If $attribute doesn't contains a ., we're dealing with a top level parameter, and we just have to check if it is present in the allowed_attributes list that we pass to the rule.
  • If $attribute's value is an array, we diff the input keys with the allowed_attributes list, and check if any attribute key has left. If so, our request had an extra key we didn't expect, so we return false.
  • Otherwise $attribute's value is an object we have to check if each parameter we're expecting (again, the allowed_attributes list) is the last segment of the current attribute (as laravel gives us the full dot notated attribute in $attribute).

The key here is to apply it to validation rules should like this (note the first validation rule):

$validationRules = [
  'parent.*' => 'allowed_attributes:first_name,last_name',
  'parent.first_name' => 'required|string|max:40',
  'parent.last_name' => 'required|string|max:40'
];

The parent.* rule will apply the custom validator to each key of the 'parent' object.

To answer your question

Just don't wrap your request in an object, but use the same concept as above and apply the allowed_attributes rule with a *:

$validationRules = [
  '*' => 'allowed_attributes:first_name,last_name',
  'first_name' => 'required|string|max:40',
  'last_name' => 'required|string|max:40'
];

This will apply the rule to all the present top level input request fields.


NOTE: Keep in mind that laravel validation is influenced by order of the rules as they are putted in rules array. For example, moving the parent.* rule on bottom will trigger that rule on parent.first_name and parent.last_name; as opposed, keeping it as the first rule will not trigger the validation for the first_name and last_name.

This means that you could eventually remove the attributes that has further validation logic from the allowed_attributes rule's parameter list.

For example, if you would like to require only the first_name and last_name and prohibit any other field in the parent object, you might use these rules:

$validationRules = [
  // This will be triggered for all the request fields except first_name and last_name
  'parent.*' => 'allowed_attributes', 
  'parent.first_name' => 'required|string|max:40',
  'parent.last_name' => 'required|string|max:40'
];

But, the following WON'T work as expected:

$validationRules = [
  'parent.first_name' => 'required|string|max:40',
  'parent.last_name' => 'required|string|max:40',
  // This, instead would be triggered on all fields, also on first_name and last_name
  // If you put this rule as last, you MUST specify the allowed fields.
  'parent.*' => 'allowed_attributes', 
];

Array Minor Issues

As far as I know, per Laravel's validation logic, if you were up to validate an array of objects, this custom validator would work, but the error message you would get would be generic on the array item, not on the key of that array item that wasn't allowed.

For example, you allow a products field in your request, each with an id:

$validationRules = [
  'products.*' => 'allowed_attributes:id',
];

If you validate a request like this:

{
    "products": [{
        "id": 3
    }, {
        "id": 17,
        "price": 3.49
    }]
}

You will get an error on product 2, but you won't be able to tell which field is causing the problem!

0
votes

I preferred to post a new answer as the approach is different from the previous one and a bit more cleaner. So I would rather keep the two approaches separated and not mixed together in the same answer.

Better problem handling

After digging deeper into the Validation's namespace's source code since my last answer I figured out that the easiest way would have been to extend the Validator class to remplement the passes() function to also check what you needed.

This implementation has the benefit to also correcly handle specific error messages for single array/object fields without any effor and should be fully compatible with the usual error messages translations.

Create a custom validator class

You should first create a Validator class within your app folder (I placed it under app/Validation/Validator.php) and implement the passes method like this:

<?php

namespace App\Validation;

use Illuminate\Support\Arr;
use Illuminate\Validation\Validator as BaseValidator;

class Validator extends BaseValidator
{
    /**
     * Determine if the data passes the validation rules.
     *
     * @return bool
     */
    public function passes()
    {
        // Perform the usual rules validation, but at this step ignore the
        // return value as we still have to validate the allowance of the fields
        // The error messages count will be recalculated later and returned.
        parent::passes();

        // Compute the difference between the request data as a dot notation
        // array and the attributes which have a rule in the current validator instance
        $extraAttributes = array_diff_key(
            Arr::dot($this->data),
            $this->rules
        );

        // We'll spin through each key that hasn't been stripped in the
        // previous filtering. Most likely the fields will be top level
        // forbidden values or array/object values, as they get mapped with
        // indexes other than asterisks (the key will differ from the rule
        // and won't match at earlier stage).
        // We have to do a deeper check if a rule with that array/object
        // structure has been specified.
        foreach ($extraAttributes as $attribute => $value) {
            if (empty($this->getExplicitKeys($attribute))) {
                $this->addFailure($attribute, 'forbidden_attribute', ['value' => $value]);
            }
        }

        return $this->messages->isEmpty();
    }
}

This would essentially extend the default Validator class to add additional checks on the passes method. The check compute the array difference by keys between the input attributes converted to dot notation (to support array/object validation) and the attributes which have at least one rule assigned.

Replace the default Validator in the container

Then the last step you miss is to bind the new Validator class in the boot method of a service provider. To do so you can just override the resolver of the Illuminate\Validation\Factory class binded into the IoC container as 'validator':

// Do not forget the class import at the top of the file!
use App\Validation\Validator;

// ...

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        $this->app->make('validator')
            ->resolver(function ($translator, $data, $rules, $messages, $attributes) {
                return new Validator($translator, $data, $rules, $messages, $attributes);
            });
    }

// ...

Pratical use in a controller

You don't have to do anything specific to use this feature. Just call the validate method as usual:

$this->validate(request(), [
    'first_name' => 'required|string|max:40',
    'last_name' => 'required|string|max:40'
]);

Customize Error messages

To customize the error message you just have to add a translation key in your lang file with a key equal to forbidden_attribute (you can customize the error key name in the custom Validator class on the addFailure method call).

Example: resources/lang/en/validation.php

<?php

return [
    // ...

    'forbidden_attribute' => 'The :attribute key is not allowed in the request body.',

    // ...
];

Note: this implementation has been tested in Laravel 5.3 only.