修改 Request headers 不完全攻略

如果某个接口即支持 form 请求又支持 api 请求,或者对于一些公共的处理程序(比如认证失败,输入校验失败等),我们往往需要根据请求类型来返回对应的 response 。

例如 Laravel 5.3 的 ValidatesRequests 中:

protected function buildFailedValidationResponse(Request $request, array $errors)
{
    if ($request->expectsJson()) {
        return new JsonResponse($errors, 422);
    }

    return redirect()->to($this->getRedirectUrl())
                    ->withInput($request->input())
                    ->withErrors($errors, $this->errorBag());
}

Request::expectsJson 实现如下:

public function expectsJson()
{
    return ($this->ajax() && ! $this->pjax()) || $this->wantsJson();
}

Request::wantsJson 是通过 request headers 中的 Accept 来判断是否需要返回 JSON 响应。一般情况下,Api 客户端会在发送 HTTP 请求时设置头部 Accept: application/json


那么问题来了,如果 api client 没有传 Accept 头部信息或者里面没有包含 json 字样, $request->wantsJson() 就会返回 false ,从而导致给 api client 返回了不正确的响应。

解决方案一

可以在 expectsJson 后面再跟一个判断,比如:

protected function unauthenticated($request, AuthenticationException $exception)
{
    if ($request->expectsJson() || $request->is('api/*')) {
        return response()->json(['error' => 'Unauthenticated.'], 401);
    }

    return redirect()->guest('login');
}

Request::expectsJson 是 Laravel 5.3 新增的方法,使用 5.3 之前版本的话,可能需要这样写:

if (
    ($request->ajax() && ! $request->pjax()) || 
    $request->wantsJson() ||
    $request->is('api/*')
) {
    //
}

或者把这一坨放到一个 helper 函数里方便调用:

function is_api_request()
{
    $request = app('request');

    return $request->...
}

需要注意的是 Laravel Framework 内部也使用了这个判断,例如本文开头提到的校验失败响应,所以要把相关的地方都重写下。并且每次升级 Framework 版本时还要检查一遍是不是内部又有别的地方使用了此类判断,或者之前重写的方法有变动。

显然,这个方案相当笨拙、不符合 Laravel 的优雅哲学。

解决方案二

既然服务端要兼容还挺麻烦,那么就强制所有的 api client 添加 Accept: application/json 请求头,违者后果自负。

这下整片雾霾都散了...

等等,老大刚说这个问题还是得服务端解决下。因为 iOS 客户端现在有几处 bug 就是这个问题导致的,而上个 iOS 版本被苹果审核了 3 个月,更何况 PHP 是世界上最好的语言,所以...

解决方案三

既然 Request::wantsJson 是通过头部信息判断的,那么我们能不能直接修改头部的 Accept 字段值添加 json 类型?答案是必须能。

  • Where
    借助 Laravel 强大的 IoC 技术, 我们可以注册 request 的绑定回调,比如 app()->rebinding('request', ...).
  • How
    如果 $request->header('Accept') 不包含 json 类型,我们就把 application/json 添加上去,并通过 $request->headers->set() 修改 header 值。
  • When
    越早越好。推荐放在某个非 deferred 的 service provider 的 register 方法里。
  • :warning: Note
    判断原始值是否包含 json 类型时不能用 expectsJson, wantsJson, accepts, acceptsJson 等方法。因为 RequestAccept 值是懒加载并缓存的,所以调用过 wantsJson 后再通过 $request->headers->set('Accept', ...) 修改的值会不起作用。
  • And
    顺便把 $_SERVER 相关值也修改下,万一有人用了呢。
  • Show me the code

    class AppServiceProvider extends ServiceProvider
    {
        /**
         * Register any application services.
         */
        public function register()
        {
            $this->addAcceptableJsonType();
        }
    
        /**
         * Add "application/json" to the "Accept" header for the current request.
         */
        protected function addAcceptableJsonType()
        {
            $this->app->rebinding('request', function ($app, $request) {
                if ($request->is('api/*')) {
                    $accept = $request->header('Accept');
    
                    if (! str_contains($accept, ['/json', '+json'])) {
                        $accept = rtrim('application/json,'.$accept, ',');
    
                        $request->headers->set('Accept', $accept);
                        $request->server->set('HTTP_ACCEPT', $accept);
                        $_SERVER['HTTP_ACCEPT'] = $accept;
                    }
                }
            });
        }
    }

解决方案四

感谢 @Ryan@oustn 的提醒,也可以把上述代码放到一个 middleware 里,然后给相关的请求添加这个 middleware.

至于用 middleware 还是用 service provider ,对于解决这个问题没多大区别,看个人喜好。详细讨论参见下面的评论。

:point_right: Laravel 官网镜像 :cn:
本帖已被设为精华帖!
本帖由 Summer 于 7年前 加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 7
Ryan

