Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void>`.
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.

Expand Down
4 changes: 4 additions & 0 deletions codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
100 changes: 100 additions & 0 deletions codegen/internal/generator/events.go
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", "*/", "*&#47;").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)
}
70 changes: 70 additions & 0 deletions codegen/internal/generator/events_test.go
Original file line number Diff line number Diff line change
@@ -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<Widget>",
"EventsHandler.java": "onWidgetUpdated(EventCallback<WidgetUpdatedEvent>",
"AsyncEventsHandler.java": "onWidgetUpdated(AsyncEventCallback<WidgetUpdatedEvent>",
"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)
}

}
2 changes: 1 addition & 1 deletion codegen/internal/generator/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions codegen/internal/generator/templates/event.tmpl
Original file line number Diff line number Diff line change
@@ -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<>() {}; }
}
109 changes: 109 additions & 0 deletions codegen/internal/generator/templates/event_notification.tmpl
Original file line number Diff line number Diff line change
@@ -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<? extends EventNotification> 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);
}
}
}
Loading
Loading