vrok/symfony-addons

Symfony helper classes

Installs: 2 863

Dependents: 0

Suggesters: 0

Security: 0

Stars: 1

Watchers: 3

Forks: 1

Open Issues: 1

Type:symfony-bundle

2.8.1 2024-03-29 17:58 UTC

README

This is a library with additional classes for usage in combination with the Symfony framework.

CI Status Coverage Status

Mailer helpers

Automatically set a sender address

We want to replace setting the sender via mailer.yaml as envelope (@see https://symfonycasts.com/screencast/mailer/event-global-recipients) as this would still require each mail to have a FROM address set and also doesn't allow us to set a sender name.

config/services.yaml:

    Vrok\SymfonyAddons\EventSubscriber\AutoSenderSubscriber:
        arguments:
            $sender: "%env(MAILER_SENDER)%"

.env[.local]:

MAILER_SENDER="Change Me <your@email>"

Messenger helpers

Resetting the logger before/after a message

We want to group all log entries belonging to a single message to be grouped with a distinct UID and to flush a buffer logger after a message was processed (successfully or failed), to immediately see the entries in the log:

config/services.yaml:

    # add a UID to the context, same UID for each HTTP request or console command
    # and with the event subscriber also for each message 
    Monolog\Processor\UidProcessor:
        tags:
            - { name: monolog.processor, handler: logstash }

    # resets the UID when a message is received, flushed a buffer after a
    # message was handled. Add this multiple times if you want to flush more
    # channels, e.g. messenger
    app.event.reset_app_logger:
        class: Vrok\SymfonyAddons\EventSubscriber\ResetLoggerSubscriber
        tags:
            - { name: monolog.logger, channel: app }

Validators

AtLeastOneOf

Works like Symfony's own AtLeastOneOf constraint, but instead of returning a message like This value should satisfy at least ... it returns the message of the last failed validation. Can be used for obviously optional form fields where only simple messages should be displayed when AtLeastOne is used with Blank as first constraint.
See AtLeastOneOfValidatorTest for examples.

NoHtml

This validator tries to detect if a string contains HTML, to allow only plain text.
See NoHtmlValidatorTest for examples of allowed / forbidden values.

NoLineBreak

This validator raises a violation if it detects one or more linebreak characters in the validated string.
Detects unicode linebreaks, see NoLineBreaksValidatorTest for details.

NoSurroundingWhitespace

This validator raises a violation if it detects trailing or leading whitespace or newline characters in the validated string. Linebreaks and spaces are valid within the string.
Uses a regex looking for \s and \R, see NoSurroundingWhitespaceValidatorTest for details on detected characters.

PasswordStrength

This validator evaluates the strength of a given password string by determining its entropy instead of requireing something like "must contain at least one uppercase & one digit & one special char".
Allows to set a minStrength to vary the requirements. See Vrok\SymfonyAddons\Helper\PasswordStrength for details on the calculation.

PHPUnit helpers

Using the ApiPlatformTestCase

This class is used to test ApiPlatform endpoints by specifying input data and verifying the response data. It combines the traits documented below to refresh the database before each test, optionally create authenticated requests and check for created logs / sent emails / dispatched messages. It allows to easily check for expected response content, allowed or forbidden keys in the data or to verify against a given schema.

Requires "symfony/browser-kit" & "symfony/http-client" to be installed (and of cause ApiPlatform).

<?php

use Vrok\SymfonyAddons\PHPUnit\ApiPlatformTestCase;

class AuthApiTest extends ApiPlatformTestCase
{
    public function testAuthRequiresPassword(): void
    {
        $this->testOperation([
            'uri'            => '/authentication_token',
            'method'         => 'POST',
            'requestOptions' => ['json' => ['username' => 'fakeuser']],
            'responseCode'   => 400,
            'contentType'    => 'application/json',
            'json'           => [
                'type'   => 'https://tools.ietf.org/html/rfc2616#section-10',
                'title'  => 'An error occurred',
                'detail' => 'The key "password" must be provided.',
            ],
        ]);
    }
}
Option Usage Example
skipRefresh if set & true the database will not be refreshed before the request, to allow using two calls to `testOperation` in one testcase, e.g. uploading & deleting a file with two requests

'skipRefresh' => true

prepare Callable, to be executed _after_ the kernel was booted and the DB refreshed, but _before_ the request is made
'prepare' => static function (ContainerInterface $container, array &$params): void {
      $em = $container->get('doctrine')->getManager();

      $log = new ActionLog();
      $log->action = ActionLog::FAILED_LOGIN;
      $log->ipAddress = '127.0.0.1';
      $em->persist($log);
      $em->flush();

      $params['requestOptions']['query']['id'] = $log->id; 
}
uri the URI / endpoint to call

'uri' => '/users'

iri

an array of [classname, [field => value]] that is used to fetch a record from the database, determine its IRI, which is then used as URI for the request

'iri' => [User::class, [email => 'test@test.de']]

email if given, tries to find a User with that email and sends the request authenticated as this user with lexikJWT bundle

'email' => 'test@test.de'

postFormAuth if given (and 'email' is set) the JWT from Lexik is sent as 'application/x-www-form-urlencoded' request in a form field.
This is used for download endpoints where the browser should present the user with the file to download instead of loading it into memory via Javascript. (As we don't want to supply the token via GET to prevent security issues and as we cannot set a cookie.)

'postFormAuth' => 'bearer'

method

HTTP method for the request, defaults to GET. If PATCH is used, the content-type header is automatically set to application/merge-patch+json (if not already specified)

'method' => 'POST'

requestOptions options for the HTTP client, e.g. query parameters or basic auth
'requestOptions' => [
  'json' => [
    'username' => 'Peter',
    'email'    => 'peter@example.com',
  ],
  
  // or:
  'query' =>  [
    'order' => ['createdAt' => 'asc'],
  ],
  
  // or:
  'headers' => ['content-type' => 'application/json'],
]
files

An array of one or more files to upload. The files will be copied to a temp file, and wrapped in an UploadedFile, so the tested application can move/delete it as it needs to. If this option is used, the content-type header is automatically set to multipart/form-data (if not already specified)

'files' => [
  'picture' => [
    'path'         => '/path/to/file.png',
    'originalName' => 'mypicture.png',
    'mimeType'     => 'image/png',
  ]
]
responseCode asserts that the received status code matches

'responseCode' => 201

contentType asserts that the received content type header matches

'contentType' => 'application/ld+json; charset=utf-8'

json asserts that the returned content is JSON and contains the given array as subset
'json' => [
  'username' => 'Peter',
  'email'    => 'peter@example.com',
]
requiredKeys asserts the dataset contains the list of keys. Used for elements where the value is not known in advance, e.g. ID, slug, timestamps. Can be nested.
'requiredKeys' => ['hydra:member'][0]['id', '@id']
forbiddenKeys like requiredKeys, but the dataset may not contain those
'forbiddenKeys' => ['hydra:member'][0]['password', 'salt']
schemaClass Asserts that the received response matches the JSON schema for the given class. If the `iri` parameter is used or the request method is *not* GET, the item schema is used. Else the collection schema is used.
'schemaClass' => User::class,
createdLogs array of entries, asserts the messages to be present (with the correct log level) in the monolog handlers after the operation ran
'createdLogs'    => [
  ['Failed to validate the provider', Level::Error],
],
emailCount asserts this number of emails to be sent via the mailer after the operation was executed
 'emailCount' => 2,
messageCount asserts this number of messages to be dispatched to the message bus
 'messageCount' => 2,
dispatchedMessages array of message classes, asserts that at least one instance of each given class has been dispatched to the message bus
'dispatchedMessages' => [
  TenantCreatedMessage::class,
],

Using the RefreshDatabaseTrait

(Re-)Creates the DB schema for each test, removes existing data and fills the tables with predefined fixtures. Install doctrine/doctrine-fixtures-bundle and create fixtures, the trait uses the test group per default.

Just include the trait in your testcase and call bootKernel() or createClient(), e.g. in the setUp method:

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Vrok\SymfonyAddons\PHPUnit\RefreshDatabaseTrait;

class DatabaseTest extends KernelTestCase
{
    use RefreshDatabaseTrait;

    /**
     * @var \Doctrine\ORM\EntityManager
     */
    private $entityManager;

    protected function setUp(): void
    {
        $kernel = self::bootKernel();

        $this->entityManager = $kernel->getContainer()
            ->get('doctrine')
            ->getManager();
    }

}

Optionally define which fixtures to use for this test class:

    protected static $fixtureGroups = ['test', 'other'];

Supports setting the cleanup method after tests via DB_CLEANUP_METHOD. Allowed values are purge and dropSchema, for more details see RefreshDatabaseTrait::$cleanupMethod.

Using the AuthenticatedClientTrait

For use with an APIPlatform project with lexik/jwt-authentication-bundle. Creates a JWT for the user given by its unique email, username etc. and adds it to the test client's headers.

Include the trait in your testcase and call createAuthenticatedClient:

use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
use Vrok\SymfonyAddons\PHPUnit\AuthenticatedClientTrait;

class ApiTest extends ApiTestCase
{
   use AuthenticatedClientTrait;

   public function testAccess(): void
   {
       $client = static::createAuthenticatedClient([
           'email' => TestFixtures::ADMIN['email']
       ]);

       $iri = $this->findIriBy(User::class, ['id' => 1]);
       $client->request('GET', $iri);
       self::assertResponseIsSuccessful();
   }
}

Using the MonologAssertsTrait

For use with an Symfony project using the monolog-bundle.
Requires monolog/monolog of v3.0 or higher.

Include the trait in your testcase and call prepareLogger before triggering the action that should create logs and use assertLoggerHasMessage afterwards to check if a log record was created with the given message & severity:

use Monolog\Level;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Vrok\SymfonyAddons\PHPUnit\MonologAssertsTrait;

class LoggerTest extends KernelTestCase
{
   use MonologAssertsTrait;

   public function testLog(): void
   {      
       self::prepareLogger();

       $logger = static::getContainer()->get(LoggerInterface::class);
       $logger->error('Failed to do something');
       
       self::assertLoggerHasMessage('Failed to do something', Level::Error);
   }
}

Workflow helpers

Require symfony/workflow.

PropertyMarkingStore

Can be used instead of the default MethodMarkingStore, for entities & properties without Setter/Getter.

workflow.yaml:

framework:
  workflows:
    application_state:
      type: state_machine
      marking_store:
        # We need to use a service as there is no option to register a new "type"
        service: workflow.application.marking_store

services.yaml:

    # When using the "service" option, all other settings like "property: state"
    # are ignored in the workflow.yaml -> That's why we need a service definition
    # with the correct arguments.
    workflow.application.marking_store:
      class: Vrok\SymfonyAddons\Workflow\PropertyMarkingStore
      arguments: [true, 'state']

WorkflowHelper

Allows to get an array of available transitions and their blockers, can be used to show the user what transitions are possible from the current state and/or why a transition is currently blocked.

    public function __invoke(
        Entity $data
        WorkflowInterface $entityStateMachine,
    ): array
    {
      $result = $data->toArray();
      
      $result['transitions'] = WorkflowHelper::getTransitionList($data, $entityStateMachine);
      
      return $result;
    }
'publish' => [
    'blockers' => [
        TransitionBlocker::UNKNOWN => 'Title is empty!',
    ],
],

Cron events

Adding this bundle to the bundles.php registers three new CLI commands:

    Vrok\SymfonyAddons\VrokSymfonyAddonsBundle::class => ['all' => true],
bin/console cron:hourly
bin/console cron:daily
bin/console cron:monthly

When these are called, they trigger an event (CronHourlyEvent, CronDailyEvent, CronMonthlyEvent) that can be used by one ore more event listeners/subscribers to do maintenance, push messages to the messenger etc. It is your responsibility to execute these commands via crontab correctly!

use Vrok\SymfonyAddons\Event\CronDailyEvent;

class MyEventSubscriber implements EventSubscriberInterface
    public static function getSubscribedEvents(): array
    {
        return [
            CronDailyEvent::class => [
                ['onCronDaily', 100],
            ],
        ];
    }
}

ApiPlatform Filters

SimpleSearchFilter

Selects entities where the search term is found (case insensitive) in at least one of the specified properties. All specified properties type must be string.

#[ApiFilter(
    filterClass: SimpleSearchFilter::class,
    properties: [
        'description',
        'name',
        'slug',
    ],
    arguments: ['searchParameterName' => 'pattern']
)]

Requires CAST as defined Doctrine function, e.g. by vrok/doctrine-addons:

doctrine:
  orm:
    dql:
      string_functions:
        CAST: Vrok\DoctrineAddons\ORM\Query\AST\CastFunction

JsonExistsFilter

Postgres-only: Filters entities by their jsonb fields, if they contain the search parameter, using the ? operator. For example for filtering Users by their role, to prevent accidental matching with overlapping role names (e.g. ROLE_ADMIN and ROLE_ADMIN_BLOG) when searching as text with WHERE roles LIKE '%ROLE_ADMIN%'.

#[ApiFilter(filterClass: JsonExistsFilter::class, properties: ['roles'])]

Requires JSON_CONTAINS_TEXT as defined Doctrine function, provided by vrok/doctrine-addons:

doctrine:
  orm:
    dql:
      string_functions:
        JSON_CONTAINS_TEXT: Vrok\DoctrineAddons\ORM\Query\AST\JsonContainsTextFunction

MultipartDecoder

Adding this bundle to the bundles.php registers the MultipartDecoder to allow handling of file uploads with additional data (e.g. in ApiPlatform):

    Vrok\SymfonyAddons\VrokSymfonyAddonsBundle::class => ['all' => true],

The decoder is automatically called for multipart requests and simply returns all POST parameters and uploaded files together. To enable this add the multipart format to your config\api_platform.yaml:

api_platform:
    formats:
        multipart: ['multipart/form-data']

FormDecoder

Adding this bundle to the bundles.php registers the FormDecoder to allow handling HTML form data in ApiPlatform:

    Vrok\SymfonyAddons\VrokSymfonyAddonsBundle::class => ['all' => true],

The decoder is automatically called for form requests and simply returns all POST parameters. To enable this add the form format to your config\api_platform.yaml:

api_platform:
    formats:
      form: ['application/x-www-form-urlencoded']

Twig Extensions

Adding this bundle to the bundles.php together with the symfony/twig-bundle registers the new extension:

    Vrok\SymfonyAddons\VrokSymfonyAddonsBundle::class => ['all' => true],

FormatBytes

Converts bytes to human-readable notation (supports up to TiB).
This extension is auto-registered.
In your Twig template:

  {{ attachment.filesize|formatBytes }}

Outputs: 9.34 MiB

Developer Doc

composer.json require

  • symfony/yaml is required for loading the bundle & test config

composer.json dev

  • doctrine/data-fixtures is automatically installed by the doctrine-fixtures bundle, but we need to pin the minimal version as the versions before 1.5.2 are not compatible with DBAL < 3 (@see doctrine/data-fixtures#370)
  • doctrine/doctrine-fixtures-bundle is required for tests of the ApiPlatformTestCase
  • symfony/browser-kit is required for tests of the MultipartDecoder
  • symfony/mailer is required for tests of the AutoSenderSubscriber
  • symfony/doctrine-messenger is required for tests of the ResetLoggerSubscriber
  • symfony/monolog-bundle is required for tests of the MonologAssertsTrait and ResetLoggerSubscriber
  • symfony/phpunit-bridge must be at least v6.2.3 to prevent"Call to undefined method Doctrine\Common\Annotations\AnnotationRegistry::registerLoader()"
  • symfony/twig-bundle is required for tests of the FormatBytesExtension
  • symfony/workflow is required for tests of the WorkflowHelper and PropertyMarkingStore
  • monolog/monolog must be at least v3 for Monolog\Level
  • api-platform/core and vrok/doctrine-addons are required for testing the ApiPlatform filters

Open ToDos

  • tests for AuthenticatedClientTrait, RefreshDatabaseTrait
  • ApiPlatformTestCase should no longer use AuthenticatedClientTrait but use its own getJWT() and make the User class configurable like the fixtures.
  • tests for QueryBuilderHelper
  • compare code to ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper