phpjsonlaravelapivalidation

Laravel FormRequest Validation for Rest API get calls with 2 query json params not working properly


I got a Laravel FormRequest class that should validate two JSON query strings (nested objects and arrays) on a GET route on my JSON Rest API. I pick up the json query strings, decode them to php objects and store them during validation prepare time on the request for validation.

For some reason, this seems not to work properly. The 'sorters' json validation is working, as is the 'sorters_decoded' validation for 'required' and 'array'. The array element validation and everything after seems not to work since i reach the controller function even if invalid data is sent. The query input bag collection on the request is modified (invalid data is set to null) but no validation 422 response is generated. Do you maybe see something wrong with this code?

class RecipeFindRequest extends FormRequest
{

    protected function prepareForValidation(): void
    {
        try {
            $sorters = null;
            if ($this->query->has('sorters')) {
                $sorters = json_decode($this->query->get('sorters'));
                $this->merge([
                    'sorters_decoded' => $sorters,
                ]);
            }

            $filters = null;
            if ($this->query->has('filters')) {
                $filters = json_decode($this->query->get('filters'));
                $this->merge([
                    'filters_decoded' => $filters,
                ]);
            }

            $this->merge([
                'language' => $this->headers->get('language'),
            ]);
        } catch(\Throwable $e) {
            //die silently, fail response will get raised on validation time
        }
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'sorters' => ['json'],
            'sorters_decoded' => ['required', 'array'],
            'sorters_decoded.*.name' => ['required', 'string', Rule::in(['likes', 'ratings', 'calories', 'carbs', 'protein', 'fat', 'created'])],
            'sorters_decoded.*.direction' => ['required', 'string', Rule::in(['asc', 'desc'])],
            'sorters_decoded.*.order' => ['required', 'integer'],
            'filters' => ['json'],
            'filters_decoded.duration_high' => ['integer', 'min:0'],
            'filters_decoded.duration_low' => ['integer', 'min:0'],
            'filters_decoded.title' => ['string'],
            'filters_decoded.difficulty' => ['array'],
            'filters_decoded.difficulty.*' => ['string', 'exists:difficulties,id'],
            'filters_decoded.ingredients' => ['array'],
            'filters_decoded.ingredients.*.id' => ['string', 'exists:ingredients,id'],
            'filters_decoded.ingredients.*.relation' => ['string', Rule::in(['include', 'exclude'])],
            'filters_decoded.liked_by_me' => ['boolean'],
            'filters_decoded.cookbooks' => ['array'],
            'filters_decoded.cookbooks.*' => ['string', 'exists:cookbooks,id'],
            'filters_decoded.nutritions' => ['array'],
            'filters_decoded.nutritions.*.category_id' => ['string', 'exists:nutrition_categories,id'],
            'filters_decoded.nutritions.*.nutrition_high' => ['numeric', 'min:0'],
            'filters_decoded.nutritions.*.nutrition_low' => ['numeric', 'min:0'],
            'language' => ['string', 'size:2', 'exists:i18n_languages,short'],
            'page' => ['integer', 'min:1'],
            'per_page' => ['integer', 'min:1'],
        ];
    }
}

Both params are sent as json query strings and the decoding step works, so its not something to do with url de/encoding.

enter image description here

I tried to change the sorters.*.name array element validation from string to integer, and when i sent in some data like [{'name':'a'}] the 'integer' validation changed the data in $response->query->sorters_decoded to [{'name':null}]. But no 422 validation fail response came up.

Thank you for considering my problem, Mikkey


Solution

  • There are a number of problems here:

    Here's what I would try:

    <?php
    
    namespace App\Http\Requests;
    
    use Illuminate\Validation\ValidationException;
    use Throwable;
    
    class RecipeFindRequest extends FormRequest
    {
    
        protected function prepareForValidation(): void
        {
            try {
                if ($this->query->has('sorters')) {
                    $sorters = json_decode($this->query->get('sorters'), associative: true, flags: \JSON_THROW_ON_ERROR);
                }
    
                if ($this->query->has('filters')) {
                    $filters = json_decode($this->query->get('filters'), associative: true, flags: \JSON_THROW_ON_ERROR);
                }
    
                $this->merge([
                    // user cannot easily change the headers of a request
                    // so you should set a sensible default for this value
                    'language' => $this->headers->get('language', 'en'),
                    'page' => $this->query->get('page'),
                    'per_page' => $this->query->get('per_page'),
                    'sorters_decoded' => $sorters ?? null,
                    'filters_decoded' => $filters ?? null,
    
                ]);
            } catch(Throwable $e) {
                if (isset($sorters)) {
                    // if this is set, the error was with the second decode
                    $messages = ['filters' => 'An invalid filter value was passed'];
                } else {
                    $messages = ['sorters' => 'An invalid sort value was passed'];
                }
                throw ValidationException::withMessages($messages);
            }
        }
    
        /**
         * Get the validation rules that apply to the request.
         *
         * @return array<string, mixed>
         */
        public function rules(): array
        {
            return [
                'sorters_decoded' => ['required', 'array'],
                'sorters_decoded.*.name' => ['required', 'string', Rule::in(['likes', 'ratings', 'calories', 'carbs', 'protein', 'fat', 'created'])],
                'sorters_decoded.*.direction' => ['required', 'string', Rule::in(['asc', 'desc'])],
                'sorters_decoded.*.order' => ['required', 'integer'],
                'filters_decoded.duration_high' => ['integer', 'min:0'],
                'filters_decoded.duration_low' => ['integer', 'min:0'],
                'filters_decoded.title' => ['string'],
                'filters_decoded.difficulty' => ['array'],
                'filters_decoded.difficulty.*' => ['string', 'exists:difficulties,id'],
                'filters_decoded.ingredients' => ['array'],
                'filters_decoded.ingredients.*.id' => ['string', 'exists:ingredients,id'],
                'filters_decoded.ingredients.*.relation' => ['string', Rule::in(['include', 'exclude'])],
                'filters_decoded.liked_by_me' => ['boolean'],
                'filters_decoded.cookbooks' => ['array'],
                'filters_decoded.cookbooks.*' => ['string', 'exists:cookbooks,id'],
                'filters_decoded.nutritions' => ['array'],
                'filters_decoded.nutritions.*.category_id' => ['string', 'exists:nutrition_categories,id'],
                'filters_decoded.nutritions.*.nutrition_high' => ['numeric', 'min:0'],
                'filters_decoded.nutritions.*.nutrition_low' => ['numeric', 'min:0'],
                'language' => ['string', 'size:2', 'exists:i18n_languages,short'],
                'page' => ['integer', 'min:1'],
                'per_page' => ['integer', 'min:1'],
            ];
        }
    }
    

    The size of these URLs must be massive; this is the sort of thing that post requests are best used for. (But this isn't a hard and fast rule, just my opinion.)