Skip to content

Commit

Permalink
feat: add full-stack signals base
Browse files Browse the repository at this point in the history
  • Loading branch information
taefi committed May 31, 2024
1 parent 81d9a93 commit 46817ad
Show file tree
Hide file tree
Showing 31 changed files with 2,090 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.util.Optional;
import java.util.TreeMap;

import com.vaadin.experimental.FeatureFlags;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
Expand Down Expand Up @@ -128,6 +129,18 @@ public void registerEndpoints(URL openApiResource) {
endpointBeans
.putAll(context.getBeansWithAnnotation(BrowserCallable.class));

if (FeatureFlags.get(VaadinService.getCurrent().getContext())
.isEnabled(FeatureFlags.HILLA_FULLSTACK_SIGNALS)) {
LOGGER.debug("Fullstack signals feature is enabled.");
if (endpointBeans.containsKey("signalsHandler")) {
LOGGER.debug("SignalsHandler endpoint will be registered.");
}
} else {
LOGGER.debug("Fullstack signals feature is disabled.");
endpointBeans.remove("signalsHandler");
LOGGER.debug("SignalsHandler endpoint will not be registered.");
}

// By default, only register those endpoints included in the Hilla
// OpenAPI definition file
registerEndpointsFromApiDefinition(endpointBeans, openApiResource);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@

import java.lang.reflect.Method;

import com.vaadin.hilla.signals.config.SignalsConfiguration;
import com.vaadin.hilla.signals.core.SignalsRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPatternParser;
Expand All @@ -42,8 +45,10 @@
* A configuration class for customizing the {@link EndpointController} class.
*/
@Configuration
@Import(SignalsConfiguration.class)
public class EndpointControllerConfiguration {
private final EndpointProperties endpointProperties;
private final SignalsRegistry signalsRegistry;

/**
* Initializes the endpoint configuration.
Expand All @@ -52,8 +57,10 @@ public class EndpointControllerConfiguration {
* Hilla endpoint properties
*/
public EndpointControllerConfiguration(
EndpointProperties endpointProperties) {
EndpointProperties endpointProperties,
SignalsRegistry signalsRegistry) {
this.endpointProperties = endpointProperties;
this.signalsRegistry = signalsRegistry;
}

/**
Expand Down Expand Up @@ -120,7 +127,8 @@ EndpointInvoker endpointInvoker(ApplicationContext applicationContext,
ExplicitNullableTypeChecker explicitNullableTypeChecker,
ServletContext servletContext, EndpointRegistry endpointRegistry) {
return new EndpointInvoker(applicationContext, endpointMapperFactory,
explicitNullableTypeChecker, servletContext, endpointRegistry);
explicitNullableTypeChecker, servletContext, endpointRegistry,
signalsRegistry);
}

/**
Expand Down Expand Up @@ -237,9 +245,9 @@ private RequestMappingInfo prependEndpointPrefixUrl(
}

/**
* Can re-generate the TypeScipt code.
* Can re-generate the TypeScript code.
*
* @param context
* @param servletContext
* the servlet context
* @param endpointController
* the endpoint controller
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.googlecode.gentyref.GenericTypeReflector;
import com.vaadin.experimental.FeatureFlags;
import com.vaadin.flow.server.VaadinService;
import com.vaadin.flow.server.VaadinServletContext;
import com.vaadin.hilla.EndpointInvocationException.EndpointAccessDeniedException;
import com.vaadin.hilla.EndpointInvocationException.EndpointBadRequestException;
Expand All @@ -32,6 +34,8 @@
import com.vaadin.hilla.exception.EndpointValidationException;
import com.vaadin.hilla.exception.EndpointValidationException.ValidationErrorData;
import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory;
import com.vaadin.hilla.signals.core.SignalQueue;
import com.vaadin.hilla.signals.core.SignalsRegistry;
import jakarta.servlet.ServletContext;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
Expand Down Expand Up @@ -80,6 +84,7 @@ public class EndpointInvoker {
private final ExplicitNullableTypeChecker explicitNullableTypeChecker;
private final ServletContext servletContext;
private final Validator validator;
private final SignalsRegistry signalsRegistry;

/**
* Creates an instance of this bean.
Expand All @@ -103,7 +108,8 @@ public class EndpointInvoker {
public EndpointInvoker(ApplicationContext applicationContext,
JacksonObjectMapperFactory endpointMapperFactory,
ExplicitNullableTypeChecker explicitNullableTypeChecker,
ServletContext servletContext, EndpointRegistry endpointRegistry) {
ServletContext servletContext, EndpointRegistry endpointRegistry,
SignalsRegistry signalsRegistry) {
this.applicationContext = applicationContext;
this.servletContext = servletContext;
this.endpointMapper = endpointMapperFactory != null
Expand All @@ -115,6 +121,7 @@ public EndpointInvoker(ApplicationContext applicationContext,
}
this.explicitNullableTypeChecker = explicitNullableTypeChecker;
this.endpointRegistry = endpointRegistry;
this.signalsRegistry = signalsRegistry;

Validator validator = null;
try {
Expand Down Expand Up @@ -481,6 +488,35 @@ private Object invokeVaadinEndpointMethod(String endpointName,
throw new EndpointInternalException(errorMessage);
}

if (returnValue instanceof SignalQueue<?>) {
if (FeatureFlags.get(VaadinService.getCurrent().getContext())
.isEnabled(FeatureFlags.HILLA_FULLSTACK_SIGNALS)) {
if (signalsRegistry == null) {
throw new EndpointInternalException(
"Signal registry is not available");
}
if (signalsRegistry
.contains(((SignalQueue<?>) returnValue).getId())) {
getLogger().debug(
"Signal already registered as a result of calling {}",
methodName);
} else {
signalsRegistry.register((SignalQueue<?>) returnValue);
getLogger().debug(
"Registered signal as a result of calling {}",
methodName);
}
} else {
String featureFlagFullName = FeatureFlags.SYSTEM_PROPERTY_PREFIX_EXPERIMENTAL
+ FeatureFlags.HILLA_FULLSTACK_SIGNALS;
throw new EndpointInternalException(String.format(
"Full-Stack Signal usage are only allowed if the %s feature flag is enabled explicitly. "
+ "You can enable it either through the Vaadin Copilot's UI, or by manually setting the "
+ "%s=true in the src/main/resources/vaadin-featureflags.properties and restarting the application.",
featureFlagFullName, featureFlagFullName));
}
}

return returnValue;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.vaadin.hilla.signals;

import com.fasterxml.jackson.databind.node.IntNode;
import com.vaadin.hilla.signals.core.SignalQueue;

public class NumberSignal extends SignalQueue<IntNode> {

public NumberSignal(int defaultValue) {
super(IntNode.valueOf(defaultValue));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.vaadin.hilla.signals.config;

import com.vaadin.hilla.signals.core.SignalsHandler;
import com.vaadin.hilla.signals.core.SignalsRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SignalsConfiguration {

private final SignalsRegistry signalsRegistry;
private final SignalsHandler signalsHandler;

public SignalsConfiguration(SignalsRegistry signalsRegistry,
SignalsHandler signalsHandler) {
this.signalsRegistry = signalsRegistry;
this.signalsHandler = signalsHandler;
}

@Bean
public SignalsRegistry signalsRegistry() {
return signalsRegistry;
}

@Bean
public SignalsHandler signalsHandler() {
return signalsHandler;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.vaadin.hilla.signals.core;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import reactor.core.publisher.Sinks.Many;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;

public abstract class EventQueue<T extends StateEvent> {
private final ReentrantLock lock = new ReentrantLock();

public static final UUID ROOT = UUID
.fromString("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF");

private static class Entry<T extends StateEvent> {
private final T value;
private Entry<T> next;

private Entry(T value) {
this.value = value;
}
}

private Entry<T> head;
private Entry<T> tail;
private final Map<UUID, Entry<T>> idToEntry = new HashMap<>();

private final Set<Many<T>> subscribers = new HashSet<>();

public Flux<T> subscribe(UUID continueFrom) {
System.out.println("Continue from " + continueFrom);
Many<T> sink = Sinks.many().multicast().onBackpressureBuffer();

return sink.asFlux().doOnSubscribe(ignore -> {
System.out.println("New Flux subscription");

lock.lock();
try {
Entry<T> entry;
if (continueFrom != null
&& (entry = idToEntry.get(continueFrom)) != null) {
entry = entry.next;
// TODO maybe some heuristic to determine whether it would
// be more efficient to restart from a snapshot instead of
// replaying lots of events?
while (entry != null) {
System.out.println("Replay " + entry.value.getId());
sink.tryEmitNext(entry.value);
entry = entry.next;
}
;
} else {
T snapshot = createSnapshot();
if (snapshot != null) {
sink.tryEmitNext(snapshot);
}
}

subscribers.add(sink);
} finally {
lock.unlock();
}
}).doFinally(ignore -> {
System.out.println("doFinally");
lock.lock();
try {
subscribers.remove(sink);
} finally {
lock.unlock();
}
});
}

public void submit(T event) {
// Thread.ofVirtual().start(() -> append(event));
append(event);
}

private void append(T event) {
lock.lock();
try {
processEvent(event);
Entry<T> entry = new Entry<>(event);

// Add to linked list
idToEntry.put(event.getId(), entry);
if (head == null) {
head = tail = entry;
} else {
tail.next = tail = entry;
}

// Truncate list
// TODO configurable or dynamic limit?
if (idToEntry.size() > 100) {
Entry<T> removed = idToEntry.remove(head.value.getId());
head = removed.next;
}

// Notify subscribers
subscribers.removeIf(sink -> {
boolean failure = sink.tryEmitNext(event).isFailure();
if (failure) {
System.out.println("Failed push");
}
return failure;
});
} finally {
lock.unlock();
}
}

protected abstract void processEvent(T newEvent);

protected abstract T createSnapshot();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.vaadin.hilla.signals.core;

import com.fasterxml.jackson.databind.node.ObjectNode;

import java.util.UUID;

public class JsonEvent extends StateEvent {

private final ObjectNode json;

public JsonEvent(UUID id, ObjectNode json) {
super(id);
this.json = json;
}

public ObjectNode getJson() {
return json;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.vaadin.hilla.signals.core;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.util.Map;
import java.util.UUID;

public class JsonEventMapper {

private final ObjectMapper mapper;

public JsonEventMapper(ObjectMapper mapper) {
this.mapper = mapper;
}

public String toJson(JsonEvent jsonEvent) {
ObjectNode root = mapper.createObjectNode();
for (Map.Entry<String, JsonNode> entry : jsonEvent.getJson()
.properties()) {
root.set(entry.getKey(), entry.getValue());
}
UUID id = jsonEvent.getId();
root.put("id", id != null ? id.toString() : null);
return root.toString();
}

public JsonEvent fromJson(String json) {
try {
ObjectNode root = (ObjectNode) mapper.readTree(json);
UUID id = UUID.fromString(root.get("id").asText());
root.remove("id");
return new JsonEvent(id, root);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
Loading

0 comments on commit 46817ad

Please sign in to comment.