Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
109 changes: 109 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, Comment>
*/
final class CommentCollection extends ArrayObject
{
}

/**
* @extends ArrayObject<int, Article>
*/
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.
Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions docs/recipes/custom-name-converter.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions docs/recipes/mapping-with-enums.md
Original file line number Diff line number Diff line change
@@ -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`.
54 changes: 54 additions & 0 deletions docs/recipes/nested-collections.md
Original file line number Diff line number Diff line change
@@ -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<int, Tag>
*/
final class TagCollection extends \ArrayObject
{
}

/**
* @extends \ArrayObject<int, TagCollection>
*/
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.
35 changes: 35 additions & 0 deletions docs/recipes/using-attributes.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading