diff --git a/docs/book/v7/tutorials/find-user-by-identity.md b/docs/book/v7/tutorials/find-user-by-identity.md index 6bc1872..ac6d570 100644 --- a/docs/book/v7/tutorials/find-user-by-identity.md +++ b/docs/book/v7/tutorials/find-user-by-identity.md @@ -3,7 +3,7 @@ ## Summary A worked example of adding an endpoint by following an existing one. -Starting from `user.view`, which fetches a user by UUID, it builds an `IdentityHandler` that looks a user up by identity, registers it in the module's `ConfigProvider` and `RoutesDelegator`, grants the route a permission, and covers it with functional tests. +Starting from `user::view-user`, which fetches a user by UUID, it builds a `GetUserByIdentityResourceHandler` that looks a user up by its identity column, registers it in the module's `ConfigProvider` and `RoutesDelegator`, grants the route a permission, and covers it with functional tests. ## Our goal @@ -13,157 +13,178 @@ We already have an endpoint that retrieves a user based on their UUID, so we can ## What we have -Let's print out all available endpoints using : +Let's print out all available endpoints: ```shell php ./bin/cli.php route:list ``` -This command will list all available endpoints, which looks like this: +This command lists every endpoint. +The rows we care about are the three under `/user/{id}`: ```text -+--------+---------------------------------+--------------------------------+ -| Method | Name | Path | -+--------+---------------------------------+--------------------------------+ -| POST | account.activate.request | /account/activate | -| PATCH | account.activate | /account/activate/{hash} | -| PATCH | account.modify-password | /account/reset-password/{hash} | -............................................................................. -............................................................................. -............................................................................. -| GET | user.my-avatar.view | /user/my-avatar | -| GET | user.role.list | /user/role | -| GET | user.role.view | /user/role/{id} | -| PATCH | user.update | /user/{id} | -| GET | user.view | /user/{id} | -+--------+---------------------------------+--------------------------------+ ++------+----------------+-------------------------------------+-------------------------------------+ +| # | Request method | Route name | Route path | ++------+----------------+-------------------------------------+-------------------------------------+ +| 31 | DELETE | user::delete-user | /user/{id} | +| 32 | GET | user::view-user | /user/{id} | +| 33 | PATCH | user::update-user | /user/{id} | ++------+----------------+-------------------------------------+-------------------------------------+ ``` ### Note -> **The above output is just an example.** +> **The above output is an excerpt.** > -> More info about listing available endpoints can be found in `../commands/display-available-endpoints.md`. +> More info about listing available endpoints can be found in [Displaying Dotkernel API endpoints](../commands/display-available-endpoints.md). -The endpoint we're focusing on is the last one, `user.view`, so let's take a closer look at its functionality. +The endpoint we're focusing on is `user::view-user`, so let's take a closer look at its functionality. -If we search for the route name `user.view` we will find its definition in the `src/User/src/RoutesDelegator.php` class, where all user-related endpoints are found. +If we search for the route name `user::view-user` we will find its definition in `src/User/src/RoutesDelegator.php`, where all user-related endpoints are declared: ```php -$app->get('/user/' . $id, UserHandler::class, 'user.view'); +$routeCollector->group('/user/' . $id) + ->delete('', DeleteUserResourceHandler::class, 'user::delete-user') + ->get('', GetUserResourceHandler::class, 'user::view-user') + ->patch('', PatchUserResourceHandler::class, 'user::update-user'); ``` -Our route points to `get` method from `UserHandler` so let's navigate to that method. +`$id` is `Core\App\ConfigProvider::REGEXP_UUID`, a placeholder constrained to the UUID format, so `/user/{id}` only matches a segment that is actually a UUID. + +Our route points to `GetUserResourceHandler`, so let's navigate to it. ```php -public function get(ServerRequestInterface $request): ResponseInterface +class GetUserResourceHandler extends AbstractHandler { - $user = $this->userService->findOneBy(['id' => $request->getAttribute('id')]); - - return $this->createResponse($request, $user); + #[Resource(entity: User::class)] + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->createResponse( + $request, + $request->getAttribute(User::class) + ); + } } ``` -As we can see, the method will query the database for the user based on its id taken from the endpoint. +The handler never queries the database. +`Api\App\Middleware\ResourceProviderMiddleware`, piped just before `DispatchMiddleware`, reads the `#[Resource]` attribute off the `handle()` method and does the lookup for it. +Three arguments of that attribute matter here: + +| Argument | Default | Meaning | +| --- | --- | --- | +| `entity` | — | The entity class to load | +| `identifier` | `'id'` | The entity property to match on | +| `placeholder` | `'id'` | The route parameter holding the value | + +With the defaults, the middleware calls `find()` on the `User` repository with the `id` route parameter. +It then places the entity on the request under the entity's class name, which is why the handler reads `$request->getAttribute(User::class)`. + +The middleware also handles the failure cases, so no handler has to: + +- No matching record throws `NotFoundException::create(Message::resourceNotFound('User'))`. +- A record whose `isDeleted()` returns `true` throws the same exception, so soft-deleted users are indistinguishable from absent ones. +- A `guard` argument, if given, decides whether the caller is allowed to see the entity at all. We now have an understanding of how things work, and we can start to implement our own endpoint. -### Implementation +## Implementation -We need to create a new handler that will process our request, we can call it `IdentityHandler`. +### Step 1: Create the handler -Create a new PHP class called `IdentityHandler.php` in `src/User/src/Handler` folder. +Create a new PHP class called `GetUserByIdentityResourceHandler.php` in the `src/User/src/Handler/User` folder. ```php createResponse( + $request, + $request->getAttribute(User::class) + ); } +} +``` - /** - * @throws NotFoundException - * @throws BadRequestException - */ - public function get(ServerRequestInterface $request): ResponseInterface - { - $identity = $request->getAttribute('identity'); - if (empty($identity)) { - throw (new BadRequestException())->setMessages([sprintf(Message::INVALID_VALUE, 'identity')]); - } +The only difference from the existing handler is the attribute. +Because `identifier` is neither `id` nor `uuid`, the middleware switches from `find()` to `findOneBy(['identity' => ...])`. +`identity` is declared `unique: true` on the `User` entity, so that lookup can only ever return one row. + +### Note - $user = $this->userService->findByIdentity($identity); - if (! $user instanceof User) { - throw new NotFoundException(Message::USER_NOT_FOUND); - } +> The handler needs no constructor and no `#[Inject]` attribute. +> `HalResponseFactory` and `ResourceGenerator`, which `createResponse()` needs, are injected by `HandlerDelegatorFactory` in the next step, not through the constructor. - return $this->createResponse($request, $user); - } -} +### Step 2: Register the handler + +Go to `src/User/src/ConfigProvider.php` and add the handler in **two** places inside `getDependencies()`. + +Under the `factories` key, so the container can build it: + +```php +GetUserByIdentityResourceHandler::class => AttributedServiceFactory::class, ``` -Our handler is very similar to the existing one, with some extra steps: +Under the `delegators` key, so it receives its response dependencies: + +```php +GetUserByIdentityResourceHandler::class => [HandlerDelegatorFactory::class], +``` -* We store the identity from the request in the `$identity` variable for later use. -* If the identity is empty we throw a `BadRequestException` with an appropriate message. -* If we can't find the user in the database, we throw an `NotFoundException`. -* If the record is found, we generate and return the response. +Both entries are required. +Every handler extending `AbstractHandler` starts with `$responseFactory` and `$resourceGenerator` set to `null`, and `HandlerDelegatorFactory` is what calls `setResponseFactory()` and `setResourceGenerator()` on it. +Register only the factory and the handler still resolves, but `$responseFactory` is never set and the first call to `createResponse()` fails. -The next step is to register the new handler. -To do this, go to `src/User/src/ConfigProvider.php`. -In the `getDependencies()` method under the `factories` key add `IdentityHandler::class => AttributedServiceFactory::class,` +### Step 3: Create the route -Next, create the route in `src/User/src/RoutesDelegator.php`: +Next, declare the route in `src/User/src/RoutesDelegator.php`: ```php - $app->get( - '/user/{identity}', - IdentityHandler::class, - 'user.view.identity' - ); +$routeCollector->get('/user/{identity}', GetUserByIdentityResourceHandler::class, 'user::view-user-by-identity'); ``` ### Note -> Make sure to register the endpoint as the last one to not shadow existing endpoints. +> Declare this route after the `'/user/' . $id` group. +> +> Static paths such as `/user/role` and `/user/account` are matched before any variable route, so they are never at risk. +> `/user/{id}` is, because its UUID constraint narrows what it accepts while `{identity}` accepts anything, and a UUID-shaped value matches both. +> The route declared first wins, so declaring ours last keeps UUIDs going to `user::view-user`. + +### Step 4: Grant the route a permission + +Go to `config/autoload/authorization.global.php` and add the route name under the `UserRoleEnum::Guest->value` key of the `permissions` array: + +```php +UserRoleEnum::Guest->value => [ + // ... + 'user::view-user-by-identity', +], +``` + +Guest is the parent of User in the `roles` map, so granting it there lets everyone reach the endpoint, authenticated or not (for the sake of simplicity). +A request that carries no access token is given the guest identity by `AuthenticationMiddleware`, and `AuthorizationMiddleware` then checks the route name against that role's permissions. -The last step is to set permissions on the newly created route. +### Note -Go to `config/autoload/authorization.global.php` and add our route name (`user.view.identity`) under the `UserRole::ROLE_GUEST` key. -This will give access to every user, including guests, to view other accounts (for the sake of simplicity). +> The response body is unaffected by which route served it. +> `MetadataMap` maps the `User` entity to `user::view-user`, so the HAL `_links.self` of a user fetched through `/user/{identity}` still points at `/user/{uuid}`. -### Writing tests +## Writing tests Because every new piece of code should be tested, we will write some tests for this endpoint also. @@ -172,9 +193,13 @@ In the `test/Functional` folder create a new php class `IdentityTest.php`: ```php get('/user/invalid_identity'); - $messages = json_decode($response->getBody()->getContents(), true); $this->assertResponseNotFound($response); - $this->assertNotEmpty($messages); - $this->assertIsArray($messages); - $this->assertNotEmpty($messages['error']['messages'][0]); - $this->assertIsString($messages['error']['messages'][0]); - $this->assertSame(Message::USER_NOT_FOUND, $messages['error']['messages'][0]); + + $data = json_decode($response->getBody()->getContents(), true); + + $this->assertIsArray($data); + $this->assertArrayHasKey('detail', $data); + $this->assertSame(Message::resourceNotFound('User'), $data['detail']); + $this->assertSame(404, $data['status']); } public function testValidIdentityReturnsUser(): void @@ -207,6 +233,7 @@ class IdentityTest extends AbstractFunctionalTest $response = $this->get('/user/valid_user'); $this->assertResponseOk($response); + $user = json_decode($response->getBody()->getContents(), true); $this->assertSame('valid_user', $user['identity']); @@ -214,6 +241,22 @@ class IdentityTest extends AbstractFunctionalTest } ``` +The two failure cases return the same status for different reasons. +`/user/` matches no route at all, because `{identity}` requires at least one character, so `ProblemDetailsNotFoundHandler` answers at the end of the pipeline. +`/user/invalid_identity` matches the route, reaches `ResourceProviderMiddleware`, finds no row and throws `NotFoundException`, which `ProblemDetailsMiddleware` renders: + +```json +{ + "title": "Not Found", + "type": "https://datatracker.ietf.org/doc/html/rfc9110#name-404-not-found", + "status": 404, + "detail": "User not found." +} +``` + +That is why the assertions read `detail` rather than a nested `error.messages` array. +See [Exceptions](../core-features/exceptions.md) for the full shape. + Planning and coding a new feature can be challenging at times, but reviewing our existing code or tutorials can serve as a source of inspiration. ## FAQ @@ -225,34 +268,40 @@ See [Displaying Dotkernel API endpoints](../commands/display-available-endpoints **Q: What are the steps to add an endpoint?** -A: Create the handler, register it in the module's `ConfigProvider` under `factories`, declare the route in `RoutesDelegator.php`, and grant the route name a permission in `config/autoload/authorization.global.php`. +A: Create the handler, register it in the module's `ConfigProvider` under both `factories` and `delegators`, declare the route in `RoutesDelegator.php`, and grant the route name a permission in `config/autoload/authorization.global.php`. -**Q: Why must the new route be registered last?** +**Q: Why must the new route be declared last?** -A: Because `/user/{identity}` and `/user/{id}` match the same shape. -Registering the new route last stops it from shadowing the existing ones. +A: Because `/user/{identity}` places no constraint on its placeholder, so it also matches a UUID. +Declaring it after `/user/{id}` keeps UUID lookups on the existing route. **Q: Which factory do I register the handler with?** -A: `AttributedServiceFactory::class`, which resolves the dependencies declared by the handler's `#[Inject]` attribute. +A: `AttributedServiceFactory::class`, which resolves the dependencies declared by the handler's `#[Inject]` attribute — none, in this case. +The handler additionally needs `HandlerDelegatorFactory::class` under `delegators`. See [Dependency injection](../core-features/dependency-injection.md). -**Q: Why does the handler throw two different exceptions?** +**Q: Why doesn't the handler throw any exception?** -A: `BadRequestException` covers a missing identity in the request (a client error in the input), while `NotFoundException` covers a valid identity with no matching record. -They map to 400 and 404 respectively. +A: Because `ResourceProviderMiddleware` runs before it and throws `NotFoundException` when the identity matches no user. +The handler is only reached once the entity exists, so there is nothing left for it to reject. See [Exceptions](../core-features/exceptions.md). -**Q: Why is the route added under `UserRole::ROLE_GUEST`?** +**Q: Do I need a new `MetadataMap` entry for the route?** + +A: No. +`MetadataMap` maps an entity to the one route used to build its self link, and `User` is already mapped to `user::view-user`. + +**Q: Why is the route added under `UserRoleEnum::Guest->value`?** -A: Only to keep the example simple — it lets everyone, including guests, view accounts. +A: Only to keep the example simple — it lets everyone, including unauthenticated callers, view accounts. Real deployments should grant it to the narrowest role that needs it. See [Authorization](../core-features/authorization.md). **Q: Will the endpoint work without an authorization entry?** A: No. -A route with no permission granted to the caller's role is refused, even though the handler and route exist. +A route with no permission granted to the caller's role is refused with `403 Forbidden`, even though the handler and route exist. **Q: What should the tests cover?**