Tying up loose ends & Stock Portfolio API (Part 4)

Stock portfolio

In the previous part of this series we’ve talked about using the Messenger component for sending emails. We’ve also talked about how to use JWT tokens as an easy way to create tokens for account activation links or password reset links. In this part we will tie up some loose ends and we’ll start implementing the basic stock portfolio entities, which we’ll extend in the future parts.

Tying up loose ends

Custom exceptions

We’ve used some general exceptions throughout our services. If you do not catch those, API Platform will throw an Internal Server Error to the client. To combat this we’ll implement custom exceptions and map those to the correct HTTP Statuscode.

Let’s start by creating a base exception for our custom exceptions. Create src/Exception/AppException.php with the following content.

namespace App\Exception;

use Exception;

/**
 * This is the base exception for our custom exceptions
 */
class AppException extends Exception
{}
Code language: PHP (php)

All of our custom exceptions will extend from AppException. This give us the ability to create an exception listener for all of our custom exceptions if we need that in the future.

In our activate method of the UserService we throw an exception when the user was not found in the database. Let’s make this better by creating a NotFoundException and mapping this exception to the 404 Not Found status code.

Start by creating src/Exception/NotFoundException.php with the following content.

namespace App\Exception;

class NotFoundException extends AppException
{
    /**
     * @param string $entity
     *
     * @return NotFoundException
     */
    public static function createEntityNotFoundException(string $entity): NotFoundException
    {
        return new self(sprintf('The requested %s was not found', $entity));
    }
}
Code language: PHP (php)

We’ve added a static function to ensure the messages of the NotFoundException are consistent for each call.

Next, add the API Platform configuration to map the NotFoundException to the correct HTTP Statuscode. Do this by editing src/config/packages/api_platform.yaml and add the exception_to_status key like below.

api_platform:
    # Rest of the settings removed for brevity
    exception_to_status:
        # The handlers are registered by default, keep those lines to prevent unexpected side effects
        Symfony\Component\Serializer\Exception\ExceptionInterface: 400
        ApiPlatform\Core\Exception\InvalidArgumentException: 400
        ApiPlatform\Core\Exception\FilterValidationException: 400
        Doctrine\ORM\OptimisticLockException: 409
        ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException: 422 # Unprocessable entity

        # Custom mapping
        App\Exception\NotFoundException: 404
Code language: YAML (yaml)

Now we’ve done all that, we can implement the custom exception we’ve just created. Open src/Application/Service/UserService.php and locate the throw \Exception call in the activate function. Replace that line with throw NotFoundException::createEntityNotFoundException('User'); like below and don’t forget to add the use clause for our custom exception. Now when the user entity is not found we’ll return a 404 Not Found status code instead of the 500 Internal Service Error.

    /**
     * @param ActivateAccountRequest $request
     *
     * @return User
     *
     * @throws NotFoundException
     */
    public function activate(ActivateAccountRequest $request): User
    {
        $decodedToken = $this->tokenManager->parse($request->token);
        $user = $this->userRepository->find($decodedToken['sub']);
        if (null === $user) {
            throw NotFoundException::createEntityNotFoundException('User');
        }

        $user->setActive(true);

        return $user;
    }
Code language: PHP (php)

Dependency Injection and named parameters

In the register function we hardcoded the url of the activation link. Of course we can leave it like this, but when we do this all the time it’s a nightmare to change domains in the future. So let’s make this future proof by making the url configurable.

Let’s add an environment variable to our .env.local file (and .env file) which will contain the base url for our frontend.

FRONTEND_URL="https://stocks.woutercarabain.com/"
Code language: Bash (bash)

Now we need to inject the value of this environment variable in our service. We could define our service in the services.yaml file and inject it there. But I can imagine we’ll be needing this value in multiple services, so let’s prepare for the future by making a default named parameter. Do this by adding the highlighted lines below the _defaults key in your services.yaml file.

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
        bind:
            $frontendUrl: '%env(resolve:FRONTEND_URL)%'
Code language: YAML (yaml)

We can now inject the url in our services using string $frontendUrl in the constructor like this.

namespace App\Application\Service;

// ...

class UserService
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private EmailService $emailService,
        private JWTTokenManagerInterface $tokenManager,
        private UserRepository $userRepository,
        private UserPasswordHasherInterface $userPasswordHasher,
        private string $frontendUrl
    ){}

    // ...

    /**
     * @param RegisterUserRequest $request
     *
     * @return User
     */
    public function register(RegisterUserRequest $request): User
    {
        // ...

        $token = $this->tokenManager->createFromPayload($user, ['sub' => $user->getId(), 'action' => JwtActions::ACTIVATE_ACCOUNT]);
        $this->emailService->sendToUser('account/welcome', $user, 'Confirm your account', [
            'activationLink' => sprintf('%sactivate-account?token=%s', $this->frontendUrl, $token),
            'user' => $user,
        ]);

        return $user;
    }

    // ...
}
Code language: PHP (php)

Because it’s a default parameter we can use it wherever we want. Nice!

Unique email addresses

At the moment there is no validation on the uniqueness of the email addresses. Because we use them as a username for the login functionality we’ll need to make sure an email address is not already used.

When you’re working with entities you can use the UniqueEntity constraint provided by the Symfony Doctrine bridge, however this isn’t possible because we’re dealing with a DTO. So let’s create our own validator to make this happen.

Start by creating src/Validator/UniqueEntityDto.php with the following contents.

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

/**
 * Validates the DTO for entity-uniqueness
 */
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class UniqueEntityDto extends Constraint
{
    public string $field;
    public string $entityClass;
    public string $existsMessage;

    /**
     * @param null $options
     * @param array|null $groups
     * @param null $payload
     * @param null $field
     * @param null $entityClass
     * @param null $existsMessage
     */
    public function __construct($options = null, array $groups = null, $payload = null, $field = null, $entityClass = null, $existsMessage = null)
    {
        parent::__construct($options, $groups, $payload);

        $this->field = $field ?? $this->field;
        $this->entityClass = $entityClass ?? $this->entityClass;
        $this->existsMessage = $existsMessage ?? $this->existsMessage;
    }

    /**
     * Set the target of this constraint to class
     *
     * @return array|string
     */
    public function getTargets(): array|string
    {
        return self::CLASS_CONSTRAINT;
    }
}
Code language: PHP (php)

This is the constraint we can apply to our RegisterUserRequest. Now we need to make the actual validator used by this constraint. Create src/Validator/UniqueEntityDtoValidator.php with this content.

namespace App\Validator;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\InvalidArgumentException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

/**
 * Validates the DTO for entity-uniqueness
 */
class UniqueEntityDtoValidator extends ConstraintValidator
{
    /**
     * @param EntityManagerInterface $entityManager
     */
    public function __construct(private EntityManagerInterface $entityManager) {}

    /**
     * @param mixed $value
     * @param Constraint $constraint
     */
    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof UniqueEntityDto) {
            throw new UnexpectedTypeException($constraint, UniqueEntityDto::class);
        }

        $repository = $this->entityManager->getRepository($constraint->entityClass);
        if (null === $repository) {
            throw new InvalidArgumentException(sprintf('The repository for %s was not found.', $constraint->entityClass));
        }

        $entity = $repository->findOneBy([$constraint->field => $value->{$constraint->field}]);
        if (null !== $entity) {
            $this->context->buildViolation($constraint->existsMessage)
                ->addViolation();
        }
    }
}
Code language: PHP (php)

This validator will simply get the entity repository from the EntityManager and checks if it can find an entity where the specified field is the same as the value the client sent us. If it finds an entity, we add the violation and the client gets an error message saying the value already exists.

Now let’s add this constraint to our RegisterUserRequest DTO.

namespace App\Dto\Request\User;

use App\Entity\User;
use App\Validator\UniqueEntityDto;
use Symfony\Component\Validator\Constraints as Assert;

#[UniqueEntityDto(field: 'email', entityClass: User::class, existsMessage: 'A user with this email address already exists')]
class RegisterUserRequest
{
    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public string $firstName;

    #[Assert\NotBlank]
    #[Assert\Length(max: 255)]
    public string $lastName;

    #[Assert\NotBlank]
    #[Assert\Email]
    #[Assert\Length(max: 1024)]
    public string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    public string $password;
}
Code language: PHP (php)

Now you can try adding a user with an existing email address. You should not be able to do that.

Stockportfolio

I think it’s time to start creating the basic APIs for our stock portfolio. Let’s begin by creating some entities to store the data needed for the portfolio.

The entities

We’ll need a few entities to make our application work and store the correct data. Ofcourse we’re going to need a Stock entity to store basic information like a ticker and the companyName. Create src/Entity/Stock.php and add the following to that file.

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Infrastructure\Doctrine\Trait\SoftDeletableTrait;
use App\Infrastructure\Doctrine\Trait\TimestampableTrait;
use App\Repository\StockRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: StockRepository::class)]
#[ApiResource()]
class Stock
{
    use TimestampableTrait, SoftDeletableTrait;

    #[ORM\Id()]
    #[ORM\Column(type: Types::GUID, unique: true)]
    #[ORM\GeneratedValue(strategy: 'UUID')]
    private string $id;

    #[ORM\OneToMany(mappedBy: 'stock', targetEntity: StockPrice::class)]
    private Collection $prices;

    #[ORM\Column(type: Types::STRING, length: 10, nullable: false)]
    private string $ticker;

    #[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
    private string|null $companyName = null;

    // getters and setters
}
Code language: PHP (php)

Don’t forget to add the get and set methods for the parameters. Most modern IDE’s support generating those for you.

We’ll also need to add the StockRepository. For this you can copy the UserRepository and do a find and replace on the word User and replace it with Stock. Do that for all entities we’re going to create in this article.

We’ll also need a way to store which user added which stock to their portfolio along with some extra data. So let’s add a Portfolio entity by creating src/Entity/Portfolio.php.

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Infrastructure\Doctrine\Trait\SoftDeletableTrait;
use App\Infrastructure\Doctrine\Trait\TimestampableTrait;
use App\Repository\PortfolioRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity(repositoryClass: PortfolioRepository::class)]
#[ApiResource()]
class Portfolio
{
    use TimestampableTrait, SoftDeletableTrait;

    private const PRICE_MULTIPLIER = 10000; // We want to support up to 4 decimals for the price and the amount

    #[ORM\Id()]
    #[ORM\Column(type: Types::GUID, unique: true)]
    #[ORM\GeneratedValue(strategy: 'UUID')]
    #[Groups(['stock:read'])]
    private string $id;

    #[ORM\ManyToOne(targetEntity: Stock::class)]
    private Stock $stock;

    #[ORM\ManyToOne(targetEntity: User::class)]
    private User|null $user = null;

    #[ORM\Column(type: Types::INTEGER, nullable: false)]
    private int $amount;

    #[ORM\Column(type: Types::DATE_MUTABLE, nullable: false)]
    private DateTime $purchaseDate;

    #[ORM\Column(type: Types::INTEGER, nullable: false)]
    private int $purchasePrice;

    // ... getters and setters
}
Code language: PHP (php)

To calculate the returns of our users’ portfolios we’ll need to keep track of the stock prices. We could simply add a price field to the Stock entity, but I want to go fancy and keep track of historical prices to show a nice graph in our app. So we’ll need a way to store that data. Create src/Entity/StockPrice.php with the following content.

namespace App\Entity;

use App\Repository\StockPriceRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: StockPriceRepository::class)]
class StockPrice
{
    private const PRICE_MULTIPLIER = 10000; // We want to support up to 4 decimals for the price

