Description
Just calling JsonApiRoute::server()
to register the routes produces a lot of side effects as it immediately resolves the server instance which resolves recursively all of the server dependencies which especially impacts the testing.
My use-case is that I have a client courses resource for which the index action needs to be automatically scoped so that the client can see only the courses that it has access to. The client in this case is the OAuth2 client which authenticates to the app (I'm using Passport for that). So in order to do this I'm adding a scope in the serving
method of the server:
public function serving(): void
{
ClientCourse::addGlobalScope($this->oAuthClientScope);
}
And the scope code looks like this:
<?php
declare(strict_types=1);
namespace App\JsonApi\V1\ClientCourses;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Laravel\Passport\Client as PassportClient;
use Laravel\Passport\Guards\TokenGuard;
use LogicException;
final class OAuthClientScope implements Scope
{
private $request;
private TokenGuard $tokenGuard;
private Repository $config;
public function __construct(
callable $request,
TokenGuard $tokenGuard,
Repository $config
) {
$this->request = $request;
$this->tokenGuard = $tokenGuard;
$this->config = $config;
}
public function apply(Builder $builder, Model $model)
{
/** @var PassportClient|null $client */
$client = $this->tokenGuard->client(call_user_func($this->request));
if (null === $client) {
throw new LogicException('This should not have happened.');
}
// there is actually some more code here that maps the OAuth client model (oauth_clients table)
// to the application client model (clients table), but it's not relevant for the issue here
$builder->where('client_id', '=', $client->getKey());
}
}
The scope instance (which gets injected into the server) has a constructor dependency on the HTTP request and on the Passport Token Guard which can tell me what OAuth client is authenticated based on the access token that is sent in the request (https://github.com/laravel/passport/blob/v10.1.2/src/Guards/TokenGuard.php#L122).
The problem here is that since the server gets resolved when the routes are registered it means that at that point in time the scope instance that is injected in the server has an empty HTTP request and for some reason the server instance is cached in the server repository (https://github.com/laravel-json-api/core/blob/v1.0.0-alpha.4/src/Core/Server/ServerRepository.php#L70-L72). That means I don't get a new server instance when I do the request in my feature test (which means that the request in the scope is empty as it was resolved before the actual request in the test was made). I was able to work around this by making the request a callable (as you can see in my scope code above) instead of directly typehinting the request class. That helped a bit as the request is now resolved only when it's actually needed instead of being resolved along with the server (and I don't consider this as a proper solution, it's more of a temporary hack), but now the problem is that the token guard depends on the OAuth2 Resource server which is supposed to be mocked by Passport in my test :
$oauthClient = PassportClientFactory::new()->create();
Passport::actingAsClient($oauthClient);
The actingAsClient
essentially does app()->instance(ResourceServer::class, $mock);
(https://github.com/laravel/passport/blob/v10.1.2/src/Passport.php#L395), but since the resource server was also resolved before the mocked instance was set into the container in my test I have the same problem that I get the actual non mocked resource server injected into the token guard so my test explodes as the non mocked server does not return the expected client back.
Removing the caching of the server in the server repository helps to solve this specific problem (as a new server instance gets created once my feature test does the request), but it still leaves the underlying problem unresolved and that is that the server still gets resolved on route registration and all recursive dependencies with it.
This is a problem when for example typehinting \Illuminate\Contracts\Auth\Guard
in the constructor of the server and I get the default guard every time there (which is the web
guard) instead of the api
guard which I wanted (because the auth:api
middleware that is supposed to set the guard has not been run at that point in time yet). The workaround for this is to explicitly use contextual binding for the server:
use Illuminate\Contracts\Auth\Factory as AuthFactory;
$this->app->when(Server::class)
->needs(Guard::class)
->give(static function (Container $container): Guard {
/** @var AuthFactory $authFactory */
$authFactory = $container->make(AuthFactory::class);
return $authFactory->guard('api');
});
This works, but it only works under the assumption that the only guard that will ever be able to be used with this server is the api
guard which is not a valid assumption for more complex projects where there can be multiple guards that could be used for the same API routes.
So removing the caching layer in the server repository only solves part of the problem. The other part would be making the package work in a way that that the server instance is not instantiated just for route registration. This could be solved by configuring the stuff that is needed for route registration via the config (per each server) or by making the needed data for route registration available on the server via static method(s) so that a server instance is not needed and so that the server gets resolved only when the \LaravelJsonApi\Laravel\Http\Middleware\BootJsonApi
middleware runs (which can be configured in the HTTP kernel to only run after the Laravel auth
middleware).