InactiveProjects / limoncello-collins

Quick start JSON API application (Laravel based)
http://jsonapi.org
71 stars 10 forks source link

Unable to use Laravel's form request classes #16

Closed philbates35 closed 8 years ago

philbates35 commented 8 years ago

In the examples in the readme, all validation is handled within the controller directly. I'm trying to use Laravel's Form Requests, but it doesn't work nicely with the way your JSON API packages work.

The Laravel Form Request class passes the result of Illuminate\Http\Request::all() into the validator class it creates. I think it would be ideal to have the flexible to parse the decoded JSON API document (i.e. the array returned from Neomerx\Limoncell\Http\JsonApiTrait::getDocument()) within the form request class - however, you only have access to the JsonApiTrait at the controller level, whereas the form request class is one level lower meaning in the form request you don't have access to JsonApiTrait::getDocument().

I was able to kind of make it work by making a new JsonApiRequest class that extended Laravel's FormRequest class and used the JsonApiTrait (initialised on construction) in my new class, but that solution fails when the header fails validation or there are parameters in the URL - again, this is because in the FormRequest we are a level lower than the controller so when the integration is initialised in my FormRequest class header / parameter validation fails because, for example, $allowedIncludePaths has not yet been set.

I think the solution would be involve having a way to be able to access to the decoded JsonApiDocument any where in the application without having to validate headers / URL parameters at the same time (which is what happens when you use the JsonApiTrait. Then, I could inject / locate in the container that class in my FormRequest classes, and only check headers / URL parameters in the controllers.

For reference, here's my hacky attempt at making an abstract JsonApiFormRequest class (that my other JSON API form request classes would extend from) that works with your packages (but remember it's triggering URL parameter / headers validation which I don't want to happen at this point!). Hopefully this will make what I'm trying to say clearer:

<?php

namespace App\Http\Requests\JsonApi;

use Illuminate\Contracts\Validation\ValidationException;
use Illuminate\Contracts\Validation\Validator;
use Neomerx\Limoncello\Contracts\IntegrationInterface;
use Neomerx\Limoncello\Http\JsonApiTrait;
use App\Http\Requests\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;

abstract class JsonApiRequest extends Request
{
    use JsonApiTrait;

    /**
     * Constructor.
     *
     * @param array           $query      The GET parameters
     * @param array           $request    The POST parameters
     * @param array           $attributes The request attributes (parameters parsed from the PATH_INFO, ...)
     * @param array           $cookies    The COOKIE parameters
     * @param array           $files      The FILES parameters
     * @param array           $server     The SERVER parameters
     * @param string|resource $content    The raw body data
     *
     * @api
     */
    public function __construct(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null)
    {
        parent::__construct($query, $request, $attributes, $cookies, $files, $server, $content);

        $this->initJsonApiSupport(app(IntegrationInterface::class));
    }

    /**
     * Get the proper failed validation response for the request.
     *
     * @param  array  $errors
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function response(array $errors)
    {
        // The response will be generated at the exception handler level (where all exceptions
        // are converted into a suitable JSON API response) so at this point the response
        // hasn't been created yet.
        return null;
    }

    /**
     * Get the response for a forbidden operation.
     *
     * @return \Illuminate\Http\Response
     */
    public function forbiddenResponse()
    {
        // The response will be generated at the exception handler level (where all exceptions
        // are converted into a suitable JSON API response) so at this point the response
        // hasn't been created yet.
        return null;
    }

    /**
     * Returns a "field_name" => "field_value" array to be validated from the requested JSON API document.
     *
     * @param array $document
     * @return array
     */
    protected function documentValidationData(array $document)
    {
        return [];
    }

    /**
     * Handle a failed validation attempt.
     *
     * @param  \Illuminate\Contracts\Validation\Validator  $validator
     * @return mixed
     */
    protected function failedValidation(Validator $validator)
    {
        // Throw validation exception that the exception handler will handle.
        // It will convert it into a suitable JSON API response.
        throw new ValidationException($validator);
    }

    /**
     * Handle a failed authorization attempt.
     *
     * @return mixed
     */
    protected function failedAuthorization()
    {
        // Throw unauthorized HTTP exception that the exception handler will handle.
        // It will convert it into a suitable JSON API response.
        throw $this->createUnauthorizedRequestException();
    }

    /**
     * Get the validator instance for the request.
     *
     * This method has been overriden so that the document is passed into the validator rather than the
     * default input
     * @return \Illuminate\Contracts\Validation\Validator
     */
    protected function getValidatorInstance()
    {
        $factory = $this->container->make('Illuminate\Validation\Factory');

        if (method_exists($this, 'validator')) {
            return $this->container->call([$this, 'validator'], compact('factory'));
        }

        $document = $this->getDocument();
        $attributes = $this->documentValidationData($document);
        return $factory->make(
            $attributes, $this->container->call([$this, 'rules']), $this->messages(), $this->attributes()
        );
    }

    /**
     * Create a new 401 unauthorized HTTP exception.
     *
     * @return \Symfony\Component\HttpKernel\Exception\HttpException
     */
    private function createUnauthorizedRequestException()
    {
        return new HttpException(401, 'Unauthorised request');
    }
}

And here's a concrete form request class that extends from my new base class:

class StoreNodeRequest extends JsonApiRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'from_date' => 'required|date_format:' . Carbon::ISO8601
        ];
    }

    /**
     * Returns a "field_name" => "field_value" array to be validated from the requested JSON API document.
     *
     * @param array $document
     * @return array
     */
    protected function documentValidationData(array $document)
    {
        return [
            'from_date' => array_get($document, 'data.attributes.from_date'),
            'to_date' => array_get($document, 'data.attributes.from_date')
        ];
    }
}
neomerx commented 8 years ago

Laravel's Form Request is its own data exchange protocol (data format - forms (not json) and it has its own agreement on redirects and HTTP return codes). If you like the approach IMO the proper way is to create JsonApiRequest extending Request and implementing ValidatesWhenResolved.

ValidatesWhenResolved has only 1 method validate which implements all logic around validation, redirects, exceptions and etc.

neomerx commented 8 years ago

and of course it should be supported in a ServiceProvider similar to vendor/laravel/framework/src/Illuminate/Foundation/Providers/FormRequestServiceProvider.php

philbates35 commented 8 years ago

Thinking about it, that does seem like the way to go. However, one important point I tried to get across in the original post (probably very badly) is that at that at the point of a form request, I'd like a way to easy access what is currently received from JsonApiTrait::getDocument(). Right now, I'd need to create a JsonApiRequest class that uses JsonApiTrait, and calls $this->initJsonApiSupport(app(IntegrationInterface::class)); in the constructor (note that this would be done again immediately after in the controller too if validation passes), when all I really want is to access the decoded Json Api document array so I can perform simple validation. Does that make sense?

neomerx commented 8 years ago

Requests similar to Laravel's are added. They could be used for attributes validation. If not only attributes but relationships should be validated it's better (easier) to do on API level. Samples for both approaches are included.