diff --git a/Controller/Annotations/Copy.php b/Controller/Annotations/Copy.php index 45aa8b9cc..d15fa71a2 100644 --- a/Controller/Annotations/Copy.php +++ b/Controller/Annotations/Copy.php @@ -20,6 +20,7 @@ * * @author Maximilian Bosch */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Copy extends Route { public function getMethod() diff --git a/Controller/Annotations/Delete.php b/Controller/Annotations/Delete.php index e27952687..6c3b68750 100644 --- a/Controller/Annotations/Delete.php +++ b/Controller/Annotations/Delete.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Delete extends Route { public function getMethod() diff --git a/Controller/Annotations/Get.php b/Controller/Annotations/Get.php index 1001e26a5..973d441a9 100644 --- a/Controller/Annotations/Get.php +++ b/Controller/Annotations/Get.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Get extends Route { public function getMethod() diff --git a/Controller/Annotations/Head.php b/Controller/Annotations/Head.php index f89690f9d..c8365e75b 100644 --- a/Controller/Annotations/Head.php +++ b/Controller/Annotations/Head.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Head extends Route { public function getMethod() diff --git a/Controller/Annotations/Link.php b/Controller/Annotations/Link.php index fa8f31a4d..1e89fea73 100644 --- a/Controller/Annotations/Link.php +++ b/Controller/Annotations/Link.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Link extends Route { public function getMethod() diff --git a/Controller/Annotations/Lock.php b/Controller/Annotations/Lock.php index d5d26236c..3cb91be12 100644 --- a/Controller/Annotations/Lock.php +++ b/Controller/Annotations/Lock.php @@ -20,6 +20,7 @@ * * @author Maximilian Bosch */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Lock extends Route { public function getMethod() diff --git a/Controller/Annotations/Mkcol.php b/Controller/Annotations/Mkcol.php index c682577cb..3332095bd 100644 --- a/Controller/Annotations/Mkcol.php +++ b/Controller/Annotations/Mkcol.php @@ -20,6 +20,7 @@ * * @author Maximilian Bosch */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Mkcol extends Route { public function getMethod() diff --git a/Controller/Annotations/Move.php b/Controller/Annotations/Move.php index 312cca8c7..1fe167d24 100644 --- a/Controller/Annotations/Move.php +++ b/Controller/Annotations/Move.php @@ -20,6 +20,7 @@ * * @author Maximilian Bosch */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Move extends Route { public function getMethod() diff --git a/Controller/Annotations/Options.php b/Controller/Annotations/Options.php index 94f5ac9bd..e93167b20 100644 --- a/Controller/Annotations/Options.php +++ b/Controller/Annotations/Options.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Options extends Route { public function getMethod() diff --git a/Controller/Annotations/Patch.php b/Controller/Annotations/Patch.php index 1891681d0..3d414e07c 100644 --- a/Controller/Annotations/Patch.php +++ b/Controller/Annotations/Patch.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Patch extends Route { public function getMethod() diff --git a/Controller/Annotations/Post.php b/Controller/Annotations/Post.php index f68e1ac0b..7269dfc02 100644 --- a/Controller/Annotations/Post.php +++ b/Controller/Annotations/Post.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Post extends Route { public function getMethod() diff --git a/Controller/Annotations/PropFind.php b/Controller/Annotations/PropFind.php index 44ff2a5dd..61f5aba70 100644 --- a/Controller/Annotations/PropFind.php +++ b/Controller/Annotations/PropFind.php @@ -20,6 +20,7 @@ * * @author Maximilian Bosch */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class PropFind extends Route { public function getMethod() diff --git a/Controller/Annotations/PropPatch.php b/Controller/Annotations/PropPatch.php index 08de54704..69d9a3248 100644 --- a/Controller/Annotations/PropPatch.php +++ b/Controller/Annotations/PropPatch.php @@ -20,6 +20,7 @@ * * @author Maximilian Bosch */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class PropPatch extends Route { public function getMethod() diff --git a/Controller/Annotations/Put.php b/Controller/Annotations/Put.php index d563a599b..f1eb87e27 100644 --- a/Controller/Annotations/Put.php +++ b/Controller/Annotations/Put.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Put extends Route { public function getMethod() diff --git a/Controller/Annotations/Route.php b/Controller/Annotations/Route.php index d74562e97..9b2813bd9 100644 --- a/Controller/Annotations/Route.php +++ b/Controller/Annotations/Route.php @@ -20,6 +20,7 @@ * @NamedArgumentConstructor * @Target({"CLASS", "METHOD"}) */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] class Route extends BaseRoute { public function __construct( diff --git a/Controller/Annotations/Unlink.php b/Controller/Annotations/Unlink.php index a09ce09cb..34a8d03f6 100644 --- a/Controller/Annotations/Unlink.php +++ b/Controller/Annotations/Unlink.php @@ -18,6 +18,7 @@ * @NamedArgumentConstructor * @Target("METHOD") */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Unlink extends Route { public function getMethod() diff --git a/Controller/Annotations/Unlock.php b/Controller/Annotations/Unlock.php index 52482c899..91c33cfa2 100644 --- a/Controller/Annotations/Unlock.php +++ b/Controller/Annotations/Unlock.php @@ -20,6 +20,7 @@ * * @author Maximilian Bosch */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD)] class Unlock extends Route { public function getMethod() diff --git a/Resources/doc/annotations-reference.rst b/Resources/doc/annotations-reference.rst index d9d1f73ea..9cf2b4a15 100644 --- a/Resources/doc/annotations-reference.rst +++ b/Resources/doc/annotations-reference.rst @@ -92,4 +92,44 @@ to define routes limited to a specific HTTP method: ``@Delete``, ``@Get``, ``@Unlock``, ``@PropFind``, ``@PropPatch``, ``@Move``, ``@Mkcol``, ``@Copy``. All of them have the same options as ``@Route``. +Example: + +.. configuration-block:: + + .. code-block:: php-annotations + + // src/Controller/BlogController.php + namespace App\Controller; + + use FOS\RestBundle\Controller\AbstractFOSRestController; + use FOS\RestBundle\Controller\Annotations as Rest; + + class BlogController extends AbstractFOSRestController + { + /** + * @Rest\Get("/blog", name="blog_list") + */ + public function list() + { + // ... + } + } + + .. code-block:: php-attributes + + // src/Controller/BlogController.php + namespace App\Controller; + + use FOS\RestBundle\Controller\AbstractFOSRestController; + use FOS\RestBundle\Controller\Annotations as Rest; + + class BlogController extends AbstractFOSRestController + { + #[Rest\Get('/blog', name: 'blog_list')] + public function list() + { + // ... + } + } + .. _`@Route Symfony annotation`: https://symfony.com/doc/current/routing.html diff --git a/Tests/Functional/Bundle/TestBundle/Controller/RouteAttributesController.php b/Tests/Functional/Bundle/TestBundle/Controller/RouteAttributesController.php new file mode 100644 index 000000000..bb1350be2 --- /dev/null +++ b/Tests/Functional/Bundle/TestBundle/Controller/RouteAttributesController.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\RestBundle\Tests\Functional\Bundle\TestBundle\Controller; + +use FOS\RestBundle\Controller\AbstractFOSRestController; +use FOS\RestBundle\Controller\Annotations as Rest; +use FOS\RestBundle\View\View; +use Symfony\Component\HttpFoundation\Request; + +/** + * Controller to test native PHP8 Route attributes. + */ +#[Rest\Route('/products')] +class RouteAttributesController extends AbstractFOSRestController +{ + /** + * @return View view instance + * + * @Rest\View() + */ + #[Rest\Get(path: '/{page}', name: 'product_list', requirements: ['page' => '\d+'], defaults: ['_format' => 'json'])] + public function listAction(int $page) + { + $view = $this->view([ + ['name' => 'product1'], + ['name' => 'product2'], + ]); + + return $view; + } + + /** + * @return View view instance + * + * @Rest\View() + */ + #[Rest\Post(path: '', name: 'product_create')] + public function createAction(Request $request) + { + $view = $this->view([ + 'name' => 'product1', + ], 201); + + return $view; + } +} diff --git a/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml b/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml index f3c307683..054294b10 100644 --- a/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml +++ b/Tests/Functional/Bundle/TestBundle/Resources/config/routing.yml @@ -70,3 +70,6 @@ test_allowed_methods2: path: /allowed-methods methods: ['POST', 'PUT'] defaults: { _controller: FOS\RestBundle\Tests\Functional\Bundle\TestBundle\Controller\AllowedMethodsController::indexAction } + +test_route_attributes: + resource: FOS\RestBundle\Tests\Functional\Bundle\TestBundle\Controller\RouteAttributesController diff --git a/Tests/Functional/RouteAttributesTest.php b/Tests/Functional/RouteAttributesTest.php new file mode 100644 index 000000000..7f011f302 --- /dev/null +++ b/Tests/Functional/RouteAttributesTest.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\RestBundle\Tests\Functional; + +/** + * @requires PHP 8 + */ +class RouteAttributesTest extends WebTestCase +{ + private const TEST_CASE = 'RouteAttributes'; + private static $client; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + static::$client = static::createClient(['test_case' => self::TEST_CASE]); + } + + public static function tearDownAfterClass(): void + { + self::deleteTmpDir(self::TEST_CASE); + parent::tearDownAfterClass(); + } + + public function testGet() + { + static::$client->request( + 'GET', + '/products/1', + [], + [], + ['HTTP_ACCEPT' => 'application/json'] + ); + + $this->assertSame(200, static::$client->getResponse()->getStatusCode()); + $this->assertJsonStringEqualsJsonString( + '[{"name": "product1"},{"name": "product2"}]', + static::$client->getResponse()->getContent() + ); + } + + public function testPost() + { + static::$client->request( + 'POST', + '/products', + [], + [], + ['HTTP_ACCEPT' => 'application/json'] + ); + + $this->assertSame(201, static::$client->getResponse()->getStatusCode()); + $this->assertJsonStringEqualsJsonString( + '{"name": "product1"}', + static::$client->getResponse()->getContent() + ); + } + + public function testInvalidQueryParameter() + { + static::$client->request( + 'GET', + '/products/foo', + [], + [], + ['HTTP_ACCEPT' => 'application/json'] + ); + + $this->assertSame(404, static::$client->getResponse()->getStatusCode()); + } +} diff --git a/Tests/Functional/app/RouteAttributes/bundles.php b/Tests/Functional/app/RouteAttributes/bundles.php new file mode 100644 index 000000000..f19d6088e --- /dev/null +++ b/Tests/Functional/app/RouteAttributes/bundles.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +return [ + new \Symfony\Bundle\FrameworkBundle\FrameworkBundle(), + new \Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(), + new \FOS\RestBundle\FOSRestBundle(), + new \FOS\RestBundle\Tests\Functional\Bundle\TestBundle\TestBundle(), + new \Symfony\Bundle\TwigBundle\TwigBundle(), +]; diff --git a/Tests/Functional/app/RouteAttributes/config.yml b/Tests/Functional/app/RouteAttributes/config.yml new file mode 100644 index 000000000..980a460bd --- /dev/null +++ b/Tests/Functional/app/RouteAttributes/config.yml @@ -0,0 +1,22 @@ +imports: + - { resource: ../config/default.yml } + - { resource: ../config/sensio_framework_extra.yml } + +framework: + serializer: + enabled: true + +fos_rest: + view: + view_response_listener: 'force' + formats: + xml: true + json: true + body_listener: true + format_listener: + rules: + - { path: ^/, priorities: [ json, xml ], fallback_format: ~, prefer_extension: true } + +twig: + exception_controller: ~ + strict_variables: '%kernel.debug%'