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/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..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 index ba504b495d3..e8fec5cb639 100644 --- a/docs/guides/return-the-iri-of-your-resources-relations.php +++ b/docs/guides/return-the-iri-of-your-resources-relations.php @@ -3,7 +3,8 @@ // slug: return-the-iri-of-your-resources-relations // name: How to return an IRI instead of an object for your resources relations ? // executable: true -// tags: serialization +// tags: serialization, expert +// position: 11 // --- // This guide shows you how to expose the IRI of a related (sub)ressource relation instead of an object. @@ -41,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 - ) - { + ) { } /** @@ -76,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')); } @@ -84,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. @@ -103,8 +103,7 @@ public function __construct( public readonly int $id = 1, public readonly string $name = 'Anon', private ?Brand $brand = null - ) - { + ) { } public function getBrand(): Brand @@ -129,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 @@ -139,8 +138,7 @@ public function __construct( public readonly int $id = 1, public readonly string $name = 'Anon', private ?Brand $brand = null - ) - { + ) { } public function getBrand(): Brand @@ -174,29 +172,27 @@ function request(): Request } } - 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' + '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/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/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); 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 */