Skip to content
Open
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
23 changes: 23 additions & 0 deletions java-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# PubSub WebSocket Java SDK

This directory contains a minimal Java reimplementation of the TypeScript SDK provided in this repository. It exposes helper classes to generate JWT tokens and interact with Synternet's NATS servers over WebSockets. The API mirrors the TypeScript version so it can be easily used from SARL.

## Building

A Maven `pom.xml` is provided. Run `mvn package` to build the library. Maven will download the required dependencies from Maven Central.

## Basic Usage

```java
NatsConfig config = new NatsConfig("wss://url.com:443");
UserJwt.JwtWithSeed creds = UserJwt.createAppJwt(developerSeed);
PubSub.connect(config, creds.jwt(), creds.userSeed());

PubSub.subscribe(config, "example.subject", messages -> {
for (Message m : messages) {
System.out.println(m.getSubject() + " -> " + m.getData());
}
}, Throwable::printStackTrace);
```

See the Java source files for more details.
34 changes: 34 additions & 0 deletions java-sdk/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.synternet</groupId>
<artifactId>pubsubws-java</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>PubSub WebSocket Java SDK</name>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>io.nats</groupId>
<artifactId>jnats</artifactId>
<version>2.21.1</version>
</dependency>
<dependency>
<groupId>io.nats</groupId>
<artifactId>nkeys-java</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.16.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
</dependencies>
</project>
19 changes: 19 additions & 0 deletions java-sdk/src/main/java/com/synternet/pubsubws/JsonUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.synternet.pubsubws;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

/** Simple wrapper around Jackson's ObjectMapper. */
public final class JsonUtil {
private static final ObjectMapper mapper = new ObjectMapper();

private JsonUtil() {}

public static String toJson(Object obj) {
try {
return mapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize JSON", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.synternet.pubsubws;

import io.nats.client.AuthHandler;
import io.nats.nkey.NKey;

import java.io.IOException;
import java.security.GeneralSecurityException;

/** AuthHandler that uses a JWT and NKey seed for NATS connections. */
public class JwtNkeyAuthHandler implements AuthHandler {
private final String jwt;
private final String seed;

public JwtNkeyAuthHandler(String jwt, String seed) {
this.jwt = jwt;
this.seed = seed;
}

@Override
public byte[] sign(byte[] nonce) {
try {
NKey nkey = NKey.fromSeed(seed.toCharArray());
return nkey.sign(nonce);
} catch (GeneralSecurityException | IOException e) {
throw new RuntimeException("Failed to sign nonce", e);
}
}

@Override
public char[] getID() {
try {
NKey nkey = NKey.fromSeed(seed.toCharArray());
return nkey.getPublicKey();
} catch (GeneralSecurityException | IOException e) {
throw new RuntimeException("Failed to get public key", e);
}
}

@Override
public char[] getJWT() {
return jwt.toCharArray();
}
}
28 changes: 28 additions & 0 deletions java-sdk/src/main/java/com/synternet/pubsubws/Message.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.synternet.pubsubws;

/** Simple message container mirroring the TypeScript type. */
public class Message {
private String subject;
private String data;

public Message(String subject, String data) {
this.subject = subject;
this.data = data;
}

public String getSubject() {
return subject;
}

public void setSubject(String subject) {
this.subject = subject;
}

public String getData() {
return data;
}

public void setData(String data) {
this.data = data;
}
}
48 changes: 48 additions & 0 deletions java-sdk/src/main/java/com/synternet/pubsubws/NatsConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.synternet.pubsubws;

import io.nats.client.Connection;
import io.nats.client.Dispatcher;

/** Configuration holder for NATS connection and subscription. */
public class NatsConfig {
private String url;
private Connection connection;
private Dispatcher dispatcher;
private String subject;

public NatsConfig(String url) {
this.url = url;
}

public String getUrl() {
return url;
}

public void setUrl(String url) {
this.url = url;
}

public Connection getConnection() {
return connection;
}

public void setConnection(Connection connection) {
this.connection = connection;
}

public Dispatcher getDispatcher() {
return dispatcher;
}

public void setDispatcher(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}

public String getSubject() {
return subject;
}

public void setSubject(String subject) {
this.subject = subject;
}
}
87 changes: 87 additions & 0 deletions java-sdk/src/main/java/com/synternet/pubsubws/PubSub.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.synternet.pubsubws;

import io.nats.client.Connection;
import io.nats.client.Dispatcher;
import io.nats.client.Nats;
import io.nats.client.Options;
import io.nats.client.MessageHandler;

import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;

/** Convenience API resembling the TypeScript SDK. */
public class PubSub {

/** Connects to the NATS server using WebSocket URL and JWT authentication. */
public static Connection connect(NatsConfig config, String jwt, String nkey) throws IOException, InterruptedException {
Options options = new Options.Builder()
.server(config.getUrl())
.authHandler(new JwtNkeyAuthHandler(jwt, nkey))
.build();
Connection nc = Nats.connect(options);
config.setConnection(nc);
return nc;
}

/** Subscribe to a subject delivering messages to the callback. */
public static void subscribe(NatsConfig config, String subject, Consumer<List<Message>> onMessages,
Consumer<Throwable> onError) {
if (config.getConnection() == null) {
onError.accept(new IllegalStateException("Connection not established"));
return;
}
MessageHandler handler = msg -> {
List<Message> m = new ArrayList<>();
m.add(new Message(msg.getSubject(), new String(msg.getData())));
onMessages.accept(m);
};
Dispatcher d = config.getConnection().createDispatcher(handler);
d.subscribe(subject);
config.setDispatcher(d);
config.setSubject(subject);
}

/** Publish a string payload to a subject. */
public static void publish(NatsConfig config, String subject, String data) {
if (config.getConnection() != null) {
config.getConnection().publish(subject, data.getBytes());
}
}

/** Request data and wait for a single response. */
public static CompletableFuture<String> request(NatsConfig config, String subject, byte[] data) {
CompletableFuture<String> future = new CompletableFuture<>();
if (config.getConnection() == null) {
future.completeExceptionally(new IllegalStateException("Connection not established"));
return future;
}
try {
io.nats.client.Message reply = config.getConnection().request(subject, data, Duration.ofSeconds(5));
future.complete(new String(reply.getData()));
} catch (InterruptedException | IOException e) {
future.completeExceptionally(e);
}
return future;
}

/** Cleanly unsubscribe and close connection. */
public static void unsubscribe(NatsConfig config) {
if (config.getDispatcher() != null && config.getSubject() != null) {
config.getDispatcher().unsubscribe(config.getSubject());
config.setDispatcher(null);
config.setSubject(null);
}
if (config.getConnection() != null) {
try {
config.getConnection().close();
} catch (InterruptedException e) {
// ignore
}
config.setConnection(null);
}
}
}
70 changes: 70 additions & 0 deletions java-sdk/src/main/java/com/synternet/pubsubws/UserJwt.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.synternet.pubsubws;

import io.nats.nkey.NKey;
import io.nats.nkey.NKeyType;

import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/** Utilities for creating JWT tokens for application access. */
public class UserJwt {

public static final int JWT_EXPIRATION_HOURS = 2;

public record JwtWithSeed(String jwt, String userSeed) {}

public static JwtWithSeed createAppJwt(String developerSeed) throws GeneralSecurityException, IOException {
return createAppJwt(developerSeed, Instant.now().plus(JWT_EXPIRATION_HOURS, ChronoUnit.HOURS));
}

public static JwtWithSeed createAppJwt(String developerSeed, Instant expiration) throws GeneralSecurityException, IOException {
NKey user = NKey.createUser(new SecureRandom());
String userSeed = new String(user.getSeed());
String jwt = generateUserJwt(userSeed, developerSeed, expiration);
return new JwtWithSeed(jwt, userSeed);
}

public static String generateUserJwt(String userSeed, String developerSeed, Instant expiration) throws GeneralSecurityException, IOException {
NKey user = NKey.fromSeed(userSeed.toCharArray());
NKey developer = NKey.fromSeed(developerSeed.toCharArray());

Map<String, Object> payload = new HashMap<>();
payload.put("jti", UUID.randomUUID().toString());
payload.put("iat", Instant.now().getEpochSecond());
payload.put("iss", new String(developer.getPublicKey()));
payload.put("name", "developer");
payload.put("sub", new String(user.getPublicKey()));
payload.put("nats", getNatsConfig());
payload.put("exp", expiration.getEpochSecond());

String headerJson = "{\"typ\":\"JWT\",\"alg\":\"ed25519-nkey\"}";
String payloadJson = JsonUtil.toJson(payload);

String jwtBase = base64UrlEncode(headerJson.getBytes()) + "." + base64UrlEncode(payloadJson.getBytes());
byte[] sig = developer.sign(jwtBase.getBytes());
return jwtBase + "." + base64UrlEncode(sig);
}

private static String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}

private static Map<String, Object> getNatsConfig() {
Map<String, Object> nats = new HashMap<>();
nats.put("pub", new HashMap<>());
nats.put("sub", new HashMap<>());
nats.put("subs", -1);
nats.put("data", -1);
nats.put("payload", -1);
nats.put("type", "user");
nats.put("version", 2);
return nats;
}
}