How I Fixed: Missing Headers on Response in Symfony 3 API

The first time this caught me out, I didn’t feel so bad. The second time – i.e. just now – I knew I had already solved this problem (on a different project), and found my urge to kill rising.

I wanted to POST in some data, and if the resource is successfully created, then the response should contain a link – via a HTTP header – to the newly created resource.

Example PHP / Symfony 3 API controller action code snippet:

    public function postAction(Request $request)
    {
        $form = $this->createForm(MyResourceType::class, null, [
            'csrf_protection' => false,
        ]);

        $form->submit($request->request->all());

        if (!$form->isValid()) {
            return $form;
        }

        $myResource = $form->getData();

        $em = $this->getDoctrine()->getManager();
        $em->persist($myResource);
        $em->flush();

        $routeOptions = [
            'id'      => $myResource->getId(),
            '_format' => $request->get('_format'),
        ];

        return $this->routeRedirectView(
            'get_myresource', 
            $routeOptions, 
            Response::HTTP_CREATED
        );
    }

And from the front end, something like this:

export async function createMyResource(important, info, here) {

  const baseRequestConfig = getBaseRequestConfig();

  const requestConfig = Object.assign({}, baseRequestConfig, {
    method: 'POST',
    body: JSON.stringify({
      important,
      info,
      here
    })
  });

  /* global API_BASE_URL */
  const url = API_BASE_URL + '/my-resource';

  const response = await asyncFetch(url, requestConfig);

  return {
    myResource: {
      id: response.headers.get('Location').replace(`${url}/`, '')
    }
  };
}

Now, the interesting line here – from my point of view, at least – is the final line.

Because this is a newly created resource, I won’t know the ID unless the API tells me. In the Symfony controller action code, the routeRedirectView  will take care of this for me, adding on a Location header pointing to the new resource / record.

I want to grab the Location  from the Headers returned on the Response and by removing the part of the string that contains the URL, I can end up with the new resource ID. It’s brittle, but it works.

Only, sometimes it doesn’t work.

Response
body:(...)
bodyUsed: false
headers: Headers
  __proto__: Headers
ok:true
status:201
statusText:"Created"
type:"cors"
url:"http://api.my-api.dev/app_dev.php/my-resource"
__proto__:Response

Excuse the formatting.

From JavaScript’s point of view, the Headers array is empty.

This leads to an enjoyable error: “Cannot read property ‘replace’ of null”.

Confusingly, however, from the Symfony profiler output from the very same request / response, I can see the header info is there:

Good times.

Ok, so the solution to this is really simple – when you know the answer.

Just expose the Location  header 🙂

# /app/config/config.yml

# Nelmio CORS
nelmio_cors:
    defaults:
        allow_origin:  ["%cors_allow_origin%"]
        allow_methods: ["POST", "PUT", "GET", "DELETE", "OPTIONS"]
        allow_headers: ["content-type", "authorization"]
        expose_headers: ["Location"] # this being the important line
        max_age:       3600
    paths:
        '^/': ~

After that, it all works as expected.

Gotcha: Upgrading To FOSRestBundle 2.0

This is not a huge issue, and likely quite easy to identify and fix. But I’m the kind of person who hits Google before engaging brain, and I didn’t find a direct answer (boo, wake up brain!).

I have, this very evening, decided to try FOSRestBundle 2.0. It’s currently still in development, so isn’t available without a little naughty composer tagging:

    "require": {
        "friendsofsymfony/rest-bundle": "^2.0@dev"
    },

A little tip there – if you use the @dev after your requested version number, you can force through development packages without globally changing your project’s minimum-stability  setting.

Anyway, being that I’ve been coding for the last 12.5 hours, I went with the lazy man’s option of copy / pasting my config from a FOSRestBundle 1.7 installation I happened to have laying around.

fos_rest:
    view:
        exception_wrapper_handler:  null
        mime_types:
            json: ['application/json', 'application/x-json']
            jpg: 'image/jpeg'
            png: 'image/png'

Firstly, I got this:

InvalidConfigurationException in ArrayNode.php line 317:
Unrecognized option "exception_wrapper_handler" under "fos_rest.view"

