We have moved our forum to GitHub Discussions. For questions about Phalcon v3/v4/v5 you can visit here and for Phalcon v6 here.

HTTP 405 Method Not Allowed For Restful Interfaces

When using a router to place a method constraint on a route such as a HTTP POST, if I perform any other method request on the same URI, I get directed to the default notfound route.

Is it possible to specify a method for a route, and then methods that do not match should return an ALLOW header but more importantly a HTTP 405.

TIA



15.2k
Accepted
answer

Okay, so going to answer my own question here.

By rights, a RESTful API should be returning a HTTP 405 when a method does not match the required action. Can DELETE if we only want POST. The problem here being the route will not match the route, as you have bound it to a HTTP method. As the route is not matched, it will then default to the standard notFound route if you have one defined. This is less than ideal for API clients, they should get a standard HTTP response with HTTP Status code telling them ther are being bad clients.

One way to do this would be to of course add in logic to the action itself, but thats a pain in the ass to do for every other action. We could limit each controller to only contain HTTP POST functionality say, but thats also not realistic long term.

So after a breif bit of thought, a plugin seems to be the way to go this. But wait, how will I know what methods my action supports? I can't intercept the router, as its not going to be matched if the method is not right... ahhhh I am no fan of them, but maybe annotations have the answer for me.

First, we need to create a plugin, this one is called RestfulMethodsCheck

namespace MyApp\Phalcon\Dispatcher\Plugin;

use Phalcon\Events\Event;
use Phalcon\Mvc\User\Plugin;
use Phalcon\Mvc\Dispatcher;

/**
 * Plugin to detect the HTTP method of a request and determine if matches an actions annotated methods. If it does not
 * then plugin will end the execution of the request by returning a HTTP 405 Method Not Allowed allow with an Allow
 * header of methods that are permitted.
 *
 * @package Pentagon\Phalcon\Dispatcher\Plugin
 */
class RestfulMethodsCheck extends Plugin
{
    /**
     * Check the requested method against an annotation on the action (if any) and fail it if its not matching.
     *
     * @param Event $event
     * @param Dispatcher $dispatcher
     * @return bool
     */
    public function beforeExecuteRoute(Event $event, Dispatcher $dispatcher)
    {
        $annotations = $this->annotations->getMethod($dispatcher->getActiveController(), $dispatcher->getActiveMethod());
        if ($annotations->has('HTTPMethods')) {
            $annotation = $annotations->get('HTTPMethods');
            if (!in_array($this->request->getMethod(), array_change_key_case($annotation->getArguments(), CASE_UPPER))) {
                $this->response->setStatusCode(405, 'Method Not Allowed');
                $this->response->setHeader('Allow', implode(',', $annotation->getArguments()));
                $this->response->send();
                return false;
            }
        }
        return true;
    }
}

Now, we need to register this as a new event listener in our services definitions. This one is in the API module, so we shall add it there.

$di->set('dispatcher', function () use ($di) {
            //Obtain the standard eventsManager from the DI
            $eventsManager = $di->getShared('eventsManager');
            //Instantiate the Restful Methods plugin
            $pluginRestfulMethods = new Pentagon\Phalcon\Dispatcher\Plugin\RestfulMethodsCheck();
            //Listen for events produced in the dispatcher using the Restful Methods plugin
            $eventsManager->attach('dispatch', $pluginRestfulMethods);
            // Create an instance of the dispatcher.
            $dispatcher = new Phalcon\Mvc\Dispatcher();
            //Bind the EventsManager to the Dispatcher
            $dispatcher->setEventsManager($eventsManager);
            return $dispatcher;
        });

We now create a route to our API endpoint. N.B Notice how I am just using add instead of addPost or addGet. That is because if we add the method, and that method is never matched, then we default to the non matched route, not a HTTP compliant response.

$router->add('/api/0.0.1/ping', array(
    'namespace' => 'Api\\Controllers',
    'module' => 'api',
    'controller' => 'ping',
    'action' => 'ping',
    'params' => 1,
));

We are now almost there. Time to add a controller that has some annotations to it. These are checked before the action is executed.

namespace Api\Controllers;
/**
 * Class PingController
 *
 * Simple testing controller to ensure client is setup correctly to access API service.
 *
 * @package Api\Controllers
 */
class PingController extends ControllerBase
{
    /**
     * This is a simple ping method, that returns an ok response if the client is authtenticated.
     *
     * @HTTPMethods(POST, GET)
     *
     */
    public function pingAction()
    {
        $this->response->setJsonContent(array("success" => true));
        $this->response->send();
    }
}

If we call this controller now using our restful test client we should be getting a standard response in json

{"success":true}

But if we call it with a HTTP DELETE method, then we actually get correctly denied access and a nice HTTP 405 response back.

HTTP/1.1 405 Method Not Allowed
Date: Tue, 15 Jul 2014 18:47:20 GMT
Server: Apache/2.4.7 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache
Status: 405 Method Not Allowed
Allow: POST,GET
Content-Length: 0
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json; charset=UTF-8

If anyone has better ideas as to how to solve this issue, I'd be keen to hear of them.



15.2k

OPTIONS, I forgot OPTIONS... yes you could in theory provide a little help to your API buddies by including OPTIONS that automagically tell the REST client what it should do.

If you wanted to do that, then the plugin class would become

class RestfulMethods extends Plugin
{
    /**
     * Check the requested method against an annotation on the action (if any) and fail it if its not matching.
     *
     * @param Event $event
     * @param Dispatcher $dispatcher
     * @return bool
     */
    public function beforeExecuteRoute(Event $event, Dispatcher $dispatcher)
    {
        $annotations = $this->annotations->getMethod($dispatcher->getActiveController(), $dispatcher->getActiveMethod());
        if ($annotations->has('HTTPMethods')) {
            $annotation = $annotations->get('HTTPMethods');
            if ($this->request->getMethod() == 'OPTIONS') {
                $this->response->setHeader('Allow', implode(',', $annotation->getArguments()));
                $this->response->setJsonContent(array('options' => $annotation->getArguments()));
                $this->response->send();
                // End the request as all we wanted was to send the options.
                return false;
            } elseif (!in_array($this->request->getMethod(), array_change_key_case($annotation->getArguments(), CASE_UPPER))) {
                $this->response->setStatusCode(405, 'Method Not Allowed');
                $this->response->setHeader('Allow', implode(',', $annotation->getArguments()));
                $this->response->send();
                return false;
            }
        }
        return true;
    }
}