diff --git a/README.md b/README.md index fd51e15..2112462 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,45 @@ reader_checkout = client.readers.create_checkout( print(f"Reader checkout created: {reader_checkout}") ``` +### Handling Events + +Create a handler with your event signing secret and register typed callbacks: + +```python +import os + +from sumup import Sumup +from sumup.events import EventNotification, ReaderCreatedEvent + +client = Sumup(api_key=os.environ["SUMUP_API_KEY"]) + + +def fallback(event: EventNotification) -> None: + print(f"Received {event.type}") + + +events = client.events_handler(os.environ["SUMUP_EVENT_SECRET"], fallback) + + +@events.on_reader_created +def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(f"Reader paired: {reader.id}") + + +# In your HTTP route, pass the unchanged body and the complete +# X-SumUp-Webhook-Signature header value: +events.handle(raw_body, signature) +``` + +You can also register an existing function with `events.on_reader_created(callback)`. + +Send a 2xx response after handling succeeds. Reject `EventSignatureError` and `EventPayloadError` with 400; return 500 for `EventCallbackError` so processing can be retried. Make callbacks idempotent and configure body limits in your server. + +Use `AsyncSumup` with async callbacks, `await events.handle(...)`, and `await event.fetch_object_async()` for async servers. For parsing without callbacks, use `client.parse_event_notification(body, signature, secret)`. + +See the runnable [Flask](examples/events-flask/) and [FastAPI](examples/events-fastapi/) examples for complete HTTP routes and error handling. + ## Version Support Policy `sumup-py` maintains compatibility with Python versions that have not passed end-of-life. As of June 8, 2026, that means Python 3.10 through 3.14. See [Status of Python versions](https://devguide.python.org/versions/). diff --git a/codegen/pkg/builder/builder.go b/codegen/pkg/builder/builder.go index 8776a53..bbcc1ec 100644 --- a/codegen/pkg/builder/builder.go +++ b/codegen/pkg/builder/builder.go @@ -34,6 +34,8 @@ type Builder struct { pathsByTag map[string]*v3.Paths + events []EventDefinition + templates *template.Template start time.Time @@ -82,6 +84,9 @@ func (b *Builder) Load(spec *v3.Document) error { b.collectPaths() b.collectSchemas() + if err := b.collectEvents(); err != nil { + return err + } return nil } @@ -101,6 +106,10 @@ func (b *Builder) Build() error { return err } + if err := b.writeEventsFile(path.Join(b.cfg.Out, "events.py")); err != nil { + return err + } + for tagName, paths := range b.pathsByTag { if err := b.generateResource(tagName, paths); err != nil { return err diff --git a/codegen/pkg/builder/events.go b/codegen/pkg/builder/events.go new file mode 100644 index 0000000..5f38337 --- /dev/null +++ b/codegen/pkg/builder/events.go @@ -0,0 +1,114 @@ +package builder + +import ( + "bytes" + "fmt" + "slices" + "strings" + + "github.com/iancoleman/strcase" + + "github.com/sumup/sumup-py/codegen/pkg/extension" +) + +type eventObjectExtension struct { + Reference string `yaml:"$ref"` +} + +func (b *Builder) collectEvents() error { + if b.spec == nil || b.spec.Webhooks == nil { + return nil + } + + events := make([]EventDefinition, 0, b.spec.Webhooks.Len()) + for eventType, pathItem := range b.spec.Webhooks.FromOldest() { + if pathItem == nil || pathItem.Post == nil { + continue + } + + operation := pathItem.Post + name := strings.TrimSuffix(operation.OperationId, "Webhook") + if name == "" { + return fmt.Errorf("webhook %q is missing an operationId", eventType) + } + + if operation.Extensions == nil { + return fmt.Errorf("webhook %q is missing x-object", eventType) + } + object, ok := extension.Get[eventObjectExtension](operation.Extensions, "x-object") + if !ok || object.Reference == "" { + return fmt.Errorf("webhook %q is missing x-object", eventType) + } + + const schemaPrefix = "#/components/schemas/" + objectSchema, ok := strings.CutPrefix(object.Reference, schemaPrefix) + if !ok || objectSchema == "" { + return fmt.Errorf( + "webhook %q has unsupported x-object reference %q", + eventType, + object.Reference, + ) + } + + objectKind, ok := extension.Get[string](operation.Extensions, "x-object-type") + if !ok || strings.TrimSpace(objectKind) == "" { + return fmt.Errorf("event %q is missing x-object-type", eventType) + } + + description := strings.TrimSpace(operation.Description) + if description == "" { + description = strings.TrimSpace(operation.Summary) + } + + events = append(events, EventDefinition{ + RegistrationMethod: "on_" + strcase.ToSnake(name), + ObjectKind: objectKind, + ClassName: strcase.ToCamel(name) + "Event", + EventType: eventType, + ObjectType: strcase.ToCamel(objectSchema), + Description: description, + }) + } + + slices.SortFunc(events, func(a, b EventDefinition) int { + return strings.Compare(a.ClassName, b.ClassName) + }) + b.events = events + return nil +} + +type eventsTemplateData struct { + Events []EventDefinition + ObjectTypes []string +} + +func (b *Builder) writeEventsFile(filename string) error { + objectTypes := make([]string, 0, len(b.events)) + for _, event := range b.events { + if !slices.Contains(objectTypes, event.ObjectType) { + objectTypes = append(objectTypes, event.ObjectType) + } + } + slices.Sort(objectTypes) + + buf := bytes.NewBuffer(nil) + if err := b.templates.ExecuteTemplate(buf, "events.py.tmpl", eventsTemplateData{ + Events: b.events, + ObjectTypes: objectTypes, + }); err != nil { + return fmt.Errorf("generate events: %w", err) + } + + file, err := openGeneratedFile(filename) + if err != nil { + return err + } + defer func() { + _ = file.Close() + }() + + if _, err := file.Write(buf.Bytes()); err != nil { + return fmt.Errorf("write events: %w", err) + } + return nil +} diff --git a/codegen/pkg/builder/events_test.go b/codegen/pkg/builder/events_test.go new file mode 100644 index 0000000..2f16e31 --- /dev/null +++ b/codegen/pkg/builder/events_test.go @@ -0,0 +1,76 @@ +package builder + +import ( + "reflect" + "testing" + + "github.com/pb33f/libopenapi" +) + +func TestCollectEvents(t *testing.T) { + document, err := libopenapi.NewDocument([]byte(`{ + "openapi": "3.1.0", + "info": {"title": "Events", "version": "1.0.0"}, + "paths": {}, + "components": { + "schemas": { + "Member": {"type": "object"}, + "Reader": {"type": "object"} + } + }, + "webhooks": { + "readers.created": { + "post": { + "operationId": "ReaderCreatedWebhook", + "description": "Sent when a reader is paired.", + "responses": {"2XX": {"description": "Acknowledged"}}, + "x-object": {"$ref": "#/components/schemas/Reader"}, + "x-object-type": "reader" + } + }, + "members.updated": { + "post": { + "operationId": "MemberUpdatedWebhook", + "description": "Sent when a member changes.", + "responses": {"2XX": {"description": "Acknowledged"}}, + "x-object": {"$ref": "#/components/schemas/Member"}, + "x-object-type": "member" + } + } + } +}`)) + if err != nil { + t.Fatalf("load document: %v", err) + } + model, err := document.BuildV3Model() + if err != nil { + t.Fatalf("build model: %v", err) + } + + builder := New(Config{}) + if err := builder.Load(&model.Model); err != nil { + t.Fatalf("load builder: %v", err) + } + + want := []EventDefinition{ + { + RegistrationMethod: "on_member_updated", + ClassName: "MemberUpdatedEvent", + ObjectKind: "member", + EventType: "members.updated", + ObjectType: "Member", + Description: "Sent when a member changes.", + }, + { + RegistrationMethod: "on_reader_created", + ClassName: "ReaderCreatedEvent", + ObjectKind: "reader", + EventType: "readers.created", + ObjectType: "Reader", + Description: "Sent when a reader is paired.", + }, + } + if !reflect.DeepEqual(builder.events, want) { + t.Fatalf("events mismatch:\n got: %#v\nwant: %#v", builder.events, want) + } +} diff --git a/codegen/pkg/builder/intermediate_representation.go b/codegen/pkg/builder/intermediate_representation.go index a4ab3ec..55c34cb 100644 --- a/codegen/pkg/builder/intermediate_representation.go +++ b/codegen/pkg/builder/intermediate_representation.go @@ -34,6 +34,17 @@ type OneOfDeclaration struct { GenerateInput bool } +// EventDefinition describes a typed event notification generated from an +// OpenAPI webhook operation. +type EventDefinition struct { + RegistrationMethod string + ObjectKind string + ClassName string + EventType string + ObjectType string + Description string +} + // Property holds the information for Property of a type. type Property struct { // Name of the field diff --git a/codegen/templates/client.py.tmpl b/codegen/templates/client.py.tmpl index 830d3b7..550ae55 100644 --- a/codegen/templates/client.py.tmpl +++ b/codegen/templates/client.py.tmpl @@ -1,8 +1,11 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. import os +import typing import httpx from ._service import Resource, AsyncResource, runtime_headers +if typing.TYPE_CHECKING: + from .events import AsyncEventCallback, AsyncEventsHandler, EventBody, EventCallback, EventNotification, EventsHandler {{- range .Resources }} from .{{ .Package }} import {{ .Name }}Resource, Async{{ .Name }}Resource {{- end }} @@ -39,6 +42,85 @@ class Sumup(Resource): }, )) + def events_handler( + self, + secret: str, + fallback: "EventCallback", + ) -> "EventsHandler": + """Create a synchronous event handler bound to this API client. + + Register typed callbacks with decorators such as @handler.on_reader_created, + then call handler.handle(body, signature) in your HTTP route. Callbacks receive + one event and can use event.fetch_object() to retrieve its current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required synchronous callback for unknown event types and known + types without a registered callback. Return None on success or raise. + + Returns: + An EventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import EventsHandler + + return EventsHandler( + secret=secret, + fallback=fallback, + client=self._client, + ) + + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification(self, body: "EventBody") -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + {{- range .Resources }} @property def {{ .Package }}(self) -> {{ .Name }}Resource: @@ -68,6 +150,85 @@ class AsyncSumup(AsyncResource): }, )) + def events_handler( + self, + secret: str, + fallback: "AsyncEventCallback", + ) -> "AsyncEventsHandler": + """Create an asynchronous event handler bound to this API client. + + Register async callbacks with decorators such as @handler.on_reader_created, + then await handler.handle(body, signature) in your HTTP route. Callbacks can + await event.fetch_object_async() to retrieve the current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required async callback for unknown event types and known types + without a registered callback. It is awaited before handling completes. + + Returns: + An AsyncEventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import AsyncEventsHandler + + return AsyncEventsHandler( + secret=secret, + fallback=fallback, + client=self._client, + ) + + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification(self, body: "EventBody") -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + {{- range .Resources }} @property def {{ .Package }}(self) -> Async{{ .Name }}Resource: diff --git a/codegen/templates/events.py.tmpl b/codegen/templates/events.py.tmpl new file mode 100644 index 0000000..f67524e --- /dev/null +++ b/codegen/templates/events.py.tmpl @@ -0,0 +1,173 @@ +# Code generated by `py-sdk-gen`. DO NOT EDIT. +from __future__ import annotations + +import typing + +import pydantic + +from ._events import ( + AsyncEventCallback, + _AsyncEventsHandler, + _ErasedCallback, + _SyncEventsHandler, + EventBody, + EventCallback, + EventCallbackError, + EventPayloadError, + EventError, + EventHandlerRegistrationError, + EventNotification, + EventObject, + EventObjectUrlError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + FetchableEvent, + SIGNATURE_HEADER, + UnknownEvent, + verify_event_signature, +) +{{- if .ObjectTypes }} +from .types import ( +{{- range .ObjectTypes }} + {{ . }}, +{{- end }} +) +{{- end }} + + +{{ range .Events -}} +class {{ .ClassName }}(FetchableEvent[{{ .ObjectType }}]): + """{{ .Description }} + + The notification type is {{ printf "%q" .EventType }}. Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + {{ .ObjectType }}. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = {{ printf "%q" .EventType }} + type: typing.Literal[{{ printf "%q" .EventType }}] = {{ printf "%q" .EventType }} + _object_type: typing.ClassVar[str] = {{ printf "%q" .ObjectKind }} + _response_model: typing.ClassVar[typing.Type[pydantic.BaseModel]] = {{ .ObjectType }} + + +{{ end -}} +{{ if .Events -}} +KnownEventNotification = ({{ range $index, $event := .Events }}{{ if $index }} | {{ end }}{{ $event.ClassName }}{{ end }}) +{{ else -}} +KnownEventNotification = EventNotification +{{ end }} + +_EVENT_MODELS: typing.Dict[str, typing.Type[EventNotification]] = { +{{- range .Events }} + {{ printf "%q" .EventType }}: {{ .ClassName }}, +{{- end }} +} + + +{{ range .Events -}} +_{{ .ClassName }}Callback = typing.TypeVar( + "_{{ .ClassName }}Callback", bound=typing.Callable[[{{ .ClassName }}], None] +) +_Async{{ .ClassName }}Callback = typing.TypeVar( + "_Async{{ .ClassName }}Callback", bound=typing.Callable[[{{ .ClassName }}], typing.Awaitable[None]] +) +{{ end }} + +class EventsHandler(_SyncEventsHandler): + """Verify incoming events and dispatch them to typed synchronous callbacks. + + Create with client.events_handler(secret, fallback). Register callbacks once + at startup using a decorator or a direct method call:: + + @handler.on_reader_created + def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(reader.id) + + Registration returns the original function, preserving its type and identity. + Unknown event types and known types without a callback reach the fallback. + Callbacks receive one event and must return None or raise an exception. + + Call handle(body, signature) with the raw request body and complete signature + header. Send a successful HTTP response after handling completes. Deliveries + may be repeated; make side effects idempotent and synchronize shared state if + serving concurrent requests. The caller owns request-body limits. + """ +{{ range .Events }} + def {{ .RegistrationMethod }}(self, callback: _{{ .ClassName }}Callback) -> _{{ .ClassName }}Callback: + """Register a callback for {{ .EventType }} and return it unchanged. + + Use @handler.{{ .RegistrationMethod }} or handler.{{ .RegistrationMethod }}(callback). + The callback receives one {{ .ClassName }} and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register({{ printf "%q" .EventType }}, typing.cast(_ErasedCallback, callback)) + return callback +{{ end }} + +class AsyncEventsHandler(_AsyncEventsHandler): + """Verify incoming events and dispatch them to typed asynchronous callbacks. + + Create with client.events_handler(secret, fallback) on an AsyncSumup client. + Register async callbacks at startup using decorators or direct method calls:: + + @handler.on_reader_created + async def reader_created(event: ReaderCreatedEvent) -> None: + reader = await event.fetch_object_async() + print(reader.id) + + Registration returns the original function. Unknown and unregistered types + reach the required async fallback. Await handle(body, signature) before + acknowledging delivery; the handler awaits the selected callback. Parsing + alone via parse() is synchronous and performs no network requests. + + Make side effects idempotent for repeat deliveries. Concurrent requests may + run callbacks concurrently; synchronize shared state and configure body limits + in your HTTP server. + """ +{{ range .Events }} + def {{ .RegistrationMethod }}(self, callback: _Async{{ .ClassName }}Callback) -> _Async{{ .ClassName }}Callback: + """Register an async callback for {{ .EventType }} and return it unchanged. + + Use @handler.{{ .RegistrationMethod }} or handler.{{ .RegistrationMethod }}(callback). + The callback receives one {{ .ClassName }} and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register({{ printf "%q" .EventType }}, typing.cast(_ErasedCallback, callback)) + return callback +{{ end }} + + +__all__ = [ + "AsyncEventCallback", + "AsyncEventsHandler", + "EventBody", + "EventCallback", + "EventCallbackError", + "EventPayloadError", + "EventError", + "EventHandlerRegistrationError", + "EventNotification", + "EventObject", + "EventObjectUrlError", + "EventSignatureError", + "EventSignatureExpiredError", + "EventTimestampError", + "EventsHandler", + "KnownEventNotification", + "SIGNATURE_HEADER", + "UnknownEvent", + "verify_event_signature", +{{- range .Events }} + "{{ .ClassName }}", +{{- end }} +] diff --git a/examples/card_reader_checkout.py b/examples/card_reader_checkout.py index 877e3aa..0f511af 100644 --- a/examples/card_reader_checkout.py +++ b/examples/card_reader_checkout.py @@ -1,3 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# [tool.ty.environment] +# extra-paths = [".."] +# /// +"""Run from the repository root: uv run --with-editable . examples/card_reader_checkout.py + +Set SUMUP_API_KEY and SUMUP_MERCHANT_CODE before running. +""" + import asyncio import os diff --git a/examples/events-fastapi/README.md b/examples/events-fastapi/README.md new file mode 100644 index 0000000..e42942c --- /dev/null +++ b/examples/events-fastapi/README.md @@ -0,0 +1,19 @@ +# Events with FastAPI + +Verify incoming events and fetch a newly paired reader using async callbacks. + +From the repository root: + +```sh +export SUMUP_API_KEY="your-api-key" +export SUMUP_EVENT_SECRET="your-event-signing-secret" +uv run --with-editable . examples/events-fastapi/main.py +``` + +Forward signed event deliveries to `POST http://localhost:8080/events`. +The signing secret must match the sender; it is separate from your API key. + +The handler receives the raw body and complete signature header. It returns `204` +after processing, `400` for invalid deliveries, and `500` for callback failures. +Make callback side effects idempotent because deliveries can be retried. +Configure request-body limits in your reverse proxy or hosting platform. diff --git a/examples/events-fastapi/main.py b/examples/events-fastapi/main.py new file mode 100644 index 0000000..fa23f1b --- /dev/null +++ b/examples/events-fastapi/main.py @@ -0,0 +1,63 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["fastapi", "uvicorn"] +# /// +"""Run with: uv run --with-editable . examples/events-fastapi/main.py""" + +import logging +import os +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI, Request, Response + +from sumup import AsyncSumup +from sumup.events import ( + SIGNATURE_HEADER, + EventCallbackError, + EventNotification, + EventPayloadError, + EventSignatureError, + ReaderCreatedEvent, +) + +logger = logging.getLogger(__name__) +client = AsyncSumup(api_key=os.environ["SUMUP_API_KEY"]) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + yield + await client._client.aclose() + + +app = FastAPI(lifespan=lifespan) + + +async def fallback(event: EventNotification) -> None: + print(f"Received {event.type}") + + +events = client.events_handler(os.environ["SUMUP_EVENT_SECRET"], fallback) + + +@events.on_reader_created +async def reader_created(event: ReaderCreatedEvent) -> None: + reader = await event.fetch_object_async() + print(f"Reader paired: {reader.id} ({reader.name})") + + +@app.post("/events") +async def receive_event(request: Request) -> Response: + try: + await events.handle(await request.body(), request.headers.get(SIGNATURE_HEADER)) + except (EventSignatureError, EventPayloadError): + return Response("Invalid event", status_code=400) + except EventCallbackError: + logger.exception("Event processing failed") + return Response("Event processing failed", status_code=500) + return Response(status_code=204) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8080) diff --git a/examples/events-flask/README.md b/examples/events-flask/README.md new file mode 100644 index 0000000..04f9ce8 --- /dev/null +++ b/examples/events-flask/README.md @@ -0,0 +1,19 @@ +# Events with Flask + +Verify incoming events and fetch a newly paired reader using synchronous callbacks. + +From the repository root: + +```sh +export SUMUP_API_KEY="your-api-key" +export SUMUP_EVENT_SECRET="your-event-signing-secret" +uv run --with-editable . examples/events-flask/main.py +``` + +Forward signed event deliveries to `POST http://localhost:8080/events`. +The signing secret must match the sender; it is separate from your API key. + +The handler receives the raw body and complete signature header. It returns `204` +after processing, `400` for invalid deliveries, and `500` for callback failures. +Make callback side effects idempotent because deliveries can be retried. +Flask limits request bodies to 1 MiB in this example. diff --git a/examples/events-flask/main.py b/examples/events-flask/main.py new file mode 100644 index 0000000..e854183 --- /dev/null +++ b/examples/events-flask/main.py @@ -0,0 +1,55 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["flask"] +# /// +"""Run with: uv run --with-editable . examples/events-flask/main.py""" + +import os + +from flask import Flask, request + +from sumup import Sumup +from sumup.events import ( + SIGNATURE_HEADER, + EventCallbackError, + EventNotification, + EventPayloadError, + EventSignatureError, + ReaderCreatedEvent, +) + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 +client = Sumup(api_key=os.environ["SUMUP_API_KEY"]) + + +def fallback(event: EventNotification) -> None: + print(f"Received {event.type}") + + +events = client.events_handler(os.environ["SUMUP_EVENT_SECRET"], fallback) + + +@events.on_reader_created +def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(f"Reader paired: {reader.id} ({reader.name})") + + +@app.post("/events") +def receive_event(): + try: + events.handle(request.get_data(), request.headers.get(SIGNATURE_HEADER)) + except (EventSignatureError, EventPayloadError): + return "Invalid event", 400 + except EventCallbackError: + app.logger.exception("Event processing failed") + return "Event processing failed", 500 + return "", 204 + + +if __name__ == "__main__": + try: + app.run(port=8080) + finally: + client._client.close() diff --git a/examples/sync.py b/examples/sync.py index 156c1a3..5081d88 100644 --- a/examples/sync.py +++ b/examples/sync.py @@ -1,3 +1,14 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# [tool.ty.environment] +# extra-paths = [".."] +# /// +"""Run from the repository root: uv run --with-editable . examples/sync.py + +Set SUMUP_API_KEY and SUMUP_MERCHANT_CODE before running. +""" + import os from sumup import APIError, Sumup diff --git a/pyproject.toml b/pyproject.toml index 7f06d7d..fafeba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,3 +69,7 @@ line-length = 100 [tool.uv.workspace] exclude = ["codegen", "examples"] + +# Framework examples declare isolated dependencies in their script metadata. +[tool.ty.src] +exclude = ["examples/events-flask/", "examples/events-fastapi/"] diff --git a/sumup/__init__.py b/sumup/__init__.py index a4fe0c5..33c79e8 100644 --- a/sumup/__init__.py +++ b/sumup/__init__.py @@ -1,5 +1,14 @@ from sumup._client import AsyncSumup, Sumup from sumup._exceptions import APIError from sumup._service import AsyncResource, Resource +from sumup.events import AsyncEventsHandler, EventsHandler -__all__ = ["APIError", "AsyncResource", "AsyncSumup", "Resource", "Sumup"] +__all__ = [ + "APIError", + "AsyncEventsHandler", + "AsyncResource", + "AsyncSumup", + "EventsHandler", + "Resource", + "Sumup", +] diff --git a/sumup/_client.py b/sumup/_client.py index d33266f..4062972 100644 --- a/sumup/_client.py +++ b/sumup/_client.py @@ -1,9 +1,20 @@ # Code generated by `py-sdk-gen`. DO NOT EDIT. import os +import typing import httpx from ._service import AsyncResource, Resource, runtime_headers + +if typing.TYPE_CHECKING: + from .events import ( + AsyncEventCallback, + AsyncEventsHandler, + EventBody, + EventCallback, + EventNotification, + EventsHandler, + ) from .checkouts import AsyncCheckoutsResource, CheckoutsResource from .customers import AsyncCustomersResource, CustomersResource from .members import AsyncMembersResource, MembersResource @@ -49,6 +60,87 @@ def __init__( ) ) + def events_handler( + self, + secret: str, + fallback: "EventCallback", + ) -> "EventsHandler": + """Create a synchronous event handler bound to this API client. + + Register typed callbacks with decorators such as @handler.on_reader_created, + then call handler.handle(body, signature) in your HTTP route. Callbacks receive + one event and can use event.fetch_object() to retrieve its current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required synchronous callback for unknown event types and known + types without a registered callback. Return None on success or raise. + + Returns: + An EventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import EventsHandler + + return EventsHandler( + secret=secret, + fallback=fallback, + client=self._client, + ) + + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification( + self, body: "EventBody" + ) -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + @property def checkouts(self) -> CheckoutsResource: """Access the Checkouts API endpoints.""" @@ -141,6 +233,87 @@ def __init__( ) ) + def events_handler( + self, + secret: str, + fallback: "AsyncEventCallback", + ) -> "AsyncEventsHandler": + """Create an asynchronous event handler bound to this API client. + + Register async callbacks with decorators such as @handler.on_reader_created, + then await handler.handle(body, signature) in your HTTP route. Callbacks can + await event.fetch_object_async() to retrieve the current resource. + + Args: + secret: Event signing secret, separate from your API key. Required; the SDK + does not read a signing secret from environment variables. + fallback: Required async callback for unknown event types and known types + without a registered callback. It is awaited before handling completes. + + Returns: + An AsyncEventsHandler ready for callback registration. + + Raises: + EventSignatureError: The signing secret is missing or empty. + EventHandlerRegistrationError: The fallback is not callable. + """ + from .events import AsyncEventsHandler + + return AsyncEventsHandler( + secret=secret, + fallback=fallback, + client=self._client, + ) + + def parse_event_notification( + self, body: "EventBody", signature: str | None, secret: str + ) -> "EventNotification": + """Verify and parse an incoming event without running callbacks. + + Performs no network requests and is synchronous, including on AsyncSumup. + For deliveries already verified before storage, use + parse_event_notification_without_verification(). + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + secret: Event signing secret, separate from your API key. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventSignatureError: Missing secret, invalid signature, or a signing timestamp + more than five minutes before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification + + return parse_event_notification(secret, body, signature, client=self._client) + + def parse_event_notification_without_verification( + self, body: "EventBody" + ) -> "EventNotification": + """Parse a trusted event without checking its signature or signing timestamp. + + Use only for fixtures or deliveries already verified before storage in a trusted + queue. For incoming HTTP requests, use parse_event_notification() instead. + This method is synchronous, including on AsyncSumup, and performs no network I/O. + + Args: + body: The raw event JSON as bytes or a UTF-8 string. + + Returns: + A typed event bound to this client, or UnknownEvent for an unrecognized type. + + Raises: + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + """ + from ._events import parse_event_notification_without_verification + + return parse_event_notification_without_verification(body, client=self._client) + @property def checkouts(self) -> AsyncCheckoutsResource: """Access the Checkouts API endpoints.""" diff --git a/sumup/_events.py b/sumup/_events.py new file mode 100644 index 0000000..65fed9a --- /dev/null +++ b/sumup/_events.py @@ -0,0 +1,430 @@ +"""Verification and dispatch for SumUp event notifications.""" + +from __future__ import annotations + +import hashlib +import hmac +import inspect +import json +import re +import time +from collections.abc import Awaitable, Callable +from typing import Annotated, ClassVar, Generic, TypeVar, cast + +import httpx +import pydantic + +from ._exceptions import APIError, SumupError + +SIGNATURE_HEADER = "X-SumUp-Webhook-Signature" +"""Header containing the signing timestamp and signature. Pass its complete value.""" +_TOLERANCE_SECONDS = 300 +EventBody = str | bytes | bytearray | memoryview +"""Unchanged request bytes or a UTF-8 string, as returned by your web framework. + +Read the body before JSON middleware. Parsing and reserializing can change the +signed bytes and cause verification to fail. +""" +_NonemptyString = Annotated[str, pydantic.Field(strict=True, min_length=1)] +_ObjectT = TypeVar("_ObjectT", bound=pydantic.BaseModel) + + +class EventError(SumupError): + """Base class for event configuration, verification, parsing, and callback errors.""" + + +class EventSignatureError(EventError): + """Missing signing secret, invalid signature, or invalid signing timestamp.""" + + +class EventTimestampError(EventSignatureError): + """Missing or malformed signing timestamp in the signature header.""" + + +class EventSignatureExpiredError(EventSignatureError): + """Signing timestamp more than five minutes before or after the receiver's clock.""" + + +class EventPayloadError(EventError): + """Invalid UTF-8, JSON, or notification envelope.""" + + +class EventCallbackError(EventError): + """The selected callback raised an exception or used the wrong sync/async interface. + + The original exception is available as __cause__. Return a 5xx HTTP response + on processing failure to allow the sender to retry the delivery. + """ + + +class EventHandlerRegistrationError(EventError): + """Invalid or duplicate event callback registration.""" + + +class EventObjectUrlError(EventError): + """The resource URL is malformed or differs from the configured API origin. + + The scheme, host, and port must match the API client's base URL. + """ + + +class EventObject(pydantic.BaseModel): + """Reference to the resource associated with an event. + + Attributes: + id: Identifier of the referenced resource. + type: Resource type, such as "member" or "reader". + url: API URL used by the event's fetch_object() or fetch_object_async(). + """ + + id: _NonemptyString + type: _NonemptyString + url: _NonemptyString + + +class EventNotification(pydantic.BaseModel): + """Metadata shared by known events and UnknownEvent. + + Notifications contain a resource reference. Known event classes provide + fetch_object() and fetch_object_async() to retrieve its current state. + + Attributes: + id: Event identifier. Use it to recognize repeated deliveries. + type: Event name, such as "members.updated". + created_at: Time the event was created, as a timezone-aware datetime. + Signature verification checks the header's signing timestamp instead. + object: Reference to the associated API resource. + """ + + id: _NonemptyString + type: _NonemptyString + created_at: pydantic.AwareDatetime + object: EventObject + + _client: httpx.Client | httpx.AsyncClient | None = pydantic.PrivateAttr(default=None) + _object_type: ClassVar[str | None] = None + + @pydantic.field_validator("object") + @classmethod + def _validate_object(cls, value: EventObject) -> EventObject: + if cls._object_type is not None and value.type != cls._object_type: + raise ValueError("event object type does not match its notification type") + return value + + +class UnknownEvent(EventNotification): + """A valid notification whose type is not recognized by this SDK version. + + Handlers send it to the fallback callback. Its id, type, created_at, and object + remain available, but it has no typed resource-fetching method. + """ + + +class FetchableEvent(EventNotification, Generic[_ObjectT]): + """Base for known events with typed resource fetching.""" + + EVENT_TYPE: ClassVar[str] + _response_model: ClassVar[type[pydantic.BaseModel]] + + def _parse_response(self, response: httpx.Response) -> _ObjectT: + if not response.is_success: + try: + body = response.json() + except ValueError: + body = response.text + raise APIError("Unable to fetch event object", status=response.status_code, body=body) + return cast(_ObjectT, self._response_model.model_validate_json(response.content)) + + def fetch_object(self) -> _ObjectT: + """Fetch the latest state of this event's resource. + + Uses the Sumup client that parsed the event, including its authentication and + request settings. The result reflects the resource at fetch time, which may + have changed since the event was created. A deleted resource may return 404. + + The resource URL must match the client's scheme, host, and port. Its path and + query are appended to the configured base URL; URL credentials and fragments + are ignored. Redirects are not followed. + + Returns: + The resource model associated with this event, such as Member or Reader. + + Raises: + EventObjectUrlError: The resource URL is invalid or uses another API origin. + APIError: The API returns a non-2xx response; status and body are available. + EventError: The event was not parsed with an API client. + TypeError: The event belongs to AsyncSumup; use fetch_object_async(). + + HTTPX transport errors and Pydantic response-validation errors propagate. + """ + if self._client is None: + raise EventError("event notification is not bound to a SumUp client") + if not isinstance(self._client, httpx.Client): + raise TypeError("use fetch_object_async() with an AsyncSumup client") + response = self._client.get( + _validated_object_url(self._client, self.object.url), follow_redirects=False + ) + return self._parse_response(response) + + async def fetch_object_async(self) -> _ObjectT: + """Asynchronously fetch the latest state of this event's resource. + + Uses the AsyncSumup client that parsed the event. Await this method inside + async callbacks. Like fetch_object(), it returns current state, checks the + API origin, and does not follow redirects. Deleted resources may return 404. + + Returns: + The resource model associated with this event, such as Member or Reader. + + Raises: + EventObjectUrlError: The resource URL is invalid or uses another API origin. + APIError: The API returns a non-2xx response; status and body are available. + EventError: The event was not parsed with an API client. + TypeError: The event belongs to Sumup; use fetch_object(). + + HTTPX transport errors and Pydantic response-validation errors propagate. + """ + if self._client is None: + raise EventError("event notification is not bound to a SumUp client") + if not isinstance(self._client, httpx.AsyncClient): + raise TypeError("use fetch_object() with a Sumup client") + response = await self._client.get( + _validated_object_url(self._client, self.object.url), follow_redirects=False + ) + return self._parse_response(response) + + +EventCallback = Callable[[EventNotification], None] +"""Synchronous fallback receiving one event. Return None on success or raise on failure.""" +AsyncEventCallback = Callable[[EventNotification], Awaitable[None]] +"""Async fallback receiving one event. Failures are wrapped in EventCallbackError.""" +_ErasedCallback = Callable[[EventNotification], object] + + +class _BaseEventsHandler: + def __init__( + self, *, secret: str, fallback: _ErasedCallback, client: httpx.Client | httpx.AsyncClient + ) -> None: + _assert_secret(secret) + if not callable(fallback): + raise EventHandlerRegistrationError("an event fallback callback is required") + self._secret = secret + self._fallback = fallback + self._client = client + self._callbacks: dict[str, _ErasedCallback] = {} + + def _register(self, event_type: str, callback: _ErasedCallback) -> None: + if not callable(callback): + raise EventHandlerRegistrationError("an event callback is required") + if event_type in self._callbacks: + raise EventHandlerRegistrationError(f"callback already registered for {event_type}") + self._callbacks[event_type] = callback + + def parse(self, body: EventBody, signature: str | None) -> EventNotification: + """Verify and parse an incoming event without running callbacks. + + Performs no network requests. Even on AsyncEventsHandler, this method is + synchronous and does not need to be awaited. + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + signature: Complete X-SumUp-Webhook-Signature header value. None is accepted + for framework compatibility but raises EventSignatureError. + + Returns: + A typed event bound to this handler's client, or UnknownEvent for a new type. + + Raises: + EventSignatureError: Verification fails, including a signing timestamp more + than five minutes before or after the receiver's clock. + EventPayloadError: The body is not raw input, or its UTF-8, JSON, or event + fields are invalid. + """ + return parse_event_notification(self._secret, body, signature, client=self._client) + + +class _SyncEventsHandler(_BaseEventsHandler): + def handle(self, body: EventBody, signature: str | None) -> None: + """Verify an event and run its registered callback or the fallback. + + Callbacks run only after verification and parsing succeed. This method waits + for the callback to finish; it does not send an HTTP response. Acknowledge with + 2xx after success, reject invalid input with 400, and return 5xx on callback + failure so the sender can retry. Make callback side effects idempotent. + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + Your server owns reading the body and enforcing its size limit. + signature: Complete X-SumUp-Webhook-Signature header value. None or an empty + value fails verification. + + Raises: + EventSignatureError: Invalid signature or signing timestamp, including the + fixed five-minute window before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + EventCallbackError: The callback failed; __cause__ holds its exception. + """ + event = self.parse(body, signature) + callback = self._callbacks.get(event.type, self._fallback) + try: + result = callback(event) + if inspect.isawaitable(result): + if inspect.iscoroutine(result): + result.close() + raise TypeError("use AsyncSumup.events_handler() for async callbacks") + except Exception as cause: + raise EventCallbackError("event callback failed") from cause + + +class _AsyncEventsHandler(_BaseEventsHandler): + async def handle(self, body: EventBody, signature: str | None) -> None: + """Verify an event and await its registered callback or the fallback. + + Callbacks run only after verification and parsing succeed. Await completion + before sending a 2xx HTTP response. Reject invalid input with 400; return 5xx + on callback failure so the sender can retry. Make side effects idempotent. + Cancellation propagates to the caller without being wrapped. + + Args: + body: Unchanged request bytes or a UTF-8 string, read before JSON parsing. + Your server owns reading the body and enforcing its size limit. + signature: Complete X-SumUp-Webhook-Signature header value. None or an empty + value fails verification. + + Raises: + EventSignatureError: Invalid signature or signing timestamp, including the + fixed five-minute window before or after the receiver's clock. + EventPayloadError: Invalid raw input, UTF-8, JSON, or event fields. + EventCallbackError: The callback failed; __cause__ holds its exception. + """ + event = self.parse(body, signature) + callback = self._callbacks.get(event.type, self._fallback) + try: + await cast(Awaitable[None], callback(event)) + except Exception as cause: + raise EventCallbackError("event callback failed") from cause + + +def verify_event_signature(secret: str, body: EventBody, signature: str | None) -> None: + """Verify a delivery's signature and signing timestamp without parsing JSON. + + Useful before storing a delivery in a trusted queue. Workers can later use + client.parse_event_notification_without_verification() on the stored body. + This function performs no network requests and does not validate event fields. + + Args: + secret: Event signing secret. This is separate from your API key. + body: Unchanged request bytes or a UTF-8 string. Do not parse and reserialize. + signature: Complete X-SumUp-Webhook-Signature header value, including its + timestamp. None is accepted for framework compatibility but fails + verification. + + Raises: + EventSignatureError: The secret is missing, the signature is invalid, or the + signing timestamp is more than five minutes before or after the + receiver's clock. + EventPayloadError: The body is not bytes or a valid UTF-8 string. + """ + _verify_bytes(secret, _body_bytes(body), signature) + + +def _assert_secret(secret: str) -> None: + if not isinstance(secret, str) or not secret: + raise EventSignatureError("event signing secret is required") + + +def _verify_bytes(secret: str, body: bytes, signature: str | None) -> None: + _assert_secret(secret) + if not isinstance(signature, str) or not signature.strip(): + raise EventSignatureError("missing event signature header") + timestamp_field, separator, signature_field = signature.strip().partition(",") + if not timestamp_field.startswith("t="): + raise EventTimestampError("missing signing timestamp") + timestamp_text = timestamp_field[2:] + if not re.fullmatch(r"[0-9]+", timestamp_text): + raise EventTimestampError("invalid signing timestamp") + try: + timestamp = int(timestamp_text) + except ValueError as cause: + raise EventTimestampError("invalid signing timestamp") from cause + if timestamp > 2**53 - 1: + raise EventTimestampError("invalid signing timestamp") + if abs(int(time.time()) - timestamp) > _TOLERANCE_SECONDS: + raise EventSignatureExpiredError("event timestamp outside the five-minute window") + if not separator or not re.fullmatch(r"v1=[0-9a-fA-F]{64}", signature_field): + raise EventSignatureError("invalid event signature header") + # Preserve the timestamp's original spelling and the exact request bytes. + expected = hmac.digest( + secret.encode("utf-8"), + b"v1:" + timestamp_text.encode("ascii") + b":" + body, + hashlib.sha256, + ) + if not hmac.compare_digest(expected, bytes.fromhex(signature_field[3:])): + raise EventSignatureError("invalid event signature") + + +def parse_event_notification( + secret: str, body: EventBody, signature: str | None, *, client: httpx.Client | httpx.AsyncClient +) -> EventNotification: + raw = _body_bytes(body) + _verify_bytes(secret, raw, signature) + return _parse(raw, client) + + +def parse_event_notification_without_verification( + body: EventBody, *, client: httpx.Client | httpx.AsyncClient +) -> EventNotification: + return _parse(_body_bytes(body), client) + + +def _parse(body: bytes, client: httpx.Client | httpx.AsyncClient) -> EventNotification: + from .events import _EVENT_MODELS + + try: + payload = json.loads(body.decode("utf-8"), parse_constant=_reject_json_constant) + if not isinstance(payload, dict): + raise EventPayloadError("expected an event object") + event_type = payload.get("type") + model = ( + _EVENT_MODELS.get(event_type, UnknownEvent) + if isinstance(event_type, str) + else UnknownEvent + ) + event = model.model_validate(payload) + except (ValueError, RecursionError) as cause: + raise EventPayloadError("invalid event JSON, UTF-8, or notification envelope") from cause + event._client = client + return event + + +def _reject_json_constant(value: str) -> None: + raise ValueError(f"invalid JSON constant: {value}") + + +def _body_bytes(body: EventBody) -> bytes: + if isinstance(body, str): + try: + return body.encode("utf-8") + except UnicodeError as cause: + raise EventPayloadError("invalid UTF-8 event body") from cause + if isinstance(body, (bytes, bytearray, memoryview)): + return bytes(body) + raise EventPayloadError("expected raw request bytes or a string, not parsed JSON") + + +def _validated_object_url(client: httpx.Client | httpx.AsyncClient, value: str) -> httpx.URL: + try: + url = httpx.URL(value) + except (httpx.InvalidURL, ValueError) as cause: + raise EventObjectUrlError("invalid event object URL") from cause + base = client.base_url + if url.scheme not in ("https", "http") or (url.scheme, url.host, url.port) != ( + base.scheme, + base.host, + base.port, + ): + raise EventObjectUrlError("event object URL must use the configured API origin") + return base.copy_with( + raw_path=base.raw_path.split(b"?", 1)[0].rstrip(b"/") + b"/" + url.raw_path.lstrip(b"/"), + fragment=None, + ) diff --git a/sumup/events.py b/sumup/events.py new file mode 100644 index 0000000..91a4308 --- /dev/null +++ b/sumup/events.py @@ -0,0 +1,391 @@ +# Code generated by `py-sdk-gen`. DO NOT EDIT. +from __future__ import annotations + +import builtins +import typing + +import pydantic + +from ._events import ( + SIGNATURE_HEADER, + AsyncEventCallback, + EventBody, + EventCallback, + EventCallbackError, + EventError, + EventHandlerRegistrationError, + EventNotification, + EventObject, + EventObjectUrlError, + EventPayloadError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + FetchableEvent, + UnknownEvent, + _AsyncEventsHandler, + _ErasedCallback, + _SyncEventsHandler, + verify_event_signature, +) +from .types import ( + Member, + Reader, +) + + +class MemberCreatedEvent(FetchableEvent[Member]): + """Sent when a member is created, invited, or accepts an invitation for a merchant account. + + The notification type is "members.created". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Member. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "members.created" + type: typing.Literal["members.created"] = "members.created" + _object_type: typing.ClassVar[str] = "member" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Member + + +class MemberDeletedEvent(FetchableEvent[Member]): + """Sent when a member is deleted from a merchant account. + + The notification type is "members.deleted". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Member. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "members.deleted" + type: typing.Literal["members.deleted"] = "members.deleted" + _object_type: typing.ClassVar[str] = "member" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Member + + +class MemberUpdatedEvent(FetchableEvent[Member]): + """Sent when a member is updated, disabled, rejected, or expires for a merchant account. + + The notification type is "members.updated". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Member. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "members.updated" + type: typing.Literal["members.updated"] = "members.updated" + _object_type: typing.ClassVar[str] = "member" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Member + + +class ReaderCreatedEvent(FetchableEvent[Reader]): + """Sent when a reader is paired to a merchant account and becomes available through the Readers API. + + The notification type is "readers.created". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Reader. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "readers.created" + type: typing.Literal["readers.created"] = "readers.created" + _object_type: typing.ClassVar[str] = "reader" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Reader + + +class ReaderDeletedEvent(FetchableEvent[Reader]): + """Sent when a reader is unpaired from a merchant account and is no longer available through the Readers API. + + The notification type is "readers.deleted". Use fetch_object() with + Sumup or await fetch_object_async() with AsyncSumup to retrieve the latest + Reader. The notification itself contains a resource reference. + """ + + EVENT_TYPE: typing.ClassVar[str] = "readers.deleted" + type: typing.Literal["readers.deleted"] = "readers.deleted" + _object_type: typing.ClassVar[str] = "reader" + _response_model: typing.ClassVar[builtins.type[pydantic.BaseModel]] = Reader + + +KnownEventNotification = ( + MemberCreatedEvent + | MemberDeletedEvent + | MemberUpdatedEvent + | ReaderCreatedEvent + | ReaderDeletedEvent +) + + +_EVENT_MODELS: dict[str, type[EventNotification]] = { + "members.created": MemberCreatedEvent, + "members.deleted": MemberDeletedEvent, + "members.updated": MemberUpdatedEvent, + "readers.created": ReaderCreatedEvent, + "readers.deleted": ReaderDeletedEvent, +} + + +_MemberCreatedEventCallback = typing.TypeVar( + "_MemberCreatedEventCallback", bound=typing.Callable[[MemberCreatedEvent], None] +) +_AsyncMemberCreatedEventCallback = typing.TypeVar( + "_AsyncMemberCreatedEventCallback", + bound=typing.Callable[[MemberCreatedEvent], typing.Awaitable[None]], +) +_MemberDeletedEventCallback = typing.TypeVar( + "_MemberDeletedEventCallback", bound=typing.Callable[[MemberDeletedEvent], None] +) +_AsyncMemberDeletedEventCallback = typing.TypeVar( + "_AsyncMemberDeletedEventCallback", + bound=typing.Callable[[MemberDeletedEvent], typing.Awaitable[None]], +) +_MemberUpdatedEventCallback = typing.TypeVar( + "_MemberUpdatedEventCallback", bound=typing.Callable[[MemberUpdatedEvent], None] +) +_AsyncMemberUpdatedEventCallback = typing.TypeVar( + "_AsyncMemberUpdatedEventCallback", + bound=typing.Callable[[MemberUpdatedEvent], typing.Awaitable[None]], +) +_ReaderCreatedEventCallback = typing.TypeVar( + "_ReaderCreatedEventCallback", bound=typing.Callable[[ReaderCreatedEvent], None] +) +_AsyncReaderCreatedEventCallback = typing.TypeVar( + "_AsyncReaderCreatedEventCallback", + bound=typing.Callable[[ReaderCreatedEvent], typing.Awaitable[None]], +) +_ReaderDeletedEventCallback = typing.TypeVar( + "_ReaderDeletedEventCallback", bound=typing.Callable[[ReaderDeletedEvent], None] +) +_AsyncReaderDeletedEventCallback = typing.TypeVar( + "_AsyncReaderDeletedEventCallback", + bound=typing.Callable[[ReaderDeletedEvent], typing.Awaitable[None]], +) + + +class EventsHandler(_SyncEventsHandler): + """Verify incoming events and dispatch them to typed synchronous callbacks. + + Create with client.events_handler(secret, fallback). Register callbacks once + at startup using a decorator or a direct method call:: + + @handler.on_reader_created + def reader_created(event: ReaderCreatedEvent) -> None: + reader = event.fetch_object() + print(reader.id) + + Registration returns the original function, preserving its type and identity. + Unknown event types and known types without a callback reach the fallback. + Callbacks receive one event and must return None or raise an exception. + + Call handle(body, signature) with the raw request body and complete signature + header. Send a successful HTTP response after handling completes. Deliveries + may be repeated; make side effects idempotent and synchronize shared state if + serving concurrent requests. The caller owns request-body limits. + """ + + def on_member_created( + self, callback: _MemberCreatedEventCallback + ) -> _MemberCreatedEventCallback: + """Register a callback for members.created and return it unchanged. + + Use @handler.on_member_created or handler.on_member_created(callback). + The callback receives one MemberCreatedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_deleted( + self, callback: _MemberDeletedEventCallback + ) -> _MemberDeletedEventCallback: + """Register a callback for members.deleted and return it unchanged. + + Use @handler.on_member_deleted or handler.on_member_deleted(callback). + The callback receives one MemberDeletedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_updated( + self, callback: _MemberUpdatedEventCallback + ) -> _MemberUpdatedEventCallback: + """Register a callback for members.updated and return it unchanged. + + Use @handler.on_member_updated or handler.on_member_updated(callback). + The callback receives one MemberUpdatedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.updated", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_created( + self, callback: _ReaderCreatedEventCallback + ) -> _ReaderCreatedEventCallback: + """Register a callback for readers.created and return it unchanged. + + Use @handler.on_reader_created or handler.on_reader_created(callback). + The callback receives one ReaderCreatedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_deleted( + self, callback: _ReaderDeletedEventCallback + ) -> _ReaderDeletedEventCallback: + """Register a callback for readers.deleted and return it unchanged. + + Use @handler.on_reader_deleted or handler.on_reader_deleted(callback). + The callback receives one ReaderDeletedEvent and returns None on success; + raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + +class AsyncEventsHandler(_AsyncEventsHandler): + """Verify incoming events and dispatch them to typed asynchronous callbacks. + + Create with client.events_handler(secret, fallback) on an AsyncSumup client. + Register async callbacks at startup using decorators or direct method calls:: + + @handler.on_reader_created + async def reader_created(event: ReaderCreatedEvent) -> None: + reader = await event.fetch_object_async() + print(reader.id) + + Registration returns the original function. Unknown and unregistered types + reach the required async fallback. Await handle(body, signature) before + acknowledging delivery; the handler awaits the selected callback. Parsing + alone via parse() is synchronous and performs no network requests. + + Make side effects idempotent for repeat deliveries. Concurrent requests may + run callbacks concurrently; synchronize shared state and configure body limits + in your HTTP server. + """ + + def on_member_created( + self, callback: _AsyncMemberCreatedEventCallback + ) -> _AsyncMemberCreatedEventCallback: + """Register an async callback for members.created and return it unchanged. + + Use @handler.on_member_created or handler.on_member_created(callback). + The callback receives one MemberCreatedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_deleted( + self, callback: _AsyncMemberDeletedEventCallback + ) -> _AsyncMemberDeletedEventCallback: + """Register an async callback for members.deleted and return it unchanged. + + Use @handler.on_member_deleted or handler.on_member_deleted(callback). + The callback receives one MemberDeletedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + def on_member_updated( + self, callback: _AsyncMemberUpdatedEventCallback + ) -> _AsyncMemberUpdatedEventCallback: + """Register an async callback for members.updated and return it unchanged. + + Use @handler.on_member_updated or handler.on_member_updated(callback). + The callback receives one MemberUpdatedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("members.updated", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_created( + self, callback: _AsyncReaderCreatedEventCallback + ) -> _AsyncReaderCreatedEventCallback: + """Register an async callback for readers.created and return it unchanged. + + Use @handler.on_reader_created or handler.on_reader_created(callback). + The callback receives one ReaderCreatedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.created", typing.cast(_ErasedCallback, callback)) + return callback + + def on_reader_deleted( + self, callback: _AsyncReaderDeletedEventCallback + ) -> _AsyncReaderDeletedEventCallback: + """Register an async callback for readers.deleted and return it unchanged. + + Use @handler.on_reader_deleted or handler.on_reader_deleted(callback). + The callback receives one ReaderDeletedEvent and is awaited by handle(). + Return None on success or raise an exception to signal processing failure. + + Raises: + EventHandlerRegistrationError: The callback is not callable, or this + event type already has a registered callback. + """ + self._register("readers.deleted", typing.cast(_ErasedCallback, callback)) + return callback + + +__all__ = [ + "SIGNATURE_HEADER", + "AsyncEventCallback", + "AsyncEventsHandler", + "EventBody", + "EventCallback", + "EventCallbackError", + "EventError", + "EventHandlerRegistrationError", + "EventNotification", + "EventObject", + "EventObjectUrlError", + "EventPayloadError", + "EventSignatureError", + "EventSignatureExpiredError", + "EventTimestampError", + "EventsHandler", + "KnownEventNotification", + "MemberCreatedEvent", + "MemberDeletedEvent", + "MemberUpdatedEvent", + "ReaderCreatedEvent", + "ReaderDeletedEvent", + "UnknownEvent", + "verify_event_signature", +] diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..e608e21 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,517 @@ +import asyncio +import hashlib +import hmac +import json + +import httpx +import pytest + +from sumup import APIError, AsyncSumup +from sumup.events import ( + EventCallbackError, + EventHandlerRegistrationError, + EventNotification, + EventObjectUrlError, + EventPayloadError, + EventSignatureError, + EventSignatureExpiredError, + EventTimestampError, + MemberCreatedEvent, + MemberDeletedEvent, + MemberUpdatedEvent, + ReaderCreatedEvent, + ReaderDeletedEvent, + UnknownEvent, + verify_event_signature, +) +from sumup.types import Reader + +_NOW = 1788696000 +_SECRET = "event_secret_test" +_URL = "https://api.sumup.test/v0.1/readers/rdr_123" + + +@pytest.fixture(autouse=True) +def fixed_clock(monkeypatch): + monkeypatch.setattr("sumup._events.time.time", lambda: _NOW + 0.9) + + +@pytest.fixture +def sdk(sdk_factory): + return sdk_factory(lambda _: httpx.Response(200, json=_reader())) + + +def _body(event_type="readers.created", *, url=_URL): + return json.dumps( + { + "id": "evt_123", + "type": event_type, + "created_at": "2026-04-11T10:00:00Z", + "object": { + "id": "rdr_123", + "type": "reader" if event_type.startswith("readers.") else "member", + "url": url, + }, + } + ).encode() + + +def _signature(body, timestamp=str(_NOW), secret=_SECRET): + digest = hmac.new( + secret.encode(), f"v1:{timestamp}:".encode() + body, hashlib.sha256 + ).hexdigest() + return f"t={timestamp},v1={digest}" + + +def _reader(): + return { + "id": "rdr_123", + "name": "Front counter", + "status": "paired", + "device": {"identifier": "device_123", "model": "solo"}, + "created_at": "2026-04-11T10:00:00Z", + "updated_at": "2026-04-11T10:00:00Z", + } + + +@pytest.mark.parametrize( + "event_type,model", + [ + ("members.created", MemberCreatedEvent), + ("members.updated", MemberUpdatedEvent), + ("members.deleted", MemberDeletedEvent), + ("readers.created", ReaderCreatedEvent), + ("readers.deleted", ReaderDeletedEvent), + ("future.event", UnknownEvent), + ], +) +def test_parse_event_notification(sdk, event_type, model): + body = _body(event_type) + for event in ( + sdk.parse_event_notification(body, _signature(body), _SECRET), + sdk.parse_event_notification_without_verification(body), + ): + assert isinstance(event, model) + assert event.type == event_type + assert event.created_at.isoformat() == "2026-04-11T10:00:00+00:00" + assert "_client" not in event.model_dump() + assert "Bearer" not in repr(event) + + +@pytest.mark.parametrize( + "body", [b'{ "name": "caf\xc3\xa9" }\n', bytearray(b"body"), memoryview(b"body"), "café"] +) +def test_verify_event_signature_preserves_raw_bytes(body): + raw = body.encode() if isinstance(body, str) else bytes(body) + verify_event_signature(_SECRET, body, _signature(raw)) + with pytest.raises(EventSignatureError): + verify_event_signature(_SECRET, raw + b" ", _signature(raw)) + + +def test_verify_event_signature_normalization(): + body = _body() + signature = _signature(body, "0" + str(_NOW)) + timestamp, digest = signature.split(",v1=") + verify_event_signature(_SECRET, body, " \t" + timestamp + ",v1=" + digest.upper() + "\r\n") + with pytest.raises(EventSignatureError): + verify_event_signature(_SECRET, body, signature.replace("t=0", "t=")) + + +@pytest.mark.parametrize("offset", [-300, 300, 0]) +def test_verify_event_signature_accepts_five_minute_boundary(offset): + verify_event_signature(_SECRET, b"body", _signature(b"body", str(_NOW + offset))) + + +@pytest.mark.parametrize("offset", [-301, 301]) +def test_verify_event_signature_rejects_expired_and_future(offset): + with pytest.raises(EventSignatureExpiredError): + verify_event_signature(_SECRET, b"body", _signature(b"body", str(_NOW + offset))) + + +@pytest.mark.parametrize( + "header", + [ + None, + "", + "v1=" + "a" * 64, + "t=,v1=" + "a" * 64, + "t=-1,v1=" + "a" * 64, + "t=1.0,v1=" + "a" * 64, + "t=١,v1=" + "a" * 64, + "t=+1788696000,v1=" + "a" * 64, + "t=" + "9" * 5000 + ",v1=" + "a" * 64, + "t=9007199254740992,v1=" + "a" * 64, + f"t={_NOW},v2=" + "a" * 64, + f"t={_NOW},v1=abc", + f"t={_NOW},v1=" + "x" * 64, + f"t={_NOW}, v1=" + "a" * 64, + f"t={_NOW},v1=" + "a" * 64 + ",v1=" + "b" * 64, + f"t={_NOW},t={_NOW},v1=" + "a" * 64, + ], +) +def test_verify_event_signature_rejects_malformed_header(header): + with pytest.raises(EventSignatureError): + verify_event_signature(_SECRET, b"body", header) + + +def test_invalid_timestamp_is_signature_error(): + with pytest.raises(EventTimestampError): + verify_event_signature(_SECRET, b"body", "t=invalid,v1=abc") + + +@pytest.mark.parametrize("secret", ["", None, 123]) +def test_signing_secret_is_required(sdk, secret): + with pytest.raises(EventSignatureError): + verify_event_signature(secret, b"body", _signature(b"body")) + with pytest.raises(EventSignatureError): + sdk.events_handler(secret, lambda _: None) + + +@pytest.mark.parametrize( + "body", [{}, 12, None, [], b"\xff", b"{", b"[]", b"null", b"{}", b'{"x":NaN}'] +) +def test_parse_rejects_invalid_payload(sdk, body): + with pytest.raises(EventPayloadError): + sdk.parse_event_notification_without_verification(body) + + +@pytest.mark.parametrize( + "field,value", + [ + ("id", ""), + ("type", []), + ("created_at", "2026-04-11"), + ("created_at", "2026-04-11T10:00:00"), + ("created_at", "2026-02-30T10:00:00Z"), + ("object", None), + ("object.id", ""), + ("object.type", "member"), + ("object.url", 123), + ], +) +def test_parse_validates_envelope(sdk, field, value): + payload = json.loads(_body()) + if "." in field: + payload["object"][field.split(".")[1]] = value + else: + payload[field] = value + with pytest.raises(EventPayloadError): + sdk.parse_event_notification_without_verification(json.dumps(payload)) + + +@pytest.mark.parametrize("created_at", [0, "1970-01-01T00:00:00Z", "1970-01-01 00:00:00+00:00"]) +def test_parse_created_at_uses_datetime_parsing(sdk, created_at): + payload = json.loads(_body()) + payload["created_at"] = created_at + body = json.dumps(payload).encode() + event = sdk.parse_event_notification(body, _signature(body), _SECRET) + assert event.created_at.isoformat() == "1970-01-01T00:00:00+00:00" + + +def test_events_handler_dispatches_and_parse_does_not(sdk): + calls = [] + handler = sdk.events_handler(_SECRET, lambda event: calls.append(("fallback", event.type))) + + @handler.on_reader_created + def reader_created(event: ReaderCreatedEvent) -> None: + calls.append(("reader", event.type)) + + body = _body() + assert isinstance(handler.parse(body, _signature(body)), ReaderCreatedEvent) + assert calls == [] + for kind in ("readers.created", "members.updated", "future.event"): + raw = _body(kind) + handler.handle(raw, _signature(raw)) + assert calls == [ + ("reader", "readers.created"), + ("fallback", "members.updated"), + ("fallback", "future.event"), + ] + + +def test_events_handler_verifies_before_parsing_or_dispatch(sdk): + calls = [] + handler = sdk.events_handler(_SECRET, calls.append) + with pytest.raises(EventSignatureError): + handler.handle(b"not json", _signature(b"not json", secret="wrong")) + with pytest.raises(EventPayloadError): + handler.handle(b"not json", _signature(b"not json")) + assert calls == [] + + +def test_events_handler_registration_errors(sdk): + with pytest.raises(EventHandlerRegistrationError): + sdk.events_handler(_SECRET, None) + handler = sdk.events_handler(_SECRET, lambda _: None) + with pytest.raises(EventHandlerRegistrationError): + handler.on_reader_created(None) + handler.on_reader_created(lambda _: None) + with pytest.raises(EventHandlerRegistrationError): + handler.on_reader_created(lambda _: None) + + +@pytest.mark.parametrize("registered", [False, True]) +def test_events_handler_chains_callback_errors(sdk, registered): + error = RuntimeError("storage unavailable") + + def fail(_: EventNotification): + raise error + + handler = sdk.events_handler(_SECRET, fail) + if registered: + handler.on_reader_created(fail) + with pytest.raises(EventCallbackError) as raised: + handler.handle(_body(), _signature(_body())) + assert raised.value.__cause__ is error + + +def test_sync_handler_rejects_async_callback(sdk): + async def callback(_: EventNotification): + pass + + handler = sdk.events_handler(_SECRET, callback) + with pytest.raises(EventCallbackError) as raised: + handler.handle(_body(), _signature(_body())) + assert isinstance(raised.value.__cause__, TypeError) + + +@pytest.mark.parametrize( + "url", + [ + "https://evil.test/reader", + "http://api.sumup.test/reader", + "https://api.sumup.test:444/reader", + "/reader", + "//evil.test/reader", + "https://api.sumup.test@evil.test/reader", + "https://[broken", + ], +) +def test_fetch_object_checks_origin_before_request(sdk_factory, url): + calls = [] + sdk = sdk_factory(lambda request: calls.append(request)) + event = sdk.parse_event_notification_without_verification(_body(url=url)) + with pytest.raises(EventObjectUrlError): + event.fetch_object() + assert calls == [] + + +@pytest.mark.parametrize( + "url,path", + [ + ( + "https://user:pass@api.sumup.test/v0.1/readers/rdr_123?expand=true#ignored", + "/prefix/v0.1/readers/rdr_123?expand=true", + ), + ("https://API.SUMUP.TEST:443//readers/rdr_123", "/prefix/readers/rdr_123"), + ], +) +def test_fetch_object_normalizes_url_and_uses_client_options(sdk_factory, url, path): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(200, json=_reader()) + + sdk = sdk_factory(respond) + sdk._client.base_url = "https://api.sumup.test/prefix" + sdk._client.headers["X-Custom"] = "value" + event = sdk.parse_event_notification_without_verification(_body(url=url)) + result = event.fetch_object() + assert isinstance(result, Reader) + assert result.name == "Front counter" + assert len(requests) == 1 + request = requests[0] + assert str(request.url) == "https://api.sumup.test" + path + assert request.headers["Authorization"] == "Bearer test" + assert request.headers["X-Custom"] == "value" + + +@pytest.mark.parametrize("status,body", [(404, {"title": "Not Found"}), (500, "unavailable")]) +def test_fetch_object_preserves_api_errors(sdk_factory, status, body): + sdk = sdk_factory( + lambda _: httpx.Response( + status, **({"json": body} if isinstance(body, dict) else {"text": body}) + ) + ) + event = sdk.parse_event_notification_without_verification(_body()) + with pytest.raises(APIError) as raised: + event.fetch_object() + assert raised.value.status == status + assert raised.value.body == body + + +def test_fetch_object_does_not_follow_redirects(sdk_factory): + requests = [] + + def respond(request): + requests.append(request) + return httpx.Response(302, headers={"Location": "https://evil.test/reader"}) + + sdk = sdk_factory(respond) + sdk._client.follow_redirects = True + event = sdk.parse_event_notification_without_verification(_body()) + with pytest.raises(APIError): + event.fetch_object() + assert len(requests) == 1 + + +def test_async_events_handler_fetch_dispatch_errors_and_cancellation(): + async def run(): + sdk = AsyncSumup(api_key="test", base_url="https://api.sumup.test") + await sdk._client.aclose() + async with httpx.AsyncClient( + base_url="https://api.sumup.test", + transport=httpx.MockTransport(lambda _: httpx.Response(200, json=_reader())), + ) as transport: + sdk._client = transport + calls = [] + + async def fallback(event: EventNotification): + await asyncio.sleep(0) + calls.append(event.type) + + handler = sdk.events_handler(_SECRET, fallback) + + @handler.on_reader_created + async def reader_created(event: ReaderCreatedEvent): + reader = await event.fetch_object_async() + calls.append(reader.id) + + assert isinstance(handler.parse(_body(), _signature(_body())), ReaderCreatedEvent) + assert isinstance( + sdk.parse_event_notification(_body(), _signature(_body()), _SECRET), + ReaderCreatedEvent, + ) + for kind in ("readers.created", "members.updated", "future.event"): + raw = _body(kind) + await handler.handle(raw, _signature(raw)) + assert calls == ["rdr_123", "members.updated", "future.event"] + error = RuntimeError("storage unavailable") + + async def fail(_: EventNotification): + raise error + + with pytest.raises(EventCallbackError) as raised: + await sdk.events_handler(_SECRET, fail).handle(_body(), _signature(_body())) + assert raised.value.__cause__ is error + + async def cancelled(_: EventNotification): + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await sdk.events_handler(_SECRET, cancelled).handle(_body(), _signature(_body())) + event = sdk.parse_event_notification_without_verification(_body()) + assert isinstance(event, ReaderCreatedEvent) + with pytest.raises(TypeError, match="fetch_object_async"): + event.fetch_object() + + asyncio.run(run()) + + +def test_body_size_is_owned_by_receiver(): + body = b"x" * (2 * 1024 * 1024) + verify_event_signature(_SECRET, body, _signature(body)) + + +def test_public_surface_has_no_tolerance_or_separate_timestamp(): + import inspect + + from sumup import events + + assert "DEFAULT_TOLERANCE" not in events.__all__ + assert "TIMESTAMP_HEADER" not in events.__all__ + assert "FetchableEvent" not in events.__all__ + assert list(inspect.signature(verify_event_signature).parameters) == [ + "secret", + "body", + "signature", + ] + + +def test_parse_rejects_non_json_constants_in_otherwise_valid_event(sdk): + body = _body()[:-1] + b', "extra": NaN}' + with pytest.raises(EventPayloadError): + sdk.parse_event_notification_without_verification(body) + + +@pytest.mark.parametrize( + "method,event_type,model", + [ + ("on_member_created", "members.created", MemberCreatedEvent), + ("on_member_deleted", "members.deleted", MemberDeletedEvent), + ("on_member_updated", "members.updated", MemberUpdatedEvent), + ("on_reader_created", "readers.created", ReaderCreatedEvent), + ("on_reader_deleted", "readers.deleted", ReaderDeletedEvent), + ], +) +def test_specific_registration_preserves_callback_and_dispatches(sdk, method, event_type, model): + calls = [] + handler = sdk.events_handler(_SECRET, lambda _: pytest.fail("unexpected fallback")) + + def callback(event: EventNotification) -> None: + calls.append(event) + + register = getattr(handler, method) + assert register(callback) is callback + body = _body(event_type) + handler.handle(body, _signature(body)) + assert len(calls) == 1 + assert isinstance(calls[0], model) + with pytest.raises(EventHandlerRegistrationError): + register(callback) + + +@pytest.mark.parametrize( + "method,event_type,model", + [ + ("on_member_created", "members.created", MemberCreatedEvent), + ("on_member_deleted", "members.deleted", MemberDeletedEvent), + ("on_member_updated", "members.updated", MemberUpdatedEvent), + ("on_reader_created", "readers.created", ReaderCreatedEvent), + ("on_reader_deleted", "readers.deleted", ReaderDeletedEvent), + ], +) +def test_async_specific_registration_preserves_callback_and_dispatches(method, event_type, model): + async def run(): + sdk = AsyncSumup(api_key="test") + try: + calls = [] + + async def fallback(_: EventNotification) -> None: + pytest.fail("unexpected fallback") + + async def callback(event: EventNotification) -> None: + await asyncio.sleep(0) + calls.append(event) + + handler = sdk.events_handler(_SECRET, fallback) + register = getattr(handler, method) + with pytest.raises(EventHandlerRegistrationError): + register(None) + assert register(callback) is callback + body = _body(event_type) + await handler.handle(body, _signature(body)) + assert len(calls) == 1 + assert isinstance(calls[0], model) + with pytest.raises(EventHandlerRegistrationError): + register(callback) + finally: + await sdk._client.aclose() + + asyncio.run(run()) + + +def test_decorated_callback_remains_directly_callable(sdk): + calls = [] + handler = sdk.events_handler(_SECRET, lambda _: None) + + @handler.on_reader_created + def callback(notification: ReaderCreatedEvent) -> None: + calls.append(notification.id) + + event = sdk.parse_event_notification_without_verification(_body()) + assert isinstance(event, ReaderCreatedEvent) + # Keeping the parameter name also checks that the decorator preserves typing. + callback(notification=event) + assert calls == [event.id]