From e63314aaa0a61277f74fe48eeeb1d6f0536d5ce4 Mon Sep 17 00:00:00 2001
From: Jesse Rushlow <jr@rushlow.dev>
Date: Fri, 29 Jan 2021 10:23:37 -0500
Subject: [PATCH 1/6] WIP - add docs to show API Platform implementation.

---
 README.md | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 171 insertions(+)

diff --git a/README.md b/README.md
index 7efd1597..3c5a587e 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,177 @@ Feel free to open an issue for questions, problems, or suggestions with our bund
 Issues pertaining to Symfony's Maker Bundle, specifically `make:reset-password`,
 should be addressed in the [Symfony Maker repository](https://github.com/symfony/maker-bundle).
 
+## API Usage Example
+
+If you're using [API Platform](https://api-platform.com/), this example will
+demonstrate how to implement ResetPasswordBundle into the API.
+
+```php
+// src/Entity/ResetPasswordRequest
+
+<?php
+
+namespace App\Entity;
+
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\Dto\ResetPasswordInput;
+use App\Repository\ResetPasswordRequestRepository;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Uid\UuidV4;
+use Symfony\Component\Validator\Constraints as Assert;
+use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
+use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
+
+/**
+ * @ApiResource(
+ *     input=ResetPasswordInput::class,
+ *     shortName="reset-password",
+ *     collectionOperations={
+ *          "post" = {"security" = "is_granted('IS_ANONYMOUS')"},
+ *     },
+ *     itemOperations={
+ *     },
+ *     denormalizationContext={"groups"={"reset-password:write"}},
+ * )
+ *
+ * @ORM\Entity(repositoryClass=ResetPasswordRequestRepository::class)
+ */
+class ResetPasswordRequest implements ResetPasswordRequestInterface
+{
+    use ResetPasswordRequestTrait;
+
+    /**
+     * @ORM\Id
+     * @ORM\Column(type="string", unique=true)
+     */
+    private string $id;
+
+    /**
+     * @ORM\ManyToOne(targetEntity=User::class)
+     * @ORM\JoinColumn(nullable=false)
+     */
+    private User $user;
+
+    /**
+     * This property is not persisted. It's needed when a reset is requested
+     * through the API.
+     * 
+     * @Assert\NotBlank
+     * @Groups({"reset-password:write"})
+     */
+    private string $email;  // email is not actually persisted. We need this to select the user in a API call.
+
+    public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
+    {
+        $this->id = new UuidV4();
+        $this->user = $user;
+        $this->initialize($expiresAt, $selector, $hashedToken);
+    }
+
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
+    public function getUser(): User
+    {
+        return $this->user;
+    }
+}
+```
+
+Because the `ResetPasswordHelper::generateResetToken()` method is responsible for
+creating and persisting a `ResetPasswordRequest` object after the reset token has been
+generated, we can't call `POST /api/reset-passwords` with `['email' => 'someone@example.com']`.
+
+We'll create a Data Transfer Object (`DTO`) first, that will be used by a Data Persister
+to generate the actual `ResetPasswordRequest` object from the email address provided
+in the `POST` api call.
+
+```php
+<?php
+
+namespace App\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * @author Jesse Rushlow <jr@rushlow.dev>
+ */
+class ResetPasswordInput
+{
+    /**
+     * @Groups({"reset-password:write"})
+     * @Assert\NotBlank
+     * @Assert\Email()
+     */
+    public ?string $email = null;
+}
+```
+
+Finally we'll create a Data Persister that is responsible for using the
+`ResetPasswordHelper::class` to generate a `ResetPasswordRequest` and email the
+token to the user.
+
+```php
+<?php
+
+namespace App\DataPersister;
+
+use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
+use ApiPlatform\Core\DataPersister\DataPersisterInterface;
+use App\Dto\ResetPasswordInput;
+use App\Entity\User;
+use App\Repository\UserRepository;
+use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
+
+/**
+ * @author Jesse Rushlow <jr@rushlow.dev>
+ */
+class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
+{
+    private DataPersisterInterface $decoratedDataPersister;
+    private UserRepository $userRepository;
+    private ResetPasswordHelperInterface $resetPasswordHelper;
+
+    public function __construct(DataPersisterInterface $decoratedDataPersister, UserRepository $userRepository, ResetPasswordHelperInterface $resetPasswordHelper)
+    {
+        $this->decoratedDataPersister = $decoratedDataPersister;
+        $this->userRepository = $userRepository;
+        $this->resetPasswordHelper = $resetPasswordHelper;
+    }
+
+    public function supports($data, array $context = []): bool
+    {
+        // Make sure to check if data is an instance of the DTO, not the ResetPasswordRequest.
+        return $data instanceof ResetPasswordInput;
+    }
+
+    public function persist($data, array $context = []): void
+    {
+        /** @var ResetPasswordInput $data */
+        $user = $this->userRepository->findOneBy(['email' => $data->email]);
+
+        if (!$user instanceof User) {
+            return;
+        }
+
+        $token = $this->resetPasswordHelper->generateResetToken($user);
+
+        // Send email || Dispatch Email w/ Messenger
+
+        return;
+    }
+
+    public function remove($data, array $context = []): void
+    {
+        $this->decoratedDataPersister->remove($data);
+    }
+}
+```
+
 ## Security Issues
 For **security related vulnerabilities**, we ask that you send an email to 
 `ryan [at] symfonycasts.com` instead of creating an issue. 

From b713e09f3f4b1ba052e8e4e1decd0bdb5d05d48b Mon Sep 17 00:00:00 2001
From: Jesse Rushlow <jr@rushlow.dev>
Date: Fri, 29 Jan 2021 18:07:38 -0500
Subject: [PATCH 2/6] return status 202 w/ no output

---
 README.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 3c5a587e..122af324 100644
--- a/README.md
+++ b/README.md
@@ -125,9 +125,10 @@ use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
 /**
  * @ApiResource(
  *     input=ResetPasswordInput::class,
+ *     output=false,
  *     shortName="reset-password",
  *     collectionOperations={
- *          "post" = {"security" = "is_granted('IS_ANONYMOUS')"},
+ *          "post" = {"security" = "is_granted('IS_ANONYMOUS')", "status" = 202},
  *     },
  *     itemOperations={
  *     },

From 9cdbce59fc8b962a3cce0ae92f9d986a36d1c23f Mon Sep 17 00:00:00 2001
From: Jesse Rushlow <jr@rushlow.dev>
Date: Sun, 31 Jan 2021 14:46:41 -0500
Subject: [PATCH 3/6] add data provider and update existing models

---
 README.md | 144 ++++++++++++++++++++++++++++++++++++++++++------------
 1 file changed, 114 insertions(+), 30 deletions(-)

diff --git a/README.md b/README.md
index 122af324..77e9bb03 100644
--- a/README.md
+++ b/README.md
@@ -116,23 +116,29 @@ use ApiPlatform\Core\Annotation\ApiResource;
 use App\Dto\ResetPasswordInput;
 use App\Repository\ResetPasswordRequestRepository;
 use Doctrine\ORM\Mapping as ORM;
-use Symfony\Component\Serializer\Annotation\Groups;
 use Symfony\Component\Uid\UuidV4;
-use Symfony\Component\Validator\Constraints as Assert;
 use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
 use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
 
 /**
  * @ApiResource(
+ *     security="is_granted('IS_ANONYMOUS')",
  *     input=ResetPasswordInput::class,
  *     output=false,
  *     shortName="reset-password",
  *     collectionOperations={
- *          "post" = {"security" = "is_granted('IS_ANONYMOUS')", "status" = 202},
+ *          "post" = {
+ *              "denormalization_context"={"groups"={"reset-password:post"}},
+ *              "status" = 202,
+ *              "validation_groups"={"postValidation"},
+ *          },
  *     },
  *     itemOperations={
+ *          "put" = {
+ *              "denormalization_context"={"groups"={"reset-password:put"}},
+ *              "validation_groups"={"putValidation"},
+ *          },
  *     },
- *     denormalizationContext={"groups"={"reset-password:write"}},
  * )
  *
  * @ORM\Entity(repositoryClass=ResetPasswordRequestRepository::class)
@@ -153,15 +159,6 @@ class ResetPasswordRequest implements ResetPasswordRequestInterface
      */
     private User $user;
 
-    /**
-     * This property is not persisted. It's needed when a reset is requested
-     * through the API.
-     * 
-     * @Assert\NotBlank
-     * @Groups({"reset-password:write"})
-     */
-    private string $email;  // email is not actually persisted. We need this to select the user in a API call.
-
     public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
     {
         $this->id = new UuidV4();
@@ -203,11 +200,58 @@ use Symfony\Component\Validator\Constraints as Assert;
 class ResetPasswordInput
 {
     /**
-     * @Groups({"reset-password:write"})
-     * @Assert\NotBlank
-     * @Assert\Email()
+     * @Assert\NotBlank(groups={"postValidation"})
+     * @Assert\Email(groups={"postValidation"})
+     * @Groups({"reset-password:post"})
+     */
+    public string $email;
+
+    /**
+     * @Assert\NotBlank(groups={"putValidation"})
+     * @Groups({"reset-password:put"})
      */
-    public ?string $email = null;
+    public string $token;
+
+    /**
+     * @Assert\NotBlank(groups={"putValidation"})
+     * @Groups({"reset-password:put"})
+     */
+    public string $plainTextPassword;
+}
+```
+
+```php
+<?php
+
+namespace App\DataProvider;
+
+use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
+use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
+use App\Entity\ResetPasswordRequest;
+use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
+
+class ResetPasswordDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
+{
+    private ResetPasswordHelperInterface $resetPasswordHelper;
+
+    public function __construct(ResetPasswordHelperInterface $resetPasswordHelper)
+    {
+        $this->resetPasswordHelper = $resetPasswordHelper;
+    }
+
+    public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
+    {
+        $user = $this->resetPasswordHelper->validateTokenAndFetchUser($id);
+
+        $this->resetPasswordHelper->removeResetRequest($id);
+
+        return $user;
+    }
+
+    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
+    {
+        return ResetPasswordRequest::class === $resourceClass && 'put' === $operationName;
+    }
 }
 ```
 
@@ -221,10 +265,12 @@ token to the user.
 namespace App\DataPersister;
 
 use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
-use ApiPlatform\Core\DataPersister\DataPersisterInterface;
 use App\Dto\ResetPasswordInput;
 use App\Entity\User;
+use App\Message\SendResetPasswordMessage;
 use App\Repository\UserRepository;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
 use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
 
 /**
@@ -232,27 +278,60 @@ use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
  */
 class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
 {
-    private DataPersisterInterface $decoratedDataPersister;
     private UserRepository $userRepository;
     private ResetPasswordHelperInterface $resetPasswordHelper;
+    private MessageBusInterface $messageBus;
+    private UserPasswordEncoderInterface $userPasswordEncoder;
 
-    public function __construct(DataPersisterInterface $decoratedDataPersister, UserRepository $userRepository, ResetPasswordHelperInterface $resetPasswordHelper)
+    public function __construct(UserRepository $userRepository, ResetPasswordHelperInterface $resetPasswordHelper, MessageBusInterface $messageBus, UserPasswordEncoderInterface $userPasswordEncoder)
     {
-        $this->decoratedDataPersister = $decoratedDataPersister;
         $this->userRepository = $userRepository;
         $this->resetPasswordHelper = $resetPasswordHelper;
+        $this->messageBus = $messageBus;
+        $this->userPasswordEncoder = $userPasswordEncoder;
     }
 
     public function supports($data, array $context = []): bool
     {
-        // Make sure to check if data is an instance of the DTO, not the ResetPasswordRequest.
-        return $data instanceof ResetPasswordInput;
+        if (!$data instanceof ResetPasswordInput) {
+            return false;
+        }
+
+        if (isset($context['collection_operation_name']) && 'post' === $context['collection_operation_name']) {
+            return true;
+        }
+
+        if (isset($context['item_operation_name']) && 'put' === $context['item_operation_name']) {
+            return true;
+        }
+
+        return false;
     }
 
+    /**
+     * @param ResetPasswordInput $data
+     */
     public function persist($data, array $context = []): void
     {
-        /** @var ResetPasswordInput $data */
-        $user = $this->userRepository->findOneBy(['email' => $data->email]);
+        if (isset($context['collection_operation_name']) && 'post' === $context['collection_operation_name']) {
+            $this->generateRequest($data->email);
+
+            return;
+        }
+
+        if (isset($context['item_operation_name']) && 'put' === $context['item_operation_name']) {
+            $this->changePassword($context['previous_data'], $data->plainTextPassword);
+        }
+    }
+
+    public function remove($data, array $context = []): void
+    {
+        throw new \RuntimeException('Operation not supported.');
+    }
+
+    private function generateRequest(string $email): void
+    {
+        $user = $this->userRepository->findOneBy(['email' => $email]);
 
         if (!$user instanceof User) {
             return;
@@ -260,14 +339,19 @@ class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
 
         $token = $this->resetPasswordHelper->generateResetToken($user);
 
-        // Send email || Dispatch Email w/ Messenger
-
-        return;
+        /** @psalm-suppress PossiblyNullArgument */
+        $this->messageBus->dispatch(new SendResetPasswordMessage($user->getEmail(), $token));
     }
 
-    public function remove($data, array $context = []): void
+    private function changePassword(User $previousUser, string $plainTextPassword): void
     {
-        $this->decoratedDataPersister->remove($data);
+        $userId = $previousUser->getId();
+
+        $user = $this->userRepository->find($userId);
+
+        $encoded = $this->userPasswordEncoder->encodePassword($user, $plainTextPassword);
+
+        $this->userRepository->upgradePassword($user, $encoded);
     }
 }
 ```

From eaf9a7f72c47cef969ddd368ac940b9ff0fd39d2 Mon Sep 17 00:00:00 2001
From: Jesse Rushlow <jr@rushlow.dev>
Date: Sun, 31 Jan 2021 15:04:32 -0500
Subject: [PATCH 4/6] model cleanup and refactoring

---
 README.md | 30 ++++++++++++++++++++++++------
 1 file changed, 24 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md
index 77e9bb03..6c8937cd 100644
--- a/README.md
+++ b/README.md
@@ -228,6 +228,8 @@ namespace App\DataProvider;
 use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
 use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
 use App\Entity\ResetPasswordRequest;
+use App\Entity\User;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
 
 class ResetPasswordDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
@@ -239,19 +241,27 @@ class ResetPasswordDataProvider implements ItemDataProviderInterface, Restricted
         $this->resetPasswordHelper = $resetPasswordHelper;
     }
 
-    public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
+    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
+    {
+        return ResetPasswordRequest::class === $resourceClass && 'put' === $operationName;
+    }
+
+    public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []): User
     {
+        if (!is_string($id)) {
+            throw new NotFoundHttpException('Invalid token.');
+        }
+
         $user = $this->resetPasswordHelper->validateTokenAndFetchUser($id);
 
+        if (!$user instanceof User) {
+            throw new NotFoundHttpException('Invalid token.');
+        }
+
         $this->resetPasswordHelper->removeResetRequest($id);
 
         return $user;
     }