Which confuses me, as this seems to be the same as what’s in the configuration reference.

Anyway, simply removing that line makes the error go away.

The next error I got was this:

Warning: array_merge(): Argument #1 is not an array
500 Internal Server Error - ContextErrorException

And what this turns out to be is that the mime_types must now all be arrays:

fos_rest:
    view:
        mime_types:
            json: ['application/json', 'application/x-json']
            jpg: ['image/jpeg']
            png: ['image/png']

So there you go 🙂

Checking An Edge Case With Symfony Voters

I love Symfony Voters. The Access Control List is, in the vast majority of cases, totally overkill for my security needs. Symfony’s Security Voters are a very quick and easy alternative.

Yesterday I got caught out by an edge case I had not been testing for.

A little background on this – I have been creating a RESTful API using Symfony 3, Behat 3, PHPSpec, and a few other interesting technologies. If you are interested in knowing a little more about this then be sure to check out the video tutorial series here on CodeReviewVideos where I have been documenting as I go.

The video tutorial series covers three different entities – Users (FOSUserBundle), Accounts, and Files (with FlySystem).

I want to ensure the currently logged in User has access to the resources they are requesting. I’ve been doing this with Voters.

It’s likely easier to follow along with these words if I give them more context. Here’s the code for the File Voter:

<?php

namespace spec\AppBundle\Security\Authorization\Voter;

use AppBundle\Model\AccountInterface;
use AppBundle\Model\FileInterface;
use AppBundle\Model\UserInterface;
use AppBundle\Security\Authorization\Voter\FileVoter;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

class FileVoterSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType('AppBundle\Security\Authorization\Voter\FileVoter');
        $this->shouldImplement('Symfony\Component\Security\Core\Authorization\Voter\VoterInterface');
    }

    function it_supports_expected_attributes()
    {
        $this->supportsAttribute('view')->shouldReturn(true);
    }

    function it_doesnt_support_unexpected_attributes()
    {
        $this->supportsAttribute('1')->shouldReturn(false);
    }

    function it_supports_expected_class(FileInterface $file)
    {
        $this->supportsClass($file)->shouldReturn(true);
    }

    function it_doesnt_support_unexpected_class()
    {
        $this->supportsClass(new \StdClass())->shouldReturn(false);
    }

    function it_abstains_if_doesnt_support_attribute(TokenInterface $token, FileInterface $file)
    {
        $this->vote($token, $file, ['unsupported'])->shouldReturn(VoterInterface::ACCESS_ABSTAIN);
    }

    function it_abstains_if_doesnt_support_this_class(TokenInterface $token)
    {
        $this->vote($token, new \StdClass(), [FileVoter::VIEW])->shouldReturn(VoterInterface::ACCESS_ABSTAIN);
    }

    function it_denies_if_not_matching(
        TokenInterface $token,
        UserInterface $user,
        FileInterface $file
    )
    {
        $token->getUser()->willReturn($user);

        $file->belongsToUser($user)->willReturn(false);

        $this->vote($token, $file, [FileVoter::VIEW])
            ->shouldReturn(VoterInterface::ACCESS_DENIED);
    }

    function it_grants_if_matching(
        TokenInterface $token,
        UserInterface $user,
        FileInterface $file
    )
    {
        $token->getUser()->willReturn($user);

        $file->belongsToUser($user)->willReturn(true);

        $this->vote($token, $file, [FileVoter::VIEW])
            ->shouldReturn(VoterInterface::ACCESS_GRANTED);
    }
}

And the implementation:

<?php

namespace AppBundle\Security\Authorization\Voter;

use AppBundle\Model\AccountInterface;
use AppBundle\Model\FileInterface;
use AppBundle\Model\UserInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

class FileVoter implements VoterInterface
{
    const VIEW = 'view';

    /**
     * @param string $attribute
     * @return bool
     */
    public function supportsAttribute($attribute)
    {
        return in_array($attribute, array(
            self::VIEW,
        ));
    }

    /**
     * @param string $class
     * @return bool
     */
    public function supportsClass($class)
    {
        $supportedClass = 'AppBundle\Model\FileInterface';

        return $supportedClass === $class || is_subclass_of($class, $supportedClass);
    }

    /**
     * @param TokenInterface            $token
     * @param null|FileInterface        $requestedFile
     * @param array                     $attributes
     * @return int
     */
    public function vote(TokenInterface $token, $requestedFile, array $attributes)
    {
        // check if class of this object is supported by this voter
        if (!$this->supportsClass(get_class($requestedFile))) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // set the attribute to check against
        $attribute = $attributes[0];

        // check if the given attribute is covered by this voter
        if (!$this->supportsAttribute($attribute)) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // get current logged in user
        $loggedInUser = $token->getUser(); /** @var $loggedInUser UserInterface */

        if ($requestedFile->belongsToUser($loggedInUser)) {
            return VoterInterface::ACCESS_GRANTED;
        }

        return VoterInterface::ACCESS_DENIED;
    }

}

This all works and is passing.

However, there is an edge case I hadn’t considered.

What happens if $requestedFile->belongsToUser($loggedInUser) fails, or returns null? Well, bad times as it happens.

Why might it fail? Well, let’s look at that interface, and the real method implementation:

<?php

namespace AppBundle\Security\Authorization;

use AppBundle\Model\UserInterface;

/**
 * Interface UserOwnableInterface
 * @package AppBundle\Security\Authorization
 */
interface UserOwnableInterface
{
    /**
     * @param UserInterface $user
     * @return bool
     */
    public function belongsToUser(UserInterface $user);
}

And the implementation:

    /**
     * @param UserInterface $user
     * @return bool
     */
    public function belongsToUser(UserInterface $user)
    {
        return $this
            ->getAccounts()
            ->filter(function ($account) use ($user) { /** @var $account AccountInterface */
                return $account->isManagedBy($user);
            })
            ->count() > 0
        ;
    }

Due to a mistake in my fixtures, the File wasn’t correctly being added to the Account I expected, so the call of getAccounts() was returning null.

In this case, I first fixed my fixtures as that situation should never really happen.

But what if it does? I don’t want to be throwing errors to the user. It’s easier to deny them access, log the fault (using monolog, and/or to ELK), and fix it in a slightly more controlled manner.

I added the following two specs to my FileVoterSpec test file:

    function it_denies_if_belongsToUser_throws(
        TokenInterface $token,
        UserInterface $user,
        FileInterface $file
    )
    {
        $token->getUser()->willReturn($user);

        $file->belongsToUser($user)->willThrow(new \Exception());

        $this->vote($token, $file, [FileVoter::VIEW])
            ->shouldReturn(VoterInterface::ACCESS_DENIED);
    }

    function it_denies_if_belongsToUser_returns_null(
        TokenInterface $token,
        UserInterface $user,
        FileInterface $file
    )
    {
        $token->getUser()->willReturn($user);

        $file->belongsToUser($user)->willReturn(null);

        $this->vote($token, $file, [FileVoter::VIEW])
            ->shouldReturn(VoterInterface::ACCESS_DENIED);
    }

And then changed up the implementation (uglify!):

    /**
     * @param TokenInterface            $token
     * @param null|FileInterface        $requestedFile
     * @param array                     $attributes
     * @return int
     */
    public function vote(TokenInterface $token, $requestedFile, array $attributes)
    {
        // check if class of this object is supported by this voter
        if (!$this->supportsClass(get_class($requestedFile))) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // set the attribute to check against
        $attribute = $attributes[0];

        // check if the given attribute is covered by this voter
        if (!$this->supportsAttribute($attribute)) {
            return VoterInterface::ACCESS_ABSTAIN;
        }

        // get current logged in user
        $loggedInUser = $token->getUser(); /** @var $loggedInUser UserInterface */

        try {
            if ($requestedFile->belongsToUser($loggedInUser)) {

                return VoterInterface::ACCESS_GRANTED;
            }
        } catch (\Exception $e) {

            return VoterInterface::ACCESS_DENIED;
        }

        return VoterInterface::ACCESS_DENIED;
    }

Note the try/catch block. It’s definitely made the code uglier, but it ‘fixes’ a potential problem I hadn’t considered.

I say ‘fixes’ as this should never happen. But if I have learned anything over the last 15-odd years, it’s that those things that should never happen… they happen.