diff --git a/README.md b/README.md index 72fe5aa..e5b609f 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,42 @@ $options = new \SumUp\HttpClient\RequestOptions( $checkout = $sumup->checkouts()->get('checkout-id', $options); ``` +## Handling Events + +Create a handler with your endpoint's signing secret and register typed callbacks: + +```php +use SumUp\Events\EventNotification; +use SumUp\Events\ReaderCreatedEvent; +use SumUp\SumUp; + +$sumup = new SumUp(); +$events = $sumup->eventsHandler( + getenv('SUMUP_EVENT_SECRET'), + function (EventNotification $event): void { + // Handles unknown event types and known types without a registered callback. + error_log('Received ' . $event->type); + } +)->onReaderCreated(function (ReaderCreatedEvent $event): void { + $reader = $event->fetchObject(); // Returns a SumUp\Types\Reader, or null for an empty response. + error_log('Reader paired: ' . $reader?->id); +}); + +// In your HTTP route, pass the unchanged request body and complete signature header. +$events->handle( + file_get_contents('php://input'), + $_SERVER['HTTP_X_SUMUP_WEBHOOK_SIGNATURE'] ?? '' +); +``` + +Register callbacks with methods such as `onCheckoutPaid`, `onMemberUpdated`, and `onRoleDeleted`. A second registration for the same type replaces the previous callback. Notifications expose `id`, `type`, `createdAt` (`DateTimeImmutable`), and an `object` reference. `fetchObject($requestOptions)` uses your API client and returns the resource's current state; deleted resources may return an API error. Object URLs must match the configured `base_uri`, and resource fetches disable redirects. If you use a custom HTTP client, configure a matching `base_uri` and honor `RequestOptions::followRedirects`. + +Send a 2xx response after handling succeeds. Return 400 for `SumUp\Exception\EventSignatureException` or `EventPayloadException`, and 500 for `EventCallbackException` so processing can be retried. The callback exception retains the original error in `getPrevious()`. Make processing idempotent using the event ID, and configure request body limits in your server. Verification requires a signing timestamp within five minutes of your server's clock. + +For parsing without callbacks, use `$sumup->parseEventNotification($body, $signature, $secret)`. To queue deliveries, verify them first with `SumUp\Events\EventSignature::verify($secret, $body, $signature)`, then use `$sumup->parseEventNotificationWithoutVerification($body)` only on previously verified payloads from trusted storage. Unrecognized types produce `SumUp\Events\UnknownEvent`; their `fetchObject()` result is untyped. + +These signed notifications are separate from the checkout and reader `return_url` callbacks. See [examples/events.php](examples/events.php) for a complete HTTP endpoint with error handling. + ## Examples The repository includes runnable examples: @@ -150,6 +186,8 @@ The repository includes runnable examples: Run with: `php examples/custom-http-client.php` - `examples/guzzle-http-client.php` - use the built-in Guzzle client. Run with: `php examples/guzzle-http-client.php` +- `examples/events.php` - receive and verify signed events. + Run with: `SUMUP_EVENT_SECRET='your-signing-secret' php -S localhost:8080 examples/events.php` For checkout-related examples, set: diff --git a/codegen/pkg/generator/events.go b/codegen/pkg/generator/events.go new file mode 100644 index 0000000..50c5630 --- /dev/null +++ b/codegen/pkg/generator/events.go @@ -0,0 +1,92 @@ +package generator + +import ( + "bytes" + "embed" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "text/template" + + "github.com/iancoleman/strcase" + + "github.com/sumup/sumup-php/codegen/pkg/extension" +) + +//go:embed templates/events/*.tmpl +var eventTemplates embed.FS + +type eventDefinition struct { + Name string + Type string + ObjectType string + SchemaName string + Method string +} + +func (g *Generator) collectEvents() ([]eventDefinition, error) { + var events []eventDefinition + names := make(map[string]bool) + for eventType, item := range g.spec.Webhooks.FromOldest() { + if item == nil || item.Post == nil || item.Post.OperationId == "" { + return nil, fmt.Errorf("webhook %q is missing a POST operationId", eventType) + } + name := strcase.ToCamel(strings.TrimSuffix(item.Post.OperationId, "Webhook")) + if name == "" || names[name] { + return nil, fmt.Errorf("webhook %q has an empty or duplicate event class name", eventType) + } + names[name] = true + object, ok := extension.Get[struct { + Reference string `yaml:"$ref"` + }](item.Post.Extensions, "x-object") + schemaName, local := strings.CutPrefix(object.Reference, "#/components/schemas/") + if !ok || !local || schemaName == "" || g.spec.Components == nil { + return nil, fmt.Errorf("webhook %q requires a local x-object schema reference", eventType) + } + schema := g.spec.Components.Schemas.GetOrZero(schemaName) + if schema == nil || !schemaShouldGenerateClass(schema) { + return nil, fmt.Errorf("webhook %q has missing or unsupported object schema %q", eventType, schemaName) + } + events = append(events, eventDefinition{ + Name: name + "Event", + Type: eventType, + ObjectType: strcase.ToCamel(strings.NewReplacer(".", "_", "-", "_").Replace(schemaName)), + SchemaName: schemaName, + Method: "on" + name, + }) + } + slices.SortFunc(events, func(a, b eventDefinition) int { return strings.Compare(a.Type, b.Type) }) + return events, nil +} + +func (g *Generator) writeEvents() error { + templates, err := template.ParseFS(eventTemplates, "templates/events/*.tmpl") + if err != nil { + return fmt.Errorf("parse event templates: %w", err) + } + dir := filepath.Join(g.cfg.Out, "Events") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create events directory: %w", err) + } + write := func(name, templateName string, data any) error { + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, templateName, data); err != nil { + return fmt.Errorf("render event file %q: %w", name, err) + } + if err := os.WriteFile(filepath.Join(dir, name+".php"), buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write event file %q: %w", name, err) + } + return nil + } + for _, event := range g.events { + if err := write(event.Name, "event.tmpl", event); err != nil { + return err + } + } + if err := write("EventFactory", "factory.tmpl", g.events); err != nil { + return err + } + return write("EventsHandler", "handler.tmpl", g.events) +} diff --git a/codegen/pkg/generator/events_test.go b/codegen/pkg/generator/events_test.go new file mode 100644 index 0000000..f71e9b1 --- /dev/null +++ b/codegen/pkg/generator/events_test.go @@ -0,0 +1,87 @@ +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi" +) + +const webhookSpec = `{ + "openapi": "3.1.0", + "info": {"title": "Events", "version": "1.0.0"}, + "paths": {}, + "components": {"schemas": { + "Widget": {"type": "object", "properties": {"id": {"type": "string"}}} + }}, + "webhooks": { + "widgets.created": {"post": { + "operationId": "WidgetCreatedWebhook", + "x-object": {"$ref": "#/components/schemas/Widget"}, + "responses": {"2XX": {"description": "Accepted"}} + }} + } +}` + +func TestGenerateEvents(t *testing.T) { + t.Parallel() + doc, err := libopenapi.NewDocument([]byte(webhookSpec)) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err != nil { + t.Fatal(err) + } + out := t.TempDir() + g := New(Config{Out: out}) + if err := g.Load(&model.Model); err != nil { + t.Fatal(err) + } + if err := g.Build(); err != nil { + t.Fatal(err) + } + for file, snippets := range map[string][]string{ + "Events/WidgetCreatedEvent.php": {"const TYPE = 'widgets.created'", "fetchObject(?RequestOptions $options = null): ?Widget", "Widget::class"}, + "Events/EventFactory.php": {"WidgetCreatedEvent::TYPE => new WidgetCreatedEvent", "new UnknownEvent"}, + "Events/EventsHandler.php": {"callable(WidgetCreatedEvent): void", "function onWidgetCreated(callable $callback): self"}, + "Types/Widget.php": {"class Widget", "public ?string $id"}, + } { + data, err := os.ReadFile(filepath.Join(out, file)) + if err != nil { + t.Fatal(err) + } + for _, snippet := range snippets { + if !strings.Contains(string(data), snippet) { + t.Errorf("%s missing %q", file, snippet) + } + } + } +} + +func TestRejectInvalidWebhooks(t *testing.T) { + t.Parallel() + for name, spec := range map[string]string{ + "missing operation": strings.Replace(webhookSpec, `"WidgetCreatedWebhook"`, `""`, 1), + "missing object": strings.Replace(webhookSpec, `"x-object"`, `"x-other"`, 1), + "nonlocal object": strings.Replace(webhookSpec, `#/components/schemas/Widget`, `https://example.com/Widget`, 1), + "unknown object": strings.Replace(webhookSpec, `#/components/schemas/Widget`, `#/components/schemas/Missing`, 1), + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + doc, err := libopenapi.NewDocument([]byte(spec)) + if err != nil { + t.Fatal(err) + } + model, err := doc.BuildV3Model() + if err == nil { + err = New(Config{}).Load(&model.Model) + } + if err == nil { + t.Fatal("expected invalid webhook definition to fail") + } + }) + } +} diff --git a/codegen/pkg/generator/generator.go b/codegen/pkg/generator/generator.go index 9ddae6e..8f9317f 100644 --- a/codegen/pkg/generator/generator.go +++ b/codegen/pkg/generator/generator.go @@ -56,6 +56,8 @@ type Generator struct { // requestClassNames tracks schema classes used as OpenAPI request bodies. requestClassNames map[string]struct{} + + events []eventDefinition } type enumDefinition struct { @@ -90,7 +92,16 @@ func (g *Generator) Load(spec *v3.Document) error { g.tagLookup[normalizeTagKey(tag.Name)] = tag } + events, err := g.collectEvents() + if err != nil { + return err + } + g.events = events usage := g.collectSchemaUsage() + for _, event := range events { + schema := spec.Components.Schemas.GetOrZero(event.SchemaName) + g.collectSchemaUsageFromSchema(schema, nil, usage, make(map[*base.SchemaProxy]struct{}), event.ObjectType) + } g.schemasByTag, g.schemaNamespaces = g.assignSchemasToTags(usage) g.operationsByTag = g.collectOperations() g.collectRequestClassNames() @@ -147,6 +158,9 @@ func (g *Generator) Build() error { if err := g.writeApiVersion(); err != nil { return err } + if err := g.writeEvents(); err != nil { + return err + } return nil } diff --git a/codegen/pkg/generator/templates/events/event.tmpl b/codegen/pkg/generator/templates/events/event.tmpl new file mode 100644 index 0000000..4372630 --- /dev/null +++ b/codegen/pkg/generator/templates/events/event.tmpl @@ -0,0 +1,26 @@ +fetchResource({{.ObjectType}}::class, $options); + if ($object !== null && !$object instanceof {{.ObjectType}}) { + throw new EventObjectException('Expected a {{.ObjectType}} resource.'); + } + return $object; + } +} diff --git a/codegen/pkg/generator/templates/events/factory.tmpl b/codegen/pkg/generator/templates/events/factory.tmpl new file mode 100644 index 0000000..cfd8bc4 --- /dev/null +++ b/codegen/pkg/generator/templates/events/factory.tmpl @@ -0,0 +1,23 @@ + $payload */ + public static function create(SumUp $client, array $payload): EventNotification + { + return match ($payload['type'] ?? null) { +{{- range .}} + {{.Name}}::TYPE => new {{.Name}}($client, $payload), +{{- end}} + default => new UnknownEvent($client, $payload), + }; + } +} diff --git a/codegen/pkg/generator/templates/events/handler.tmpl b/codegen/pkg/generator/templates/events/handler.tmpl new file mode 100644 index 0000000..98d76ba --- /dev/null +++ b/codegen/pkg/generator/templates/events/handler.tmpl @@ -0,0 +1,27 @@ +register({{.Name}}::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof {{.Name}}) { + $callback($event); + } + }); + return $this; + } +{{end}} +} diff --git a/examples/events.php b/examples/events.php new file mode 100644 index 0000000..01903b1 --- /dev/null +++ b/examples/events.php @@ -0,0 +1,62 @@ + $maxBodyBytes) { + http_response_code(413); + exit; +} + +$sumup = new SumUp(); +$events = $sumup->eventsHandler($secret, static function (EventNotification $event): void { + error_log('Received ' . $event->type . ' (' . $event->id . ')'); +})->onReaderCreated(static function (ReaderCreatedEvent $event): void { + // Fetches the latest resource using SUMUP_API_KEY, not the event signing secret. + $reader = $event->fetchObject(); + error_log('Reader paired: ' . $reader?->id); + // Make any side effects idempotent using $event->id; deliveries may repeat. +}); + +try { + $events->handle($body, $_SERVER['HTTP_X_SUMUP_WEBHOOK_SIGNATURE'] ?? ''); + http_response_code(204); +} catch (EventSignatureException | EventPayloadException $exception) { + http_response_code(400); +} catch (EventCallbackException $exception) { + // Record failures in your application monitoring; avoid logging secrets or payloads. + error_log('Event processing failed.'); + http_response_code(500); +} diff --git a/src/Events/CheckoutCreatedEvent.php b/src/Events/CheckoutCreatedEvent.php new file mode 100644 index 0000000..e5520ea --- /dev/null +++ b/src/Events/CheckoutCreatedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Checkout::class, $options); + if ($object !== null && !$object instanceof Checkout) { + throw new EventObjectException('Expected a Checkout resource.'); + } + return $object; + } +} diff --git a/src/Events/CheckoutExpiredEvent.php b/src/Events/CheckoutExpiredEvent.php new file mode 100644 index 0000000..45c04f1 --- /dev/null +++ b/src/Events/CheckoutExpiredEvent.php @@ -0,0 +1,26 @@ +fetchResource(Checkout::class, $options); + if ($object !== null && !$object instanceof Checkout) { + throw new EventObjectException('Expected a Checkout resource.'); + } + return $object; + } +} diff --git a/src/Events/CheckoutFailedEvent.php b/src/Events/CheckoutFailedEvent.php new file mode 100644 index 0000000..f72e676 --- /dev/null +++ b/src/Events/CheckoutFailedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Checkout::class, $options); + if ($object !== null && !$object instanceof Checkout) { + throw new EventObjectException('Expected a Checkout resource.'); + } + return $object; + } +} diff --git a/src/Events/CheckoutPaidEvent.php b/src/Events/CheckoutPaidEvent.php new file mode 100644 index 0000000..692d359 --- /dev/null +++ b/src/Events/CheckoutPaidEvent.php @@ -0,0 +1,26 @@ +fetchResource(Checkout::class, $options); + if ($object !== null && !$object instanceof Checkout) { + throw new EventObjectException('Expected a Checkout resource.'); + } + return $object; + } +} diff --git a/src/Events/CheckoutUpdatedEvent.php b/src/Events/CheckoutUpdatedEvent.php new file mode 100644 index 0000000..145ba6f --- /dev/null +++ b/src/Events/CheckoutUpdatedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Checkout::class, $options); + if ($object !== null && !$object instanceof Checkout) { + throw new EventObjectException('Expected a Checkout resource.'); + } + return $object; + } +} diff --git a/src/Events/EventFactory.php b/src/Events/EventFactory.php new file mode 100644 index 0000000..84b08fe --- /dev/null +++ b/src/Events/EventFactory.php @@ -0,0 +1,33 @@ + $payload */ + public static function create(SumUp $client, array $payload): EventNotification + { + return match ($payload['type'] ?? null) { + CheckoutCreatedEvent::TYPE => new CheckoutCreatedEvent($client, $payload), + CheckoutExpiredEvent::TYPE => new CheckoutExpiredEvent($client, $payload), + CheckoutFailedEvent::TYPE => new CheckoutFailedEvent($client, $payload), + CheckoutPaidEvent::TYPE => new CheckoutPaidEvent($client, $payload), + CheckoutUpdatedEvent::TYPE => new CheckoutUpdatedEvent($client, $payload), + MemberCreatedEvent::TYPE => new MemberCreatedEvent($client, $payload), + MemberDeletedEvent::TYPE => new MemberDeletedEvent($client, $payload), + MemberUpdatedEvent::TYPE => new MemberUpdatedEvent($client, $payload), + ReaderCreatedEvent::TYPE => new ReaderCreatedEvent($client, $payload), + ReaderDeletedEvent::TYPE => new ReaderDeletedEvent($client, $payload), + RoleCreatedEvent::TYPE => new RoleCreatedEvent($client, $payload), + RoleDeletedEvent::TYPE => new RoleDeletedEvent($client, $payload), + RoleUpdatedEvent::TYPE => new RoleUpdatedEvent($client, $payload), + default => new UnknownEvent($client, $payload), + }; + } +} diff --git a/src/Events/EventNotification.php b/src/Events/EventNotification.php new file mode 100644 index 0000000..60c9a2a --- /dev/null +++ b/src/Events/EventNotification.php @@ -0,0 +1,113 @@ + $payload + */ + public function __construct(private SumUp $client, array $payload) + { + foreach (['id', 'type', 'created_at'] as $field) { + if (!isset($payload[$field]) || !is_string($payload[$field]) || $payload[$field] === '') { + throw new EventPayloadException('Missing or invalid event field: ' . $field . '.'); + } + } + $object = $payload['object'] ?? null; + if (!is_array($object)) { + throw new EventPayloadException('Expected an event object reference.'); + } + foreach (['id', 'type', 'url'] as $field) { + if (!isset($object[$field]) || !is_string($object[$field]) || $object[$field] === '') { + throw new EventPayloadException('Missing or invalid event object field: ' . $field . '.'); + } + } + try { + $this->createdAt = new DateTimeImmutable($payload['created_at']); + } catch (\Exception $cause) { + throw new EventPayloadException('Invalid event creation timestamp.', previous: $cause); + } + $this->id = $payload['id']; + $this->type = $payload['type']; + $this->object = new EventObjectReference($object['id'], $object['type'], $object['url']); + } + + /** @internal Prefer SumUp::parseEventNotificationWithoutVerification() for trusted stored events. */ + public static function parseWithoutVerification(SumUp $client, string $body): self + { + try { + $payload = json_decode($body, false, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $cause) { + throw new EventPayloadException('Invalid event JSON or UTF-8.', previous: $cause); + } + if (!$payload instanceof \stdClass) { + throw new EventPayloadException('Expected an event JSON object.'); + } + $data = get_object_vars($payload); + if (($data['object'] ?? null) instanceof \stdClass) { + $data['object'] = get_object_vars($data['object']); + } + return EventFactory::create($client, $data); + } + + /** + * Fetch the resource's current state. Deleted resources may return an API error. + * Unknown event types return the decoded response for the caller to validate. + */ + public function fetchObject(?RequestOptions $options = null): mixed + { + return $this->fetchResource(null, $options); + } + + /** + * @param class-string|null $className + */ + protected function fetchResource(?string $className, ?RequestOptions $options): mixed + { + $url = parse_url($this->object->url); + $base = parse_url($this->client->getBaseUri()); + if ($url === false || $base === false + || !isset($url['scheme'], $url['host'], $base['scheme'], $base['host']) + || !in_array(strtolower($url['scheme']), ['http', 'https'], true) + || strtolower($url['scheme']) !== strtolower($base['scheme']) + || strtolower($url['host']) !== strtolower($base['host']) + || ($url['port'] ?? (strtolower($url['scheme']) === 'https' ? 443 : 80)) + !== ($base['port'] ?? (strtolower($base['scheme']) === 'https' ? 443 : 80)) + || isset($url['user']) || isset($url['pass']) || isset($url['fragment']) + || preg_match('/[\x00-\x20\x7f\\\\]/', $this->object->url) + ) { + throw new EventObjectException('Event object URL must match the API client origin.'); + } + $path = $url['path'] ?? '/'; + if (str_starts_with($path, '//')) { + throw new EventObjectException('Event object path must not start with //.'); + } + if (isset($url['query'])) { + $path .= '?' . $url['query']; + } + // Keep redirects from bypassing the origin check. Preserve the caller's options. + $options = $options === null ? new RequestOptions() : clone $options; + $options->followRedirects = false; + $response = $this->client->request('GET', $path, [], $options); + return ResponseDecoder::decodeOrThrow($response, $className, null, 'GET', $path); + } +} diff --git a/src/Events/EventObjectReference.php b/src/Events/EventObjectReference.php new file mode 100644 index 0000000..340ea72 --- /dev/null +++ b/src/Events/EventObjectReference.php @@ -0,0 +1,16 @@ + 300) { + throw new EventSignatureExpiredException('Event timestamp outside the five-minute window.'); + } + } +} diff --git a/src/Events/EventsHandler.php b/src/Events/EventsHandler.php new file mode 100644 index 0000000..b84fc14 --- /dev/null +++ b/src/Events/EventsHandler.php @@ -0,0 +1,207 @@ +register(CheckoutCreatedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof CheckoutCreatedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for checkouts.expired. + * + * @param callable(CheckoutExpiredEvent): void $callback + */ + public function onCheckoutExpired(callable $callback): self + { + $this->register(CheckoutExpiredEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof CheckoutExpiredEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for checkouts.failed. + * + * @param callable(CheckoutFailedEvent): void $callback + */ + public function onCheckoutFailed(callable $callback): self + { + $this->register(CheckoutFailedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof CheckoutFailedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for checkouts.paid. + * + * @param callable(CheckoutPaidEvent): void $callback + */ + public function onCheckoutPaid(callable $callback): self + { + $this->register(CheckoutPaidEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof CheckoutPaidEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for checkouts.updated. + * + * @param callable(CheckoutUpdatedEvent): void $callback + */ + public function onCheckoutUpdated(callable $callback): self + { + $this->register(CheckoutUpdatedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof CheckoutUpdatedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for members.created. + * + * @param callable(MemberCreatedEvent): void $callback + */ + public function onMemberCreated(callable $callback): self + { + $this->register(MemberCreatedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof MemberCreatedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for members.deleted. + * + * @param callable(MemberDeletedEvent): void $callback + */ + public function onMemberDeleted(callable $callback): self + { + $this->register(MemberDeletedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof MemberDeletedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for members.updated. + * + * @param callable(MemberUpdatedEvent): void $callback + */ + public function onMemberUpdated(callable $callback): self + { + $this->register(MemberUpdatedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof MemberUpdatedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for readers.created. + * + * @param callable(ReaderCreatedEvent): void $callback + */ + public function onReaderCreated(callable $callback): self + { + $this->register(ReaderCreatedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof ReaderCreatedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for readers.deleted. + * + * @param callable(ReaderDeletedEvent): void $callback + */ + public function onReaderDeleted(callable $callback): self + { + $this->register(ReaderDeletedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof ReaderDeletedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for roles.created. + * + * @param callable(RoleCreatedEvent): void $callback + */ + public function onRoleCreated(callable $callback): self + { + $this->register(RoleCreatedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof RoleCreatedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for roles.deleted. + * + * @param callable(RoleDeletedEvent): void $callback + */ + public function onRoleDeleted(callable $callback): self + { + $this->register(RoleDeletedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof RoleDeletedEvent) { + $callback($event); + } + }); + return $this; + } + + /** + * Register a callback for roles.updated. + * + * @param callable(RoleUpdatedEvent): void $callback + */ + public function onRoleUpdated(callable $callback): self + { + $this->register(RoleUpdatedEvent::TYPE, static function (EventNotification $event) use ($callback): void { + if ($event instanceof RoleUpdatedEvent) { + $callback($event); + } + }); + return $this; + } + +} diff --git a/src/Events/EventsHandlerBase.php b/src/Events/EventsHandlerBase.php new file mode 100644 index 0000000..f76f6bb --- /dev/null +++ b/src/Events/EventsHandlerBase.php @@ -0,0 +1,60 @@ + */ + private array $callbacks = []; + + /** @param callable(EventNotification): void $fallback Unknown and unregistered event types. */ + public function __construct( + private SumUp $client, + #[\SensitiveParameter] private string $secret, + callable $fallback + ) { + if ($secret === '') { + throw new EventSignatureException('Event signing secret is required.'); + } + $this->fallback = \Closure::fromCallable($fallback); + } + + /** Verify and parse without invoking callbacks. */ + public function parse(string $body, string $signature): EventNotification + { + return $this->client->parseEventNotification($body, $signature, $this->secret); + } + + /** + * Pass the unchanged body and complete X-SumUp-Webhook-Signature header value. + * Callbacks only run after verification. Processing failures preserve their cause. + * Make side effects idempotent using the event ID; deliveries may repeat. + * + * @throws EventCallbackException + */ + public function handle(string $body, string $signature): void + { + $event = $this->parse($body, $signature); + $callback = $this->callbacks[$event->type] ?? $this->fallback; + try { + $callback($event); + } catch (\Throwable $cause) { + throw new EventCallbackException('Event callback failed.', previous: $cause); + } + } + + /** @param callable(EventNotification): void $callback */ + protected function register(string $type, callable $callback): void + { + $this->callbacks[$type] = $callback; + } +} diff --git a/src/Events/MemberCreatedEvent.php b/src/Events/MemberCreatedEvent.php new file mode 100644 index 0000000..0c1ec41 --- /dev/null +++ b/src/Events/MemberCreatedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Member::class, $options); + if ($object !== null && !$object instanceof Member) { + throw new EventObjectException('Expected a Member resource.'); + } + return $object; + } +} diff --git a/src/Events/MemberDeletedEvent.php b/src/Events/MemberDeletedEvent.php new file mode 100644 index 0000000..be42701 --- /dev/null +++ b/src/Events/MemberDeletedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Member::class, $options); + if ($object !== null && !$object instanceof Member) { + throw new EventObjectException('Expected a Member resource.'); + } + return $object; + } +} diff --git a/src/Events/MemberUpdatedEvent.php b/src/Events/MemberUpdatedEvent.php new file mode 100644 index 0000000..52f6764 --- /dev/null +++ b/src/Events/MemberUpdatedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Member::class, $options); + if ($object !== null && !$object instanceof Member) { + throw new EventObjectException('Expected a Member resource.'); + } + return $object; + } +} diff --git a/src/Events/ReaderCreatedEvent.php b/src/Events/ReaderCreatedEvent.php new file mode 100644 index 0000000..230effb --- /dev/null +++ b/src/Events/ReaderCreatedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Reader::class, $options); + if ($object !== null && !$object instanceof Reader) { + throw new EventObjectException('Expected a Reader resource.'); + } + return $object; + } +} diff --git a/src/Events/ReaderDeletedEvent.php b/src/Events/ReaderDeletedEvent.php new file mode 100644 index 0000000..6290670 --- /dev/null +++ b/src/Events/ReaderDeletedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Reader::class, $options); + if ($object !== null && !$object instanceof Reader) { + throw new EventObjectException('Expected a Reader resource.'); + } + return $object; + } +} diff --git a/src/Events/RoleCreatedEvent.php b/src/Events/RoleCreatedEvent.php new file mode 100644 index 0000000..25a00fb --- /dev/null +++ b/src/Events/RoleCreatedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Role::class, $options); + if ($object !== null && !$object instanceof Role) { + throw new EventObjectException('Expected a Role resource.'); + } + return $object; + } +} diff --git a/src/Events/RoleDeletedEvent.php b/src/Events/RoleDeletedEvent.php new file mode 100644 index 0000000..fcd0edc --- /dev/null +++ b/src/Events/RoleDeletedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Role::class, $options); + if ($object !== null && !$object instanceof Role) { + throw new EventObjectException('Expected a Role resource.'); + } + return $object; + } +} diff --git a/src/Events/RoleUpdatedEvent.php b/src/Events/RoleUpdatedEvent.php new file mode 100644 index 0000000..d5a4a18 --- /dev/null +++ b/src/Events/RoleUpdatedEvent.php @@ -0,0 +1,26 @@ +fetchResource(Role::class, $options); + if ($object !== null && !$object instanceof Role) { + throw new EventObjectException('Expected a Role resource.'); + } + return $object; + } +} diff --git a/src/Events/UnknownEvent.php b/src/Events/UnknownEvent.php new file mode 100644 index 0000000..8f9b6e9 --- /dev/null +++ b/src/Events/UnknownEvent.php @@ -0,0 +1,10 @@ +followRedirects !== null) { + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $options->followRedirects); + } curl_setopt($ch, CURLOPT_HTTPHEADER, $this->formatHeaders($reqHeaders)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, true); diff --git a/src/HttpClient/GuzzleClient.php b/src/HttpClient/GuzzleClient.php index 125b3ed..533982a 100644 --- a/src/HttpClient/GuzzleClient.php +++ b/src/HttpClient/GuzzleClient.php @@ -95,6 +95,9 @@ function ($retry) use ($backoffMs) { ]); $requestParams = ['headers' => $reqHeaders]; + if ($options?->followRedirects !== null) { + $requestParams['allow_redirects'] = $options->followRedirects; + } if (!empty($body)) { $requestParams['json'] = $body; diff --git a/src/HttpClient/HttpClientInterface.php b/src/HttpClient/HttpClientInterface.php index d766ea7..4022e29 100644 --- a/src/HttpClient/HttpClientInterface.php +++ b/src/HttpClient/HttpClientInterface.php @@ -14,7 +14,8 @@ interface HttpClientInterface * @param string $url The endpoint to send the request to. * @param array $body The body of the request. * @param array $headers The headers of the request. - * @param RequestOptions|null $options Optional typed request options. + * @param RequestOptions|null $options Optional typed request options. Implementations must + * honor followRedirects=false to keep event resource fetches on the configured origin. * * @return Response */ diff --git a/src/HttpClient/RequestOptions.php b/src/HttpClient/RequestOptions.php index 91e7025..1bf1be5 100644 --- a/src/HttpClient/RequestOptions.php +++ b/src/HttpClient/RequestOptions.php @@ -42,6 +42,9 @@ class RequestOptions */ public ?int $retryBackoffMs = null; + /** Whether to follow redirects. Null keeps the HTTP client's default behavior. */ + public ?bool $followRedirects = null; + /** * @param array $headers */ @@ -50,12 +53,14 @@ public function __construct( ?int $connectTimeout = null, ?int $retries = null, ?int $retryBackoffMs = null, - array $headers = [] + array $headers = [], + ?bool $followRedirects = null ) { $this->timeout = $timeout; $this->connectTimeout = $connectTimeout; $this->retries = $retries; $this->retryBackoffMs = $retryBackoffMs; $this->headers = $headers; + $this->followRedirects = $followRedirects; } } diff --git a/src/SumUp.php b/src/SumUp.php index 059a779..d225d22 100644 --- a/src/SumUp.php +++ b/src/SumUp.php @@ -4,6 +4,8 @@ use SumUp\Exception\ConfigurationException; use SumUp\Exception\SDKException; +use SumUp\Events\EventNotification; +use SumUp\Events\EventsHandler; use SumUp\HttpClient\CurlClient; use SumUp\HttpClient\HttpClientInterface; use SumUp\HttpClient\RequestHeaders; @@ -40,6 +42,8 @@ class SumUp */ protected HttpClientInterface $client; + private string $baseUri; + /** * SumUp constructor. * @@ -61,6 +65,7 @@ public function __construct(string|array|null $configOrApiKey = null) } $config = $this->normalizeConfig($config); + $this->baseUri = $config['base_uri']; if ($customHttpClient instanceof HttpClientInterface) { $this->client = $customHttpClient; } else { @@ -89,6 +94,38 @@ public function getDefaultAccessToken(): ?string return $this->accessToken; } + /** The configured API base URI, also used to check event resource URLs. */ + public function getBaseUri(): string + { + return $this->baseUri; + } + + /** + * Create a signed event handler. Unknown and unregistered types reach the fallback. + * + * @param callable(EventNotification): void $fallback + */ + public function eventsHandler(#[\SensitiveParameter] string $secret, callable $fallback): EventsHandler + { + return new EventsHandler($this, $secret, $fallback); + } + + /** Verify and parse the unchanged body and complete signature header value. */ + public function parseEventNotification( + string $body, + string $signature, + #[\SensitiveParameter] string $secret + ): EventNotification { + \SumUp\Events\EventSignature::verify($secret, $body, $signature); + return $this->parseEventNotificationWithoutVerification($body); + } + + /** Parse a previously verified event from trusted storage. Never use on incoming requests. */ + public function parseEventNotificationWithoutVerification(string $body): EventNotification + { + return EventNotification::parseWithoutVerification($this, $body); + } + /** * Sets the default access token. * diff --git a/tests/Doubles/FakeHttpClient.php b/tests/Doubles/FakeHttpClient.php index 42cab9a..fb8e58a 100644 --- a/tests/Doubles/FakeHttpClient.php +++ b/tests/Doubles/FakeHttpClient.php @@ -48,6 +48,7 @@ public function send(string $method, string $url, array $body, array $headers, ? 'url' => $url, 'body' => $body, 'headers' => $headers, + 'options' => $options, ]; return $this->response; diff --git a/tests/EventsTest.php b/tests/EventsTest.php new file mode 100644 index 0000000..a956745 --- /dev/null +++ b/tests/EventsTest.php @@ -0,0 +1,278 @@ + 'api-key', + 'base_uri' => 'https://api.sumup.test', + 'client' => $http ?? new FakeHttpClient(new Response(200, ['id' => 'rdr_123'])), + ]); + } + + private function body(string $type = 'readers.created', string $url = self::URL): string + { + return json_encode([ + 'id' => 'evt_123', + 'type' => $type, + 'created_at' => '2026-04-11T10:00:00Z', + 'object' => ['id' => 'rdr_123', 'type' => 'reader', 'url' => $url], + ], JSON_THROW_ON_ERROR); + } + + private function signature(string $body, ?string $timestamp = null, string $secret = self::SECRET): string + { + $timestamp = $timestamp ?? (string) time(); + return 't=' . $timestamp . ',v1=' . hash_hmac('sha256', 'v1:' . $timestamp . ':' . $body, $secret); + } + + public static function eventTypes(): array + { + return [ + ['readers.created', ReaderCreatedEvent::class, Reader::class], + ['checkouts.paid', CheckoutPaidEvent::class, Checkout::class], + ['members.updated', MemberUpdatedEvent::class, Member::class], + ['roles.deleted', RoleDeletedEvent::class, Role::class], + ]; + } + + /** @dataProvider eventTypes */ + public function testParsesTypedNotificationsAndFetchesResources(string $type, string $eventClass, string $resourceClass): void + { + $http = new FakeHttpClient(new Response(200, ['id' => 'resource_123'])); + $client = $this->client($http); + $body = $this->body($type); + $event = $client->parseEventNotification($body, $this->signature($body), self::SECRET); + + $this->assertInstanceOf($eventClass, $event); + $this->assertSame('evt_123', $event->id); + $this->assertSame('2026-04-11T10:00:00+00:00', $event->createdAt->format(DATE_ATOM)); + $this->assertSame(self::URL, $event->object->url); + $this->assertSame([], $http->getRequests()); + $this->assertInstanceOf($resourceClass, $event->fetchObject()); + $this->assertSame('Bearer api-key', $http->getRequests()[0]['headers']['Authorization']); + } + + public function testDispatchesTypedCallbacksAndFallbacks(): void + { + $seen = []; + $handler = $this->client()->eventsHandler(self::SECRET, function (EventNotification $event) use (&$seen): void { + $seen[] = $event; + })->onReaderCreated(function (ReaderCreatedEvent $event) use (&$seen): void { + $seen[] = $event; + }); + + foreach (['readers.created', 'roles.deleted', 'future.created'] as $type) { + $body = $this->body($type); + $handler->handle($body, $this->signature($body)); + } + $this->assertCount(3, $seen); + $this->assertInstanceOf(ReaderCreatedEvent::class, $seen[0]); + $this->assertInstanceOf(RoleDeletedEvent::class, $seen[1]); + $this->assertInstanceOf(UnknownEvent::class, $seen[2]); + $this->assertSame(['id' => 'rdr_123'], $seen[2]->fetchObject()); + } + + public function testHandlerParseDoesNotRunCallbacksAndRegistrationReplacesPrevious(): void + { + $handler = $this->client()->eventsHandler(self::SECRET, function (): void { + $this->fail('Fallback should not run.'); + })->onReaderCreated(function (): void { + $this->fail('Replaced callback should not run.'); + }); + $seen = []; + $this->assertSame($handler, $handler->onReaderCreated(function (ReaderCreatedEvent $event) use (&$seen): void { + $seen[] = $event; + })); + $body = $this->body(); + $this->assertInstanceOf(ReaderCreatedEvent::class, $handler->parse($body, $this->signature($body))); + $this->assertSame([], $seen); + $handler->handle($body, $this->signature($body)); + $this->assertCount(1, $seen); + } + + public function testPreservesCallbackFailureForRetryHandling(): void + { + $cause = new \RuntimeException('Processing failed.'); + $handler = $this->client()->eventsHandler(self::SECRET, static function () use ($cause): void { + throw $cause; + }); + $body = $this->body(); + try { + $handler->handle($body, $this->signature($body)); + $this->fail('Expected a callback exception.'); + } catch (EventCallbackException $exception) { + $this->assertSame($cause, $exception->getPrevious()); + } + } + + public function testVerifiesExactRawBytesAndTimestampSpelling(): void + { + $body = "{\n \"message\": \"café\"\n}"; + $signature = $this->signature($body, '0' . time()); + EventSignature::verify(self::SECRET, $body, $signature); + $this->expectException(EventSignatureException::class); + EventSignature::verify(self::SECRET, $body . "\n", $signature); + } + + public function testMatchesIndependentSignatureVector(): void + { + // Computed with OpenSSL HMAC-SHA256 over v1:1234567890:{"id":"evt_123"}. + // Reaching the expiry check proves that the signature itself matched. + $this->expectException(EventSignatureExpiredException::class); + EventSignature::verify( + self::SECRET, + '{"id":"evt_123"}', + 't=1234567890,v1=94a0f7740d808830926eb68b0b2710d8fe7acf8e51475616417a05e55bb2ba1f' + ); + } + + public static function invalidSignatures(): array + { + return [ + [''], ['t=123'], ['v1=' . str_repeat('a', 64)], + ['t=1.5,v1=' . str_repeat('a', 64)], + ['t=-1,v1=' . str_repeat('a', 64)], + ['t=9999999999999999999999,v1=' . str_repeat('a', 64)], + ['t=123,v1=xyz'], ['t=123,v1=' . str_repeat('a', 64) . ',extra=1'], + ]; + } + + /** @dataProvider invalidSignatures */ + public function testRejectsMalformedSignatureHeaders(string $signature): void + { + $this->expectException(EventSignatureException::class); + EventSignature::verify(self::SECRET, $this->body(), $signature); + } + + public function testRejectsWrongSecretBeforeInvokingCallbacks(): void + { + $handler = $this->client()->eventsHandler(self::SECRET, function (): void { + $this->fail('Callback must not run before verification.'); + }); + $body = $this->body(); + $this->expectException(EventSignatureException::class); + $handler->handle($body, $this->signature($body, secret: 'wrong-secret')); + } + + public function testRequiresSigningSecret(): void + { + $this->expectException(EventSignatureException::class); + $this->client()->eventsHandler('', static function (): void { + }); + } + + public static function expiredTimestamps(): array + { + return [[-600], [600]]; + } + + /** @dataProvider expiredTimestamps */ + public function testRejectsOldAndFutureDeliveries(int $offset): void + { + $body = $this->body(); + $this->expectException(EventSignatureExpiredException::class); + EventSignature::verify(self::SECRET, $body, $this->signature($body, (string) (time() + $offset))); + } + + public static function invalidPayloads(): array + { + return [['{'], ['[]'], ['null'], ['42'], ['{}'], ["{\"id\":\"\xff\"}"]]; + } + + /** @dataProvider invalidPayloads */ + public function testRejectsInvalidPayloadsWithoutCallingCallbacks(string $body): void + { + $handler = $this->client()->eventsHandler(self::SECRET, function (): void { + $this->fail('Callback must not run for invalid JSON.'); + }); + $this->expectException(EventPayloadException::class); + $handler->handle($body, $this->signature($body)); + } + + public function testParsesTrustedStoredEventAndRetainsRequestConfiguration(): void + { + $http = new FakeHttpClient(new Response(200, ['id' => 'rdr_123'])); + $client = $this->client($http); + $event = $client->parseEventNotificationWithoutVerification($this->body(url: self::URL . '?expand=device')); + $client->setDefaultAccessToken('updated-key'); + $options = new RequestOptions(timeout: 15, retries: 2, headers: ['X-Integrator' => 'test'], followRedirects: true); + $event->fetchObject($options); + $request = $http->getRequests()[0]; + $this->assertSame('GET', $request['method']); + $this->assertSame('/v0.1/readers/rdr_123?expand=device', $request['url']); + $this->assertSame('Bearer updated-key', $request['headers']['Authorization']); + $this->assertSame('test', $request['headers']['X-Integrator']); + $this->assertSame(15, $request['options']->timeout); + $this->assertSame(2, $request['options']->retries); + $this->assertFalse($request['options']->followRedirects); + $this->assertTrue($options->followRedirects); + } + + public static function unsafeUrls(): array + { + return [ + ['https://attacker.test/resource'], ['http://api.sumup.test/resource'], + ['https://api.sumup.test:444/resource'], ['/resource'], + ['https://api.sumup.test//attacker.test/resource'], + ['https://user:password@api.sumup.test/resource'], + ['https://api.sumup.test/resource#fragment'], + ['https://api.sumup.test/\\attacker.test/resource'], + ["https://api.sumup.test/resource\n"], + ]; + } + + /** @dataProvider unsafeUrls */ + public function testRejectsUnsafeObjectUrlsWithoutSendingCredentials(string $url): void + { + $http = new FakeHttpClient(new Response(200, []), true); + $event = $this->client($http)->parseEventNotificationWithoutVerification($this->body(url: $url)); + $this->expectException(EventObjectException::class); + $event->fetchObject(); + } + + public function testAcceptsEquivalentOriginsAndDefaultPorts(): void + { + $event = $this->client()->parseEventNotificationWithoutVerification( + $this->body(url: 'https://API.SUMUP.TEST:443/v0.1/readers/rdr_123') + ); + $this->assertInstanceOf(Reader::class, $event->fetchObject()); + } + + public function testPropagatesApiErrorsForDeletedResources(): void + { + $http = new FakeHttpClient(new Response(404, ['message' => 'Deleted'])); + $event = $this->client($http)->parseEventNotificationWithoutVerification($this->body('readers.deleted')); + $this->expectException(UnexpectedApiException::class); + $event->fetchObject(); + } +}