跳至正文

更新日志 2.4.0 — 2026 年 3 月 23 日

重要变更

此版本为库带来了一整套新功能:

享受! 🎉


HTTP 请求映射支持

该库现在提供了一种将 HTTP 请求映射到控制器操作参数或对象属性的方法。参数可以从路由、查询和正文值映射。

三个特性可用于将参数显式绑定到单个源,确保永远不会从错误的源解析该值:

  • #[FromRoute] — 用于路由器从 URL 路径中提取的参数
  • #[FromQuery] — 用于查询字符串参数
  • #[FromBody] — 用于请求正文值

如果参数未绑定到特定源,则可以完全省略这些特性,在这种情况下,如果在多个源中找到相同的键,则会引发冲突错误。

这为控制器提供了干净、类型安全的签名,无需耦合到框架的请求对象,同时受益于库的验证和错误处理。

正常映射规则适用于此处:参数是必需的,除非它们具有默认值。

来自 HTTP 请求的路由和查询参数值通常是字符串。映射器自动处理这些参数的标量值转换:字符串"42"将正确映射到int参数。

使用特性映射请求

考虑一个列出给定作者文章的 API。作者标识符来自 URL 路径,而过滤和分页来自查询字符串。

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
     *
     * @param non-empty-string $page
     * @param positive-int $page
     * @param int<10, 100> $limit
     */
    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,
    ): ResponseInterface { … }
}

// GET /api/authors/42/articles?status=published&page=2
$request = new HttpRequest(
    routeParameters: ['authorId' => 42],
    queryParameters: [
        'status' => 'published',
        'page' => 2,
    ],
);

$controller = new ListArticles();

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments($controller, $request);

$response = $controller(...$arguments);

不使用特性映射请求

当不需要区分参数来自哪个源时,可以完全省略该特性 - 映射器将从包含匹配键的源解析每个参数。

use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

final class PostComment
{
    /**
     * POST /api/posts/{postId}/comments
     *
     * @param non-empty-string $author
     * @param non-empty-string $content
     */
    public function __invoke(
        int $postId,
        string $author,
        string $content,
    ): ResponseInterface { … }
}

// POST /api/posts/1337/comments
$request = new HttpRequest(
    routeParameters: ['postId' => 1337],
    bodyValues: [
        'author' => 'jane.doe@example.com',
        'content' => 'Great article, thanks for sharing!',
    ],
);

$controller = new PostComment();

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments($controller, $request);

$response = $controller(...$arguments);

注意

如果在多个源中找到没有特性的参数的相同键,则会引发冲突错误。

一次映射所有参数

可以使用asRoot选项将所有查询参数或正文值一次映射到单个参数,而不是将各个查询参数或正文值映射到单独的参数。当处理复杂的数据结构或参数数量很大时,这非常有用。

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;

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

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

对于正文值,同样的方法适用于#[FromBody(asRoot: true)] 。

暗示

成形数组可以与asRoot一起使用,将所有值映射到单个参数:

use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;

final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles?status=X&&page=X&limit=X
     *
     * @param array{
     *     status: non-empty-string,
     *     page?: positive-int,
     *     limit?: int<10, 100>,
     * } $filters
     */
    public function __invoke(
        #[FromRoute] string $authorId,
        #[FromQuery(asRoot: true)] array $filters,
    ): ResponseInterface { … }
}

映射到对象

HttpRequest可以直接映射到对象,而不是映射到可调用的参数。这些属性在构造函数参数或提升属性上的工作方式相同。

use CuyZ\Valinor\Mapper\Http\FromBody;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

final readonly class PostComment
{
    public function __construct(
        #[FromRoute] public int $postId,
        /** @var non-empty-string */
        #[FromBody] public string $author,
        /** @var non-empty-string */
        #[FromBody] public string $content,
    ) {}
}

$request = new HttpRequest(
    routeParameters: ['postId' => 1337],
    bodyValues: [
        'author' => 'jane.doe@example.com',
        'content' => 'Great article, thanks for sharing!',
    ],
);

$comment = (new MapperBuilder())
    ->mapper()
    ->map(PostComment::class, $request);

// $comment->postId  === 1337
// $comment->author  === 'jane.doe@example.com'
// $comment->content === 'Great article, thanks for sharing!'

使用 PSR-7 请求

HttpRequest实例可以直接从PSR-7 ServerRequestInterface构建。这是与使用 PSR-7 的框架集成时推荐的方法。

use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;

// `$psrRequest` is a PSR-7 `ServerRequestInterface` instance
// `$routeParameters` are the parameters extracted by the router
$request = HttpRequest::fromPsr($psrRequest, $routeParameters);

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments($controller, $request);

工厂方法从getQueryParams()中提取查询参数,从getParsedBody()中提取正文值。它还传递原始 PSR-7 请求对象,因此可以根据需要将其注入控制器参数中(见下文)。

访问原始请求对象

构建HttpRequest时,可以提供原始请求对象。如果控制器参数的类型与该对象匹配,则会自动注入;不需要任何特性。

use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;
use Psr\Http\Message\ServerRequestInterface;

final class ListArticles
{
    /**
     * GET /api/authors/{authorId}/articles
     */
    public function __invoke(
        // Request object injected automatically
        ServerRequestInterface $request,

        #[FromRoute] string $authorId,
    ): ResponseInterface {
        $acceptHeader = $request->getHeaderLine('Accept');

        // …
    }
}

$request = HttpRequest::fromPsr($psrRequest, $routeParameters);

$arguments = (new MapperBuilder())
    ->argumentsMapper()
    ->mapArguments(new ListArticles(), $request);

// $arguments['request'] is the original PSR-7 request instance

错误处理

当映射失败时(例如,由于缺少必需的查询参数或主体值的类型错误),会抛出MappingError ,就像常规映射一样。

请阅读验证和错误处理章节以获取更多信息。


映射器/规范化器配置器支持

在两个构建器上引入MapperBuilderConfigurator和NormalizerBuilderConfigurator接口以及configureWith()方法。

配置器是可重用的配置逻辑,可以应用于MapperBuilder或NormalizerBuilder实例。当需要在应用程序的多个位置应用相同的配置,或者需要将配置逻辑作为包分发时,这非常有用。

在下面的示例中,我们将两个配置设置应用于单个类内的MapperBuilder ,但这可以包含任意数量的自定义,具体取决于应用程序的需求。

namespace My\App;

use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\Mapper\Configurator\MapperBuilderConfigurator;

final class ApplicationMappingConfigurator implements MapperBuilderConfigurator
{
    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        return $builder
            ->allowSuperfluousKeys()
            ->registerConstructor(
                \My\App\CustomerId::fromString(...),
            );
    }
}

该配置器可以在MapperBuilder实例中注册:

$result = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(new \My\App\ApplicationMappingConfigurator())
    ->mapper()
    ->map(\My\App\User::class, [
        'id' => '604e4b36-5b76-4b1a-9e6c-02d5acb53a4d',
        'name' => 'John Doe',
        'extraField' => 'ignored because superfluous keys are allowed',
    ]);

组合多个配置器

可以组合多个配置器来组成最终配置。每个配置器都按顺序应用,允许分层和模块化配置。

namespace My\App;

use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\Mapper\Configurator\MapperBuilderConfigurator;

final class FlexibleMappingConfigurator implements MapperBuilderConfigurator
{
    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        return $builder
            ->allowScalarValueCasting()
            ->allowSuperfluousKeys();
    }
}

final class DomainConstructorsConfigurator implements MapperBuilderConfigurator
{
    public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
    {
        return $builder
            ->registerConstructor(
                \My\App\CustomerId::fromString(...),
                \My\App\Email::fromString(...),
            );
    }
}

$result = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \My\App\FlexibleMappingConfigurator(),
        new \My\App\DomainConstructorsConfigurator(),
    )
    ->mapper()
    ->map(\My\App\User::class, $someData);

这种方法使每个配置器专注于一个问题,使它们更容易独立测试和重用。

使用NormalizerBuilderConfigurator

相同的配置器逻辑可以应用于NormalizerBuilder :

namespace My\App;

use CuyZ\Valinor\NormalizerBuilder;
use CuyZ\Valinor\Normalizer\Configurator\NormalizerBuilderConfigurator;

final class DomainObjectConfigurator implements NormalizerBuilderConfigurator
{
    public function configureNormalizerBuilder(NormalizerBuilder $builder): NormalizerBuilder
    {
        return $builder
            ->registerTransformer(
                fn (\DateTimeInterface $date) => $date->format('Y-m-d')
            )
            ->registerTransformer(
                fn (\My\App\Money $money) => [
                    'amount' => $money->amount,
                    'currency' => $money->currency->value,
                ]
            );
    }
}

final class SensitiveDataConfigurator implements NormalizerBuilderConfigurator
{
    public function configureNormalizerBuilder(NormalizerBuilder $builder): NormalizerBuilder
    {
        return $builder
            ->registerTransformer(
                fn (\My\App\EmailAddress $email) => '***@' . $email->domain()
            );
    }
}

$json = (new \CuyZ\Valinor\NormalizerBuilder())
    ->configureWith(
        new \My\App\DomainObjectConfigurator(),
        new \My\App\SensitiveDataConfigurator(),
    )
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::json())
    ->normalize($someObject);

CamelCase/snake_case 键转换支持

有两个配置器可用于在将输入数据的键映射到对象属性或指定结构的数组键之前转换它们。这允许接受具有与 PHP 代码库中使用的命名约定不同的命名约定的数据。

ConvertKeysToCamelCase

转换
first_name → firstName
FirstName → firstName
first-name → firstName
$user = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase()
    )
    ->mapper()
    ->map(\My\App\User::class, [
        'first_name' => 'John', // mapped to `$firstName`
        'last_name' => 'Doe',   // mapped to `$lastName`
    ]);

ConvertKeysToSnakeCase

转换
firstName → first_name
FirstName → first_name
first-name → first_name
$user = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \CuyZ\Valinor\Mapper\Configurator\ConvertKeysToSnakeCase()
    )
    ->mapper()
    ->map(\My\App\User::class, [
        'firstName' => 'John', // mapped to `$first_name`
        'lastName' => 'Doe',   // mapped to `$last_name`
    ]);

该配置器可以与键限制配置器结合使用,以便在一个步骤中验证和转换键。必须在转换之前注册限制配置器,以便在原始输入键上运行验证。

use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Configurator\RestrictKeysToSnakeCase;

$user = (new MapperBuilder())
    ->configureWith(
        new RestrictKeysToSnakeCase(),
        new ConvertKeysToCamelCase(),
    )
    ->mapper()
    ->map(User::class, [
        'first_name' => 'John',
        'last_name' => 'Doe',
    ]);

键大小写限制支持

将输入数据映射到对象或指定结构的数组时,四个配置器限制接受哪种键名的大小写格式。如果键与预期大小写不匹配,则会引发映射错误。

例如,这有助于在 API 的输入中强制执行一致的命名约定,以确保 JSON 负载仅包含camelCase 、 snake_case 、 PascalCase或kebab-case键。

可用的配置器:

配置器 示例
new RestrictKeysToCamelCase() firstName
new RestrictKeysToPascalCase() FirstName
new RestrictKeysToSnakeCase() first_name
new RestrictKeysToKebabCase() first-name
$user = (new \CuyZ\Valinor\MapperBuilder())
    ->configureWith(
        new \CuyZ\Valinor\Mapper\Configurator\RestrictKeysToCamelCase()
    )
    ->mapper()
    ->map(\My\App\User::class, [
        'firstName' => 'John', // Ok
        'last_name' => 'Doe',  // Error
    ]);

新功能

  • 添加 HTTP 请求映射支持 ( 385f0c )
  • 添加对映射器和规范化器构建器的配置器支持( 49dd0a )
  • 添加映射器配置器以将键转换为camelCase/snake_case ( a92bd3 )
  • 添加映射器配置器以限制按键情况( 0be7dc )
  • 引入键转换器来转换源键( bfd4ab )

错误修复

  • 允许将单个值映射到列表类型( 7241a6 )
  • 禁止重复的转换键 ( 498dcf )
  • 处理并发缓存目录竞争条件( 13f06d )
  • 使用FileWatchingCache ( d445e4 ) 时正确使缓存条目无效

内部的

部门
  • 更新依赖项( 73a1cb )
  • 更新 mkdocs 依赖项 ( 67a6b4 )