Skip to content

Commit 9d1af9e

Browse files
committed
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.
1 parent fe68f2f commit 9d1af9e

36 files changed

Lines changed: 1901 additions & 37 deletions

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,39 @@ readerIdFuture
256256
.join();
257257
```
258258

259+
## Handling events
260+
261+
```java
262+
var events = client.eventsHandler(secret, event ->
263+
System.out.printf("Unhandled event %s: %s%n", event.id(), event.type()));
264+
265+
events.onMemberUpdated(event -> {
266+
var member = event.fetchObject();
267+
System.out.printf("Member updated: %s%n", member.id());
268+
});
269+
270+
events.handle(rawBody, signatureHeader);
271+
```
272+
273+
Pass the original request bytes and the `X-SumUp-Webhook-Signature` header.
274+
The SDK verifies the signature and its fixed five-minute delivery window before processing.
275+
Register callbacks before serving requests and acknowledge delivery only after processing succeeds.
276+
Deliveries may repeat; use event IDs to deduplicate processing.
277+
278+
`SumUpAsyncClient.eventsHandler` accepts callbacks returning a `CompletionStage<Void>`.
279+
Call `handleAsync` and wait for its future to complete before acknowledging delivery.
280+
Use `event.fetchObjectAsync()` for asynchronous resource fetches.
281+
282+
For manual dispatch, use `client.parseEventNotification(rawBody, signatureHeader, secret)`
283+
and match the notification type. Unknown event types remain available as `EventNotification`.
284+
Resource fetches require an HTTP client with redirects disabled (the default).
285+
`fetchObject` retrieves the resource's current state; deleted resources may return an API error.
286+
287+
See the [standalone HTTP-server example](examples/events) for a complete receiver.
288+
259289
## Examples
260290

291+
- [examples/events](examples/events) – receives signed events using the JDK HTTP server.
261292
- `examples/basic` – lists recent checkouts to verify that your API token works.
262293
- `examples/card-reader-checkout` – lists paired readers and creates a €10 checkout on the first available device.
263294

codegen/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ just generate-codesamples
3333
```
3434

3535
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.
36+
37+
Event classes, notification parsing, and callback registration methods are generated from
38+
OpenAPI 3.1 `webhooks` entries and their `x-object` references. Signature verification
39+
and resource-fetching support live in the handwritten `events` runtime.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package generator
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"sort"
9+
"strconv"
10+
"strings"
11+
12+
v3 "github.com/pb33f/libopenapi/datamodel/high/v3"
13+
)
14+
15+
type eventData struct{ Name, Type, Description, Model, Package string }
16+
17+
func renderEvents(doc *v3.Document, params Params) error {
18+
events := []eventData{}
19+
if doc.Webhooks != nil {
20+
for eventType, path := range doc.Webhooks.FromOldest() {
21+
if path == nil || path.Post == nil {
22+
continue
23+
}
24+
op := path.Post
25+
var object struct {
26+
Ref string `yaml:"$ref"`
27+
}
28+
if op.Extensions == nil || op.Extensions.GetOrZero("x-object") == nil {
29+
return fmt.Errorf("event %s: missing x-object", eventType)
30+
}
31+
if err := op.Extensions.GetOrZero("x-object").Decode(&object); err != nil {
32+
return fmt.Errorf("event %s: decode object: %w", eventType, err)
33+
}
34+
model := strings.TrimPrefix(object.Ref, "#/components/schemas/")
35+
if model == object.Ref || doc.Components == nil || doc.Components.Schemas.GetOrZero(model) == nil || op.OperationId == "" {
36+
return fmt.Errorf("event %s: invalid object reference or operation ID", eventType)
37+
}
38+
description := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "*/", "*&#47;").Replace(op.Description)
39+
events = append(events, eventData{pascalCase(strings.TrimSuffix(op.OperationId, "Webhook"), ""), strconv.Quote(eventType), description, pascalCase(model, ""), params.BasePackage})
40+
}
41+
}
42+
sort.Slice(events, func(i, j int) bool { return events[i].Type < events[j].Type })
43+
dir := filepath.Join(params.OutputDir, params.basePackagePath(), "events")
44+
if err := os.MkdirAll(dir, 0o755); err != nil {
45+
return fmt.Errorf("create events directory: %w", err)
46+
}
47+
entries, err := os.ReadDir(dir)
48+
if err != nil {
49+
return fmt.Errorf("read events directory: %w", err)
50+
}
51+
for _, entry := range entries {
52+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".java") {
53+
continue
54+
}
55+
path := filepath.Join(dir, entry.Name())
56+
content, err := os.ReadFile(path)
57+
if err != nil {
58+
return fmt.Errorf("read generated event: %w", err)
59+
}
60+
if bytes.HasPrefix(content, []byte("// Code generated by sumup-java/codegen. DO NOT EDIT.")) {
61+
if err := os.Remove(path); err != nil {
62+
return fmt.Errorf("remove generated event: %w", err)
63+
}
64+
}
65+
}
66+
write := func(name, templateName string, data any) error {
67+
tmpl, err := loadTemplate(templateName)
68+
if err != nil {
69+
return err
70+
}
71+
var output bytes.Buffer
72+
if err := tmpl.Execute(&output, data); err != nil {
73+
return fmt.Errorf("render event %s: %w", name, err)
74+
}
75+
if err := os.WriteFile(filepath.Join(dir, name+".java"), output.Bytes(), 0o644); err != nil {
76+
return fmt.Errorf("write event %s: %w", name, err)
77+
}
78+
return nil
79+
}
80+
for _, event := range events {
81+
if err := write(event.Name+"Event", "event.tmpl", event); err != nil {
82+
return err
83+
}
84+
}
85+
data := struct {
86+
Package string
87+
Events []eventData
88+
Async bool
89+
Class string
90+
}{params.BasePackage, events, false, "EventsHandler"}
91+
if err := write("EventNotification", "event_notification.tmpl", data); err != nil {
92+
return err
93+
}
94+
if err := write(data.Class, "events_handler.tmpl", data); err != nil {
95+
return err
96+
}
97+
data.Async = true
98+
data.Class = "AsyncEventsHandler"
99+
return write(data.Class, "events_handler.tmpl", data)
100+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package generator
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestRenderEvents(t *testing.T) {
11+
t.Parallel()
12+
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"}}}}}}`
13+
path := filepath.Join(t.TempDir(), "openapi.json")
14+
if err := os.WriteFile(path, []byte(spec), 0o644); err != nil {
15+
t.Fatal(err)
16+
}
17+
doc, err := loadDocument(path)
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
params := Params{OutputDir: t.TempDir(), BasePackage: "com.test.sdk"}
22+
if err := renderEvents(doc, params); err != nil {
23+
t.Fatal(err)
24+
}
25+
dir := filepath.Join(params.OutputDir, "com/test/sdk/events")
26+
for file, expected := range map[string]string{
27+
"WidgetUpdatedEvent.java": "extends FetchableEvent<Widget>",
28+
"EventsHandler.java": "onWidgetUpdated(EventCallback<WidgetUpdatedEvent>",
29+
"AsyncEventsHandler.java": "onWidgetUpdated(AsyncEventCallback<WidgetUpdatedEvent>",
30+
"EventNotification.java": `case "widgets.updated" -> WidgetUpdatedEvent.class`,
31+
} {
32+
content, err := os.ReadFile(filepath.Join(dir, file))
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
if !strings.Contains(string(content), expected) {
37+
t.Errorf("%s missing %q", file, expected)
38+
}
39+
if err := renderEvents(doc, params); err != nil {
40+
t.Fatal(err)
41+
}
42+
again, err := os.ReadFile(filepath.Join(dir, file))
43+
if err != nil {
44+
t.Fatal(err)
45+
}
46+
if string(content) != string(again) {
47+
t.Errorf("%s is not deterministic", file)
48+
}
49+
}
50+
runtime := filepath.Join(dir, "EventSignature.java")
51+
if err := os.WriteFile(runtime, []byte("// Handwritten runtime"), 0o644); err != nil {
52+
t.Fatal(err)
53+
}
54+
55+
doc.Webhooks.GetOrZero("widgets.updated").Post.OperationId = ""
56+
if err := renderEvents(doc, params); err == nil {
57+
t.Fatal("expected error for missing operation ID")
58+
}
59+
doc.Webhooks = nil
60+
if err := renderEvents(doc, params); err != nil {
61+
t.Fatal(err)
62+
}
63+
if _, err := os.Stat(filepath.Join(dir, "WidgetUpdatedEvent.java")); !os.IsNotExist(err) {
64+
t.Fatalf("obsolete event still exists: %v", err)
65+
}
66+
if _, err := os.Stat(runtime); err != nil {
67+
t.Fatalf("handwritten runtime was removed: %v", err)
68+
}
69+
70+
}

codegen/internal/generator/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func Run(ctx context.Context, params Params) error {
5151
return err
5252
}
5353

54-
return nil
54+
return renderEvents(doc, params)
5555
}
5656

5757
// loadDocument reads and parses the OpenAPI specification into the pbo33f
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Code generated by sumup-java/codegen. DO NOT EDIT.
2+
package {{.Package}}.events;
3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import {{.Package}}.models.{{.Model}};
5+
/** {{.Description}} */
6+
public final class {{.Name}}Event extends FetchableEvent<{{.Model}}> {
7+
@Override TypeReference<{{.Model}}> resourceType() { return new TypeReference<>() {}; }
8+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Code generated by sumup-java/codegen. DO NOT EDIT.
2+
package {{.Package}}.events;
3+
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import {{.Package}}.core.ApiClient;
6+
import com.fasterxml.jackson.databind.DeserializationFeature;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
9+
import java.io.IOException;
10+
import java.time.OffsetDateTime;
11+
12+
/** An event notification, also used for event types introduced after this SDK release. */
13+
public class EventNotification {
14+
private static final ObjectMapper MAPPER =
15+
new ObjectMapper()
16+
.registerModule(new JavaTimeModule())
17+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
18+
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
19+
20+
21+
@JsonProperty("id")
22+
private String id;
23+
24+
@JsonProperty("type")
25+
private String type;
26+
27+
@JsonProperty("created_at")
28+
private OffsetDateTime createdAt;
29+
30+
@JsonProperty("object")
31+
private EventObjectReference object;
32+
33+
private ApiClient client;
34+
35+
/** Returns the event ID. Use it to deduplicate deliveries. */
36+
public String id() {
37+
return id;
38+
}
39+
40+
/** Returns the event name, such as members.updated. */
41+
public String type() {
42+
return type;
43+
}
44+
45+
/**
46+
* Returns when the event occurred; signature verification uses the delivery timestamp instead.
47+
*/
48+
public OffsetDateTime createdAt() {
49+
return createdAt;
50+
}
51+
52+
/** Returns the reference to the affected resource. */
53+
public EventObjectReference object() {
54+
return object;
55+
}
56+
57+
ApiClient client() {
58+
if (client == null)
59+
throw new EventObjectException(
60+
"Parse the event through a SumUp client before fetching its resource.");
61+
return client;
62+
}
63+
64+
/**
65+
* Verifies and deserializes an event using the supplied API client for resource fetches.
66+
* Prefer the client's {@code parseEventNotification} method when handling incoming requests.
67+
* @param client client used to fetch affected resources
68+
* @param body unmodified HTTP request bytes
69+
* @param signature complete signature header value
70+
* @param secret endpoint signing secret, not an API key
71+
* @return typed notification, or a base notification for an unknown type
72+
* @throws EventSignatureException if verification fails
73+
* @throws EventPayloadException if deserialization fails
74+
*/
75+
public static EventNotification parse(
76+
ApiClient client, byte[] body, String signature, String secret) {
77+
EventSignature.verify(body, signature, secret);
78+
return parseBody(client, body);
79+
}
80+
81+
/**
82+
* Parses an already verified payload from trusted storage. Never use directly on incoming
83+
* requests.
84+
* @param client client used to fetch affected resources
85+
* @param body JSON event body
86+
* @return notification bound to the supplied client
87+
* @throws EventPayloadException if deserialization fails
88+
*/
89+
public static EventNotification parseWithoutVerification(ApiClient client, byte[] body) {
90+
return parseBody(client, body);
91+
}
92+
93+
private static EventNotification parseBody(ApiClient client, byte[] body) {
94+
try {
95+
var root = MAPPER.readTree(body);
96+
if (root == null || !root.isObject())
97+
throw new EventPayloadException("Expected a JSON object.");
98+
Class<? extends EventNotification> type = switch (root.path("type").asText("")) {
99+
{{range .Events}} case {{.Type}} -> {{.Name}}Event.class;
100+
{{end}} default -> EventNotification.class;
101+
};
102+
EventNotification event = MAPPER.treeToValue(root, type);
103+
event.client = client;
104+
return event;
105+
} catch (IOException cause) {
106+
throw new EventPayloadException("Cannot deserialize the event body.", cause);
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)