A gateway/firewall task to be able to talk someone about the real job
Fibinger Ádám
2021-10-22 fd7692eae00cbb0db3e6b732f68357e3c64a8a8b
First testable and dev ready version
7 files modified
23 files added
7351 ■■■■■ changed files
.env 14 ●●●●● patch | view | raw | blame | history
.gitignore 4 ●●●● patch | view | raw | blame | history
composer.json 24 ●●●●● patch | view | raw | blame | history
composer.lock 6175 ●●●●● patch | view | raw | blame | history
config/bundles.php 7 ●●●●● patch | view | raw | blame | history
config/packages/api_platform.yaml 20 ●●●●● patch | view | raw | blame | history
config/packages/doctrine.yaml 18 ●●●●● patch | view | raw | blame | history
config/packages/doctrine_migrations.yaml 6 ●●●●● patch | view | raw | blame | history
config/packages/nelmio_cors.yaml 10 ●●●●● patch | view | raw | blame | history
config/packages/prod/doctrine.yaml 17 ●●●●● patch | view | raw | blame | history
config/packages/security.yaml 28 ●●●●● patch | view | raw | blame | history
config/packages/test/doctrine.yaml 4 ●●●● patch | view | raw | blame | history
config/packages/test/validator.yaml 3 ●●●●● patch | view | raw | blame | history
config/packages/twig.yaml 6 ●●●●● patch | view | raw | blame | history
config/packages/validator.yaml 8 ●●●●● patch | view | raw | blame | history
config/routes/annotations.yaml 7 ●●●●● patch | view | raw | blame | history
config/routes/api_platform.yaml 4 ●●●● patch | view | raw | blame | history
config/services.yaml 45 ●●●●● patch | view | raw | blame | history
phpunit.xml 40 ●●●●● patch | view | raw | blame | history
src/Controller/RetrieveSecretController.php 30 ●●●●● patch | view | raw | blame | history
src/DataTransformer/CreateSecretTransformer.php 67 ●●●●● patch | view | raw | blame | history
src/Dto/CreateSecretDTO.php 49 ●●●●● patch | view | raw | blame | history
src/Entity/Secret.php 159 ●●●●● patch | view | raw | blame | history
src/EventListener/DeserializeListener.php 70 ●●●●● patch | view | raw | blame | history
src/Filter/PublicSecretQueryExtension.php 26 ●●●●● patch | view | raw | blame | history
src/Repository/SecretRepository.php 15 ●●●●● patch | view | raw | blame | history
src/Serializer/SecretDenormalizer.php 41 ●●●●● patch | view | raw | blame | history
symfony.lock 359 ●●●●● patch | view | raw | blame | history
tests/SecretServerTest.php 84 ●●●●● patch | view | raw | blame | history
tests/bootstrap.php 11 ●●●●● patch | view | raw | blame | history
.env
@@ -15,5 +15,19 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_DEBUG=1
APP_SECRET=5f741ee8c025b998f20f53e4f9040374
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
# DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8"
###< doctrine/doctrine-bundle ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
.gitignore
@@ -8,3 +8,7 @@
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
.phpunit.result.cache
###< phpunit/phpunit ###
composer.json
@@ -7,14 +7,38 @@
        "php": ">=7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.6",
        "composer/package-versions-deprecated": "1.11.99.4",
        "doctrine/annotations": "^1.0",
        "doctrine/doctrine-bundle": "^2.4",
        "doctrine/doctrine-migrations-bundle": "^3.1",
        "doctrine/orm": "^2.10",
        "nelmio/cors-bundle": "^2.1",
        "phpdocumentor/reflection-docblock": "^5.2",
        "symfony/asset": "5.3.*",
        "symfony/browser-kit": "5.3.*",
        "symfony/console": "5.3.*",
        "symfony/dotenv": "5.3.*",
        "symfony/expression-language": "5.3.*",
        "symfony/flex": "^1.3.1",
        "symfony/framework-bundle": "5.3.*",
        "symfony/http-client": "5.3.*",
        "symfony/property-access": "5.3.*",
        "symfony/property-info": "5.3.*",
        "symfony/proxy-manager-bridge": "5.3.*",
        "symfony/runtime": "5.3.*",
        "symfony/security-bundle": "5.3.*",
        "symfony/serializer": "5.3.*",
        "symfony/twig-bundle": "5.3.*",
        "symfony/validator": "5.3.*",
        "symfony/yaml": "5.3.*"
    },
    "require-dev": {
        "justinrainbow/json-schema": "^5.2",
        "phpunit/phpunit": "^9.5",
        "symfony/css-selector": "5.3.*",
        "symfony/maker-bundle": "^1.34",
        "symfony/phpunit-bridge": "^5.3"
    },
    "config": {
        "optimize-autoloader": true,
composer.lock
Diff too large
config/bundles.php
@@ -2,4 +2,11 @@
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
    Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
    ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
    Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
];
config/packages/api_platform.yaml
New file
@@ -0,0 +1,20 @@
api_platform:
    mapping:
        paths: ['%kernel.project_dir%/src/Entity']
    patch_formats:
        json: ['application/merge-patch+json']
    swagger:
        versions: [3]
    exception_to_status:
        # this should be left alone, but the original swagger asked for this
        ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException: 405
    formats:
        jsonld:   ['application/ld+json']
        jsonhal:  ['application/hal+json']
        jsonapi:  ['application/vnd.api+json']
        json:     ['application/json']
        xml:      ['application/xml', 'text/xml']
        yaml:     ['application/x-yaml']
        csv:      ['text/csv']
        html:     ['text/html']
config/packages/doctrine.yaml
New file
@@ -0,0 +1,18 @@
doctrine:
    dbal:
        url: '%env(resolve:DATABASE_URL)%'
        # IMPORTANT: You MUST configure your server version,
        # either here or in the DATABASE_URL env var (see .env file)
        #server_version: '13'
    orm:
        auto_generate_proxy_classes: true
        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
        auto_mapping: true
        mappings:
            App:
                is_bundle: false
                type: annotation
                dir: '%kernel.project_dir%/src/Entity'
                prefix: 'App\Entity'
                alias: App
config/packages/doctrine_migrations.yaml
New file
@@ -0,0 +1,6 @@
doctrine_migrations:
    migrations_paths:
        # namespace is arbitrary but should be different from App\Migrations
        # as migrations classes should NOT be autoloaded
        'DoctrineMigrations': '%kernel.project_dir%/migrations'
    enable_profiler: '%kernel.debug%'
config/packages/nelmio_cors.yaml
New file
@@ -0,0 +1,10 @@
nelmio_cors:
    defaults:
        origin_regex: true
        allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
        allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
        allow_headers: ['Content-Type', 'Authorization']
        expose_headers: ['Link']
        max_age: 3600
    paths:
        '^/': null
config/packages/prod/doctrine.yaml
New file
@@ -0,0 +1,17 @@
doctrine:
    orm:
        auto_generate_proxy_classes: false
        query_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        result_cache_driver:
            type: pool
            pool: doctrine.result_cache_pool
framework:
    cache:
        pools:
            doctrine.result_cache_pool:
                adapter: cache.app
            doctrine.system_cache_pool:
                adapter: cache.system
config/packages/security.yaml
New file
@@ -0,0 +1,28 @@
security:
    # https://symfony.com/doc/current/security/authenticator_manager.html
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#c-hashing-passwords
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        users_in_memory: { memory: null }
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: users_in_memory
            # activate different ways to authenticate
            # https://symfony.com/doc/current/security.html#firewalls-authentication
            # https://symfony.com/doc/current/security/impersonating_user.html
            # switch_user: true
    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        # - { path: ^/admin, roles: ROLE_ADMIN }
        # - { path: ^/profile, roles: ROLE_USER }
config/packages/test/doctrine.yaml
New file
@@ -0,0 +1,4 @@
doctrine:
    dbal:
        # "TEST_TOKEN" is typically set by ParaTest
        dbname_suffix: '_test%env(default::TEST_TOKEN)%'
config/packages/test/validator.yaml
New file
@@ -0,0 +1,3 @@
framework:
    validation:
        not_compromised_password: false
config/packages/twig.yaml
New file
@@ -0,0 +1,6 @@
twig:
    default_path: '%kernel.project_dir%/templates'
when@test:
    twig:
        strict_variables: true
config/packages/validator.yaml
New file
@@ -0,0 +1,8 @@
framework:
    validation:
        email_validation_mode: html5
        # Enables validator auto-mapping support.
        # For instance, basic validation constraints will be inferred from Doctrine's metadata.
        #auto_mapping:
        #    App\Entity\: []
config/routes/annotations.yaml
New file
@@ -0,0 +1,7 @@
controllers:
    resource: ../../src/Controller/
    type: annotation
kernel:
    resource: ../../src/Kernel.php
    type: annotation
config/routes/api_platform.yaml
New file
@@ -0,0 +1,4 @@
api_platform:
    resource: .
    type: api_platform
    prefix: /v1
config/services.yaml
@@ -6,20 +6,35 @@
parameters:
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.
  # 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.
    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'
            - '../src/Tests/'
  # makes classes in src/ available to be used as services
  # this creates a service per class whose id is the fully-qualified class name
  App\:
    resource: '../src/'
    exclude:
      - '../src/DependencyInjection/'
      - '../src/Entity/'
      - '../src/Kernel.php'
      - '../src/Tests/'
    # add more service definitions when explicit configuration is needed
    # please note that last definitions always *replace* previous ones
  # add more service definitions when explicit configuration is needed
  # please note that last definitions always *replace* previous ones
  'App\EventListener\DeserializeListener':
    tags:
      - { name: 'kernel.event_listener', event: 'kernel.request', method: 'onKernelRequest', priority: 2 }
    # Autoconfiguration must be disabled to set a custom priority
    autoconfigure: false
    decorates: 'api_platform.listener.request.deserialize'
    arguments:
      $decorated: '@App\EventListener\DeserializeListener.inner'
  'App\DataTransformer\CreateSecretTransformer': ~
    # Uncomment only if autoconfiguration is disabled
  #tags: [ 'api_platform.data_transformer' ]
  App\Filter\PublicSecretQueryExtension:
    tags:
      - { name: api_platform.doctrine.orm.query_extension.collection }
phpunit.xml
New file
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         colors="true"
         bootstrap="tests/bootstrap.php"
>
    <php>
        <ini name="display_errors" value="1" />
        <ini name="error_reporting" value="-1" />
        <server name="APP_ENV" value="test" force="true" />
        <server name="SHELL_VERBOSITY" value="-1" />
        <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
        <server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
    </php>
    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    <coverage processUncoveredFiles="true">
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>
    <listeners>
        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
    </listeners>
    <!-- Run `composer require symfony/panther` before enabling this extension -->
    <!--
    <extensions>
        <extension class="Symfony\Component\Panther\ServerExtension" />
    </extensions>
    -->
</phpunit>
src/Controller/RetrieveSecretController.php
New file
@@ -0,0 +1,30 @@
<?php
namespace App\Controller;
use App\Entity\Secret;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
/**
 * We use this custom controller to decrement Secret after a GET operation
 */
class RetrieveSecretController extends AbstractController
{
    /**
     * @var EntityManagerInterface
     */
    private $em;
    public function __construct(EntityManagerInterface $entityManager) {
        $this->em = $entityManager;
    }
    public function __invoke(Secret $data): Secret
    {
        $data->setRemainingViews($data->getRemainingViews() - 1);
        $this->em->persist($data);
        $this->em->flush();
        return $data;
    }
}
src/DataTransformer/CreateSecretTransformer.php
New file
@@ -0,0 +1,67 @@
<?php
namespace App\DataTransformer;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use ApiPlatform\Core\Validator\ValidatorInterface;
use App\Dto\CreateSecretDTO;
use App\Entity\Secret;
class CreateSecretTransformer implements DataTransformerInterface
{
    /**
     * @var ValidatorInterface
     */
    private $validator;
    public function __construct(ValidatorInterface $validator)
    {
        $this->validator = $validator;
    }
    /**
     * @inheritDoc
     */
    public function transform($object, string $to, array $context = [])
    {
        $this->validator->validate($object);
        $expireInMinutes = $object->expireAfter > 0 ? $object->expireAfter : 0;
        $expireTime = null;
        if ($expireInMinutes)
        {
            $expireTime = (new \DateTimeImmutable())
                ->add(new \DateInterval('PT' . $expireInMinutes . 'M'));
        }
        return (new Secret())
            ->setSecretText($object->secret)
            ->setExpiresAt($expireTime)
            ->setRemainingViews($object->expireAfterViews);
    }
    /**
     * @inheritDoc
     */
    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        if ($to !== Secret::class)
        {
            return false;
        }
        if ($data instanceof CreateSecretDTO)
        {
            return true;
        }
        if (isset($data['secret']) && isset($data["expireAfterViews"]))
        {
            return true;
        }
        return false;
    }
}
src/Dto/CreateSecretDTO.php
New file
@@ -0,0 +1,49 @@
<?php
namespace App\Dto;
use ApiPlatform\Core\Annotation\ApiProperty;
use Symfony\Component\Validator\Constraints as Assert;
final class CreateSecretDTO
{
    /**
     * @var string
     * @ApiProperty(attributes={"openapi_context"={"description" = "This text will be saved as a secret"}})
     * @Assert\NotNull
     * @Assert\NotBlank
     */
    public $secret;
    /**
     * @var integer
     * @ApiProperty(attributes={"openapi_context"={"description" = "The secret won't be available after the given number of views. It must be greater than 0."}})
     * @Assert\NotBlank
     * @Assert\NotNull
     * @Assert\Range(
     *     min=1
     * )
     */
    public $expireAfterViews;
    /**
     * @var integer
     * @ApiProperty(attributes={"openapi_context"={"description" = "The secret won't be available after the given time. The value is provided in minutes. 0 means never expires."}})
     * @Assert\NotBlank
     * @Assert\NotNull
     * @Assert\Range(
     *     min = 0
     * )
     */
    public $expireAfter;
    public static function fromArray($secret = null, $expireAfter = null, $expireAfterViews = null)
    {
        $s = new CreateSecretDTO();
        $s->secret = $secret;
        $s->expireAfter = $expireAfter;
        $s->expireAfterViews = $expireAfterViews;
        return $s;
    }
}
src/Entity/Secret.php
New file
@@ -0,0 +1,159 @@
<?php
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\SecretRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
 * @ApiResource(
 *     collectionOperations={
 *         "create"={
 *              "path"="secret",
 *              "method"="post",
 *              "input"=App\Dto\CreateSecretDTO::class,
 *     *              "openapi_context": {
 *                    "requestBody": {
 *                           "content": {
 *                                "application/x-www-form-urlencoded": {
 *                                       "schema": {
 *                                             "type": "object",
 *                                             "properties": {
 *                                                 "secret": {
 *                                                     "type": "string",
 *                                                     "example": "https://i.pinimg.com/originals/e2/b8/e3/e2b8e32a0f9660e8fa0a2d68da618f0f.jpg",
 *                                                     "description": "This text will be saved as a secret"
 *                                                      },
 *                                                 "expireAfterViews": {
  *                                                      "type": "integer",
 *                                                      "example": 10,
 *                                                      "description": "The secret won't be available after the given number of views. It must be greater than 0.",
 *                                                      },
 *                                                 "expireAfter": {
 *                                                      "type": "integer",
 *                                                      "example": "100",
 *                                                      "description": "The secret won't be available after the given time. The value is provided in minutes. 0 means never expires"
 *                                                       },
 *                                                },
 *                                        },
 *                                  },
 *                             },
 *                    },
 *                 },
 *          }
 *     },
 *     itemOperations={
 *     "get"=
 *          {
 *              "path"="secret/{hash}",
 *              "controller"=\App\Controller\RetrieveSecretController::class
 *          },
 *     }
 *     )
 * @ORM\Entity(repositoryClass=SecretRepository::class)
 */
class Secret
{
    public function __construct()
    {
        $this->setCreatedAt(new \DateTimeImmutable());
    }
    /**
     * @ORM\Id
     * @ORM\Column(type="string", length=255, unique=true)
     * @ApiProperty(attributes={"openapi_context"={"description" = "Unique hash to identify the secrets"}})
     * @Assert\NotNull
     */
    private $hash;
    /**
     * @ORM\Column(type="string", length=255)
     * @ApiProperty(attributes={"openapi_context"={"description" = "The secret itself"}})
     * @Assert\NotNull
     * @Assert\Length(
     *     min = 1,
     *     max = 255
     * )
     */
    private $secretText;
    /**
     * @ORM\Column(type="datetime_immutable")
     * @ApiProperty(attributes={"openapi_context"={"description" = "The date and time of the creation"}})
     * @Assert\NotNull
     */
    private $createdAt;
    /**
     * @ORM\Column(type="datetime_immutable", nullable=true)
     * @ApiProperty(attributes={"openapi_context"={"description" = "The secret cannot be reached after this time"}})
     */
    private $expiresAt;
    /**
     * @ORM\Column(type="integer")
     * @ApiProperty(attributes={"openapi_context"={"description" = "How many times the secret can be viewed"}})
     * @Assert\NotNull
     * @Assert\Range(
     *     min = 0
     * )
     */
    private $remainingViews;
    public function getHash(): ?string
    {
        return $this->hash;
    }
    public function getSecretText(): ?string
    {
        return $this->secretText;
    }
    public function setSecretText(string $secretText): self
    {
        $this->secretText = $secretText;
        $this->hash = md5($secretText . uniqid('dont be too salty please', true));
        return $this;
    }
    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }
    public function setCreatedAt(\DateTimeImmutable $createdAt): self
    {
        $this->createdAt = $createdAt;
        return $this;
    }
    public function getExpiresAt(): ?\DateTimeImmutable
    {
        return $this->expiresAt;
    }
    public function setExpiresAt(?\DateTimeImmutable $expiresAt): self
    {
        $this->expiresAt = $expiresAt;
        return $this;
    }
    public function getRemainingViews(): ?int
    {
        return $this->remainingViews;
    }
    public function setRemainingViews(int $remainingViews): self
    {
        $this->remainingViews = $remainingViews;
        return $this;
    }
}
src/EventListener/DeserializeListener.php
New file
@@ -0,0 +1,70 @@
<?php
namespace App\EventListener;
use ApiPlatform\Core\Util\RequestAttributesExtractor;
use ApiPlatform\Core\Validator\ValidatorInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use ApiPlatform\Core\EventListener\DeserializeListener as DecoratedListener;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
/**
 * We need this to be able to accept application/x-www-form-urlencoded type data
 */
final class DeserializeListener
{
    private $decorated;
    private $denormalizer;
    private $serializerContextBuilder;
    public function __construct(
        DenormalizerInterface $denormalizer,
        SerializerContextBuilderInterface $serializerContextBuilder,
        DecoratedListener $decorated,
        ValidatorInterface $validator
    )
    {
        $this->denormalizer = $denormalizer;
        $this->serializerContextBuilder = $serializerContextBuilder;
        $this->decorated = $decorated;
    }
    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        if ($request->isMethodCacheable() || $request->isMethod(Request::METHOD_DELETE))
        {
            return;
        }
        if ('form' === $request->getContentType())
        {
            $this->denormalizeFormRequest($request);
        }
        else
        {
            $this->decorated->onKernelRequest($event);
        }
    }
    private function denormalizeFormRequest(Request $request): void
    {
        if (!$attributes = RequestAttributesExtractor::extractAttributes($request))
        {
            return;
        }
        $context = $this->serializerContextBuilder->createFromRequest($request, false, $attributes);
        $populated = $request->attributes->get('data');
        if (null !== $populated)
        {
            $context['object_to_populate'] = $populated;
        }
        $data = $request->request->all();
        $object = $this->denormalizer->denormalize($data, $attributes['resource_class'], null, $context);
        $request->attributes->set('data', $object);
    }
}
src/Filter/PublicSecretQueryExtension.php
New file
@@ -0,0 +1,26 @@
<?php
namespace App\Filter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Secret;
use Doctrine\ORM\QueryBuilder;
/**
 * This class restricts the items we can retrieve trough the API.
 */
class PublicSecretQueryExtension implements QueryItemExtensionInterface
{
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
    {
        if ($resourceClass !== Secret::class) {
            return;
        }
        $rootAlias = $queryBuilder->getRootAliases()[0];
        $queryBuilder
            ->andWhere($rootAlias . ".expiresAt >= CURRENT_TIMESTAMP() OR " . $rootAlias . ".expiresAt is null")
            ->andWhere($rootAlias . ".remainingViews > 0");
    }
}
src/Repository/SecretRepository.php
New file
@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\Secret;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SecretRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Secret::class);
    }
}
src/Serializer/SecretDenormalizer.php
New file
@@ -0,0 +1,41 @@
<?php
namespace App\Serializer;
use App\Dto\CreateSecretDTO;
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
/**
 * This denormalizer is required when we got the form in an x-www-form-urlencoded format, because we have to
 * make a CreateSecretDTO object from the input POST data.
 *
 * After this, the regular (non-x-www-form-urlencoded) pipeline is active, validation & creation is handled by that.
 */
class SecretDenormalizer implements ContextAwareDenormalizerInterface
{
    /**
     * @inheritDoc
     */
    public function supportsDenormalization($data, string $type, string $format = null, array $context = [])
    {
        if ($type !== CreateSecretDTO::class)
        {
            return false;
        }
        return (isset($data['secret']) && isset($data['expireAfterViews']) && isset($data['expireAfter']));
    }
    /**
     * @inheritDoc
     */
    public function denormalize($data, string $type, string $format = null, array $context = [])
    {
        if ($type !== CreateSecretDTO::class)
        {
            return false;
        }
        return CreateSecretDTO::fromArray($data['secret'], $data['expireAfter'], $data['expireAfterViews']);
    }
}
symfony.lock
@@ -1,4 +1,178 @@
{
    "api-platform/core": {
        "version": "2.6",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "2.5",
            "ref": "05b57782a78c21a664a42055dc11cf1954ca36bb"
        },
        "files": [
            "./config/routes/api_platform.yaml",
            "./config/packages/api_platform.yaml",
            "./src/Entity/.gitignore"
        ]
    },
    "composer/package-versions-deprecated": {
        "version": "1.11.99.4"
    },
    "doctrine/annotations": {
        "version": "1.13",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "1.0",
            "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457"
        },
        "files": [
            "./config/routes/annotations.yaml"
        ]
    },
    "doctrine/cache": {
        "version": "2.1.1"
    },
    "doctrine/collections": {
        "version": "1.6.8"
    },
    "doctrine/common": {
        "version": "3.2.0"
    },
    "doctrine/dbal": {
        "version": "3.1.3"
    },
    "doctrine/deprecations": {
        "version": "v0.5.3"
    },
    "doctrine/doctrine-bundle": {
        "version": "2.4",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "2.4",
            "ref": "bac5c852ff628886de2753215fe5eb1f9ce980fb"
        },
        "files": [
            "./config/packages/doctrine.yaml",
            "./config/packages/test/doctrine.yaml",
            "./config/packages/prod/doctrine.yaml",
            "./src/Repository/.gitignore",
            "./src/Entity/.gitignore"
        ]
    },
    "doctrine/doctrine-migrations-bundle": {
        "version": "3.1",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "3.1",
            "ref": "ee609429c9ee23e22d6fa5728211768f51ed2818"
        },
        "files": [
            "./config/packages/doctrine_migrations.yaml",
            "./migrations/.gitignore"
        ]
    },
    "doctrine/event-manager": {
        "version": "1.1.1"
    },
    "doctrine/inflector": {
        "version": "2.0.3"
    },
    "doctrine/instantiator": {
        "version": "1.4.0"
    },
    "doctrine/lexer": {
        "version": "1.2.1"
    },
    "doctrine/migrations": {
        "version": "3.3.0"
    },
    "doctrine/orm": {
        "version": "2.10.1"
    },
    "doctrine/persistence": {
        "version": "2.2.2"
    },
    "doctrine/sql-formatter": {
        "version": "1.1.1"
    },
    "fig/link-util": {
        "version": "1.1.2"
    },
    "friendsofphp/proxy-manager-lts": {
        "version": "v1.0.5"
    },
    "justinrainbow/json-schema": {
        "version": "5.2.11"
    },
    "laminas/laminas-code": {
        "version": "4.4.3"
    },
    "myclabs/deep-copy": {
        "version": "1.10.2"
    },
    "nelmio/cors-bundle": {
        "version": "2.1",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "1.5",
            "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
        },
        "files": [
            "./config/packages/nelmio_cors.yaml"
        ]
    },
    "nikic/php-parser": {
        "version": "v4.13.0"
    },
    "phar-io/manifest": {
        "version": "2.0.3"
    },
    "phar-io/version": {
        "version": "3.1.0"
    },
    "phpdocumentor/reflection-common": {
        "version": "2.2.0"
    },
    "phpdocumentor/reflection-docblock": {
        "version": "5.2.2"
    },
    "phpdocumentor/type-resolver": {
        "version": "1.5.1"
    },
    "phpspec/prophecy": {
        "version": "1.14.0"
    },
    "phpunit/php-code-coverage": {
        "version": "9.2.7"
    },
    "phpunit/php-file-iterator": {
        "version": "3.0.5"
    },
    "phpunit/php-invoker": {
        "version": "3.1.1"
    },
    "phpunit/php-text-template": {
        "version": "2.0.4"
    },
    "phpunit/php-timer": {
        "version": "5.0.3"
    },
    "phpunit/phpunit": {
        "version": "9.5",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "9.3",
            "ref": "a6249a6c4392e9169b87abf93225f7f9f59025e6"
        },
        "files": [
            "./.env.test",
            "./phpunit.xml.dist",
            "./tests/bootstrap.php"
        ]
    },
    "psr/cache": {
        "version": "1.0.1"
    },
@@ -8,8 +182,65 @@
    "psr/event-dispatcher": {
        "version": "1.0.0"
    },
    "psr/link": {
        "version": "1.0.0"
    },
    "psr/log": {
        "version": "1.1.4"
    },
    "sebastian/cli-parser": {
        "version": "1.0.1"
    },
    "sebastian/code-unit": {
        "version": "1.0.8"
    },
    "sebastian/code-unit-reverse-lookup": {
        "version": "2.0.3"
    },
    "sebastian/comparator": {
        "version": "4.0.6"
    },
    "sebastian/complexity": {
        "version": "2.0.2"
    },
    "sebastian/diff": {
        "version": "4.0.4"
    },
    "sebastian/environment": {
        "version": "5.1.3"
    },
    "sebastian/exporter": {
        "version": "4.0.3"
    },
    "sebastian/global-state": {
        "version": "5.0.3"
    },
    "sebastian/lines-of-code": {
        "version": "1.0.3"
    },
    "sebastian/object-enumerator": {
        "version": "4.0.4"
    },
    "sebastian/object-reflector": {
        "version": "2.0.4"
    },
    "sebastian/recursion-context": {
        "version": "4.0.4"
    },
    "sebastian/resource-operations": {
        "version": "3.0.3"
    },
    "sebastian/type": {
        "version": "2.3.4"
    },
    "sebastian/version": {
        "version": "3.0.2"
    },
    "symfony/asset": {
        "version": "v5.3.4"
    },
    "symfony/browser-kit": {
        "version": "v5.3.4"
    },
    "symfony/cache": {
        "version": "v5.3.8"
@@ -32,11 +263,20 @@
            "./bin/console"
        ]
    },
    "symfony/css-selector": {
        "version": "v5.3.4"
    },
    "symfony/dependency-injection": {
        "version": "v5.3.8"
    },
    "symfony/deprecation-contracts": {
        "version": "v2.4.0"
    },
    "symfony/doctrine-bridge": {
        "version": "v5.3.8"
    },
    "symfony/dom-crawler": {
        "version": "v5.3.7"
    },
    "symfony/dotenv": {
        "version": "v5.3.8"
@@ -49,6 +289,9 @@
    },
    "symfony/event-dispatcher-contracts": {
        "version": "v2.4.0"
    },
    "symfony/expression-language": {
        "version": "v5.3.7"
    },
    "symfony/filesystem": {
        "version": "v5.3.4"
@@ -87,6 +330,9 @@
            "./src/Controller/.gitignore"
        ]
    },
    "symfony/http-client": {
        "version": "v5.3.8"
    },
    "symfony/http-client-contracts": {
        "version": "v2.4.0"
    },
@@ -95,6 +341,33 @@
    },
    "symfony/http-kernel": {
        "version": "v5.3.9"
    },
    "symfony/maker-bundle": {
        "version": "1.34",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "1.0",
            "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
        }
    },
    "symfony/password-hasher": {
        "version": "v5.3.8"
    },
    "symfony/phpunit-bridge": {
        "version": "5.3",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "5.3",
            "ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96"
        },
        "files": [
            "./.env.test",
            "./bin/phpunit",
            "./phpunit.xml.dist",
            "./tests/bootstrap.php"
        ]
    },
    "symfony/polyfill-intl-grapheme": {
        "version": "v1.23.1"
@@ -114,6 +387,15 @@
    "symfony/polyfill-php81": {
        "version": "v1.23.0"
    },
    "symfony/property-access": {
        "version": "v5.3.8"
    },
    "symfony/property-info": {
        "version": "v5.3.8"
    },
    "symfony/proxy-manager-bridge": {
        "version": "v5.3.4"
    },
    "symfony/routing": {
        "version": "5.3",
        "recipe": {
@@ -130,11 +412,73 @@
    "symfony/runtime": {
        "version": "v5.3.4"
    },
    "symfony/security-bundle": {
        "version": "5.3",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "5.3",
            "ref": "9c4fcf79873f7400c885b90935f7163233615d6f"
        },
        "files": [
            "./config/packages/security.yaml"
        ]
    },
    "symfony/security-core": {
        "version": "v5.3.8"
    },
    "symfony/security-csrf": {
        "version": "v5.3.4"
    },
    "symfony/security-guard": {
        "version": "v5.3.7"
    },
    "symfony/security-http": {
        "version": "v5.3.8"
    },
    "symfony/serializer": {
        "version": "v5.3.8"
    },
    "symfony/service-contracts": {
        "version": "v2.4.0"
    },
    "symfony/stopwatch": {
        "version": "v5.3.4"
    },
    "symfony/string": {
        "version": "v5.3.7"
    },
    "symfony/translation-contracts": {
        "version": "v2.4.0"
    },
    "symfony/twig-bridge": {
        "version": "v5.3.7"
    },
    "symfony/twig-bundle": {
        "version": "5.3",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "5.3",
            "ref": "3dd530739a4284e3272274c128dbb7a8140a66f1"
        },
        "files": [
            "./config/packages/twig.yaml",
            "./templates/base.html.twig"
        ]
    },
    "symfony/validator": {
        "version": "5.3",
        "recipe": {
            "repo": "github.com/symfony/recipes",
            "branch": "master",
            "version": "4.3",
            "ref": "3eb8df139ec05414489d55b97603c5f6ca0c44cb"
        },
        "files": [
            "./config/packages/validator.yaml",
            "./config/packages/test/validator.yaml"
        ]
    },
    "symfony/var-dumper": {
        "version": "v5.3.8"
@@ -142,7 +486,22 @@
    "symfony/var-exporter": {
        "version": "v5.3.8"
    },
    "symfony/web-link": {
        "version": "v5.3.4"
    },
    "symfony/yaml": {
        "version": "v5.3.6"
    },
    "theseer/tokenizer": {
        "version": "1.2.1"
    },
    "twig/twig": {
        "version": "v3.3.3"
    },
    "webmozart/assert": {
        "version": "1.10.0"
    },
    "willdurand/negotiation": {
        "version": "3.0.0"
    }
}
tests/SecretServerTest.php
New file
@@ -0,0 +1,84 @@
<?php
namespace App\Tests;
use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Secret;
class SecretServerTest extends ApiTestCase
{
    private $secretValidDtoFixture = ['secret' => 'Something very secretly created stuff', 'expireAfterViews' => 10, 'expireAfter' => 10];
    public function testDefaultCollectionRoutes(): void
    {
        $client = static::createClient();
        $client->request('GET', 'v1/secrets');
        $this->assertResponseStatusCodeSame(404);
        $client->request('GET', 'v1/secret/asdf');
        $this->assertResponseStatusCodeSame(404);
    }
    public function testInvalidSecretFormats()
    {
        $client = static::createClient();
        $invalidFixture = $this->secretValidDtoFixture;
        $invalidFixture['expireAfterViews'] = 'sajt';
        $client->request(
            'POST',
            'v1/secret',
            [
                'json' => $invalidFixture
            ]
        );
        //missing field, invalid field, etc
        $this->assertResponseStatusCodeSame(405);
        unset($invalidFixture['expireAfterViews']);
        $client->request(
            'POST',
            'v1/secret',
            [
                'json' => $invalidFixture
            ]
        );
        //missing field, invalid field, etc
        $this->assertResponseStatusCodeSame(405);
    }
    public function testValidSecretCreation()
    {
        $client = static::createClient();
        $response = $client->request(
            'POST',
            'v1/secret',
            [
                'json' => $this->secretValidDtoFixture
            ]
        );
        //should be created
        $this->assertResponseStatusCodeSame(201);
        $content = $response->getContent();
        $this->assertMatchesJsonSchema(['hash', 'secretText', 'createdAt', 'expiresAt', 'remainingViews']);
        $objectAsArray = json_decode($content, true);
        $this->assertEquals($this->secretValidDtoFixture['secret'], $objectAsArray['secretText']);
        $this->assertEquals($this->secretValidDtoFixture['expireAfterViews'], $objectAsArray['remainingViews']);
        $response = $client->request('GET', 'v1/secret/' . $objectAsArray['hash']);
        $this->assertResponseStatusCodeSame(200);
        $content = $response->getContent();
        //we haven't saw this in the previous endpoint, we saw the creation response, so it worth to do the same check
        $this->assertMatchesJsonSchema(['hash', 'secretText', 'createdAt', 'expiresAt', 'remainingViews']);
        $objectAsArray = json_decode($content, true);
        $this->assertEquals($this->secretValidDtoFixture['expireAfterViews'] - 1, $objectAsArray['remainingViews']);
    }
}
tests/bootstrap.php
New file
@@ -0,0 +1,11 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (file_exists(dirname(__DIR__).'/config/bootstrap.php')) {
    require dirname(__DIR__).'/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
    (new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}