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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ Shipmondo list endpoints return a **bare JSON array** of items and carry paginat
## Requests, responses, exceptions

- **Requests** (`src/Request/`): `abstract Payload` is the marker the normalizer matches. The body DTOs (`Payload` subclasses) are `final` with **mutable** `public` promoted properties and **all-optional** constructor args (no construction-time `Assert`), so callers can build a request incrementally (`new SalesOrderRequest()` → assign fields → `$req->orderLines[] = $line`) or in one named-arg call; required fields are enforced server-side (a missing one → `ValidationException`). The query VOs (`CollectionRequestOptions`, `PickupPointSearch`) are the exception — they stay `readonly` + validated. One more exception: `WebhookRequest::$key` IS validated in the constructor (≥32 bytes / 256-bit floor) — not a server rule but a client-side HS256 requirement, since a shorter key could never be verified by `WebhookParser` (the property stays mutable, so the constructor check covers the named-arg path; the verify path re-checks). Native enums live in `src/Enum/`. **Naming:** the top-level request body an endpoint method accepts is suffixed `Request` (`SalesOrderRequest`, `WebhookRequest`); nested value objects (`Recipient`, `Sender`, `OrderLine`, …) and all responses stay bare (the `Request\`/`Response\` namespace carries the distinction). `PickupPointSearch` and `CollectionRequestOptions` are **query VOs (not Payloads)** with a `toArray()` of snake_case query params. Quirk: address `zipcode` (lowercase property name so snake-casing is a no-op) and the `vat_no` (ship_to/bill_to → `Recipient`) vs `vat_id` (sender → `Sender`) split.
- **Responses** (`src/Response/`): `abstract Resource` carries `public array $raw`. DTOs are `final` with `public readonly` props (not `final readonly class` — `$raw` is mutated post-map; Rector skips them). Domain folders are singular (`Response/ShipmentTemplate/…`). Response `Webhook` keeps `action`/`resourceName` as plain strings (lenient reads); the request `Webhook` uses enums.
- **Responses** (`src/Response/`): `abstract Resource` carries `public array $raw`. DTOs are `final` with `public readonly` props (not `final readonly class` — `$raw` is mutated post-map; Rector skips them). Domain folders are singular (`Response/ShipmentTemplate/…`). Both the response `Webhook` and the inbound `WebhookEvent` type `action`/`resourceName`(`resourceType`) as the `WebhookAction`/`WebhookResourceName` enums and **fail-fast on an unknown value** (a `MappingException` on the read path, a `MalformedWebhookException` in the parser) rather than passing an unmodeled string through — the original strings are still reachable via `Resource::$raw` for the response. (This reverses an earlier lenient-strings choice at the user's request: they prefer things to blow up when something is unexpected.)
- **Exceptions** (`src/Exception/`): marker interface `ShipmondoException` → `ResponseAwareException` (pre-reads the body once so `getError()` works on non-seekable streams; Shipmondo errors are `{"error": "..."}`) → `ClientErrorException` / `ServerErrorException` → concretes. `Client::assertStatusCode()` maps status codes via `match` (401/404/422/429, `>=500`, default `UnexpectedStatusCodeException`); 2xx-but-unmappable → `MappingException` (extends `MalformedResponseException`). The webhook-parsing exceptions are a **separate** branch off the marker (they carry no Shipmondo HTTP response, so they do NOT extend `ResponseAwareException`): abstract `WebhookException` → `WebhookVerificationException` (bad signature / wrong key → 401/403) and `MalformedWebhookException` (bad JSON / missing `data` / non-HS256 token → 400).

### Incoming webhook parsing (`src/Webhook/`)
Separate from the API-management `WebhooksEndpoint` (subscription CRUD), `WebhookParser` verifies and parses the webhooks Shipmondo POSTs to a consumer's server. A delivery is `{"data": "<JWT>"}` (HS256-signed with the webhook `key`) plus five `SMD-*` headers (`SMD-Resource-Type`/`-Resource-Id`/`-Webhook-Id`/`-Action`/`-User`); the verified JWT payload is `{webhook, data, url}`. `parse(ServerRequestInterface, $key)` (PSR-7) and `parsePayload(string $body, array $headers, $key)` (framework-agnostic) return a `WebhookEvent` (plain VO, NOT a `Resource`) exposing the header metadata + the `data` resource as `array<array-key, mixed>` (raw snake_case, like `Resource::$raw`). Verification pins HS256 (firebase rejects any other `alg`, incl. `none`). The parser is standalone (Client-decoupled — different secret), stateless, and the `key` is passed per call so a multi-webhook server can pick the key by `SMD-Webhook-Id`. No typed mapping of `data` (deliberate — payload shapes can drift from GET responses), and `WebhookEvent` carries **no `$raw`** (its three claims `webhook`/`url`/`data` are all already typed properties, so a `Resource`-style `$raw` would be redundant; `data` is itself the untyped escape hatch). Unlike endpoints, `WebhookParser` **does** implement an interface (`WebhookParserInterface`) — it's an injectable consumer-facing service, not a Client-internal endpoint, so the no-`*EndpointInterface` rule doesn't apply.
Separate from the API-management `WebhooksEndpoint` (subscription CRUD), `WebhookParser` verifies and parses the webhooks Shipmondo POSTs to a consumer's server. A delivery is `{"data": "<JWT>"}` (HS256-signed with the webhook `key`) plus five `SMD-*` headers (`SMD-Resource-Type`/`-Resource-Id`/`-Webhook-Id`/`-Action`/`-User`); the verified JWT payload is `{webhook, data, url}`. `parse(ServerRequestInterface, $key)` (PSR-7) and `parsePayload(string $body, array $headers, $key)` (framework-agnostic) return a `WebhookEvent` (plain VO, NOT a `Resource`) exposing the header metadata + the `data` resource as `array<array-key, mixed>` (raw snake_case, like `Resource::$raw`). Verification pins HS256 (firebase rejects any other `alg`, incl. `none`). The parser is standalone (Client-decoupled — different secret), stateless, and the `key` is passed per call so a multi-webhook server can pick the key by `SMD-Webhook-Id`. No typed mapping of `data` (deliberate — payload shapes can drift from GET responses), and `WebhookEvent` carries **no `$raw`** (its three claims `webhook`/`url`/`data` are all already typed properties, so a `Resource`-style `$raw` would be redundant; `data` is itself the untyped escape hatch). Unlike endpoints, `WebhookParser` **does** implement an interface (`WebhookParserInterface`) — it's an injectable consumer-facing service, not a Client-internal endpoint, so the no-`*EndpointInterface` rule doesn't apply. `WebhookEvent::$action`/`$resourceType` are the `WebhookAction`/`WebhookResourceName` enums; the parser resolves the `SMD-Action`/`SMD-Resource-Type` headers via `tryFrom()` and throws `MalformedWebhookException` if missing/unknown. The resource↔action relationship is encoded on `WebhookResourceName` (`actions(): list<WebhookAction>` + `supports(WebhookAction): bool`), and `WebhooksEndpoint::create()` throws `\InvalidArgumentException` on an unsupported resource/action pair before sending (only when both are set — null fields stay server-authoritative).

## Testing conventions

Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,21 @@ try {
return;
}

$event->action; // 'create', 'cancel', 'status_update', ... (the SMD-Action header)
$event->resourceType; // 'Shipments', 'Orders', ... (the SMD-Resource-Type header)
$event->action; // WebhookAction::Create (typed enum, from the SMD-Action header)
$event->resourceType; // WebhookResourceName::Shipments (from the SMD-Resource-Type header)
$event->resourceId; // int|null (the SMD-Resource-Id header)
$event->data; // array<string, mixed> — the resource, snake_case, as in the API docs
$event->data; // array<array-key, mixed> — the resource, snake_case, as in the API docs
$event->data['id'];

// Reply within 3 seconds with a 200, then do the heavy lifting out of band.
http_response_code(200);
```

`action` and `resourceType` are typed enums — if Shipmondo ever sends an action or resource the SDK
doesn't model, `parse()` throws `MalformedWebhookException` rather than passing an unknown value
through. (`WebhookResourceName::actions()` lists the actions valid for a resource, and
`WebhooksEndpoint::create()` rejects an invalid resource/action pair before sending.)

If you are not on PSR-7, pass the raw body and headers instead:

```php
Expand Down
20 changes: 20 additions & 0 deletions src/Client/Endpoint/WebhooksEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Setono\Shipmondo\Client\Endpoint;

use Setono\Shipmondo\Enum\WebhookAction;
use Setono\Shipmondo\Request\Webhook\WebhookRequest;
use Setono\Shipmondo\Response\Webhook\Webhook;

Expand All @@ -15,9 +16,28 @@ final class WebhooksEndpoint extends CollectionEndpoint
/**
* Create a webhook (`POST /webhooks`). Shipmondo immediately calls the endpoint to verify it,
* expecting an HTTP 200 response.
*
* @throws \InvalidArgumentException if the request pairs a resource with an action it does not support
*/
public function create(WebhookRequest $request): Webhook
{
// Fail fast on an invalid resource/action pair (a server 422) when both are set. When either
// is null we stay lenient and let the API decide — required fields are enforced server-side.
if (null !== $request->resourceName &&
null !== $request->action &&
!$request->resourceName->supports($request->action)
) {
throw new \InvalidArgumentException(sprintf(
'The "%s" action is not valid for the "%s" resource. Valid actions are: %s.',
$request->action->value,
$request->resourceName->value,
implode(', ', array_map(
static fn (WebhookAction $action): string => $action->value,
$request->resourceName->actions(),
)),
));
}

return $this->createOne($request);
}

Expand Down
38 changes: 38 additions & 0 deletions src/Enum/WebhookResourceName.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,42 @@ enum WebhookResourceName: string
case Shipments = 'Shipments';
case Orders = 'Orders';
case ShipmentMonitor = 'Shipment Monitor';

/**
* The webhook actions Shipmondo supports for this resource. Use it to discover the valid
* actions for a resource (e.g. to build a UI) or to pre-validate a combination before creating
* a webhook — `WebhooksEndpoint::create()` rejects an unsupported pair before sending.
*
* @return list<WebhookAction>
*/
public function actions(): array
{
return match ($this) {
self::Shipments => [
WebhookAction::Create,
WebhookAction::Cancel,
],
self::Orders => [
WebhookAction::Create,
WebhookAction::StatusUpdate,
WebhookAction::CreateFulfillment,
WebhookAction::CreateShipment,
WebhookAction::PaymentCaptured,
WebhookAction::PaymentVoided,
WebhookAction::Delete,
],
self::ShipmentMonitor => [
WebhookAction::Latest,
WebhookAction::Delivered,
],
};
}

/**
* Whether Shipmondo supports the given action for this resource.
*/
public function supports(WebhookAction $action): bool
{
return in_array($action, $this->actions(), true);
}
}
12 changes: 7 additions & 5 deletions src/Response/Webhook/Webhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

namespace Setono\Shipmondo\Response\Webhook;

use Setono\Shipmondo\Enum\WebhookAction;
use Setono\Shipmondo\Enum\WebhookResourceName;
use Setono\Shipmondo\Response\Resource;

/**
* `action` and `resourceName` are kept as plain strings (not enums) on the read side so the SDK
* never fails to map a response when Shipmondo introduces a new action/resource value. The raw
* values are also available via {@see Resource::$raw}.
* `action` and `resourceName` are typed enums: a webhook whose action/resource the SDK does not
* model fails to map (a {@see \Setono\Shipmondo\Exception\MappingException}) rather than silently
* passing an unknown value through. The original string values remain available via {@see Resource::$raw}.
*/
final class Webhook extends Resource
{
Expand All @@ -18,8 +20,8 @@ public function __construct(
public readonly string $endpoint,
public readonly bool $active,
public readonly string $name,
public readonly string $action,
public readonly string $resourceName,
public readonly WebhookAction $action,
public readonly WebhookResourceName $resourceName,
) {
}
}
17 changes: 10 additions & 7 deletions src/Webhook/WebhookEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace Setono\Shipmondo\Webhook;

use Setono\Shipmondo\Enum\WebhookAction;
use Setono\Shipmondo\Enum\WebhookResourceName;

/**
* A verified incoming webhook delivered by Shipmondo, returned by {@see WebhookParser}.
*
Expand All @@ -12,15 +15,15 @@
* {@see self::$user}); the verified JWT payload populates {@see self::$webhookName},
* {@see self::$url}, and {@see self::$data}.
*
* Following the SDK's lenient-read convention (cf. {@see \Setono\Shipmondo\Response\Webhook\Webhook}),
* `action` and `resourceType` are plain strings so a new Shipmondo action/resource value never
* causes parsing to fail.
* `action` and `resourceType` are typed enums; {@see WebhookParser} throws a
* {@see \Setono\Shipmondo\Exception\MalformedWebhookException} if Shipmondo sends an action or
* resource the SDK does not model, rather than passing an unknown value through.
*/
final class WebhookEvent
{
/**
* @param string $action the `SMD-Action` header, e.g. `create`, `cancel`, `status_update`
* @param string $resourceType the `SMD-Resource-Type` header, e.g. `Shipments`, `Orders`
* @param WebhookAction $action the `SMD-Action` header (e.g. `create`, `cancel`, `status_update`)
* @param WebhookResourceName $resourceType the `SMD-Resource-Type` header (e.g. `Shipments`, `Orders`)
* @param int|null $resourceId the `SMD-Resource-Id` header (the id of the affected resource)
* @param int|null $webhookId the `SMD-Webhook-Id` header (the id of the webhook that fired)
* @param string|null $user the `SMD-User` header (who triggered the action), or `null` if absent
Expand All @@ -31,8 +34,8 @@ final class WebhookEvent
* resource's GET endpoint
*/
public function __construct(
public readonly string $action,
public readonly string $resourceType,
public readonly WebhookAction $action,
public readonly WebhookResourceName $resourceType,
public readonly ?int $resourceId,
public readonly ?int $webhookId,
public readonly ?string $user,
Expand Down
32 changes: 28 additions & 4 deletions src/Webhook/WebhookParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;
use Psr\Http\Message\ServerRequestInterface;
use Setono\Shipmondo\Enum\WebhookAction;
use Setono\Shipmondo\Enum\WebhookResourceName;
use Setono\Shipmondo\Exception\MalformedWebhookException;
use Setono\Shipmondo\Exception\WebhookVerificationException;

Expand All @@ -29,8 +31,8 @@
* ```php
* $event = (new WebhookParser())->parse($serverRequest, $webhookKey);
*
* $event->action; // 'create'
* $event->resourceType; // 'Shipments'
* $event->action; // WebhookAction::Create
* $event->resourceType; // WebhookResourceName::Shipments
* $event->data; // array<array-key, mixed> — the resource, snake_case as in the API docs
* ```
*/
Expand Down Expand Up @@ -120,8 +122,8 @@ public function parsePayload(string $body, array $headers, string $key): Webhook
$user = $headers['smd-user'] ?? '';

return new WebhookEvent(
action: $headers['smd-action'] ?? '',
resourceType: $headers['smd-resource-type'] ?? '',
action: self::action($headers['smd-action'] ?? ''),
resourceType: self::resourceType($headers['smd-resource-type'] ?? ''),
resourceId: self::toInt($headers['smd-resource-id'] ?? ''),
webhookId: self::toInt($headers['smd-webhook-id'] ?? ''),
user: '' === $user ? null : $user,
Expand Down Expand Up @@ -200,6 +202,28 @@ private static function toInt(string $value): ?int
return is_numeric($value) ? (int) $value : null;
}

/**
* @throws MalformedWebhookException if the SMD-Action header is missing or carries an unknown value
*/
private static function action(string $value): WebhookAction
{
return WebhookAction::tryFrom($value) ?? throw new MalformedWebhookException(sprintf(
'Unexpected SMD-Action header: "%s" is not a webhook action the SDK knows about.',
$value,
));
}

/**
* @throws MalformedWebhookException if the SMD-Resource-Type header is missing or carries an unknown value
*/
private static function resourceType(string $value): WebhookResourceName
{
return WebhookResourceName::tryFrom($value) ?? throw new MalformedWebhookException(sprintf(
'Unexpected SMD-Resource-Type header: "%s" is not a webhook resource the SDK knows about.',
$value,
));
}

/**
* @param array<array-key, mixed> $payload
*/
Expand Down
19 changes: 17 additions & 2 deletions tests/Client/Endpoint/WebhooksEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ public function it_creates_a_webhook(): void

self::assertSame(8500472, $webhook->id);
self::assertTrue($webhook->active);
self::assertSame('create', $webhook->action);
self::assertSame('Shipments', $webhook->resourceName);
self::assertSame(WebhookAction::Create, $webhook->action);
self::assertSame(WebhookResourceName::Shipments, $webhook->resourceName);

/** @var array<string, mixed> $body */
$body = json_decode((string) $http->sentRequests[0]->getBody(), true, flags: \JSON_THROW_ON_ERROR);
Expand Down Expand Up @@ -113,4 +113,19 @@ public function it_skips_non_array_rows_in_a_page(): void

self::assertCount(1, iterator_to_array($page));
}

#[Test]
public function it_rejects_an_invalid_resource_action_combination_before_sending(): void
{
// "delete" is only valid for Orders, not Shipments — this must throw before any HTTP call.
$this->expectException(\InvalidArgumentException::class);

$this->client(new ScriptedHttpClient())->webhooks()->create(new WebhookRequest(
name: 'sdk-test',
endpoint: 'https://example.com',
key: 'a-sufficiently-long-webhook-signing-key',
action: WebhookAction::Delete,
resourceName: WebhookResourceName::Shipments,
));
}
}
Loading
Loading