Skip to content

Add update() (full-replace PUT), CustomerRequest::fromResponse(), typed Customer fields#4

Merged
loevgaard merged 4 commits into
1.xfrom
update-endpoints-and-rmw
Jun 10, 2026
Merged

Add update() (full-replace PUT), CustomerRequest::fromResponse(), typed Customer fields#4
loevgaard merged 4 commits into
1.xfrom
update-endpoints-and-rmw

Conversation

@loevgaard

@loevgaard loevgaard commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

Implements four end-user DX feedback items, in their priority order:

  1. update() support — new Client::put() / ClientInterface::put() (mirrors post(), both delegate to a shared private send()), ResourceEndpoint::updateOne() pipeline, and typed one-liners CustomersEndpoint::update(int, CustomerRequest): Customer and DraftOrdersEndpoint::update(int, DraftOrderRequest): Order.
  2. Typed phone fieldstelephoneAndFaxNumber / mobilePhone are now ?string properties on the response Customer (previously $raw-only).
  3. Read-modify-write storyPayload::fromResponse(Resource $response): static, inherited by every request DTO: the response's full decoded body ($raw) is mapped into the request DTO via a dedicated Valinor mapper. Reference objects become Identifier instances via the new Identifier::fromReference() (field name inferred from the reference's single scalar <x>Number key, so identifiers round-trip back to the exact shape the server produced); server-computed fields, HATEOAS links and unmodeled schema fields are dropped as superfluous. The mapper is strict — present-but-malformed raw data throws (with node-path context) instead of being silently dropped, because a dropped field would be cleared server-side by the full-replace PUT. This works for orders too: DraftOrderRequest::fromResponse($order) maps nested Payloads (Recipient, Delivery, References, Line) recursively. To make mutation-after-prefill work, all Payload request DTOs are mutable (final class, no readonly); Payload itself became an abstract base class (was a marker interface); rector.php skips the ReadOnly rectors for src/Request/Customer and src/Request/Order.
  4. Customer::$lastUpdated is now ?\DateTimeImmutable — Valinor's default date handling maps e-conomic's 2020-02-19T09:18:09Z format with no builder changes; wire string remains at $raw['lastUpdated'].

Also in this PR:

  • Claims about full-replace PUT verified against the official e-conomic REST docs ("PUT to an existing ressource will overwrite the full object"; "To null a property you must exclude it from your JSON") — the null-stripping Payload transformer is mandatory, not cosmetic, since e-conomic rejects explicit nulls.
  • Documented nuance found during verification: eInvoicingDisabledByDefault is "updatable only by using PATCH to /customers/:customerNumber" (the API's sole exception to its no-PATCH-on-JSON rule) — its value in a PUT body is ignored, so mutating it on a prefilled request has no effect through update().
  • Fixes a pre-existing #[CoversClass(EconomicException::class)] on an interface in ExceptionHierarchyTest — PHPUnit 11 rejects interfaces as coverage targets, which would fail the CI mutation job.
  • Removes AUDIT.md (its closeout note says it is safe to delete; the deletion was already staged).
  • README: read-modify-write examples for customers and draft orders, full-replace warnings, updated endpoint table, supportDateFormats() caveat. CLAUDE.md updated to match.

BC notes (pre-release 1.x, latest tag v1.0.0-alpha)

  • Customer::$lastUpdated changed type ?string?\DateTimeImmutable
  • ClientInterface gained put()
  • Request DTOs are no longer readonly; Payload is an abstract class instead of an interface (implements Payloadextends Payload)
  • fromResponse() takes Resource (inherited generic) — passing the wrong resource class is a runtime mapping error, not a compile-time type error

Test plan

  • PHPUnit: 173 tests / 445 assertions (32 new), including two full-body round-trip tests — customers and draft orders — that GET a rich resource, prefill via fromResponse(), mutate, PUT, and assertSame the entire serialized body; direct Identifier::fromReference() unit tests; put() cases in ClientTest; phone/lastUpdated mapping cases
  • PHPStan level max: clean, zero new ignores
  • ECS + Rector dry-run: clean
  • Infection: exit 0 — MSI 68% (min 53.85), covered MSI 79% (min 79.25)

Addresses end-user DX feedback:

- Client::put() + ClientInterface::put() (shared send() pipeline with post())
- ResourceEndpoint::updateOne() + update() on CustomersEndpoint and
  DraftOrdersEndpoint (e-conomic full-replace PUT)
- CustomerRequest::fromResponse(Customer) read-modify-write prefill helper;
  reference objects and untyped scalars extracted from $raw, malformed raw
  data throws instead of silently dropping (full-replace would clear it)
- All Payload request DTOs are now mutable (final class, no readonly) so the
  RMW flow is prefill -> assign -> update(); rector skips ReadOnly* rules
  for src/Request/Customer and src/Request/Order
- Customer response: typed telephoneAndFaxNumber/mobilePhone, lastUpdated
  is now ?DateTimeImmutable (wire string stays in $raw['lastUpdated'])
- Fix pre-existing CoversClass on the EconomicException interface (rejected
  by PHPUnit 11 under a coverage driver, would fail CI's mutation job)
- Delete AUDIT.md (closeout note says safe to delete; was already staged)

BC notes for next pre-release tag: Customer::$lastUpdated type change,
ClientInterface gained put(), request DTOs no longer readonly.
Verified the full-replace PUT claims against the official e-conomic REST
docs. PUT semantics ("overwrite the full object and must include the full
entity") and absent-field-clears ("To null a property you must exclude it
from your JSON on the write operation") are confirmed verbatim.

One nuance surfaced: eInvoicingDisabledByDefault "is updatable only by
using PATCH to /customers/:customerNumber" — the API's sole exception to
its no-PATCH-on-JSON rule. Its value in a PUT body is ignored and it is
not cleared by omission, so mutating it on a prefilled request has no
effect through update(). Documented on CustomersEndpoint::update(),
CustomerRequest::fromResponse(), the README RMW section, and CLAUDE.md.
CustomerRequest::fromResponse() and its five raw-extraction helpers are
replaced by a single generic Payload::fromResponse(Resource): static that
maps $response->raw into the calling DTO via a dedicated Valinor mapper:

- Payload becomes an abstract base class (was a marker interface); the
  eight request DTOs now extend it instead of implementing it
- Identifier::fromReference(array) infers the JSON field name from the
  reference object's single scalar <x>Number key, so one constructor
  covers every reference type and identifiers round-trip back to the
  exact shape the server produced
- the request mapper is strict (no scalar casting - malformed raw data
  throws, never silently dropped), allows superfluous keys (raw carries
  the full response body), and converts the SDK's guard exceptions into
  mapping errors with node-path context via filterExceptions()
- MappingError is wrapped in InvalidArgumentException carrying the
  "requires a response fetched through the SDK" hint with the Valinor
  error preserved as $previous

DraftOrderRequest::fromResponse() falls out for free, closing the
read-modify-write gap for orders: nested Payloads (Recipient, Delivery,
References, Line) and their Identifiers map recursively. Covered by a
full-body order round-trip test mirroring the customers one, plus direct
Identifier::fromReference() unit tests.

Trade-off: the parameter type widens from Customer to Resource (PHP
cannot narrow parameter types in overrides), so passing the wrong
resource class is a runtime mapping error rather than a compile-time
type error.
The Unit tests (8.4, lowest) job has been failing since before this
branch: on valinor <= 2.2.1 the RawStamper converter never fires, so
$raw is empty on every mapped Resource (breaking ResourceRawTest on
1.x, and cascading into the new fromResponse() pipeline here), and
mapping error paths/messages differ. Bisected the floor empirically:
2.0.0, 2.1.x, 2.2.0 and 2.2.1 all fail in different ways; 2.2.2 is
the first version where the full suite passes (2.2.2 ships "handle
object arguments default value", which the DTOs' defaulted
constructor args rely on).

Verified both resolution ends locally: --prefer-lowest (2.2.2) and
highest (2.4.0) — phpunit, PHPStan, ECS, Rector and composer
validate/normalize all green.
@loevgaard loevgaard merged commit 97c8b49 into 1.x Jun 10, 2026
7 checks passed
@loevgaard loevgaard deleted the update-endpoints-and-rmw branch June 10, 2026 11:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant