diff --git a/.drone.yml b/.drone.yml index 1acafc2..ddd4266 100644 --- a/.drone.yml +++ b/.drone.yml @@ -59,6 +59,8 @@ steps: environment: # Disable HTTPS redirection as it served by a reverse proxy CODEFIRST_CLIENTDRONE_ENV_SERVER_NAME: http://codefirst.iut.uca.fr + CODEFIRST_CLIENTDRONE_ENV_CORS_ALLOW_ORIGIN: https://codefirst.iut.uca.fr + CODEFIRST_CLIENTDRONE_ENV_ASSETS_BASE_PATH: /containers/clementfreville2-herbarium depends_on: - docker-image when: diff --git a/.env b/.env index 0b4b6b2..1443282 100755 --- a/.env +++ b/.env @@ -14,6 +14,8 @@ # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration +ASSETS_BASE_PATH=/ + ###> symfony/framework-bundle ### APP_ENV=dev APP_SECRET=654d4972fc24500c7b43763fc3b3efa7 @@ -39,3 +41,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> symfony/mailer ### # MAILER_DSN=null://null ###< symfony/mailer ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### diff --git a/Dockerfile b/Dockerfile index a5e8f9d..e5338da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ COPY . . RUN composer dump-autoload --classmap-authoritative --no-dev \ && composer dump-env prod \ && composer run-script --no-dev post-install-cmd \ + && php bin/console asset-map:compile \ ; RUN rm -Rf frankenphp/ diff --git a/composer.json b/composer.json index d59ef97..0a5a255 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,12 @@ "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", + "api-platform/core": "^3.3", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.12", "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.1", + "nelmio/cors-bundle": "^2.4", "phpdocumentor/reflection-docblock": "^5.4", "phpstan/phpdoc-parser": "^1.29", "runtime/frankenphp-symfony": "^0.2.0", @@ -97,6 +99,7 @@ } }, "require-dev": { + "dama/doctrine-test-bundle": "*", "doctrine/doctrine-fixtures-bundle": "^3.6", "fakerphp/faker": "^1.23", "phpstan/phpstan": "^1.11", diff --git a/composer.lock b/composer.lock index 9e8ea25..a528b05 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,196 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "888e1953f86d0f1fe9b271b85aab0ed0", + "content-hash": "cce9cbfaf4a49449e6431a8515f9d9eb", "packages": [ + { + "name": "api-platform/core", + "version": "v3.3.5", + "source": { + "type": "git", + "url": "https://github.com/api-platform/core.git", + "reference": "b5a93fb0bb855273aabb0807505ba61b68813246" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/api-platform/core/zipball/b5a93fb0bb855273aabb0807505ba61b68813246", + "reference": "b5a93fb0bb855273aabb0807505ba61b68813246", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^1.0 || ^2.0", + "php": ">=8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^3.1", + "symfony/http-foundation": "^6.4 || ^7.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/property-access": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0", + "symfony/translation-contracts": "^3.3", + "symfony/web-link": "^6.4 || ^7.0", + "willdurand/negotiation": "^3.0" + }, + "conflict": { + "doctrine/common": "<3.2.2", + "doctrine/dbal": "<2.10", + "doctrine/mongodb-odm": "<2.4", + "doctrine/orm": "<2.14.0", + "doctrine/persistence": "<1.3", + "elasticsearch/elasticsearch": ">=8.0,<8.4", + "phpspec/prophecy": "<1.15", + "phpunit/phpunit": "<9.5", + "symfony/framework-bundle": "6.4.6 || 7.0.6", + "symfony/var-exporter": "<6.1.1" + }, + "require-dev": { + "behat/behat": "^3.11", + "behat/mink": "^1.9", + "doctrine/cache": "^1.11 || ^2.1", + "doctrine/common": "^3.2.2", + "doctrine/dbal": "^3.4.0", + "doctrine/doctrine-bundle": "^1.12 || ^2.0", + "doctrine/mongodb-odm": "^2.2", + "doctrine/mongodb-odm-bundle": "^4.0 || ^5.0", + "doctrine/orm": "^2.14 || ^3.0", + "elasticsearch/elasticsearch": "^7.11 || ^8.4", + "friends-of-behat/mink-browserkit-driver": "^1.3.1", + "friends-of-behat/mink-extension": "^2.2", + "friends-of-behat/symfony-extension": "^2.1", + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "jangregor/phpstan-prophecy": "^1.0", + "justinrainbow/json-schema": "^5.2.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpdoc-parser": "^1.13", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-doctrine": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-symfony": "^1.0", + "phpunit/phpunit": "^9.6", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^3.9.7 || ^4.0", + "ramsey/uuid-doctrine": "^1.4 || ^2.0", + "sebastian/comparator": "<5.0", + "soyuka/contexts": "v3.3.9", + "soyuka/pmu": "^0.0.2", + "soyuka/stubs-mongodb": "^1.0", + "symfony/asset": "^6.4 || ^7.0", + "symfony/browser-kit": "^6.4 || ^7.0", + "symfony/cache": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/css-selector": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0.12", + "symfony/doctrine-bridge": "^6.4 || ^7.0", + "symfony/dom-crawler": "^6.4 || ^7.0", + "symfony/error-handler": "^6.4 || ^7.0", + "symfony/event-dispatcher": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/finder": "^6.4 || ^7.0", + "symfony/form": "^6.4 || ^7.0", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/http-client": "^6.4 || ^7.0", + "symfony/intl": "^6.4 || ^7.0", + "symfony/maker-bundle": "^1.24", + "symfony/mercure-bundle": "*", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^6.4.1 || ^7.0", + "symfony/routing": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/security-core": "^6.4 || ^7.0", + "symfony/stopwatch": "^6.4 || ^7.0", + "symfony/twig-bundle": "^6.4 || ^7.0", + "symfony/uid": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0", + "twig/twig": "^1.42.3 || ^2.12 || ^3.0", + "webonyx/graphql-php": "^14.0 || ^15.0" + }, + "suggest": { + "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", + "elasticsearch/elasticsearch": "To support Elasticsearch.", + "ocramius/package-versions": "To display the API Platform's version in the debug bar.", + "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", + "psr/cache-implementation": "To use metadata caching.", + "ramsey/uuid": "To support Ramsey's UUID identifiers.", + "symfony/cache": "To have metadata caching when using Symfony integration.", + "symfony/config": "To load XML configuration files.", + "symfony/expression-language": "To use authorization features.", + "symfony/http-client": "To use the HTTP cache invalidation system.", + "symfony/messenger": "To support messenger integration.", + "symfony/security": "To use authorization features.", + "symfony/twig-bundle": "To use the Swagger UI integration.", + "symfony/uid": "To support Symfony UUID/ULID identifiers.", + "symfony/web-profiler-bundle": "To use the data collector.", + "webonyx/graphql-php": "To support GraphQL." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.3.x-dev" + }, + "symfony": { + "require": "^6.4 || ^7.0" + }, + "projects": [ + "api-platform/doctrine-common", + "api-platform/doctrine-orm", + "api-platform/doctrine-odm", + "api-platform/metadata", + "api-platform/json-schema", + "api-platform/elasticsearch", + "api-platform/jsonld", + "api-platform/hydra", + "api-platform/openapi", + "api-platform/graphql", + "api-platform/http-cache", + "api-platform/documentation", + "api-platform/parameter-validator", + "api-platform/ramsey-uuid", + "api-platform/serializer", + "api-platform/state", + "api-platform/symfony", + "api-platform/validator" + ] + }, + "autoload": { + "psr-4": { + "ApiPlatform\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr", + "homepage": "https://dunglas.fr" + } + ], + "description": "Build a fully-featured hypermedia or GraphQL API in minutes!", + "homepage": "https://api-platform.com", + "keywords": [ + "Hydra", + "JSON-LD", + "api", + "graphql", + "hal", + "jsonapi", + "openapi", + "rest", + "swagger" + ], + "support": { + "issues": "https://github.com/api-platform/core/issues", + "source": "https://github.com/api-platform/core/tree/v3.3.5" + }, + "time": "2024-05-29T05:48:47+00:00" + }, { "name": "composer/semver", "version": "3.4.0", @@ -1478,6 +1666,68 @@ ], "time": "2024-04-12T21:02:21+00:00" }, + { + "name": "nelmio/cors-bundle", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "78fcdb91f76b080a1008133def9c7f613833933d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/78fcdb91f76b080a1008133def9c7f613833933d", + "reference": "78fcdb91f76b080a1008133def9c7f613833933d", + "shasum": "" + }, + "require": { + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.6", + "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.4.0" + }, + "time": "2023-11-30T16:41:19+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -7548,9 +7798,132 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "willdurand/negotiation", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/willdurand/Negotiation.git", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/willdurand/Negotiation/zipball/68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", + "reference": "68e9ea0553ef6e2ee8db5c1d98829f111e623ec2", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Negotiation\\": "src/Negotiation" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "William Durand", + "email": "will+git@drnd.me" + } + ], + "description": "Content Negotiation tools for PHP provided as a standalone library.", + "homepage": "http://williamdurand.fr/Negotiation/", + "keywords": [ + "accept", + "content", + "format", + "header", + "negotiation" + ], + "support": { + "issues": "https://github.com/willdurand/Negotiation/issues", + "source": "https://github.com/willdurand/Negotiation/tree/3.1.0" + }, + "time": "2022-01-30T20:08:53+00:00" } ], "packages-dev": [ + { + "name": "dama/doctrine-test-bundle", + "version": "v8.2.0", + "source": { + "type": "git", + "url": "https://github.com/dmaicher/doctrine-test-bundle.git", + "reference": "1f81a280ea63f049d24e9c8ce00e557b18e0ff2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/1f81a280ea63f049d24e9c8ce00e557b18e0ff2f", + "reference": "1f81a280ea63f049d24e9c8ce00e557b18e0ff2f", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.3 || ^4.0", + "doctrine/doctrine-bundle": "^2.11.0", + "php": "^7.4 || ^8.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "symfony/cache": "^5.4 || ^6.3 || ^7.0", + "symfony/framework-bundle": "^5.4 || ^6.3 || ^7.0" + }, + "require-dev": { + "behat/behat": "^3.0", + "friendsofphp/php-cs-fixer": "^3.27", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0 || ^11.0", + "symfony/phpunit-bridge": "^6.3", + "symfony/process": "^5.4 || ^6.3 || ^7.0", + "symfony/yaml": "^5.4 || ^6.3 || ^7.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "8.x-dev" + } + }, + "autoload": { + "psr-4": { + "DAMA\\DoctrineTestBundle\\": "src/DAMA/DoctrineTestBundle" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Maicher", + "email": "mail@dmaicher.de" + } + ], + "description": "Symfony bundle to isolate doctrine database tests and improve test performance", + "keywords": [ + "doctrine", + "isolation", + "performance", + "symfony", + "testing", + "tests" + ], + "support": { + "issues": "https://github.com/dmaicher/doctrine-test-bundle/issues", + "source": "https://github.com/dmaicher/doctrine-test-bundle/tree/v8.2.0" + }, + "time": "2024-05-28T15:41:06+00:00" + }, { "name": "doctrine/data-fixtures", "version": "1.7.0", diff --git a/config/bundles.php b/config/bundles.php index 3584c3f..fbce34f 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -15,4 +15,7 @@ return [ Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], ]; diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml new file mode 100644 index 0000000..1f8d9fb --- /dev/null +++ b/config/packages/api_platform.yaml @@ -0,0 +1,19 @@ +api_platform: + title: Hello API Platform + version: 1.0.0 + formats: + jsonld: ['application/ld+json'] + json: ['application/json'] + docs_formats: + jsonld: ['application/ld+json'] + jsonopenapi: ['application/vnd.openapi+json'] + html: ['text/html'] + defaults: + stateless: true + cache_headers: + vary: ['Content-Type', 'Authorization', 'Origin'] + extra_properties: + standard_put: true + rfc_7807_compliant_errors: true + keep_legacy_inflector: false + use_symfony_listeners: true diff --git a/config/packages/dama_doctrine_test_bundle.yaml b/config/packages/dama_doctrine_test_bundle.yaml new file mode 100644 index 0000000..3482cba --- /dev/null +++ b/config/packages/dama_doctrine_test_bundle.yaml @@ -0,0 +1,5 @@ +when@test: + dama_doctrine_test: + enable_static_connection: true + enable_static_meta_data_cache: true + enable_static_query_cache: true diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 877eb25..a09ef0c 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -3,6 +3,9 @@ framework: secret: '%env(APP_SECRET)%' #csrf_protection: true + assets: + base_path: '%env(ASSETS_BASE_PATH)%' + # Note that the session will be started ONLY if you read or write from it. session: true diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..c766508 --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -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 diff --git a/config/routes/api_platform.yaml b/config/routes/api_platform.yaml new file mode 100644 index 0000000..38f11cb --- /dev/null +++ b/config/routes/api_platform.yaml @@ -0,0 +1,4 @@ +api_platform: + resource: . + type: api_platform + prefix: /api diff --git a/config/services.yaml b/config/services.yaml index 2d6a76f..2021197 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -20,5 +20,9 @@ services: - '../src/Entity/' - '../src/Kernel.php' + App\State\UserPasswordHasher: + bind: + $processor: '@api_platform.doctrine.orm.state.persist_processor' + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6c4bfed..4e6c0e2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -34,5 +34,6 @@ + diff --git a/src/Entity/Post.php b/src/Entity/Post.php index 17ce70c..0b54550 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -2,38 +2,62 @@ namespace App\Entity; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; use App\Repository\PostRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Entity(repositoryClass: PostRepository::class)] +#[ORM\HasLifecycleCallbacks] +#[ApiResource( + operations: [new Metadata\Post(), new Metadata\Get(), new Metadata\Put(), new Metadata\Delete(), new Metadata\Patch()], + normalizationContext: ['groups' => ['post:collection:read', 'post:read']], +)] +#[GetCollection(normalizationContext: ['groups' => ['post:collection:read']])] +#[Metadata\ApiFilter(filterClass: SearchFilter::class, properties: ['species' => 'exact'])] class Post { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + #[Groups(['post:collection:read'])] private ?int $id = null; #[ORM\Column] + #[Groups(['post:collection:read'])] private ?\DateTimeImmutable $foundDate = null; #[ORM\Column] + #[ApiProperty(writable: false)] + #[Groups(['post:collection:read'])] private ?\DateTimeImmutable $publicationDate = null; #[ORM\Column(nullable: true)] + #[Groups(['post:collection:read'])] private ?float $latitude = null; #[ORM\Column(nullable: true)] + #[Groups(['post:collection:read'])] private ?float $longitude = null; #[ORM\Column(nullable: true)] + #[Groups(['post:collection:read'])] private ?float $altitude = null; #[ORM\Column(type: Types::TEXT)] + #[Groups(['post:read'])] private ?string $commentary = null; #[ORM\ManyToOne(inversedBy: 'posts')] + #[ApiProperty(readableLink: false)] + #[Groups(['post:collection:read'])] private ?Species $species = null; + public function getId(): ?int { return $this->id; @@ -122,4 +146,13 @@ class Post return $this; } + + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function setPublicationDateValue(): void + { + if ($this->publicationDate === null) { + $this->publicationDate = new \DateTimeImmutable(); + } + } } diff --git a/src/Entity/Species.php b/src/Entity/Species.php index 9243896..e2e1b78 100644 --- a/src/Entity/Species.php +++ b/src/Entity/Species.php @@ -2,32 +2,45 @@ namespace App\Entity; +use ApiPlatform\Metadata; +use ApiPlatform\Metadata\ApiResource; use App\Repository\SpeciesRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Entity(repositoryClass: SpeciesRepository::class)] +#[ApiResource( + operations: [new Metadata\Post(), new Metadata\Get(), new Metadata\Put(), new Metadata\Delete(), new Metadata\Patch()], + normalizationContext: ['groups' => ['species:collection:read', 'species:read']], +)] +#[Metadata\GetCollection(normalizationContext: ['groups' => ['species:collection:read']])] class Species { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] + #[Groups(['species:collection:read'])] private ?int $id = null; #[ORM\Column(length: 255)] + #[Groups(['species:collection:read'])] private ?string $scientific_name = null; #[ORM\Column(length: 255)] + #[Groups(['species:collection:read'])] private ?string $vernacular_name = null; #[ORM\Column(length: 255)] + #[Groups(['species:collection:read'])] private ?string $region = null; /** * @var Collection */ #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'species')] + #[Groups(['species:read'])] private Collection $posts; public function __construct() diff --git a/src/Entity/User.php b/src/Entity/User.php index 8e4dab6..e924817 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -2,15 +2,36 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Put; use App\Repository\UserRepository; +use App\State\UserPasswordHasher; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])] #[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')] +#[ApiResource( + operations: [ + new GetCollection(), + new \ApiPlatform\Metadata\Post(validationContext: ['groups' => ['Default', 'user:create']], processor: UserPasswordHasher::class), + new Get(), + new Put(processor: UserPasswordHasher::class), + new Patch(processor: UserPasswordHasher::class), + new Delete(), + ], + normalizationContext: ['groups' => ['user:read']], + denormalizationContext: ['groups' => ['user:create', 'user:update']], +)] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] @@ -18,6 +39,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column] private ?int $id = null; + #[Assert\Email] + #[Groups(['user:read', 'user:create', 'user:update'])] #[ORM\Column(length: 180)] private ?string $email = null; @@ -33,6 +56,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column] private ?string $password = null; + #[Assert\NotBlank(groups: ['user:create'])] + #[Groups(['user:create', 'user:update'])] + private ?string $plainPassword = null; + public function getId(): ?int { return $this->id; @@ -99,6 +126,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getPlainPassword(): ?string + { + return $this->plainPassword; + } + + public function setPlainPassword(?string $plainPassword): self + { + $this->plainPassword = $plainPassword; + + return $this; + } + /** * @see UserInterface */ diff --git a/src/State/UserPasswordHasher.php b/src/State/UserPasswordHasher.php new file mode 100644 index 0000000..0fc91be --- /dev/null +++ b/src/State/UserPasswordHasher.php @@ -0,0 +1,44 @@ + + */ +final readonly class UserPasswordHasher implements ProcessorInterface +{ + /** + * @param ProcessorInterface $processor + * @param UserPasswordHasherInterface $passwordHasher + */ + public function __construct( + private ProcessorInterface $processor, + private UserPasswordHasherInterface $passwordHasher, + ) + { + } + + /** + * @param User $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User + { + if (!$data->getPlainPassword()) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + $hashedPassword = $this->passwordHasher->hashPassword( + $data, + $data->getPlainPassword() + ); + $data->setPassword($hashedPassword); + $data->eraseCredentials(); + + return $this->processor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/symfony.lock b/symfony.lock index 59594a0..5e18ec6 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,30 @@ { + "api-platform/core": { + "version": "3.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.3", + "ref": "74b45ac570c57eb1fbe56c984091a9ff87e18bab" + }, + "files": [ + "config/packages/api_platform.yaml", + "config/routes/api_platform.yaml", + "src/ApiResource/.gitignore" + ] + }, + "dama/doctrine-test-bundle": { + "version": "8.2", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "7.2", + "ref": "896306d79d4ee143af9eadf9b09fd34a8c391b70" + }, + "files": [ + "config/packages/dama_doctrine_test_bundle.yaml" + ] + }, "doctrine/doctrine-bundle": { "version": "2.12", "recipe": { @@ -38,6 +64,18 @@ "migrations/.gitignore" ] }, + "nelmio/cors-bundle": { + "version": "2.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.5", + "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c" + }, + "files": [ + "config/packages/nelmio_cors.yaml" + ] + }, "phpstan/phpstan": { "version": "1.11", "recipe": { diff --git a/tests/Api/PostApiTest.php b/tests/Api/PostApiTest.php new file mode 100644 index 0000000..9304115 --- /dev/null +++ b/tests/Api/PostApiTest.php @@ -0,0 +1,114 @@ +getContainer()->get('doctrine')->getManager(); + $species = (new Species()) + ->setVernacularName('Oak') + ->setScientificName('Quercus') + ->setRegion('Europe'); + $post = (new Post()) + ->setPublicationDate(new \DateTimeImmutable('2024-06-07')) + ->setFoundDate(new \DateTimeImmutable('2024-06-06')) + ->setCommentary("maple") + ->setSpecies($species); + $manager->persist($species); + $manager->persist($post); + $manager->flush(); + $this->speciesId = $species->getId(); + $this->postId = $post->getId(); + } + + public function testFindInSpecies(): void + { + $response = static::createClient()->request('GET', '/api/species/' . $this->speciesId); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + '@context' => '/api/contexts/Species', + '@id' => '/api/species/' . $this->speciesId, + '@type' => 'Species', + 'id' => $this->speciesId, + 'vernacular_name' => 'Oak', + 'scientific_name' => 'Quercus', + 'region' => 'Europe', + 'posts' => ['/api/posts/' . $this->postId], + ]); + } + + public function testGetExisting(): void + { + $response = static::createClient()->request('GET', '/api/posts/' . $this->postId); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + '@context' => '/api/contexts/Post', + '@id' => '/api/posts/' . $this->postId, + '@type' => 'Post', + 'id' => $this->postId, + 'foundDate' => '2024-06-06T00:00:00+00:00', + 'publicationDate' => '2024-06-07T00:00:00+00:00', + 'commentary' => 'maple', + 'species' => '/api/species/' . $this->speciesId, + ]); + } + + public function testFilterBySpecies(): void + { + $response = static::createClient()->request('GET', '/api/posts', [ + 'query' => [ + 'species' => $this->speciesId, + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/api/contexts/Post', + '@id' => '/api/posts', + '@type' => 'hydra:Collection', + 'hydra:totalItems' => 1, + 'hydra:member' => [ + [ + '@type' => 'Post', + 'id' => $this->postId, + 'foundDate' => '2024-06-06T00:00:00+00:00', + 'species' => '/api/species/' . $this->speciesId, + ], + ], + ]); + } + + public function testPostSetPublicationDate(): void + { + $response = static::createClient()->request('POST', '/api/posts', [ + 'json' => [ + 'foundDate' => '2024-06-06', + 'publicationDate' => '2024-06-07', + 'commentary' => 'maple', + 'species' => '/api/species/' . $this->speciesId, + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/api/contexts/Post', + '@type' => 'Post', + 'foundDate' => '2024-06-06T00:00:00+00:00', + 'commentary' => 'maple', + 'species' => '/api/species/' . $this->speciesId, + ]); + $this->assertArrayHasKey('publicationDate', $response->toArray(false)); + } +} diff --git a/tests/Api/UserApiTest.php b/tests/Api/UserApiTest.php new file mode 100644 index 0000000..e5e997f --- /dev/null +++ b/tests/Api/UserApiTest.php @@ -0,0 +1,31 @@ +request('POST', '/api/users', [ + 'json' => [ + 'email' => 'test@test.com', + 'plainPassword' => 'password', + ] + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHasHeader('Content-Location'); + + $location = $response->getHeaders()['content-location'][0]; + static::createClient()->request('GET', $location); + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + '@context' => '/api/contexts/User', + '@id' => $location, + '@type' => 'User', + 'email' => 'test@test.com', + ]); + } +}