yiisoft / yii2

Yii 2: The Fast, Secure and Professional PHP Framework
http://www.yiiframework.com
BSD 3-Clause "New" or "Revised" License
14.23k stars 6.91k forks source link

Yii2 API Rest not returning JSON when the method is not allowed[405] #13774

Open alvarofvr opened 7 years ago

alvarofvr commented 7 years ago

What steps will reproduce the problem?

I built a structure for API Rest in Yii2 (Version 2.0.10). I have this situation:

Class Even with relative modelClass:

namespace app\api\modules\v1\controllers;
class EvenController extends OddController
{
    public $modelClass = 'app\api\modules\v1\models\Even';
}

Class Odd that extend \yii\rest\ActiveController:

namespace app\api\modules\v1\controllers;
class OddController extends ActiveController
{
public function behaviors()
    {
        $behaviors = parent::behaviors();
        $behaviors['verbFilter'] = [
            'class' => VerbFilter::className(),
            'actions' => $this->verbs(),
        ];

        return $behaviors;
    }

    public function actions()
    {
        $actions = parent::actions();
        $actions['foo'] = [
            'class' => 'app\api\modules\v1\actions\FooAction',
            'modelClass' => $this->modelClass,
            'checkAccess' => [$this, 'checkAccess'],
        ];

        return $actions;
    }

    public function verbs()
    {
        $verbs = parent::verbs();
        $verbs['foo'] = ['GET', 'HEAD'];

        return $verbs;
    }
}

Class FooAction that extend \yii\rest\Action:

namespace app\api\modules\v1\actions;
class FooAction extends Action
{
    public function run()
    {
        if ($this->checkAccess) {
            call_user_func($this->checkAccess, $this->id);
        }

        return ['mydatareturned']);
    }
}

Component UrlManager in config:

'urlManager' => [
            'showScriptName' => false,
            'enablePrettyUrl' => true,
            'enableStrictParsing' => true,
            'rules' => [
                [
                    'class' => 'yii\rest\UrlRule',
                    'controller' => [
                        'v1/even',
                    ],
                    'pluralize' => false,
                    'tokens' => [
                        '{id}' => '<id:\\w+>',
                    ],
                    'except' => ['delete', 'create', 'update'],
                    'extraPatterns' => [
                        'foo' => 'foo'
                    ]
                ],
            ],
        ],

What is the expected result?

I use POSTMAN, when i call my API Rest named "api.localhost.dev" the result is this, look few example:

  1. Request URL: api.localhost.dev/even
    Method: GET
    Status: 200
    Response:  JSON with data table of model Even
  2. Request URL: api.localhost.dev/even/foo
    Method: GET
    Status: 200
    Response:  JSON with my data returned by "run"
  3. Request URL: api.localhost.dev/even
    Method: POST
    Status: 405
    Response:  EMPTY!!!!
  4. Request URL: api.localhost.dev/even/foo
    Method: POST
    Status: 405
    Response:  JSON with information error 405 catched

What do you get instead?

Why using default actions implemented in yii\rest the response is EMPTY? If i want use APIs in a client frontend app with a call in javascript it would be useful to have the response JSON over only status. Now I debug through yii class and i found in class yii\filters\VerbFilter this method:

    public function beforeAction($event)
    {
        $action = $event->action->id;
        // $action is always =='options' when use default rest action, why??
        if (isset($this->actions[$action])) {
            $verbs = $this->actions[$action];
        } elseif (isset($this->actions['*'])) {
            $verbs = $this->actions['*'];
        } else {
            return $event->isValid;
        }

        $verb = Yii::$app->getRequest()->getMethod();
        $allowed = array_map('strtoupper', $verbs);
        if (!in_array($verb, $allowed)) {
            $event->isValid = false;
            // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.7
            Yii::$app->getResponse()->getHeaders()->set('Allow', implode(', ', $allowed));
            throw new MethodNotAllowedHttpException('Method Not Allowed. This url can only handle the following request methods: ' . implode(', ', $allowed) . '.');
        }

        return $event->isValid;
    }

If i debug action id in case of a call to default implemented action is always "options", instead in case of a call to my custom action the action id is correct(name of my action), so the MethodNotAllowedHttpException not throwed.

Additional info

I think is becouse the default url settings provide by UrlManager is this:

    'PUT,PATCH even/<id>' => 'even/update',
    'DELETE even/<id>' => 'even/delete',
    'GET,HEAD even/<id>' => 'even/view',
    'POST even' => 'even/create',
    'GET,HEAD even' => 'even/index',
    'even/<id>' => 'even/options',
    'even' => 'even/options',

But, my question is: Are there no methods to avoid this? Thank you for your help. Alberto

Q A
Yii version 2.0.10
PHP version 5.5.38
Operating system Linux CentOS7
alvarofvr commented 7 years ago

For fix now? Can you help me @samdark?

samdark commented 7 years ago

First of all, try updating to 2.0.11.2 then, if it doesn't work try code from master branch. Then report back.

alvarofvr commented 7 years ago

Grazie, thank you! Tomorrow i try it...

alvarofvr commented 7 years ago

I update to 2.0.11.2 version but doesn't work.

samdark commented 7 years ago

Seems you need to adjust your URL manager config a bit:

[
        'class' => 'yii\rest\UrlRule',
        'controller' => ['1.0/even' => 'api1/even'],
        'only' => ['index', 'view'], // THIS
        'prefix' => 'api',
    ],
alvarofvr commented 7 years ago

Sorry @samdark but that does not work.

samdark commented 7 years ago

How exactly it does not work?

alvarofvr commented 7 years ago

Look at (3.) in "What is the expected result?" paragraph:

  1. Request URL: api.localhost.dev/even
    Method: POST
    Status: 405
    Response:  EMPTY!!!!

    Now the response code is 404, instead 405 (and the response continues to be empty).

samdark commented 7 years ago

Well, 404 is expected. Empty is not. Are you in debug or in production mode? Have you customized error handler?

alvarofvr commented 7 years ago
samdark commented 7 years ago

Response is not empty, my wrong.

What it is then?

alvarofvr commented 7 years ago

Default html code of 404 error.

samdark commented 7 years ago

Then it's all fine, isn't it? You've limited actions to 'index', 'view' and getting 404 for an action which is not in the list.

samdark commented 7 years ago

Or... maybe I don't understand what the issue is all about...

alvarofvr commented 7 years ago

If i limited actions to 'only' => ['index', 'view'] (or 'except' => ['delete', 'create', 'update']) . I expect that the response of this request: curl -i -H "Accept:application/json" -XPOST "http://api.localhost.dev/api/v1/even" is an 405 MethodNotAllowedException with JSON response not empty against a 404 NotFoundException. Look (3.) and (4.) of paragraph named "What is the expected result?". The issue is visible in the response of (3.) and (4.), but I try to explain again. If i call a default action implemented in Yii with not allowed method the response code is correct(405) but the json response is empty. In case of i call a custom action the returned code is correct(405) and json response work great, is not empty. Why this difference? I think is an issue.

samdark commented 7 years ago

Ah... if you need method no allowed then you should not modify URL rules but instead use VerbFilter in the controller.

samdark commented 7 years ago

Need to check about JSON response...

alvarofvr commented 7 years ago
//i ovveride this for specify my custom action's allowed methods
public function verbs()
{
        $verbs = parent::verbs();
        $verbs['foo'] = ['GET', 'HEAD'];

        return $verbs;
}

You mean I have to override this completely and specify 'index', 'view' allowed methods?

samdark commented 7 years ago

Yes.

alvarofvr commented 7 years ago

Ok, I tried with this configuration of verbs filter:

public function verbs()
{
        $verbs = [
            'foo' => ['GET', 'HEAD'],
            'index' => ['GET', 'HEAD'],
            'view' => ['GET', 'HEAD']
        ];

        return $verbs;
}

Nothing change. And now I noticed that if i use 'only' => ['index', 'view'] and try to make a DELETE the code returned is a 404 and not go inside beforeAction($event) of yii\filters\VerbFilter . But if i use 'except' => ['delete', 'create', 'update'] go inside beforeAction($event) of yii\filters\VerbFilter and $event->action->id is 'options'.

alvarofvr commented 7 years ago

I carried out the controls through the Yii classes. The problem is due to a lack in the method "parseRequest" of the class web\UrlRule. This method attempts to find a match between the request and the url rules. The problem is that the method does not retain the "route" of the request but overwrite with another. This is because the control of the verb returns "false" in parseRequest. Look at comments. Class web\UrlRule:

public function parseRequest($manager, $request)
    {
        if ($this->mode === self::CREATION_ONLY) {
            return false;
        }

        if (!empty($this->verb) && !in_array($request->getMethod(), $this->verb, true)) {
            // there is a problem, when try to parse an a not allowed method this return false,
            // this method match 'options' action (not correct) and parse with it and use the options route insted original route.
            return false;
        }

        $suffix = (string)($this->suffix === null ? $manager->suffix : $this->suffix);
        $pathInfo = $request->getPathInfo();
        $normalized = false;
        if ($this->hasNormalizer($manager)) {
            $pathInfo = $this->getNormalizer($manager)->normalizePathInfo($pathInfo, $suffix, $normalized);
        }
        if ($suffix !== '' && $pathInfo !== '') {
            $n = strlen($suffix);
            if (substr_compare($pathInfo, $suffix, -$n, $n) === 0) {
                $pathInfo = substr($pathInfo, 0, -$n);
                if ($pathInfo === '') {
                    // suffix alone is not allowed
                    return false;
                }
            } else {
                return false;
            }
        }

        if ($this->host !== null) {
            $pathInfo = strtolower($request->getHostInfo()) . ($pathInfo === '' ? '' : '/' . $pathInfo);
        }

        if (!preg_match($this->pattern, $pathInfo, $matches)) {
            return false;
        }
        $matches = $this->substitutePlaceholderNames($matches);

        foreach ($this->defaults as $name => $value) {
            if (!isset($matches[$name]) || $matches[$name] === '') {
                $matches[$name] = $value;
            }
        }
        $params = $this->defaults;
        $tr = [];
        foreach ($matches as $name => $value) {
            if (isset($this->_routeParams[$name])) {
                $tr[$this->_routeParams[$name]] = $value;
                unset($params[$name]);
            } elseif (isset($this->_paramRules[$name])) {
                $params[$name] = $value;
            }
        }
        if ($this->_routeRule !== null) {
            $route = strtr($this->route, $tr);
        } else {
            $route = $this->route;
        }

        Yii::trace("Request parsed with URL rule: {$this->name}", __METHOD__);

        if ($normalized) {
            // pathInfo was changed by normalizer - we need also normalize route
            return $this->getNormalizer($manager)->normalizeRoute([$route, $params]);
        } else {
            return [$route, $params];
        }
    }

Consequently it is explained why the $action->id is always 'options': Class yii\filters\VerbFilter:

    public function beforeAction($event)
    {
        $action = $event->action->id;
        // $action->id is always 'options' when use default rest action, becouse default implemented action /          
        //with not allowed method return only 'options', 'options' not has verb 
        //filter and not throw the exception.
        if (isset($this->actions[$action])) {
            $verbs = $this->actions[$action];
        } elseif (isset($this->actions['*'])) {
            $verbs = $this->actions['*'];
        } else {
            return $event->isValid;
        }

        $verb = Yii::$app->getRequest()->getMethod();
        $allowed = array_map('strtoupper', $verbs);
        if (!in_array($verb, $allowed)) {
            $event->isValid = false;
            // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.7
            Yii::$app->getResponse()->getHeaders()->set('Allow', implode(', ', $allowed));
            throw new MethodNotAllowedHttpException('Method Not Allowed. This url can only handle the following request methods: ' . implode(', ', $allowed) . '.');
        }

        return $event->isValid;
    }
samdark commented 7 years ago

It's scheduled to after 2.0.12 release. Will dig into it then.

alvarofvr commented 7 years ago

ok thank you

Faryshta commented 7 years ago

where did you configured your errorAction? mostlikely the exception is been thrown before the error action is configured. i solved it on a library like this

https://github.com/tecnocen-com/yii2-roa/blob/master/src/modules/ApiContainer.php#L60

maybe we can make a module behavior to do this

sudhir600 commented 6 years ago

@alvarofvr I tried your idea protected function verbs() { and its working but how to set this as a global. so that i don't have to write in every controller file.