-
-    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
-    {
-        return ResetPasswordRequest::class === $resourceClass && 'put' === $operationName;
-    }
 }
 ```
 
@@ -320,6 +330,10 @@ class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
         }
 
         if (isset($context['item_operation_name']) && 'put' === $context['item_operation_name']) {
+            if (!$context['previous_data'] instanceof User) {
+                return;
+            }
+
             $this->changePassword($context['previous_data'], $data->plainTextPassword);
         }
     }
@@ -349,6 +363,10 @@ class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
 
         $user = $this->userRepository->find($userId);
 
+        if (null === $user) {
+            return;
+        }
+
         $encoded = $this->userPasswordEncoder->encodePassword($user, $plainTextPassword);
 
         $this->userRepository->upgradePassword($user, $encoded);

From bd2f8ccd21b811e935a43421fb7d6fa4747decd4 Mon Sep 17 00:00:00 2001
From: Jesse Rushlow <jr@rushlow.dev>
Date: Sun, 31 Jan 2021 15:39:45 -0500
Subject: [PATCH 5/6] WIP - add missing transformer

---
 README.md | 30 ++++++++++++++++++++++++++++++
 1 file changed, 30 insertions(+)

diff --git a/README.md b/README.md
index 6c8937cd..15f6b16d 100644
--- a/README.md
+++ b/README.md
@@ -223,6 +223,36 @@ class ResetPasswordInput
 ```php
 <?php
 
