diff --git a/README.md b/README.md index ebf5fdd..f347424 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # PHP-Jackson +[![PHP Tests](https://github.com/tcds-io/php-jackson/actions/workflows/tests.yml/badge.svg)](https://github.com/tcds-io/php-jackson/actions/workflows/tests.yml) + ###### A lightweight, flexible object serializer for PHP, inspired by [Jackson](https://github.com/FasterXML/jackson). It provides strong typing, JSON ↔ object mapping, generics support, array/object shapes, custom type mappers, and detailed error tracing. @@ -21,9 +23,13 @@ It provides strong typing, JSON ↔ object mapping, generics support, array/obje - [Map Example](#map-example) - [Array Shape Example](#array-shape-example) - [Object Shape Example](#object-shape-example) +- [Renaming JSON keys with `#[JsonProperty]`](#-renaming-json-keys-with-jsonproperty) - [Custom Type Mappers](#-custom-type-mappers) + - [Using Custom Mappers with External Context](#using-custom-mappers-with-external-context) + - [Pinning a Mapper on the Class with `#[JsonMapper]`](#pinning-a-mapper-on-the-class-with-jsonmapper) - [Date Handling](#-date-handling) - [Error Handling](#-error-handling) +- [Development](#-development) - [Summary](#-summary) --- @@ -53,9 +59,9 @@ PHP Jackson offers first-class integrations for popular PHP frameworks and tools Each integration extends the core mapper with framework-specific features for a smoother development experience. Official Plugins: -- Laravel -- Symfony -- Guzzle +- Laravel — controller injection, JSON responses, request error handling, and Eloquent casts +- Symfony — controller argument resolvers, JSON responses, and configurable request error handling +- Guzzle — typed HTTP client with request DTO mapping and async response parsing ## 🔧 Basic Usage @@ -170,6 +176,8 @@ $json = $mapper->writeValue($object); ## 📚 Generic Types (`list`, `map`, shapes) +The `generic()` and `shape()` helper functions are loaded by Composer through `php-better-generics`. + ### List example ```php @@ -237,16 +245,55 @@ $object->position instanceof LatLng --- +## 🏷️ Renaming JSON keys with `#[JsonProperty]` + +PHP-Jackson maps JSON keys to PHP names 1:1 by default. When the wire format +uses a different naming convention (snake_case, kebab-case, etc.), pin the +JSON key on the constructor parameter (or property) with `#[JsonProperty]`: + +```php +use Tcds\Io\Jackson\Node\JsonProperty; + +readonly class User +{ + public function __construct( + #[JsonProperty('first_name')] public string $firstName, + #[JsonProperty('last_name')] public string $lastName, + public int $age, + ) {} +} +``` + +The attribute is honored on **both** directions: + +```php +$mapper = new JsonObjectMapper(); + +$user = $mapper->readValue(User::class, '{"first_name":"Arthur","last_name":"Dent","age":42}'); +// User { firstName: "Arthur", lastName: "Dent", age: 42 } + +$mapper->writeValue($user); +// {"first_name":"Arthur","last_name":"Dent","age":42} +``` + +Error traces and the `expected` payload on `UnableToParseValue` use the wire +key — the one users will recognize from the JSON they are sending — not the +PHP identifier. + +--- + ## 🧩 Custom Type Mappers Custom mappers are useful when object construction depends on complex logic or external data: ```php +use Tcds\Io\Jackson\ArrayObjectMapper; + $mapper = new ArrayObjectMapper( typeMappers: [ LatLng::class => [ - 'reader' => fn (string $value) => new LatLng(...explode(',', $value)), - 'writer' => fn (LatLng $value) => sprintf("%s, %s", $value->lat, $value->lng), + 'reader' => fn(string $data) => new LatLng(...explode(',', $data)), + 'writer' => fn(LatLng $data) => sprintf("%s, %s", $data->lat, $data->lng), ] ] ); @@ -275,13 +322,15 @@ and serialize back into: ### Using Custom Mappers with External Context ```php +use Tcds\Io\Jackson\ArrayObjectMapper; + $mapper = new ArrayObjectMapper( typeMappers: [ User::class => [ - 'reader' => fn () => Auth::user(), - 'writer' => fn (User $user) => [ - 'id' => $user->id, - 'name' => $user->name, + 'reader' => fn() => Auth::user(), + 'writer' => fn(User $data) => [ + 'id' => $data->id, + 'name' => $data->name, // 'email' intentionally omitted ], ] @@ -289,6 +338,52 @@ $mapper = new ArrayObjectMapper( ); ``` +Mapper closures can receive any of the named arguments used internally by PHP-Jackson: + +```php +fn(mixed $data, string $type, ObjectMapper $mapper, array $path): mixed +``` + +Use only the parameters you need; `ReflectionFunction::call()` binds them by name. + +--- + +### Pinning a Mapper on the Class with `#[JsonMapper]` + +If a class always wants the same custom (de)serialization, declare it once on +the class itself instead of registering it on every mapper instance: + +```php +use Tcds\Io\Jackson\Node\JsonMapper; + +#[JsonMapper(reader: MoneyReader::class, writer: MoneyWriter::class)] +readonly class Money +{ + public function __construct(public int $cents) {} +} +``` + +The `reader` and `writer` accept any of: + +- a class string of an implementation of `Reader` / `Writer` (instance is + built with a no-arg constructor), +- a class string of `StaticReader` / `StaticWriter` (no instance — the static + `read` / `write` is called), +- a class string of any class with a matching `__invoke` (treated as a + `MapperClosure`), +- an instance of `Reader` / `Writer` (PHP 8.1 `new` in attribute initializers), +- a `Closure` matching `MapperClosure`, when constructing `JsonMapper` + programmatically (PHP attribute literals can't carry closures). + +**Resolution order on every read/write:** + +1. `#[JsonMapper]` attribute on the target class — declaration site, wins +2. `typeMappers` constructor argument +3. default reader/writer + +That is, an explicit class-level mapper cannot be silently overridden by +mapper-instance config — the class itself is the canonical source. + --- ## 🕒 Date Handling @@ -335,6 +430,16 @@ This makes debugging extremely easy. --- +## 🔧 Development + +```bash +composer install +composer tests # runs cs:check + phpstan + phpunit +composer cs:fix # auto-fix code style +``` + +--- + ## ✅ Summary You can: @@ -344,5 +449,7 @@ You can: - Merge missing fields using `readValueWith` - Write objects → JSON/arrays via `writeValue` - Use generics (`list`, `map`, shapes) -- Register custom mappers for any class +- Rename wire keys per field with `#[JsonProperty('snake_case')]` +- Register custom mappers for any class via `typeMappers` or pin them on the + class itself with `#[JsonMapper(reader: …, writer: …)]` - Rely on strong error tracing with full path information diff --git a/tests/Fixture/Money.php b/tests/Fixture/Money.php index 84ee961..a77f8e1 100644 --- a/tests/Fixture/Money.php +++ b/tests/Fixture/Money.php @@ -6,12 +6,10 @@ use Tcds\Io\Jackson\Node\JsonMapper; -#[ - JsonMapper( - reader: MoneyReader::class, - writer: MoneyWriter::class, - ) -] +#[JsonMapper( + reader: MoneyReader::class, + writer: MoneyWriter::class, +)] readonly class Money { public function __construct(public int $cents)