    #[ORM\Id()]
    #[ORM\Column(type: Types::GUID, unique: true)]
    #[ORM\GeneratedValue(strategy: 'UUID')]
    private string $id;

    #[ORM\ManyToOne(targetEntity: Stock::class)]
    private Stock $stock;

    #[ORM\Column(type: Types::DATE_MUTABLE, nullable: false)]
    private DateTime $date;

    #[ORM\Column(type: Types::INTEGER, nullable: false)]
    private int $price;  // We store the price as an integer (price * PRICE_MULTIPLIER)

    // ... getters and setters
}
Code language: PHP (php)

These are the entities we’ll be needing for now. We can always come back to add more fields or entities.

Security

Because we’re working with user specific data, we don’t want the data to be shown to the wrong user. You might have noticed we’ve added a user field to the Portfolio entity. Now let’s make full use of this field by making sure the user that creates the entity is added to that field automatically.

Because we might have to added extra user specific data in the future, we’ll be adding an interface which we can use in our checks. This saves a lot of time and protects us from potential bugs in the future. Start by creating src/Application/Interface/HasUserInterface.php and add this code.

namespace App\Application\Interface;

use App\Entity\User;

interface HasUserInterface
{
    /**
     * Returns the User for the entity
     *
     * @return User
     */
    public function getUser(): User;

    /**
     * Sets the User for the entity
     *
     * @param User $user
     *
     * @return self
     */
    public function setUser(User $user): self;
}
Code language: PHP (php)

Every entity which is linked to the User entity will need to implement this interface. This will ensure the automatic checks we’ll create in a minute will work correctly.

Automatic checks

I’ve covered this before in my other article ‘How to use Extensions in API Platform properly?’ so I won’t go into much detail in this article. Create a file src/Infrastructure/Api/Extension/CurrentUserExtension.php file with this content.

namespace App\Infrastructure\Api\Extension;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Application\Interface\HasUserInterface;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;

/**
 * This extension makes sure normal users can only access their own entities
 */
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    /**
     * @param Security $security
     */
    public function __construct(private Security $security) {}

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param string|null $operationName
     */
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param array $identifiers
     * @param string|null $operationName
     * @param array $context
     */
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = []): void
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param string $resourceClass
     */
    private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
    {
        if (false === is_subclass_of($resourceClass, HasUserInterface::class)
            || $this->security->isGranted(User::ROLE_ADMIN)
            || null === $user = $this->security->getUser()
        ) {
            return;
        }

        $rootAlias = $queryBuilder->getRootAliases()[0];
        $queryBuilder->andWhere(sprintf('%s.user = :current_user', $rootAlias));

        /** @var User $user */
        $queryBuilder->setParameter('current_user', $user->getId());
    }
}
Code language: PHP (php)

As you can see in the highlighted line we’re checking against the HasUserInterface to see if we need to alter the Doctrine query.

Automatically add the current user

Because we created the HasUserInterface we can write a Doctrine Lifecycle Subscriber to automatically link the current user to the entity. Let’s create src/Infrastructure/Doctrine/Event/CurrentUserEventSubscriber.php with the following code.

namespace App\Infrastructure\Doctrine\Event;

use App\Application\Interface\HasUserInterface;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Symfony\Component\Security\Core\Security;

/**
 * This subscriber makes sure the entities that need a user attached will get it
 */
class CurrentUserEventSubscriber implements EventSubscriberInterface
{
    public function __construct(private Security $security) {}

    /**
     * @return array
     */
    public function getSubscribedEvents(): array
    {
        return [
            Events::prePersist,
        ];
    }

    /**
     * @param LifecycleEventArgs $args
     */
    public function prePersist(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();

        if(($entity instanceof HasUserInterface)
            && !$entity->getUser()
            && null !== $user = $this->security->getUser()
        ) {
            /** @var User $user */
            $entity->setUser($user);
        }
    }
}
Code language: PHP (php)