+namespace App\DataTransformer;
+
+use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
+use App\Dto\ResetPasswordInput;
+use App\Entity\ResetPasswordRequest;
+
+/**
+ * @author Jesse Rushlow <jr@rushlow.dev>
+ */
+class ResetPasswordInputDataTransformer implements DataTransformerInterface
+{
+    public function transform($object, string $to, array $context = []): object
+    {
+        return $object;
+    }
+
+    public function supportsTransformation($data, string $to, array $context = []): bool
+    {
+        if ($data instanceof ResetPasswordRequest) {
+            return false;
+        }
+
+        return ResetPasswordRequest::class === $to && ($context['input']['class'] ?? null) === ResetPasswordInput::class;
+    }
+}
+```
+
+```php
+<?php
+
 namespace App\DataProvider;
 
 use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;

From d316a1b21bfe1d0ea535a8d936960cee3d8da0a8 Mon Sep 17 00:00:00 2001
From: Jesse Rushlow <jr@rushlow.dev>
Date: Mon, 15 Feb 2021 09:28:35 -0500
Subject: [PATCH 6/6] stop using the request entity and use 2 dto's

---
 README.md | 110 +++++++++++++++++++++++++-----------------------------
 1 file changed, 51 insertions(+), 59 deletions(-)

diff --git a/README.md b/README.md
index 15f6b16d..14a42506 100644
--- a/README.md
+++ b/README.md
@@ -121,26 +121,6 @@ use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
 use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
 
 /**
- * @ApiResource(
- *     security="is_granted('IS_ANONYMOUS')",
- *     input=ResetPasswordInput::class,
- *     output=false,
- *     shortName="reset-password",
- *     collectionOperations={
- *          "post" = {
- *              "denormalization_context"={"groups"={"reset-password:post"}},
- *              "status" = 202,
- *              "validation_groups"={"postValidation"},
- *          },
- *     },
- *     itemOperations={
- *          "put" = {
- *              "denormalization_context"={"groups"={"reset-password:put"}},
- *              "validation_groups"={"putValidation"},
- *          },
- *     },
- * )
- *
  * @ORM\Entity(repositoryClass=ResetPasswordRequestRepository::class)
  */
 class ResetPasswordRequest implements ResetPasswordRequestInterface
