
net.pincette.jes.api.Server Maven / Gradle / Ivy
package net.pincette.jes.api;
import static com.mongodb.client.model.Filters.and;
import static com.mongodb.client.model.Filters.eq;
import static com.mongodb.client.model.Filters.exists;
import static com.mongodb.client.model.Filters.in;
import static com.mongodb.client.model.Filters.not;
import static com.mongodb.client.model.Filters.or;
import static com.mongodb.reactivestreams.client.MongoClients.create;
import static io.jsonwebtoken.Jwts.parser;
import static java.lang.String.join;
import static java.net.URLDecoder.decode;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.security.KeyFactory.getInstance;
import static java.time.Instant.now;
import static java.util.Base64.getDecoder;
import static java.util.Base64.getEncoder;
import static java.util.UUID.randomUUID;
import static java.util.concurrent.CompletableFuture.completedFuture;
import static java.util.logging.Level.FINEST;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.SEVERE;
import static java.util.logging.Logger.getGlobal;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import static java.util.stream.Stream.concat;
import static javax.json.Json.createObjectBuilder;
import static net.pincette.jes.api.Response.accepted;
import static net.pincette.jes.api.Response.badRequest;
import static net.pincette.jes.api.Response.forbidden;
import static net.pincette.jes.api.Response.notAuthorized;
import static net.pincette.jes.api.Response.notFound;
import static net.pincette.jes.api.Response.notImplemented;
import static net.pincette.jes.api.Response.ok;
import static net.pincette.jes.api.Response.redirect;
import static net.pincette.jes.util.Commands.DELETE;
import static net.pincette.jes.util.Commands.PATCH;
import static net.pincette.jes.util.Commands.PUT;
import static net.pincette.jes.util.JsonFields.ACL;
import static net.pincette.jes.util.JsonFields.ACL_GET;
import static net.pincette.jes.util.JsonFields.COMMAND;
import static net.pincette.jes.util.JsonFields.CORR;
import static net.pincette.jes.util.JsonFields.ID;
import static net.pincette.jes.util.JsonFields.JWT;
import static net.pincette.jes.util.JsonFields.JWT_BREAKING_THE_GLASS;
import static net.pincette.jes.util.JsonFields.JWT_ROLES;
import static net.pincette.jes.util.JsonFields.JWT_SUB;
import static net.pincette.jes.util.JsonFields.OPS;
import static net.pincette.jes.util.JsonFields.TIMESTAMP;
import static net.pincette.jes.util.JsonFields.TYPE;
import static net.pincette.jes.util.Kafka.createReliableProducer;
import static net.pincette.jes.util.Kafka.send;
import static net.pincette.jes.util.Mongo.NOT_DELETED;
import static net.pincette.mongo.BsonUtil.fromBson;
import static net.pincette.mongo.BsonUtil.toBsonDocument;
import static net.pincette.mongo.Collection.find;
import static net.pincette.rs.Chain.with;
import static net.pincette.rs.Source.of;
import static net.pincette.util.Array.append;
import static net.pincette.util.Array.hasPrefix;
import static net.pincette.util.Builder.create;
import static net.pincette.util.Collections.list;
import static net.pincette.util.Collections.map;
import static net.pincette.util.Json.addIf;
import static net.pincette.util.Json.from;
import static net.pincette.util.Json.string;
import static net.pincette.util.Or.tryWith;
import static net.pincette.util.PBE.decrypt;
import static net.pincette.util.PBE.encrypt;
import static net.pincette.util.Pair.pair;
import static net.pincette.util.Util.getSegments;
import static net.pincette.util.Util.must;
import static net.pincette.util.Util.tryToGet;
import static net.pincette.util.Util.tryToGetRethrow;
import com.mongodb.reactivestreams.client.MongoClient;
import com.mongodb.reactivestreams.client.MongoCollection;
import com.mongodb.reactivestreams.client.MongoDatabase;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import java.io.Closeable;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletionStage;
import java.util.function.BiPredicate;
import java.util.function.Function;
import java.util.logging.Logger;
import java.util.stream.Stream;
import javax.json.JsonArray;
import javax.json.JsonObject;
import javax.json.JsonObjectBuilder;
import javax.json.JsonString;
import javax.json.JsonValue;
import net.pincette.function.SideEffect;
import net.pincette.function.SupplierWithException;
import net.pincette.jes.util.AuditFields;
import net.pincette.jes.util.JsonSerializer;
import net.pincette.mongo.BsonUtil;
import net.pincette.util.Json;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;
import org.bson.BsonDocument;
import org.bson.BsonValue;
import org.bson.Document;
import org.bson.conversions.Bson;
/**
* This class handles all the HTTP logic for the JSON Event Sourcing library. This makes it easier
* to use different HTTP server solutions. The URL path for an aggregate always has the form
* [/context]/app/type[/id]
. When the ID is present an individual aggregate instance is
* addressed, otherwise the complete list of aggregates of the given type is addressed. It may be
* limited with MongoDB search criteria. For this a POST should be used with a JSON array of staging
* objects.
*
* When aggregates change because of a command the result is sent back through the Kafka reply
* topic. It is possible to push this to the client using Server-Sent Events. This is done with the
* fanout.io service. The endpoint /sse
is where the client should connect to. It will
* be redirected to the fanout.io service with the encrypted username in a URL parameter. Then
* fanout.io comes back to the /sse-setup
endpoint, where the channel is created.
*
* @author Werner Donn\u00e9
* @since 1.0
*/
public class Server implements Closeable {
private static final String ACCESS_TOKEN = "access_token";
private static final String ERROR = "ERROR";
private static final String MATCH = "$match";
private static final String SSE = "sse";
private static final String SSE_SETUP = "sse-setup";
private final char[] salt = randomUUID().toString().toCharArray();
private String auditTopic;
private boolean breakingTheGlass;
private String[] contextPath = new String[0];
private String environment;
private char[] fanoutSecret;
private int fanoutTimeout = 20;
private String fanoutUri;
private JwtParser jwtParser;
private Logger logger = getGlobal();
private MongoClient mongoClient;
private MongoDatabase mongoDatabase;
private String mongoDatabaseName;
private KafkaProducer producer;
private BiPredicate responseFilter = (json, jwt) -> true;
private static Bson aclQuery(final JsonObject jwt) {
return or(not(exists(ACL)), in(ACL + "." + ACL_GET, getRoles(jwt)));
}
private static JsonObject completeCommand(final JsonObject command) {
return createObjectBuilder(command)
.add(ID, command.getString(ID).toLowerCase())
.add(TIMESTAMP, now().toEpochMilli())
.add(
CORR,
Optional.ofNullable(command.getString(CORR, null))
.orElseGet(() -> randomUUID().toString()))
.build();
}
private static JsonObject createAuditRecord(
final JsonObject jwt, final Path path, final String command) {
return addIf(
createObjectBuilder()
.add(AuditFields.TYPE, path.fullType())
.add(AuditFields.COMMAND, command)
.add(AuditFields.TIMESTAMP, now().toEpochMilli())
.add(
AuditFields.USER,
Optional.ofNullable(jwt.getString(JWT_SUB, null)).orElse("anonymous"))
.add(AuditFields.BREAKING_THE_GLASS, jwt.getBoolean(JWT_BREAKING_THE_GLASS, false)),
() -> path.id != null,
b -> b.add(AuditFields.AGGREGATE, path.id))
.build();
}
private static Optional createCommand(
final String command, final JsonObject jwt, final Path path) {
return Optional.ofNullable(path.id)
.map(
id ->
createObjectBuilder()
.add(COMMAND, command)
.add(JWT, jwt)
.add(ID, id)
.add(TYPE, path.fullType()));
}
private static String createFanoutUri(final String fanoutUri, final String[] contextPath) {
return fanoutUri
+ (contextPath.length > 0 ? ("/" + join("/", contextPath)) : "")
+ "/"
+ SSE_SETUP;
}
private static JsonObject createPutCommand(final Request request, final JsonObject jwt) {
return createObjectBuilder(request.body.asJsonObject()).add(JWT, jwt).add(COMMAND, PUT).build();
}
private static List fromJson(final JsonArray array) {
return array.stream()
.filter(Json::isObject)
.map(BsonUtil::fromJson)
.map(BsonValue::asDocument)
.collect(toList());
}
private static Set getRoles(final JsonObject jwt) {
return concat(
Optional.ofNullable(jwt.getJsonArray(JWT_ROLES))
.map(
a ->
a.stream()
.filter(Json::isString)
.map(Json::asString)
.map(JsonString::getString))
.orElseGet(Stream::empty),
Stream.of(jwt.getString(JWT_SUB)))
.collect(toSet());
}
private static boolean hasMatch(final List stages) {
return stages.stream().anyMatch(stage -> stage.containsKey(MATCH));
}
private static RSAPublicKey getRSAPublicKey(final String key) {
return tryToGetRethrow(
() ->
(RSAPublicKey)
getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(getDecoder().decode(key))))
.orElse(null);
}
private static Optional getBearerToken(final Request request) {
return tryWith(() -> getBearerTokenFromAuthorization(request))
.or(() -> getBearerTokenFromQueryString(request))
.or(() -> request.cookies.get(ACCESS_TOKEN))
.get()
.flatMap(t -> tryToGet(() -> decode(t, "UTF-8")));
}
private static String getBearerTokenFromAuthorization(final Request request) {
return Optional.ofNullable(request.headersLowerCaseKeys.get("authorization"))
.filter(values -> values.length == 1)
.map(values -> values[0])
.map(header -> header.split(" "))
.filter(s -> s.length == 2)
.filter(s -> s[0].equalsIgnoreCase("Bearer"))
.map(s -> s[1])
.orElse(null);
}
private static String getBearerTokenFromQueryString(final Request request) {
return Optional.ofNullable(request.queryString)
.map(q -> q.get(ACCESS_TOKEN))
.filter(values -> values.length == 1)
.map(values -> values[0])
.orElse(null);
}
private static T log(final Logger logger, final T obj) {
logger.log(FINEST, "{0}", obj);
return obj;
}
private static JsonObject onBehalfOf(final JsonObject jwt, final Request request) {
return Optional.of(jwt.getString(JWT_SUB))
.filter(sub -> sub.equals("system"))
.map(sub -> request.headers.get("X-Pincette-JES-OnBehalfOf"))
.filter(value -> value.length == 1)
.map(value -> value[0])
.filter(value -> value.length() > 0)
.flatMap(Json::from)
.filter(Json::isObject)
.map(JsonValue::asJsonObject)
.orElse(jwt);
}
public void close() {
producer.close();
if (mongoClient != null) {
mongoClient.close();
}
}
private String commandTopic(final JsonObject command) {
return command.getString(TYPE) + "-command" + (environment != null ? ("-" + environment) : "");
}
private BsonDocument completeMatch(final Bson original, final JsonObject jwt) {
return new BsonDocument(MATCH, toBsonDocument(completeQuery(original, jwt)));
}
private Bson completeQuery(final Bson original, final JsonObject jwt) {
return and(
create(() -> list(NOT_DELETED))
.updateIf(
l ->
!jwt.getString(JWT_SUB).equals("system")
&& (!breakingTheGlass || !jwt.getBoolean(JWT_BREAKING_THE_GLASS, false)),
l -> l.add(aclQuery(jwt)))
.updateIf(l -> original != null, l -> l.add(original))
.build());
}
private List completeQuery(final List stages, final JsonObject jwt) {
final List result =
stages.stream()
.map(
stage ->
stage.containsKey(MATCH) ? completeMatch(stage.getDocument(MATCH), jwt) : stage)
.collect(toList());
return hasMatch(result)
? result
: concat(Stream.of(completeMatch(null, jwt)), result.stream()).collect(toList());
}
private String decodeUsername(final String username) {
return tryWithLog(() -> new String(decrypt(getDecoder().decode(username), fanoutSecret), UTF_8))
.orElse(null);
}
private CompletionStage delete(final JsonObject jwt, final Path path) {
return createCommand(DELETE, jwt, path)
.map(JsonObjectBuilder::build)
.map(this::sendCommand)
.orElseGet(() -> completedFuture(notFound()));
}
private String encodeUsername(final String username) {
return tryWithLog(
() -> getEncoder().encodeToString(encrypt(username.getBytes(UTF_8), fanoutSecret)))
.orElse(null);
}
private Map fanoutHeaders(final String username) {
return map(
pair("Content-Type", new String[] {"text/event-stream"}),
pair("Cache-Control", new String[] {"no-cache"}),
pair("Grip-Hold", new String[] {"stream"}),
pair("Grip-Channel", new String[] {username}),
pair(
"Grip-Keep-Alive", new String[] {":\\n\\n; format=cstring; timeout=" + fanoutTimeout}));
}
private CompletionStage get(
final Request request, final JsonObject jwt, final Path path) {
return tryWith(() -> path.sse && hasFanout() ? getSse(jwt) : null)
.or(() -> path.sseSetup && hasFanout() ? getSseSetup(request) : null)
.or(
() ->
Optional.ofNullable(mongoDatabase)
.map(database -> path.id != null ? getOne(jwt, path) : getList(jwt, path))
.orElse(null))
.get()
.orElseGet(() -> completedFuture(notImplemented()));
}
private MongoCollection getCollection(final Path path) {
return mongoDatabase.getCollection(
path.fullType() + (environment != null ? ("-" + environment) : ""));
}
private Optional getJwt(final Request request) {
return getBearerToken(request)
.flatMap(jwt -> tryWithLog(() -> jwtParser.parse(jwt).getBody()))
.map(jwt -> (Claims) jwt)
.map(Json::from)
.filter(jwt -> jwt.containsKey(JWT_SUB))
.map(jwt -> onBehalfOf(jwt, request))
.map(
j ->
SideEffect.run(() -> logger.log(FINEST, "{0}", string(j)))
.andThenGet(() -> j));
}
private CompletionStage getList(final JsonObject jwt, final Path path) {
return sendAudit(jwt, path, "list")
.thenApply(
r ->
ok().withBody(
with(getCollection(path)
.find(completeQuery((Bson) null, jwt), BsonDocument.class))
.map(BsonUtil::fromBson)
.filter(json -> responseFilter.test(json, jwt))
.get()));
}
private CompletionStage getOne(final JsonObject jwt, final Path path) {
final Function filter =
json -> responseFilter.test(json, jwt) ? ok().withBody(of(list(json))) : forbidden();
return find(getCollection(path), completeQuery(eq(ID, path.id), jwt), BsonDocument.class, null)
.thenComposeAsync(
result ->
result.isEmpty()
? completedFuture(notFound())
: sendAudit(jwt, path, "get")
.thenApply(r -> filter.apply(fromBson(result.get(0)))));
}
private Optional getPath(final Request request) {
return Optional.ofNullable(request.path)
.map(path -> new Path(path, contextPath))
.filter(path -> path.valid);
}
private CompletionStage getSse(final JsonObject jwt) {
final String username = jwt.getString(JWT_SUB);
final String uri = createFanoutUri(fanoutUri, contextPath) + "?u=" + encodeUsername(username);
logger.log(INFO, "Redirect to {0} for user {1}", new Object[] {uri, username});
return completedFuture(redirect(uri));
}
private CompletionStage getSseSetup(final Request request) {
return Optional.ofNullable(request.queryString)
.map(q -> q.get("u"))
.filter(u -> u.length == 1)
.map(u -> decodeUsername(u[0].replace(' ', '+')))
.map(
username ->
SideEffect.>run(
() -> logger.log(INFO, "Set up fanout channel for user {0}", username))
.andThenGet(() -> completedFuture(ok().withHeaders(fanoutHeaders(username)))))
.orElseGet(() -> completedFuture(forbidden()));
}
private CompletionStage handleRequest(
final Request request, final JsonObject jwt, final Path path) {
switch (request.method) {
case "DELETE":
return delete(jwt, path);
case "GET":
return get(request, jwt, path);
case "PATCH":
return patch(request, jwt, path);
case "POST":
return post(request, jwt, path);
case "PUT":
return put(request, jwt, path);
default:
return completedFuture(notImplemented());
}
}
private boolean hasFanout() {
return fanoutSecret != null && fanoutUri != null;
}
private boolean isCorrectObject(final Request request, final String id) {
return Optional.ofNullable(request.body)
.filter(Json::isObject)
.map(JsonValue::asJsonObject)
.filter(json -> json.containsKey(ID) && json.containsKey(TYPE))
.map(
json ->
id.equalsIgnoreCase(json.getString(ID))
&& getPath(request)
.map(path -> path.fullType().equals(json.getString(TYPE)))
.orElse(false))
.orElse(false);
}
private void openDatabase() {
if (mongoDatabase == null && mongoClient != null && mongoDatabaseName != null) {
mongoDatabase = mongoClient.getDatabase(mongoDatabaseName);
}
}
private CompletionStage patch(
final Request request, final JsonObject jwt, final Path path) {
return Optional.ofNullable(path.id)
.map(
id ->
Optional.ofNullable(request.body)
.filter(Json::isArray)
.flatMap(
body ->
createCommand(PATCH, jwt, path)
.map(builder -> builder.add(OPS, body).build()))
.map(this::sendCommand)
.orElseGet(() -> completedFuture(badRequest())))
.orElseGet(() -> completedFuture(notFound()));
}
private CompletionStage post(
final Request request, final JsonObject jwt, final Path path) {
return Optional.ofNullable(path.id)
.map(
id ->
isCorrectObject(request, id)
? sendCommand(
createObjectBuilder(request.body.asJsonObject()).add(JWT, jwt).build())
: completedFuture(badRequest()))
.orElseGet(() -> search(request, jwt, path));
}
private CompletionStage put(
final Request request, final JsonObject jwt, final Path path) {
return Optional.ofNullable(path.id)
.map(
id ->
isCorrectObject(request, id)
? sendCommand(createPutCommand(request, jwt))
: completedFuture(badRequest()))
.orElseGet(() -> completedFuture(notFound()));
}
/**
* Processes a request asynchronously. It must always have a JSON Web Token, which can be a bearer
* token in the Authorization
header, the URL parameter access_token
or
* a cookie with the name access_token
.
*
* @param request the given request.
* @return The completion stage for the response.
* @since 1.0
*/
public CompletionStage request(final Request request) {
return getPath(log(logger, request))
.map(
path ->
path.sseSetup
? getSseSetup(request)
: getJwt(request)
.filter(jwt -> jwt.containsKey(JWT_SUB))
.map(
jwt ->
handleRequest(request, jwt, path)
.exceptionally(
e ->
SideEffect.run(
() -> logger.log(SEVERE, ERROR, e))
.andThenGet(Response::internalServerError)))
.orElseGet(() -> completedFuture(notAuthorized())))
.orElseGet(() -> completedFuture(notFound()))
.thenApply(response -> log(logger, response));
}
/**
* Indicates whether a request would return more than one JSON object. The client can use this to
* prepare an array or not without complicating streaming.
*
* @param request the given request
* @return Returns true
if the result will contain more than one JSON object.
* @since 1.0
*/
public boolean returnsMultiple(final Request request) {
return (request.method.equals("GET") || request.method.equals("POST"))
&& getPath(request).map(path -> path.id == null).orElse(false);
}
private CompletionStage search(
final Request request, final JsonObject jwt, final Path path) {
return Optional.ofNullable(request.body)
.filter(Json::isArray)
.map(JsonValue::asJsonArray)
.map(stages -> pair(fromJson(stages), string(stages)))
.map(pair -> pair(completeQuery(pair.first, jwt), pair.second))
.map(
pair ->
sendAudit(jwt, path, pair.second)
.thenApply(
r ->
ok().withBody(
with(getCollection(path)
.aggregate(pair.first, BsonDocument.class))
.map(BsonUtil::fromBson)
.filter(json -> responseFilter.test(json, jwt))
.get())))
.orElseGet(() -> completedFuture(badRequest()));
}
private CompletionStage sendAudit(
final JsonObject jwt, final Path path, final String command) {
final String key = path.id != null ? path.id : path.fullType();
return auditTopic != null
? send(
producer,
new ProducerRecord<>(auditTopic, key, createAuditRecord(jwt, path, command)))
.thenApply(result -> must(result, r -> r))
: completedFuture(true);
}
private CompletionStage sendCommand(final JsonObject command) {
final JsonObject c = completeCommand(command);
return send(producer, new ProducerRecord<>(commandTopic(c), c.getString(ID), c))
.thenApply(result -> must(result, r -> r))
.thenApply(result -> accepted());
}
private Optional tryWithLog(final SupplierWithException supplier) {
return tryToGet(
supplier,
e -> SideEffect.run(() -> logger.log(SEVERE, ERROR, e)).andThenGet(() -> null));
}
/**
* Causes all read-side requests to yield an entry in the audit trail, which is a Kafka topic.
* Note that the write-side is already audited in pincette-jes.
*
* @param auditTopic the given Kafka topic. It may be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withAudit(final String auditTopic) {
this.auditTopic = auditTopic;
return this;
}
/**
* Enables the breaking the glass feature. When the JSON Web Token of a request has the field
* breakingTheGlass
field set, the ACL is overruled and the request is let through.
* Use this with auditing turned on.
*
* @return The server object itself.
* @since 1.0
*/
public Server withBreakingTheGlass() {
breakingTheGlass = true;
return this;
}
/**
* Sets the URL context path.
*
* @param contextPath the given context path. It may be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withContextPath(final String contextPath) {
this.contextPath =
contextPath != null ? getSegments(contextPath, "/").toArray(String[]::new) : new String[0];
return this;
}
/**
* This value is used as a suffix to the name of the command Kafka topic. Several environment such
* as "dev", "test", "acceptance", etc. could live in the same Kafka cluster.
*
* @param environment the given environment. It may be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withEnvironment(final String environment) {
this.environment = environment;
return this;
}
/**
* This is the secret used to encrypt the username in the Server-Sent Event set-up with the
* fanout.io service. It must not be null
.
*
* @param fanoutSecret the given secret.
* @return The server object itself.
* @since 1.0
*/
public Server withFanoutSecret(final String fanoutSecret) {
this.fanoutSecret = append(salt, fanoutSecret.toCharArray());
return this;
}
/**
* Sets the amount of seconds a fanout.io connection is allowed to be idle.
*
* @param fanoutTimeout the timeout in seconds.
* @return The server object itself.
* @since 1.0
*/
public Server withFanoutTimeout(final int fanoutTimeout) {
this.fanoutTimeout = fanoutTimeout;
return this;
}
/**
* The URL of the fanout.io service, which is used by the Server-Sent Events endpoint.
*
* @param fanoutUri the given URI. It may be null
, in which case there will be no
* support for Server-Sent Events.
* @return The server object itself.
* @since 1.0
*/
public Server withFanoutUri(final String fanoutUri) {
this.fanoutUri = fanoutUri;
return this;
}
/**
* The configuration for the Kafka producer, which is used to send commands to the command topic.
*
* @param config the given configuration. It must not be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withKafkaConfig(final Map config) {
producer = createReliableProducer(config, new StringSerializer(), new JsonSerializer());
return this;
}
/**
* The logger.
*
* @param logger the given logger. It must not be null
.
* @return The server object itself.
* @since 1.0.3
*/
public Server withLogger(final Logger logger) {
this.logger = logger;
return this;
}
/**
* The MongoDB database for the read-side.
*
* @param database the given database. It must not be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withMongoDatabase(final String database) {
mongoDatabaseName = database;
openDatabase();
return this;
}
/**
* The URI of the MongoDB service. When it is not set there will be no read-side.
*
* @param uri the given URI. It must not be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withMongoUri(final String uri) {
mongoClient = create(uri);
openDatabase();
return this;
}
/**
* The public key with which all JSON Web Tokens are validated.
*
* @param key the given public key. It must not be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withJwtPublicKey(final String key) {
jwtParser = parser().setSigningKey(getRSAPublicKey(key));
return this;
}
/**
* A function to transform the JSON objects on the read-side.
*
* @param responseFilter the given filter function. It may be null
.
* @return The server object itself.
* @since 1.0
*/
public Server withResponseFilter(final BiPredicate responseFilter) {
this.responseFilter = responseFilter;
return this;
}
private static class Path {
final String app;
final String id;
final boolean sse;
final boolean sseSetup;
final String type;
final boolean valid;
private Path(final String path, final String[] contextPath) {
final String[] segments = getSegments(path, "/").toArray(String[]::new);
final boolean aggregatePath = isAggregate(segments, contextPath);
sse = isSse(segments, contextPath);
sseSetup = isSseSetup(segments, contextPath);
valid = aggregatePath || sse || sseSetup;
app = aggregatePath ? segments[contextPath.length] : null;
type = aggregatePath ? getType(segments[contextPath.length + 1]) : null;
id =
aggregatePath && segments.length == contextPath.length + 3
? segments[contextPath.length + 2].toLowerCase()
: null;
}
private static String getType(final String type) {
return Optional.of(type.indexOf('-'))
.filter(i -> i != -1)
.map(i -> type.substring(i + 1))
.orElse(type);
}
private static boolean isAggregate(final String[] path, final String[] contextPath) {
return (path.length == contextPath.length + 2 || path.length == contextPath.length + 3)
&& hasPrefix(path, contextPath);
}
private static boolean isSse(final String[] path, final String[] contextPath) {
return path.length == contextPath.length + 1 && path[contextPath.length].equals(SSE);
}
private static boolean isSseSetup(final String[] path, final String[] contextPath) {
return path.length == contextPath.length + 1 && path[contextPath.length].equals(SSE_SETUP);
}
private String fullType() {
return app + "-" + type;
}
}
}