In this subscriber we hook into the prePersist event of Doctrine. This event is called when Doctrine is about to persist the entities to the database. When this is called we get the current entity from the event and check if it’s an instance of our HasUserInterface then we check if the entity already has a user. We do this because an admin user might have created the entity for another user and we don’t want to overwrite that decision. After that we check if we actually have an authenticated user. If so, we can call the setUser method of the entity, which we know exists because of the interface.

Now we just need to add the interface to the Portfolio entity. Do this by adding the highlighted lines below to your file.

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Application\Interface\HasUserInterface;
use App\Infrastructure\Doctrine\Trait\SoftDeletableTrait;
use App\Infrastructure\Doctrine\Trait\TimestampableTrait;
use App\Repository\PortfolioRepository;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity(repositoryClass: PortfolioRepository::class)]
#[ApiResource()]
class Portfolio implements HasUserInterface
{
    // ... rest of the portfolio entity
}
Code language: PHP (php)

Fetch the stock data

We’ve got our basic API setup, we’ve created the entities for our stock portfolio. Now let’s get to the fun stuff! We’ll need to periodically update our stock prices to keep our application up to date. For this we’ll be using a composer package created by Christian Scheb called Yahoo Finance API.

Start by importing the package using composer.

composer require scheb/yahoo-finance-api
Code language: Bash (bash)

After installing the package we can create a service where we can add the code to actually use the package and create our stock price entities. Create src/Application/Service/StockService.php and add this code.

namespace App\Application\Service;

use App\Entity\StockPrice;
use App\Repository\StockRepository;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Scheb\YahooFinanceApi\ApiClientFactory;

class StockService
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private StockRepository $stockRepository
    ) {}

    /**
     * Updates the prices for all stocks in the database
     */
    public function updatePrices(): void
    {
        $client = ApiClientFactory::createApiClient();
        $stocks = $this->stockRepository->findAll();
        foreach($stocks as $stock) {
            $quote = $client->getQuote($stock->getTicker());
            if (null === $quote) {
                continue;
            }

            // Update the name
            $stock->setCompanyName($quote->getLongName());

            // Add the latest price
            $stockPrice = new StockPrice();
            $stockPrice->setPrice($quote->getRegularMarketPreviousClose());
            $stockPrice->setStock($stock);
            $stockPrice->setDate(new DateTime());
            $this->entityManager->persist($stockPrice);
        }

        $this->entityManager->flush();
    }
}
Code language: PHP (php)

The updatePrices method of the StockService that we’ve just created, fetches all stocks from our database. It then uses the Yahoo Finance API package to retrieve the new data from Yahoo Finance. We then store the data in our database for usage later on.

Because the updatePrices method needs to be called every workday we’re going to create a command for it, which we can use in a linux cron job. To create the command create a file src/Command/UpdatePricesCommand.php with the following content.

namespace App\Command;

use App\Application\Service\StockService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class UpdatePricesCommand extends Command
{
    protected static $defaultName = 'app:update-prices';

    /**
     * @param StockService $stockService
     * @param string|null $name
     */
    public function __construct(private StockService $stockService, string $name = null)
    {
        parent::__construct($name);
    }

    /**
     * @param InputInterface $input
     * @param OutputInterface $output
     *
     * @return int
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->stockService->updatePrices();

        return Command::SUCCESS;
    }
}
Code language: PHP (php)

This creates a command which we can run using bin/console. The $defaultName parameter specifies the part that comes after bin/console to execute the command. That means in this case the full command would be this.

bin/console app:update-prices
Code language: Bash (bash)

Now when we deploy our application we can create a cron job calling this command and our prices would be updated automatically.

Final words

In this article we’ve created our basic stock portfolio and we’ve tied up some loose ends. In the next part we’ll start working on our React Native application by setting up the project.

Leave a Reply

Your email address will not be published. Required fields are marked *