@@ -182,39 +162,38 @@ Because the `ResetPasswordHelper::generateResetToken()` method is responsible fo
 creating and persisting a `ResetPasswordRequest` object after the reset token has been
 generated, we can't call `POST /api/reset-passwords` with `['email' => 'someone@example.com']`.
 
-We'll create a Data Transfer Object (`DTO`) first, that will be used by a Data Persister
+We'll create 2 Data Transfer Objects (`DTO`) ~that will be used by a Data Persister
 to generate the actual `ResetPasswordRequest` object from the email address provided
-in the `POST` api call.
+in the `POST` api call.~
 
 ```php
 <?php
 
 namespace App\Dto;
 
-use Symfony\Component\Serializer\Annotation\Groups;
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
 use Symfony\Component\Validator\Constraints as Assert;
 
 /**
+ * @ApiResource(
+ *     output=false,
+ *     collectionOperations={},
+ *     itemOperations={"put"},
+ *     shortName="reset-password"
+ * )
+ *
  * @author Jesse Rushlow <jr@rushlow.dev>
  */
 class ResetPasswordInput
 {
     /**
-     * @Assert\NotBlank(groups={"postValidation"})
-     * @Assert\Email(groups={"postValidation"})
-     * @Groups({"reset-password:post"})
-     */
-    public string $email;
-
-    /**
-     * @Assert\NotBlank(groups={"putValidation"})
-     * @Groups({"reset-password:put"})
+     * @ApiProperty(identifier=true, writable=false)
      */
     public string $token;
 
     /**
-     * @Assert\NotBlank(groups={"putValidation"})
-     * @Groups({"reset-password:put"})
+     * @Assert\NotBlank()
      */
     public string $plainTextPassword;
 }
@@ -223,30 +202,34 @@ class ResetPasswordInput
 ```php
 <?php
 
