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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ List changed API surfaces and relevant attributes/converters in the “Reference
## 7) Domain cheat sheet

* **Property Info:** Use Symfony’s extractor stack (reflection, PHPDoc, type info). Respect the ordering and fallbacks.
* **Type handlers:** Implement `MagicSunday\JsonMapper\Value\TypeHandlerInterface`; handlers are stateless and must honour the `supports()`/`convert()` contract.
* **Type handlers:** Implement `MagicSunday\JsonMapper\Value\TypeHandlerInterface`; handlers are stateless and must honour the `supports()`/`convert()` contract. This is the ONE public extension point for value conversion. The `Value\Strategy\*` classes and `ValueConversionStrategyInterface` are `@internal` - the conversion chain's order is an implementation detail, not a contract, and there is no public hook to register a strategy. A handler runs ahead of the built-in deciding strategies but AFTER null handling, so it never sees a null. Their `supports()` carries a `MappingContext` the handler contract does not; the two are not meant to align, since one is internal and one is public.
* **Attributes:**
* `ReplaceNullWithDefaultValue` — only applies when a default exists.
* `ReplaceProperty` — supports multiple alias names. They are collected into a map keyed by
Expand Down
6 changes: 6 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ var_dump($mapper::class);

### Methods

> The built-in `Value\Strategy\*` classes are `@internal`: they are the conversion chain's
> implementation, not an extension point, and there is no public way to register one. A
> `TypeHandlerInterface` added through `addTypeHandler()` is consulted ahead of the built-in
> *deciding* strategies (after null handling), which is the supported way to change how a type
> converts.

| Method | Description |
| --- | --- |
| `addTypeHandler(TypeHandlerInterface $handler): self` | Registers a reusable conversion strategy for a specific type. |
Expand Down
10 changes: 5 additions & 5 deletions src/JsonMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,20 +277,20 @@ public function addType(string $type, Closure $closure): JsonMapper
* $allowedTargets drops the list the earlier registration carried, since a list written for one
* closure must not outlive it. Registration order therefore decides what is enforced.
*
* @param class-string $className Fully qualified class name that should be resolved dynamically.
* @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure Closure that returns the concrete class to instantiate for the provided value.
* @param list<string>|null $allowedTargets Classes the closure may return; null leaves it unrestricted.
* @param class-string $className Fully qualified class name that should be resolved dynamically.
* @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string|class-string $closure Closure returning the concrete class for the value, or a plain class-string to map to unconditionally.
* @param list<string>|null $allowedTargets Classes the closure may return; null leaves it unrestricted. Only meaningful for a closure - a static class-string target has no payload-derived choice to constrain.
*
* @phpstan-param class-string $className
* @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $closure
* @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string|class-string $closure
*
* @return JsonMapper Returns the mapper instance for fluent configuration.
*
* @throws DomainException When $allowedTargets is empty or names something that is not a class.
*/
public function addCustomClassMapEntry(
string $className,
Closure $closure,
Closure|string $closure,
?array $allowedTargets = null,
): JsonMapper {
$this->classResolver->add($className, $closure, $allowedTargets);
Expand Down
30 changes: 22 additions & 8 deletions src/JsonMapper/Resolver/ClassResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,35 @@ public function __construct(array $classMap = [])
/**
* Adds a custom resolution rule.
*
* @param class-string $className Base class or interface the resolver handles.
* @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver Callback returning a concrete class based on the JSON payload and optional mapping context.
* @param list<string>|null $allowedTargets Classes the resolver may return. Null leaves it
* unrestricted, which is the default for backwards
* compatibility - see the note below.
* The target may be a resolver closure or a plain class-string, matching what the constructor
* $classMap already accepts - a static mapping (SdkFoo::class => Foo::class) could be given at
* construction but not at runtime without wrapping it in a trivial closure. resolve() already
* handles both.
*
* @param class-string $className Base class or interface the resolver handles.
* @param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string|class-string $resolver Resolver closure, or a concrete class-string to map to unconditionally.
* @param list<string>|null $allowedTargets Classes the resolver may return. Null leaves it
* unrestricted, which is the default for backwards
* compatibility - see the note below. Only
* meaningful for a closure: a static string target
* is returned directly, with no payload choice to
* constrain, so a list beside one is inert.
*
* @phpstan-param class-string $className
* @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string $resolver
* @phpstan-param Closure(mixed):class-string|Closure(mixed, MappingContext):class-string|class-string $resolver
*
* @throws DomainException When the base class or a listed target does not exist.
* @throws DomainException When the base class, the target, or a listed target does not exist.
*/
public function add(string $className, Closure $resolver, ?array $allowedTargets = null): void
public function add(string $className, Closure|string $resolver, ?array $allowedTargets = null): void
{
$this->assertClassString($className);

// A plain class-string target is validated on the way in, like the constructor does, so a
// typo fails at registration rather than at the first payload.
if (is_string($resolver)) {
$this->assertClassString($resolver);
}

// Everything is validated BEFORE anything is stored, so a rejected list cannot leave the
// resolver registered without it. Storing first made this fail OPEN: a typo in the list
// threw, and the entry it was meant to restrict stayed live and unrestricted - the exact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@

/**
* Converts scalar values to the requested builtin type.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final class BuiltinValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@

/**
* Converts collection values using the configured factory.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final readonly class CollectionValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

/**
* Handles conversion of registered custom types.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final readonly class CustomTypeValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@

/**
* Converts ISO-8601 strings and timestamps into date/time value objects.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final class DateTimeValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
3 changes: 3 additions & 0 deletions src/JsonMapper/Value/Strategy/EnumValueConversionStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
/**
* Converts scalar JSON values into enum cases. A backed enum is addressed by case value, a pure
* enum by case name.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final class EnumValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
3 changes: 3 additions & 0 deletions src/JsonMapper/Value/Strategy/NullValueConversionStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

/**
* Decides what a null payload means for the declared type.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final class NullValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

/**
* Provides reusable guards for strategies operating on object types.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
trait ObjectTypeConversionGuardTrait
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@

/**
* Converts object values by delegating to the mapper callback.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final readonly class ObjectValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

/**
* Fallback strategy returning the value unchanged.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final class PassthroughValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
* elements being the case that mattered - matched no strategy for a union element type and handed
* the raw payload back unconverted, without recording anything. Registering the resolution as a
* strategy puts it where every consumer of the converter reaches it.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final readonly class UnionValueConversionStrategy implements ValueConversionStrategyInterface
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

/**
* Contract for value conversion strategies.
*
* @internal This is not a public extension point. Register conversions through
* {@see \MagicSunday\JsonMapper\Value\TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
interface ValueConversionStrategyInterface
{
Expand Down
3 changes: 3 additions & 0 deletions src/JsonMapper/Value/ValueConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

/**
* Converts JSON values according to the registered strategies.
*
* @internal This is not a public extension point. Register conversions through
* {@see TypeHandlerInterface} via JsonMapper::addTypeHandler().
*/
final class ValueConverter
{
Expand Down
14 changes: 14 additions & 0 deletions tests/JsonMapper/Resolver/ClassResolverAllowlistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,18 @@ public function itRejectsAnAllowlistNamingAClassThatDoesNotExist(): void

$resolver->add(Person::class, static fn (): string => VipPerson::class, ['NotAClass']);
}

#[Test]
public function itAcceptsAPlainClassStringTarget(): void
{
// The constructor $classMap already accepts a static SdkFoo => Foo mapping; add() now does
// too, rather than forcing a trivial closure wrapper. resolve() has always handled both.
$resolver = new ClassResolver();
$resolver->add(Person::class, VipPerson::class);

self::assertSame(
VipPerson::class,
$resolver->resolve(Person::class, [], new MappingContext([])),
);
}
}
40 changes: 40 additions & 0 deletions tests/JsonMapper/StaticClassMapEntryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/**
* This file is part of the package magicsunday/jsonmapper.
*
* For the full copyright and license information, please read the
* LICENSE file that was distributed with this source code.
*/

declare(strict_types=1);

namespace MagicSunday\Test\JsonMapper;

use MagicSunday\Test\Classes\Person;
use MagicSunday\Test\Classes\VipPerson;
use MagicSunday\Test\TestCase;
use PHPUnit\Framework\Attributes\Test;

/**
* The constructor $classMap accepts a static SdkFoo => Foo mapping; addCustomClassMapEntry() now
* accepts one too, so a static mapping can be registered at runtime without wrapping it in a
* trivial closure. This drives that through the PUBLIC entry point - the widened ClassResolver::add()
* would otherwise be reachable only from a unit test.
*
* @internal
*/
final class StaticClassMapEntryTest extends TestCase
{
#[Test]
public function itMapsThroughAStaticClassStringEntry(): void
{
$result = $this->getJsonMapper()
->addCustomClassMapEntry(Person::class, VipPerson::class)
->map($this->getJsonAsObject('{"name": "a", "oscars": 3}'), Person::class);

self::assertInstanceOf(VipPerson::class, $result, 'The static target class is instantiated.');
self::assertSame('a', $result->name);
self::assertSame(3, $result->oscars);
}
}