Skip to content

Commit bbce86c

Browse files
author
Heinrich Neb
committed
feat: implement PubSubClient + WorkflowClient (SSE subscribe, full HTTP)
1 parent 40f6501 commit bbce86c

3 files changed

Lines changed: 398 additions & 19 deletions

File tree

src/main/java/dev/cachly/CachlyClient.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ public Iterator<String> streamGet(String key) {
579579
*/
580580
public PubSubClient pubsub() {
581581
if (pubsubUrl == null) throw new CachlyException("pubsub() requires pubsubUrl.");
582-
return new PubSubClient(pubsubUrl);
582+
return new PubSubClient(pubsubUrl, HttpClient.newHttpClient(), MAPPER);
583583
}
584584

585585
/**
@@ -588,7 +588,7 @@ public PubSubClient pubsub() {
588588
*/
589589
public WorkflowClient workflow() {
590590
if (workflowUrl == null) throw new CachlyException("workflow() requires workflowUrl.");
591-
return new WorkflowClient(workflowUrl);
591+
return new WorkflowClient(workflowUrl, HttpClient.newHttpClient(), MAPPER);
592592
}
593593

594594
/**
Lines changed: 190 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,201 @@
11
package dev.cachly;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
6+
import java.io.BufferedReader;
7+
import java.io.InputStreamReader;
8+
import java.net.URI;
9+
import java.net.http.HttpClient;
10+
import java.net.http.HttpRequest;
11+
import java.net.http.HttpResponse;
12+
import java.time.Duration;
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.function.Consumer;
17+
318
/**
4-
* Pub/Sub client for real-time messaging via the cachly API (feature U).
19+
* Pub/Sub client for real-time messaging via the cachly API.
520
* Obtain via {@link CachlyClient#pubsub()}.
6-
*
7-
* <p><em>Note:</em> Full streaming SSE subscription requires a separate implementation
8-
* or can be handled via server-sent events with an appropriate HTTP client.
921
*/
1022
public class PubSubClient {
1123

12-
private final String url;
24+
// ── Inner types ───────────────────────────────────────────────────────────
1325

14-
public PubSubClient(String url) {
15-
this.url = url;
26+
/** Result of a publish operation. */
27+
public static final class PublishResult {
28+
public final String channel;
29+
public final int receivers;
30+
public final String at;
31+
32+
public PublishResult(String channel, int receivers, String at) {
33+
this.channel = channel;
34+
this.receivers = receivers;
35+
this.at = at;
36+
}
1637
}
1738

18-
// TODO: Implement publish, subscribe, channels, stats methods
19-
// For now, this is a placeholder to satisfy the compilation.
20-
}
39+
/** Information about a single active channel. */
40+
public static final class ChannelInfo {
41+
public final String name;
42+
public final int subscribers;
43+
44+
public ChannelInfo(String name, int subscribers) {
45+
this.name = name;
46+
this.subscribers = subscribers;
47+
}
48+
}
49+
50+
/** Aggregate pub/sub statistics. */
51+
public static final class StatsResult {
52+
public final int activeChannels;
53+
public final int totalSubscribers;
54+
public final int errors;
55+
56+
public StatsResult(int activeChannels, int totalSubscribers, int errors) {
57+
this.activeChannels = activeChannels;
58+
this.totalSubscribers = totalSubscribers;
59+
this.errors = errors;
60+
}
61+
}
62+
63+
/** A single message received via SSE subscription. */
64+
public static final class Message {
65+
public final String channel;
66+
public final String message;
67+
public final String at;
68+
69+
public Message(String channel, String message, String at) {
70+
this.channel = channel;
71+
this.message = message;
72+
this.at = at;
73+
}
74+
}
75+
76+
// ── Implementation ────────────────────────────────────────────────────────
77+
78+
private final String pubsubUrl;
79+
private final HttpClient http;
80+
private final ObjectMapper mapper;
2181

82+
PubSubClient(String pubsubUrl, HttpClient http, ObjectMapper mapper) {
83+
this.pubsubUrl = pubsubUrl;
84+
this.http = http;
85+
this.mapper = mapper;
86+
}
87+
88+
/**
89+
* Convenience constructor used when no shared HttpClient / ObjectMapper exists.
90+
* @deprecated Prefer the constructor with shared HttpClient via {@link CachlyClient#pubsub()}.
91+
*/
92+
PubSubClient(String pubsubUrl) {
93+
this(pubsubUrl,
94+
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(),
95+
new ObjectMapper());
96+
}
97+
98+
private JsonNode httpSync(String method, String url, Object body) throws CachlyException {
99+
try {
100+
HttpRequest.Builder b = HttpRequest.newBuilder()
101+
.uri(URI.create(url))
102+
.timeout(Duration.ofSeconds(10))
103+
.header("Accept", "application/json");
104+
if (body != null) {
105+
String json = mapper.writeValueAsString(body);
106+
b.header("Content-Type", "application/json")
107+
.method(method, HttpRequest.BodyPublishers.ofString(json));
108+
} else {
109+
b.method(method, HttpRequest.BodyPublishers.noBody());
110+
}
111+
HttpResponse<String> resp = http.send(b.build(), HttpResponse.BodyHandlers.ofString());
112+
if (resp.statusCode() >= 400)
113+
throw new CachlyException("cachly pubsub " + method + " " + url + " → " + resp.statusCode());
114+
String rb = resp.body();
115+
return (rb == null || rb.isEmpty()) ? mapper.createObjectNode() : mapper.readTree(rb);
116+
} catch (CachlyException e) {
117+
throw e;
118+
} catch (Exception e) {
119+
throw new CachlyException("cachly pubsub request failed: " + method + " " + url, e);
120+
}
121+
}
122+
123+
/**
124+
* Publish a message to a channel.
125+
* {@code POST {pubsubUrl}/publish}
126+
*/
127+
public PublishResult publish(String channel, String message) throws CachlyException {
128+
JsonNode n = httpSync("POST", pubsubUrl + "/publish",
129+
Map.of("channel", channel, "message", message));
130+
return new PublishResult(
131+
n.path("channel").asText(channel),
132+
n.path("receivers").asInt(0),
133+
n.path("at").asText(""));
134+
}
135+
136+
/**
137+
* Subscribe to one or more channels via SSE (blocking, calls {@code consumer} for each message).
138+
* Returns when the stream closes or an error occurs.
139+
* {@code POST {pubsubUrl}/subscribe}
140+
*/
141+
public void subscribe(List<String> channels, Consumer<Message> consumer) throws CachlyException {
142+
try {
143+
String bodyJson = mapper.writeValueAsString(Map.of("channels", channels));
144+
HttpRequest req = HttpRequest.newBuilder()
145+
.uri(URI.create(pubsubUrl + "/subscribe"))
146+
.header("Content-Type", "application/json")
147+
.header("Accept", "text/event-stream")
148+
.POST(HttpRequest.BodyPublishers.ofString(bodyJson))
149+
.timeout(Duration.ofSeconds(300))
150+
.build();
151+
HttpResponse<java.io.InputStream> resp =
152+
http.send(req, HttpResponse.BodyHandlers.ofInputStream());
153+
if (resp.statusCode() >= 400)
154+
throw new CachlyException("cachly pubsub subscribe → " + resp.statusCode());
155+
try (BufferedReader reader = new BufferedReader(new InputStreamReader(resp.body()))) {
156+
String line;
157+
while ((line = reader.readLine()) != null) {
158+
if (!line.startsWith("data:")) continue;
159+
String data = line.substring(5).trim();
160+
if (data.isEmpty() || data.equals("{}")) continue;
161+
try {
162+
JsonNode node = mapper.readTree(data);
163+
consumer.accept(new Message(
164+
node.path("channel").asText(""),
165+
node.path("message").asText(""),
166+
node.path("at").asText("")));
167+
} catch (Exception ignored) { /* skip malformed events */ }
168+
}
169+
}
170+
} catch (CachlyException e) {
171+
throw e;
172+
} catch (Exception e) {
173+
throw new CachlyException("cachly pubsub subscribe failed", e);
174+
}
175+
}
176+
177+
/**
178+
* List active channels.
179+
* {@code GET {pubsubUrl}/channels}
180+
*/
181+
public List<ChannelInfo> channels() throws CachlyException {
182+
JsonNode n = httpSync("GET", pubsubUrl + "/channels", null);
183+
List<ChannelInfo> result = new ArrayList<>();
184+
JsonNode arr = n.isArray() ? n : n.path("channels");
185+
for (JsonNode ch : arr)
186+
result.add(new ChannelInfo(ch.path("name").asText(""), ch.path("subscribers").asInt(0)));
187+
return result;
188+
}
189+
190+
/**
191+
* Pub/Sub aggregate statistics.
192+
* {@code GET {pubsubUrl}/stats}
193+
*/
194+
public StatsResult stats() throws CachlyException {
195+
JsonNode n = httpSync("GET", pubsubUrl + "/stats", null);
196+
return new StatsResult(
197+
n.path("active_channels").asInt(0),
198+
n.path("total_subscribers").asInt(0),
199+
n.path("errors").asInt(0));
200+
}
201+
}

0 commit comments

Comments
 (0)