mirror of
https://github.com/danog/Valinor.git
synced 2024-11-26 20:24:40 +01:00
doc: introduce mkdocs as a static documentation generator
This commit is contained in:
parent
eebc6511a9
commit
56ff6849bc
96
.github/workflows/docs.yml
vendored
Normal file
96
.github/workflows/docs.yml
vendored
Normal file
@ -0,0 +1,96 @@
|
||||
name: Publish docs
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Prepare
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
status: ${{ steps.check.outputs.status }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
tag: ${{ steps.check.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Find version
|
||||
id: get_version
|
||||
uses: battila7/get-version-action@v2
|
||||
|
||||
- name: Version format check
|
||||
id: check
|
||||
run: |
|
||||
if [ "${{ steps.get_version.outputs.is-semver }}" == "true" ]
|
||||
then
|
||||
echo "::set-output name=status::ok"
|
||||
echo "::set-output name=version::${{ steps.get_version.outputs.major }}.${{ steps.get_version.outputs.minor }}"
|
||||
echo "::set-output name=tag::${{ steps.get_version.outputs.version-without-v }}"
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Deploy docs
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
if: needs.check.outputs.status == 'ok'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r docs/requirements.txt
|
||||
|
||||
- name: Set up git author
|
||||
uses: oleksiyrudenko/gha-git-credentials@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Find latest release
|
||||
id: latest
|
||||
uses: pozetroninc/github-action-get-latest-release@v0.6.0
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
excludes: draft
|
||||
|
||||
- name: Deploy docs
|
||||
env:
|
||||
SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
|
||||
run: |
|
||||
# Setup SSH deploy key
|
||||
mkdir -p ~/.ssh
|
||||
echo "${SSH_DEPLOY_KEY}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H github.com > ~/.ssh/known_hosts
|
||||
|
||||
- run: git remote add doc git@github.com:CuyZ/Valinor-Documentation.git
|
||||
- run: git fetch doc gh-pages --verbose
|
||||
|
||||
- run: |
|
||||
# Check if the "latest" alias exists
|
||||
VALINOR_HAS_LATEST=$(mike list --config-file docs/mkdocs.yml --rebase --remote doc | grep latest) || true
|
||||
|
||||
# If so then it is set as the default version (to enable the index redirect)
|
||||
if [ "${VALINOR_HAS_LATEST}" != "" ]
|
||||
then
|
||||
echo "Set latest as default"
|
||||
mike set-default latest --config-file docs/mkdocs.yml --remote doc
|
||||
fi
|
||||
|
||||
- run: |
|
||||
if [ "${{ steps.latest.outputs.release }}" = "${{ needs.check.outputs.tag }}" ]
|
||||
then
|
||||
# Here we deploy a new latest version
|
||||
mike deploy ${{ needs.check.outputs.version }} latest --config-file docs/mkdocs.yml --update-aliases --push --remote doc
|
||||
else
|
||||
# Here we deploy a version that's not the latest one
|
||||
mike deploy ${{ needs.check.outputs.version }} --config-file docs/mkdocs.yml --push --remote doc
|
||||
fi
|
@ -59,6 +59,11 @@
|
||||
"@putenv XDEBUG_MODE=coverage",
|
||||
"phpunit --coverage-xml='var/cache/phpunit/coverage' --log-junit='var/cache/phpunit/coverage/junit.xml'",
|
||||
"infection --threads=12 --skip-initial-tests --coverage='var/cache/phpunit/coverage'"
|
||||
],
|
||||
"doc": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"pip install -r docs/requirements.txt",
|
||||
"mkdocs serve --config-file docs/mkdocs.yml"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
|
17
docs/includes/links.md
Normal file
17
docs/includes/links.md
Normal file
@ -0,0 +1,17 @@
|
||||
[PHPStan]: https://phpstan.org/
|
||||
|
||||
[Psalm]: https://psalm.dev/
|
||||
|
||||
[Rector]: https://github.com/rectorphp/rector
|
||||
|
||||
[Webmozart Assert]: https://github.com/webmozarts/assert
|
||||
|
||||
[link-packagist]: https://packagist.org/packages/cuyz/valinor
|
||||
|
||||
[ICU library]: https://unicode-org.github.io/icu/
|
||||
|
||||
[ICU documentation]: https://unicode-org.github.io/icu/userguide/format_parse/messages/
|
||||
|
||||
[contributors]: https://github.com/CuyZ/Valinor/graphs/contributors
|
||||
|
||||
[blackfire-logo]: img/blackfire-logo.svg "Blackfire logo"
|
74
docs/mkdocs.yml
Normal file
74
docs/mkdocs.yml
Normal file
@ -0,0 +1,74 @@
|
||||
# yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json
|
||||
|
||||
site_name: Valinor
|
||||
repo_url: https://github.com/CuyZ/Valinor
|
||||
repo_name: CuyZ/Valinor
|
||||
edit_uri: edit/master/docs/
|
||||
docs_dir: pages
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/CuyZ
|
||||
- icon: fontawesome/solid/globe
|
||||
link: https://cuyz.io
|
||||
version:
|
||||
provider: mike
|
||||
|
||||
theme:
|
||||
name: material
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
logo: material/pine-tree
|
||||
features:
|
||||
- navigation.sections
|
||||
- navigation.top
|
||||
- navigation.indexes
|
||||
- content.code.annotate
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: deep purple
|
||||
accent: deep purple
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: deep purple
|
||||
accent: deep purple
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
|
||||
markdown_extensions:
|
||||
- meta
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
extend_pygments_lang:
|
||||
- name: php
|
||||
lang: php
|
||||
options:
|
||||
startinline: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets:
|
||||
auto_append:
|
||||
- docs/includes/links.md
|
||||
- pymdownx.superfences
|
||||
- admonition
|
||||
|
||||
nav:
|
||||
- Introduction: index.md
|
||||
- Getting Started: getting-started.md
|
||||
- Credits: credits.md
|
||||
- Usage:
|
||||
- Validation: validation.md
|
||||
- Message customization: message-customization.md
|
||||
- Source: source.md
|
||||
- Mapping:
|
||||
- Construction strategy: mapping/construction-strategy.md
|
||||
- Inferring interfaces: mapping/inferring-interfaces.md
|
||||
- Handled types: mapping/handled-types.md
|
||||
- Other:
|
||||
- Performance & caching: other/performance-and-cache.md
|
||||
- Static analysis: other/static-analysis.md
|
10
docs/pages/credits.md
Normal file
10
docs/pages/credits.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Credits & thank you
|
||||
|
||||
The development of this library is mainly motivated by the kind words and the
|
||||
help of many people. I am grateful to everyone, especially to the [contributors]
|
||||
of this repository who directly help to push the project forward.
|
||||
|
||||
I also want to thank
|
||||
[![blackfire-logo] Blackfire](https://www.blackfire.io/?utm_source=valinor&utm_medium=readme&utm_campaign=free-open-source)
|
||||
for providing a license of their awesome tool, leading to notable performance
|
||||
gains when using this library.
|
121
docs/pages/getting-started.md
Normal file
121
docs/pages/getting-started.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Getting started
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require cuyz/valinor
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
An application must handle the data coming from an external API; the response
|
||||
has a JSON format and describes a thread and its answers. The validity of this
|
||||
input is unsure, besides manipulating a raw JSON string is laborious and
|
||||
inefficient.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1337,
|
||||
"content": "Do you like potatoes?",
|
||||
"date": "1957-07-23 13:37:42",
|
||||
"answers": [
|
||||
{
|
||||
"user": "Ella F.",
|
||||
"message": "I like potatoes",
|
||||
"date": "1957-07-31 15:28:12"
|
||||
},
|
||||
{
|
||||
"user": "Louis A.",
|
||||
"message": "And I like tomatoes",
|
||||
"date": "1957-08-13 09:05:24"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The application must be certain that it can handle this data correctly; wrapping
|
||||
the input in a value object will help.
|
||||
|
||||
---
|
||||
|
||||
A schema representing the needed structure must be provided, using classes.
|
||||
|
||||
```php
|
||||
final class Thread
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly string $content,
|
||||
public readonly DateTimeInterface $date,
|
||||
/** @var Answer[] */
|
||||
public readonly array $answers,
|
||||
) {}
|
||||
}
|
||||
|
||||
final class Answer
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $user,
|
||||
public readonly string $message,
|
||||
public readonly DateTimeInterface $date,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
Then a mapper is used to hydrate a source into these objects.
|
||||
|
||||
```php
|
||||
public function getThread(int $id): Thread
|
||||
{
|
||||
$rawJson = $this->client->request("https://example.com/thread/$id");
|
||||
|
||||
try {
|
||||
return (new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(
|
||||
Thread::class,
|
||||
new \CuyZ\Valinor\Mapper\Source\JsonSource($rawJson)
|
||||
);
|
||||
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
|
||||
// Do something…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mapping advanced types
|
||||
|
||||
Although it is recommended to map an input to a value object, in some cases
|
||||
mapping to another type can be easier/more flexible.
|
||||
|
||||
It is for instance possible to map to an array of objects:
|
||||
|
||||
```php
|
||||
try {
|
||||
$objects = (new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(
|
||||
'array<' . SomeClass::class . '>',
|
||||
[/* … */]
|
||||
);
|
||||
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
|
||||
// Do something…
|
||||
}
|
||||
```
|
||||
|
||||
For simple use-cases, an array shape can be used:
|
||||
|
||||
```php
|
||||
try {
|
||||
$array = (new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(
|
||||
'array{foo: string, bar: int}',
|
||||
[/* … */]
|
||||
);
|
||||
|
||||
echo $array['foo'];
|
||||
echo $array['bar'] * 2;
|
||||
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
|
||||
// Do something…
|
||||
}
|
||||
```
|
Before Width: | Height: | Size: 558 B After Width: | Height: | Size: 558 B |
47
docs/pages/index.md
Normal file
47
docs/pages/index.md
Normal file
@ -0,0 +1,47 @@
|
||||
---
|
||||
hide:
|
||||
- toc
|
||||
---
|
||||
|
||||
Valinor • PHP object mapper with strong type support
|
||||
====================================================
|
||||
|
||||
[![Total Downloads](http://poser.pugx.org/cuyz/valinor/downloads)][link-packagist]
|
||||
[![Latest Stable Version](http://poser.pugx.org/cuyz/valinor/v)][link-packagist]
|
||||
[![PHP Version Require](http://poser.pugx.org/cuyz/valinor/require/php)][link-packagist]
|
||||
|
||||
[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2FCuyZ%2FValinor%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/CuyZ/Valinor/master)
|
||||
|
||||
Valinor is a PHP library that helps to map any input into a strongly-typed value
|
||||
object structure.
|
||||
|
||||
The conversion can handle native PHP types as well as other well-known advanced
|
||||
type annotations like array shapes, generics and more.
|
||||
|
||||
## Why?
|
||||
|
||||
There are many benefits of using value objects instead of plain arrays and
|
||||
scalar values in a modern codebase, among which:
|
||||
|
||||
1. **Data and behaviour encapsulation** — locks an object's behaviour inside its
|
||||
class, preventing it from being scattered across the codebase.
|
||||
2. **Data validation** — guarantees the valid state of an object.
|
||||
3. **Immutability** — ensures the state of an object cannot be changed during
|
||||
runtime.
|
||||
|
||||
When mapping any source to an object structure, this library will ensure that
|
||||
all input values are properly converted to match the types of the nodes — class
|
||||
properties or method parameters. Any value that cannot be converted to the
|
||||
correct type will trigger an error and prevent the mapping from completing.
|
||||
|
||||
These checks guarantee that if the mapping succeeds, the object structure is
|
||||
perfectly valid, hence there is no need for further validation nor type
|
||||
conversion: the objects are ready to be used.
|
||||
|
||||
### Static analysis
|
||||
|
||||
A strongly-typed codebase allows the usage of static analysis tools like
|
||||
[PHPStan] and [Psalm] that can identify issues in a codebase without running it.
|
||||
|
||||
Moreover, static analysis can help during a refactoring of a codebase with tools
|
||||
like an IDE or [Rector].
|
125
docs/pages/mapping/construction-strategy.md
Normal file
125
docs/pages/mapping/construction-strategy.md
Normal file
@ -0,0 +1,125 @@
|
||||
# Construction strategy
|
||||
|
||||
During the mapping, instances of objects are recursively created and hydrated
|
||||
with transformed values. Construction strategies will determine what values are
|
||||
needed and how an object is built.
|
||||
|
||||
## Native constructor
|
||||
|
||||
If a constructor exists and is public, its arguments will determine which values
|
||||
are needed from the input.
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $foo,
|
||||
public readonly int $bar,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom constructor
|
||||
|
||||
An object may have custom ways of being created, in such cases these
|
||||
constructors need to be registered to the mapper to be used. A constructor is a
|
||||
callable that can be either:
|
||||
|
||||
1. A named constructor, also known as a static factory method
|
||||
2. The method of a service — for instance a repository
|
||||
3. A "callable object" — a class that declares an `__invoke` method
|
||||
4. Any other callable — including anonymous functions
|
||||
|
||||
In any case, the return type of the callable will be resolved by the mapper to
|
||||
know when to use it. Any argument can be provided and will automatically be
|
||||
mapped using the given source. These arguments can then be used to instantiate
|
||||
the object in the desired way.
|
||||
|
||||
Registering any constructor will disable the native constructor — the
|
||||
`__construct` method — of the targeted class. If for some reason it still needs
|
||||
to be handled as well, the name of the class must be given to the
|
||||
registration method.
|
||||
|
||||
```php
|
||||
(new \CuyZ\Valinor\MapperBuilder())
|
||||
->registerConstructor(
|
||||
// Allow the native constructor to be used
|
||||
Color::class,
|
||||
|
||||
// Register a named constructor (1)
|
||||
Color::fromHex(...),
|
||||
|
||||
/**
|
||||
* An anonymous function can also be used, for instance when the desired
|
||||
* object is an external dependency that cannot be modified.
|
||||
*
|
||||
* @param 'red'|'green'|'blue' $color
|
||||
* @param 'dark'|'light' $darkness
|
||||
*/
|
||||
function (string $color, string $darkness): Color {
|
||||
$main = $darkness === 'dark' ? 128 : 255;
|
||||
$other = $darkness === 'dark' ? 0 : 128;
|
||||
|
||||
return new Color(
|
||||
$color === 'red' ? $main : $other,
|
||||
$color === 'green' ? $main : $other,
|
||||
$color === 'blue' ? $main : $other,
|
||||
);
|
||||
}
|
||||
)
|
||||
->mapper()
|
||||
->map(Color::class, [/* … */]);
|
||||
|
||||
final class Color
|
||||
{
|
||||
/**
|
||||
* @param int<0, 255> $red
|
||||
* @param int<0, 255> $green
|
||||
* @param int<0, 255> $blue
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $red,
|
||||
public readonly int $green,
|
||||
public readonly int $blue
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param non-empty-string $hex
|
||||
*/
|
||||
public static function fromHex(string $hex): self
|
||||
{
|
||||
if (strlen($hex) !== 6) {
|
||||
throw new DomainException('Must be 6 characters long');
|
||||
}
|
||||
|
||||
/** @var int<0, 255> $red */
|
||||
$red = hexdec(substr($hex, 0, 2));
|
||||
/** @var int<0, 255> $green */
|
||||
$green = hexdec(substr($hex, 2, 2));
|
||||
/** @var int<0, 255> $blue */
|
||||
$blue = hexdec(substr($hex, 4, 2));
|
||||
|
||||
return new self($red, $green, $blue);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. …or for PHP < 8.1:
|
||||
|
||||
```php
|
||||
[Color::class, 'fromHex'],
|
||||
```
|
||||
|
||||
## Properties
|
||||
|
||||
If no constructor is registered, properties will determine which values are
|
||||
needed from the input.
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public readonly string $foo;
|
||||
|
||||
public readonly int $bar;
|
||||
}
|
||||
```
|
150
docs/pages/mapping/handled-types.md
Normal file
150
docs/pages/mapping/handled-types.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Handled types
|
||||
|
||||
To prevent conflicts or duplication of the type annotations, this library tries
|
||||
to handle most of the type annotations that are accepted by [PHPStan] and
|
||||
[Psalm].
|
||||
|
||||
## Scalar
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public function __construct(
|
||||
private bool $boolean,
|
||||
|
||||
private float $float,
|
||||
|
||||
private int $integer,
|
||||
|
||||
/** @var positive-int */
|
||||
private int $positiveInteger,
|
||||
|
||||
/** @var negative-int */
|
||||
private int $negativeInteger,
|
||||
|
||||
/** @var int<-42, 1337> */
|
||||
private int $integerRange,
|
||||
|
||||
/** @var int<min, 0> */
|
||||
private int $integerRangeWithMinRange,
|
||||
|
||||
/** @var int<0, max> */
|
||||
private int $integerRangeWithMaxRange,
|
||||
|
||||
private string $string,
|
||||
|
||||
/** @var non-empty-string */
|
||||
private string $nonEmptyString,
|
||||
|
||||
/** @var class-string */
|
||||
private string $classString,
|
||||
|
||||
/** @var class-string<SomeInterface> */
|
||||
private string $classStringOfAnInterface,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Object
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public function __construct(
|
||||
private SomeClass $class,
|
||||
|
||||
private DateTimeInterface $interface,
|
||||
|
||||
/** @var SomeInterface&AnotherInterface */
|
||||
private object $intersection,
|
||||
|
||||
/** @var SomeCollection<SomeClass> */
|
||||
private SomeCollection $classWithGeneric,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T of object
|
||||
*/
|
||||
final class SomeCollection
|
||||
{
|
||||
public function __construct(
|
||||
/** @var array<T> */
|
||||
private array $objects,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Array & lists
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public function __construct(
|
||||
/** @var string[] */
|
||||
private array $simpleArray,
|
||||
|
||||
/** @var array<string> */
|
||||
private array $arrayOfStrings,
|
||||
|
||||
/** @var array<string, SomeClass> */
|
||||
private array $arrayOfClassWithStringKeys,
|
||||
|
||||
/** @var array<int, SomeClass> */
|
||||
private array $arrayOfClassWithIntegerKeys,
|
||||
|
||||
/** @var non-empty-array<string> */
|
||||
private array $nonEmptyArrayOfStrings,
|
||||
|
||||
/** @var non-empty-array<string, SomeClass> */
|
||||
private array $nonEmptyArrayWithStringKeys,
|
||||
|
||||
/** @var list<string> */
|
||||
private array $listOfStrings,
|
||||
|
||||
/** @var non-empty-list<string> */
|
||||
private array $nonEmptyListOfStrings,
|
||||
|
||||
/** @var array{foo: string, bar: int} */
|
||||
private array $shapedArray,
|
||||
|
||||
/** @var array{foo: string, bar?: int} */
|
||||
private array $shapedArrayWithOptionalElement,
|
||||
|
||||
/** @var array{string, bar: int} */
|
||||
private array $shapedArrayWithUndefinedKey,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## Union
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public function __construct(
|
||||
private int|string $simpleUnion,
|
||||
|
||||
/** @var class-string<SomeInterface|AnotherInterface> */
|
||||
private string $unionOfClassString,
|
||||
|
||||
/** @var array<SomeInterface|AnotherInterface> */
|
||||
private array $unionInsideArray,
|
||||
|
||||
/** @var int|true */
|
||||
private int|bool $unionWithLiteralTrueType;
|
||||
|
||||
/** @var int|false */
|
||||
private int|bool $unionWithLiteralFalseType;
|
||||
|
||||
/** @var 404.42|1337.42 */
|
||||
private float $unionOfFloatValues,
|
||||
|
||||
/** @var 42|1337 */
|
||||
private int $unionOfIntegerValues,
|
||||
|
||||
/** @var 'foo'|'bar' */
|
||||
private string $unionOfStringValues,
|
||||
) {}
|
||||
}
|
||||
```
|
49
docs/pages/mapping/inferring-interfaces.md
Normal file
49
docs/pages/mapping/inferring-interfaces.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Inferring interfaces
|
||||
|
||||
When the mapper meets an interface, it needs to understand which implementation
|
||||
(a class that implements this interface) will be used — this information must be
|
||||
provided in the mapper builder, using the method `infer()`.
|
||||
|
||||
The callback given to this method must return the name of a class that
|
||||
implements the interface. Any arguments can be required by the callback; they
|
||||
will be mapped properly using the given source.
|
||||
|
||||
```php
|
||||
$mapper = (new \CuyZ\Valinor\MapperBuilder())
|
||||
->infer(UuidInterface::class, fn () => MyUuid::class)
|
||||
->infer(SomeInterface::class, fn (string $type) => match($type) {
|
||||
'first' => FirstImplementation::class,
|
||||
'second' => SecondImplementation::class,
|
||||
default => throw new DomainException("Unhandled type `$type`.")
|
||||
})->mapper();
|
||||
|
||||
// Will return an instance of `FirstImplementation`
|
||||
$mapper->map(SomeInterface::class, [
|
||||
'type' => 'first',
|
||||
'uuid' => 'a6868d61-acba-406d-bcff-30ecd8c0ceb6',
|
||||
'someString' => 'foo',
|
||||
]);
|
||||
|
||||
// Will return an instance of `SecondImplementation`
|
||||
$mapper->map(SomeInterface::class, [
|
||||
'type' => 'second',
|
||||
'uuid' => 'a6868d61-acba-406d-bcff-30ecd8c0ceb6',
|
||||
'someInt' => 42,
|
||||
]);
|
||||
|
||||
interface SomeInterface {}
|
||||
|
||||
final class FirstImplementation implements SomeInterface
|
||||
{
|
||||
public readonly UuidInterface $uuid;
|
||||
|
||||
public readonly string $someString;
|
||||
}
|
||||
|
||||
final class SecondImplementation implements SomeInterface
|
||||
{
|
||||
public readonly UuidInterface $uuid;
|
||||
|
||||
public readonly int $someInt;
|
||||
}
|
||||
```
|
172
docs/pages/message-customization.md
Normal file
172
docs/pages/message-customization.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Message customization
|
||||
|
||||
The content of a message can be changed to fit custom use cases; it can contain
|
||||
placeholders that will be replaced with useful information.
|
||||
|
||||
The placeholders below are always available; even more may be used depending
|
||||
on the original message.
|
||||
|
||||
| Placeholder | Description |
|
||||
|----------------------|:-----------------------------------------------------|
|
||||
| `{message_code}` | the code of the message |
|
||||
| `{node_name}` | name of the node to which the message is bound |
|
||||
| `{node_path}` | path of the node to which the message is bound |
|
||||
| `{node_type}` | type of the node to which the message is bound |
|
||||
| `{original_value}` | the source value that was given to the node |
|
||||
| `{original_message}` | the original message before being customized |
|
||||
|
||||
Usage:
|
||||
|
||||
```php
|
||||
try {
|
||||
(new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(SomeClass::class, [/* … */]);
|
||||
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
|
||||
$node = $error->node();
|
||||
$messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node);
|
||||
|
||||
foreach ($messages as $message) {
|
||||
if ($message->code() === 'some_code') {
|
||||
$message = $message->withBody('new message / {original_message}');
|
||||
}
|
||||
|
||||
echo $message;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The messages are formatted using the [ICU library], enabling the placeholders to
|
||||
use advanced syntax to perform proper translations, for instance currency
|
||||
support.
|
||||
|
||||
```php
|
||||
try {
|
||||
(new \CuyZ\Valinor\MapperBuilder())->mapper()->map('int<0, 100>', 1337);
|
||||
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
|
||||
$message = $error->node()->messages()[0];
|
||||
|
||||
if (is_numeric($message->value())) {
|
||||
$message = $message->withBody(
|
||||
'Invalid amount {original_value, number, currency}'
|
||||
);
|
||||
}
|
||||
|
||||
// Invalid amount: $1,337.00
|
||||
echo $message->withLocale('en_US');
|
||||
|
||||
// Invalid amount: £1,337.00
|
||||
echo $message->withLocale('en_GB');
|
||||
|
||||
// Invalid amount: 1 337,00 €
|
||||
echo $message->withLocale('fr_FR');
|
||||
}
|
||||
```
|
||||
|
||||
See [ICU documentation] for more information on available syntax.
|
||||
|
||||
!!! warning
|
||||
|
||||
If the `intl` extension is not installed, a shim will be
|
||||
available to replace the placeholders, but it won't handle advanced syntax as
|
||||
described above.
|
||||
|
||||
## Deeper message customization / translation
|
||||
|
||||
For deeper message changes, formatters can be used — for instance to translate
|
||||
content.
|
||||
|
||||
### Translation
|
||||
|
||||
The formatter `TranslationMessageFormatter` can be used to translate the content
|
||||
of messages.
|
||||
|
||||
The library provides a list of all messages that can be returned; this list can
|
||||
be filled or modified with custom translations.
|
||||
|
||||
```php
|
||||
\CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default()
|
||||
// Create/override a single entry…
|
||||
->withTranslation('fr', 'some custom message', 'un message personnalisé')
|
||||
// …or several entries.
|
||||
->withTranslations([
|
||||
'some custom message' => [
|
||||
'en' => 'Some custom message',
|
||||
'fr' => 'Un message personnalisé',
|
||||
'es' => 'Un mensaje personalizado',
|
||||
],
|
||||
'some other message' => [
|
||||
// …
|
||||
],
|
||||
])
|
||||
->format($message);
|
||||
```
|
||||
|
||||
### Replacement map
|
||||
|
||||
The formatter `MessageMapFormatter` can be used to provide a list of messages
|
||||
replacements. It can be instantiated with an array where each key represents
|
||||
either:
|
||||
|
||||
- The code of the message to be replaced
|
||||
- The body of the message to be replaced
|
||||
- The class name of the message to be replaced
|
||||
|
||||
If none of those is found, the content of the message will stay unchanged unless
|
||||
a default one is given to the class.
|
||||
|
||||
If one of these keys is found, the array entry will be used to replace the
|
||||
content of the message. This entry can be either a plain text or a callable that
|
||||
takes the message as a parameter and returns a string; it is for instance
|
||||
advised to use a callable in cases where a custom translation service is used —
|
||||
to avoid useless greedy operations.
|
||||
|
||||
In any case, the content can contain placeholders as described
|
||||
[above](#message-customization).
|
||||
|
||||
```php
|
||||
(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([
|
||||
// Will match if the given message has this exact code
|
||||
'some_code' => 'New content / code: {message_code}',
|
||||
|
||||
// Will match if the given message has this exact content
|
||||
'Some message content' => 'New content / previous: {original_message}',
|
||||
|
||||
// Will match if the given message is an instance of `SomeError`
|
||||
SomeError::class => 'New content / value: {original_value}',
|
||||
|
||||
// A callback can be used to get access to the message instance
|
||||
OtherError::class => function (NodeMessage $message): string {
|
||||
if ($message->path() === 'foo.bar') {
|
||||
return 'Some custom message';
|
||||
}
|
||||
|
||||
return $message->body();
|
||||
},
|
||||
|
||||
// For greedy operation, it is advised to use a lazy-callback
|
||||
'foo' => fn () => $this->customTranslator->translate('foo.bar'),
|
||||
]))
|
||||
->defaultsTo('some default message')
|
||||
// …or…
|
||||
->defaultsTo(fn () => $this->customTranslator->translate('default_message'))
|
||||
->format($message);
|
||||
```
|
||||
|
||||
### Several formatters
|
||||
|
||||
It is possible to join several formatters into one formatter by using the
|
||||
`AggregateMessageFormatter`. This instance can then easily be injected in a
|
||||
service that will handle messages.
|
||||
|
||||
The formatters will be called in the same order they are given to the aggregate.
|
||||
|
||||
```php
|
||||
(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\AggregateMessageFormatter(
|
||||
new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\LocaleMessageFormatter('fr'),
|
||||
new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([
|
||||
// …
|
||||
],
|
||||
\CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default(),
|
||||
))->format($message)
|
||||
```
|
55
docs/pages/other/performance-and-cache.md
Normal file
55
docs/pages/other/performance-and-cache.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Performance & caching
|
||||
|
||||
This library needs to parse a lot of information in order to handle all provided
|
||||
features. Therefore, it is strongly advised to activate the cache to reduce
|
||||
heavy workload between runtimes, especially when the application runs in a
|
||||
production environment.
|
||||
|
||||
The library provides a cache implementation out of the box, which saves
|
||||
cache entries into the file system.
|
||||
|
||||
!!! Note
|
||||
|
||||
It is also possible to use any PSR-16 compliant implementation, as
|
||||
long as it is capable of caching the entries handled by the library.
|
||||
|
||||
When the application runs in a development environment, the cache implementation
|
||||
should be decorated with `FileWatchingCache`, which will watch the files of the
|
||||
application and invalidate cache entries when a PHP file is modified by a
|
||||
developer — preventing the library not behaving as expected when the signature
|
||||
of a property or a method changes.
|
||||
|
||||
```php
|
||||
$cache = new \CuyZ\Valinor\Cache\FileSystemCache('path/to/cache-directory');
|
||||
|
||||
if ($isApplicationInDevelopmentEnvironment) {
|
||||
$cache = new \CuyZ\Valinor\Cache\FileWatchingCache($cache);
|
||||
}
|
||||
|
||||
(new \CuyZ\Valinor\MapperBuilder())
|
||||
->withCache($cache)
|
||||
->mapper()
|
||||
->map(SomeClass::class, [/* … */]);
|
||||
```
|
||||
|
||||
## Warming up cache
|
||||
|
||||
The cache can be warmed up, for instance in a pipeline during the build and
|
||||
deployment of the application.
|
||||
|
||||
!!! note
|
||||
|
||||
The cache has to be registered first, otherwise the warmup will end
|
||||
up being useless.
|
||||
|
||||
```php
|
||||
$cache = new \CuyZ\Valinor\Cache\FileSystemCache('path/to/cache-dir');
|
||||
|
||||
$mapperBuilder = (new \CuyZ\Valinor\MapperBuilder())->withCache($cache);
|
||||
|
||||
// During the build:
|
||||
$mapperBuilder->warmup(SomeClass::class, SomeOtherClass::class);
|
||||
|
||||
// In the application:
|
||||
$mapper->mapper()->map(SomeClass::class, [/* … */]);
|
||||
```
|
80
docs/pages/other/static-analysis.md
Normal file
80
docs/pages/other/static-analysis.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Static analysis
|
||||
|
||||
To help static analysis of a codebase using this library, an extension for
|
||||
[PHPStan] and a plugin for [Psalm] are provided. They enable these tools to
|
||||
better understand the behaviour of the mapper.
|
||||
|
||||
Considering at least one of those tools are installed on a project, below are
|
||||
examples of the kind of errors that would be reported.
|
||||
|
||||
**Mapping to an array of classes**
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $foo,
|
||||
public readonly int $bar,
|
||||
) {}
|
||||
}
|
||||
|
||||
$objects = (new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(
|
||||
'array<' . SomeClass::class . '>',
|
||||
[/* … */]
|
||||
);
|
||||
|
||||
foreach ($objects as $object) {
|
||||
// ✅
|
||||
echo $object->foo;
|
||||
|
||||
// ✅
|
||||
echo $object->bar * 2;
|
||||
|
||||
// ❌ Cannot perform operation between `string` and `int`
|
||||
echo $object->foo * $object->bar;
|
||||
|
||||
// ❌ Property `SomeClass::$fiz` is not defined
|
||||
echo $object->fiz;
|
||||
}
|
||||
```
|
||||
|
||||
**Mapping to a shaped array**
|
||||
|
||||
```php
|
||||
$array = (new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(
|
||||
'array{foo: string, bar: int}',
|
||||
[/* … */]
|
||||
);
|
||||
|
||||
// ✅
|
||||
echo $array['foo'];
|
||||
|
||||
// ❌ Expected `string` but got `int`
|
||||
echo strtolower($array['bar']);
|
||||
|
||||
// ❌ Cannot perform operation between `string` and `int`
|
||||
echo $array['foo'] * $array['bar'];
|
||||
|
||||
// ❌ Offset `fiz` does not exist on array
|
||||
echo $array['fiz'];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
To activate this feature, the configuration must be updated for the installed
|
||||
tool(s):
|
||||
|
||||
```yaml title="phpstan.neon"
|
||||
includes:
|
||||
- vendor/cuyz/valinor/qa/PHPStan/valinor-phpstan-configuration.php
|
||||
```
|
||||
|
||||
```xml title="psalm.xml"
|
||||
<plugins>
|
||||
<plugin filename="vendor/cuyz/valinor/qa/Psalm/Plugin/TreeMapperPsalmPlugin.php"/>
|
||||
</plugins>
|
||||
```
|
146
docs/pages/source.md
Normal file
146
docs/pages/source.md
Normal file
@ -0,0 +1,146 @@
|
||||
# Source
|
||||
|
||||
Any source can be given to the mapper, be it an array, some json, yaml or even a
|
||||
file:
|
||||
|
||||
```php
|
||||
$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper();
|
||||
|
||||
$mapper->map(
|
||||
SomeClass::class,
|
||||
\CuyZ\Valinor\Mapper\Source\Source::array($someData)
|
||||
);
|
||||
|
||||
$mapper->map(
|
||||
SomeClass::class,
|
||||
\CuyZ\Valinor\Mapper\Source\Source::json($jsonString)
|
||||
);
|
||||
|
||||
$mapper->map(
|
||||
SomeClass::class,
|
||||
\CuyZ\Valinor\Mapper\Source\Source::yaml($yamlString)
|
||||
);
|
||||
|
||||
$mapper->map(
|
||||
SomeClass::class,
|
||||
// File containing valid Json or Yaml content and with valid extension
|
||||
\CuyZ\Valinor\Mapper\Source\Source::file(
|
||||
new SplFileObject('path/to/my/file.json')
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
## Modifiers
|
||||
|
||||
Sometimes the source is not in the same format and/or organised in the same
|
||||
way as a value object. Modifiers can be used to change a source before the
|
||||
mapping occurs.
|
||||
|
||||
### Camel case keys
|
||||
|
||||
This modifier recursively forces all keys to be in camelCase format.
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public readonly string $someValue;
|
||||
}
|
||||
|
||||
$source = \CuyZ\Valinor\Mapper\Source\Source::array([
|
||||
'some_value' => 'foo',
|
||||
// …or…
|
||||
'some-value' => 'foo',
|
||||
// …or…
|
||||
'some value' => 'foo',
|
||||
// …will be replaced by `['someValue' => 'foo']`
|
||||
])
|
||||
->camelCaseKeys();
|
||||
|
||||
(new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(SomeClass::class, $source);
|
||||
```
|
||||
|
||||
### Path mapping
|
||||
|
||||
This modifier can be used to change paths in the source data using a dot
|
||||
notation.
|
||||
|
||||
The mapping is done using an associative array of path mappings. This array must
|
||||
have the source path as key and the target path as value.
|
||||
|
||||
The source path uses the dot notation (eg `A.B.C`) and can contain one `*` for
|
||||
array paths (eg `A.B.*.C`).
|
||||
|
||||
```php
|
||||
final class Country
|
||||
{
|
||||
/** @var City[] */
|
||||
public readonly array $cities;
|
||||
}
|
||||
|
||||
final class City
|
||||
{
|
||||
public readonly string $name;
|
||||
}
|
||||
|
||||
$source = \CuyZ\Valinor\Mapper\Source\Source::array([
|
||||
'towns' => [
|
||||
['label' => 'Ankh Morpork'],
|
||||
['label' => 'Minas Tirith'],
|
||||
],
|
||||
])
|
||||
->map([
|
||||
'towns' => 'cities',
|
||||
'towns.*.label' => 'name',
|
||||
]);
|
||||
|
||||
// After modification this is what the source will look like:
|
||||
[
|
||||
'cities' => [
|
||||
['name' => 'Ankh Morpork'],
|
||||
['name' => 'Minas Tirith'],
|
||||
],
|
||||
];
|
||||
|
||||
(new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(Country::class, $source);
|
||||
```
|
||||
|
||||
## Custom source
|
||||
|
||||
The source is just an iterable, so it's easy to create a custom one.
|
||||
It can even be combined with the provided builder.
|
||||
|
||||
```php
|
||||
final class AcmeSource implements IteratorAggregate
|
||||
{
|
||||
private iterable $source;
|
||||
|
||||
public function __construct(iterable $source)
|
||||
{
|
||||
$this->source = $this->doSomething($source);
|
||||
}
|
||||
|
||||
private function doSomething(iterable $source): iterable
|
||||
{
|
||||
// Do something with $source
|
||||
|
||||
return $source;
|
||||
}
|
||||
|
||||
public function getIterator()
|
||||
{
|
||||
yield from $this->source;
|
||||
}
|
||||
}
|
||||
|
||||
$source = \CuyZ\Valinor\Mapper\Source\Source::iterable(
|
||||
new AcmeSource(['value' => 'foo'])
|
||||
)->camelCaseKeys();
|
||||
|
||||
(new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(SomeClass::class, $source);
|
||||
```
|
53
docs/pages/validation.md
Normal file
53
docs/pages/validation.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Validation
|
||||
|
||||
The source given to a mapper can never be trusted, this is actually the very
|
||||
goal of this library: transforming an unstructured input to a well-defined
|
||||
object structure. If the mapper cannot guess how to cast a certain value, it
|
||||
means that it is not able to guarantee the validity of the desired object thus
|
||||
it will fail.
|
||||
|
||||
Any issue encountered during the mapping will add an error to an upstream
|
||||
exception of type `\CuyZ\Valinor\Mapper\MappingError`. It is therefore always
|
||||
recommended wrapping the mapping function call with a try/catch statement and
|
||||
handle the error properly.
|
||||
|
||||
More specific validation should be done in the constructor of the value object,
|
||||
by throwing an exception if something is wrong with the given data. A good
|
||||
practice would be to use lightweight validation tools like [Webmozart Assert].
|
||||
|
||||
When the mapping fails, the exception gives access to the root node. This
|
||||
recursive object allows retrieving all needed information through the whole
|
||||
mapping tree: path, values, types and messages, including the issues that caused
|
||||
the exception.
|
||||
|
||||
```php
|
||||
final class SomeClass
|
||||
{
|
||||
public function __construct(private string $someValue)
|
||||
{
|
||||
Assert::startsWith($someValue, 'foo_');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
(new \CuyZ\Valinor\MapperBuilder())
|
||||
->mapper()
|
||||
->map(
|
||||
SomeClass::class,
|
||||
['someValue' => 'bar_baz']
|
||||
);
|
||||
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
|
||||
// Get flatten list of all messages through the whole nodes tree
|
||||
$node = $error->node();
|
||||
$messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node);
|
||||
|
||||
// If only errors are wanted, they can be filtered
|
||||
$errorMessages = $messages->errors();
|
||||
|
||||
// Should print something similar to:
|
||||
// > Expected a value to start with "foo_". Got: "bar_baz"
|
||||
foreach ($errorMessages as $message) {
|
||||
echo $message;
|
||||
}
|
||||
}
|
||||
```
|
11
docs/requirements.txt
Normal file
11
docs/requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
mkdocs==1.3.0
|
||||
mike==1.1.2
|
||||
markdown==3.3.7
|
||||
mkdocs-material==8.2.3
|
||||
|
||||
# Markdown extensions
|
||||
Pygments==2.12.0
|
||||
pymdown-extensions==9.4
|
||||
|
||||
# MkDocs plugins
|
||||
mkdocs-material-extensions==1.0.3
|
Loading…
Reference in New Issue
Block a user