通过路由middleware来对Request headers修改判断等操作也是一种优雅的方式

7年前 评论

感觉略显麻烦啊 不如直接加个中间件吧,这个中间件不干啥事,就是给请求加一个请求头,然后所有的 api 路由都运用这个中间件,是不是会简单点

7年前 评论

@Ryan @oustn 嗯,放在 middleware 里也可以。

推荐放到 service provider 的 register 方法里是考虑到 request 有很多方法会导致 accept 值被缓存,比如第三方扩展包里的 service provider,一旦 request 被读取过 accept 值,后面再修改 header 值都是无效的。

7年前 评论

@ElfSundae 看了下源码,大概懂你的意思了。acceptswantsJsonacceptsJson 这三个方法确实是通过获取 $this->acceptableContentTypes 来判断,而这个值是会在第一次读取 header 之后就被缓存的,后面修改 header 不起作用。但是 expectsJsonajax 这俩是不会的,这俩是通过 return 'XMLHttpRequest' == $this->headers->get('X-Requested-With'); 来判断,所以 accept 缓存对这个没有影响,可以通过设置 header "X-Requested-With:XMLHttpRequest"来达到这个目的。

另外如果像你说的放在 Service Provider 的 register 方法里面,我觉得可能有点不太符合 Laravel 的思想,文档里面强调了不要在注册中做除了绑定的其他任何操作,虽然你的处理肯定不会有问题,正常来讲应该放在 boot 方法中,但是放在 boot 方法中就无法保证 $this->acceptableContentTypes 没有被缓存过。

并且你这种办法只有放在 App\Providers\AppServiceProvider 里面是最最保险的,并且在 register 方法里面要放在第一位,这样才能保证没有其他的 Service Provider 可能会缓存 $acceptableContentTypes 因为 ServiceProvider 也是有启动顺序的。

探讨一下,没有其他意思 :)

7年前 评论

@oustn 感谢回复。我发文也是希望有人讨论,大家互相学习 ;)

ajax 方法确实不会缓存 header 值,ajax client 也都会正确设置 X-Requested-With 头,不需要我们设置 "X-Requested-With: XMLHttpRequest"

但是 wantsJson 会缓存 Accept 头,而 expectsJson 会调用到 wantsJson 方法。本文修改请求头就是为了替 HTTP client (非 ajax) 添加 application/json 的 Accept 头。有很多 api 设计的是请求时用 form urlencode 类型但是响应是 json 或 xml 类型,这样做是为了方便服务端和客服端解析数据。而大多数 HTTP client 库默认情况下不会对 form 请求设置 accept 值,从而导致了 Laravel 中的 expectsJson 返回 false

你提到的针对 api 接口设置 X-Requested-With: XMLHttpRequest 也是一种解决办法,相当于是把所有 api client 都看做是 ajax client.

放在 service provider 的 register 里是符合 Laravel 思想的,因为我们是通过 $this->app->rebinding 调用的是 Container 的绑定类方法,绑定了一个 "request" 的绑定回调 callback ,并没有直接操作 Request 实例,所以这属于一种 register 行为而不是 boot 。Laravel Framework 也是用这种方法,比如给 Request 绑定 userResolver , 给UrlGenerator 绑定 routes等。
不过你倒是提醒了我,判断 $request->is('api') 应该放到 rebinding 的 callback 里。:tada: 稍候我更新下文中代码。

就目前我使用过的扩展包来看,还没有哪个在 register 方法里调用 request 的 wantsJson 等会导致缓存 $acceptableContentType 的相关方法,所以放到 service provider 和 middleware 效果是一样的,看个人喜好了。service provider 独立性更强一点而已。

7年前 评论

文章已更新。

7年前 评论

@ElfSundae 研究的很透啊,赞一个。

我那上面可能有两点没说清楚,补充一下哈

  1. expectsJson 里面判断用的是或,在判断是 ajax 后就不会去判断 wantsJson;
    public function expectsJson()
    {
        return ($this->ajax() && ! $this->pjax()) || $this->wantsJson();
    }
  1. 我说的 设置 header "X-Requested-With:XMLHttpRequest" 不是站在前端(客户端)的角度说,我知道有些 HTTP client 默认不会设置这个头 ,我指的是可以写个针对 api 路由的中间件,做个判断,没有这个头的请求就自动添加这个头,然后所有的 api 路由都使用这个中间件,这样所有的 api 请求不管 HTTP client 有没有设置 X-Requested-With, 逻辑处理的时候 api 请求就都有 X-Requested-With 这个头了 ,再用 ajax() 或 expectsJson() 判断~~~

你的方法当然也是很好的实践,涨姿势了,后面碰到这种情况有一个额外的选择。:100:

7年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!