From 6c3c731e4d05c850d4811f34832ecfa750e0b178 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 13 Sep 2023 20:56:51 +0200 Subject: [PATCH 1/7] docs: change class description --- src/Symfony/Security/State/AccessCheckerProvider.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Security/State/AccessCheckerProvider.php b/src/Symfony/Security/State/AccessCheckerProvider.php index af372971adb..69f7953dafa 100644 --- a/src/Symfony/Security/State/AccessCheckerProvider.php +++ b/src/Symfony/Security/State/AccessCheckerProvider.php @@ -23,7 +23,8 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** - * Allows access to content the resourceAccessChecker. + * Allows access based on the ApiPlatform\Symfony\Security\ResourceAccessCheckerInterface. + * This implementation covers GraphQl and HTTP. * * @see ResourceAccessCheckerInterface */ From df6e547b6098a7c4bb1d57dc72707d65555bdb48 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 13 Sep 2023 21:47:20 +0200 Subject: [PATCH 2/7] test: fix guides tests --- docs/src/Kernel.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/src/Kernel.php b/docs/src/Kernel.php index 02eee6812fc..f190515f4c3 100644 --- a/docs/src/Kernel.php +++ b/docs/src/Kernel.php @@ -44,9 +44,18 @@ class Kernel extends BaseKernel use MicroKernelTrait; private $declaredClasses = []; - public function __construct(string $environment, bool $debug, private string $guide) + public function __construct(string $environment, bool $debug, string $guide = null) { parent::__construct($environment, $debug); + + if (!$guide) { + $guide = $_SERVER['APP_GUIDE'] ?? $_ENV['APP_GUIDE'] ?? null; + + if (!$guide) { + throw new \RuntimeException('No guide.'); + } + } + $this->guide = $guide; require_once "{$this->getProjectDir()}/guides/{$this->guide}.php"; } From 01d79bbe8f4cfae8f255e3a8d3c269bd5493b7d6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 14 Sep 2023 16:50:49 +0200 Subject: [PATCH 3/7] temp --- docs/composer.json | 5 +- docs/config/packages/doctrine.yaml | 2 +- docs/config/packages/framework.yaml | 19 +- docs/guides/declare-a-resource.php | 1 - docs/guides/doctrine-entity-as-resource.php | 110 ++++++++ docs/guides/doctrine-orm-service-filter.php | 115 -------- ...-filter.php => doctrine-search-filter.php} | 31 ++- ...k-a-persistence-layer-with-a-processor.php | 45 ++- .../secure-a-resource-with-custom-voters.php | 78 ------ docs/guides/use-doctrine-orm-filters.php | 262 ------------------ docs/guides/use-validation-groups.php | 67 ----- docs/src/Kernel.php | 8 +- 12 files changed, 192 insertions(+), 551 deletions(-) create mode 100644 docs/guides/doctrine-entity-as-resource.php delete mode 100644 docs/guides/doctrine-orm-service-filter.php rename docs/guides/{use-doctrine-search-filter.php => doctrine-search-filter.php} (83%) delete mode 100644 docs/guides/secure-a-resource-with-custom-voters.php delete mode 100644 docs/guides/use-doctrine-orm-filters.php delete mode 100644 docs/guides/use-validation-groups.php diff --git a/docs/composer.json b/docs/composer.json index dc5f5eaa3f2..8321efc7f30 100644 --- a/docs/composer.json +++ b/docs/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "api-platform/core": "^3.1", + "api-platform/core": "dev-main", "symfony/expression-language": "6.2.*", "nelmio/cors-bundle": "^2.2", "phpstan/phpdoc-parser": "^1.15", @@ -43,5 +43,6 @@ }, "require-dev": { "phpunit/phpunit": "^10" - } + }, + "minimum-stability": "dev" } diff --git a/docs/config/packages/doctrine.yaml b/docs/config/packages/doctrine.yaml index 2e58fa97242..1d6e342f607 100644 --- a/docs/config/packages/doctrine.yaml +++ b/docs/config/packages/doctrine.yaml @@ -1,6 +1,6 @@ doctrine: dbal: - url: 'sqlite:///%kernel.project_dir%/var/data.db' + url: 'sqlite:///%kernel.project_dir%/var/%guide%.db' orm: auto_generate_proxy_classes: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware diff --git a/docs/config/packages/framework.yaml b/docs/config/packages/framework.yaml index 09d96bc3b74..fc2237ad0c0 100644 --- a/docs/config/packages/framework.yaml +++ b/docs/config/packages/framework.yaml @@ -1,10 +1,13 @@ when@test: - framework: - test: true + framework: + test: true api_platform: - formats: - jsonld: ['application/ld+json'] - json: ['application/json'] - defaults: - extraProperties: - standard_put: true + formats: + jsonld: ['application/ld+json'] + json: ['application/json'] + event_listeners_backward_compatibility_layer: false + keep_legacy_inflector: false + defaults: + extra_properties: + rfc_7807_compliant_errors: true + standard_put: true diff --git a/docs/guides/declare-a-resource.php b/docs/guides/declare-a-resource.php index f2a2a87bf9f..02a26c15d7d 100644 --- a/docs/guides/declare-a-resource.php +++ b/docs/guides/declare-a-resource.php @@ -5,7 +5,6 @@ // position: 1 // executable: true // tags: design -// homepage: true // --- // # Declare a Resource diff --git a/docs/guides/doctrine-entity-as-resource.php b/docs/guides/doctrine-entity-as-resource.php new file mode 100644 index 00000000000..d400695787a --- /dev/null +++ b/docs/guides/doctrine-entity-as-resource.php @@ -0,0 +1,110 @@ +id; + } + } +} + +namespace App\Playground { + + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + // Persistence is automatic, you can try to create or read data: + return Request::create('/books?order[id]=desc', 'GET'); + return Request::create('/books/1', 'GET'); + return Request::create(uri: '/books', method: 'POST', server: ['CONTENT_TYPE' => 'application/ld+json'], content: json_encode(['id' => 1, 'title' => 'API Platform rocks.'])); + } +} + +namespace DoctrineMigrations { + + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL)'); + } + } +} + +namespace App\Fixtures { + + use App\Entity\Book; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\repository; + + final class BookFixtures extends Fixture + { + public function load(ObjectManager $manager): void + { + $bookFactory = anonymous(Book::class); + if (repository(Book::class)->count()) { + return; + } + + $bookFactory->many(10)->create([ + 'title' => 'title' + ]); + } + } +} + +namespace App\Tests { + + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use ApiPlatform\Playground\Test\TestGuideTrait; + + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + + public function testGet(): void + { + static::createClient()->request('GET', '/books.jsonld'); + $this->assertResponseIsSuccessful(); + } + + public function testGetOne(): void + { + static::createClient()->request('GET', '/books/1.jsonld'); + $this->assertResponseIsSuccessful(); + } + } +} diff --git a/docs/guides/doctrine-orm-service-filter.php b/docs/guides/doctrine-orm-service-filter.php deleted file mode 100644 index fe46991dccf..00000000000 --- a/docs/guides/doctrine-orm-service-filter.php +++ /dev/null @@ -1,115 +0,0 @@ -services() - ->set('book.search_filter') - ->parent('api_platform.doctrine.orm.search_filter') - ->args([['title' => null]]) - ->tag('api_platform.filter') - ->autowire(false) - ->autoconfigure(false) - ->public(false) - ; - } -} - -namespace App\Playground { - use Symfony\Component\HttpFoundation\Request; - - function request(): Request - { - return Request::create('/books.jsonld', 'GET'); - } -} - -namespace DoctrineMigrations { - use Doctrine\DBAL\Schema\Schema; - use Doctrine\Migrations\AbstractMigration; - - final class Migration extends AbstractMigration - { - public function up(Schema $schema): void - { - $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL)'); - } - - public function down(Schema $schema): void - { - $this->addSql('DROP TABLE book'); - } - } -} - -namespace App\Tests { - use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; - use App\Entity\Book; - use ApiPlatform\Playground\Test\TestGuideTrait; - - final class BookTest extends ApiTestCase - { - use TestGuideTrait; - - public function testAsAnonymousICanAccessTheDocumentation(): void - { - static::createClient()->request('GET', '/books.jsonld'); - - $this->assertResponseIsSuccessful(); - $this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection', 'jsonld'); - $this->assertJsonContains([ - 'hydra:search' => [ - '@type' => 'hydra:IriTemplate', - 'hydra:template' => '/books.jsonld{?title,title[]}', - 'hydra:variableRepresentation' => 'BasicRepresentation', - 'hydra:mapping' => [ - [ - '@type' => 'IriTemplateMapping', - 'variable' => 'title', - 'property' => 'title', - 'required' => false, - ], - [ - '@type' => 'IriTemplateMapping', - 'variable' => 'title[]', - 'property' => 'title', - 'required' => false, - ], - ], - ], - ]); - } - } -} diff --git a/docs/guides/use-doctrine-search-filter.php b/docs/guides/doctrine-search-filter.php similarity index 83% rename from docs/guides/use-doctrine-search-filter.php rename to docs/guides/doctrine-search-filter.php index d2a8c477b52..cc64965a11f 100644 --- a/docs/guides/use-doctrine-search-filter.php +++ b/docs/guides/doctrine-search-filter.php @@ -1,7 +1,7 @@ addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL)'); } + } +} - public function down(Schema $schema): void +namespace App\Fixtures { + + use App\Entity\Book; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\repository; + use function Zenstruck\Foundry\faker; + + final class BookFixtures extends Fixture + { + public function load(ObjectManager $manager): void { - $this->addSql('DROP TABLE book'); + $bookFactory = anonymous(Book::class); + if (repository(Book::class)->count()) { + return; + } + + $bookFactory->many(10)->create(fn() => + [ + 'title' => faker()->name(), + 'author' => faker()->firstName(), + ] + ); } } } diff --git a/docs/guides/hook-a-persistence-layer-with-a-processor.php b/docs/guides/hook-a-persistence-layer-with-a-processor.php index 903e3fa8bcd..836478db5e1 100644 --- a/docs/guides/hook-a-persistence-layer-with-a-processor.php +++ b/docs/guides/hook-a-persistence-layer-with-a-processor.php @@ -1,28 +1,32 @@ id; + file_put_contents(sprintf('book-%s.json', $id), json_encode($data)); return $data; } } + + final class BookProvider implements ProviderInterface { + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?Book + { + if ($operation instanceof CollectionInterface) { + throw new \RuntimeException('Not supported.'); + } + + $file = sprintf('book-%s.json', $uriVariables['id']); + if (!file_exists($file)) { + return null; + } + + $data = json_decode(file_get_contents($file)); + return new Book($data->id, $data->title); + } + } } +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + function request(): Request + { + return Request::create(uri: '/books', method: 'POST', server: ['CONTENT_TYPE' => 'application/ld+json'], content: json_encode(['id' => '1', 'title' => 'API Platform rocks.'])); + } +} diff --git a/docs/guides/secure-a-resource-with-custom-voters.php b/docs/guides/secure-a-resource-with-custom-voters.php deleted file mode 100644 index 88b10d100d3..00000000000 --- a/docs/guides/secure-a-resource-with-custom-voters.php +++ /dev/null @@ -1,78 +0,0 @@ -security = $security; - } - - protected function supports($attribute, $subject): bool - { - // It supports several attributes related to our Resource access control. - $supportsAttribute = in_array($attribute, ['BOOK_CREATE', 'BOOK_READ', 'BOOK_EDIT', 'BOOK_DELETE']); - $supportsSubject = $subject instanceof Book; - - return $supportsAttribute && $supportsSubject; - } - - /** - * @param string $attribute - * @param Book $subject - * @param TokenInterface $token - * @return bool - */ - protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool - { - /** ... check if the user is anonymous ... **/ - - switch ($attribute) { - case 'BOOK_CREATE': - if ( $this->security->isGranted(Role::ADMIN) ) { return true; } // only admins can create books - break; - case 'BOOK_READ': - /** ... other autorization rules ... **/ - } - - return false; - } - } -} - -namespace App\ApiResource { - use ApiPlatform\Metadata\ApiResource; - use ApiPlatform\Metadata\Delete; - use ApiPlatform\Metadata\Get; - use ApiPlatform\Metadata\GetCollection; - use ApiPlatform\Metadata\Post; - use ApiPlatform\Metadata\Put; - - #[ApiResource(security: "is_granted('ROLE_USER')")] - // We can then use the `is_granted` expression with our access control attributes: - #[Get(security: "is_granted('BOOK_READ', object)")] - #[Put(security: "is_granted('BOOK_EDIT', object)")] - #[Delete(security: "is_granted('BOOK_DELETE', object)")] - // On a collection, you need to [implement a Provider](provide-the-resource-state) to filter the collection manually. - #[GetCollection] - // `object` is empty uppon creation, we use `securityPostDenormalize` to get the denormalized object. - #[Post(securityPostDenormalize: "is_granted('BOOK_CREATE', object)")] - class Book - { - // ... - } -} diff --git a/docs/guides/use-doctrine-orm-filters.php b/docs/guides/use-doctrine-orm-filters.php deleted file mode 100644 index 418da97eed4..00000000000 --- a/docs/guides/use-doctrine-orm-filters.php +++ /dev/null @@ -1,262 +0,0 @@ -id; - } - } - - /* - * Each Book is related to a User, supposedly allowed to authenticate. - */ - #[ApiResource] - #[ORM\Entity] - /* - * This entity is restricted by current user: only current user books will be shown (cf. UserFilter). - */ - #[UserAware(userFieldName: 'user_id')] - class Book - { - #[ORM\Id, ORM\Column, ORM\GeneratedValue] - private ?int $id = null; - - #[ORM\ManyToOne(User::class)] - #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id')] - public User $user; - - #[ORM\Column] - public ?string $title = null; - - public function getId(): ?int - { - return $this->id; - } - } -} - -namespace App\Attribute { - use Attribute; - - /* - * The UserAware attribute restricts entities to the current user. - */ - #[Attribute(Attribute::TARGET_CLASS)] - final class UserAware - { - public ?string $userFieldName = null; - } -} - -namespace App\Filter { - use App\Attribute\UserAware; - use Doctrine\ORM\Mapping\ClassMetadata; - use Doctrine\ORM\Query\Filter\SQLFilter; - - /* - * The UserFilter adds a `AND user_id = :user_id` in the SQL query. - */ - final class UserFilter extends SQLFilter - { - public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string - { - /* - * The Doctrine filter is called for any query on any entity. - * Check if the current entity is "user aware" (marked with an attribute). - */ - $userAware = $targetEntity->getReflectionClass()->getAttributes(UserAware::class)[0] ?? null; - - $fieldName = $userAware?->getArguments()['userFieldName'] ?? null; - if ('' === $fieldName || is_null($fieldName)) { - return ''; - } - - try { - /* - * Don't worry, getParameter automatically escapes parameters - */ - $userId = $this->getParameter('id'); - } catch (\InvalidArgumentException $e) { - /* - * No user ID has been defined - */ - return ''; - } - - if (empty($fieldName) || empty($userId)) { - return ''; - } - - return sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId); - } - } -} - -namespace App\EventSubscriber { - use App\Entity\User; - use Doctrine\Persistence\ObjectManager; - use Symfony\Component\EventDispatcher\EventSubscriberInterface; - use Symfony\Component\HttpKernel\KernelEvents; - - /* - * Retrieve the current user id and set it as SQL query parameter. - */ - final class UserAwareEventSubscriber implements EventSubscriberInterface - { - public function __construct(private readonly ObjectManager $em) - { - } - - public static function getSubscribedEvents(): array - { - return [ - KernelEvents::REQUEST => 'onKernelRequest', - ]; - } - - public function onKernelRequest(): void - { - /* - * You should retrieve the current user using the TokenStorage service. - * In this example, the user is forced by username to keep this guide simple. - */ - $user = $this->em->getRepository(User::class)->findOneBy(['username' => 'jane.doe']); - $filter = $this->em->getFilters()->enable('user_filter'); - $filter->setParameter('id', $user->getId()); - } - } -} - - namespace App\DependencyInjection { - - use App\EventSubscriber\UserAwareEventSubscriber; - use App\Filter\UserFilter; - use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; - use function Symfony\Component\DependencyInjection\Loader\Configurator\service; - - function configure(ContainerConfigurator $configurator) { - $services = $configurator->services(); - $services->set(UserAwareEventSubscriber::class) - ->args([service('doctrine.orm.default_entity_manager')]) - ->tag('kernel.event_subscriber') - ; - $configurator->extension('doctrine', [ - 'orm' => [ - 'filters' => [ - 'user_filter' => [ - 'class' => UserFilter::class, - 'enabled' => true, - ], - ], - ], - ]); - - } - } - -namespace App\Playground { - use Symfony\Component\HttpFoundation\Request; - - function request(): Request - { - return Request::create('/books.jsonld', 'GET'); - } -} - -namespace DoctrineMigrations { - use Doctrine\DBAL\Schema\Schema; - use Doctrine\Migrations\AbstractMigration; - - final class Migration extends AbstractMigration - { - public function up(Schema $schema): void - { - $this->addSql('CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, username VARCHAR(255) NOT NULL)'); - $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, user_id INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES user (id))'); - } - } -} - -namespace App\Fixtures { - use App\Entity\Book; - use App\Entity\User; - use Doctrine\Bundle\FixturesBundle\Fixture; - use Doctrine\Persistence\ObjectManager; - use function Zenstruck\Foundry\anonymous; - - final class BookFixtures extends Fixture - { - public function load(ObjectManager $manager): void - { - $userFactory = anonymous(User::class); - $johnDoe = $userFactory->create(['username' => 'john.doe']); - $janeDoe = $userFactory->create(['username' => 'jane.doe']); - - $bookFactory = anonymous(Book::class); - $bookFactory->many(10)->create([ - 'title' => 'title', - 'user' => $johnDoe - ]); - $bookFactory->many(10)->create([ - 'title' => 'title', - 'user' => $janeDoe - ]); - } - } -} - -namespace App\Tests { - use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; - use App\Entity\Book; - use ApiPlatform\Playground\Test\TestGuideTrait; - - final class BookTest extends ApiTestCase - { - use TestGuideTrait; - - public function testAsAnonymousICanAccessTheDocumentation(): void - { - $response = static::createClient()->request('GET', '/books.jsonld'); - - $this->assertResponseIsSuccessful(); - $this->assertMatchesResourceCollectionJsonSchema(Book::class, '_api_/books{._format}_get_collection', 'jsonld'); - $this->assertNotSame(0, $response->toArray(false)['hydra:totalItems'], 'The collection is empty.'); - $this->assertJsonContains([ - 'hydra:totalItems' => 10, - ]); - } - } -} diff --git a/docs/guides/use-validation-groups.php b/docs/guides/use-validation-groups.php deleted file mode 100644 index 8bbb2a051da..00000000000 --- a/docs/guides/use-validation-groups.php +++ /dev/null @@ -1,67 +0,0 @@ - ['a', 'b']], - operations: [ - // When configured on a specific operation the configuration takes precedence over the one declared on the ApiResource. - // You can use a [callable](https://www.php.net/manual/en/language.types.callable.php) instead of strings. - new Get(validationContext: ['groups' => [Book::class, 'validationGroups']]), - new GetCollection(), - // You sometimes want to specify in which order groups must be tested against. On the Post operation, we use a Symfony service - // to use a [group sequence](http://symfony.com/doc/current/validation/sequence_provider.html). - new Post(validationContext: ['groups' => MySequencedGroup::class]) - ] - )] - final class Book - { - #[Assert\NotBlank(groups: ['a'])] - public string $name; - - #[Assert\NotNull(groups: ['b'])] - public string $author; - - /** - * Return dynamic validation groups. - * - * @param self $book Contains the instance of Book to validate. - * - * @return string[] - */ - public static function validationGroups(self $book) - { - return ['a']; - } - } -} - -namespace App\Validator { - use Symfony\Component\Validator\Constraints\GroupSequence; - - final class MySequencedGroup - { - public function __invoke(): GroupSequence - { - return new GroupSequence(['a', 'b']); // now, no matter which is first in the class declaration, it will be tested in this order. - } - } -} - -// To go further, read the guide on [Validating data on a Delete operation](./validate-data-on-a-delete-operation) diff --git a/docs/src/Kernel.php b/docs/src/Kernel.php index f190515f4c3..f11e2472d0d 100644 --- a/docs/src/Kernel.php +++ b/docs/src/Kernel.php @@ -43,6 +43,7 @@ class Kernel extends BaseKernel { use MicroKernelTrait; private $declaredClasses = []; + private string $guide; public function __construct(string $environment, bool $debug, string $guide = null) { @@ -108,12 +109,9 @@ private function configureContainer(ContainerConfigurator $container, LoaderInte $builder->addCompilerPass(new AttributeFilterPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 101); $builder->addCompilerPass(new FilterPass()); - $container->parameters()->set( - 'database_url', - sprintf('sqlite:///%s/%s', $this->getCacheDir(), 'data.db') - ); + $container->parameters()->set('guide', $this->guide); - $services->set('doctrine.orm.default_metadata_driver', StaticMappingDriver::class)->args(['$classes' => $resources]); + $services->set('doctrine.orm.default_metadata_driver', StaticMappingDriver::class)->args(['$classes' => $entities]); if (\function_exists('App\DependencyInjection\configure')) { configure($container); From 6786a060ff74ab917632e9038194ae1e879bb6e5 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 15 Sep 2023 11:13:55 +0200 Subject: [PATCH 4/7] temp --- docs/config/packages/framework.yaml | 2 + .../create-a-custom-doctrine-filter.php | 2 +- docs/guides/custom-pagination.php | 2 +- .../delete-operation-with-validation.php | 120 ++++++++++++++++++ docs/guides/doctrine-entity-as-resource.php | 9 +- docs/guides/doctrine-search-filter.php | 11 +- docs/guides/extend-openapi-documentation.php | 2 +- ...rn-the-iri-of-your-resources-relations.php | 3 +- docs/guides/serialization-context.php | 0 docs/guides/test-your-api.php | 3 + .../validate-data-on-a-delete-operation.php | 91 ------------- docs/guides/validate-incoming-data.php | 36 +++++- 12 files changed, 171 insertions(+), 110 deletions(-) create mode 100644 docs/guides/delete-operation-with-validation.php create mode 100644 docs/guides/serialization-context.php delete mode 100644 docs/guides/validate-data-on-a-delete-operation.php diff --git a/docs/config/packages/framework.yaml b/docs/config/packages/framework.yaml index fc2237ad0c0..582a3b5972c 100644 --- a/docs/config/packages/framework.yaml +++ b/docs/config/packages/framework.yaml @@ -5,6 +5,8 @@ api_platform: formats: jsonld: ['application/ld+json'] json: ['application/json'] + docs_formats: + jsonopenapi: ['application/vnd.openapi+json'] event_listeners_backward_compatibility_layer: false keep_legacy_inflector: false defaults: diff --git a/docs/guides/create-a-custom-doctrine-filter.php b/docs/guides/create-a-custom-doctrine-filter.php index 81f6ebb353c..99e6dd7fc6e 100644 --- a/docs/guides/create-a-custom-doctrine-filter.php +++ b/docs/guides/create-a-custom-doctrine-filter.php @@ -4,7 +4,7 @@ // name: Create a Custom Doctrine Filter // executable: true // position: 10 -// tags: doctrine +// tags: doctrine, expert // --- // Custom filters can be written by implementing the `ApiPlatform\Api\FilterInterface` interface. diff --git a/docs/guides/custom-pagination.php b/docs/guides/custom-pagination.php index 386f8557b30..de2c7736acb 100644 --- a/docs/guides/custom-pagination.php +++ b/docs/guides/custom-pagination.php @@ -4,7 +4,7 @@ // name: Custom pagination // executable: true // position: 12 -// tags: state +// tags: expert // --- // In case you're using a custom collection (through a Provider), make sure you return the `Paginator` object to get the full hydra response with `hydra:view` (which contains information about first, last, next and previous page). diff --git a/docs/guides/delete-operation-with-validation.php b/docs/guides/delete-operation-with-validation.php new file mode 100644 index 00000000000..00154848dd6 --- /dev/null +++ b/docs/guides/delete-operation-with-validation.php @@ -0,0 +1,120 @@ +context->buildViolation($constraint->message)->addViolation(); + } + } +} + + +namespace App\Entity { + + use ApiPlatform\Metadata\Delete; + use App\Validator\AssertCanDelete; + use Doctrine\ORM\Mapping as ORM; + + #[ORM\Entity] + #[Delete( + // By default, validation is not triggered on a DELETE operation, let's activate it. + validate: true, + // Just as with serialization we can add [validation groups](/docs/core/validation/#using-validation-groups). + validationContext: ['groups' => ['deleteValidation']] + )] + // Here we use the previously created constraint on the class directly. + #[AssertCanDelete(groups: ['deleteValidation'])] + class Book + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\Column] + public string $title = ''; + + public function getId() + { + return $this->id; + } + } +} + +namespace App\Playground { + + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create(uri: '/books/1', method: 'DELETE', server: ['CONTENT_TYPE' => 'application/ld+json']); + } +} + +namespace App\Fixtures { + use App\Entity\Book; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\repository; + use function Zenstruck\Foundry\faker; + + final class BookFixtures extends Fixture + { + public function load(ObjectManager $manager): void + { + $bookFactory = anonymous(Book::class); + if (repository(Book::class)->count()) { + return; + } + + $bookFactory->many(10)->create(fn() => + [ + 'title' => faker()->name(), + ] + ); + } + } +} + +namespace DoctrineMigrations { + + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL)'); + } + } +} + diff --git a/docs/guides/doctrine-entity-as-resource.php b/docs/guides/doctrine-entity-as-resource.php index d400695787a..67d01ba2804 100644 --- a/docs/guides/doctrine-entity-as-resource.php +++ b/docs/guides/doctrine-entity-as-resource.php @@ -69,6 +69,7 @@ public function up(Schema $schema): void use Doctrine\Persistence\ObjectManager; use function Zenstruck\Foundry\anonymous; use function Zenstruck\Foundry\repository; + use function Zenstruck\Foundry\faker; final class BookFixtures extends Fixture { @@ -79,9 +80,11 @@ public function load(ObjectManager $manager): void return; } - $bookFactory->many(10)->create([ - 'title' => 'title' - ]); + $bookFactory->many(10)->create(fn() => + [ + 'title' => faker()->name(), + ] + ); } } } diff --git a/docs/guides/doctrine-search-filter.php b/docs/guides/doctrine-search-filter.php index cc64965a11f..77b598080d3 100644 --- a/docs/guides/doctrine-search-filter.php +++ b/docs/guides/doctrine-search-filter.php @@ -1,9 +1,10 @@ ['deleteValidation']], processor: BookRemoveProcessor::class)] - // Here we use the previously created constraint on the class directly. - #[AssertCanDelete(groups: ['deleteValidation'])] - class Book - { - #[ORM\Id, ORM\Column, ORM\GeneratedValue] - private ?int $id = null; - - #[ORM\Column] - public string $title = ''; - } -} - -// Then, we will trigger the validation within a processor. -// the removal into the Database. -namespace App\State { - use ApiPlatform\Doctrine\Common\State\RemoveProcessor as DoctrineRemoveProcessor; - use ApiPlatform\Metadata\Operation; - use ApiPlatform\State\ProcessorInterface; - use ApiPlatform\Validator\ValidatorInterface; - use Symfony\Component\DependencyInjection\Attribute\Autowire; - - class BookRemoveProcessor implements ProcessorInterface - { - public function __construct( - // We're decorating API Platform's Doctrine processor to persist the removal. - #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] - private DoctrineRemoveProcessor $doctrineProcessor, - private ValidatorInterface $validator, - ) { - } - - public function process($data, Operation $operation, array $uriVariables = [], array $context = []) - { - // First step is to trigger Symfony's validation. - $this->validator->validate($data, ['groups' => ['deleteValidation']]); - // Then we persist the data. - $this->doctrineProcessor->process($data, $operation, $uriVariables, $context); - } - } -} - -// TODO move this to reference somehow -// This operation uses a Callable as group so that you can vary the Validation according to your dataset -// new Get(validationContext: ['groups' =>]) -// ## Sequential Validation Groups -// If you need to specify the order in which your validation groups must be tested against, you can use a [group sequence](http://symfony.com/doc/current/validation/sequence_provider.html). diff --git a/docs/guides/validate-incoming-data.php b/docs/guides/validate-incoming-data.php index 62d90ff5a23..38afe30df8e 100644 --- a/docs/guides/validate-incoming-data.php +++ b/docs/guides/validate-incoming-data.php @@ -1,8 +1,9 @@ id; + } } } @@ -83,6 +88,23 @@ public function validate($value, Constraint $constraint): void } } +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create( + uri: '/products', + method: 'POST', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json' + ], + content: '{"name": "test", "properties": {"description": "Test product"}}' + ); + } +} + //If the data submitted by the client is invalid, the HTTP status code will be set to 422 Unprocessable Entity and the response's body will contain the list of violations serialized in a format compliant with the requested one. For instance, a validation error will look like the following if the requested format is JSON-LD (the default): // ```json // { From e304c521d9fbb33d9506a95eb3ea893fc9cec661 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 15 Sep 2023 15:54:48 +0200 Subject: [PATCH 5/7] temp --- .../delete-operation-with-validation.php | 19 +++++-- docs/guides/handle-links.php | 0 docs/guides/secure-a-resource-access.php | 57 ------------------- docs/guides/serialization-context.php | 1 + docs/guides/subresource.php | 0 docs/guides/validate-incoming-data.php | 15 ++++- 6 files changed, 28 insertions(+), 64 deletions(-) create mode 100644 docs/guides/handle-links.php delete mode 100644 docs/guides/secure-a-resource-access.php create mode 100644 docs/guides/subresource.php diff --git a/docs/guides/delete-operation-with-validation.php b/docs/guides/delete-operation-with-validation.php index 00154848dd6..90bad3237e1 100644 --- a/docs/guides/delete-operation-with-validation.php +++ b/docs/guides/delete-operation-with-validation.php @@ -13,11 +13,16 @@ use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; - #[\Attribute(\Attribute::TARGET_CLASS)] + #[\Attribute] class AssertCanDelete extends Constraint { public string $message = 'For whatever reason we denied removeal of this data.'; public string $mode = 'strict'; + + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } } } @@ -31,8 +36,7 @@ class AssertCanDeleteValidator extends ConstraintValidator { public function validate(mixed $value, Constraint $constraint) { - dump($value); - // $this->context->buildViolation($constraint->message)->addViolation(); + $this->context->buildViolation($constraint->message)->addViolation(); } } } @@ -43,13 +47,15 @@ public function validate(mixed $value, Constraint $constraint) use ApiPlatform\Metadata\Delete; use App\Validator\AssertCanDelete; use Doctrine\ORM\Mapping as ORM; + use ApiPlatform\Symfony\Validator\Exception\ValidationException; #[ORM\Entity] #[Delete( // By default, validation is not triggered on a DELETE operation, let's activate it. validate: true, // Just as with serialization we can add [validation groups](/docs/core/validation/#using-validation-groups). - validationContext: ['groups' => ['deleteValidation']] + validationContext: ['groups' => ['deleteValidation']], + exceptionToStatus: [ValidationException::class => 403] )] // Here we use the previously created constraint on the class directly. #[AssertCanDelete(groups: ['deleteValidation'])] @@ -79,6 +85,7 @@ function request(): Request } namespace App\Fixtures { + use App\Entity\Book; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; @@ -95,7 +102,8 @@ public function load(ObjectManager $manager): void return; } - $bookFactory->many(10)->create(fn() => + $bookFactory->many(10)->create( + fn () => [ 'title' => faker()->name(), ] @@ -117,4 +125,3 @@ public function up(Schema $schema): void } } } - diff --git a/docs/guides/handle-links.php b/docs/guides/handle-links.php new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/guides/secure-a-resource-access.php b/docs/guides/secure-a-resource-access.php deleted file mode 100644 index 6b7dc2f540b..00000000000 --- a/docs/guides/secure-a-resource-access.php +++ /dev/null @@ -1,57 +0,0 @@ -context->buildViolation($constraint->message)->addViolation(); } } } } +namespace DoctrineMigrations { + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE product (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, properties CLOB NOT NULL)'); + } + } +} + namespace App\Playground { use Symfony\Component\HttpFoundation\Request; From 3a18c6b446203ca917dac6e732fcd0089cea2aeb Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 18 Sep 2023 18:34:07 +0200 Subject: [PATCH 6/7] end --- docs/guides/doctrine-entity-as-resource.php | 5 +- docs/guides/extend-openapi-documentation.php | 89 +++++------ docs/guides/handle-links.php | 133 +++++++++++++++++ ...rn-the-iri-of-your-resources-relations.php | 1 + docs/guides/serialization-context.php | 1 - docs/guides/subresource.php | 138 ++++++++++++++++++ docs/guides/test-your-api.php | 72 +++++++++ docs/guides/validate-incoming-data.php | 60 +++++--- 8 files changed, 435 insertions(+), 64 deletions(-) delete mode 100644 docs/guides/serialization-context.php diff --git a/docs/guides/doctrine-entity-as-resource.php b/docs/guides/doctrine-entity-as-resource.php index 67d01ba2804..d48abbf0fb9 100644 --- a/docs/guides/doctrine-entity-as-resource.php +++ b/docs/guides/doctrine-entity-as-resource.php @@ -7,8 +7,9 @@ // executable: true // --- +// # API Resource on a Doctrine Entity. +// // API Platform is compatible with [Doctrine ORM](https://www.doctrine-project.org), all we need is to declare an -// API Resource on a Doctrine Entity. namespace App\Entity { use ApiPlatform\Metadata\ApiResource; @@ -16,7 +17,7 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use Doctrine\ORM\Mapping as ORM; - // When an ApiResource is declared on an ORM\Entity we have access to [Doctrine filters](https://api-platform.com/docs/core/filters/). + // When an ApiResource is declared on an `\ORM\Entity` we have access to [Doctrine filters](https://api-platform.com/docs/core/filters/). #[ApiResource] #[ApiFilter(OrderFilter::class)] #[ORM\Entity] diff --git a/docs/guides/extend-openapi-documentation.php b/docs/guides/extend-openapi-documentation.php index 397f19bc9fa..8b55ed97a90 100644 --- a/docs/guides/extend-openapi-documentation.php +++ b/docs/guides/extend-openapi-documentation.php @@ -4,57 +4,60 @@ // name: Extend OpenAPI Documentation // position: 11 // executable: true +// tags: openapi, expert // --- -namespace App\OpenApi { - use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; - use ApiPlatform\OpenApi\OpenApi; - use ApiPlatform\OpenApi\Model; - - final class OpenApiFactory implements OpenApiFactoryInterface - { - private $decorated; - - public function __construct(OpenApiFactoryInterface $decorated) - { - $this->decorated = $decorated; - } - - public function __invoke(array $context = []): OpenApi - { - $openApi = $this->decorated->__invoke($context); - $pathItem = $openApi->getPaths()->getPath('/api/grumpy_pizzas/{id}'); - $operation = $pathItem->getGet(); - - $openApi->getPaths()->addPath('/api/grumpy_pizzas/{id}', $pathItem->withGet( - $operation->withParameters(array_merge( - $operation->getParameters(), - [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] - )) - )); - - $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); - $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); - $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); - - return $openApi; - } +namespace App\ApiResource { + use ApiPlatform\Metadata\Post; + use ApiPlatform\OpenApi\Model\Operation; + use ApiPlatform\OpenApi\Model\Response; + use ApiPlatform\OpenApi\Model\RequestBody; + + #[Post( + openapi: new Operation( + responses: [ + '200' => new Response(description: 'Ok') + ], + summary: 'Add a book to the library.', + description: 'My awesome operation', + requestBody: new RequestBody( + content: new \ArrayObject( + [ + 'application/ld+json' => [ + 'schema' => [ + 'properties' => [ + 'id' => ['type' => 'integer', 'required' => true, 'description' => 'id'] + ] + ], + 'example' => [ + 'id' => 12345 + ] + ] + ] + ) + ) + ) + )] + class Book { } } -namespace App\Configurator { - use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; - - function configure(ContainerConfigurator $configurator) { - $services = $configurator->services(); - $services->set(App\OpenApi\OpenApiFactory::class)->decorate('api_platform.openapi.factory'); - }; -} - namespace App\Tests { use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use ApiPlatform\Playground\Test\TestGuideTrait; - class OpenApiTestCase extends ApiTestCase { + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + public function testBookDoesNotExists(): void + { + $response = static::createClient()->request('GET', '/docs', options: ['headers' => ['accept' => 'application/vnd.openapi+json']]); + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + 'paths' => ['/books' => ['post' => ['summary' => 'Add a book to the library.', 'description' => 'My awesome operation']]] + ]); + } } } + diff --git a/docs/guides/handle-links.php b/docs/guides/handle-links.php index e69de29bb2d..7310cef87db 100644 --- a/docs/guides/handle-links.php +++ b/docs/guides/handle-links.php @@ -0,0 +1,133 @@ + new Link(fromClass: Company::class, toProperty: 'company')], + // The `handleLinks` option takes a service name implementing the [LinksHandlerInterface](/docs/reference/Doctrine/Orm/State/LinksHandlerInterface) or a callable. + stateOptions: new Options(handleLinks: [Employee::class, 'handleLinks']) + )] + #[Get('/company/{companyId}/employees/{id}')] + #[ORM\Entity] + class Employee + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + public ?int $id; + + #[ORM\Column] + public string $name; + + #[ORM\ManyToOne(targetEntity: Company::class)] + public ?Company $company; + + public function getId() + { + return $this->id; + } + + // This function gets called in our generic ItemProvider or CollectionProvider, the idea is to create the WHERE clause + // to get the correct data. You can also perform joins or whatever SQL clause you need: + static public function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context) + { + $queryBuilder + ->andWhere($queryBuilder->getRootAliases()[0].'.company = :companyId') + ->setParameter('companyId', $uriVariables['companyId']); + } + } + + #[ORM\Entity] + #[ApiResource] + class Company + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + public ?int $id; + + #[ORM\Column] + public string $name; + } +} + +namespace App\Playground { + + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + // Persistence is automatic, you can try to create or read data: + return Request::create('/company/1/employees', 'GET'); + } +} + +namespace DoctrineMigrations { + + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE company (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL);'); + $this->addSql('CREATE TABLE employee (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, company_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, CONSTRAINT FK_COMPANY FOREIGN KEY (company_id) REFERENCES company (id) NOT DEFERRABLE INITIALLY IMMEDIATE); +'); + $this->addSql('CREATE INDEX FK_COMPANY ON employee (company_id)'); + + } + } +} + +namespace App\Fixtures { + + use App\Entity\Employee; + use App\Entity\Company; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\repository; + use function Zenstruck\Foundry\faker; + + final class BookFixtures extends Fixture + { + public function load(ObjectManager $manager): void + { + $companyFactory = anonymous(Company::class); + $companyRepository = repository(Company::class); + if ($companyRepository->count()) { + return; + } + + $companyFactory->many(1)->create(fn() => [ + 'name' => faker()->company() + ]); + + $employeeFactory = anonymous(Employee::class); + $employeeFactory->many(10)->create(fn() => + [ + 'name' => faker()->name(), + 'company' => $companyRepository->first() + ] + ); + } + } +} diff --git a/docs/guides/return-the-iri-of-your-resources-relations.php b/docs/guides/return-the-iri-of-your-resources-relations.php index 03a63fa2798..e521dcdfa8e 100644 --- a/docs/guides/return-the-iri-of-your-resources-relations.php +++ b/docs/guides/return-the-iri-of-your-resources-relations.php @@ -4,6 +4,7 @@ // name: How to return an IRI instead of an object for your resources relations ? // executable: true // tags: serialization, expert +// position: 11 // --- // This guide shows you how to expose the IRI of a related (sub)ressource relation instead of an object. diff --git a/docs/guides/serialization-context.php b/docs/guides/serialization-context.php deleted file mode 100644 index 1bf4d96c0b2..00000000000 --- a/docs/guides/serialization-context.php +++ /dev/null @@ -1 +0,0 @@ -+ collectdenorm diff --git a/docs/guides/subresource.php b/docs/guides/subresource.php index e69de29bb2d..a62ff857ef7 100644 --- a/docs/guides/subresource.php +++ b/docs/guides/subresource.php @@ -0,0 +1,138 @@ + new Link(fromClass: Company::class, toProperty: 'company'), + 'id' => new Link(fromClass: Employee::class), + ], + operations: [ new Get() ] + )] + #[ApiResource( + uriTemplate: '/companies/{companyId}/employees', + uriVariables: [ + 'companyId' => new Link(fromClass: Company::class, toProperty: 'company'), + ], + operations: [ new GetCollection() ] + )] + #[ORM\Entity] + class Employee + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + public ?int $id; + + #[ORM\Column] + public string $name; + + #[ORM\ManyToOne(targetEntity: Company::class)] + public ?Company $company; + + public function getId() + { + return $this->id; + } + } + + #[ORM\Entity] + #[ApiResource] + class Company + { + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + public ?int $id; + + #[ORM\Column] + public string $name; + } +} + +namespace App\Playground { + + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + // Persistence is automatic, you can try to create or read data: + return Request::create('/company/1/employees', 'GET'); + } +} + +namespace DoctrineMigrations { + + use Doctrine\DBAL\Schema\Schema; + use Doctrine\Migrations\AbstractMigration; + + final class Migration extends AbstractMigration + { + public function up(Schema $schema): void + { + $this->addSql('CREATE TABLE company (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL);'); + $this->addSql('CREATE TABLE employee (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, company_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, CONSTRAINT FK_COMPANY FOREIGN KEY (company_id) REFERENCES company (id) NOT DEFERRABLE INITIALLY IMMEDIATE); +'); + $this->addSql('CREATE INDEX FK_COMPANY ON employee (company_id)'); + + } + } +} + +namespace App\Fixtures { + + use App\Entity\Employee; + use App\Entity\Company; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\repository; + use function Zenstruck\Foundry\faker; + + final class BookFixtures extends Fixture + { + public function load(ObjectManager $manager): void + { + $companyFactory = anonymous(Company::class); + $companyRepository = repository(Company::class); + if ($companyRepository->count()) { + return; + } + + $companyFactory->many(1)->create(fn() => [ + 'name' => faker()->company() + ]); + + $employeeFactory = anonymous(Employee::class); + $employeeFactory->many(10)->create(fn() => + [ + 'name' => faker()->name(), + 'company' => $companyRepository->first() + ] + ); + } + } +} diff --git a/docs/guides/test-your-api.php b/docs/guides/test-your-api.php index 50cce95b3fc..4bf85c1f87a 100644 --- a/docs/guides/test-your-api.php +++ b/docs/guides/test-your-api.php @@ -1,3 +1,75 @@ request(method: 'GET', url: '/books/1'); + $this->assertResponseStatusCodeSame(404); + // Our API uses the JSON Problem specification on every thrown exception. + $this->assertJsonContains([ + 'detail' => 'Not Found', + ]); + } + + public function testGetCollection(): void + { + $response = static::createClient()->request(method: 'GET', url: '/books'); + + // We provide assertions based on your resource's JSON Schema to save time asserting that the data + // matches an expected format, for example here with a collection. + $this->assertMatchesResourceCollectionJsonSchema(Book::class); + // PHPUnit default assertios are also available. + $this->assertCount(0, $response->toArray()['hydra:member']); + } + } +} + +namespace App\ApiResource { + use ApiPlatform\Metadata\ApiResource; + use ApiPlatform\Metadata\CollectionOperationInterface; + + #[ApiResource(provider: [Book::class, 'provide'])] + class Book + { + public string $id; + + static public function provide($operation) { + return $operation instanceof CollectionOperationInterface ? [] : null; + } + } +} + +// # Test your API +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create( + uri: '/books/1', + method: 'GET', + server: [ + 'HTTP_ACCEPT' => 'application/ld+json' + ] + ); + } +} diff --git a/docs/guides/validate-incoming-data.php b/docs/guides/validate-incoming-data.php index 57d8e265f86..05a12db041b 100644 --- a/docs/guides/validate-incoming-data.php +++ b/docs/guides/validate-incoming-data.php @@ -118,21 +118,45 @@ function request(): Request } } -//If the data submitted by the client is invalid, the HTTP status code will be set to 422 Unprocessable Entity and the response's body will contain the list of violations serialized in a format compliant with the requested one. For instance, a validation error will look like the following if the requested format is JSON-LD (the default): -// ```json -// { -// "@context": "/contexts/ConstraintViolationList", -// "@type": "ConstraintViolationList", -// "hydra:title": "An error occurred", -// "hydra:description": "properties: The product must have the minimal properties required (\"description\", \"price\")", -// "violations": [ -// { -// "propertyPath": "properties", -// "message": "The product must have the minimal properties required (\"description\", \"price\")" -// } -// ] -// } -// ``` -// -// Take a look at the [Errors Handling guide](errors.md) to learn how API Platform converts PHP exceptions like validation -// errors to HTTP errors. +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\Entity\Book; + use ApiPlatform\Playground\Test\TestGuideTrait; + + final class BookTest extends ApiTestCase + { + use TestGuideTrait; + + public function testValidation(): void + { + $response = static::createClient()->request(method: 'POST', url: '/products', options: [ + 'json' => ['name' => 'test', 'properties' => ['description' => 'foo']], + 'headers' => ['content-type' => 'application/ld+json'] + ]); + + //If the data submitted by the client is invalid, the HTTP status code will be set to 422 Unprocessable Entity and the response's body will contain the list of violations serialized in a format compliant with the requested one. For instance, a validation error will look like the following if the requested format is JSON-LD (the default): + // ```json + // { + // "@context": "/contexts/ConstraintViolationList", + // "@type": "ConstraintViolationList", + // "hydra:title": "An error occurred", + // "hydra:description": "properties: The product must have the minimal properties required (\"description\", \"price\")", + // "violations": [ + // { + // "propertyPath": "properties", + // "message": "The product must have the minimal properties required (\"description\", \"price\")" + // } + // ] + // } + // ``` + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'hydra:description' => 'properties: The product must have the minimal properties required ("description", "price")', + 'title' => 'An error occurred', + 'violations' => [ + ['propertyPath' => 'properties', 'message' => 'The product must have the minimal properties required ("description", "price")'] + ], + ]); + } + } +} From 4932e32d3668ff158a7ec2f7b7d29d355102b965 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 18 Sep 2023 18:47:05 +0200 Subject: [PATCH 7/7] php-cs-fixer --- docs/.gitignore | 1 + docs/.php-cs-fixer.dist.php | 92 +++++++++++++++++++ .../create-a-custom-doctrine-filter.php | 6 +- docs/guides/custom-pagination.php | 8 +- docs/guides/declare-a-resource.php | 9 +- .../delete-operation-with-validation.php | 22 ++--- docs/guides/doctrine-entity-as-resource.php | 24 +++-- docs/guides/doctrine-search-filter.php | 23 ++--- docs/guides/extend-openapi-documentation.php | 22 ++--- docs/guides/handle-links.php | 29 +++--- ...k-a-persistence-layer-with-a-processor.php | 10 +- docs/guides/how-to.php | 25 +++-- docs/guides/provide-the-resource-state.php | 6 +- ...rn-the-iri-of-your-resources-relations.php | 30 +++--- docs/guides/subresource.php | 38 ++++---- docs/guides/test-your-api.php | 11 ++- docs/guides/validate-incoming-data.php | 29 +++--- 17 files changed, 234 insertions(+), 151 deletions(-) create mode 100644 docs/.php-cs-fixer.dist.php diff --git a/docs/.gitignore b/docs/.gitignore index 64cb047953f..cc116279bae 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -13,3 +13,4 @@ node_modules composer.lock vendor var +.php-cs-fixer.cache diff --git a/docs/.php-cs-fixer.dist.php b/docs/.php-cs-fixer.dist.php new file mode 100644 index 00000000000..2e03588600f --- /dev/null +++ b/docs/.php-cs-fixer.dist.php @@ -0,0 +1,92 @@ +in(__DIR__.'/guides'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@DoctrineAnnotation' => true, + '@PHP71Migration' => true, + '@PHP71Migration:risky' => true, + '@PHPUnit60Migration:risky' => true, + '@Symfony' => true, + '@Symfony:risky' => true, + 'align_multiline_comment' => [ + 'comment_type' => 'phpdocs_like', + ], + 'array_indentation' => true, + 'compact_nullable_typehint' => true, + 'doctrine_annotation_array_assignment' => [ + 'operator' => '=', + ], + 'doctrine_annotation_spaces' => [ + 'after_array_assignments_equals' => false, + 'before_array_assignments_equals' => false, + ], + 'explicit_indirect_variable' => true, + 'fully_qualified_strict_types' => true, + 'logical_operators' => true, + 'multiline_comment_opening_closing' => true, + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + 'no_alternative_syntax' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'break', + 'continue', + 'curly_brace_block', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'throw', + 'use', + ], + ], + 'no_superfluous_elseif' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => false, + ], + 'no_unset_cast' => true, + 'no_unset_on_property' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'ordered_imports' => [ + 'imports_order' => [ + 'class', + 'function', + 'const', + ], + 'sort_algorithm' => 'alpha', + ], + 'php_unit_method_casing' => [ + 'case' => 'camel_case', + ], + 'php_unit_set_up_tear_down_visibility' => true, + 'php_unit_test_annotation' => [ + 'style' => 'prefix', + ], + 'phpdoc_add_missing_param_annotation' => [ + 'only_untyped' => true, + ], + 'phpdoc_no_alias_tag' => true, + 'phpdoc_order' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_var_annotation_correct_order' => true, + 'return_assignment' => true, + 'strict_param' => true, + 'blank_line_after_opening_tag' => false, + 'declare_strict_types' => false, + 'visibility_required' => [ + 'elements' => [ + 'const', + 'method', + 'property', + ], + ], + ]) + ->setFinder($finder); diff --git a/docs/guides/create-a-custom-doctrine-filter.php b/docs/guides/create-a-custom-doctrine-filter.php index 99e6dd7fc6e..f06c4d8bfce 100644 --- a/docs/guides/create-a-custom-doctrine-filter.php +++ b/docs/guides/create-a-custom-doctrine-filter.php @@ -39,8 +39,8 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB * Otherwise this filter is applied to order and page as well. */ if ( - !$this->isPropertyEnabled($property, $resourceClass) || - !$this->isPropertyMapped($property, $resourceClass) + !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass) ) { return; } @@ -138,9 +138,9 @@ public function up(Schema $schema): void } namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\Entity\Book; - use ApiPlatform\Playground\Test\TestGuideTrait; final class BookTest extends ApiTestCase { diff --git a/docs/guides/custom-pagination.php b/docs/guides/custom-pagination.php index de2c7736acb..85187f5c0c8 100644 --- a/docs/guides/custom-pagination.php +++ b/docs/guides/custom-pagination.php @@ -12,7 +12,6 @@ // The following example shows how to handle it using a custom Provider. You will need to use the Doctrine Paginator and pass it to the API Platform Paginator. namespace App\Entity { - use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use App\Repository\BookRepository; @@ -22,7 +21,7 @@ /* Use custom Provider on operation to retrieve the custom collection */ #[ApiResource( operations: [ - new GetCollection(provider: BooksListProvider::class) + new GetCollection(provider: BooksListProvider::class), ] )] #[ORM\Entity(repositoryClass: BookRepository::class)] @@ -71,7 +70,6 @@ public function getPublishedBooks(int $page = 1, int $itemsPerPage = 30): Doctri } namespace App\State { - use ApiPlatform\Doctrine\Orm\Paginator; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Pagination\Pagination; @@ -105,7 +103,6 @@ function request(): Request } namespace DoctrineMigrations { - use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -123,6 +120,7 @@ public function up(Schema $schema): void use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; use Zenstruck\Foundry\AnonymousFactory; + use function Zenstruck\Foundry\faker; final class BookFixtures extends Fixture @@ -148,9 +146,9 @@ public function load(ObjectManager $manager): void } namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\Entity\Book; - use ApiPlatform\Playground\Test\TestGuideTrait; final class BookTest extends ApiTestCase { diff --git a/docs/guides/declare-a-resource.php b/docs/guides/declare-a-resource.php index 02a26c15d7d..f7b49c0a54a 100644 --- a/docs/guides/declare-a-resource.php +++ b/docs/guides/declare-a-resource.php @@ -9,15 +9,16 @@ // # Declare a Resource // This class represents an API resource + namespace App\ApiResource { // The `#[ApiResource]` attribute registers this class as an HTTP resource. use ApiPlatform\Metadata\ApiResource; // These are the list of HTTP operations we use to declare a "CRUD" (Create, Read, Update, Delete). + use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; - use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Patch; - use ApiPlatform\Metadata\Delete; + use ApiPlatform\Metadata\Post; use ApiPlatform\Validator\Exception\ValidationException; // Each resource has its set of Operations. @@ -34,7 +35,7 @@ ], // This is a configuration that is shared accross every operations. More details are available at [ApiResource::exceptionToStatus](/reference/Metadata/ApiResource#exceptionToStatus). exceptionToStatus: [ - ValidationException::class => 422 + ValidationException::class => 422, ] )] // If a property named `id` is found it is the property used in your URI template @@ -46,6 +47,7 @@ class Book } // Check our next guide to [provide the resource state](./provide-the-resource-state). + namespace App\Playground { use Symfony\Component\HttpFoundation\Request; @@ -54,4 +56,3 @@ function request(): Request return Request::create('/docs', 'GET'); } } - diff --git a/docs/guides/delete-operation-with-validation.php b/docs/guides/delete-operation-with-validation.php index 90bad3237e1..fdc1b3c755e 100644 --- a/docs/guides/delete-operation-with-validation.php +++ b/docs/guides/delete-operation-with-validation.php @@ -8,10 +8,9 @@ // --- // Let's add a [custom Constraint](https://symfony.com/doc/current/validation/custom_constraint.html). -namespace App\Validator { +namespace App\Validator { use Symfony\Component\Validator\Constraint; - use Symfony\Component\Validator\ConstraintValidator; #[\Attribute] class AssertCanDelete extends Constraint @@ -27,27 +26,25 @@ public function getTargets(): string } // And a custom validator following Symfony's naming conventions. -namespace App\Validator { - use Symfony\Component\Validator\ConstraintValidator; +namespace App\Validator { use Symfony\Component\Validator\Constraint; + use Symfony\Component\Validator\ConstraintValidator; class AssertCanDeleteValidator extends ConstraintValidator { - public function validate(mixed $value, Constraint $constraint) + public function validate(mixed $value, Constraint $constraint): void { $this->context->buildViolation($constraint->message)->addViolation(); } } } - namespace App\Entity { - use ApiPlatform\Metadata\Delete; + use ApiPlatform\Symfony\Validator\Exception\ValidationException; use App\Validator\AssertCanDelete; use Doctrine\ORM\Mapping as ORM; - use ApiPlatform\Symfony\Validator\Exception\ValidationException; #[ORM\Entity] #[Delete( @@ -75,7 +72,6 @@ public function getId() } namespace App\Playground { - use Symfony\Component\HttpFoundation\Request; function request(): Request @@ -85,13 +81,13 @@ function request(): Request } namespace App\Fixtures { - use App\Entity\Book; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; - use function Zenstruck\Foundry\repository; use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; final class BookFixtures extends Fixture { @@ -103,8 +99,7 @@ public function load(ObjectManager $manager): void } $bookFactory->many(10)->create( - fn () => - [ + fn () => [ 'title' => faker()->name(), ] ); @@ -113,7 +108,6 @@ public function load(ObjectManager $manager): void } namespace DoctrineMigrations { - use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; diff --git a/docs/guides/doctrine-entity-as-resource.php b/docs/guides/doctrine-entity-as-resource.php index d48abbf0fb9..e35a56afca8 100644 --- a/docs/guides/doctrine-entity-as-resource.php +++ b/docs/guides/doctrine-entity-as-resource.php @@ -10,11 +10,11 @@ // # API Resource on a Doctrine Entity. // // API Platform is compatible with [Doctrine ORM](https://www.doctrine-project.org), all we need is to declare an -namespace App\Entity { - use ApiPlatform\Metadata\ApiResource; - use ApiPlatform\Metadata\ApiFilter; +namespace App\Entity { use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; + use ApiPlatform\Metadata\ApiFilter; + use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; // When an ApiResource is declared on an `\ORM\Entity` we have access to [Doctrine filters](https://api-platform.com/docs/core/filters/). @@ -37,20 +37,20 @@ public function getId(): ?int } namespace App\Playground { - use Symfony\Component\HttpFoundation\Request; function request(): Request { // Persistence is automatic, you can try to create or read data: return Request::create('/books?order[id]=desc', 'GET'); + return Request::create('/books/1', 'GET'); + return Request::create(uri: '/books', method: 'POST', server: ['CONTENT_TYPE' => 'application/ld+json'], content: json_encode(['id' => 1, 'title' => 'API Platform rocks.'])); } } namespace DoctrineMigrations { - use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -64,13 +64,13 @@ public function up(Schema $schema): void } namespace App\Fixtures { - use App\Entity\Book; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; - use function Zenstruck\Foundry\repository; use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; final class BookFixtures extends Fixture { @@ -81,19 +81,17 @@ public function load(ObjectManager $manager): void return; } - $bookFactory->many(10)->create(fn() => - [ - 'title' => faker()->name(), - ] + $bookFactory->many(10)->create(fn () => [ + 'title' => faker()->name(), + ] ); } } } namespace App\Tests { - - use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Playground\Test\TestGuideTrait; + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; final class BookTest extends ApiTestCase { diff --git a/docs/guides/doctrine-search-filter.php b/docs/guides/doctrine-search-filter.php index 77b598080d3..8f54016615d 100644 --- a/docs/guides/doctrine-search-filter.php +++ b/docs/guides/doctrine-search-filter.php @@ -65,13 +65,13 @@ public function up(Schema $schema): void } namespace App\Fixtures { - use App\Entity\Book; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; - use function Zenstruck\Foundry\repository; use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; final class BookFixtures extends Fixture { @@ -82,20 +82,19 @@ public function load(ObjectManager $manager): void return; } - $bookFactory->many(10)->create(fn() => - [ - 'title' => faker()->name(), - 'author' => faker()->firstName(), - ] + $bookFactory->many(10)->create(fn () => [ + 'title' => faker()->name(), + 'author' => faker()->firstName(), + ] ); } } } namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\Entity\Book; - use ApiPlatform\Playground\Test\TestGuideTrait; final class BookTest extends ApiTestCase { @@ -110,7 +109,7 @@ public function testAsAnonymousICanAccessTheDocumentation(): void $this->assertJsonContains([ 'hydra:search' => [ '@type' => 'hydra:IriTemplate', - 'hydra:template' => '/books.jsonld{?title,title[],author,author[]}', + 'hydra:template' => '/books.jsonld{?title,title[],author}', 'hydra:variableRepresentation' => 'BasicRepresentation', 'hydra:mapping' => [ [ @@ -131,12 +130,6 @@ public function testAsAnonymousICanAccessTheDocumentation(): void 'property' => 'author', 'required' => false, ], - [ - '@type' => 'IriTemplateMapping', - 'variable' => 'author[]', - 'property' => 'author', - 'required' => false, - ], ], ], ]); diff --git a/docs/guides/extend-openapi-documentation.php b/docs/guides/extend-openapi-documentation.php index 8b55ed97a90..6ce243decbd 100644 --- a/docs/guides/extend-openapi-documentation.php +++ b/docs/guides/extend-openapi-documentation.php @@ -10,13 +10,13 @@ namespace App\ApiResource { use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; - use ApiPlatform\OpenApi\Model\Response; use ApiPlatform\OpenApi\Model\RequestBody; + use ApiPlatform\OpenApi\Model\Response; #[Post( openapi: new Operation( responses: [ - '200' => new Response(description: 'Ok') + '200' => new Response(description: 'Ok'), ], summary: 'Add a book to the library.', description: 'My awesome operation', @@ -26,25 +26,26 @@ 'application/ld+json' => [ 'schema' => [ 'properties' => [ - 'id' => ['type' => 'integer', 'required' => true, 'description' => 'id'] - ] + 'id' => ['type' => 'integer', 'required' => true, 'description' => 'id'], + ], ], 'example' => [ - 'id' => 12345 - ] - ] + 'id' => 12345, + ], + ], ] ) ) ) )] - class Book { + class Book + { } } namespace App\Tests { - use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Playground\Test\TestGuideTrait; + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; final class BookTest extends ApiTestCase { @@ -55,9 +56,8 @@ public function testBookDoesNotExists(): void $response = static::createClient()->request('GET', '/docs', options: ['headers' => ['accept' => 'application/vnd.openapi+json']]); $this->assertResponseStatusCodeSame(200); $this->assertJsonContains([ - 'paths' => ['/books' => ['post' => ['summary' => 'Add a book to the library.', 'description' => 'My awesome operation']]] + 'paths' => ['/books' => ['post' => ['summary' => 'Add a book to the library.', 'description' => 'My awesome operation']]], ]); } } } - diff --git a/docs/guides/handle-links.php b/docs/guides/handle-links.php index 7310cef87db..a034fc63b2b 100644 --- a/docs/guides/handle-links.php +++ b/docs/guides/handle-links.php @@ -11,12 +11,13 @@ // // When using subresources with doctrine, API Platform tries to handle your links, // and the algorithm sometimes overcomplicates SQL queries. + namespace App\Entity { use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\ApiResource; - use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Get; + use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\QueryBuilder; @@ -48,7 +49,7 @@ public function getId() // This function gets called in our generic ItemProvider or CollectionProvider, the idea is to create the WHERE clause // to get the correct data. You can also perform joins or whatever SQL clause you need: - static public function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context) + public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void { $queryBuilder ->andWhere($queryBuilder->getRootAliases()[0].'.company = :companyId') @@ -69,7 +70,6 @@ class Company } namespace App\Playground { - use Symfony\Component\HttpFoundation\Request; function request(): Request @@ -80,7 +80,6 @@ function request(): Request } namespace DoctrineMigrations { - use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -91,21 +90,20 @@ public function up(Schema $schema): void $this->addSql('CREATE TABLE company (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL);'); $this->addSql('CREATE TABLE employee (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, company_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, CONSTRAINT FK_COMPANY FOREIGN KEY (company_id) REFERENCES company (id) NOT DEFERRABLE INITIALLY IMMEDIATE); '); - $this->addSql('CREATE INDEX FK_COMPANY ON employee (company_id)'); - + $this->addSql('CREATE INDEX FK_COMPANY ON employee (company_id)'); } } } namespace App\Fixtures { - - use App\Entity\Employee; use App\Entity\Company; + use App\Entity\Employee; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; - use function Zenstruck\Foundry\repository; use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; final class BookFixtures extends Fixture { @@ -117,16 +115,15 @@ public function load(ObjectManager $manager): void return; } - $companyFactory->many(1)->create(fn() => [ - 'name' => faker()->company() + $companyFactory->many(1)->create(fn () => [ + 'name' => faker()->company(), ]); $employeeFactory = anonymous(Employee::class); - $employeeFactory->many(10)->create(fn() => - [ - 'name' => faker()->name(), - 'company' => $companyRepository->first() - ] + $employeeFactory->many(10)->create(fn () => [ + 'name' => faker()->name(), + 'company' => $companyRepository->first(), + ] ); } } diff --git a/docs/guides/hook-a-persistence-layer-with-a-processor.php b/docs/guides/hook-a-persistence-layer-with-a-processor.php index 836478db5e1..f7d8225a8c7 100644 --- a/docs/guides/hook-a-persistence-layer-with-a-processor.php +++ b/docs/guides/hook-a-persistence-layer-with-a-processor.php @@ -8,6 +8,7 @@ // --- // # Hook a Persistence Layer with a Processor + namespace App\ApiResource { use ApiPlatform\Metadata\ApiResource; use App\State\BookProcessor; @@ -17,7 +18,9 @@ #[ApiResource(processor: BookProcessor::class, provider: BookProvider::class)] class Book { - public function __construct(public string $id, public string $title) {} + public function __construct(public string $id, public string $title) + { + } } } @@ -39,11 +42,13 @@ public function process($data, Operation $operation, array $uriVariables = [], a { $id = $uriVariables['id'] ?? $data->id; file_put_contents(sprintf('book-%s.json', $id), json_encode($data)); + return $data; } } - final class BookProvider implements ProviderInterface { + final class BookProvider implements ProviderInterface + { public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?Book { if ($operation instanceof CollectionInterface) { @@ -56,6 +61,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $data = json_decode(file_get_contents($file)); + return new Book($data->id, $data->title); } } diff --git a/docs/guides/how-to.php b/docs/guides/how-to.php index a4367c0c6cf..d9999dac1a9 100644 --- a/docs/guides/how-to.php +++ b/docs/guides/how-to.php @@ -21,8 +21,8 @@ // ``` // // Two namespaces are available to register API resources: `App\Entity` (for Doctrine) and `App\ApiResource`. -namespace App\Entity { +namespace App\Entity { use ApiPlatform\Metadata\ApiResource; use Doctrine\ORM\Mapping as ORM; @@ -45,33 +45,36 @@ public function getId(): ?int } // We can declare as many namespaces or classes that we need to for this code to work. + namespace App\Service { use Psr\Log\LoggerInterface; class MyService { - public function __construct(private LoggerInterface $logger) {} + public function __construct(private LoggerInterface $logger) + { + } } } // If you need to change something within Symfony's Container you need to declare this namespace with a `configure` method. -namespace App\DependencyInjection { +namespace App\DependencyInjection { use App\Service\MyService; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + use function Symfony\Component\DependencyInjection\Loader\Configurator\service; - function configure(ContainerConfigurator $configurator) + function configure(ContainerConfigurator $configurator): void { $services = $configurator->services(); $services->set(MyService::class) - ->args([service('logger')]) - ; + ->args([service('logger')]); } } - // Doctrine migrations will run from this namespace. + namespace DoctrineMigrations { use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -86,10 +89,12 @@ public function up(Schema $schema): void } // And we can load fixtures using [Foundry](https://github.com/zenstruck/foundry) + namespace App\Fixtures { use App\Entity\Book; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; final class BookFixtures extends Fixture @@ -98,13 +103,14 @@ public function load(ObjectManager $manager): void { $bookFactory = anonymous(Book::class); $bookFactory->many(10)->create([ - 'title' => 'title' + 'title' => 'title', ]); } } } // The `request` method is the one executed by the API Platform online Playground on startup. + namespace App\Playground { use Symfony\Component\HttpFoundation\Request; @@ -115,10 +121,11 @@ function request(): Request } // The Guide huge advantage is that it is also tested with phpunit. + namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\Entity\Book; - use ApiPlatform\Playground\Test\TestGuideTrait; final class BookTest extends ApiTestCase { diff --git a/docs/guides/provide-the-resource-state.php b/docs/guides/provide-the-resource-state.php index 5f3d49ccc6e..9dfa1be0185 100644 --- a/docs/guides/provide-the-resource-state.php +++ b/docs/guides/provide-the-resource-state.php @@ -10,6 +10,7 @@ // # Provide the Resource State // Our model is the same then in the previous guide ([Declare a Resource](./declare-a-resource). API Platform will declare // CRUD operations if we don't declare them. + namespace App\ApiResource { use ApiPlatform\Metadata\ApiResource; use App\State\BookProvider; @@ -37,15 +38,17 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ($operation instanceof CollectionOperationInterface) { $book = new Book(); $book->id = '1'; + // $book2 = new Book(); // $book2->id = '2'; // As an exercise you can edit the code and add a second book in the collection. - return [$book, /** $book2 */]; + return [$book/* $book2 */]; } $book = new Book(); // The value at `$uriVariables['id']` is the one that matches the `{id}` variable of the **[URI template](/explanation/uri#uri-template)**. $book->id = $uriVariables['id']; + return $book; } } @@ -59,4 +62,3 @@ function request(): Request return Request::create('/books.jsonld', 'GET'); } } - diff --git a/docs/guides/return-the-iri-of-your-resources-relations.php b/docs/guides/return-the-iri-of-your-resources-relations.php index e521dcdfa8e..e8fec5cb639 100644 --- a/docs/guides/return-the-iri-of-your-resources-relations.php +++ b/docs/guides/return-the-iri-of-your-resources-relations.php @@ -42,8 +42,7 @@ public function __construct( // It is based on the uriTemplate set on the operation defined on the Address resource (see below). #[ApiProperty(uriTemplate: '/brands/{brandId}/addresses/{id}')] private ?Address $headQuarters = null - ) - { + ) { } /** @@ -77,7 +76,7 @@ public function setHeadQuarters(?Address $headQuarters): self public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - return (new Brand(1, 'Ford')) + return (new self(1, 'Ford')) ->setHeadQuarters(new Address(1, 'One American Road near Michigan Avenue, Dearborn, Michigan')) ->addCar(new Car(1, 'Torpedo Roadster')); } @@ -85,7 +84,7 @@ public static function provide(Operation $operation, array $uriVariables = [], a #[ApiResource( operations: [ - new Get, + new Get(), // Without the use of uriTemplate on the property this would be used coming from the Brand resource, but not anymore. new GetCollection(uriTemplate: '/cars'), // This operation will be used to create the IRI instead since the uriTemplate matches. @@ -104,8 +103,7 @@ public function __construct( public readonly int $id = 1, public readonly string $name = 'Anon', private ?Brand $brand = null - ) - { + ) { } public function getBrand(): Brand @@ -130,7 +128,7 @@ public function setBrand(Brand $brand): void 'brandId' => new Link(toProperty: 'brand', fromClass: Brand::class), 'id' => new Link(fromClass: Address::class), ] - ) + ), ], )] class Address @@ -140,8 +138,7 @@ public function __construct( public readonly int $id = 1, public readonly string $name = 'Anon', private ?Brand $brand = null - ) - { + ) { } public function getBrand(): Brand @@ -181,22 +178,21 @@ function request(): Request final class BrandTest extends ApiTestCase { - public function testResourceExposeIRI(): void { static::createClient()->request('GET', '/brands/1', ['headers' => [ - 'Accept: application/ld+json' + 'Accept: application/ld+json', ]]); $this->assertResponseIsSuccessful(); $this->assertMatchesResourceCollectionJsonSchema(Brand::class, '_api_/brands/{id}{._format}_get'); $this->assertJsonContains([ - "@context" => "/contexts/Brand", - "@id" => "/brands/1", - "@type" => "Brand", - "name"=> "Ford", - "cars" => "/brands/1/cars", - "headQuarters" => "/brands/1/addresses/1" + '@context' => '/contexts/Brand', + '@id' => '/brands/1', + '@type' => 'Brand', + 'name' => 'Ford', + 'cars' => '/brands/1/cars', + 'headQuarters' => '/brands/1/addresses/1', ]); } } diff --git a/docs/guides/subresource.php b/docs/guides/subresource.php index a62ff857ef7..7ccee093e0d 100644 --- a/docs/guides/subresource.php +++ b/docs/guides/subresource.php @@ -10,20 +10,18 @@ // # Subresource // // In API Platform, a subresource is an alternate way to reach a Resource. + namespace App\Entity { - use ApiPlatform\Doctrine\Orm\State\Options; - use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\ApiResource; - use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Get; - use ApiPlatform\Metadata\Post; + use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; + use ApiPlatform\Metadata\Post; use Doctrine\ORM\Mapping as ORM; - use Doctrine\ORM\QueryBuilder; // This is our standard Resource, we only allow the Post operation. #[ApiResource( - operations: [ new Post() ] + operations: [new Post()] )] // To read this resource, we decided that it is only available through a Company. #[ApiResource( @@ -34,14 +32,14 @@ 'companyId' => new Link(fromClass: Company::class, toProperty: 'company'), 'id' => new Link(fromClass: Employee::class), ], - operations: [ new Get() ] + operations: [new Get()] )] #[ApiResource( uriTemplate: '/companies/{companyId}/employees', uriVariables: [ 'companyId' => new Link(fromClass: Company::class, toProperty: 'company'), ], - operations: [ new GetCollection() ] + operations: [new GetCollection()] )] #[ORM\Entity] class Employee @@ -74,7 +72,6 @@ class Company } namespace App\Playground { - use Symfony\Component\HttpFoundation\Request; function request(): Request @@ -85,7 +82,6 @@ function request(): Request } namespace DoctrineMigrations { - use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -96,21 +92,20 @@ public function up(Schema $schema): void $this->addSql('CREATE TABLE company (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL);'); $this->addSql('CREATE TABLE employee (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, company_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, CONSTRAINT FK_COMPANY FOREIGN KEY (company_id) REFERENCES company (id) NOT DEFERRABLE INITIALLY IMMEDIATE); '); - $this->addSql('CREATE INDEX FK_COMPANY ON employee (company_id)'); - + $this->addSql('CREATE INDEX FK_COMPANY ON employee (company_id)'); } } } namespace App\Fixtures { - - use App\Entity\Employee; use App\Entity\Company; + use App\Entity\Employee; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; + use function Zenstruck\Foundry\anonymous; - use function Zenstruck\Foundry\repository; use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; final class BookFixtures extends Fixture { @@ -122,16 +117,15 @@ public function load(ObjectManager $manager): void return; } - $companyFactory->many(1)->create(fn() => [ - 'name' => faker()->company() + $companyFactory->many(1)->create(fn () => [ + 'name' => faker()->company(), ]); $employeeFactory = anonymous(Employee::class); - $employeeFactory->many(10)->create(fn() => - [ - 'name' => faker()->name(), - 'company' => $companyRepository->first() - ] + $employeeFactory->many(10)->create(fn () => [ + 'name' => faker()->name(), + 'company' => $companyRepository->first(), + ] ); } } diff --git a/docs/guides/test-your-api.php b/docs/guides/test-your-api.php index 4bf85c1f87a..0b4faf5541a 100644 --- a/docs/guides/test-your-api.php +++ b/docs/guides/test-your-api.php @@ -6,10 +6,11 @@ // position: 7 // tags: tests // --- + namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\ApiResource\Book; - use ApiPlatform\Playground\Test\TestGuideTrait; // API Platform [testing utilities](/docs/core/testing/) provides an [ApiTestCase](/docs/reference/Symfony/Bundle/Test/ApiTestCase/) // that allows you to send an HTTP Request, and to perform assertions on the Response. @@ -52,13 +53,15 @@ class Book { public string $id; - static public function provide($operation) { - return $operation instanceof CollectionOperationInterface ? [] : null; + public static function provide($operation) + { + return $operation instanceof CollectionOperationInterface ? [] : null; } } } // # Test your API + namespace App\Playground { use Symfony\Component\HttpFoundation\Request; @@ -68,7 +71,7 @@ function request(): Request uri: '/books/1', method: 'GET', server: [ - 'HTTP_ACCEPT' => 'application/ld+json' + 'HTTP_ACCEPT' => 'application/ld+json', ] ); } diff --git a/docs/guides/validate-incoming-data.php b/docs/guides/validate-incoming-data.php index 05a12db041b..ac1786445c4 100644 --- a/docs/guides/validate-incoming-data.php +++ b/docs/guides/validate-incoming-data.php @@ -15,12 +15,12 @@ // By default, the framework relies on the powerful [Symfony Validator Component](http://symfony.com/doc/current/validation.html) for this task, but you can replace it with your preferred validation library such as the [PHP filter extension](https://www.php.net/manual/en/intro.filter.php) if you want to. // Validation is called when handling a POST, PATCH, PUT request as follows : -//graph LR -//Request --> Deserialization -//Deserialization --> Validation -//Validation --> Persister -//Persister --> Serialization -//Serialization --> Response +// graph LR +// Request --> Deserialization +// Deserialization --> Validation +// Validation --> Persister +// Persister --> Serialization +// Serialization --> Response // In this guide we're going to use [Symfony's built-in constraints](http://symfony.com/doc/current/reference/constraints.html) and a [custom constraint](http://symfony.com/doc/current/validation/custom_constraint.html). Let's start by shaping our to-be-validated resource: @@ -53,7 +53,8 @@ class Product #[ORM\Column(type: 'json')] public $properties; - public function getId(): ?int { + public function getId(): ?int + { return $this->id; } } @@ -61,6 +62,7 @@ public function getId(): ?int { // The `MinimalProperties` constraint will check that the `properties` data holds at least two values: description and price. // We start by creating the constraint: + namespace App\Validator\Constraints { use Symfony\Component\Validator\Constraint; @@ -81,7 +83,7 @@ final class MinimalPropertiesValidator extends ConstraintValidator { public function validate($value, Constraint $constraint): void { - if (!array_key_exists('description', $value) || !array_key_exists('price', $value)) { + if (!\array_key_exists('description', $value) || !\array_key_exists('price', $value)) { $this->context->buildViolation($constraint->message)->addViolation(); } } @@ -111,7 +113,7 @@ function request(): Request method: 'POST', server: [ 'CONTENT_TYPE' => 'application/ld+json', - 'HTTP_ACCEPT' => 'application/ld+json' + 'HTTP_ACCEPT' => 'application/ld+json', ], content: '{"name": "test", "properties": {"description": "Test product"}}' ); @@ -119,9 +121,8 @@ function request(): Request } namespace App\Tests { - use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; - use App\Entity\Book; use ApiPlatform\Playground\Test\TestGuideTrait; + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; final class BookTest extends ApiTestCase { @@ -131,10 +132,10 @@ public function testValidation(): void { $response = static::createClient()->request(method: 'POST', url: '/products', options: [ 'json' => ['name' => 'test', 'properties' => ['description' => 'foo']], - 'headers' => ['content-type' => 'application/ld+json'] + 'headers' => ['content-type' => 'application/ld+json'], ]); - //If the data submitted by the client is invalid, the HTTP status code will be set to 422 Unprocessable Entity and the response's body will contain the list of violations serialized in a format compliant with the requested one. For instance, a validation error will look like the following if the requested format is JSON-LD (the default): + // If the data submitted by the client is invalid, the HTTP status code will be set to 422 Unprocessable Entity and the response's body will contain the list of violations serialized in a format compliant with the requested one. For instance, a validation error will look like the following if the requested format is JSON-LD (the default): // ```json // { // "@context": "/contexts/ConstraintViolationList", @@ -154,7 +155,7 @@ public function testValidation(): void 'hydra:description' => 'properties: The product must have the minimal properties required ("description", "price")', 'title' => 'An error occurred', 'violations' => [ - ['propertyPath' => 'properties', 'message' => 'The product must have the minimal properties required ("description", "price")'] + ['propertyPath' => 'properties', 'message' => 'The product must have the minimal properties required ("description", "price")'], ], ]); }