Skip to content

feat: add support for HTTP request mapping using attributes - #46

Merged
romm merged 1 commit into
CuyZ:masterfrom
romm:feat/http-request
Mar 23, 2026
Merged

romm merged 1 commit into
CuyZ:masterfrom
romm:feat/http-request

Conversation

@romm

@romm romm commented Mar 23, 2026

Copy link
Copy Markdown
Member

The bundle provides automatic mapping of HTTP request values to controller arguments using attributes. This feature leverages Valinor's mapping capabilities to handle route parameters, query values, and request body data.

Lean more about HTTP request mapping in the library documentation.

Note that Symfony provides a similar built-in solution, which makes use of attributes like #[MapQueryString] and #[MapRequestPayload]. This bundle can bring some additional features:

  • Ability to map advanced types like non-empty-string, positive-int, int<10, 100> and more.
  • Precise error messages when a request contains invalid values.
  • Easy customization of the mapping process using mapper configurators.
  • And, in the end, any other feature provided by Valinor's mapping system.

Basic usage

Using the #[MapRequest] on a controller's method enables automatic mapping.

Arguments can be mapped from different sources:

  • Route parameters — using #[FromRoute] attribute
  • Query parameters — using #[FromQuery] attribute
  • Request body — using #[FromBody] attribute

Basic example:

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
     *
     * @param positive-int $page
     * @param int<10, 100> $limit
     */
    #[Route('/api/authors/{authorId}/articles', methods: 'GET')]
    #[MapRequest]
    public function __invoke(
        // Comes from the route
        #[FromRoute] string $authorId,

        // All come from query parameters
        #[FromQuery] string $status,
        #[FromQuery] int $page = 1,
        #[FromQuery] int $limit = 10,
    ): Response { /* … */ }
}

Per-controller mapper configuration

You can customize the mapper behavior for a specific controller by passing mapper configurators to the #[MapRequest] attribute:

use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Http\FromBody;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class CreateAuthor
{
    #[Route('/api/authors/new', methods: 'POST')]
    #[MapRequest(new ConvertKeysToCamelCase())]
    public function __invoke(
        #[FromBody] string $name,
        #[FromBody] DateTimeInterface $birthDate,
    ): Response { /* … */ }
}

APIs often need to define rules concerning the keys cases passed in the request; this can be defined using the following configurators:

Custom request mapping attribute

When multiple controllers share the same mapper configuration (date formats, key case rules, etc.), a custom attribute can be created to avoid repeating the same configurators on every controller.

This is done by implementing the MapRequestAttribute interface directly:

use Attribute;
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Configurator\RestrictKeysToSnakeCase;
use CuyZ\Valinor\MapperBuilder;
use CuyZ\ValinorBundle\Http\MapRequestAttribute;

#[Attribute(Attribute::TARGET_METHOD)]
final class MyAppMapRequest implements MapRequestAttribute
{
    public function __construct(
        /** @var list<non-empty-string> */
        private array $dateFormats = ['Y-m-d', 'Y-m-d H:i:s'],
        private bool $allowSuperfluousKeys = false,
    ) {}

    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        $builder = $builder->configureWith(
            // Always restrict keys to `snake_case`
            new RestrictKeysToSnakeCase(),
            // Always convert keys to `camelCase`
            new ConvertKeysToCamelCase(),
        );

        $builder = $builder->supportDateFormats(...$this->dateFormats);

        if ($this->allowSuperfluousKeys) {
            $builder = $builder->allowSuperfluousKeys();
        }

        return $builder;
    }
}

It can then be used in place of #[MapRequest] on any controller method:

use CuyZ\Valinor\Mapper\Http\FromBody;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class CreateComment
{
    #[Route('/api/comments', methods: 'POST')]
    #[MyAppMapRequest(
        dateFormats: ['d/m/Y'],
        allowSuperfluousKeys: true,
    )]
    public function __invoke(
        #[FromBody] string $author,
        #[FromBody] string $content,
    ): Response { /* … */ }
}

Error handling

When mapping fails, the bundle throws an HttpRequestMappingError exception with a 422 Unprocessable Entity status code. The error message includes all validation errors. Example:

