From fb36c0692d88ade8d1e3ea858be1133033ef4385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Sun, 6 Sep 2026 21:17:28 +0200 Subject: [PATCH] feat: events Adds typed event handling with signature verification, synchronous and asynchronous callbacks, and resource fetching. ```java var events = client.eventsHandler(secret, event -> System.out.printf("Unhandled event %s: %s%n", event.id(), event.type())); events.onMemberUpdated(event -> { var member = event.fetchObject(); System.out.printf("Member updated: %s%n", member.id()); }); events.handle(rawBody, signatureHeader); ``` Includes Javadocs, concise documentation, and a standalone HTTP-server example. Updates the specification to OpenAPI 3.1 for event definitions while preserving existing API models. --- README.md | 31 ++ codegen/README.md | 4 + codegen/internal/generator/events.go | 100 ++++++ codegen/internal/generator/events_test.go | 70 ++++ codegen/internal/generator/run.go | 2 +- .../internal/generator/templates/event.tmpl | 8 + .../templates/event_notification.tmpl | 109 ++++++ .../generator/templates/events_handler.tmpl | 85 +++++ .../generator/templates/sumup_client.tmpl | 34 ++ examples/events/README.md | 14 + examples/events/build.gradle | 6 + .../sumup/examples/events/EventsExample.java | 53 +++ settings.gradle | 3 + .../java/com/sumup/sdk/SumUpAsyncClient.java | 41 +++ src/main/java/com/sumup/sdk/SumUpClient.java | 41 +++ .../java/com/sumup/sdk/core/ApiClient.java | 10 + .../sumup/sdk/events/AsyncEventCallback.java | 20 ++ .../sumup/sdk/events/AsyncEventsHandler.java | 148 ++++++++ .../com/sumup/sdk/events/EventCallback.java | 17 + .../sdk/events/EventCallbackException.java | 14 + .../sumup/sdk/events/EventNotification.java | 115 +++++++ .../sdk/events/EventObjectException.java | 14 + .../sdk/events/EventObjectReference.java | 10 + .../sdk/events/EventPayloadException.java | 14 + .../com/sumup/sdk/events/EventSignature.java | 67 ++++ .../sdk/events/EventSignatureException.java | 14 + .../EventSignatureExpiredException.java | 14 + .../com/sumup/sdk/events/EventsHandler.java | 149 ++++++++ .../com/sumup/sdk/events/FetchableEvent.java | 78 +++++ .../sumup/sdk/events/MemberCreatedEvent.java | 13 + .../sumup/sdk/events/MemberDeletedEvent.java | 13 + .../sumup/sdk/events/MemberUpdatedEvent.java | 13 + .../sumup/sdk/events/ReaderCreatedEvent.java | 15 + .../sumup/sdk/events/ReaderDeletedEvent.java | 16 + .../java/com/sumup/sdk/events/EventsTest.java | 318 ++++++++++++++++++ 35 files changed, 1672 insertions(+), 1 deletion(-) create mode 100644 codegen/internal/generator/events.go create mode 100644 codegen/internal/generator/events_test.go create mode 100644 codegen/internal/generator/templates/event.tmpl create mode 100644 codegen/internal/generator/templates/event_notification.tmpl create mode 100644 codegen/internal/generator/templates/events_handler.tmpl create mode 100644 examples/events/README.md create mode 100644 examples/events/build.gradle create mode 100644 examples/events/src/main/java/com/sumup/examples/events/EventsExample.java create mode 100644 src/main/java/com/sumup/sdk/events/AsyncEventCallback.java create mode 100644 src/main/java/com/sumup/sdk/events/AsyncEventsHandler.java create mode 100644 src/main/java/com/sumup/sdk/events/EventCallback.java create mode 100644 src/main/java/com/sumup/sdk/events/EventCallbackException.java create mode 100644 src/main/java/com/sumup/sdk/events/EventNotification.java create mode 100644 src/main/java/com/sumup/sdk/events/EventObjectException.java create mode 100644 src/main/java/com/sumup/sdk/events/EventObjectReference.java create mode 100644 src/main/java/com/sumup/sdk/events/EventPayloadException.java create mode 100644 src/main/java/com/sumup/sdk/events/EventSignature.java create mode 100644 src/main/java/com/sumup/sdk/events/EventSignatureException.java create mode 100644 src/main/java/com/sumup/sdk/events/EventSignatureExpiredException.java create mode 100644 src/main/java/com/sumup/sdk/events/EventsHandler.java create mode 100644 src/main/java/com/sumup/sdk/events/FetchableEvent.java create mode 100644 src/main/java/com/sumup/sdk/events/MemberCreatedEvent.java create mode 100644 src/main/java/com/sumup/sdk/events/MemberDeletedEvent.java create mode 100644 src/main/java/com/sumup/sdk/events/MemberUpdatedEvent.java create mode 100644 src/main/java/com/sumup/sdk/events/ReaderCreatedEvent.java create mode 100644 src/main/java/com/sumup/sdk/events/ReaderDeletedEvent.java create mode 100644 src/test/java/com/sumup/sdk/events/EventsTest.java diff --git a/README.md b/README.md index 1169d1b..c93a340 100644 --- a/README.md +++ b/README.md @@ -256,8 +256,39 @@ readerIdFuture .join(); ``` +## Handling events + +```java +var events = client.eventsHandler(secret, event -> + System.out.printf("Unhandled event %s: %s%n", event.id(), event.type())); + +events.onMemberUpdated(event -> { + var member = event.fetchObject(); + System.out.printf("Member updated: %s%n", member.id()); +}); + +events.handle(rawBody, signatureHeader); +``` + +Pass the original request bytes and the `X-SumUp-Webhook-Signature` header. +The SDK verifies the signature and its fixed five-minute delivery window before processing. +Register callbacks before serving requests and acknowledge delivery only after processing succeeds. +Deliveries may repeat; use event IDs to deduplicate processing. + +`SumUpAsyncClient.eventsHandler` accepts callbacks returning a `CompletionStage`. +Call `handleAsync` and wait for its future to complete before acknowledging delivery. +Use `event.fetchObjectAsync()` for asynchronous resource fetches. + +For manual dispatch, use `client.parseEventNotification(rawBody, signatureHeader, secret)` +and match the notification type. Unknown event types remain available as `EventNotification`. +Resource fetches require an HTTP client with redirects disabled (the default). +`fetchObject` retrieves the resource's current state; deleted resources may return an API error. + +See the [standalone HTTP-server example](examples/events) for a complete receiver. + ## Examples +- [examples/events](examples/events) – receives signed events using the JDK HTTP server. - `examples/basic` – lists recent checkouts to verify that your API token works. - `examples/card-reader-checkout` – lists paired readers and creates a €10 checkout on the first available device. diff --git a/codegen/README.md b/codegen/README.md index a706195..168365c 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -33,3 +33,7 @@ just generate-codesamples ``` The recipe writes `code-samples.json` in the repository root by default. Pass another path as its argument to use a different destination. Every generated program is compiled in Continuous Integration. When an SDK release is published, the release workflow regenerates the catalog from that tag and opens or updates a pull request in `sumup/sumup-developer`; the generated JSON is not committed to this repository. + +Event classes, notification parsing, and callback registration methods are generated from +OpenAPI 3.1 `webhooks` entries and their `x-object` references. Signature verification +and resource-fetching support live in the handwritten `events` runtime. diff --git a/codegen/internal/generator/events.go b/codegen/internal/generator/events.go new file mode 100644 index 0000000..796f894 --- /dev/null +++ b/codegen/internal/generator/events.go @@ -0,0 +1,100 @@ +package generator + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" +) + +type eventData struct{ Name, Type, Description, Model, Package string } + +func renderEvents(doc *v3.Document, params Params) error { + events := []eventData{} + if doc.Webhooks != nil { + for eventType, path := range doc.Webhooks.FromOldest() { + if path == nil || path.Post == nil { + continue + } + op := path.Post + var object struct { + Ref string `yaml:"$ref"` + } + if op.Extensions == nil || op.Extensions.GetOrZero("x-object") == nil { + return fmt.Errorf("event %s: missing x-object", eventType) + } + if err := op.Extensions.GetOrZero("x-object").Decode(&object); err != nil { + return fmt.Errorf("event %s: decode object: %w", eventType, err) + } + model := strings.TrimPrefix(object.Ref, "#/components/schemas/") + if model == object.Ref || doc.Components == nil || doc.Components.Schemas.GetOrZero(model) == nil || op.OperationId == "" { + return fmt.Errorf("event %s: invalid object reference or operation ID", eventType) + } + description := strings.NewReplacer("&", "&", "<", "<", ">", ">", "*/", "*/").Replace(op.Description) + events = append(events, eventData{pascalCase(strings.TrimSuffix(op.OperationId, "Webhook"), ""), strconv.Quote(eventType), description, pascalCase(model, ""), params.BasePackage}) + } + } + sort.Slice(events, func(i, j int) bool { return events[i].Type < events[j].Type }) + dir := filepath.Join(params.OutputDir, params.basePackagePath(), "events") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create events directory: %w", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read events directory: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".java") { + continue + } + path := filepath.Join(dir, entry.Name()) + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read generated event: %w", err) + } + if bytes.HasPrefix(content, []byte("// Code generated by sumup-java/codegen. DO NOT EDIT.")) { + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove generated event: %w", err) + } + } + } + write := func(name, templateName string, data any) error { + tmpl, err := loadTemplate(templateName) + if err != nil { + return err + } + var output bytes.Buffer + if err := tmpl.Execute(&output, data); err != nil { + return fmt.Errorf("render event %s: %w", name, err) + } + if err := os.WriteFile(filepath.Join(dir, name+".java"), output.Bytes(), 0o644); err != nil { + return fmt.Errorf("write event %s: %w", name, err) + } + return nil + } + for _, event := range events { + if err := write(event.Name+"Event", "event.tmpl", event); err != nil { + return err + } + } + data := struct { + Package string + Events []eventData + Async bool + Class string + }{params.BasePackage, events, false, "EventsHandler"} + if err := write("EventNotification", "event_notification.tmpl", data); err != nil { + return err + } + if err := write(data.Class, "events_handler.tmpl", data); err != nil { + return err + } + data.Async = true + data.Class = "AsyncEventsHandler" + return write(data.Class, "events_handler.tmpl", data) +} diff --git a/codegen/internal/generator/events_test.go b/codegen/internal/generator/events_test.go new file mode 100644 index 0000000..0678e3f --- /dev/null +++ b/codegen/internal/generator/events_test.go @@ -0,0 +1,70 @@ +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRenderEvents(t *testing.T) { + t.Parallel() + const spec = `{"openapi":"3.1.0","info":{"title":"test","version":"1"},"paths":{},"components":{"schemas":{"Widget":{"type":"object","properties":{"id":{"type":"string"}}}}},"webhooks":{"widgets.updated":{"post":{"operationId":"WidgetUpdatedWebhook","description":"Widget changed.","x-object":{"$ref":"#/components/schemas/Widget"},"responses":{"200":{"description":"ok"}}}}}}` + path := filepath.Join(t.TempDir(), "openapi.json") + if err := os.WriteFile(path, []byte(spec), 0o644); err != nil { + t.Fatal(err) + } + doc, err := loadDocument(path) + if err != nil { + t.Fatal(err) + } + params := Params{OutputDir: t.TempDir(), BasePackage: "com.test.sdk"} + if err := renderEvents(doc, params); err != nil { + t.Fatal(err) + } + dir := filepath.Join(params.OutputDir, "com/test/sdk/events") + for file, expected := range map[string]string{ + "WidgetUpdatedEvent.java": "extends FetchableEvent", + "EventsHandler.java": "onWidgetUpdated(EventCallback", + "AsyncEventsHandler.java": "onWidgetUpdated(AsyncEventCallback", + "EventNotification.java": `case "widgets.updated" -> WidgetUpdatedEvent.class`, + } { + content, err := os.ReadFile(filepath.Join(dir, file)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), expected) { + t.Errorf("%s missing %q", file, expected) + } + if err := renderEvents(doc, params); err != nil { + t.Fatal(err) + } + again, err := os.ReadFile(filepath.Join(dir, file)) + if err != nil { + t.Fatal(err) + } + if string(content) != string(again) { + t.Errorf("%s is not deterministic", file) + } + } + runtime := filepath.Join(dir, "EventSignature.java") + if err := os.WriteFile(runtime, []byte("// Handwritten runtime"), 0o644); err != nil { + t.Fatal(err) + } + + doc.Webhooks.GetOrZero("widgets.updated").Post.OperationId = "" + if err := renderEvents(doc, params); err == nil { + t.Fatal("expected error for missing operation ID") + } + doc.Webhooks = nil + if err := renderEvents(doc, params); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "WidgetUpdatedEvent.java")); !os.IsNotExist(err) { + t.Fatalf("obsolete event still exists: %v", err) + } + if _, err := os.Stat(runtime); err != nil { + t.Fatalf("handwritten runtime was removed: %v", err) + } + +} diff --git a/codegen/internal/generator/run.go b/codegen/internal/generator/run.go index 7e66ecf..9b6403a 100644 --- a/codegen/internal/generator/run.go +++ b/codegen/internal/generator/run.go @@ -51,7 +51,7 @@ func Run(ctx context.Context, params Params) error { return err } - return nil + return renderEvents(doc, params) } // loadDocument reads and parses the OpenAPI specification into the pbo33f diff --git a/codegen/internal/generator/templates/event.tmpl b/codegen/internal/generator/templates/event.tmpl new file mode 100644 index 0000000..3f76b69 --- /dev/null +++ b/codegen/internal/generator/templates/event.tmpl @@ -0,0 +1,8 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package {{.Package}}.events; +import com.fasterxml.jackson.core.type.TypeReference; +import {{.Package}}.models.{{.Model}}; +/** {{.Description}} */ +public final class {{.Name}}Event extends FetchableEvent<{{.Model}}> { + @Override TypeReference<{{.Model}}> resourceType() { return new TypeReference<>() {}; } +} diff --git a/codegen/internal/generator/templates/event_notification.tmpl b/codegen/internal/generator/templates/event_notification.tmpl new file mode 100644 index 0000000..bd3d653 --- /dev/null +++ b/codegen/internal/generator/templates/event_notification.tmpl @@ -0,0 +1,109 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package {{.Package}}.events; + +import com.fasterxml.jackson.annotation.JsonProperty; +import {{.Package}}.core.ApiClient; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.IOException; +import java.time.OffsetDateTime; + +/** An event notification, also used for event types introduced after this SDK release. */ +public class EventNotification { + private static final ObjectMapper MAPPER = + new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("created_at") + private OffsetDateTime createdAt; + + @JsonProperty("object") + private EventObjectReference object; + + private ApiClient client; + + /** Returns the event ID. Use it to deduplicate deliveries. */ + public String id() { + return id; + } + + /** Returns the event name, such as members.updated. */ + public String type() { + return type; + } + + /** + * Returns when the event occurred; signature verification uses the delivery timestamp instead. + */ + public OffsetDateTime createdAt() { + return createdAt; + } + + /** Returns the reference to the affected resource. */ + public EventObjectReference object() { + return object; + } + + ApiClient client() { + if (client == null) + throw new EventObjectException( + "Parse the event through a SumUp client before fetching its resource."); + return client; + } + + /** + * Verifies and deserializes an event using the supplied API client for resource fetches. + * Prefer the client's {@code parseEventNotification} method when handling incoming requests. + * @param client client used to fetch affected resources + * @param body unmodified HTTP request bytes + * @param signature complete signature header value + * @param secret endpoint signing secret, not an API key + * @return typed notification, or a base notification for an unknown type + * @throws EventSignatureException if verification fails + * @throws EventPayloadException if deserialization fails + */ + public static EventNotification parse( + ApiClient client, byte[] body, String signature, String secret) { + EventSignature.verify(body, signature, secret); + return parseBody(client, body); + } + + /** + * Parses an already verified payload from trusted storage. Never use directly on incoming + * requests. + * @param client client used to fetch affected resources + * @param body JSON event body + * @return notification bound to the supplied client + * @throws EventPayloadException if deserialization fails + */ + public static EventNotification parseWithoutVerification(ApiClient client, byte[] body) { + return parseBody(client, body); + } + + private static EventNotification parseBody(ApiClient client, byte[] body) { + try { + var root = MAPPER.readTree(body); + if (root == null || !root.isObject()) + throw new EventPayloadException("Expected a JSON object."); + Class type = switch (root.path("type").asText("")) { +{{range .Events}} case {{.Type}} -> {{.Name}}Event.class; +{{end}} default -> EventNotification.class; + }; + EventNotification event = MAPPER.treeToValue(root, type); + event.client = client; + return event; + } catch (IOException cause) { + throw new EventPayloadException("Cannot deserialize the event body.", cause); + } + } +} diff --git a/codegen/internal/generator/templates/events_handler.tmpl b/codegen/internal/generator/templates/events_handler.tmpl new file mode 100644 index 0000000..8f592a9 --- /dev/null +++ b/codegen/internal/generator/templates/events_handler.tmpl @@ -0,0 +1,85 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package {{.Package}}.events; +import {{.Package}}.core.ApiClient; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CancellationException; +{{if .Async}}import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +{{end}} +/** + * Verifies and dispatches events to typed callbacks, using the fallback for unregistered types. + * Register callbacks before serving requests; registering again replaces the previous callback. + * Deliveries may repeat. Make processing idempotent using the event ID. + */ +public final class {{.Class}} { + private final ApiClient client; + private final String secret; + private final {{if .Async}}Async{{end}}EventCallback fallback; + private final Map> callbacks = new HashMap<>(); + /** + * Creates a handler bound to an API client. + * @param client client used for resource fetches + * @param secret endpoint signing secret, not an API key + * @param fallback callback for unknown and unregistered event types + */ + public {{.Class}}(ApiClient client, String secret, {{if .Async}}Async{{end}}EventCallback fallback) { + EventSignature.requireSecret(secret); + this.client = Objects.requireNonNull(client); + this.secret = secret; + this.fallback = Objects.requireNonNull(fallback); + } + /** + * Verifies and parses an event without invoking a callback. + * @param body original HTTP request bytes + * @param signature complete signature header value + * @return typed notification, or a base notification for an unknown event type + * @throws EventSignatureException if signature verification fails + * @throws EventPayloadException if the JSON cannot be deserialized + */ + public EventNotification parse(byte[] body, String signature) { + return EventNotification.parse(client, body, signature, secret); + } +{{range .Events}} + /** + * Registers the callback for {@code {{.Type}}}, replacing any previous registration. + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public {{$.Class}} on{{.Name}}({{if $.Async}}Async{{end}}EventCallback<{{.Name}}Event> callback) { + Objects.requireNonNull(callback); + callbacks.put({{.Type}}, event -> {{if not $.Async}}{ {{end}}callback.handle(({{.Name}}Event) event){{if not $.Async}}; }{{end}}); + return this; + } +{{end}} + /** + * Verifies, parses, and {{if .Async}}completes when the selected callback finishes{{else}}invokes the selected callback{{end}}. + * @param body original HTTP request bytes; do not reserialize the JSON + * @param signature complete signature header value + {{if .Async}}* @return future completing after processing; failures retain the original callback exception as their cause + {{end}}* @throws EventSignatureException if signature verification fails{{if .Async}} before returning a future{{end}} + * @throws EventPayloadException if deserialization fails{{if .Async}} before returning a future{{end}} + {{if not .Async}}* @throws EventCallbackException if the callback fails + {{end}}*/ + public {{if .Async}}CompletableFuture handleAsync{{else}}void handle{{end}}(byte[] body, String signature) { + var event = parse(body, signature); + var callback = callbacks.getOrDefault(event.type(), fallback); + try { + {{if .Async}}return Objects.requireNonNull(callback.handle(event), "Callback must return a completion stage.") + .handle((value, failure) -> { + if (failure != null) { + var cause = failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure; + if (cause instanceof CancellationException cancellation) throw cancellation; + throw new EventCallbackException("Event callback failed.", cause); + } + return (Void) null; + }).toCompletableFuture();{{else}}callback.handle(event);{{end}} + } catch (CancellationException cause) { + {{if .Async}}return CompletableFuture.failedFuture(cause);{{else}}throw cause;{{end}} + } catch (Exception cause) { + if (cause instanceof InterruptedException) Thread.currentThread().interrupt(); + {{if .Async}}return CompletableFuture.failedFuture(new EventCallbackException("Event callback failed.", cause));{{else}}throw new EventCallbackException("Event callback failed.", cause);{{end}} + } + } +} diff --git a/codegen/internal/generator/templates/sumup_client.tmpl b/codegen/internal/generator/templates/sumup_client.tmpl index 9484ed3..2ae93fb 100644 --- a/codegen/internal/generator/templates/sumup_client.tmpl +++ b/codegen/internal/generator/templates/sumup_client.tmpl @@ -50,6 +50,40 @@ public final class {{ .ClassName }} { {{- end }} } + /** + * Creates a handler for verified event deliveries. Register callbacks before serving requests. + * @param secret endpoint signing secret, not an API key + * @param fallback callback for unknown and unregistered event types + * @return handler bound to this client's configuration + */ + public {{.Package}}.events.{{if eq .ClassName "SumUpAsyncClient"}}Async{{end}}EventsHandler eventsHandler( + String secret, {{.Package}}.events.{{if eq .ClassName "SumUpAsyncClient"}}Async{{end}}EventCallback<{{.Package}}.events.EventNotification> fallback) { + return new {{.Package}}.events.{{if eq .ClassName "SumUpAsyncClient"}}Async{{end}}EventsHandler(apiClient, secret, fallback); + } + + /** + * Verifies the original request body before parsing it as a typed event. + * @param body unmodified HTTP request bytes + * @param signature complete X-SumUp-Webhook-Signature header value + * @param secret endpoint signing secret, not an API key + * @return typed notification, or a base notification for an unknown type + * @throws {{.Package}}.events.EventSignatureException if verification fails + * @throws {{.Package}}.events.EventPayloadException if deserialization fails + */ + public {{.Package}}.events.EventNotification parseEventNotification(byte[] body, String signature, String secret) { + return {{.Package}}.events.EventNotification.parse(apiClient, body, signature, secret); + } + + /** + * Parses an already verified payload from trusted storage. Never use directly on incoming requests. + * @param body JSON event body + * @return notification bound to this client's resource fetching configuration + * @throws {{.Package}}.events.EventPayloadException if deserialization fails + */ + public {{.Package}}.events.EventNotification parseEventNotificationWithoutVerification(byte[] body) { + return {{.Package}}.events.EventNotification.parseWithoutVerification(apiClient, body); + } + /** * Creates a new builder for {{ .ClassName }}. * diff --git a/examples/events/README.md b/examples/events/README.md new file mode 100644 index 0000000..8c5e6c1 --- /dev/null +++ b/examples/events/README.md @@ -0,0 +1,14 @@ +# Events with the JDK HTTP server + +```sh +export SUMUP_API_KEY="your_api_key" +export SUMUP_EVENT_SECRET="your_endpoint_signing_secret" +./gradlew :examples:events:run +``` + +Forward event deliveries to `POST http://localhost:8080/events`. +The example verifies the raw body, runs typed callbacks, and returns HTTP 204 after successful processing. +Invalid deliveries return 400; callback failures return 500 so delivery can be retried. + +Use event IDs to deduplicate processing. Resource fetches return the current state; +deleted resources may return an API error. Configure body limits in your production web server or proxy. diff --git a/examples/events/build.gradle b/examples/events/build.gradle new file mode 100644 index 0000000..f51cf66 --- /dev/null +++ b/examples/events/build.gradle @@ -0,0 +1,6 @@ +plugins { + id 'application' + id 'com.diffplug.spotless' +} +application { mainClass = 'com.sumup.examples.events.EventsExample' } +dependencies { implementation project(':sumup-sdk') } diff --git a/examples/events/src/main/java/com/sumup/examples/events/EventsExample.java b/examples/events/src/main/java/com/sumup/examples/events/EventsExample.java new file mode 100644 index 0000000..2bb6ec9 --- /dev/null +++ b/examples/events/src/main/java/com/sumup/examples/events/EventsExample.java @@ -0,0 +1,53 @@ +package com.sumup.examples.events; + +import com.sumup.sdk.SumUpClient; +import com.sumup.sdk.events.EventCallbackException; +import com.sumup.sdk.events.EventPayloadException; +import com.sumup.sdk.events.EventSignature; +import com.sumup.sdk.events.EventSignatureException; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; + +/** A minimal event receiver using the JDK HTTP server. */ +public final class EventsExample { + private EventsExample() {} + + /** Starts a receiver on port 8080. Set SUMUP_API_KEY and SUMUP_EVENT_SECRET first. */ + public static void main(String[] args) throws Exception { + var client = new SumUpClient(); + var events = + client.eventsHandler( + System.getenv("SUMUP_EVENT_SECRET"), + event -> System.out.printf("Unhandled event %s: %s%n", event.id(), event.type())); + events.onMemberUpdated( + event -> { + var member = event.fetchObject(); + System.out.printf("Member updated: %s%n", member.id()); + }); + var server = HttpServer.create(new InetSocketAddress(8080), 0); + server.createContext( + "/events", + request -> { + try { + if (!"POST".equals(request.getRequestMethod())) { + request.sendResponseHeaders(405, -1); + return; + } + var body = request.getRequestBody().readAllBytes(); + try { + events.handle(body, request.getRequestHeaders().getFirst(EventSignature.HEADER_NAME)); + request.sendResponseHeaders(204, -1); + } catch (EventSignatureException | EventPayloadException failure) { + request.sendResponseHeaders(400, -1); + } catch (EventCallbackException failure) { + failure.printStackTrace(); + request.sendResponseHeaders(500, -1); + } + } finally { + request.close(); + } + }); + server.start(); + System.out.println("Listening on http://localhost:8080/events"); + } +} diff --git a/settings.gradle b/settings.gradle index 79e37fa..49c237d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,6 @@ project(':examples:basic').projectDir = file('examples/basic') include(':examples:card-reader-checkout') project(':examples:card-reader-checkout').projectDir = file('examples/card-reader-checkout') + +include(':examples:events') +project(':examples:events').projectDir = file('examples/events') diff --git a/src/main/java/com/sumup/sdk/SumUpAsyncClient.java b/src/main/java/com/sumup/sdk/SumUpAsyncClient.java index fab88ae..d4e2dff 100644 --- a/src/main/java/com/sumup/sdk/SumUpAsyncClient.java +++ b/src/main/java/com/sumup/sdk/SumUpAsyncClient.java @@ -74,6 +74,47 @@ private SumUpAsyncClient(ApiClient apiClient) { this.transactions = new TransactionsAsyncClient(this.apiClient); } + /** + * Creates a handler for verified event deliveries. Register callbacks before serving requests. + * + * @param secret endpoint signing secret, not an API key + * @param fallback callback for unknown and unregistered event types + * @return handler bound to this client's configuration + */ + public com.sumup.sdk.events.AsyncEventsHandler eventsHandler( + String secret, + com.sumup.sdk.events.AsyncEventCallback fallback) { + return new com.sumup.sdk.events.AsyncEventsHandler(apiClient, secret, fallback); + } + + /** + * Verifies the original request body before parsing it as a typed event. + * + * @param body unmodified HTTP request bytes + * @param signature complete X-SumUp-Webhook-Signature header value + * @param secret endpoint signing secret, not an API key + * @return typed notification, or a base notification for an unknown type + * @throws com.sumup.sdk.events.EventSignatureException if verification fails + * @throws com.sumup.sdk.events.EventPayloadException if deserialization fails + */ + public com.sumup.sdk.events.EventNotification parseEventNotification( + byte[] body, String signature, String secret) { + return com.sumup.sdk.events.EventNotification.parse(apiClient, body, signature, secret); + } + + /** + * Parses an already verified payload from trusted storage. Never use directly on incoming + * requests. + * + * @param body JSON event body + * @return notification bound to this client's resource fetching configuration + * @throws com.sumup.sdk.events.EventPayloadException if deserialization fails + */ + public com.sumup.sdk.events.EventNotification parseEventNotificationWithoutVerification( + byte[] body) { + return com.sumup.sdk.events.EventNotification.parseWithoutVerification(apiClient, body); + } + /** * Creates a new builder for SumUpAsyncClient. * diff --git a/src/main/java/com/sumup/sdk/SumUpClient.java b/src/main/java/com/sumup/sdk/SumUpClient.java index cace91c..c3fa2f4 100644 --- a/src/main/java/com/sumup/sdk/SumUpClient.java +++ b/src/main/java/com/sumup/sdk/SumUpClient.java @@ -74,6 +74,47 @@ private SumUpClient(ApiClient apiClient) { this.transactions = new TransactionsClient(this.apiClient); } + /** + * Creates a handler for verified event deliveries. Register callbacks before serving requests. + * + * @param secret endpoint signing secret, not an API key + * @param fallback callback for unknown and unregistered event types + * @return handler bound to this client's configuration + */ + public com.sumup.sdk.events.EventsHandler eventsHandler( + String secret, + com.sumup.sdk.events.EventCallback fallback) { + return new com.sumup.sdk.events.EventsHandler(apiClient, secret, fallback); + } + + /** + * Verifies the original request body before parsing it as a typed event. + * + * @param body unmodified HTTP request bytes + * @param signature complete X-SumUp-Webhook-Signature header value + * @param secret endpoint signing secret, not an API key + * @return typed notification, or a base notification for an unknown type + * @throws com.sumup.sdk.events.EventSignatureException if verification fails + * @throws com.sumup.sdk.events.EventPayloadException if deserialization fails + */ + public com.sumup.sdk.events.EventNotification parseEventNotification( + byte[] body, String signature, String secret) { + return com.sumup.sdk.events.EventNotification.parse(apiClient, body, signature, secret); + } + + /** + * Parses an already verified payload from trusted storage. Never use directly on incoming + * requests. + * + * @param body JSON event body + * @return notification bound to this client's resource fetching configuration + * @throws com.sumup.sdk.events.EventPayloadException if deserialization fails + */ + public com.sumup.sdk.events.EventNotification parseEventNotificationWithoutVerification( + byte[] body) { + return com.sumup.sdk.events.EventNotification.parseWithoutVerification(apiClient, body); + } + /** * Creates a new builder for SumUpClient. * diff --git a/src/main/java/com/sumup/sdk/core/ApiClient.java b/src/main/java/com/sumup/sdk/core/ApiClient.java index f420304..a7fc87d 100644 --- a/src/main/java/com/sumup/sdk/core/ApiClient.java +++ b/src/main/java/com/sumup/sdk/core/ApiClient.java @@ -119,6 +119,16 @@ public CompletableFuture sendAsync( }); } + /** Returns the configured base URI used to resolve API paths. */ + public URI baseUri() { + return baseUri; + } + + /** Returns the redirect policy of the configured HTTP transport. */ + public HttpClient.Redirect redirectPolicy() { + return httpClient.followRedirects(); + } + private HttpRequest buildRequest( HttpMethod method, String path, diff --git a/src/main/java/com/sumup/sdk/events/AsyncEventCallback.java b/src/main/java/com/sumup/sdk/events/AsyncEventCallback.java new file mode 100644 index 0000000..553402c --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/AsyncEventCallback.java @@ -0,0 +1,20 @@ +package com.sumup.sdk.events; + +import java.util.concurrent.CompletionStage; + +/** + * An asynchronous callback. Return a stage representing all processing before acknowledgment. + * + * @param notification type + */ +@FunctionalInterface +public interface AsyncEventCallback { + /** + * Processes the verified notification. + * + * @param event verified notification + * @return stage completing when processing succeeds + * @throws Exception if processing fails before returning a stage + */ + CompletionStage handle(T event) throws Exception; +} diff --git a/src/main/java/com/sumup/sdk/events/AsyncEventsHandler.java b/src/main/java/com/sumup/sdk/events/AsyncEventsHandler.java new file mode 100644 index 0000000..700c658 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/AsyncEventsHandler.java @@ -0,0 +1,148 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.sumup.sdk.core.ApiClient; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * Verifies and dispatches events to typed callbacks, using the fallback for unregistered types. + * Register callbacks before serving requests; registering again replaces the previous callback. + * Deliveries may repeat. Make processing idempotent using the event ID. + */ +public final class AsyncEventsHandler { + private final ApiClient client; + private final String secret; + private final AsyncEventCallback fallback; + private final Map> callbacks = new HashMap<>(); + + /** + * Creates a handler bound to an API client. + * + * @param client client used for resource fetches + * @param secret endpoint signing secret, not an API key + * @param fallback callback for unknown and unregistered event types + */ + public AsyncEventsHandler( + ApiClient client, String secret, AsyncEventCallback fallback) { + EventSignature.requireSecret(secret); + this.client = Objects.requireNonNull(client); + this.secret = secret; + this.fallback = Objects.requireNonNull(fallback); + } + + /** + * Verifies and parses an event without invoking a callback. + * + * @param body original HTTP request bytes + * @param signature complete signature header value + * @return typed notification, or a base notification for an unknown event type + * @throws EventSignatureException if signature verification fails + * @throws EventPayloadException if the JSON cannot be deserialized + */ + public EventNotification parse(byte[] body, String signature) { + return EventNotification.parse(client, body, signature, secret); + } + + /** + * Registers the callback for {@code "members.created"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public AsyncEventsHandler onMemberCreated(AsyncEventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put("members.created", event -> callback.handle((MemberCreatedEvent) event)); + return this; + } + + /** + * Registers the callback for {@code "members.deleted"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public AsyncEventsHandler onMemberDeleted(AsyncEventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put("members.deleted", event -> callback.handle((MemberDeletedEvent) event)); + return this; + } + + /** + * Registers the callback for {@code "members.updated"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public AsyncEventsHandler onMemberUpdated(AsyncEventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put("members.updated", event -> callback.handle((MemberUpdatedEvent) event)); + return this; + } + + /** + * Registers the callback for {@code "readers.created"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public AsyncEventsHandler onReaderCreated(AsyncEventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put("readers.created", event -> callback.handle((ReaderCreatedEvent) event)); + return this; + } + + /** + * Registers the callback for {@code "readers.deleted"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public AsyncEventsHandler onReaderDeleted(AsyncEventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put("readers.deleted", event -> callback.handle((ReaderDeletedEvent) event)); + return this; + } + + /** + * Verifies, parses, and completes when the selected callback finishes. + * + * @param body original HTTP request bytes; do not reserialize the JSON + * @param signature complete signature header value + * @return future completing after processing; failures retain the original callback exception as + * their cause + * @throws EventSignatureException if signature verification fails before returning a future + * @throws EventPayloadException if deserialization fails before returning a future + */ + public CompletableFuture handleAsync(byte[] body, String signature) { + var event = parse(body, signature); + var callback = callbacks.getOrDefault(event.type(), fallback); + try { + return Objects.requireNonNull( + callback.handle(event), "Callback must return a completion stage.") + .handle( + (value, failure) -> { + if (failure != null) { + var cause = + failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() + : failure; + if (cause instanceof CancellationException cancellation) throw cancellation; + throw new EventCallbackException("Event callback failed.", cause); + } + return (Void) null; + }) + .toCompletableFuture(); + } catch (CancellationException cause) { + return CompletableFuture.failedFuture(cause); + } catch (Exception cause) { + if (cause instanceof InterruptedException) Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture( + new EventCallbackException("Event callback failed.", cause)); + } + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventCallback.java b/src/main/java/com/sumup/sdk/events/EventCallback.java new file mode 100644 index 0000000..a1e349e --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventCallback.java @@ -0,0 +1,17 @@ +package com.sumup.sdk.events; + +/** + * A synchronous event callback that may throw an application exception. + * + * @param notification type + */ +@FunctionalInterface +public interface EventCallback { + /** + * Processes the event. Delivery should be acknowledged only after this returns successfully. + * + * @param event verified notification + * @throws Exception if application processing fails + */ + void handle(T event) throws Exception; +} diff --git a/src/main/java/com/sumup/sdk/events/EventCallbackException.java b/src/main/java/com/sumup/sdk/events/EventCallbackException.java new file mode 100644 index 0000000..77b44ae --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventCallbackException.java @@ -0,0 +1,14 @@ +package com.sumup.sdk.events; + +/** A callback failed. The cause contains the original exception. */ +public class EventCallbackException extends RuntimeException { + /** Creates an exception with a description of the failure. */ + public EventCallbackException(String message) { + super(message); + } + + /** Creates an exception retaining the original failure. */ + public EventCallbackException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventNotification.java b/src/main/java/com/sumup/sdk/events/EventNotification.java new file mode 100644 index 0000000..5e4b010 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventNotification.java @@ -0,0 +1,115 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sumup.sdk.core.ApiClient; +import java.io.IOException; +import java.time.OffsetDateTime; + +/** An event notification, also used for event types introduced after this SDK release. */ +public class EventNotification { + private static final ObjectMapper MAPPER = + new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + + @JsonProperty("id") + private String id; + + @JsonProperty("type") + private String type; + + @JsonProperty("created_at") + private OffsetDateTime createdAt; + + @JsonProperty("object") + private EventObjectReference object; + + private ApiClient client; + + /** Returns the event ID. Use it to deduplicate deliveries. */ + public String id() { + return id; + } + + /** Returns the event name, such as members.updated. */ + public String type() { + return type; + } + + /** + * Returns when the event occurred; signature verification uses the delivery timestamp instead. + */ + public OffsetDateTime createdAt() { + return createdAt; + } + + /** Returns the reference to the affected resource. */ + public EventObjectReference object() { + return object; + } + + ApiClient client() { + if (client == null) + throw new EventObjectException( + "Parse the event through a SumUp client before fetching its resource."); + return client; + } + + /** + * Verifies and deserializes an event using the supplied API client for resource fetches. Prefer + * the client's {@code parseEventNotification} method when handling incoming requests. + * + * @param client client used to fetch affected resources + * @param body unmodified HTTP request bytes + * @param signature complete signature header value + * @param secret endpoint signing secret, not an API key + * @return typed notification, or a base notification for an unknown type + * @throws EventSignatureException if verification fails + * @throws EventPayloadException if deserialization fails + */ + public static EventNotification parse( + ApiClient client, byte[] body, String signature, String secret) { + EventSignature.verify(body, signature, secret); + return parseBody(client, body); + } + + /** + * Parses an already verified payload from trusted storage. Never use directly on incoming + * requests. + * + * @param client client used to fetch affected resources + * @param body JSON event body + * @return notification bound to the supplied client + * @throws EventPayloadException if deserialization fails + */ + public static EventNotification parseWithoutVerification(ApiClient client, byte[] body) { + return parseBody(client, body); + } + + private static EventNotification parseBody(ApiClient client, byte[] body) { + try { + var root = MAPPER.readTree(body); + if (root == null || !root.isObject()) + throw new EventPayloadException("Expected a JSON object."); + Class type = + switch (root.path("type").asText("")) { + case "members.created" -> MemberCreatedEvent.class; + case "members.deleted" -> MemberDeletedEvent.class; + case "members.updated" -> MemberUpdatedEvent.class; + case "readers.created" -> ReaderCreatedEvent.class; + case "readers.deleted" -> ReaderDeletedEvent.class; + default -> EventNotification.class; + }; + EventNotification event = MAPPER.treeToValue(root, type); + event.client = client; + return event; + } catch (IOException cause) { + throw new EventPayloadException("Cannot deserialize the event body.", cause); + } + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventObjectException.java b/src/main/java/com/sumup/sdk/events/EventObjectException.java new file mode 100644 index 0000000..f1df049 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventObjectException.java @@ -0,0 +1,14 @@ +package com.sumup.sdk.events; + +/** The resource URL cannot be fetched through this client. */ +public class EventObjectException extends RuntimeException { + /** Creates an exception with a description of the failure. */ + public EventObjectException(String message) { + super(message); + } + + /** Creates an exception retaining the original failure. */ + public EventObjectException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventObjectReference.java b/src/main/java/com/sumup/sdk/events/EventObjectReference.java new file mode 100644 index 0000000..6515272 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventObjectReference.java @@ -0,0 +1,10 @@ +package com.sumup.sdk.events; + +/** + * Reference to the affected resource; its ID is available without an API request. + * + * @param id resource ID + * @param type resource type, such as member or reader + * @param url resource API URL + */ +public record EventObjectReference(String id, String type, String url) {} diff --git a/src/main/java/com/sumup/sdk/events/EventPayloadException.java b/src/main/java/com/sumup/sdk/events/EventPayloadException.java new file mode 100644 index 0000000..59caf2b --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventPayloadException.java @@ -0,0 +1,14 @@ +package com.sumup.sdk.events; + +/** The event body could not be deserialized. */ +public class EventPayloadException extends RuntimeException { + /** Creates an exception with a description of the failure. */ + public EventPayloadException(String message) { + super(message); + } + + /** Creates an exception retaining the original failure. */ + public EventPayloadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventSignature.java b/src/main/java/com/sumup/sdk/events/EventSignature.java new file mode 100644 index 0000000..a7f2db2 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventSignature.java @@ -0,0 +1,67 @@ +package com.sumup.sdk.events; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Verifies event signatures without decoding or modifying the request body. */ +public final class EventSignature { + /** Header containing the delivery timestamp and signature. */ + public static final String HEADER_NAME = "X-SumUp-Webhook-Signature"; + + private EventSignature() {} + + /** + * Verifies the signature and enforces a five-minute delivery window in either direction. + * + * @param body original HTTP request bytes; do not reserialize the JSON + * @param signature complete signature header value + * @param secret endpoint signing secret, not an API key + * @throws EventSignatureException if the header is missing, malformed, or does not match + * @throws EventSignatureExpiredException if the delivery timestamp is outside the window + * @throws IllegalArgumentException if the secret is blank + */ + public static void verify(byte[] body, String signature, String secret) { + verify(body, signature, secret, Instant.now().getEpochSecond()); + } + + static void requireSecret(String secret) { + if (secret == null || secret.isBlank()) + throw new IllegalArgumentException("An endpoint signing secret is required."); + } + + static void verify(byte[] body, String signature, String secret, long now) { + requireSecret(secret); + var parts = signature == null ? new String[0] : signature.trim().split(",", -1); + if (parts.length != 2 || !parts[0].startsWith("t=") || !parts[1].startsWith("v1=")) + throw new EventSignatureException("Expected t=,v1=."); + var timestampText = parts[0].substring(2); + long timestamp; + byte[] digest; + try { + if (timestampText.isEmpty() + || !timestampText.chars().allMatch(c -> c >= '0' && c <= '9') + || parts[1].length() != 67) throw new IllegalArgumentException(); + timestamp = Long.parseLong(timestampText); + digest = HexFormat.of().parseHex(parts[1].substring(3)); + } catch (IllegalArgumentException cause) { + throw new EventSignatureException("Invalid signature timestamp or digest."); + } + try { + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + mac.update(("v1:" + timestampText + ":").getBytes(StandardCharsets.UTF_8)); + if (!MessageDigest.isEqual(mac.doFinal(body), digest)) + throw new EventSignatureException("The event signature does not match."); + } catch (GeneralSecurityException cause) { + throw new IllegalStateException("Cannot compute an event signature.", cause); + } + if (timestamp < now - 300 || timestamp > now + 300) + throw new EventSignatureExpiredException( + "The signature timestamp is outside the five-minute window."); + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventSignatureException.java b/src/main/java/com/sumup/sdk/events/EventSignatureException.java new file mode 100644 index 0000000..d83c989 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventSignatureException.java @@ -0,0 +1,14 @@ +package com.sumup.sdk.events; + +/** The signature is missing, malformed, or does not match the original body. */ +public class EventSignatureException extends RuntimeException { + /** Creates an exception with a description of the failure. */ + public EventSignatureException(String message) { + super(message); + } + + /** Creates an exception retaining the original failure. */ + public EventSignatureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventSignatureExpiredException.java b/src/main/java/com/sumup/sdk/events/EventSignatureExpiredException.java new file mode 100644 index 0000000..ee16d8d --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventSignatureExpiredException.java @@ -0,0 +1,14 @@ +package com.sumup.sdk.events; + +/** The signed delivery timestamp is outside the five-minute acceptance window. */ +public class EventSignatureExpiredException extends EventSignatureException { + /** Creates an exception with a description of the failure. */ + public EventSignatureExpiredException(String message) { + super(message); + } + + /** Creates an exception retaining the original failure. */ + public EventSignatureExpiredException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/events/EventsHandler.java b/src/main/java/com/sumup/sdk/events/EventsHandler.java new file mode 100644 index 0000000..203b800 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/EventsHandler.java @@ -0,0 +1,149 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.sumup.sdk.core.ApiClient; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CancellationException; + +/** + * Verifies and dispatches events to typed callbacks, using the fallback for unregistered types. + * Register callbacks before serving requests; registering again replaces the previous callback. + * Deliveries may repeat. Make processing idempotent using the event ID. + */ +public final class EventsHandler { + private final ApiClient client; + private final String secret; + private final EventCallback fallback; + private final Map> callbacks = new HashMap<>(); + + /** + * Creates a handler bound to an API client. + * + * @param client client used for resource fetches + * @param secret endpoint signing secret, not an API key + * @param fallback callback for unknown and unregistered event types + */ + public EventsHandler(ApiClient client, String secret, EventCallback fallback) { + EventSignature.requireSecret(secret); + this.client = Objects.requireNonNull(client); + this.secret = secret; + this.fallback = Objects.requireNonNull(fallback); + } + + /** + * Verifies and parses an event without invoking a callback. + * + * @param body original HTTP request bytes + * @param signature complete signature header value + * @return typed notification, or a base notification for an unknown event type + * @throws EventSignatureException if signature verification fails + * @throws EventPayloadException if the JSON cannot be deserialized + */ + public EventNotification parse(byte[] body, String signature) { + return EventNotification.parse(client, body, signature, secret); + } + + /** + * Registers the callback for {@code "members.created"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public EventsHandler onMemberCreated(EventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put( + "members.created", + event -> { + callback.handle((MemberCreatedEvent) event); + }); + return this; + } + + /** + * Registers the callback for {@code "members.deleted"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public EventsHandler onMemberDeleted(EventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put( + "members.deleted", + event -> { + callback.handle((MemberDeletedEvent) event); + }); + return this; + } + + /** + * Registers the callback for {@code "members.updated"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public EventsHandler onMemberUpdated(EventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put( + "members.updated", + event -> { + callback.handle((MemberUpdatedEvent) event); + }); + return this; + } + + /** + * Registers the callback for {@code "readers.created"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public EventsHandler onReaderCreated(EventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put( + "readers.created", + event -> { + callback.handle((ReaderCreatedEvent) event); + }); + return this; + } + + /** + * Registers the callback for {@code "readers.deleted"}, replacing any previous registration. + * + * @param callback callback to complete before acknowledging delivery + * @return this handler for chaining + */ + public EventsHandler onReaderDeleted(EventCallback callback) { + Objects.requireNonNull(callback); + callbacks.put( + "readers.deleted", + event -> { + callback.handle((ReaderDeletedEvent) event); + }); + return this; + } + + /** + * Verifies, parses, and invokes the selected callback. + * + * @param body original HTTP request bytes; do not reserialize the JSON + * @param signature complete signature header value + * @throws EventSignatureException if signature verification fails + * @throws EventPayloadException if deserialization fails + * @throws EventCallbackException if the callback fails + */ + public void handle(byte[] body, String signature) { + var event = parse(body, signature); + var callback = callbacks.getOrDefault(event.type(), fallback); + try { + callback.handle(event); + } catch (CancellationException cause) { + throw cause; + } catch (Exception cause) { + if (cause instanceof InterruptedException) Thread.currentThread().interrupt(); + throw new EventCallbackException("Event callback failed.", cause); + } + } +} diff --git a/src/main/java/com/sumup/sdk/events/FetchableEvent.java b/src/main/java/com/sumup/sdk/events/FetchableEvent.java new file mode 100644 index 0000000..1fd2d16 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/FetchableEvent.java @@ -0,0 +1,78 @@ +package com.sumup.sdk.events; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sumup.sdk.core.HttpMethod; +import com.sumup.sdk.core.RequestOptions; +import java.net.URI; +import java.net.http.HttpClient; +import java.util.concurrent.CompletableFuture; + +/** + * An event whose affected resource can be fetched using the originating client's configuration. + * + * @param resource model + */ +public abstract class FetchableEvent extends EventNotification { + abstract TypeReference resourceType(); + + /** Fetches the resource's current state. Deleted resources may return an API error. */ + public T fetchObject() { + return fetchObject(null); + } + + /** + * Fetches the current resource, rather than its state when the event occurred. + * + * @param options optional authentication, headers, and timeout overrides + * @return current resource, or null for an empty response + * @throws EventObjectException if the URL has a different origin or the HTTP client follows + * redirects + * @throws com.sumup.sdk.core.ApiException if the API request fails + */ + public T fetchObject(RequestOptions options) { + return client().send(HttpMethod.GET, resourcePath(), null, null, null, resourceType(), options); + } + + /** Fetches the current resource asynchronously. Deleted resources may return an API error. */ + public CompletableFuture fetchObjectAsync() { + return fetchObjectAsync(null); + } + + /** + * Fetches the current resource asynchronously using optional request overrides. + * + * @param options optional authentication, headers, and timeout overrides + * @return future containing the current resource, or null for an empty response + */ + public CompletableFuture fetchObjectAsync(RequestOptions options) { + return client() + .sendAsync(HttpMethod.GET, resourcePath(), null, null, null, resourceType(), options); + } + + private String resourcePath() { + if (client().redirectPolicy() != HttpClient.Redirect.NEVER) { + throw new EventObjectException( + "Event resource fetching requires an HTTP client with redirects disabled."); + } + var resource = URI.create(object().url()); + var baseUri = client().baseUri(); + if (!baseUri.getScheme().equalsIgnoreCase(resource.getScheme()) + || !baseUri.getHost().equalsIgnoreCase(resource.getHost()) + || effectivePort(resource) != effectivePort(baseUri)) { + throw new EventObjectException( + "The event resource URL must have the same origin as the API client."); + } + var path = resource.getRawPath(); + // A leading // would be interpreted as another authority by URI.resolve. + if (path.startsWith("//")) { + throw new EventObjectException("The event resource path must not start with //."); + } + return path + (resource.getRawQuery() == null ? "" : "?" + resource.getRawQuery()); + } + + private static int effectivePort(URI uri) { + return uri.getPort() != -1 + ? uri.getPort() + : ("https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80); + } +} diff --git a/src/main/java/com/sumup/sdk/events/MemberCreatedEvent.java b/src/main/java/com/sumup/sdk/events/MemberCreatedEvent.java new file mode 100644 index 0000000..4eec0b9 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/MemberCreatedEvent.java @@ -0,0 +1,13 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sumup.sdk.models.Member; + +/** Sent when a member is created, invited, or accepts an invitation for a merchant account. */ +public final class MemberCreatedEvent extends FetchableEvent { + @Override + TypeReference resourceType() { + return new TypeReference<>() {}; + } +} diff --git a/src/main/java/com/sumup/sdk/events/MemberDeletedEvent.java b/src/main/java/com/sumup/sdk/events/MemberDeletedEvent.java new file mode 100644 index 0000000..b9018b0 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/MemberDeletedEvent.java @@ -0,0 +1,13 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sumup.sdk.models.Member; + +/** Sent when a member is deleted from a merchant account. */ +public final class MemberDeletedEvent extends FetchableEvent { + @Override + TypeReference resourceType() { + return new TypeReference<>() {}; + } +} diff --git a/src/main/java/com/sumup/sdk/events/MemberUpdatedEvent.java b/src/main/java/com/sumup/sdk/events/MemberUpdatedEvent.java new file mode 100644 index 0000000..c0f80b0 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/MemberUpdatedEvent.java @@ -0,0 +1,13 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sumup.sdk.models.Member; + +/** Sent when a member is updated, disabled, rejected, or expires for a merchant account. */ +public final class MemberUpdatedEvent extends FetchableEvent { + @Override + TypeReference resourceType() { + return new TypeReference<>() {}; + } +} diff --git a/src/main/java/com/sumup/sdk/events/ReaderCreatedEvent.java b/src/main/java/com/sumup/sdk/events/ReaderCreatedEvent.java new file mode 100644 index 0000000..e74cf16 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/ReaderCreatedEvent.java @@ -0,0 +1,15 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sumup.sdk.models.Reader; + +/** + * Sent when a reader is paired to a merchant account and becomes available through the Readers API. + */ +public final class ReaderCreatedEvent extends FetchableEvent { + @Override + TypeReference resourceType() { + return new TypeReference<>() {}; + } +} diff --git a/src/main/java/com/sumup/sdk/events/ReaderDeletedEvent.java b/src/main/java/com/sumup/sdk/events/ReaderDeletedEvent.java new file mode 100644 index 0000000..42006f6 --- /dev/null +++ b/src/main/java/com/sumup/sdk/events/ReaderDeletedEvent.java @@ -0,0 +1,16 @@ +// Code generated by sumup-java/codegen. DO NOT EDIT. +package com.sumup.sdk.events; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.sumup.sdk.models.Reader; + +/** + * Sent when a reader is unpaired from a merchant account and is no longer available through the + * Readers API. + */ +public final class ReaderDeletedEvent extends FetchableEvent { + @Override + TypeReference resourceType() { + return new TypeReference<>() {}; + } +} diff --git a/src/test/java/com/sumup/sdk/events/EventsTest.java b/src/test/java/com/sumup/sdk/events/EventsTest.java new file mode 100644 index 0000000..db19cbc --- /dev/null +++ b/src/test/java/com/sumup/sdk/events/EventsTest.java @@ -0,0 +1,318 @@ +package com.sumup.sdk.events; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sumup.sdk.SumUpAsyncClient; +import com.sumup.sdk.SumUpClient; +import com.sumup.sdk.core.ApiException; +import com.sumup.sdk.core.RequestOptions; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HexFormat; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +final class EventsTest { + private static final String SECRET = "test_secret"; + private static final long NOW = 1788600000; + private static final SumUpClient CLIENT = new SumUpClient("test_key"); + + private static byte[] bytes(String body) { + return body.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] body(String type) { + return bytes( + "{\"id\":\"evt_123\",\"type\":\"" + + type + + "\",\"created_at\":\"2026-09-05T09:30:00Z\",\"object\":{\"id\":\"123\",\"type\":\"member\"}}"); + } + + private static String sign(byte[] body) throws Exception { + return sign(body, Long.toString(Instant.now().getEpochSecond())); + } + + private static String sign(byte[] body, String timestamp) throws Exception { + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(bytes(SECRET), "HmacSHA256")); + mac.update(bytes("v1:" + timestamp + ":")); + return "t=" + timestamp + ",v1=" + HexFormat.of().formatHex(mac.doFinal(body)); + } + + @ParameterizedTest + @ValueSource(longs = {-300, 0, 300}) + void verifiesBoundariesAndTimestampText(long offset) throws Exception { + var body = bytes("héllo"); + var signature = sign(body, "00" + (NOW + offset)); + EventSignature.verify(body, " " + signature + " ", SECRET, NOW); + EventSignature.verify( + body, + signature.substring(0, signature.indexOf("v1=")) + + "v1=" + + signature.substring(signature.indexOf("v1=") + 3).toUpperCase(), + SECRET, + NOW); + } + + @ParameterizedTest + @ValueSource(longs = {-301, 301}) + void rejectsExpiredSignatures(long offset) throws Exception { + var body = body("members.created"); + var signature = sign(body, Long.toString(NOW + offset)); + assertThrows( + EventSignatureExpiredException.class, + () -> EventSignature.verify(body, signature, SECRET, NOW)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "", + "v1=ab", + "t=1,v1=ab", + "t=-1,v1=ab", + "t=+1,v1=ab", + "t=1, v1=ab", + "t=1,v1=ab,t=1", + "v1=ab,t=1" + }) + void rejectsMalformedHeaders(String header) { + assertThrows( + EventSignatureException.class, + () -> EventSignature.verify(bytes("{}"), header, SECRET, NOW)); + } + + @Test + void rejectsMissingHeadersChangedBytesAndWrongSecrets() throws Exception { + var body = body("members.created"); + var signature = sign(body); + assertThrows(EventSignatureException.class, () -> EventSignature.verify(body, null, SECRET)); + assertThrows( + EventSignatureException.class, () -> EventSignature.verify(body, signature, "other")); + body[0] ^= 1; + assertThrows( + EventSignatureException.class, () -> EventSignature.verify(body, signature, SECRET)); + assertThrows(IllegalArgumentException.class, () -> EventSignature.verify(body, signature, "")); + var overflow = sign(body, "9223372036854775808"); + assertThrows( + EventSignatureException.class, () -> EventSignature.verify(body, overflow, SECRET)); + var future = sign(body, Long.toString(Long.MAX_VALUE)); + assertThrows( + EventSignatureExpiredException.class, () -> EventSignature.verify(body, future, SECRET)); + } + + @Test + void parsesAllKnownTypesAndUnknownEvents() throws Exception { + String[] names = { + "members.created", + "members.updated", + "members.deleted", + "readers.created", + "readers.deleted", + "future.event" + }; + Class[] types = { + MemberCreatedEvent.class, + MemberUpdatedEvent.class, + MemberDeletedEvent.class, + ReaderCreatedEvent.class, + ReaderDeletedEvent.class, + EventNotification.class + }; + for (int i = 0; i < names.length; i++) { + var body = body(names[i]); + var event = CLIENT.parseEventNotification(body, sign(body), SECRET); + assertEquals(types[i], event.getClass()); + assertEquals(names[i], event.type()); + assertEquals("evt_123", event.id()); + assertEquals(2026, event.createdAt().getYear()); + assertEquals(types[i], CLIENT.parseEventNotificationWithoutVerification(body).getClass()); + } + assertNotNull(CLIENT.parseEventNotificationWithoutVerification(bytes("{}"))); + assertInstanceOf( + MemberUpdatedEvent.class, + CLIENT.parseEventNotificationWithoutVerification( + bytes("{\"type\":\"members.updated\",\"object\":{\"type\":\"future\"}}"))); + } + + @ParameterizedTest + @ValueSource(strings = {"null", "[]", "{} {}", "{", "{\"created_at\":\"invalid\"}"}) + void reportsInvalidJson(String json) { + assertThrows( + EventPayloadException.class, + () -> CLIENT.parseEventNotificationWithoutVerification(bytes(json))); + assertThrows( + EventSignatureException.class, + () -> CLIENT.parseEventNotification(bytes(json), null, SECRET)); + } + + @Test + void dispatchesReplacesAndFallsBack() throws Exception { + var calls = new AtomicInteger(); + var fallback = new AtomicInteger(); + var handler = CLIENT.eventsHandler(SECRET, event -> fallback.incrementAndGet()); + handler.onMemberUpdated( + event -> { + fail("replaced callback"); + }); + handler.onMemberUpdated(event -> calls.incrementAndGet()); + for (var type : new String[] {"members.updated", "members.created", "future.event"}) { + var body = body(type); + handler.handle(body, sign(body)); + } + assertEquals(1, calls.get()); + assertEquals(2, fallback.get()); + var cause = new Exception("failure"); + handler.onMemberUpdated( + event -> { + throw cause; + }); + var body = body("members.updated"); + var signature = sign(body); + assertSame( + cause, + assertThrows(EventCallbackException.class, () -> handler.handle(body, signature)) + .getCause()); + } + + @Test + void asyncHandlingWaitsAndPreservesFailures() throws Exception { + var completion = new CompletableFuture(); + var handler = + new SumUpAsyncClient("key") + .eventsHandler(SECRET, event -> CompletableFuture.completedFuture(null)); + handler.onMemberUpdated(event -> completion); + var body = body("members.updated"); + var result = handler.handleAsync(body, sign(body)); + assertFalse(result.isDone()); + completion.complete(null); + result.join(); + var cause = new IllegalStateException("failure"); + handler.onMemberUpdated(event -> CompletableFuture.failedFuture(cause)); + var failed = handler.handleAsync(body, sign(body)); + assertSame( + cause, + assertInstanceOf( + EventCallbackException.class, + assertThrows(CompletionException.class, failed::join).getCause()) + .getCause()); + handler.onMemberUpdated( + event -> { + throw cause; + }); + assertInstanceOf( + EventCallbackException.class, + assertThrows(CompletionException.class, () -> handler.handleAsync(body, sign(body)).join()) + .getCause()); + handler.onMemberUpdated(event -> CompletableFuture.failedFuture(new CancellationException())); + var cancelled = handler.handleAsync(body, sign(body)); + assertInstanceOf( + CancellationException.class, + assertThrows(CompletionException.class, cancelled::join).getCause()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "https://evil.example/member", + "http://api.sumup.com/member", + "https://api.sumup.com:444/member", + "/member", + "file:///member", + "https://api.sumup.com.evil.example/member", + "https://api.sumup.com//evil.example/member" + }) + void rejectsForeignResourceOrigins(String url) { + var event = resource(CLIENT, url); + assertThrows(EventObjectException.class, event::fetchObject); + assertThrows(EventObjectException.class, event::fetchObjectAsync); + } + + @Test + void rejectsRedirectFollowingTransports() { + var client = + SumUpClient.builder() + .httpClient( + java.net.http.HttpClient.newBuilder() + .followRedirects(java.net.http.HttpClient.Redirect.ALWAYS) + .build()) + .build(); + assertThrows( + EventObjectException.class, + () -> resource(client, "https://api.sumup.com/member").fetchObject()); + } + + private static MemberUpdatedEvent resource(SumUpClient client, String url) { + return (MemberUpdatedEvent) + client.parseEventNotificationWithoutVerification( + bytes("{\"type\":\"members.updated\",\"object\":{\"url\":\"" + url + "\"}}")); + } + + @Test + void fetchesUsingConfiguredTransportAndPreservesEscapes() throws Exception { + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + var requests = new AtomicInteger(); + server.createContext( + "/", + exchange -> { + try { + assertEquals( + requests.get() == 0 ? "Bearer key" : "Bearer override", + exchange.getRequestHeaders().getFirst("Authorization")); + assertEquals("/member%2F123?expand=a%2Fb", exchange.getRequestURI().toString()); + requests.incrementAndGet(); + var payload = bytes("{\"id\":\"123\"}"); + exchange.sendResponseHeaders(200, payload.length); + exchange.getResponseBody().write(payload); + } finally { + exchange.close(); + } + }); + server.createContext( + "/missing", + exchange -> { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + }); + server.start(); + try { + var origin = "http://127.0.0.1:" + server.getAddress().getPort(); + var client = SumUpClient.builder().accessToken("key").baseUri(origin).build(); + var event = + resource( + client, + origin.replace("http://", "http://user:pass@") + + "/member%2F123?expand=a%2Fb#ignored"); + assertEquals("123", event.fetchObject().id()); + assertEquals( + "123", + event + .fetchObjectAsync( + RequestOptions.builder().authorizationHeader("Bearer override").build()) + .join() + .id()); + assertEquals(2, requests.get()); + assertEquals( + 404, + assertThrows( + ApiException.class, () -> resource(client, origin + "/missing").fetchObject()) + .getStatusCode()); + var failure = + assertThrows( + CompletionException.class, + () -> resource(client, origin + "/missing").fetchObjectAsync().join()); + assertInstanceOf(ApiException.class, failure.getCause()); + } finally { + server.stop(0); + } + } +}