0

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.

5
  • 1
    Have you done a dump($this->sorters_decoded) to see if the structure matches up with what you expect? (Do not include pictures of text in your question, it is not accessible or reusable.) Commented Jun 8, 2023 at 20:07
  • 1
    Also if you want to fail in prepareForValidation() you would need to a) not consume the exception and b) tell json_decode() to throw exceptions – it does not do this by default. Commented Jun 8, 2023 at 20:12
  • 1
    And, are you really passing these JSON strings in the URL query string? That seems not ideal. Commented Jun 8, 2023 at 20:13
  • hi @miken32. Thanks for your comment. Sorry for text on the pic, the relevant sorter query param is "[{"name":"likes","direction":"asc","order":1}]". Since it is a get call, I didn't want to transport the json in the request body. I could change it to POST if nothing else will help tho. I checked the $this->sorters_decoded with xdebug and it seems to be the correct format. var_dump($this->sorters) shows: array(1) { [0]=> object(stdClass)#1450 (3) { ["name"]=> string(5) "likes" ["direction"]=> string(3) "asc" ["order"]=> int(1) } } Commented Jun 9, 2023 at 7:01
  • ^^ the var_dump is from $this->sorters_decoded Commented Jun 9, 2023 at 7:26

1 Answer 1

0

There are a number of problems here:

  • In prepareForValidation() you have a try/catch block that does nothing with the caught exception. Contrary to what your comment suggests, it will not be returned as a validation error because you're catching it. Even if it went uncaught, I'm not sure it would show up as a 422 error on the client side.
  • json_decode() does not throw errors by default, so there's nothing for your try/catch statement to work with anyway!
  • json_decode() also decodes to objects by default; your subsequent validation rules will not work without arrays.
  • You have a validation rules for data that you send in the query string. Form request validation only works on the body of the message, not the query string. Anything you want validated needs to be manually merged.

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.)

Sign up to request clarification or add additional context in comments.

1 Comment

Thank you, this is what I was looking for. I'm fairly new to PHP, worked with C# the last couple years.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.