-namespace App\DataTransformer;
+namespace App\Dto;
 
-use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
-use App\Dto\ResetPasswordInput;
-use App\Entity\ResetPasswordRequest;
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+use Symfony\Component\Validator\Constraints as Assert;
 
 /**
+ * @ApiResource(
+ *     output=false,
+ *     collectionOperations={
+ *          "post" = {
+ *              "status" = 202,
+ *          },
+ *     },
+ *     itemOperations={},
+ *     shortName="reset-password-request"
+ * )
+ *
  * @author Jesse Rushlow <jr@rushlow.dev>
  */
-class ResetPasswordInputDataTransformer implements DataTransformerInterface
+class ResetPasswordRequestInput
 {
-    public function transform($object, string $to, array $context = []): object
-    {
-        return $object;
-    }
-
-    public function supportsTransformation($data, string $to, array $context = []): bool
-    {
-        if ($data instanceof ResetPasswordRequest) {
-            return false;
-        }
-
-        return ResetPasswordRequest::class === $to && ($context['input']['class'] ?? null) === ResetPasswordInput::class;
-    }
+    /**
+     * @Assert\NotBlank()
+     * @Assert\Email()
+     * @ApiProperty(identifier=true)
+     */
+    public string $email;
 }
 ```
 
@@ -257,11 +240,15 @@ namespace App\DataProvider;
 
 use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
 use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
-use App\Entity\ResetPasswordRequest;
+use App\Dto\ResetPasswordInput;
 use App\Entity\User;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
 use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
 
+/**
+ * @author Jesse Rushlow <jr@rushlow.dev>
+ */
 class ResetPasswordDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
 {
     private ResetPasswordHelperInterface $resetPasswordHelper;
@@ -273,7 +260,7 @@ class ResetPasswordDataProvider implements ItemDataProviderInterface, Restricted
 
     public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
     {
-        return ResetPasswordRequest::class === $resourceClass && 'put' === $operationName;
+        return ResetPasswordInput::class === $resourceClass && 'put' === $operationName;
     }
 
     public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []): User
@@ -282,7 +269,12 @@ class ResetPasswordDataProvider implements ItemDataProviderInterface, Restricted
             throw new NotFoundHttpException('Invalid token.');
         }
 
-        $user = $this->resetPasswordHelper->validateTokenAndFetchUser($id);
+        try {
+            $user = $this->resetPasswordHelper->validateTokenAndFetchUser($id);
+        } catch (ResetPasswordExceptionInterface $ex) {
+            // Log exception id needed
+            throw new NotFoundHttpException();
+        }
 
         if (!$user instanceof User) {
             throw new NotFoundHttpException('Invalid token.');
@@ -306,6 +298,7 @@ namespace App\DataPersister;
 
 use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
 use App\Dto\ResetPasswordInput;
+use App\Dto\ResetPasswordRequestInput;
 use App\Entity\User;
 use App\Message\SendResetPasswordMessage;
 use App\Repository\UserRepository;
@@ -333,7 +326,7 @@ class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
 
     public function supports($data, array $context = []): bool
     {
-        if (!$data instanceof ResetPasswordInput) {
+        if (!($data instanceof ResetPasswordInput || $data instanceof ResetPasswordRequestInput)) {
             return false;
         }
 
@@ -348,18 +341,17 @@ class ResetPasswordDataPersister implements ContextAwareDataPersisterInterface
         return false;
     }
 
-    /**
-     * @param ResetPasswordInput $data
-     */
     public function persist($data, array $context = []): void
     {
         if (isset($context['collection_operation_name']) && 'post' === $context['collection_operation_name']) {
+            /** @var ResetPasswordRequestInput $data */
             $this->generateRequest($data->email);
 
             return;
         }
 
         if (isset($context['item_operation_name']) && 'put' === $context['item_operation_name']) {
+            /** @var ResetPasswordInput $data */
             if (!$context['previous_data'] instanceof User) {
                 return;
             }