From dce0ee19faf57a4de709c9080b65dc8a4747346c Mon Sep 17 00:00:00 2001 From: Rico Sonntag Date: Thu, 13 Nov 2025 09:19:51 +0100 Subject: [PATCH] chore(project): enforce final mapper and expand docs --- CHANGELOG.md | 9 ++ README.md | 109 ++++++++++++++++++ docs/API.md | 86 ++++++++++++++ docs/recipes/custom-name-converter.md | 29 +++++ docs/recipes/mapping-with-enums.md | 39 +++++++ docs/recipes/nested-collections.md | 54 +++++++++ docs/recipes/using-attributes.md | 35 ++++++ src/JsonMapper.php | 16 +-- .../CamelCasePropertyNameConverter.php | 17 +-- 9 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/recipes/custom-name-converter.md create mode 100644 docs/recipes/mapping-with-enums.md create mode 100644 docs/recipes/nested-collections.md create mode 100644 docs/recipes/using-attributes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..fe2e6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +## Unreleased + +### Changed +- Marked `MagicSunday\\JsonMapper\\JsonMapper` as `final` and promoted constructor dependencies to `readonly` properties for consistent visibility. +- Declared `MagicSunday\\JsonMapper\\Converter\\CamelCasePropertyNameConverter` as `final` and immutable. + +### Documentation +- Added a quick start walkthrough and guidance on type converters, error strategies, and performance tuning to the README. +- Published an API reference (`docs/API.md`) and new recipe guides for enums, attributes, nested collections, and custom name converters. diff --git a/README.md b/README.md index ad81123..8520ba3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,70 @@ composer remove magicsunday/jsonmapper ## Usage +### Quick start +A minimal mapping run consists of two parts: a set of DTOs annotated with collection metadata and the mapper bootstrap code. + +```php +namespace App\Dto; + +use ArrayObject; + +final class Comment +{ + public string $message; +} + +/** + * @extends ArrayObject + */ +final class CommentCollection extends ArrayObject +{ +} + +/** + * @extends ArrayObject + */ +final class ArticleCollection extends ArrayObject +{ +} + +final class Article +{ + public string $title; + + /** @var CommentCollection */ + public CommentCollection $comments; +} +``` + +```php +declare(strict_types=1); + +use App\Dto\Article; +use App\Dto\ArticleCollection; +use MagicSunday\JsonMapper\JsonMapper; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +$single = json_decode('{"title":"Hello world","comments":[{"message":"First!"}]}', associative: false, flags: JSON_THROW_ON_ERROR); +$list = json_decode('[{"title":"Hello world","comments":[{"message":"First!"}]},{"title":"Second","comments":[]}]', associative: false, flags: JSON_THROW_ON_ERROR); + +$propertyInfo = new PropertyInfoExtractor( + [new ReflectionExtractor()], + [new PhpDocExtractor()] +); +$propertyAccessor = PropertyAccess::createPropertyAccessor(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); + +$article = $mapper->map($single, Article::class); +$articles = $mapper->map($list, Article::class, ArticleCollection::class); +``` + +The first call produces an `Article` instance with a populated `CommentCollection`; the second call returns an `ArticleCollection` containing `Article` objects. + ### PHP classes In order to guarantee a seamless mapping of a JSON response into PHP classes you should prepare your classes well. Annotate all properties with the requested type. @@ -200,6 +264,51 @@ protected function getJsonMapper(array $classMap = []): \MagicSunday\JsonMapper } ``` +### Type converters and custom class maps +Custom types should implement `MagicSunday\\JsonMapper\\Value\\TypeHandlerInterface` and can be registered once via `JsonMapper::addTypeHandler()`. For lightweight overrides you may still use `addType()` with a closure, but new code should prefer dedicated handler classes. + +Use `JsonMapper::addCustomClassMapEntry()` when the target class depends on runtime data. The resolver receives the decoded JSON payload and may inspect a `MappingContext` when you need additional state. + +```php +$mapper->addCustomClassMapEntry(SdkFoo::class, static function (array $payload): string { + return $payload['type'] === 'bar' ? FooBar::class : FooBaz::class; +}); +``` + +### Error handling strategies +The mapper operates in a lenient mode by default. Switch to strict mapping when every property must be validated: + +```php +use MagicSunday\\JsonMapper\\Configuration\\JsonMapperConfiguration; + +$config = JsonMapperConfiguration::strict() + ->withCollectErrors(true); + +$result = $mapper->mapWithReport($payload, Article::class, configuration: $config); +``` + +For tolerant APIs combine `JsonMapperConfiguration::lenient()` with `->withIgnoreUnknownProperties(true)` or `->withTreatNullAsEmptyCollection(true)` to absorb schema drifts. + +### Performance hints +Type resolution is the most expensive part of a mapping run. Provide a PSR-6 cache pool to the constructor to reuse computed `Type` metadata: + +```php +use Symfony\\Component\\Cache\\Adapter\\ArrayAdapter; + +$cache = new ArrayAdapter(); +$mapper = new JsonMapper($propertyInfo, $propertyAccessor, nameConverter: null, classMap: [], typeCache: $cache); +``` + +Reuse a single `JsonMapper` instance across requests to share the cached metadata and registered handlers. + +## Additional documentation +* [API reference](docs/API.md) +* Recipes + * [Mapping JSON to PHP enums](docs/recipes/mapping-with-enums.md) + * [Using mapper attributes](docs/recipes/using-attributes.md) + * [Mapping nested collections](docs/recipes/nested-collections.md) + * [Using a custom name converter](docs/recipes/custom-name-converter.md) + ## Development ### Testing diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..fe660bf --- /dev/null +++ b/docs/API.md @@ -0,0 +1,86 @@ +# JsonMapper API reference + +This document summarises the public surface of the JsonMapper package. All classes are namespaced under `MagicSunday\\JsonMapper` unless stated otherwise. + +## JsonMapper (final) +The `JsonMapper` class is the main entry point for mapping arbitrary JSON structures to PHP objects. The class is `final`; prefer composition over inheritance. + +### Constructor +``` +__construct( + PropertyInfoExtractorInterface $extractor, + PropertyAccessorInterface $accessor, + ?PropertyNameConverterInterface $nameConverter = null, + array $classMap = [], + ?CacheItemPoolInterface $typeCache = null, + JsonMapperConfiguration $config = new JsonMapperConfiguration(), +) +``` + +* `$classMap` allows overriding resolved target classes. Use `addCustomClassMapEntry()` for runtime registration. +* `$typeCache` enables caching of resolved Symfony `Type` instances. Any PSR-6 cache pool is supported. +* `$config` provides the default configuration that will be cloned for every mapping operation. + +### Methods + +| Method | Description | +| --- | --- | +| `addTypeHandler(TypeHandlerInterface $handler): self` | Registers a reusable conversion strategy for a specific type. | +| `addType(string $type, Closure $closure): self` | Deprecated shortcut for registering closure-based handlers. Prefer `addTypeHandler()`. | +| `addCustomClassMapEntry(string $className, Closure $resolver): self` | Adds or replaces a class map entry. The resolver receives JSON data (and optionally the current `MappingContext`). | +| `map(mixed $json, ?string $className = null, ?string $collectionClassName = null, ?MappingContext $context = null, ?JsonMapperConfiguration $configuration = null): mixed` | Maps the provided JSON payload to the requested class or collection. | +| `mapWithReport(mixed $json, ?string $className = null, ?string $collectionClassName = null, ?JsonMapperConfiguration $configuration = null): MappingResult` | Maps data and returns a `MappingResult` containing both the mapped value and an error report. | + +> `map()` and `mapWithReport()` accept JSON decoded into arrays or objects (`json_decode(..., associative: false)` is recommended). Collections require either an explicit collection class name or collection PHPDoc (`@extends`) metadata. + +## JsonMapperConfiguration (final) +The `JsonMapperConfiguration` class encapsulates mapping options. All configuration methods return a **new** instance; treat instances as immutable value objects. + +### Factory helpers +* `JsonMapperConfiguration::lenient()` – default, tolerant configuration. +* `JsonMapperConfiguration::strict()` – enables strict mode (missing and unknown properties raise `MappingException`). +* `JsonMapperConfiguration::fromArray(array $data)` – rebuilds a configuration from persisted values. +* `JsonMapperConfiguration::fromContext(MappingContext $context)` – reconstructs a configuration for an existing mapping run. + +### Withers +Each `with*` method toggles a single option and returns a clone: + +| Method | Purpose | +| --- | --- | +| `withStrictMode(bool $enabled)` | Enable strict validation. | +| `withCollectErrors(bool $enabled)` | Collect errors instead of failing fast. Required for `mapWithReport()`. | +| `withTreatEmptyStringAsNull(bool $enabled)` | Map empty strings to `null`. | +| `withIgnoreUnknownProperties(bool $enabled)` | Skip unmapped JSON keys. | +| `withTreatNullAsEmptyCollection(bool $enabled)` | Replace `null` collections with their default value. | +| `withDefaultDateFormat(string $format)` | Configure the default `DateTimeInterface` parsing format. | +| `withScalarToObjectCasting(bool $enabled)` | Allow casting scalar values to object types when possible. | + +Use `toOptions()` to feed configuration data into a `MappingContext`, or `toArray()` to persist settings. + +## Property name converters +`CamelCasePropertyNameConverter` implements `PropertyNameConverterInterface` and is declared `final`. Instantiate it when JSON keys use snake case: + +``` +$nameConverter = new CamelCasePropertyNameConverter(); +$mapper = new JsonMapper($extractor, $accessor, $nameConverter); +``` + +## Custom type handlers +Implement `Value\TypeHandlerInterface` to plug in custom conversion logic: + +``` +final class UuidTypeHandler implements TypeHandlerInterface +{ + public function supports(Type $type, mixed $value): bool + { + return $type instanceof ObjectType && $type->getClassName() === Uuid::class; + } + + public function convert(Type $type, mixed $value, MappingContext $context): Uuid + { + return Uuid::fromString((string) $value); + } +} +``` + +Register handlers via `JsonMapper::addTypeHandler()` to make them available for all mappings. diff --git a/docs/recipes/custom-name-converter.md b/docs/recipes/custom-name-converter.md new file mode 100644 index 0000000..dc12c18 --- /dev/null +++ b/docs/recipes/custom-name-converter.md @@ -0,0 +1,29 @@ +# Using a custom name converter + +Property name converters translate JSON keys to PHP property names. JsonMapper provides `CamelCasePropertyNameConverter` out of the box and allows you to supply your own implementation of `PropertyNameConverterInterface`. + +```php +use MagicSunday\JsonMapper\Converter\PropertyNameConverterInterface; + +final class UpperSnakeCaseConverter implements PropertyNameConverterInterface +{ + public function convert(string $name): string + { + return strtolower(str_replace('_', '', $name)); + } +} +``` + +```php +use MagicSunday\JsonMapper\Converter\CamelCasePropertyNameConverter; +use MagicSunday\JsonMapper\JsonMapper; + +$propertyInfo = /* PropertyInfoExtractorInterface */; +$propertyAccessor = /* PropertyAccessorInterface */; +$converter = new CamelCasePropertyNameConverter(); +// or $converter = new UpperSnakeCaseConverter(); + +$mapper = new JsonMapper($propertyInfo, $propertyAccessor, $converter); +``` + +Name converters are stateless and should be declared `final`. They are applied to every property access during mapping, so keep the implementation idempotent and efficient. diff --git a/docs/recipes/mapping-with-enums.md b/docs/recipes/mapping-with-enums.md new file mode 100644 index 0000000..38376d4 --- /dev/null +++ b/docs/recipes/mapping-with-enums.md @@ -0,0 +1,39 @@ +# Mapping JSON to PHP enums + +JsonMapper can map backed enums transparently when the target property is typed with the enum class. The built-in `EnumValueConversionStrategy` handles the conversion from scalars to enum cases. + +```php +namespace App\Dto; + +enum Status: string +{ + case Draft = 'draft'; + case Published = 'published'; +} + +final class Article +{ + public string $title; + public Status $status; +} +``` + +```php +use App\Dto\Article; +use MagicSunday\JsonMapper\Configuration\JsonMapperConfiguration; +use MagicSunday\JsonMapper\JsonMapper; + +$json = json_decode('{ + "title": "Enum mapping", + "status": "published" +}', associative: false, flags: JSON_THROW_ON_ERROR); + +// Create PropertyInfoExtractor and PropertyAccessor instances as shown in the quick start guide. +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); +$article = $mapper->map($json, Article::class); + +assert($article instanceof Article); +assert($article->status === Status::Published); +``` + +The mapper validates enum values. When strict mode is enabled (`JsonMapperConfiguration::strict()`), an invalid enum value results in a `TypeMismatchException`. diff --git a/docs/recipes/nested-collections.md b/docs/recipes/nested-collections.md new file mode 100644 index 0000000..9f4a2b9 --- /dev/null +++ b/docs/recipes/nested-collections.md @@ -0,0 +1,54 @@ +# Mapping nested collections + +Collections of collections require explicit metadata so JsonMapper can determine the element types at every level. + +```php +namespace App\Dto; + +/** + * @extends \ArrayObject + */ +final class TagCollection extends \ArrayObject +{ +} + +/** + * @extends \ArrayObject + */ +final class NestedTagCollection extends \ArrayObject +{ +} + +final class Article +{ + /** @var NestedTagCollection */ + public NestedTagCollection $tags; +} +``` + +```php +use App\Dto\Article; +use App\Dto\NestedTagCollection; +use App\Dto\Tag; +use App\Dto\TagCollection; +use MagicSunday\JsonMapper\JsonMapper; + +$json = json_decode('[ + { + "tags": [ + [{"name": "php"}], + [{"name": "json"}] + ] + } +]', associative: false, flags: JSON_THROW_ON_ERROR); + +// Create PropertyInfoExtractor and PropertyAccessor instances as shown in the quick start guide. +$mapper = new JsonMapper($propertyInfo, $propertyAccessor); +$articles = $mapper->map($json, Article::class, \ArrayObject::class); + +assert($articles instanceof \ArrayObject); +assert($articles[0] instanceof Article); +assert($articles[0]->tags instanceof NestedTagCollection); +``` + +Each custom collection advertises its value type through the `@extends` PHPDoc annotation, allowing the mapper to recurse through nested structures. diff --git a/docs/recipes/using-attributes.md b/docs/recipes/using-attributes.md new file mode 100644 index 0000000..483bc80 --- /dev/null +++ b/docs/recipes/using-attributes.md @@ -0,0 +1,35 @@ +# Using mapper attributes + +JsonMapper ships with attributes that can refine how JSON data is mapped to PHP objects. + +## `ReplaceNullWithDefaultValue` +Use this attribute on properties that should fall back to their default value when the JSON payload explicitly contains `null`. + +```php +use MagicSunday\JsonMapper\Attribute\ReplaceNullWithDefaultValue; + +final class User +{ + #[ReplaceNullWithDefaultValue] + public array $roles = []; +} +``` + +When a payload contains `{ "roles": null }`, the mapper keeps the default empty array. + +## `ReplaceProperty` +Apply this attribute at class level to redirect one or more incoming property names to a different target property. + +```php +use MagicSunday\JsonMapper\Attribute\ReplaceProperty; + +#[ReplaceProperty('fullName', replaces: ['first_name', 'name'])] +final class Contact +{ + public string $fullName; +} +``` + +Both `first_name` and `name` keys will populate the `$fullName` property. Order matters: the first matching alias wins. + +Attributes can be combined with PHPDoc annotations and work alongside the classic DocBlock metadata. diff --git a/src/JsonMapper.php b/src/JsonMapper.php index 5a45020..b810517 100644 --- a/src/JsonMapper.php +++ b/src/JsonMapper.php @@ -90,22 +90,22 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ -class JsonMapper +final class JsonMapper { - private TypeResolver $typeResolver; + private readonly TypeResolver $typeResolver; - private ClassResolver $classResolver; + private readonly ClassResolver $classResolver; - private ValueConverter $valueConverter; + private readonly ValueConverter $valueConverter; /** * @var CollectionFactoryInterface */ - private CollectionFactoryInterface $collectionFactory; + private readonly CollectionFactoryInterface $collectionFactory; - private CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; + private readonly CollectionDocBlockTypeResolver $collectionDocBlockTypeResolver; - private CustomTypeRegistry $customTypeRegistry; + private readonly CustomTypeRegistry $customTypeRegistry; /** * @param array $classMap @@ -119,7 +119,7 @@ public function __construct( private readonly ?PropertyNameConverterInterface $nameConverter = null, array $classMap = [], ?CacheItemPoolInterface $typeCache = null, - private JsonMapperConfiguration $config = new JsonMapperConfiguration(), + private readonly JsonMapperConfiguration $config = new JsonMapperConfiguration(), ) { $this->typeResolver = new TypeResolver($extractor, $typeCache); $this->classResolver = new ClassResolver($classMap); diff --git a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php index e57aba3..9a7c589 100644 --- a/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php +++ b/src/JsonMapper/Converter/CamelCasePropertyNameConverter.php @@ -21,28 +21,15 @@ * @license https://opensource.org/licenses/MIT * @link https://github.com/magicsunday/jsonmapper/ */ -class CamelCasePropertyNameConverter implements PropertyNameConverterInterface +final class CamelCasePropertyNameConverter implements PropertyNameConverterInterface { - /** - * @var Inflector - */ - private Inflector $inflector; + private readonly Inflector $inflector; - /** - * CamelCasePropertyNameConverter constructor. - */ public function __construct() { $this->inflector = InflectorFactory::create()->build(); } - /** - * Convert the specified JSON property name to its PHP property name. - * - * @param string $name - * - * @return string - */ public function convert(string $name): string { return $this->inflector->camelize($name);