From e1e99376b79f7a47fc372d9ce47e09d254cc3bcf Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Mon, 11 Sep 2023 16:34:20 +0100 Subject: [PATCH 1/6] chore: remove conflict with of symfony/service-contracts:<3 allows psr/container:v1 --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 4d50110e210..a594e176679 100644 --- a/composer.json +++ b/composer.json @@ -94,7 +94,6 @@ "doctrine/orm": "<2.14.0", "doctrine/mongodb-odm": "<2.4", "doctrine/persistence": "<1.3", - "symfony/service-contracts": "<3", "symfony/var-exporter" : "<6.1.1", "phpunit/phpunit": "<9.5", "phpspec/prophecy": "<1.15", From 074c1f2e4faae3a8600fa81f896b3ed08857196b Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Mon, 11 Sep 2023 16:51:12 +0100 Subject: [PATCH 2/6] chore: require behat/behat:^3.11 to include https://github.com/Behat/Behat/pull/1368 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a594e176679..ea9ef64a1d1 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "willdurand/negotiation": "^3.0" }, "require-dev": { - "behat/behat": "^3.1", + "behat/behat": "^3.11", "behat/mink": "^1.9", "doctrine/cache": "^1.11 || ^2.1", "doctrine/common": "^3.2.2", From e5b9d16c9ba91ee91ec299382f87b628f20bd04b Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 13 Sep 2023 16:24:07 +0200 Subject: [PATCH 3/6] chore: update changelog about Doctrine filters (#5816) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16aff6b95db..c03b5ee1c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -379,6 +379,7 @@ Breaking changes: * Serializer: `skip_null_values` now defaults to `true` * Metadata: `Patch` is added to the automatic CRUD * Symfony: generated route names and operation names changed, route naming can be changed directly within metadata +* Doctrine: remove `@final` annotation from filters and mark them as `final` ## v2.7.14 From 198ca0f1f1c9f730a15e85762b04af43eb43424e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 15 Sep 2023 15:45:54 +0200 Subject: [PATCH 4/6] chore: changelog 3.1.18 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c03b5ee1c11..3291cd31730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## v3.1.18 + ## v3.1.17 ### Bug fixes @@ -1688,4 +1690,4 @@ Please read #2825 if you have issues with the behavior of Readable/Writable Link ## 1.0.0 beta 2 * Preserve indexes when normalizing and denormalizing associative arrays -* Allow setting default order for property when registering a `Doctrine\Orm\Filter\OrderFilter` instance +* Allow setting default order for property when registering a `Doctrine\Orm\Filter\OrderFilter` instance \ No newline at end of file From 4e4cf5a1076f0f307157917e10d39efaaa9df333 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 19 Sep 2023 10:26:24 +0200 Subject: [PATCH 5/6] docs: add a few guides * docs: change class description * test: fix guides tests * temp * temp * temp * end * php-cs-fixer --- docs/.gitignore | 1 + docs/.php-cs-fixer.dist.php | 92 ++++++ docs/adr/0005-refactor-state-management.md | 54 ++++ docs/composer.json | 14 +- docs/config/packages/doctrine.yaml | 2 +- docs/config/packages/framework.yaml | 21 +- .../create-a-custom-doctrine-filter.php | 8 +- docs/guides/custom-pagination.php | 10 +- docs/guides/declare-a-resource.php | 10 +- .../delete-operation-with-validation.php | 121 ++++++++ docs/guides/doctrine-entity-as-resource.php | 112 ++++++++ docs/guides/doctrine-orm-service-filter.php | 115 -------- ...-filter.php => doctrine-search-filter.php} | 49 +++- docs/guides/extend-openapi-documentation.php | 89 +++--- docs/guides/handle-links.php | 130 +++++++++ ...k-a-persistence-layer-with-a-processor.php | 51 +++- docs/guides/how-to.php | 25 +- docs/guides/provide-the-resource-state.php | 6 +- ...rn-the-iri-of-your-resources-relations.php | 199 +++++++++++++ docs/guides/secure-a-resource-access.php | 57 ---- .../secure-a-resource-with-custom-voters.php | 78 ------ docs/guides/subresource.php | 132 +++++++++ docs/guides/test-your-api.php | 78 ++++++ docs/guides/use-doctrine-orm-filters.php | 262 ------------------ docs/guides/use-validation-groups.php | 67 ----- .../validate-data-on-a-delete-operation.php | 91 ------ docs/guides/validate-incoming-data.php | 124 ++++++--- docs/pdg.config.yaml | 4 +- docs/src/Kernel.php | 19 +- 29 files changed, 1198 insertions(+), 823 deletions(-) create mode 100644 docs/.php-cs-fixer.dist.php create mode 100644 docs/adr/0005-refactor-state-management.md create mode 100644 docs/guides/delete-operation-with-validation.php 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} (74%) create mode 100644 docs/guides/handle-links.php create mode 100644 docs/guides/return-the-iri-of-your-resources-relations.php delete mode 100644 docs/guides/secure-a-resource-access.php delete mode 100644 docs/guides/secure-a-resource-with-custom-voters.php create mode 100644 docs/guides/subresource.php delete mode 100644 docs/guides/use-doctrine-orm-filters.php delete mode 100644 docs/guides/use-validation-groups.php delete mode 100644 docs/guides/validate-data-on-a-delete-operation.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/adr/0005-refactor-state-management.md b/docs/adr/0005-refactor-state-management.md new file mode 100644 index 00000000000..a4196044dab --- /dev/null +++ b/docs/adr/0005-refactor-state-management.md @@ -0,0 +1,54 @@ +# Refactor State Management + +* Status: proposed +* Deciders: @dunglas, @soyuka, @alanpoulain + +## Context and Problem Statement + +Since version 2.2, we support both REST and GraphQL API styles out of the box. +This leads to some code duplication to extract states from request bodies (write requests), +and to generate resource representations from states retrieved from the data source. + +The REST susbystem uses [Symfony-specific kernel event listeners](https://api-platform.com/docs/core/events/#built-in-event-listeners) +while the GraphQL subsystem relies on [an ad-hoc resolver system](https://api-platform.com/docs/core/graphql/#workflow-of-the-resolvers). + +Also, while it's possible [to use API Platform as a standalone PHP library](https://api-platform.com/docs/core/bootstrap/), +this requires writing boilerplate code doing mostly what is done in the Symfony-specific listeners. +As we're working on integrating API Platform with Laravel, the Laravel package will contain similar code to the one in the Symfony +event listeners too. + +As Martin Fowler writes in the _Patterns of Enterprise Application Architecture_: + +> There’s just one controller, so you can easily enhance its behavior at +runtime with decorators [Gang of Four](https://en.wikipedia.org/wiki/Design_Patterns). You can have decorators for +authentication, character encoding, internationalization, and so forth, +and add them using a configuration file or even while the server is +running. ([Alur et al.](http://www.corej2eepatterns.com/InterceptingFilter.htm) describe this approach in detail under the name +Intercepting Filter.) + +For API Platform 3, we refactored the whole metadata susbsytem to be more flexible, powerful, and covering more use cases. +This led to the refactoring of the two main interfaces allowing to plug a data source in API Platform: the state provider and the state processor interfaces. + +Leveraging these new interfaces, it should be possible to simplify the code base and to remove most code duplication by transforming most of the code currently +stored in the kernel event listeners and in the GraphQL resolvers in dedicated state processors and state providers. + +## Decision Outcome + +1. Move the logic currently stored in `ReadListener`, `DeserializeListener`, `DenyAccessListener`, `ValidateListener`, `WriteListener` and `SerializeListener` in classes implementing the `StateProcessorInterface` or `StateProviderInterface`. These classes will implement the decorator pattern. All classes will be composed to create a chain. The classes containing Symfony-specific and/or Doctrine-specific logic will then be able to be easily replaced by other implementations (Laravel, PSR-7, super-globals...). +2. Remove the corresponding listeners. +3. Replace `PlaceholderController` by a controller calling the main `StateProcessor` and `StateProvider` objects directly. +4. Remove the GraphQL resolvers, call the main `StateProcessor` and StateProvider objects directly. + +Consenquently, transforming the raw request body (e.g. the raw JSON document) to a PHP data structure will be the responsibility of a processor. +The default one will use the Symfony Serializer component to do so, but this will give the opportunity to the user to replace this class by one using another Serializer if necessary. +This will also allow the user to access the raw body if necessary, and will enable a whole class of optimizations, extra validations (e.g. validating a raw JSON string against a JSON Schema) etc. +Similarly, transforming PHP data structures into strings to be stored in response bodies will now be the responsibility of a state provider. + +This will reduce the weigth of the code base and improve the whole design of API Platform. + +This new design will also replace what we currently call [the "DTO" feature](https://api-platform.com/docs/core/dto/): a "data transformer" will now be just another state provider or processor. + +Finally, the `resumable()` method will be removed from these interfaces. The decorator pattern allows, by ordering the composed objects, to achieve the same result with more flexibility. + +To help using API Platform without Symfony, we could provide factories building the correct chain of data providers and persisters without relying on the Symfony Dependency Injection Component. + diff --git a/docs/composer.json b/docs/composer.json index def84f49829..8321efc7f30 100644 --- a/docs/composer.json +++ b/docs/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "api-platform/core": "*", + "api-platform/core": "dev-main", "symfony/expression-language": "6.2.*", "nelmio/cors-bundle": "^2.2", "phpstan/phpdoc-parser": "^1.15", @@ -34,18 +34,8 @@ "zenstruck/foundry": "^1.31", "symfony/http-client": "^6.2", "symfony/browser-kit": "^6.2", - "justinrainbow/json-schema": "^5.2", - "symfony/asset": "7.0.x-dev" + "justinrainbow/json-schema": "^5.2" }, - "repositories": [ - { - "type": "path", - "url": "../", - "options": { - "symlink": false - } - } - ], "config": { "allow-plugins": { "symfony/runtime": true 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..582a3b5972c 100644 --- a/docs/config/packages/framework.yaml +++ b/docs/config/packages/framework.yaml @@ -1,10 +1,15 @@ 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'] + docs_formats: + jsonopenapi: ['application/vnd.openapi+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/create-a-custom-doctrine-filter.php b/docs/guides/create-a-custom-doctrine-filter.php index 81f6ebb353c..f06c4d8bfce 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. @@ -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 386f8557b30..85187f5c0c8 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). @@ -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 f2a2a87bf9f..f7b49c0a54a 100644 --- a/docs/guides/declare-a-resource.php +++ b/docs/guides/declare-a-resource.php @@ -5,20 +5,20 @@ // position: 1 // executable: true // tags: design -// homepage: true // --- // # 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. @@ -35,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 @@ -47,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; @@ -55,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 new file mode 100644 index 00000000000..fdc1b3c755e --- /dev/null +++ b/docs/guides/delete-operation-with-validation.php @@ -0,0 +1,121 @@ +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; + + #[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']], + exceptionToStatus: [ValidationException::class => 403] + )] + // 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\faker; + 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( + 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 new file mode 100644 index 00000000000..e35a56afca8 --- /dev/null +++ b/docs/guides/doctrine-entity-as-resource.php @@ -0,0 +1,112 @@ +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\faker; + 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(fn () => [ + 'title' => faker()->name(), + ] + ); + } + } +} + +namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + + 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 74% rename from docs/guides/use-doctrine-search-filter.php rename to docs/guides/doctrine-search-filter.php index d2a8c477b52..8f54016615d 100644 --- a/docs/guides/use-doctrine-search-filter.php +++ b/docs/guides/doctrine-search-filter.php @@ -1,9 +1,10 @@ addSql('CREATE TABLE book (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, author VARCHAR(255) NOT NULL)'); } + } +} + +namespace App\Fixtures { + use App\Entity\Book; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; - public function down(Schema $schema): void + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; + + 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(), + ] + ); } } } 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 { @@ -84,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' => [ [ @@ -105,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 1c0c15a17ac..6ce243decbd 100644 --- a/docs/guides/extend-openapi-documentation.php +++ b/docs/guides/extend-openapi-documentation.php @@ -2,59 +2,62 @@ // --- // slug: extend-openapi-documentation // name: Extend OpenAPI Documentation -// position: 5 +// 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 +namespace App\ApiResource { + use ApiPlatform\Metadata\Post; + use ApiPlatform\OpenApi\Model\Operation; + use ApiPlatform\OpenApi\Model\RequestBody; + use ApiPlatform\OpenApi\Model\Response; + + #[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 { - 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\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\Playground\Test\TestGuideTrait; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; - 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 new file mode 100644 index 00000000000..a034fc63b2b --- /dev/null +++ b/docs/guides/handle-links.php @@ -0,0 +1,130 @@ + 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: + public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void + { + $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\Company; + use App\Entity\Employee; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; + + 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/hook-a-persistence-layer-with-a-processor.php b/docs/guides/hook-a-persistence-layer-with-a-processor.php index 903e3fa8bcd..f7d8225a8c7 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,35 @@ 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/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 new file mode 100644 index 00000000000..e8fec5cb639 --- /dev/null +++ b/docs/guides/return-the-iri-of-your-resources-relations.php @@ -0,0 +1,199 @@ + $cars + */ + #[ApiProperty(uriTemplate: '/brands/{brandId}/cars')] + private array $cars = [], + + // Setting uriTemplate on a relation with a resource item will try to find the related operation. + // 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 + ) { + } + + /** + * @return array + */ + public function getCars(): array + { + return $this->cars; + } + + public function addCar(Car $car): self + { + $car->setBrand($this); + $this->cars[] = $car; + + return $this; + } + + public function getHeadQuarters(): ?Address + { + return $this->headQuarters; + } + + public function setHeadQuarters(?Address $headQuarters): self + { + $headQuarters?->setBrand($this); + $this->headQuarters = $headQuarters; + + return $this; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + return (new self(1, 'Ford')) + ->setHeadQuarters(new Address(1, 'One American Road near Michigan Avenue, Dearborn, Michigan')) + ->addCar(new Car(1, 'Torpedo Roadster')); + } + } + + #[ApiResource( + operations: [ + 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. + new GetCollection( + uriTemplate: '/brands/{brandId}/cars', + uriVariables: [ + 'brandId' => new Link(toProperty: 'brand', fromClass: Brand::class), + ] + ), + ], + )] + class Car + { + public function __construct( + #[ApiProperty(identifier: true)] + public readonly int $id = 1, + public readonly string $name = 'Anon', + private ?Brand $brand = null + ) { + } + + public function getBrand(): Brand + { + return $this->brand; + } + + public function setBrand(Brand $brand): void + { + $this->brand = $brand; + } + } + + #[ApiResource( + operations: [ + // Without the use of uriTemplate on the property this would be used coming from the Brand resource, but not anymore. + new Get(uriTemplate: '/addresses/{id}'), + // This operation will be used to create the IRI instead since the uriTemplate matches. + new Get( + uriTemplate: '/brands/{brandId}/addresses/{id}', + uriVariables: [ + 'brandId' => new Link(toProperty: 'brand', fromClass: Brand::class), + 'id' => new Link(fromClass: Address::class), + ] + ), + ], + )] + class Address + { + public function __construct( + #[ApiProperty(identifier: true)] + public readonly int $id = 1, + public readonly string $name = 'Anon', + private ?Brand $brand = null + ) { + } + + public function getBrand(): Brand + { + return $this->brand; + } + + public function setBrand(Brand $brand): void + { + $this->brand = $brand; + } + } +} + +// If API Platform does not find any `GetCollection` operation on the target resource, it will result in a `NotFoundException`. +// +// The **OpenAPI** documentation will set the properties as `read-only` of type `string` in the format `iri-reference` for `JSON-LD`, `JSON:API` and `HAL` formats. +// +// The **Hydra** documentation will set the properties as `hydra:Link` with the right domain, with `hydra:readable` to `true` but `hydra:writable` to `false`. +// +// When using JSON:API or HAL formats, the IRI will be used and set links, embedded and relationship. +// +// *Additional Note:* If you are using the default doctrine provider, this will prevent unnecessary sql join and related processing. + +namespace App\Playground { + use Symfony\Component\HttpFoundation\Request; + + function request(): Request + { + return Request::create(uri: '/brands/1', method: 'GET', server: ['HTTP_ACCEPT' => 'application/ld+json']); + } +} + +namespace App\Tests { + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + use App\ApiResource\Brand; + + final class BrandTest extends ApiTestCase + { + public function testResourceExposeIRI(): void + { + static::createClient()->request('GET', '/brands/1', ['headers' => [ + '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', + ]); + } + } +} 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 @@ -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/subresource.php b/docs/guides/subresource.php new file mode 100644 index 00000000000..7ccee093e0d --- /dev/null +++ b/docs/guides/subresource.php @@ -0,0 +1,132 @@ + 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\Company; + use App\Entity\Employee; + use Doctrine\Bundle\FixturesBundle\Fixture; + use Doctrine\Persistence\ObjectManager; + + use function Zenstruck\Foundry\anonymous; + use function Zenstruck\Foundry\faker; + use function Zenstruck\Foundry\repository; + + 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 e69de29bb2d..0b4faf5541a 100644 --- a/docs/guides/test-your-api.php +++ b/docs/guides/test-your-api.php @@ -0,0 +1,78 @@ +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; + + public static 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/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/guides/validate-data-on-a-delete-operation.php b/docs/guides/validate-data-on-a-delete-operation.php deleted file mode 100644 index e287166b582..00000000000 --- a/docs/guides/validate-data-on-a-delete-operation.php +++ /dev/null @@ -1,91 +0,0 @@ - ['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..ac1786445c4 100644 --- a/docs/guides/validate-incoming-data.php +++ b/docs/guides/validate-incoming-data.php @@ -1,8 +1,9 @@ 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: @@ -34,7 +35,7 @@ /** * A product. */ - #[ORM\Entity] + #[ORM\Entity] #[ApiResource] class Product { @@ -49,13 +50,19 @@ class Product * @var string[] Describe the product */ #[MinimalProperties] - #[ORM\Column(type: 'json')] + #[ORM\Column(type: 'json')] public $properties; + + public function getId(): ?int + { + return $this->id; + } } } // 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; @@ -76,28 +83,81 @@ final class MinimalPropertiesValidator extends ConstraintValidator { public function validate($value, Constraint $constraint): void { - if (!array_diff(['description', 'price'], $value)) { + if (!\array_key_exists('description', $value) || !\array_key_exists('price', $value)) { $this->context->buildViolation($constraint->message)->addViolation(); } } } } -//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 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; + + 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"}}' + ); + } +} + +namespace App\Tests { + use ApiPlatform\Playground\Test\TestGuideTrait; + use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + + 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")'], + ], + ]); + } + } +} diff --git a/docs/pdg.config.yaml b/docs/pdg.config.yaml index 7e079b3d41b..39bc4f435ca 100644 --- a/docs/pdg.config.yaml +++ b/docs/pdg.config.yaml @@ -5,8 +5,8 @@ pdg: src: './guides' references: base_url: '/docs/reference' - exclude: ['*Factory.php', '*.tpl.php'] - exclude_path: ['JsonSchema/Tests', 'Metadata/Tests', 'OpenApi/Tests'] + exclude: ['*Factory.php', '*.tpl.php', 'deprecation.php'] + exclude_path: ['OpenApi/Tests', 'JsonSchema/Tests', 'RamseyUuid/Tests', 'Metadata/Tests', 'HttpCache/Tests', 'Elasticsearch/Tests', 'Doctrine/Common/Tests', 'GraphQl/Tests', 'Serializer/Tests'] namespace: 'ApiPlatform' output: 'dist/reference' src: '../src' diff --git a/docs/src/Kernel.php b/docs/src/Kernel.php index 02eee6812fc..f11e2472d0d 100644 --- a/docs/src/Kernel.php +++ b/docs/src/Kernel.php @@ -43,10 +43,20 @@ class Kernel extends BaseKernel { use MicroKernelTrait; private $declaredClasses = []; + private string $guide; - 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"; } @@ -99,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 4a3e7e61c6afb57ff0594f5eb68054e38e1e05a9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 19 Sep 2023 15:58:25 +0200 Subject: [PATCH 6/6] docs: wrong comment inside guide (#5838) fix links --- docs/guides/create-a-custom-doctrine-filter.php | 4 ++-- docs/guides/declare-a-resource.php | 2 +- docs/guides/doctrine-entity-as-resource.php | 2 +- docs/guides/extend-openapi-documentation.php | 4 ++++ .../hook-a-persistence-layer-with-a-processor.php | 2 +- docs/guides/provide-the-resource-state.php | 10 +++++----- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/guides/create-a-custom-doctrine-filter.php b/docs/guides/create-a-custom-doctrine-filter.php index f06c4d8bfce..ec66b21368e 100644 --- a/docs/guides/create-a-custom-doctrine-filter.php +++ b/docs/guides/create-a-custom-doctrine-filter.php @@ -11,11 +11,11 @@ // // API Platform provides a convenient way to create Doctrine ORM and MongoDB ODM filters. If you use [custom state providers](/docs/guide/state-providers), you can still create filters by implementing the previously mentioned interface, but - as API Platform isn't aware of your persistence system's internals - you have to create the filtering logic by yourself. // -// Doctrine ORM filters have access to the context created from the HTTP request and to the `QueryBuilder` instance used to retrieve data from the database. They are only applied to collections. If you want to deal with the DQL query generated to retrieve items, [extensions](/docs/in-depth/extensions) are the way to go. +// Doctrine ORM filters have access to the context created from the HTTP request and to the `QueryBuilder` instance used to retrieve data from the database. They are only applied to collections. If you want to deal with the DQL query generated to retrieve items, [extensions](/docs/core/extensions/) are the way to go. // // A Doctrine ORM filter is basically a class implementing the `ApiPlatform\Doctrine\Orm\Filter\FilterInterface`. API Platform includes a convenient abstract class implementing this interface and providing utility methods: `ApiPlatform\Doctrine\Orm\Filter\AbstractFilter`. // -// Note: Doctrine MongoDB ODM filters have access to the context created from the HTTP request and to the [aggregation builder](https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/aggregation-builder.html) instance used to retrieve data from the database and to execute [complex operations on data](https://docs.mongodb.com/manual/aggregation/). They are only applied to collections. If you want to deal with the aggregation pipeline generated to retrieve items, [extensions](/docs/in-depth/extensions) are the way to go. +// Note: Doctrine MongoDB ODM filters have access to the context created from the HTTP request and to the [aggregation builder](https://www.doctrine-project.org/projects/doctrine-mongodb-odm/en/latest/reference/aggregation-builder.html) instance used to retrieve data from the database and to execute [complex operations on data](https://docs.mongodb.com/manual/aggregation/). They are only applied to collections. If you want to deal with the aggregation pipeline generated to retrieve items, [extensions](/docs/core/extensions/) are the way to go. // // A Doctrine MongoDB ODM filter is basically a class implementing the `ApiPlatform\Doctrine\Odm\Filter\FilterInterface`. API Platform includes a convenient abstract class implementing this interface and providing utility methods: `ApiPlatform\Doctrine\Odm\Filter\AbstractFilter`. // diff --git a/docs/guides/declare-a-resource.php b/docs/guides/declare-a-resource.php index f7b49c0a54a..c741ea06bae 100644 --- a/docs/guides/declare-a-resource.php +++ b/docs/guides/declare-a-resource.php @@ -46,7 +46,7 @@ class Book } } -// Check our next guide to [provide the resource state](./provide-the-resource-state). +// Check our next guide to [provide the resource state](/playground/provide-the-resource-state). namespace App\Playground { use Symfony\Component\HttpFoundation\Request; diff --git a/docs/guides/doctrine-entity-as-resource.php b/docs/guides/doctrine-entity-as-resource.php index e35a56afca8..343b57f922c 100644 --- a/docs/guides/doctrine-entity-as-resource.php +++ b/docs/guides/doctrine-entity-as-resource.php @@ -17,7 +17,7 @@ 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/). + // When an ApiResource is declared on an `\ORM\Entity` we have access to [Doctrine filters](/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 6ce243decbd..cd1ddbd4b9a 100644 --- a/docs/guides/extend-openapi-documentation.php +++ b/docs/guides/extend-openapi-documentation.php @@ -7,6 +7,7 @@ // tags: openapi, expert // --- +// # Extend OpenAPI Documentation namespace App\ApiResource { use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model\Operation; @@ -14,12 +15,15 @@ use ApiPlatform\OpenApi\Model\Response; #[Post( + // To extend the OpenAPI documentation we use an [OpenApi Operation model](/docs/reference/OpenApi/Model/Operation/). + // When a field is not specified API Platform will add the missing informations. openapi: new Operation( responses: [ '200' => new Response(description: 'Ok'), ], summary: 'Add a book to the library.', description: 'My awesome operation', + // Each of the Operation field that you want to customize has a model in our [OpenApi reference](/docs/reference/). requestBody: new RequestBody( content: new \ArrayObject( [ 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 f7d8225a8c7..0c689b56188 100644 --- a/docs/guides/hook-a-persistence-layer-with-a-processor.php +++ b/docs/guides/hook-a-persistence-layer-with-a-processor.php @@ -14,7 +14,7 @@ use App\State\BookProcessor; use App\State\BookProvider; - // We use a `BookProcessor` as the [ApiResource::processor](http://localhost:3000/reference/Metadata/ApiResource#processor) option. + // We use a `BookProcessor` as the [ApiResource::processor](/docs/reference/Metadata/ApiResource#processor) option. #[ApiResource(processor: BookProcessor::class, provider: BookProvider::class)] class Book { diff --git a/docs/guides/provide-the-resource-state.php b/docs/guides/provide-the-resource-state.php index 9dfa1be0185..675fb3c5c7e 100644 --- a/docs/guides/provide-the-resource-state.php +++ b/docs/guides/provide-the-resource-state.php @@ -8,14 +8,14 @@ // --- // # Provide the Resource State -// Our model is the same then in the previous guide ([Declare a Resource](./declare-a-resource). API Platform will declare +// Our model is the same then in the previous guide ([Declare a Resource](/playground/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; - // We use a `BookProvider` as the [ApiResource::provider](/reference/Metadata/ApiResource#provider) option. + // We use a `BookProvider` as the [ApiResource::provider](/docs/reference/Metadata/ApiResource#provider) option. #[ApiResource(provider: BookProvider::class)] class Book { @@ -39,14 +39,14 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $book = new Book(); $book->id = '1'; - // $book2 = new Book(); - // $book2->id = '2'; + /** $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 */]; } $book = new Book(); - // The value at `$uriVariables['id']` is the one that matches the `{id}` variable of the **[URI template](/explanation/uri#uri-template)**. + // The value at `$uriVariables['id']` is the one that matches the `{id}` variable of the **[URI template](/docs/core/subresources/)**. $book->id = $uriVariables['id']; return $book;