HTTP request is invalid, a total of 2 error(s) were found:
- page: value 0 is not a valid positive integer.
- limit: value 150 is not a valid integer between 10 and 100.

Mapping all parameters at once

Instead of mapping individual query parameters or body values to separate parameters, the mapAll option can be used to map all of them at once to a single parameter. This is useful when working with complex data structures or when the number of parameters is large.

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

final readonly class ArticleFilters
{
    public function __construct(
        public string $status,
        /** @var positive-int */
        public int $page = 1,
        /** @var int<10, 100> */
        public int $limit = 10,
    ) {}
}

#[AsController]
final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
     */
    #[Route('/api/authors/{authorId}/articles', methods: 'GET')]
    #[MapRequest]
    public function __invoke(
        #[FromRoute] string $authorId,
        #[FromQuery(mapAll: true)] ArticleFilters $filters,
    ): Response { /* … */ }
}

The same approach works with #[FromBody(mapAll: true)] for body values.

Request object mapping

When a controller needs to access the original request object, it can be directly added as an argument:

use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class ListArticles
{
    #[Route('/api/authors/{authorId}/articles', methods: 'GET')]
    #[MapRequest]
    public function __invoke(
        // Request object injected automatically
        Request $request,

        #[FromRoute] string $authorId,
    ): Response {
        if ($request->headers->has('My-Customer-Header')) {
            // …
        }
    }
}

Note — by enabling the valinor.http.convert_request_to_psr configuration, controllers can type-hint a PSR-7 ServerRequestInterface parameter instead of Symfony's Request. The bundle will automatically convert the incoming Symfony request to a PSR-7 instance.

This requires the symfony/psr-http-message-bridge package to be installed.

The bundle provides automatic mapping of HTTP request values to
controller arguments using attributes. This feature leverages Valinor's
mapping capabilities to handle route parameters, query values, and
request body data.

Lean more about HTTP request mapping [in the library
documentation](https://valinor-php.dev/latest/how-to/map-http-request/).

Note that Symfony provides a similar built-in solution, which makes use
of attributes like `#[MapQueryString]` and `#[MapRequestPayload]`. This
bundle can bring some additional features:

- Ability to map advanced types like `non-empty-string`, `positive-int`,
  `int<10, 100>` and more.
- Precise error messages when a request contains invalid values.
- Easy customization of the mapping process using [mapper
  configurators].
- And, in the end, any other feature provided by Valinor's mapping
  system.

Basic usage
-----------

Using the `#[MapRequest]` on a controller's method enables automatic
mapping.

Arguments can be mapped from different sources:

- **Route parameters** — using `#[FromRoute]` attribute
- **Query parameters** — using `#[FromQuery]` attribute
- **Request body** — using `#[FromBody]` attribute

Basic example:

```php
use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
     *
     * @PARAM positive-int $page
     * @PARAM int<10, 100> $limit
     */
    #[Route('/api/authors/{authorId}/articles', methods: 'GET')]
    #[MapRequest]
    public function __invoke(
        // Comes from the route
        #[FromRoute] string $authorId,

        // All come from query parameters
        #[FromQuery] string $status,
        #[FromQuery] int $page = 1,
        #[FromQuery] int $limit = 10,
    ): Response { /* … */ }
}
```

Per-controller mapper configuration
-----------------------------------

You can customize the mapper behavior for a specific controller by
passing [mapper configurators] to the `#[MapRequest]` attribute:

```php
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Http\FromBody;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class CreateAuthor
{
    #[Route('/api/authors/new', methods: 'POST')]
    #[MapRequest(new ConvertKeysToCamelCase())]
    public function __invoke(
        #[FromBody] string $name,
        #[FromBody] DateTimeInterface $birthDate,
    ): Response { /* … */ }
}
```

APIs often need to define rules concerning the keys cases passed in the
request; this can be defined using the following configurators:

- [Restricting key case configurators] — restricting keys to
  `camelCase`, `PascalCase`, `snake_case` or `kebab-case`.
- [Converting key case configurators] — automatically converting keys to
  `camelCase` or `snake_case`.

[Restricting key case configurators]: https://valinor-php.dev/latest/how-to/use-provided-mapper-configurators/#restricting-key-case
[Converting key case configurators]: https://valinor-php.dev/latest/how-to/use-provided-mapper-configurators/#converting-key-case

Custom request mapping attribute
--------------------------------

When multiple controllers share the same mapper configuration (date
formats, key case rules, etc.), a custom attribute can be created to
avoid repeating the same configurators on every controller.

This is done by implementing the `MapRequestAttribute` interface
directly:

```php
use Attribute;
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Configurator\RestrictKeysToSnakeCase;
use CuyZ\Valinor\MapperBuilder;
use CuyZ\ValinorBundle\Http\MapRequestAttribute;

#[Attribute(Attribute::TARGET_METHOD)]
final class MyAppMapRequest implements MapRequestAttribute
{
    public function __construct(
        /** @var list<non-empty-string> */
        private array $dateFormats = ['Y-m-d', 'Y-m-d H:i:s'],
        private bool $allowSuperfluousKeys = false,
    ) {}

    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        $builder = $builder->configureWith(
            // Always restrict keys to `snake_case`
            new RestrictKeysToSnakeCase(),
            // Always convert keys to `camelCase`
            new ConvertKeysToCamelCase(),
        );

        $builder = $builder->supportDateFormats(...$this->dateFormats);

        if ($this->allowSuperfluousKeys) {
            $builder = $builder->allowSuperfluousKeys();
        }

        return $builder;
    }
}
```

It can then be used in place of `#[MapRequest]` on any controller
method:

```php
use CuyZ\Valinor\Mapper\Http\FromBody;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class CreateComment
{
    #[Route('/api/comments', methods: 'POST')]
    #[MyAppMapRequest(
        dateFormats: ['d/m/Y'],
        allowSuperfluousKeys: true,
    )]
    public function __invoke(
        #[FromBody] string $author,
        #[FromBody] string $content,
    ): Response { /* … */ }
}
```

Error handling
--------------

When mapping fails, the bundle throws an `HttpRequestMappingError`
exception with a `422 Unprocessable Entity` status code. The error
message includes all validation errors. Example:

```php
HTTP request is invalid, a total of 2 error(s) were found:
- page: value 0 is not a valid positive integer.
- limit: value 150 is not a valid integer between 10 and 100.
```

Mapping all parameters at once
------------------------------

Instead of mapping individual query parameters or body values to
separate parameters, the `mapAll` option can be used to map all of them
at once to a single parameter. This is useful when working with complex
data structures or when the number of parameters is large.

```php
use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

final readonly class ArticleFilters
{
    public function __construct(
        public string $status,
        /** @var positive-int */
        public int $page = 1,
        /** @var int<10, 100> */
        public int $limit = 10,
    ) {}
}

#[AsController]
final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
     */
    #[Route('/api/authors/{authorId}/articles', methods: 'GET')]
    #[MapRequest]
    public function __invoke(
        #[FromRoute] string $authorId,
        #[FromQuery(mapAll: true)] ArticleFilters $filters,
    ): Response { /* … */ }
}
```

The same approach works with `#[FromBody(mapAll: true)]` for body
values.

Request object mapping
----------------------

When a controller needs to access the original request object, it can be
directly added as an argument:

```php
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
final class ListArticles
{
    #[Route('/api/authors/{authorId}/articles', methods: 'GET')]
    #[MapRequest]
    public function __invoke(
        // Request object injected automatically
        Request $request,

        #[FromRoute] string $authorId,
    ): Response {
        if ($request->headers->has('My-Customer-Header')) {
            // …
        }
    }
}
```

**Note** — by enabling the [`valinor.http.convert_request_to_psr`
configuration](#bundle-configuration), controllers can type-hint a PSR-7
`ServerRequestInterface` parameter instead of Symfony's `Request`. The
bundle will automatically convert the incoming Symfony request to a
PSR-7 instance.

This requires the `symfony/psr-http-message-bridge` package to be
installed.

[mapper configurators]: https://valinor-php.dev/latest/how-to/use-provided-mapper-configurators/
@romm
romm force-pushed the feat/http-request branch from 7dfd3b7 to 2536003 Compare March 23, 2026 17:55
@romm
romm merged commit 3b4c6b3 into CuyZ:master Mar 23, 2026
14 checks passed
@romm
romm deleted the feat/http-request branch March 23, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant