action) {
+ if (applicationContext == null
+ || (applicationContext instanceof ConfigurableApplicationContext
+ && !((ConfigurableApplicationContext) applicationContext)
+ .isActive())) {
+ pendingActions.add(action);
+ } else {
+ action.accept(applicationContext);
+ }
+ }
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/BrowserCallable.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/BrowserCallable.java
index d9d94a51b4..16926735c6 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/BrowserCallable.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/BrowserCallable.java
@@ -26,8 +26,8 @@
* Makes the methods of the annotated class available to the browser.
*
* For each class, a corresponding TypeScript class is generated in
- * {@code frontend/generated} with TypeScript methods for invoking the methods
- * in this class.
+ * {@code src/main/frontend/generated} with TypeScript methods for invoking the
+ * methods in this class.
*
* This is an alias for {@link Endpoint}.
*/
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointCodeGenerator.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointCodeGenerator.java
index 24a58e54e9..90573d0f84 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointCodeGenerator.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointCodeGenerator.java
@@ -17,21 +17,27 @@
import java.io.IOException;
import java.nio.file.Files;
-import java.nio.file.Path;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.stream.Collectors;
+import com.vaadin.flow.server.VaadinContext;
+import com.vaadin.flow.server.frontend.FrontendTools;
+import com.vaadin.flow.server.frontend.FrontendUtils;
+import com.vaadin.flow.server.startup.ApplicationConfiguration;
import com.vaadin.hilla.engine.EngineConfiguration;
import com.vaadin.hilla.engine.GeneratorProcessor;
import com.vaadin.hilla.engine.ParserProcessor;
+
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.aop.framework.AopProxyUtils;
+import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
-import com.vaadin.flow.server.VaadinContext;
-import com.vaadin.flow.server.frontend.FrontendTools;
-import com.vaadin.flow.server.startup.ApplicationConfiguration;
-
/**
* Handles (re)generation of the TypeScript code.
*/
@@ -43,11 +49,10 @@ public class EndpointCodeGenerator {
private final EndpointController endpointController;
private final VaadinContext context;
- private Path buildDirectory;
private ApplicationConfiguration configuration;
- private String nodeExecutable;
private Set classesUsedInOpenApi = null;
+ private EngineConfiguration engineConfiguration;
/**
* Creates the singleton.
@@ -74,71 +79,84 @@ public static EndpointCodeGenerator getInstance() {
/**
* Re-generates the endpoint TypeScript and re-registers the endpoints in
* Java.
- *
- * @throws IOException
- * if something went wrong
*/
- public void update() throws IOException {
+ public void update() {
initIfNeeded();
if (configuration.isProductionMode()) {
throw new IllegalStateException(
"This method is not available in production mode");
}
- EngineConfiguration engineConfiguration = EngineConfiguration
- .loadDirectory(buildDirectory);
- ParserProcessor parser = new ParserProcessor(engineConfiguration,
- getClass().getClassLoader(), false);
- parser.process();
- GeneratorProcessor generator = new GeneratorProcessor(
- engineConfiguration, nodeExecutable, false);
- generator.process();
-
- OpenAPIUtil.getCurrentOpenAPIPath(buildDirectory, false)
- .ifPresent(openApiPath -> {
- try {
- this.endpointController
- .registerEndpoints(openApiPath.toUri().toURL());
- } catch (IOException e) {
- LOGGER.error(
- "Endpoints could not be registered due to an exception: ",
- e);
- }
- });
+ ApplicationContextProvider.runOnContext(applicationContext -> {
+ List> browserCallables = findBrowserCallables(
+ engineConfiguration, applicationContext);
+ ParserProcessor parser = new ParserProcessor(engineConfiguration);
+ parser.process(browserCallables);
+
+ GeneratorProcessor generator = new GeneratorProcessor(
+ engineConfiguration);
+ generator.process();
+ this.endpointController.registerEndpoints();
+ });
+ }
+
+ /**
+ * Finds all beans in the application context that have a browser callable
+ * annotation.
+ *
+ * @param engineConfiguration
+ * the engine configuration that provides the annotations to
+ * search for
+ * @param applicationContext
+ * the application context to search for beans in
+ * @return a list of classes that qualify as browser callables
+ */
+ public static List> findBrowserCallables(
+ EngineConfiguration engineConfiguration,
+ ApplicationContext applicationContext) {
+ return engineConfiguration.getEndpointAnnotations().stream()
+ .map(applicationContext::getBeansWithAnnotation)
+ .map(Map::values).flatMap(Collection::stream)
+ // maps to original class when proxies are found
+ // (also converts to class in all cases)
+ .map(AopProxyUtils::ultimateTargetClass).distinct()
+ .collect(Collectors.toList());
}
private void initIfNeeded() {
if (configuration == null) {
configuration = ApplicationConfiguration.get(context);
- Path projectFolder = configuration.getProjectFolder().toPath();
- buildDirectory = projectFolder
- .resolve(configuration.getBuildFolder());
-
- FrontendTools tools = new FrontendTools(configuration,
+ var frontendTools = new FrontendTools(configuration,
configuration.getProjectFolder());
- nodeExecutable = tools.getNodeBinary();
+ engineConfiguration = new EngineConfiguration.Builder()
+ .baseDir(configuration.getProjectFolder().toPath())
+ .buildDir(configuration.getBuildFolder())
+ .outputDir(
+ FrontendUtils
+ .getFrontendGeneratedFolder(
+ configuration.getFrontendFolder())
+ .toPath())
+ .productionMode(false).withDefaultAnnotations()
+ .nodeCommand(frontendTools.getNodeBinary()).build();
}
}
public Optional> getClassesUsedInOpenApi() throws IOException {
if (classesUsedInOpenApi == null) {
initIfNeeded();
- OpenAPIUtil.getCurrentOpenAPIPath(buildDirectory, false)
- .ifPresent(openApiPath -> {
- if (openApiPath.toFile().exists()) {
- try {
- classesUsedInOpenApi = OpenAPIUtil
- .findOpenApiClasses(
- Files.readString(openApiPath));
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- } else {
- LOGGER.debug(
- "No OpenAPI file is available yet ...");
- }
- });
+ var conf = EngineConfiguration.getDefault();
+ var openApiPath = conf.getOpenAPIFile();
+ if (openApiPath != null && openApiPath.toFile().exists()) {
+ try {
+ classesUsedInOpenApi = OpenAPIUtil
+ .findOpenApiClasses(Files.readString(openApiPath));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ } else {
+ LOGGER.debug("No OpenAPI file is available yet ...");
+ }
}
return Optional.ofNullable(classesUsedInOpenApi);
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java
index 5a797cca0e..517b833a3a 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointController.java
@@ -15,12 +15,13 @@
*/
package com.vaadin.hilla;
-import java.io.IOException;
-import java.net.URL;
-import java.util.Map;
-import java.util.Optional;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import java.util.TreeMap;
+import java.util.stream.Collectors;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
@@ -33,18 +34,11 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ArrayNode;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-
import com.vaadin.flow.internal.CurrentInstance;
import com.vaadin.flow.server.VaadinRequest;
import com.vaadin.flow.server.VaadinService;
-import com.vaadin.flow.server.VaadinServletRequest;
-import com.vaadin.flow.server.VaadinServletService;
-
+import com.vaadin.flow.server.dau.DAUUtils;
+import com.vaadin.flow.server.dau.EnforcementNotificationMessages;
import com.vaadin.hilla.EndpointInvocationException.EndpointAccessDeniedException;
import com.vaadin.hilla.EndpointInvocationException.EndpointBadRequestException;
import com.vaadin.hilla.EndpointInvocationException.EndpointInternalException;
@@ -53,8 +47,6 @@
import com.vaadin.hilla.auth.EndpointAccessChecker;
import com.vaadin.hilla.exception.EndpointException;
-import jakarta.servlet.http.HttpServletRequest;
-
/**
* The controller that is responsible for processing Vaadin endpoint requests.
* Each class that is annotated with {@link Endpoint} or {@link BrowserCallable}
@@ -85,6 +77,8 @@ public class EndpointController {
*/
public static final String ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER = "endpointMapperFactory";
+ private static final String SIGNALS_HANDLER_BEAN_NAME = "signalsHandler";
+
private final ApplicationContext context;
EndpointRegistry endpointRegistry;
@@ -93,6 +87,8 @@ public class EndpointController {
private final EndpointInvoker endpointInvoker;
+ VaadinService vaadinService;
+
/**
* A constructor used to initialize the controller.
*
@@ -118,7 +114,7 @@ public EndpointController(ApplicationContext context,
* Initializes the controller by registering all endpoints found in the
* OpenApi definition or, as a fallback, in the Spring context.
*/
- public void registerEndpoints(URL openApiResource) {
+ public void registerEndpoints() {
// Spring returns bean names in lower camel case, while Hilla names
// endpoints in upper camel case, so a case-insensitive map is used to
// ease searching
@@ -128,21 +124,23 @@ public void registerEndpoints(URL openApiResource) {
endpointBeans
.putAll(context.getBeansWithAnnotation(BrowserCallable.class));
- // By default, only register those endpoints included in the Hilla
- // OpenAPI definition file
- registerEndpointsFromApiDefinition(endpointBeans, openApiResource);
+ var currentEndpointNames = endpointBeans.values().stream()
+ .map(endpointRegistry::registerEndpoint)
+ .collect(Collectors.toSet());
+ // remove obsolete endpoints
+ endpointRegistry.getEndpoints().keySet()
+ .retainAll(currentEndpointNames);
- if (endpointRegistry.isEmpty() && !endpointBeans.isEmpty()) {
- LOGGER.debug("No endpoints found in openapi.json:"
- + " registering all endpoints found using the Spring context");
+ endpointBeans.keySet().stream()
+ .filter(name -> !name.equals(SIGNALS_HANDLER_BEAN_NAME))
+ .findAny().ifPresent(name -> HillaStats.reportHasEndpoint());
- endpointBeans.forEach((name, endpointBean) -> endpointRegistry
- .registerEndpoint(endpointBean));
+ // Temporary Hack
+ VaadinService vaadinService = VaadinService.getCurrent();
+ if (vaadinService != null) {
+ this.vaadinService = vaadinService;
}
- if (!endpointRegistry.isEmpty()) {
- HillaStats.reportHasEndpoint();
- }
}
/**
@@ -167,6 +165,8 @@ public void registerEndpoints(URL openApiResource) {
* called has parameters
* @param request
* the current request which triggers the endpoint call
+ * @param response
+ * the current response
* @return execution result as a JSON string or an error message string
*/
@PostMapping(path = ENDPOINT_METHODS, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@@ -174,7 +174,43 @@ public ResponseEntity serveEndpoint(
@PathVariable("endpoint") String endpointName,
@PathVariable("method") String methodName,
@RequestBody(required = false) ObjectNode body,
- HttpServletRequest request) {
+ HttpServletRequest request, HttpServletResponse response) {
+ return doServeEndpoint(endpointName, methodName, body, request,
+ response);
+ }
+
+ /**
+ * Captures and processes the Vaadin endpoint requests.
+ *
+ * Matches the endpoint name and a method name with the corresponding Java
+ * class and a public method in the class. Extracts parameters from a
+ * request body if the Java method requires any and applies in the same
+ * order. After the method call, serializes the Java method execution result
+ * and sends it back.
+ *
+ * If an issue occurs during the request processing, an error response is
+ * returned instead of the serialized Java method return value.
+ *
+ * @param endpointName
+ * the name of an endpoint to address the calls to, not case
+ * sensitive
+ * @param methodName
+ * the method name to execute on an endpoint, not case sensitive
+ * @param body
+ * optional request body, that should be specified if the method
+ * called has parameters
+ * @param request
+ * the current request which triggers the endpoint call
+ * @return execution result as a JSON string or an error message string
+ */
+ public ResponseEntity serveEndpoint(String endpointName,
+ String methodName, ObjectNode body, HttpServletRequest request) {
+ return doServeEndpoint(endpointName, methodName, body, request, null);
+ }
+
+ private ResponseEntity doServeEndpoint(String endpointName,
+ String methodName, ObjectNode body, HttpServletRequest request,
+ HttpServletResponse response) {
LOGGER.debug("Endpoint: {}, method: {}, request body: {}", endpointName,
methodName, body);
@@ -184,13 +220,13 @@ public ResponseEntity serveEndpoint(
EndpointAccessChecker.ACCESS_DENIED_MSG));
}
+ DAUUtils.EnforcementResult enforcementResult = null;
try {
- // Put a VaadinRequest in the instances object so as the request is
- // available in the endpoint method
- VaadinServletService service = (VaadinServletService) VaadinService
- .getCurrent();
- CurrentInstance.set(VaadinRequest.class,
- new VaadinServletRequest(request, service));
+ enforcementResult = DAUUtils.trackDAU(this.vaadinService, request,
+ response);
+ if (enforcementResult.isEnforcementNeeded()) {
+ return buildEnforcementResponseEntity(enforcementResult);
+ }
Object returnValue = endpointInvoker.invoke(endpointName,
methodName, body, request.getUserPrincipal(),
request::isUserInRole);
@@ -227,48 +263,29 @@ public ResponseEntity serveEndpoint(
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
endpointInvoker.createResponseErrorObject(e.getMessage()));
} finally {
- CurrentInstance.set(VaadinRequest.class, null);
- }
+ if (enforcementResult != null
+ && enforcementResult.endRequestAction() != null) {
+ enforcementResult.endRequestAction().run();
+ } else {
+ CurrentInstance.set(VaadinRequest.class, null);
+ }
+ }
}
- /**
- * Parses the openapi.json
file to discover defined endpoints.
- *
- * @param knownEndpointBeans
- * the endpoint beans found in the Spring context
- */
- private void registerEndpointsFromApiDefinition(
- Map knownEndpointBeans, URL openApiResource) {
-
- if (openApiResource == null) {
- LOGGER.debug(
- "Resource 'hilla-openapi.json' is not available: endpoints cannot be registered yet");
- } else {
- try (var stream = openApiResource.openStream()) {
- // Read the openapi.json file and extract the tags, which in
- // turn define the endpoints and their implementation classes
- var rootNode = new ObjectMapper().readTree(stream);
- var tagsNode = (ArrayNode) rootNode.get("tags");
-
- if (tagsNode != null) {
- // Declared endpoints are first searched as Spring Beans. If
- // not found, they are, if possible, instantiated as regular
- // classes using their default constructor
- tagsNode.forEach(tag -> {
- Optional.ofNullable(tag.get("name"))
- .map(JsonNode::asText)
- .map(knownEndpointBeans::get)
- .or(() -> Optional
- .ofNullable(tag.get("x-class-name"))
- .map(JsonNode::asText)
- .map(this::instantiateEndpointByClassName))
- .ifPresent(endpointRegistry::registerEndpoint);
- });
- }
- } catch (IOException e) {
- LOGGER.warn("Failed to read openapi.json", e);
- }
+ private ResponseEntity buildEnforcementResponseEntity(
+ DAUUtils.EnforcementResult enforcementResult) {
+ EnforcementNotificationMessages messages = enforcementResult.messages();
+ EndpointException endpointException = new EndpointException(
+ messages.caption(), enforcementResult.origin(), messages);
+ try {
+ return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
+ .body(endpointInvoker.createResponseErrorObject(
+ endpointException.getSerializationData()));
+ } catch (JsonProcessingException ee) {
+ return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
+ .body(endpointInvoker.createResponseErrorObject(
+ messages.caption() + ". " + messages.message()));
}
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java
index 287f93afd5..a17111de91 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointControllerConfiguration.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2000-2023 Vaadin Ltd.
+ * Copyright 2000-2024 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
@@ -18,6 +18,8 @@
import java.lang.reflect.Method;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.vaadin.hilla.endpointransfermapper.EndpointTransferMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -25,6 +27,7 @@
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPatternParser;
@@ -43,7 +46,9 @@
*/
@Configuration
public class EndpointControllerConfiguration {
+ private static final EndpointTransferMapper ENDPOINT_TRANSFER_MAPPER = new EndpointTransferMapper();
private final EndpointProperties endpointProperties;
+ private ObjectMapper endpointMapper;
/**
* Initializes the endpoint configuration.
@@ -71,7 +76,7 @@ AccessAnnotationChecker accessAnnotationChecker() {
* Registers a default {@link EndpointAccessChecker} bean instance.
*
* @param accessAnnotationChecker
- * the access controlks checker to use
+ * the access controls checker to use
* @return the default Vaadin endpoint access checker bean
*/
@Bean
@@ -94,16 +99,50 @@ CsrfChecker csrfChecker(ServletContext servletContext) {
}
/**
- * Registers the endpoint invoker.
+ * Creates ObjectMapper instance that is used for Hilla endpoints'
+ * serializing and deserializing request and response bodies.
*
* @param applicationContext
* The Spring application context
* @param endpointMapperFactory
- * optional bean to override the default
+ * optional factory bean to override the default
* {@link JacksonObjectMapperFactory} that is used for
* serializing and deserializing request and response bodies Use
* {@link EndpointController#ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER}
* qualifier to override the mapper.
+ */
+ @Bean
+ ObjectMapper hillaEndpointObjectMapper(
+ ApplicationContext applicationContext,
+ @Autowired(required = false) @Qualifier(EndpointController.ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER) JacksonObjectMapperFactory endpointMapperFactory) {
+ if (this.endpointMapper == null) {
+ this.endpointMapper = endpointMapperFactory != null
+ ? endpointMapperFactory.build()
+ : createDefaultEndpointMapper(applicationContext);
+ if (this.endpointMapper != null) {
+ this.endpointMapper.registerModule(
+ ENDPOINT_TRANSFER_MAPPER.getJacksonModule());
+ }
+ }
+ return this.endpointMapper;
+ }
+
+ private static ObjectMapper createDefaultEndpointMapper(
+ ApplicationContext applicationContext) {
+ var endpointMapper = new JacksonObjectMapperFactory.Json().build();
+ applicationContext.getBean(Jackson2ObjectMapperBuilder.class)
+ .configure(endpointMapper);
+ return endpointMapper;
+ }
+
+ /**
+ * Registers the endpoint invoker.
+ *
+ * @param applicationContext
+ * The Spring application context
+ * @param hillaEndpointObjectMapper
+ * ObjectMapper instance that is used for Hilla endpoints'
+ * serializing and deserializing request and response bodies.
* @param explicitNullableTypeChecker
* the method parameter and return value type checker to verify
* that null values are explicit
@@ -116,11 +155,12 @@ CsrfChecker csrfChecker(ServletContext servletContext) {
*/
@Bean
EndpointInvoker endpointInvoker(ApplicationContext applicationContext,
- @Autowired(required = false) @Qualifier(EndpointController.ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER) JacksonObjectMapperFactory endpointMapperFactory,
+ @Qualifier("hillaEndpointObjectMapper") ObjectMapper hillaEndpointObjectMapper,
ExplicitNullableTypeChecker explicitNullableTypeChecker,
ServletContext servletContext, EndpointRegistry endpointRegistry) {
- return new EndpointInvoker(applicationContext, endpointMapperFactory,
- explicitNullableTypeChecker, servletContext, endpointRegistry);
+ return new EndpointInvoker(applicationContext,
+ hillaEndpointObjectMapper, explicitNullableTypeChecker,
+ servletContext, endpointRegistry);
}
/**
@@ -237,9 +277,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
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java
index 24c388b3b0..ca0f91599d 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointInvoker.java
@@ -27,7 +27,6 @@
import com.vaadin.hilla.EndpointInvocationException.EndpointNotFoundException;
import com.vaadin.hilla.EndpointRegistry.VaadinEndpointData;
import com.vaadin.hilla.auth.EndpointAccessChecker;
-import com.vaadin.hilla.endpointransfermapper.EndpointTransferMapper;
import com.vaadin.hilla.exception.EndpointException;
import com.vaadin.hilla.exception.EndpointValidationException;
import com.vaadin.hilla.exception.EndpointValidationException.ValidationErrorData;
@@ -40,7 +39,6 @@
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.http.ResponseEntity;
-import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.lang.NonNullApi;
import org.springframework.util.ClassUtils;
@@ -72,10 +70,8 @@
* For internal use only. May be renamed or removed in a future release.
*/
public class EndpointInvoker {
-
- private static final EndpointTransferMapper endpointTransferMapper = new EndpointTransferMapper();
private final ApplicationContext applicationContext;
- private final ObjectMapper endpointMapper;
+ private final ObjectMapper endpointObjectMapper;
private final EndpointRegistry endpointRegistry;
private final ExplicitNullableTypeChecker explicitNullableTypeChecker;
private final ServletContext servletContext;
@@ -86,12 +82,12 @@ public class EndpointInvoker {
*
* @param applicationContext
* The Spring application context
- * @param endpointMapperFactory
- * optional factory bean to override the default
- * {@link JacksonObjectMapperFactory} that is used for
- * serializing and deserializing request and response bodies Use
+ * @param endpointObjectMapper
+ * The object mapper to be used for serialization and
+ * deserialization of request and response bodies. To override
+ * the mapper, use the
* {@link EndpointController#ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER}
- * qualifier to override the mapper.
+ * qualifier on a JacksonObjectMapperFactory bean definition.
* @param explicitNullableTypeChecker
* the method parameter and return value type checker to verify
* that null values are explicit
@@ -101,18 +97,12 @@ public class EndpointInvoker {
* the registry used to store endpoint information
*/
public EndpointInvoker(ApplicationContext applicationContext,
- JacksonObjectMapperFactory endpointMapperFactory,
+ ObjectMapper endpointObjectMapper,
ExplicitNullableTypeChecker explicitNullableTypeChecker,
ServletContext servletContext, EndpointRegistry endpointRegistry) {
this.applicationContext = applicationContext;
this.servletContext = servletContext;
- this.endpointMapper = endpointMapperFactory != null
- ? endpointMapperFactory.build()
- : createDefaultEndpointMapper(applicationContext);
- if (this.endpointMapper != null) {
- this.endpointMapper
- .registerModule(endpointTransferMapper.getJacksonModule());
- }
+ this.endpointObjectMapper = endpointObjectMapper;
this.explicitNullableTypeChecker = explicitNullableTypeChecker;
this.endpointRegistry = endpointRegistry;
@@ -128,15 +118,6 @@ public EndpointInvoker(ApplicationContext applicationContext,
: validator;
}
- private static ObjectMapper createDefaultEndpointMapper(
- ApplicationContext applicationContext) {
- var endpointMapper = new JacksonObjectMapperFactory.Json().build();
- applicationContext.getBean(Jackson2ObjectMapperBuilder.class)
- .configure(endpointMapper);
-
- return endpointMapper;
- }
-
private static Logger getLogger() {
return LoggerFactory.getLogger(EndpointInvoker.class);
}
@@ -190,12 +171,8 @@ public Object invoke(String endpointName, String methodName,
Function rolesChecker)
throws EndpointNotFoundException, EndpointAccessDeniedException,
EndpointBadRequestException, EndpointInternalException {
- VaadinEndpointData vaadinEndpointData = endpointRegistry
- .get(endpointName);
- if (vaadinEndpointData == null) {
- getLogger().debug("Endpoint '{}' not found", endpointName);
- throw new EndpointNotFoundException();
- }
+ VaadinEndpointData vaadinEndpointData = getVaadinEndpointData(
+ endpointName);
Method methodToInvoke = getMethod(endpointName, methodName);
if (methodToInvoke == null) {
@@ -210,15 +187,26 @@ public Object invoke(String endpointName, String methodName,
}
+ public VaadinEndpointData getVaadinEndpointData(String endpointName)
+ throws EndpointNotFoundException {
+ VaadinEndpointData vaadinEndpointData = endpointRegistry
+ .get(endpointName);
+ if (vaadinEndpointData == null) {
+ getLogger().debug("Endpoint '{}' not found", endpointName);
+ throw new EndpointNotFoundException();
+ }
+ return vaadinEndpointData;
+ }
+
String createResponseErrorObject(String errorMessage) {
- ObjectNode objectNode = endpointMapper.createObjectNode();
+ ObjectNode objectNode = endpointObjectMapper.createObjectNode();
objectNode.put(EndpointException.ERROR_MESSAGE_FIELD, errorMessage);
return objectNode.toString();
}
String createResponseErrorObject(Map serializationData)
throws JsonProcessingException {
- return endpointMapper.writeValueAsString(serializationData);
+ return endpointObjectMapper.writeValueAsString(serializationData);
}
EndpointAccessChecker getAccessChecker() {
@@ -235,7 +223,7 @@ EndpointAccessChecker getAccessChecker() {
String writeValueAsString(Object returnValue)
throws JsonProcessingException {
- return endpointMapper.writeValueAsString(returnValue);
+ return endpointObjectMapper.writeValueAsString(returnValue);
}
private List createBeanValidationErrors(
@@ -336,8 +324,8 @@ private Object[] getVaadinEndpointParameters(
Type parameterType = javaParameters[i];
Type incomingType = parameterType;
try {
- Object parameter = endpointMapper
- .readerFor(endpointMapper.getTypeFactory()
+ Object parameter = endpointObjectMapper
+ .readerFor(endpointObjectMapper.getTypeFactory()
.constructType(incomingType))
.readValue(requestParameters.get(parameterNames[i]));
endpointParameters[i] = parameter;
@@ -381,19 +369,13 @@ private ResponseEntity handleMethodExecutionError(
}
}
- private Object invokeVaadinEndpointMethod(String endpointName,
- String methodName, Method methodToInvoke, ObjectNode body,
- VaadinEndpointData vaadinEndpointData, Principal principal,
- Function rolesChecker)
- throws EndpointAccessDeniedException, EndpointBadRequestException,
- EndpointInternalException {
- HillaStats.reportEndpointActive();
- EndpointAccessChecker accessChecker = getAccessChecker();
-
+ public String checkAccess(EndpointRegistry.VaadinEndpointData endpointData,
+ Method methodToInvoke, Principal principal,
+ Function rolesChecker) {
var methodDeclaringClass = methodToInvoke.getDeclaringClass();
var invokedEndpointClass = ClassUtils
- .getUserClass(vaadinEndpointData.getEndpointObject());
-
+ .getUserClass(endpointData.getEndpointObject());
+ EndpointAccessChecker accessChecker = getAccessChecker();
String checkError;
if (methodDeclaringClass.equals(invokedEndpointClass)) {
checkError = accessChecker.check(methodToInvoke, principal,
@@ -402,6 +384,19 @@ private Object invokeVaadinEndpointMethod(String endpointName,
checkError = accessChecker.check(invokedEndpointClass, principal,
rolesChecker);
}
+ return checkError;
+ }
+
+ private Object invokeVaadinEndpointMethod(String endpointName,
+ String methodName, Method methodToInvoke, ObjectNode body,
+ VaadinEndpointData vaadinEndpointData, Principal principal,
+ Function rolesChecker)
+ throws EndpointAccessDeniedException, EndpointBadRequestException,
+ EndpointInternalException {
+ HillaStats.reportEndpointActive();
+
+ var checkError = checkAccess(vaadinEndpointData, methodToInvoke,
+ principal, rolesChecker);
if (checkError != null) {
throw new EndpointAccessDeniedException(String.format(
"Endpoint '%s' method '%s' request cannot be accessed, reason: '%s'",
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointRegistry.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointRegistry.java
index ccbc60e20d..2b4e02774d 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointRegistry.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/EndpointRegistry.java
@@ -54,6 +54,15 @@ private VaadinEndpointData(Object vaadinEndpointObject,
method));
}
+ /**
+ * Gets all the endpoint methods.
+ *
+ * @return the endpoint methods
+ */
+ public Map getMethods() {
+ return methods;
+ }
+
/**
* Finds a method with the given name.
*
@@ -90,10 +99,10 @@ private static String getEndpointNameForClass(Class> beanType) {
// BrowserCallable has no value so this works
return Optional.ofNullable(beanType.getAnnotation(Endpoint.class))
.map(Endpoint::value).filter(value -> !value.isEmpty())
- .orElse(beanType.getSimpleName());
+ .orElse(beanType.getSimpleName()).toLowerCase(Locale.ENGLISH);
}
- void registerEndpoint(Object endpointBean) {
+ String registerEndpoint(Object endpointBean) {
// Check the bean type instead of the implementation type in
// case of e.g. proxies
Class> beanType = ClassUtils.getUserClass(endpointBean.getClass());
@@ -120,10 +129,20 @@ void registerEndpoint(Object endpointBean) {
Method[] endpointPublicMethods = beanType.getMethods();
AccessibleObject.setAccessible(endpointPublicMethods, true);
- vaadinEndpoints.put(endpointName.toLowerCase(Locale.ENGLISH),
+ vaadinEndpoints.put(endpointName,
new VaadinEndpointData(endpointBean, endpointPublicMethods));
LOGGER.debug("Registered endpoint '{}' with class '{}'", endpointName,
beanType);
+ return endpointName;
+ }
+
+ /**
+ * Gets all registered endpoints.
+ *
+ * @return a map of endpoint names to endpoint data
+ */
+ public Map getEndpoints() {
+ return vaadinEndpoints;
}
VaadinEndpointData get(String endpointName) {
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/Hotswapper.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/Hotswapper.java
index c14a0db663..65c7dd4f22 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/Hotswapper.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/Hotswapper.java
@@ -4,6 +4,8 @@
import java.util.List;
import java.util.Set;
+import com.vaadin.flow.hotswap.VaadinHotswapper;
+import com.vaadin.flow.server.VaadinService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -11,7 +13,7 @@
* Takes care of updating internals of Hilla that need updates when application
* classes are updated.
*/
-public class Hotswapper {
+public class Hotswapper implements VaadinHotswapper {
private static boolean inUse;
@@ -19,6 +21,14 @@ private static Logger getLogger() {
return LoggerFactory.getLogger(Hotswapper.class);
}
+ @Override
+ public boolean onClassLoadEvent(VaadinService vaadinService,
+ Set> classes, boolean redefined) {
+ onHotswap(redefined,
+ classes.stream().map(Class::getName).toArray(String[]::new));
+ return false;
+ }
+
/**
* Called by hot swap solutions when one or more classes have been updated.
*
@@ -106,7 +116,7 @@ private static boolean affectsEndpoints(String[] changedClasses)
.getClassesUsedInOpenApi().orElse(Set.of());
for (String classUsedInEndpoints : classesUsedInEndpoints) {
if (changedClassesSet.contains(classUsedInEndpoints)) {
- getLogger().debug("The changed class " + classesUsedInEndpoints
+ getLogger().debug("The changed class " + classUsedInEndpoints
+ " is used in an endpoint");
return true;
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nonnull.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nonnull.java
index 24d9c099b4..b4c275a2c8 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nonnull.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nonnull.java
@@ -28,9 +28,13 @@
*
* This annotation exists only for convenience because the traditional
* `jakarta.annotation.Nonnull` annotation is not applicable to type parameters.
+ *
+ * @deprecated use the standardized {@link org.jspecify.annotations.NonNull}
+ * instead
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE_USE })
+@Deprecated(forRemoval = true)
public @interface Nonnull {
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nullable.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nullable.java
index 8e8f1a51c9..1478fde2f0 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nullable.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/Nullable.java
@@ -29,9 +29,13 @@
* This annotation exists only for convenience because the traditional
* `jakarta.annotation.Nullable` annotation is not applicable to type
* parameters.
+ *
+ * @deprecated use the standardized {@link org.jspecify.annotations.Nullable}
+ * instead
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE_USE })
+@Deprecated(forRemoval = true)
public @interface Nullable {
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/OpenAPIUtil.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/OpenAPIUtil.java
index 3089d4decc..728c337232 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/OpenAPIUtil.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/OpenAPIUtil.java
@@ -3,7 +3,6 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.Collections;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
@@ -13,7 +12,6 @@
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.vaadin.hilla.engine.EngineConfiguration;
-import com.vaadin.hilla.engine.ParserProcessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,35 +59,10 @@ public static String getCurrentOpenAPI(Path buildDirectory,
*/
public static Optional getCurrentOpenAPIPath(Path buildDirectory,
boolean isProductionMode) throws IOException {
- EngineConfiguration engineConfiguration = EngineConfiguration
- .loadDirectory(buildDirectory);
- if (engineConfiguration == null) {
- return Optional.empty();
- }
- return Optional
- .of(engineConfiguration.getOpenAPIFile(isProductionMode));
- }
-
- /**
- * Generate a new openapi.json and return it, based on the classes in the
- * build directory.
- *
- * @param buildDirectory
- * the build directory, {@code target} if running with Maven
- * @param isProductionMode
- * whether to generate the openapi for production mode
- * @return the contents of the generated openapi.json
- * @throws IOException
- * if something went wrong
- */
- public static String generateOpenAPI(Path buildDirectory,
- boolean isProductionMode) throws IOException {
- EngineConfiguration engineConfiguration = EngineConfiguration
- .loadDirectory(buildDirectory);
- ParserProcessor parserProcessor = new ParserProcessor(
- engineConfiguration, OpenAPIUtil.class.getClassLoader(),
- isProductionMode);
- return parserProcessor.createOpenAPI();
+ var engineConfiguration = new EngineConfiguration.Builder()
+ .buildDir(buildDirectory).productionMode(isProductionMode)
+ .withDefaultAnnotations().build();
+ return Optional.of(engineConfiguration.getOpenAPIFile());
}
/**
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/auth/EndpointAccessChecker.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/auth/EndpointAccessChecker.java
index 92fdabefc7..00f7dfa9f5 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/auth/EndpointAccessChecker.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/auth/EndpointAccessChecker.java
@@ -74,7 +74,7 @@ public class EndpointAccessChecker {
public static final String ACCESS_DENIED_MSG = "Access denied";
- public static final String ACCESS_DENIED_MSG_DEV_MODE = "Unauthorized access to Vaadin endpoint; "
+ public static final String ACCESS_DENIED_MSG_DEV_MODE = "Access denied to Vaadin endpoint; "
+ "to enable endpoint access use one of the following annotations: @AnonymousAllowed, @PermitAll, @RolesAllowed";
private final AccessAnnotationChecker accessAnnotationChecker;
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CountService.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CountService.java
index 953861ee6a..cf7b0dd7bf 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CountService.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CountService.java
@@ -1,8 +1,9 @@
package com.vaadin.hilla.crud;
-import com.vaadin.hilla.Nullable;
import com.vaadin.hilla.crud.filter.Filter;
+import org.jspecify.annotations.Nullable;
+
/**
* A browser-callable service that can count the given type of objects with a
* given filter.
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CrudConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CrudConfiguration.java
deleted file mode 100644
index b67a3adff0..0000000000
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CrudConfiguration.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.vaadin.hilla.crud;
-
-import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.data.jpa.domain.Specification;
-
-@Configuration
-public class CrudConfiguration {
-
- @Bean
- @ConditionalOnClass(Specification.class)
- JpaFilterConverter jpaFilterConverter() {
- return new JpaFilterConverter();
- }
-
-}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CrudRepositoryService.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CrudRepositoryService.java
index cc9dcd8c1e..f28f9a16c0 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CrudRepositoryService.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/CrudRepositoryService.java
@@ -3,11 +3,11 @@
import java.util.ArrayList;
import java.util.List;
-import com.vaadin.hilla.EndpointExposed;
-import com.vaadin.hilla.Nullable;
-import com.vaadin.hilla.crud.filter.Filter;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
+import org.jspecify.annotations.Nullable;
+
+import com.vaadin.hilla.EndpointExposed;
/**
* A browser-callable service that delegates crud operations to a JPA
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/FormService.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/FormService.java
index 1b1947c5ee..75ebb350f7 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/FormService.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/FormService.java
@@ -1,6 +1,6 @@
package com.vaadin.hilla.crud;
-import com.vaadin.hilla.Nullable;
+import org.jspecify.annotations.Nullable;
/**
* A browser-callable service that can create, update, and delete a given type
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/JpaFilterConverter.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/JpaFilterConverter.java
index 2a343fc856..d3840b2404 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/JpaFilterConverter.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/JpaFilterConverter.java
@@ -1,36 +1,27 @@
package com.vaadin.hilla.crud;
-import jakarta.persistence.EntityManager;
+import org.springframework.data.jpa.domain.Specification;
import com.vaadin.hilla.crud.filter.AndFilter;
import com.vaadin.hilla.crud.filter.Filter;
import com.vaadin.hilla.crud.filter.OrFilter;
import com.vaadin.hilla.crud.filter.PropertyStringFilter;
-import jakarta.persistence.criteria.Path;
-import jakarta.persistence.criteria.Root;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.jpa.domain.Specification;
-import org.springframework.stereotype.Component;
/**
* Utility class for converting Hilla {@link Filter} specifications into JPA
* filter specifications. This class can be used to implement filtering for
* custom {@link ListService} or {@link CrudService} implementations that use
* JPA as the data source.
- *
- * This class requires an EntityManager to be available as a Spring bean and
- * thus should be injected into the bean that wants to use the converter.
- * Manually creating new instances of this class will not work.
*/
-@Component
-public class JpaFilterConverter {
+public final class JpaFilterConverter {
- @Autowired
- private EntityManager em;
+ private JpaFilterConverter() {
+ // Utils only
+ }
/**
- * Converts the given Hilla filter specification into a JPA filter
- * specification for the specified entity class.
+ * Converts the given filter specification into a JPA filter specification
+ * for the specified entity class.
*
* If the filter contains {@link PropertyStringFilter} instances, their
* properties, or nested property paths, need to match the structure of the
@@ -45,7 +36,8 @@ public class JpaFilterConverter {
* the entity class
* @return a JPA filter specification for the given filter
*/
- public Specification toSpec(Filter rawFilter, Class entity) {
+ public static Specification toSpec(Filter rawFilter,
+ Class entity) {
if (rawFilter == null) {
return Specification.anyOf();
}
@@ -56,35 +48,10 @@ public Specification toSpec(Filter rawFilter, Class entity) {
return Specification.anyOf(filter.getChildren().stream()
.map(f -> toSpec(f, entity)).toList());
} else if (rawFilter instanceof PropertyStringFilter filter) {
- Class> javaType = extractPropertyJavaType(entity,
- filter.getPropertyId());
- return new PropertyStringFilterSpecification<>(filter, javaType);
+ return new PropertyStringFilterSpecification<>(filter);
} else {
- if (rawFilter != null) {
- throw new IllegalArgumentException("Unknown filter type "
- + rawFilter.getClass().getName());
- }
- return Specification.anyOf();
+ throw new IllegalArgumentException(
+ "Unknown filter type " + rawFilter.getClass().getName());
}
}
-
- private Class> extractPropertyJavaType(Class> entity,
- String propertyId) {
- if (propertyId.contains(".")) {
- String[] parts = propertyId.split("\\.");
- Root> root = em.getCriteriaBuilder().createQuery(entity)
- .from(entity);
- Path> path = root.get(parts[0]);
- int i = 1;
- while (i < parts.length) {
- path = path.get(parts[i]);
- i++;
- }
- return path.getJavaType();
- } else {
- return em.getMetamodel().entity(entity).getAttribute(propertyId)
- .getJavaType();
- }
- }
-
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListRepositoryService.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListRepositoryService.java
index 1e2ae42e34..8ebfb63f31 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListRepositoryService.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListRepositoryService.java
@@ -8,8 +8,9 @@
import com.googlecode.gentyref.GenericTypeReflector;
import com.vaadin.hilla.EndpointExposed;
-import com.vaadin.hilla.Nullable;
import com.vaadin.hilla.crud.filter.Filter;
+
+import org.jspecify.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.data.domain.Pageable;
@@ -25,9 +26,6 @@
public class ListRepositoryService & JpaSpecificationExecutor>
implements ListService, GetService, CountService {
- @Autowired
- private JpaFilterConverter jpaFilterConverter;
-
@Autowired
private ApplicationContext applicationContext;
@@ -105,7 +103,7 @@ public long count(@Nullable Filter filter) {
* @return a JPA specification
*/
protected Specification toSpec(@Nullable Filter filter) {
- return jpaFilterConverter.toSpec(filter, entityClass);
+ return JpaFilterConverter.toSpec(filter, entityClass);
}
@SuppressWarnings("unchecked")
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListService.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListService.java
index 84e2bbdf73..af8baf3140 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListService.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/ListService.java
@@ -2,11 +2,12 @@
import java.util.List;
-import com.vaadin.hilla.Nonnull;
-import com.vaadin.hilla.Nullable;
-import com.vaadin.hilla.crud.filter.Filter;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Pageable;
+import com.vaadin.hilla.crud.filter.Filter;
+
/**
* A browser-callable service that can list the given type of object.
*/
@@ -21,7 +22,7 @@ public interface ListService {
* the filter to apply or {@code null} to not filter
* @return a list of objects or an empty list if no objects were found
*/
- @Nonnull
- List<@Nonnull T> list(Pageable pageable, @Nullable Filter filter);
+ @NonNull
+ List<@NonNull T> list(Pageable pageable, @Nullable Filter filter);
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/PropertyStringFilterSpecification.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/PropertyStringFilterSpecification.java
index 103a3b4fcf..e06c843f5e 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/PropertyStringFilterSpecification.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/crud/PropertyStringFilterSpecification.java
@@ -17,18 +17,16 @@
public class PropertyStringFilterSpecification implements Specification {
private final PropertyStringFilter filter;
- private final Class> javaType;
- public PropertyStringFilterSpecification(PropertyStringFilter filter,
- Class> javaType) {
+ public PropertyStringFilterSpecification(PropertyStringFilter filter) {
this.filter = filter;
- this.javaType = javaType;
}
@Override
public Predicate toPredicate(Root root, CriteriaQuery> query,
CriteriaBuilder criteriaBuilder) {
String value = filter.getFilterValue();
+ Class> javaType = getJavaType(filter.getPropertyId(), root);
Path propertyPath = getPath(filter.getPropertyId(), root);
if (javaType == String.class) {
Expression expr = criteriaBuilder.lower(propertyPath);
@@ -171,6 +169,21 @@ private Path getPath(String propertyId, Root root) {
return path;
}
+ private Class> getJavaType(String propertyId, Root root) {
+ if (propertyId.contains(".")) {
+ String[] parts = propertyId.split("\\.");
+ Path> path = root.get(parts[0]);
+ int i = 1;
+ while (i < parts.length) {
+ path = path.get(parts[i]);
+ i++;
+ }
+ return path.getJavaType();
+ } else {
+ return root.get(propertyId).getJavaType();
+ }
+ }
+
private boolean isBoolean(Class> javaType) {
return javaType == boolean.class || javaType == Boolean.class;
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushConfigurer.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushConfigurer.java
index fb9bacb4f8..305840df49 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushConfigurer.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushConfigurer.java
@@ -4,6 +4,8 @@
import java.util.Collections;
import java.util.List;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
import com.vaadin.hilla.EndpointProperties;
import org.atmosphere.client.TrackMessageSizeInterceptor;
import org.atmosphere.cpr.ApplicationConfig;
@@ -14,6 +16,7 @@
import org.atmosphere.interceptor.AtmosphereResourceLifecycleInterceptor;
import org.atmosphere.interceptor.SuspendTrackerInterceptor;
import org.atmosphere.util.SimpleBroadcaster;
+import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
@@ -46,8 +49,10 @@ public PushConfigurer(EndpointProperties endpointProperties) {
}
@Bean
- PushEndpoint pushEndpoint() {
- return new PushEndpoint();
+ PushEndpoint pushEndpoint(
+ @Qualifier("hillaEndpointObjectMapper") ObjectMapper objectMapper,
+ PushMessageHandler pushMessageHandler) {
+ return new PushEndpoint(objectMapper, pushMessageHandler);
}
@Bean
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushEndpoint.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushEndpoint.java
index 60de0a4d96..57411abf03 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushEndpoint.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushEndpoint.java
@@ -4,6 +4,8 @@
import java.security.Principal;
import java.util.function.Consumer;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.atmosphere.cpr.AtmosphereRequest;
import org.atmosphere.cpr.AtmosphereResource;
import org.atmosphere.cpr.AtmosphereResourceEvent;
@@ -12,14 +14,11 @@
import org.atmosphere.util.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.ObjectMapper;
-
import com.vaadin.hilla.push.messages.fromclient.AbstractServerMessage;
import com.vaadin.hilla.push.messages.toclient.AbstractClientMessage;
@@ -28,11 +27,15 @@
*/
public class PushEndpoint extends AtmosphereHandlerAdapter {
- @Autowired
private ObjectMapper objectMapper;
- @Autowired
private PushMessageHandler pushMessageHandler;
+ PushEndpoint(ObjectMapper objectMapper,
+ PushMessageHandler pushMessageHandler) {
+ this.objectMapper = objectMapper;
+ this.pushMessageHandler = pushMessageHandler;
+ }
+
@Override
public void onRequest(AtmosphereResource resource) throws IOException {
String method = resource.getRequest().getMethod();
@@ -82,6 +85,8 @@ public void onStateChange(AtmosphereResourceEvent event)
super.onStateChange(event);
if (event.isCancelled() || event.isResumedOnTimeout()) {
onDisconnect(event);
+ } else if (event.isResuming()) {
+ onReconnect(event);
}
}
@@ -168,6 +173,16 @@ private void onDisconnect(AtmosphereResourceEvent event) {
pushMessageHandler.handleBrowserDisconnect(event.getResource().uuid());
}
+ /**
+ * Called when the push channel is disconnected.
+ *
+ * @param event
+ * the Atmosphere event
+ */
+ private void onReconnect(AtmosphereResourceEvent event) {
+ pushMessageHandler.handleBrowserReconnect(event.getResource().uuid());
+ }
+
private void onThrowable(AtmosphereResourceEvent event) {
getLogger().error("Exception in push connection", event.throwable());
onDisconnect(event);
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushMessageHandler.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushMessageHandler.java
index d3e5c6a1e5..e63c3a57dd 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushMessageHandler.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/push/PushMessageHandler.java
@@ -219,6 +219,19 @@ public void handleBrowserConnect(String connectionId) {
fluxSubscriptionInfos.put(connectionId, new ConcurrentHashMap<>());
}
+ /**
+ * Called when the browser establishes a new connection.
+ *
+ * Only ever called once for the same connectionId parameter.
+ *
+ * @param connectionId
+ * the id of the connection
+ */
+ public void handleBrowserReconnect(String connectionId) {
+ fluxSubscriptionInfos.putIfAbsent(connectionId,
+ new ConcurrentHashMap<>());
+ }
+
/**
* Called when the browser connection has been lost.
*
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java
index 858b1dfbe9..e01945b147 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListener.java
@@ -32,9 +32,11 @@
import com.vaadin.flow.server.auth.NavigationAccessControl;
import com.vaadin.flow.server.auth.ViewAccessChecker;
import com.vaadin.flow.server.menu.AvailableViewInfo;
-import com.vaadin.flow.server.menu.MenuRegistry;
+import com.vaadin.flow.internal.menu.MenuRegistry;
import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.core.JsonProcessingException;
+
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@@ -63,53 +65,20 @@ public class RouteUnifyingIndexHtmlRequestListener
private static final Logger LOGGER = LoggerFactory
.getLogger(RouteUnifyingIndexHtmlRequestListener.class);
- private final NavigationAccessControl accessControl;
- private final DeploymentConfiguration deploymentConfiguration;
- private final boolean exposeServerRoutesToClient;
- private final ObjectMapper mapper = new ObjectMapper();
- private final ViewAccessChecker viewAccessChecker;
- /**
- * Creates a new listener instance with the given route registry.
- *
- * @param deploymentConfiguration
- * the runtime deployment configuration
- * @param exposeServerRoutesToClient
- * whether to expose server routes to the client
- */
- public RouteUnifyingIndexHtmlRequestListener(
- DeploymentConfiguration deploymentConfiguration,
- @Nullable NavigationAccessControl accessControl,
- @Nullable ViewAccessChecker viewAccessChecker,
- boolean exposeServerRoutesToClient) {
- this.deploymentConfiguration = deploymentConfiguration;
- this.accessControl = accessControl;
- this.viewAccessChecker = viewAccessChecker;
- this.exposeServerRoutesToClient = exposeServerRoutesToClient;
+ private ServerAndClientViewsProvider serverAndClientViewsProvider;
- mapper.addMixIn(AvailableViewInfo.class, IgnoreMixin.class);
+ public RouteUnifyingIndexHtmlRequestListener(
+ ServerAndClientViewsProvider serverAndClientViewsProvider) {
+ this.serverAndClientViewsProvider = serverAndClientViewsProvider;
}
@Override
public void modifyIndexHtmlResponse(IndexHtmlResponse response) {
- final Map availableViews = new HashMap<>(
- collectClientViews(response.getVaadinRequest()));
- if (exposeServerRoutesToClient) {
- LOGGER.debug(
- "Exposing server-side views to the client based on user configuration");
- availableViews
- .putAll(collectServerViews(hasMainMenu(availableViews)));
- }
-
- if (availableViews.isEmpty()) {
- LOGGER.debug(
- "No server-side nor client-side views found, skipping response modification.");
- return;
- }
try {
- final String fileRoutesJson = mapper
- .writeValueAsString(availableViews);
- final String script = SCRIPT_STRING.formatted(fileRoutesJson);
+ final String script = SCRIPT_STRING
+ .formatted(serverAndClientViewsProvider
+ .createFileRoutesJson(response.getVaadinRequest()));
response.getDocument().head().appendElement("script")
.appendChild(new DataNode(script));
} catch (IOException e) {
@@ -119,92 +88,4 @@ public void modifyIndexHtmlResponse(IndexHtmlResponse response) {
}
}
- protected Map collectClientViews(
- VaadinRequest request) {
-
- return MenuRegistry
- .collectClientMenuItems(true, deploymentConfiguration, request)
- .entrySet().stream()
- .filter(view -> !hasRequiredParameter(
- view.getValue().routeParameters()))
- .collect(Collectors.toMap(Map.Entry::getKey,
- Map.Entry::getValue));
- }
-
- private boolean hasRequiredParameter(
- Map routeParameters) {
- return routeParameters != null && !routeParameters.isEmpty()
- && routeParameters.values().stream().anyMatch(
- paramType -> paramType == RouteParamType.REQUIRED);
- }
-
- protected Map collectServerViews(
- boolean hasMainMenu) {
- final var vaadinService = VaadinService.getCurrent();
- if (vaadinService == null) {
- LOGGER.debug(
- "No VaadinService found, skipping server view collection");
- return Collections.emptyMap();
- }
- final var serverRouteRegistry = vaadinService.getRouter().getRegistry();
-
- var accessControls = Stream.of(accessControl, viewAccessChecker)
- .filter(Objects::nonNull).toList();
-
- var serverRoutes = new HashMap();
-
- if (vaadinService.getInstantiator().getMenuAccessControl()
- .getPopulateClientSideMenu() == MenuAccessControl.PopulateClientMenu.ALWAYS
- || hasMainMenu) {
- MenuRegistry.collectAndAddServerMenuItems(
- RouteConfiguration.forRegistry(serverRouteRegistry),
- accessControls, serverRoutes);
- }
-
- return serverRoutes.values().stream()
- .filter(view -> view.routeParameters().values().stream()
- .noneMatch(param -> param == RouteParamType.REQUIRED))
- .collect(Collectors.toMap(this::getMenuLink,
- Function.identity()));
- }
-
- private boolean hasMainMenu(Map availableViews) {
- Map clientItems = new HashMap<>(
- availableViews);
-
- Set clientEntries = new HashSet<>(clientItems.keySet());
- for (String key : clientEntries) {
- if (!clientItems.containsKey(key)) {
- continue;
- }
- AvailableViewInfo viewInfo = clientItems.get(key);
- if (viewInfo.children() != null) {
- RouteUtil.removeChildren(clientItems, viewInfo, key);
- }
- }
- return !clientItems.isEmpty() && clientItems.size() == 1
- && clientItems.values().iterator().next().route().equals("");
- }
-
- /**
- * Gets menu link with omitted route parameters.
- *
- * @param info
- * the menu item's target view
- * @return target path for menu link
- */
- private String getMenuLink(AvailableViewInfo info) {
- final var parameterNames = info.routeParameters().keySet();
- return Stream.of(info.route().split("/"))
- .filter(Predicate.not(parameterNames::contains))
- .collect(Collectors.joining("/"));
- }
-
- /**
- * Mixin to ignore unwanted fields in the json results.
- */
- abstract static class IgnoreMixin {
- @JsonIgnore
- abstract List children(); // we don't need it!
- }
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUtil.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUtil.java
index 64e68739d1..1db3302c3f 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUtil.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/RouteUtil.java
@@ -5,7 +5,7 @@
import com.vaadin.flow.server.VaadinRequest;
import com.vaadin.flow.server.VaadinServletContext;
import com.vaadin.flow.server.menu.AvailableViewInfo;
-import com.vaadin.flow.server.menu.MenuRegistry;
+import com.vaadin.flow.internal.menu.MenuRegistry;
import com.vaadin.flow.server.startup.ApplicationConfiguration;
import jakarta.servlet.http.HttpServletRequest;
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/ServerAndClientViewsProvider.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/ServerAndClientViewsProvider.java
new file mode 100644
index 0000000000..b755ad7230
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/route/ServerAndClientViewsProvider.java
@@ -0,0 +1,181 @@
+package com.vaadin.hilla.route;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.lang.Nullable;
+
+import com.vaadin.flow.function.DeploymentConfiguration;
+import com.vaadin.flow.router.RouteConfiguration;
+import com.vaadin.flow.server.VaadinRequest;
+import com.vaadin.flow.server.VaadinService;
+import com.vaadin.flow.server.auth.MenuAccessControl;
+import com.vaadin.flow.server.auth.NavigationAccessControl;
+import com.vaadin.flow.server.auth.ViewAccessChecker;
+import com.vaadin.flow.server.menu.AvailableViewInfo;
+import com.vaadin.flow.internal.menu.MenuRegistry;
+import com.vaadin.flow.server.menu.RouteParamType;
+
+public class ServerAndClientViewsProvider {
+
+ private final NavigationAccessControl accessControl;
+ private final DeploymentConfiguration deploymentConfiguration;
+ private final boolean exposeServerRoutesToClient;
+ private final ObjectMapper mapper = new ObjectMapper();
+ private final ViewAccessChecker viewAccessChecker;
+
+ private static final Logger LOGGER = LoggerFactory
+ .getLogger(ServerAndClientViewsProvider.class);
+
+ /**
+ * Creates a new listener instance with the given route registry.
+ *
+ * @param deploymentConfiguration
+ * the runtime deployment configuration
+ * @param exposeServerRoutesToClient
+ * whether to expose server routes to the client
+ */
+ public ServerAndClientViewsProvider(
+ DeploymentConfiguration deploymentConfiguration,
+ @Nullable NavigationAccessControl accessControl,
+ @Nullable ViewAccessChecker viewAccessChecker,
+ boolean exposeServerRoutesToClient) {
+ this.deploymentConfiguration = deploymentConfiguration;
+ this.accessControl = accessControl;
+ this.viewAccessChecker = viewAccessChecker;
+ this.exposeServerRoutesToClient = exposeServerRoutesToClient;
+
+ mapper.addMixIn(AvailableViewInfo.class, IgnoreMixin.class);
+ }
+
+ public String createFileRoutesJson(VaadinRequest request)
+ throws JsonProcessingException {
+ final Map availableViews = new HashMap<>(
+ collectClientViews(request));
+ final boolean hasAutoLayout = MenuRegistry.hasHillaMainLayout(
+ request.getService().getDeploymentConfiguration());
+ if (exposeServerRoutesToClient) {
+ LOGGER.debug(
+ "Exposing server-side views to the client based on user configuration");
+ availableViews.putAll(collectServerViews(hasAutoLayout));
+ }
+
+ return mapper.writeValueAsString(availableViews);
+ }
+
+ protected Map collectClientViews(
+ VaadinRequest request) {
+
+ final Map viewInfoMap = MenuRegistry
+ .collectClientMenuItems(true, deploymentConfiguration, request);
+
+ final Set clientViewEntries = new HashSet<>(
+ viewInfoMap.keySet());
+ for (var path : clientViewEntries) {
+ if (!viewInfoMap.containsKey(path)) {
+ continue;
+ }
+
+ var viewInfo = viewInfoMap.get(path);
+ // Remove routes with required parameters, including nested ones
+ if (hasRequiredParameter(viewInfo)) {
+ viewInfoMap.remove(path);
+ if (viewInfo.children() != null) {
+ RouteUtil.removeChildren(viewInfoMap, viewInfo, path);
+ }
+ continue;
+ }
+
+ // Remove layouts
+ if (viewInfo.children() != null) {
+ viewInfoMap.remove(path);
+ }
+ }
+
+ return viewInfoMap;
+ }
+
+ private static boolean hasRequiredParameter(AvailableViewInfo viewInfo) {
+ final Map routeParameters = viewInfo
+ .routeParameters();
+ if (routeParameters != null && !routeParameters.isEmpty()
+ && routeParameters.values().stream().anyMatch(
+ paramType -> paramType == RouteParamType.REQUIRED)) {
+ return true;
+ }
+
+ // Nested routes could have parameters on the parent, check them also
+ final AvailableViewInfo parentViewInfo = null;
+ if (parentViewInfo != null) {
+ return hasRequiredParameter(parentViewInfo);
+ }
+
+ return false;
+ }
+
+ protected Map collectServerViews(
+ boolean hasMainMenu) {
+ final var vaadinService = VaadinService.getCurrent();
+ if (vaadinService == null) {
+ LOGGER.debug(
+ "No VaadinService found, skipping server view collection");
+ return Collections.emptyMap();
+ }
+ final var serverRouteRegistry = vaadinService.getRouter().getRegistry();
+
+ var accessControls = Stream.of(accessControl, viewAccessChecker)
+ .filter(Objects::nonNull).toList();
+
+ var serverRoutes = new HashMap();
+
+ if (vaadinService.getInstantiator().getMenuAccessControl()
+ .getPopulateClientSideMenu() == MenuAccessControl.PopulateClientMenu.ALWAYS
+ || hasMainMenu) {
+ MenuRegistry.collectAndAddServerMenuItems(
+ RouteConfiguration.forRegistry(serverRouteRegistry),
+ accessControls, serverRoutes);
+ }
+
+ return serverRoutes.values().stream()
+ .filter(view -> view.routeParameters().values().stream()
+ .noneMatch(param -> param == RouteParamType.REQUIRED))
+ .collect(Collectors.toMap(this::getMenuLink,
+ Function.identity()));
+ }
+
+ /**
+ * Gets menu link with omitted route parameters.
+ *
+ * @param info
+ * the menu item's target view
+ * @return target path for menu link
+ */
+ private String getMenuLink(AvailableViewInfo info) {
+ final var parameterNames = info.routeParameters().keySet();
+ return Stream.of(info.route().split("/"))
+ .filter(Predicate.not(parameterNames::contains))
+ .collect(Collectors.joining("/"));
+ }
+
+ /**
+ * Mixin to ignore unwanted fields in the json results.
+ */
+ abstract static class IgnoreMixin {
+ @JsonIgnore
+ abstract List children(); // we don't need it!
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/ListSignal.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/ListSignal.java
new file mode 100644
index 0000000000..9589b46cb1
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/ListSignal.java
@@ -0,0 +1,447 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.core.event.ListStateEvent;
+import com.vaadin.hilla.signals.core.event.StateEvent;
+import com.vaadin.hilla.signals.core.event.InvalidEventTypeException;
+import com.vaadin.hilla.signals.core.event.MissingFieldException;
+import com.vaadin.hilla.signals.operation.ListInsertOperation;
+import com.vaadin.hilla.signals.operation.ListRemoveOperation;
+import com.vaadin.hilla.signals.operation.OperationValidator;
+import com.vaadin.hilla.signals.operation.ReplaceValueOperation;
+import com.vaadin.hilla.signals.operation.SetValueOperation;
+import com.vaadin.hilla.signals.operation.ValidationResult;
+import com.vaadin.hilla.signals.operation.ValueOperation;
+import jakarta.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import static com.vaadin.hilla.signals.core.event.ListStateEvent.ListEntry;
+
+public class ListSignal extends Signal {
+
+ private static final class Entry implements ListEntry {
+ private final UUID id;
+ private UUID prev;
+ private UUID next;
+ private final ValueSignal value;
+
+ public Entry(UUID id, @Nullable UUID prev, @Nullable UUID next,
+ ValueSignal valueSignal) {
+ this.id = id;
+ this.prev = prev;
+ this.next = next;
+ this.value = valueSignal;
+ }
+
+ public Entry(UUID id, ValueSignal valueSignal) {
+ this(id, null, null, valueSignal);
+ }
+
+ @Override
+ public UUID id() {
+ return id;
+ }
+
+ @Override
+ public UUID previous() {
+ return prev;
+ }
+
+ @Override
+ public UUID next() {
+ return next;
+ }
+
+ @Override
+ public V value() {
+ return value.getValue();
+ }
+
+ @Override
+ public ValueSignal getValueSignal() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o)
+ return true;
+ if (!(o instanceof ListEntry> entry))
+ return false;
+ return Objects.equals(id, entry.id());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(id);
+ }
+ }
+
+ private static final Logger LOGGER = LoggerFactory
+ .getLogger(ListSignal.class);
+ private final Map> entries = new HashMap<>();
+
+ private UUID head;
+ private UUID tail;
+
+ public ListSignal(Class valueType) {
+ super(valueType);
+ }
+
+ protected ListSignal(ListSignal delegate) {
+ super(delegate);
+ }
+
+ @Override
+ protected ListSignal getDelegate() {
+ return (ListSignal) super.getDelegate();
+ }
+
+ @Override
+ public Flux subscribe(String signalId) {
+ if (getDelegate() != null) {
+ return getDelegate().subscribe(signalId);
+ }
+ var signalEntry = entries.get(UUID.fromString(signalId));
+ return signalEntry.value.subscribe();
+ }
+
+ @Override
+ public void submit(ObjectNode event) {
+ var rawEventType = StateEvent.extractRawEventType(event);
+ // check if the event is targeting a child signal:
+ if (StateEvent.EventType.find(rawEventType).isPresent()) {
+ submitToChild(event);
+ } else {
+ super.submit(event);
+ }
+ }
+
+ protected void submitToChild(ObjectNode event) {
+ if (getDelegate() != null) {
+ getDelegate().submitToChild(event);
+ return;
+ }
+ // For internal signals, the signal id is the event id:
+ var entryId = StateEvent.extractId(event);
+ var signalEntry = entries.get(UUID.fromString(entryId));
+ if (signalEntry == null) {
+ LOGGER.debug(
+ "Signal entry not found for id: {}. Ignoring the event: {}",
+ entryId, event);
+ return;
+ }
+ signalEntry.value.submit(event);
+ }
+
+ @Override
+ protected ObjectNode createSnapshotEvent() {
+ if (getDelegate() != null) {
+ return getDelegate().createSnapshotEvent();
+ }
+ var entries = this.entries.values().stream()
+ .map(entry -> (ListEntry) entry).toList();
+ var event = new ListStateEvent<>(getId().toString(),
+ ListStateEvent.EventType.SNAPSHOT, entries);
+ event.setAccepted(true);
+ return event.toJson();
+ }
+
+ @Override
+ protected ObjectNode processEvent(ObjectNode event) {
+ try {
+ var stateEvent = new ListStateEvent<>(event, getValueType());
+ return switch (stateEvent.getEventType()) {
+ case INSERT -> handleInsert(stateEvent).toJson();
+ case REMOVE -> handleRemoval(stateEvent).toJson();
+ default -> throw new UnsupportedOperationException(
+ "Unsupported event: " + stateEvent.getEventType());
+ };
+ } catch (InvalidEventTypeException e) {
+ throw new UnsupportedOperationException(
+ "Unsupported JSON: " + event, e);
+ }
+ }
+
+ protected ListStateEvent handleInsert(ListStateEvent event) {
+ if (getDelegate() != null) {
+ return getDelegate().handleInsert(event);
+ }
+ if (event.getValue() == null) {
+ throw new MissingFieldException(StateEvent.Field.VALUE);
+ }
+ var toBeInserted = createEntry(event.getValue());
+ if (entries.containsKey(toBeInserted.id())) {
+ // already exists (the chances of this happening are extremely low)
+ LOGGER.warn(
+ "Duplicate UUID generation detected when adding a new entry: {}, rejecting the insert event.",
+ toBeInserted.id());
+ event.setAccepted(false);
+ return event;
+ }
+ switch (event.getPosition()) {
+ case FIRST -> throw new UnsupportedOperationException(
+ "Insert first is not supported");
+ case BEFORE -> throw new UnsupportedOperationException(
+ "Insert before is not supported");
+ case AFTER -> throw new UnsupportedOperationException(
+ "Insert after is not supported");
+ case LAST -> {
+ if (tail == null) {
+ // first entry being added:
+ head = tail = toBeInserted.id();
+ } else {
+ var currentTail = entries.get(tail);
+ currentTail.next = toBeInserted.id();
+ toBeInserted.prev = currentTail.id();
+ tail = toBeInserted.id();
+ }
+ entries.put(toBeInserted.id(), toBeInserted);
+ event.setEntryId(toBeInserted.id());
+ event.setAccepted(true);
+ return event;
+ }
+ }
+ return event;
+ }
+
+ private Entry createEntry(T value) {
+ return new Entry<>(UUID.randomUUID(), createValueSignal(value));
+ }
+
+ private ValueSignal createValueSignal(T value) {
+ return new ValueSignal<>(value, getValueType());
+ }
+
+ protected ListStateEvent handleRemoval(ListStateEvent event) {
+ if (getDelegate() != null) {
+ return getDelegate().handleRemoval(event);
+ }
+ if (event.getEntryId() == null) {
+ throw new MissingFieldException(ListStateEvent.Field.ENTRY_ID);
+ }
+ if (head == null || entries.isEmpty()) {
+ event.setAccepted(false);
+ return event;
+ }
+ var toBeRemovedEntry = entries.get(event.getEntryId());
+ if (toBeRemovedEntry == null) {
+ // no longer exists anyway
+ event.setAccepted(true);
+ return event;
+ }
+
+ if (head.equals(toBeRemovedEntry.id())) {
+ // removing head
+ if (toBeRemovedEntry.next() == null) {
+ // removing the only entry
+ head = tail = null;
+ } else {
+ var newHead = entries.get(toBeRemovedEntry.next());
+ head = newHead.id();
+ newHead.prev = null;
+ }
+ } else {
+ var prev = entries.get(toBeRemovedEntry.previous());
+ var next = entries.get(toBeRemovedEntry.next());
+ if (next == null) {
+ // removing tail
+ tail = prev.id();
+ prev.next = null;
+ } else {
+ prev.next = next.id();
+ next.prev = prev.id();
+ }
+ }
+ entries.remove(toBeRemovedEntry.id());
+
+ event.setAccepted(true);
+ return event;
+ }
+
+ protected ListEntry getEntry(UUID entryId) {
+ return entries.get(entryId);
+ }
+
+ private static class ValidatedListSignal extends ListSignal {
+
+ private final OperationValidator operationValidator;
+
+ private ValidatedListSignal(ListSignal delegate,
+ OperationValidator operationValidator) {
+ super(delegate);
+ this.operationValidator = operationValidator;
+ }
+
+ @Override
+ protected ListStateEvent handleInsert(ListStateEvent event) {
+ var listInsertOperation = new ListInsertOperation<>(event.getId(),
+ event.getPosition(), event.getValue());
+ var validationResult = operationValidator
+ .validate(listInsertOperation);
+ return handleValidationResult(event, validationResult,
+ super::handleInsert);
+ }
+
+ @Override
+ protected ListStateEvent handleRemoval(ListStateEvent event) {
+ if (event.getEntryId() == null) {
+ throw new MissingFieldException(ListStateEvent.Field.ENTRY_ID);
+ }
+ var entryToRemove = getEntry(event.getEntryId());
+ var listRemoveOperation = new ListRemoveOperation<>(event.getId(),
+ entryToRemove);
+ var validationResult = operationValidator
+ .validate(listRemoveOperation);
+ return handleValidationResult(event, validationResult,
+ super::handleRemoval);
+ }
+
+ @Override
+ protected void submitToChild(ObjectNode event) {
+ // are we interested in this event:
+ if (!StateEvent.isSetEvent(event)
+ && !StateEvent.isReplaceEvent(event)) {
+ super.submitToChild(event);
+ return;
+ }
+
+ var valueOperation = extractValueOperation(event);
+ var validationResult = operationValidator.validate(valueOperation);
+ handleValidationResult(event, validationResult,
+ super::submitToChild);
+ }
+
+ private ValueOperation extractValueOperation(ObjectNode event) {
+ if (StateEvent.isSetEvent(event)) {
+ return SetValueOperation.of(event, getValueType());
+ } else if (StateEvent.isReplaceEvent(event)) {
+ return ReplaceValueOperation.of(event, getValueType());
+ } else {
+ throw new UnsupportedOperationException(
+ "Unsupported event: " + event);
+ }
+ }
+
+ private ListStateEvent handleValidationResult(
+ ListStateEvent event, ValidationResult validationResult,
+ Function, ListStateEvent> handler) {
+ if (validationResult.isOk()) {
+ return handler.apply(event);
+ } else {
+ return rejectEvent(event, validationResult);
+ }
+ }
+
+ private ListStateEvent rejectEvent(ListStateEvent event,
+ ValidationResult result) {
+ event.setAccepted(false);
+ event.setValidationError(result.getErrorMessage());
+ return event;
+ }
+
+ private void handleValidationResult(ObjectNode event,
+ ValidationResult validationResult,
+ Consumer handler) {
+ if (validationResult.isOk()) {
+ handler.accept(event);
+ } else {
+ handler.accept(rejectEvent(event, validationResult));
+ }
+ }
+
+ private ObjectNode rejectEvent(ObjectNode event,
+ ValidationResult result) {
+ var stateEvent = new StateEvent<>(event, getValueType());
+ stateEvent.setAccepted(false);
+ stateEvent.setValidationError(result.getErrorMessage());
+ return stateEvent.toJson();
+ }
+ }
+
+ /**
+ * Returns a new signal that validates the operations with the provided
+ * validator. As the same validator is for all operations, the validator
+ * should be able to handle all operations that the signal supports.
+ *
+ * For example, the following code creates a signal that disallows adding
+ * values containing the word "bad":
+ *
+ *
+ * ListSignal<String> signal = new ListSignal<>(String.class);
+ * ListSignal<String> noBadWordSignal = signal.withOperationValidator(op -> {
+ * if (op instanceof ListInsertOperation<String> insertOp && insertOp.value().contains("bad")) {
+ * return ValidationResult.reject("Bad words are not allowed");
+ * }
+ * return ValidationResult.allow();
+ * });
+ *
+ *
+ * In the example above, the validator does not cover the set and replace
+ * operations that can affect the entry values after insertion.
+ * A similar type checking can be done for the set and replace operation
+ * if needed. However, the ValueOperation
type allows unifying
+ * the validation logic for all the operations that are manipulating the
+ * value.
+ * The following example shows how to define a validator that covers all the
+ * operations that can affect the entry values:
+ *
+ *
+ * ListSignal<String> signal = new ListSignal<>(String.class);
+ * ListSignal<String> noBadWordSignal = signal.withOperationValidator(op -> {
+ * if (op instanceof ValueOperation<String> valueOp && valueOp.value().contains("bad")) {
+ * return ValidationResult.reject("Bad words are not allowed");
+ * }
+ * return ValidationResult.allow();
+ * });
+ *
+ *
+ * As ListInsertOperation
, SetValueOperation
, and
+ * ReplaceValueOperation
implement the
+ * ValueOperation
, the validator covers all of these
+ * operations.
+ *
+ * @param validator
+ * the operation validator, not null
+ * @return a new signal that validates the operations with the provided
+ * validator
+ * @throws NullPointerException
+ * if the validator is null
+ */
+ public ListSignal withOperationValidator(
+ OperationValidator validator) {
+ Objects.requireNonNull(validator, "Validator cannot be null");
+ return new ValidatedListSignal<>(this, validator);
+ }
+
+ @Override
+ public ListSignal asReadonly() {
+ return this.withOperationValidator(op -> ValidationResult
+ .reject("Read-only signal does not allow any modifications"));
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/NumberSignal.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/NumberSignal.java
new file mode 100644
index 0000000000..f74cd41068
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/NumberSignal.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.core.event.InvalidEventTypeException;
+import com.vaadin.hilla.signals.core.event.MissingFieldException;
+import com.vaadin.hilla.signals.core.event.StateEvent;
+import com.vaadin.hilla.signals.operation.IncrementOperation;
+import com.vaadin.hilla.signals.operation.OperationValidator;
+import com.vaadin.hilla.signals.operation.ReplaceValueOperation;
+import com.vaadin.hilla.signals.operation.SetValueOperation;
+import com.vaadin.hilla.signals.operation.ValidationResult;
+
+import java.util.Objects;
+
+/**
+ * A signal that holds a number value.
+ */
+public class NumberSignal extends ValueSignal {
+
+ /**
+ * Creates a new NumberSignal with the provided default value.
+ *
+ * @param defaultValue
+ * the default value
+ *
+ * @throws NullPointerException
+ * if the default value is null
+ */
+ public NumberSignal(Double defaultValue) {
+ super(defaultValue, Double.class);
+ }
+
+ /**
+ * Creates a new NumberSignal with the default value of 0.
+ */
+ public NumberSignal() {
+ this(0.0);
+ }
+
+ protected NumberSignal(NumberSignal delegate) {
+ super(delegate);
+ }
+
+ @Override
+ protected NumberSignal getDelegate() {
+ return (NumberSignal) super.getDelegate();
+ }
+
+ /**
+ * Processes the event and updates the signal value if needed. Note that
+ * this method is not thread-safe and should be called from a synchronized
+ * context.
+ *
+ * @param event
+ * the event to process
+ * @return true
if the event was successfully processed and the
+ * signal value was updated, false
otherwise.
+ */
+ @Override
+ protected ObjectNode processEvent(ObjectNode event) {
+ try {
+ var stateEvent = new StateEvent<>(event, Double.class);
+ if (!StateEvent.EventType.INCREMENT
+ .equals(stateEvent.getEventType())) {
+ return super.processEvent(event);
+ }
+ return handleIncrement(stateEvent);
+ } catch (InvalidEventTypeException | MissingFieldException e) {
+ throw new UnsupportedOperationException(
+ "Unsupported JSON: " + event, e);
+ }
+ }
+
+ protected ObjectNode handleIncrement(StateEvent stateEvent) {
+ if (getDelegate() != null) {
+ return getDelegate().handleIncrement(stateEvent);
+ } else {
+ Double expectedValue = getValue();
+ Double newValue = expectedValue + stateEvent.getValue();
+ boolean accepted = super.compareAndSet(newValue, expectedValue);
+ stateEvent.setAccepted(accepted);
+ return stateEvent.toJson();
+ }
+ }
+
+ private static class ValidatedNumberSignal extends NumberSignal {
+
+ private final OperationValidator validator;
+
+ private ValidatedNumberSignal(NumberSignal delegate,
+ OperationValidator validator) {
+ super(delegate);
+ this.validator = validator;
+ }
+
+ @Override
+ protected ObjectNode handleIncrement(StateEvent stateEvent) {
+ var operation = IncrementOperation.of(stateEvent.getId(),
+ stateEvent.getValue());
+ var validationResult = validator.validate(operation);
+ return handleValidationResult(stateEvent, validationResult,
+ super::handleIncrement);
+ }
+
+ @Override
+ protected ObjectNode handleSetEvent(StateEvent stateEvent) {
+ var operation = SetValueOperation.of(stateEvent.getId(),
+ stateEvent.getValue());
+ var validation = validator.validate(operation);
+ return handleValidationResult(stateEvent, validation,
+ super::handleSetEvent);
+ }
+
+ @Override
+ protected ObjectNode handleReplaceEvent(StateEvent stateEvent) {
+ var operation = ReplaceValueOperation.of(stateEvent.getId(),
+ stateEvent.getExpected(), stateEvent.getValue());
+ var validation = validator.validate(operation);
+ return handleValidationResult(stateEvent, validation,
+ super::handleReplaceEvent);
+ }
+ }
+
+ /**
+ * Returns a new signal that validates the operations with the provided
+ * validator. As the same validator is for all operations, the validator
+ * should be able to handle all operations that the signal supports.
+ *
+ * For example, the following code creates a signal that only allows
+ * increment by 1:
+ *
+ *
+ * NumberSignal number = new NumberSignal(42.0);
+ * NumberSignal limitedNumber = number.withOperationValidator(operation -> {
+ * if (op instanceof IncrementOperation increment
+ * && increment.value() != 1) {
+ * return ValidationResult
+ * .reject("Only increment by 1 is allowed");
+ * }
+ * return ValidationResult.allow();
+ * });
+ *
+ *
+ * Note that the above allows other operations without any validations.
+ * If more concise restrictions are needed, specialized operation type
+ * should be used:
+ *
+ *
+ * NumberSignal number = new NumberSignal(42.0);
+ * NumberSignal limitedNumber = number.withOperationValidator(operation -> {
+ * return switch (operation) {
+ * case IncrementOperation increment -> {
+ * if (increment.value() != 1) {
+ * yield ValidationResult
+ * .reject("Only increment by 1 is allowed");
+ * }
+ * yield ValidationResult.allow();
+ * }
+ * case ReplaceValueOperation<Double> ignored ->
+ * ValidationResult.reject("No setting is allowed");
+ * case SetValueOperation<Double> ignored ->
+ * ValidationResult.reject("No replacing is allowed");
+ * default -> ValidationResult.reject("Unknown operation is not allowed");
+ * };
+ * });
+ *
+ *
+ * @param validator
+ * the operation validator, not null
+ * @return a new signal that validates the operations with the provided
+ * validator.
+ * @throws NullPointerException
+ * if the validator is null
+ */
+ @Override
+ public NumberSignal withOperationValidator(
+ OperationValidator validator) {
+ Objects.requireNonNull(validator, "Validator cannot be null");
+ return new ValidatedNumberSignal(this, validator);
+ }
+
+ @Override
+ public NumberSignal asReadonly() {
+ return this.withOperationValidator(op -> ValidationResult
+ .reject("Read-only signal does not allow any modifications"));
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/Signal.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/Signal.java
new file mode 100644
index 0000000000..868f80084c
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/Signal.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.core.event.StateEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Sinks;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.locks.ReentrantLock;
+
+public abstract class Signal {
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private final UUID id = UUID.randomUUID();
+
+ private final Class valueType;
+
+ private final Set> subscribers = new HashSet<>();
+
+ private final Signal delegate;
+
+ private Signal(Class valueType, Signal delegate) {
+ this.valueType = Objects.requireNonNull(valueType);
+ this.delegate = delegate;
+ }
+
+ public Signal(Class valueType) {
+ this(valueType, null);
+ }
+
+ protected Signal(Signal delegate) {
+ this(Objects.requireNonNull(delegate).getValueType(), delegate);
+ }
+
+ protected Signal getDelegate() {
+ return delegate;
+ }
+
+ /**
+ * Returns the signal UUID.
+ *
+ * @return the id
+ */
+ public UUID getId() {
+ return this.id;
+ }
+
+ /**
+ * Returns the signal value type.
+ *
+ * @return the value type
+ */
+ public Class getValueType() {
+ return valueType;
+ }
+
+ /**
+ * Subscribes to the signal.
+ *
+ * @return a Flux of JSON events
+ */
+ public Flux subscribe() {
+ if (delegate != null) {
+ return delegate.subscribe();
+ }
+ Sinks.Many sink = Sinks.many().unicast()
+ .onBackpressureBuffer();
+
+ return sink.asFlux().doOnSubscribe(ignore -> {
+ getLogger().debug("New Flux subscription...");
+ lock.lock();
+ try {
+ var snapshot = createSnapshotEvent();
+ sink.tryEmitNext(snapshot);
+ subscribers.add(sink);
+ } finally {
+ lock.unlock();
+ }
+ }).doFinally(ignore -> {
+ lock.lock();
+ try {
+ getLogger().debug("Unsubscribing from Signal...");
+ subscribers.remove(sink);
+ } finally {
+ lock.unlock();
+ }
+ });
+ }
+
+ /**
+ * Subscribes to an internal child signal with a specific signal id.
+ *
+ * @param signalId
+ * the internal signal id
+ * @return a Flux of JSON events
+ */
+ public Flux subscribe(String signalId) {
+ if (delegate != null) {
+ return delegate.subscribe(signalId);
+ }
+ return subscribe();
+ }
+
+ /**
+ * Submits an event to the signal and notifies subscribers about the change
+ * of the signal value.
+ *
+ * @param event
+ * the event to submit
+ */
+ public void submit(ObjectNode event) {
+ lock.lock();
+ try {
+ var processedEvent = StateEvent.isRejected(event) ? event
+ : processEvent(event);
+ notifySubscribers(processedEvent);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void notifySubscribers(ObjectNode processedEvent) {
+ if (delegate != null) {
+ delegate.notifySubscribers(processedEvent);
+ return;
+ }
+ if (StateEvent.isRejected(processedEvent)) {
+ getLogger().warn(
+ "Operation with id '{}' is rejected with validator message: '{}'",
+ StateEvent.extractId(processedEvent),
+ StateEvent.extractValidationError(processedEvent));
+ StateEvent.clearValidationError(processedEvent);
+ }
+ subscribers.removeIf(sink -> {
+ boolean failure = sink.tryEmitNext(processedEvent).isFailure();
+ if (failure) {
+ getLogger().debug("Failed push");
+ }
+ return failure;
+ });
+ }
+
+ /**
+ * Creates a snapshot event reflecting the current state of the signal.
+ *
+ * @return the snapshot event
+ */
+ protected abstract ObjectNode createSnapshotEvent();
+
+ /**
+ * Processes the event and updates the signal value if needed. Note that
+ * this method is not thread-safe and should be called from a synchronized
+ * context.
+ *
+ * @param event
+ * the event to process
+ * @return true
if the event was successfully processed and the
+ * signal value was updated, false
otherwise.
+ */
+ protected abstract ObjectNode processEvent(ObjectNode event);
+
+ /**
+ * Returns a read-only instance of the signal that rejects any attempt to
+ * modify the signal value. The read-only signal, however, receives the same
+ * updates as the original signal does.
+ *
+ * @return the read-only signal
+ */
+ public abstract Signal asReadonly();
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Signal> signal)) {
+ return false;
+ }
+ return Objects.equals(getId(), signal.getId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(getId());
+ }
+
+ /**
+ * Sets the object mapper to be used for JSON serialization in Signals. This
+ * is helpful for testing purposes. If not set, the default Hilla endpoint
+ * object mapper is used.
+ *
+ * Note: If a custom endpointMapperFactory bean defined
+ * using the
+ * {@code EndpointController.ENDPOINT_MAPPER_FACTORY_BEAN_QUALIFIER}
+ * qualifier, the mapper from that factory is used also in Signals, and
+ * there is no need to set it manually here.
+ *
+ * @param mapper
+ * the object mapper to be used in Signals
+ */
+ public static void setMapper(ObjectMapper mapper) {
+ StateEvent.setMapper(mapper);
+ }
+
+ private Logger getLogger() {
+ return LoggerFactory.getLogger(Signal.class);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/ValueSignal.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/ValueSignal.java
new file mode 100644
index 0000000000..d3e9121aea
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/ValueSignal.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.core.event.InvalidEventTypeException;
+import com.vaadin.hilla.signals.core.event.MissingFieldException;
+import com.vaadin.hilla.signals.core.event.StateEvent;
+import jakarta.annotation.Nullable;
+
+import java.util.Objects;
+import java.util.function.Function;
+
+import com.vaadin.hilla.signals.operation.OperationValidator;
+import com.vaadin.hilla.signals.operation.ReplaceValueOperation;
+import com.vaadin.hilla.signals.operation.SetValueOperation;
+import com.vaadin.hilla.signals.operation.ValidationResult;
+import reactor.core.publisher.Flux;
+
+public class ValueSignal extends Signal {
+
+ private T value;
+
+ /**
+ * Creates a new ValueSignal with the provided default value.
+ *
+ * @param defaultValue
+ * the default value, not null
+ * @param valueType
+ * the value type class, not null
+ * @throws NullPointerException
+ * if the default defaultValue or the valueType is
+ * null
+ */
+ public ValueSignal(T defaultValue, Class valueType) {
+ this(valueType);
+ value = Objects.requireNonNull(defaultValue);
+ }
+
+ /**
+ * Creates a new ValueSignal with provided valueType and null
+ * as the default value.
+ *
+ * @param valueType
+ * the value type class, not null
+ * @throws NullPointerException
+ * if the default defaultValue or the valueType is
+ * null
+ */
+ public ValueSignal(Class valueType) {
+ super(valueType);
+ }
+
+ protected ValueSignal(ValueSignal delegate) {
+ super(delegate);
+ }
+
+ @Override
+ protected ValueSignal getDelegate() {
+ return (ValueSignal) super.getDelegate();
+ }
+
+ @Override
+ public Flux subscribe() {
+ if (getDelegate() != null) {
+ return getDelegate().subscribe();
+ }
+ return super.subscribe();
+ }
+
+ @Override
+ public Flux subscribe(String signalId) {
+ if (getDelegate() != null) {
+ return getDelegate().subscribe(signalId);
+ }
+ return subscribe();
+ }
+
+ /**
+ * Returns the signal's current value.
+ *
+ * @return the value
+ */
+ @Nullable
+ public T getValue() {
+ return getDelegate() != null ? getDelegate().getValue() : this.value;
+ }
+
+ @Override
+ protected ObjectNode createSnapshotEvent() {
+ if (getDelegate() != null) {
+ return getDelegate().createSnapshotEvent();
+ }
+ var snapshot = new StateEvent<>(getId().toString(),
+ StateEvent.EventType.SNAPSHOT, this.value);
+ snapshot.setAccepted(true);
+ return snapshot.toJson();
+ }
+
+ /**
+ * Processes the event and updates the signal value if needed. Note that
+ * this method is not thread-safe and should be called from a synchronized
+ * context.
+ *
+ * @param event
+ * the event to process
+ * @return the processed event, with the accepted flag set to either
+ * true
or false
, and the validation error
+ * set with the validator message (if case of a failure).
+ */
+ @Override
+ protected ObjectNode processEvent(ObjectNode event) {
+ try {
+ var stateEvent = new StateEvent<>(event, getValueType());
+ return switch (stateEvent.getEventType()) {
+ case SET -> handleSetEvent(stateEvent);
+ case REPLACE -> handleReplaceEvent(stateEvent);
+ default -> throw new UnsupportedOperationException(
+ "Unsupported event: " + stateEvent.getEventType());
+ };
+ } catch (InvalidEventTypeException | MissingFieldException e) {
+ throw new UnsupportedOperationException(
+ "Unsupported JSON: " + event, e);
+ }
+ }
+
+ protected ObjectNode handleSetEvent(StateEvent stateEvent) {
+ if (getDelegate() != null) {
+ return getDelegate().handleSetEvent(stateEvent);
+ } else {
+ this.value = stateEvent.getValue();
+ stateEvent.setAccepted(true);
+ return stateEvent.toJson();
+ }
+ }
+
+ protected ObjectNode handleReplaceEvent(StateEvent stateEvent) {
+ if (getDelegate() != null) {
+ return getDelegate().handleReplaceEvent(stateEvent);
+ } else {
+ boolean accepted = compareAndSet(stateEvent.getValue(),
+ stateEvent.getExpected());
+ stateEvent.setAccepted(accepted);
+ return stateEvent.toJson();
+ }
+ }
+
+ /**
+ * Compares the current value with the expected value and updates the signal
+ * value if they match. Note that this method is not thread-safe and should
+ * be called from a synchronized context.
+ *
+ * @param newValue
+ * the new value to set
+ * @param expectedValue
+ * the expected value
+ * @return true
if the value was successfully updated,
+ * false
otherwise
+ */
+ protected boolean compareAndSet(T newValue, T expectedValue) {
+ if (Objects.equals(this.value, expectedValue)) {
+ this.value = newValue;
+ return true;
+ }
+ return false;
+ }
+
+ protected ObjectNode handleValidationResult(StateEvent event,
+ ValidationResult validationResult,
+ Function, ObjectNode> handler) {
+ if (validationResult.isOk()) {
+ return handler.apply(event);
+ } else {
+ event.setAccepted(false);
+ event.setValidationError(validationResult.getErrorMessage());
+ return event.toJson();
+ }
+ }
+
+ /**
+ * Returns a new signal that validates the operations with the provided
+ * validator. As the same validator is for all operations, the validator
+ * should be able to handle all operations that the signal supports.
+ *
+ * For example, the following code creates a signal that disallows setting
+ * values containing the word "bad":
+ *
+ *
+ * ValueSignal<String> signal = new ValueSignal<>("Foo", String.class);
+ * ValueSignal<String> noBadWordSignal = signal.withOperationValidator(op -> {
+ * if (op instanceof SetValueOperation set && set.value().contains("bad")) {
+ * return ValidationResult.reject("Bad words are not allowed");
+ * }
+ * return ValidationResult.allow();
+ * });
+ *
+ *
+ * In the example above, the validator does not cover the replace operation.
+ * A similar type checking can be done for the replace operation if needed.
+ * However, the ValueOperation
type allows unifying the
+ * validation logic for all the operations that are manipulating the value.
+ * The following example shows how to define a validator that covers both
+ * the set and replace operations:
+ *
+ *
+ * ValueSignal<String> signal = new ValueSignal<>("Foo", String.class);
+ * ValueSignal<String> noBadWordSignal = signal.withOperationValidator(op -> {
+ * if (op instanceof ValueOperation<String> valueOp && valueOp.value().contains("bad")) {
+ * return ValidationResult.reject("Bad words are not allowed");
+ * }
+ * return ValidationResult.allow();
+ * });
+ *
+ *
+ * As both SetValueOperation
and
+ * ReplaceValueOperation
implement the
+ * ValueOperation
, the validator covers both operations.
+ *
+ * @param validator
+ * the operation validator, not null
+ * @return a new signal that validates the operations with the provided
+ * validator
+ * @throws NullPointerException
+ * if the validator is null
+ */
+ public ValueSignal withOperationValidator(
+ OperationValidator validator) {
+ Objects.requireNonNull(validator, "Validator cannot be null");
+ return new ValidatedValueSignal<>(this, validator);
+ }
+
+ private static class ValidatedValueSignal extends ValueSignal {
+ private final OperationValidator validator;
+
+ private ValidatedValueSignal(ValueSignal delegate,
+ OperationValidator validator) {
+ super(delegate);
+ this.validator = validator;
+ }
+
+ @Override
+ protected ObjectNode handleSetEvent(StateEvent stateEvent) {
+ var operation = new SetValueOperation<>(stateEvent.getId(),
+ stateEvent.getValue());
+ var validation = validator.validate(operation);
+ return handleValidationResult(stateEvent, validation,
+ super::handleSetEvent);
+ }
+
+ @Override
+ protected ObjectNode handleReplaceEvent(StateEvent stateEvent) {
+ var operation = new ReplaceValueOperation<>(stateEvent.getId(),
+ stateEvent.getExpected(), stateEvent.getValue());
+ var validation = validator.validate(operation);
+ return handleValidationResult(stateEvent, validation,
+ super::handleReplaceEvent);
+ }
+ }
+
+ @Override
+ public ValueSignal asReadonly() {
+ return this.withOperationValidator(op -> ValidationResult
+ .reject("Read-only signal does not allow any modifications"));
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java
new file mode 100644
index 0000000000..680ff1c500
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/config/SignalsConfiguration.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.vaadin.hilla.ConditionalOnFeatureFlag;
+import com.vaadin.hilla.EndpointInvoker;
+import com.vaadin.hilla.signals.Signal;
+import com.vaadin.hilla.signals.core.registry.SecureSignalsRegistry;
+import com.vaadin.hilla.signals.handler.SignalsHandler;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Spring beans configuration for signals.
+ */
+@Configuration
+public class SignalsConfiguration {
+
+ private SecureSignalsRegistry signalsRegistry;
+ private SignalsHandler signalsHandler;
+ private final EndpointInvoker endpointInvoker;
+
+ public SignalsConfiguration(EndpointInvoker endpointInvoker,
+ @Qualifier("hillaEndpointObjectMapper") ObjectMapper hillaEndpointObjectMapper) {
+ this.endpointInvoker = endpointInvoker;
+ Signal.setMapper(hillaEndpointObjectMapper);
+ }
+
+ /**
+ * Initializes the SignalsRegistry bean when the fullstackSignals feature
+ * flag is enabled.
+ *
+ * @return SignalsRegistry bean instance
+ */
+ @ConditionalOnFeatureFlag("fullstackSignals")
+ @Bean
+ public SecureSignalsRegistry signalsRegistry() {
+ if (signalsRegistry == null) {
+ signalsRegistry = new SecureSignalsRegistry(endpointInvoker);
+ }
+ return signalsRegistry;
+ }
+
+ /**
+ * Initializes the SignalsHandler endpoint when the fullstackSignals feature
+ * flag is enabled.
+ *
+ * @return SignalsHandler endpoint instance
+ */
+ @Bean
+ public SignalsHandler signalsHandler(
+ @Autowired(required = false) SecureSignalsRegistry signalsRegistry) {
+ if (signalsHandler == null) {
+ signalsHandler = new SignalsHandler(signalsRegistry);
+ }
+ return signalsHandler;
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/InvalidEventTypeException.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/InvalidEventTypeException.java
new file mode 100644
index 0000000000..71a1d03480
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/InvalidEventTypeException.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.core.event;
+
+/**
+ * An exception thrown when the event type is null or invalid.
+ */
+public class InvalidEventTypeException extends RuntimeException {
+ public InvalidEventTypeException(String message) {
+ super(message);
+ }
+
+ public InvalidEventTypeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/ListStateEvent.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/ListStateEvent.java
new file mode 100644
index 0000000000..d0a29f1a71
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/ListStateEvent.java
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.core.event;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.ValueSignal;
+
+import jakarta.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.UUID;
+
+public class ListStateEvent {
+
+ public interface ListEntry {
+ UUID id();
+
+ @Nullable
+ UUID previous();
+
+ @Nullable
+ UUID next();
+
+ T value();
+
+ ValueSignal getValueSignal();
+ }
+
+ @FunctionalInterface
+ public interface ListEntryFactory {
+ ListEntry create(UUID id, UUID prev, UUID next, T value,
+ Class valueType);
+ }
+
+ /**
+ * The field names used in the JSON representation of the state event.
+ */
+ public static final class Field {
+ public static final String NEXT = "next";
+ public static final String PREV = "prev";
+ public static final String POSITION = "position";
+ public static final String ENTRIES = "entries";
+ public static final String ENTRY_ID = "entryId";
+ public static final String PARENT_SIGNAL_ID = "parentSignalId";
+ }
+
+ /**
+ * Possible types of state events.
+ */
+ public enum InsertPosition {
+ FIRST, LAST, BEFORE, AFTER;
+
+ public static InsertPosition of(String direction) {
+ return valueOf(direction.toUpperCase());
+ }
+ }
+
+ /**
+ * Possible types of state events.
+ */
+ public enum EventType {
+ SNAPSHOT, INSERT, REMOVE;
+
+ public static EventType of(String type) {
+ return valueOf(type.toUpperCase());
+ }
+ }
+
+ private final String id;
+ private final EventType eventType;
+ private Boolean accepted;
+ private final T value;
+ // Only used for snapshot event:
+ private final Collection> entries;
+ private UUID entryId;
+ // Only used for insert event:
+ private final InsertPosition insertPosition;
+ private String validationError;
+
+ public ListStateEvent(String id, EventType eventType,
+ Collection> entries) {
+ this.id = id;
+ this.eventType = eventType;
+ this.insertPosition = null;
+ this.value = null;
+ this.entries = entries;
+ }
+
+ public ListStateEvent(String id, EventType eventType, T value,
+ InsertPosition insertPosition) {
+ this.id = id;
+ this.eventType = eventType;
+ this.value = value;
+ this.insertPosition = insertPosition;
+ this.entries = null;
+ }
+
+ /**
+ * Creates a new state event using the given JSON representation.
+ *
+ * @param json
+ * The JSON representation of the event.
+ */
+ public ListStateEvent(ObjectNode json, Class valueType) {
+ this.id = StateEvent.extractId(json);
+ this.eventType = extractEventType(json);
+ this.value = this.eventType == EventType.INSERT
+ ? StateEvent.convertValue(StateEvent.extractValue(json, true),
+ valueType)
+ : null;
+ this.insertPosition = this.eventType == EventType.INSERT
+ ? extractPosition(json)
+ : null;
+ this.entryId = this.eventType == EventType.REMOVE
+ ? UUID.fromString(extractEntryId(json))
+ : null;
+ this.entries = null;
+ }
+
+ private static EventType extractEventType(JsonNode json) {
+ var rawType = StateEvent.extractRawEventType(json);
+ try {
+ return EventType.of(rawType);
+ } catch (IllegalArgumentException e) {
+ var message = String.format(
+ "Invalid event type %s. Type should be one of: %s", rawType,
+ Arrays.toString(EventType.values()));
+ throw new InvalidEventTypeException(message, e);
+ }
+ }
+
+ private static InsertPosition extractPosition(JsonNode json) {
+ var rawPosition = json.get(Field.POSITION);
+ if (rawPosition == null) {
+ var message = String.format(
+ "Missing event position. Position is required, and should be one of: %s",
+ Arrays.toString(InsertPosition.values()));
+ throw new MissingFieldException(message);
+ }
+ try {
+ return InsertPosition.of(rawPosition.asText());
+ } catch (IllegalArgumentException e) {
+ var message = String.format(
+ "Invalid event position: %s. Position should be one of: %s",
+ rawPosition.asText(),
+ Arrays.toString(InsertPosition.values()));
+ throw new InvalidEventTypeException(message, e);
+ }
+ }
+
+ public static String extractParentSignalId(JsonNode json) {
+ var rawParentSignalId = json.get(Field.PARENT_SIGNAL_ID);
+ if (rawParentSignalId == null) {
+ return null;
+ }
+ return rawParentSignalId.asText();
+ }
+
+ private static String extractEntryId(JsonNode json) {
+ var entryId = json.get(Field.ENTRY_ID);
+ if (entryId == null) {
+ throw new MissingFieldException(Field.ENTRY_ID);
+ }
+ return entryId.asText();
+ }
+
+ public ObjectNode toJson() {
+ ObjectNode snapshotData = StateEvent.MAPPER.createObjectNode();
+ snapshotData.put(StateEvent.Field.ID, id);
+ snapshotData.put(StateEvent.Field.TYPE, eventType.name().toLowerCase());
+ if (value != null) {
+ snapshotData.set(StateEvent.Field.VALUE,
+ StateEvent.MAPPER.valueToTree(value));
+ }
+ if (entries != null) {
+ ArrayNode snapshotEntries = StateEvent.MAPPER.createArrayNode();
+ entries.forEach(entry -> {
+ ObjectNode entryNode = snapshotEntries.addObject();
+ entryNode.put(StateEvent.Field.ID, entry.id().toString());
+ if (entry.next() != null) {
+ entryNode.put(Field.NEXT, entry.next().toString());
+ }
+ if (entry.previous() != null) {
+ entryNode.put(Field.PREV, entry.previous().toString());
+ }
+ if (entry.value() != null) {
+ entryNode.set(StateEvent.Field.VALUE,
+ StateEvent.MAPPER.valueToTree(entry.value()));
+ }
+ });
+ snapshotData.set(Field.ENTRIES, snapshotEntries);
+ }
+ if (insertPosition != null) {
+ snapshotData.put(Field.POSITION,
+ insertPosition.name().toLowerCase());
+ }
+ if (entryId != null) {
+ snapshotData.put(Field.ENTRY_ID, entryId.toString());
+ }
+ if (accepted != null) {
+ snapshotData.put(StateEvent.Field.ACCEPTED, accepted);
+ }
+ if (validationError != null) {
+ snapshotData.put(StateEvent.Field.VALIDATION_ERROR,
+ validationError);
+ }
+ return snapshotData;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public EventType getEventType() {
+ return eventType;
+ }
+
+ public Collection> getEntries() {
+ return entries;
+ }
+
+ public T getValue() {
+ return value;
+ }
+
+ public InsertPosition getPosition() {
+ return insertPosition;
+ }
+
+ public UUID getEntryId() {
+ return entryId;
+ }
+
+ public void setEntryId(UUID entryId) {
+ this.entryId = entryId;
+ }
+
+ public Boolean getAccepted() {
+ return accepted;
+ }
+
+ public void setAccepted(Boolean accepted) {
+ this.accepted = accepted;
+ }
+
+ public String getValidationError() {
+ return validationError;
+ }
+
+ public void setValidationError(String validationError) {
+ this.validationError = validationError;
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/MissingFieldException.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/MissingFieldException.java
new file mode 100644
index 0000000000..d893b593dd
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/MissingFieldException.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.core.event;
+
+/**
+ * An exception thrown when a required field is missing in the JSON
+ * representation of a state event.
+ */
+public class MissingFieldException extends RuntimeException {
+ public MissingFieldException(String fieldName) {
+ super("Missing field: " + fieldName);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/StateEvent.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/StateEvent.java
new file mode 100644
index 0000000000..c7822a7cef
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/event/StateEvent.java
@@ -0,0 +1,375 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.core.event;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * A utility class for representing state events out of an ObjectNode. This
+ * helps to serialize and deserialize state events without getting involved with
+ * the string literals for field names and event types.
+ *
+ * @param
+ * The type of the value of the event.
+ */
+public class StateEvent {
+
+ /**
+ * The field names used in the JSON representation of the state event.
+ */
+ public static final class Field {
+ public static final String ID = "id";
+ public static final String TYPE = "type";
+ public static final String VALUE = "value";
+ public static final String EXPECTED = "expected";
+ public static final String ACCEPTED = "accepted";
+ public static final String VALIDATION_ERROR = "validationError";
+ }
+
+ /**
+ * Possible types of state events.
+ */
+ public enum EventType {
+ SNAPSHOT, SET, REPLACE, INCREMENT;
+
+ public static EventType of(String type) {
+ return valueOf(type.toUpperCase());
+ }
+
+ public static Optional find(String type) {
+ return Arrays.stream(values())
+ .filter(e -> e.name().equalsIgnoreCase(type)).findFirst();
+ }
+ }
+
+ static ObjectMapper MAPPER;
+
+ private final String id;
+ private final EventType eventType;
+ private final T value;
+ private final T expected;
+ private Boolean accepted;
+ private String validationError;
+
+ /**
+ * Creates a new state event using the given parameters.
+ *
+ * @param id
+ * The unique identifier of the event.
+ * @param eventType
+ * The type of the event.
+ * @param value
+ * The value of the event.
+ * @param expected
+ * The expected value of the event before the change is applied.
+ */
+ public StateEvent(String id, EventType eventType, T value, T expected) {
+ this.id = id;
+ this.eventType = eventType;
+ this.value = value;
+ this.expected = expected;
+ }
+
+ /**
+ * Creates a new state event using the given parameters.
+ *
+ * @param id
+ * The unique identifier of the event.
+ * @param eventType
+ * The type of the event.
+ * @param value
+ * The value of the event.
+ */
+ public StateEvent(String id, EventType eventType, T value) {
+ this(id, eventType, value, null);
+ }
+
+ /**
+ * Creates a new state event using the given JSON representation.
+ *
+ * @param json
+ * The JSON representation of the event.
+ */
+ public StateEvent(ObjectNode json, Class valueType) {
+ this.id = extractId(json);
+ this.eventType = extractEventType(json);
+ this.value = convertValue(extractValue(json, true), valueType);
+
+ JsonNode expected = json.get(Field.EXPECTED);
+ this.expected = convertValue(expected, valueType);
+
+ }
+
+ /**
+ * Sets the object mapper to be used for serialization and deserialization
+ * of state events in Signal library.
+ *
+ * @param mapper
+ * The object mapper to be used for serialization and
+ * deserialization of state events.
+ */
+ public static void setMapper(ObjectMapper mapper) {
+ MAPPER = mapper;
+ }
+
+ public static X convertValue(JsonNode rawValue, Class valueType) {
+ if (rawValue == null) {
+ return null;
+ }
+ return MAPPER.convertValue(rawValue, valueType);
+ }
+
+ public static String extractId(JsonNode json) {
+ var id = json.get(Field.ID);
+ if (id == null) {
+ throw new MissingFieldException(Field.ID);
+ }
+ return id.asText();
+ }
+
+ public static JsonNode extractValue(JsonNode json, boolean required) {
+ var value = json.get(Field.VALUE);
+ if (value == null) {
+ if (required) {
+ throw new MissingFieldException(Field.VALUE);
+ }
+ return null;
+ }
+ return value;
+ }
+
+ public static JsonNode extractExpected(JsonNode json, boolean required) {
+ var expected = json.get(Field.EXPECTED);
+ if (expected == null) {
+ if (required) {
+ throw new MissingFieldException(Field.EXPECTED);
+ }
+ return null;
+ }
+ return expected;
+ }
+
+ public static String extractRawEventType(JsonNode json) {
+ var rawType = json.get(Field.TYPE);
+ if (rawType == null) {
+ var message = String.format(
+ "Missing event type. Type is required, and should be one of: %s",
+ Arrays.toString(EventType.values()));
+ throw new MissingFieldException(message);
+ }
+ return rawType.asText();
+ }
+
+ public static EventType extractEventType(JsonNode json) {
+ var rawType = extractRawEventType(json);
+ try {
+ return EventType.of(rawType);
+ } catch (IllegalArgumentException e) {
+ var message = String.format(
+ "Invalid event type %s. Type should be one of: %s", rawType,
+ Arrays.toString(EventType.values()));
+ throw new InvalidEventTypeException(message, e);
+ }
+ }
+
+ /**
+ * Checks if the given JSON object represents a SET state event.
+ *
+ * @param event
+ * The JSON object to check.
+ * @return true
if the given JSON object represents a SET state
+ * event, false
otherwise.
+ * @throws MissingFieldException
+ * If the event does not contain the TYPE field.
+ * @throws InvalidEventTypeException
+ * If the event contains an invalid event type.
+ */
+ public static boolean isSetEvent(ObjectNode event) {
+ var rawEventType = StateEvent.extractRawEventType(event);
+ if (rawEventType == null) {
+ throw new MissingFieldException(Field.TYPE);
+ }
+ var eventType = StateEvent.EventType.find(rawEventType)
+ .orElseThrow(() -> new InvalidEventTypeException(rawEventType));
+ return eventType == EventType.SET;
+ }
+
+ /**
+ * Checks if the given JSON object represents a REPLACE state event.
+ *
+ * @param event
+ * The JSON object to check.
+ * @return true
if the given JSON object represents a REPLACE
+ * state event, false
otherwise.
+ * @throws MissingFieldException
+ * If the event does not contain the TYPE field.
+ * @throws InvalidEventTypeException
+ * If the event contains an invalid event type.
+ */
+ public static boolean isReplaceEvent(ObjectNode event) {
+ var rawEventType = StateEvent.extractRawEventType(event);
+ if (rawEventType == null) {
+ throw new MissingFieldException(Field.TYPE);
+ }
+ var eventType = StateEvent.EventType.find(rawEventType)
+ .orElseThrow(() -> new InvalidEventTypeException(rawEventType));
+ return eventType == EventType.REPLACE;
+ }
+
+ /**
+ * Returns the JSON representation of the event.
+ *
+ * @return The JSON representation of the event.
+ */
+ public ObjectNode toJson() {
+ ObjectNode json = MAPPER.createObjectNode();
+ json.put(Field.ID, id);
+ json.put(Field.TYPE, eventType.name().toLowerCase());
+ json.set(Field.VALUE, valueAsJsonNode(getValue()));
+ if (getExpected() != null) {
+ json.set(Field.EXPECTED, valueAsJsonNode(getExpected()));
+ }
+ if (accepted != null) {
+ json.put(Field.ACCEPTED, accepted);
+ }
+ if (validationError != null) {
+ json.put(StateEvent.Field.VALIDATION_ERROR, validationError);
+ }
+ return json;
+ }
+
+ public static boolean isAccepted(ObjectNode event) {
+ return event.has(Field.ACCEPTED)
+ && event.get(Field.ACCEPTED).asBoolean();
+ }
+
+ public static boolean isRejected(ObjectNode event) {
+ return event.has(Field.ACCEPTED)
+ && !event.get(Field.ACCEPTED).asBoolean()
+ && event.has(Field.VALIDATION_ERROR)
+ && !event.get(Field.VALIDATION_ERROR).asText().isBlank();
+ }
+
+ public static String extractValidationError(ObjectNode event) {
+ if (!isRejected(event)) {
+ throw new IllegalStateException(
+ "The event is not rejected, so it does not have a validation error");
+ }
+ return event.get(Field.VALIDATION_ERROR).asText();
+ }
+
+ public static void clearValidationError(ObjectNode event) {
+ if (!isRejected(event)) {
+ throw new IllegalStateException(
+ "The event is not rejected, so it does not have a validation error");
+ }
+ event.remove(Field.VALIDATION_ERROR);
+ }
+
+ private static JsonNode valueAsJsonNode(Object value) {
+ return MAPPER.valueToTree(value);
+ }
+
+ /**
+ * Returns the unique identifier of the event.
+ *
+ * @return The unique identifier of the event.
+ */
+ public String getId() {
+ return id;
+ }
+
+ /**
+ * Returns the type of the event.
+ *
+ * @return The type of the event.
+ */
+ public EventType getEventType() {
+ return eventType;
+ }
+
+ /**
+ * Returns the value of the event.
+ *
+ * @return The value of the event.
+ */
+ public T getValue() {
+ return value;
+ }
+
+ /**
+ * Returns the expected value of the event if exists.
+ *
+ * @return The expected value of the event if exists.
+ */
+ public T getExpected() {
+ return expected;
+ }
+
+ /**
+ * Returns whether the event was accepted or not.
+ *
+ * @return whether the event was accepted or not.
+ */
+ public Boolean getAccepted() {
+ return accepted;
+ }
+
+ /**
+ * Sets whether the event was accepted or not.
+ *
+ * @param accepted
+ * whether the event was accepted or not.
+ */
+ public void setAccepted(Boolean accepted) {
+ this.accepted = accepted;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof StateEvent> that)) {
+ return false;
+ }
+ return Objects.equals(getId(), that.getId());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getId());
+ }
+
+ public String getValidationError() {
+ return validationError;
+ }
+
+ public void setValidationError(String validationError) {
+ this.validationError = validationError;
+ }
+
+ public void clearValidationError() {
+ this.validationError = null;
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/registry/SecureSignalsRegistry.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/registry/SecureSignalsRegistry.java
new file mode 100644
index 0000000000..682a2f5acc
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/registry/SecureSignalsRegistry.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.core.registry;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.AuthenticationUtil;
+import com.vaadin.hilla.EndpointInvocationException;
+import com.vaadin.hilla.EndpointInvoker;
+import com.vaadin.hilla.EndpointRegistry;
+import com.vaadin.hilla.signals.Signal;
+import jakarta.validation.constraints.NotNull;
+import org.springframework.stereotype.Component;
+
+import java.lang.reflect.Method;
+import java.security.Principal;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * Proxy for the accessing the SignalRegistry.
+ */
+@Component
+public class SecureSignalsRegistry {
+
+ record EndpointMethod(String endpoint, String method) {
+ }
+
+ private final Map endpointMethods = new HashMap<>();
+ private final SignalsRegistry delegate;
+ private final EndpointInvoker invoker;
+
+ public SecureSignalsRegistry(EndpointInvoker invoker) {
+ this.invoker = invoker;
+ this.delegate = new SignalsRegistry();
+ }
+
+ public synchronized void register(String clientSignalId,
+ String endpointName, String methodName, ObjectNode body)
+ throws EndpointInvocationException.EndpointAccessDeniedException,
+ EndpointInvocationException.EndpointNotFoundException,
+ EndpointInvocationException.EndpointBadRequestException,
+ EndpointInvocationException.EndpointInternalException {
+ Principal principal = AuthenticationUtil
+ .getSecurityHolderAuthentication();
+ Function isInRole = AuthenticationUtil
+ .getSecurityHolderRoleChecker();
+ checkAccess(endpointName, methodName, principal, isInRole);
+
+ Signal> signal = (Signal>) invoker.invoke(endpointName, methodName,
+ body, principal, isInRole);
+ endpointMethods.put(clientSignalId,
+ new EndpointMethod(endpointName, methodName));
+ delegate.register(clientSignalId, signal);
+ }
+
+ public synchronized void unsubscribe(String clientSignalId) {
+ var endpointMethodInfo = endpointMethods.get(clientSignalId);
+ if (endpointMethodInfo == null) {
+ return;
+ }
+ delegate.removeClientSignalToSignalMapping(clientSignalId);
+ endpointMethods.remove(clientSignalId);
+ }
+
+ public synchronized Signal> get(String clientSignalId)
+ throws EndpointInvocationException.EndpointAccessDeniedException,
+ EndpointInvocationException.EndpointNotFoundException {
+ var endpointMethodInfo = endpointMethods.get(clientSignalId);
+ if (endpointMethodInfo == null) {
+ return null;
+ }
+ checkAccess(endpointMethodInfo.endpoint, endpointMethodInfo.method);
+ return delegate.get(clientSignalId);
+ }
+
+ private void checkAccess(String endpointName, String methodName)
+ throws EndpointInvocationException.EndpointNotFoundException,
+ EndpointInvocationException.EndpointAccessDeniedException {
+ Principal principal = AuthenticationUtil
+ .getSecurityHolderAuthentication();
+ Function isInRole = AuthenticationUtil
+ .getSecurityHolderRoleChecker();
+ checkAccess(endpointName, methodName, principal, isInRole);
+ }
+
+ private void checkAccess(String endpointName, String methodName,
+ Principal principal, Function isInRole)
+ throws EndpointInvocationException.EndpointNotFoundException,
+ EndpointInvocationException.EndpointAccessDeniedException {
+ EndpointRegistry.VaadinEndpointData endpointData = invoker
+ .getVaadinEndpointData(endpointName);
+ Method method = getMethod(endpointData, methodName);
+ var checkError = invoker.checkAccess(endpointData, method, principal,
+ isInRole);
+ if (checkError != null) {
+ throw new EndpointInvocationException.EndpointAccessDeniedException(
+ String.format(
+ "Endpoint '%s' method '%s' request cannot be accessed, reason: '%s'",
+ endpointName, methodName, checkError));
+ }
+ }
+
+ private Method getMethod(
+ @NotNull EndpointRegistry.VaadinEndpointData endpointData,
+ String methodName)
+ throws EndpointInvocationException.EndpointNotFoundException {
+ return endpointData.getMethod(methodName).orElseThrow(
+ EndpointInvocationException.EndpointNotFoundException::new);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/registry/SignalsRegistry.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/registry/SignalsRegistry.java
new file mode 100644
index 0000000000..e6b9e86cbb
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/core/registry/SignalsRegistry.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.core.registry;
+
+import com.vaadin.hilla.signals.Signal;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.WeakHashMap;
+import java.util.stream.Collectors;
+
+/**
+ * A registry for signal instances and their client signal id mappings.
+ */
+public final class SignalsRegistry {
+
+ private static final Logger LOGGER = LoggerFactory
+ .getLogger(SignalsRegistry.class);
+ private final Map> signals = new WeakHashMap<>();
+ private final Map clientSignalToSignalMapping = new HashMap<>();
+
+ SignalsRegistry() {
+ }
+
+ /**
+ * Registers a signal instance and creates an association between the
+ * provided {@code clientSignalId} and {@code signal}.
+ *
+ * If the signal is already registered, signal instance registration is
+ * skipped. if the mapping between the provided {@code clientSignalId} and
+ * {@code signal} is already registered, the mapping is skipped, too.
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ * @param signal
+ * the signal instance, must not be null
+ * @throws NullPointerException
+ * if {@code clientSignalId} or {@code signal} is null
+ */
+ public synchronized void register(String clientSignalId, Signal> signal) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id must not be null");
+ Objects.requireNonNull(signal, "Signal must not be null");
+ if (!signals.containsKey(signal.getId())) {
+ signals.put(signal.getId(), signal);
+ }
+ if (!clientSignalToSignalMapping.containsKey(clientSignalId)) {
+ clientSignalToSignalMapping.put(clientSignalId, signal.getId());
+ }
+ LOGGER.debug("Registered client-signal: {} => signal: {}",
+ clientSignalId, signal.getId());
+ }
+
+ /**
+ * Get a signal instance by the provided {@code clientSignalId}.
+ *
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ *
+ * @return the signal instance, or null if no signal is found for the
+ * provided {@code clientSignalId}
+ * @throws NullPointerException
+ * if {@code clientSignalId} is null
+ */
+ public synchronized Signal> get(String clientSignalId) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id must not be null");
+ UUID signalId = clientSignalToSignalMapping.get(clientSignalId);
+ if (signalId == null) {
+ LOGGER.debug("No associated signal found for client signal id: {}",
+ clientSignalId);
+ return null;
+ }
+ return signals.get(signalId);
+ }
+
+ /**
+ * Get a signal instance by the provided {@code signalId}.
+ *
+ *
+ * @param signalId
+ * the signal id, must not be null
+ *
+ * @return the signal instance, or null if no signal is found for the
+ * provided {@code signalId}
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized Signal> getBySignalId(UUID signalId) {
+ Objects.requireNonNull(signalId, "Signal id must not be null");
+ return signals.get(signalId);
+ }
+
+ /**
+ * Checks if a mapping exists between a registered signal instance and the
+ * provided {@code clientSignalId}.
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ * @return true if the signal instance is registered, false otherwise
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized boolean contains(String clientSignalId) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id must not be null");
+ if (!clientSignalToSignalMapping.containsKey(clientSignalId)) {
+ return false;
+ }
+ var signalId = clientSignalToSignalMapping.get(clientSignalId);
+ if (!signals.containsKey(signalId)) {
+ throw new IllegalStateException(String.format(
+ "A mapping for client Signal exists, but the signal itself is not registered. Client signal id: %s",
+ clientSignalId));
+ }
+ return true;
+ }
+
+ /**
+ * Removes a signal instance by the provided {@code signalId}.
+ *
+ * It also removes all the possible associated client signals, too.
+ *
+ * @param signalId
+ * the signal id, must not be null
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized void unregister(UUID signalId) {
+ Objects.requireNonNull(signalId,
+ "Signal id to remove must not be null");
+ signals.remove(signalId);
+ clientSignalToSignalMapping.values().removeIf(signalId::equals);
+ LOGGER.debug(
+ "Removed signal {}, and the possible mappings between for its associated client signals, too.",
+ signalId);
+ }
+
+ /**
+ * Removes only the mapping between a signal instance and the provided
+ * {@code clientSignalId}.
+ *
+ * @param clientSignalId
+ * the client signal id, must not be null
+ * @throws NullPointerException
+ * if {@code clientSignalId} is null
+ */
+ public synchronized void removeClientSignalToSignalMapping(
+ String clientSignalId) {
+ Objects.requireNonNull(clientSignalId,
+ "Client signal id to remove must not be null");
+ clientSignalToSignalMapping.remove(clientSignalId);
+ LOGGER.debug("Removed client signal to signal mapping: {}",
+ clientSignalId);
+ }
+
+ /**
+ * Checks if the registry is empty.
+ *
+ * @return true if the registry is empty, false otherwise
+ */
+ public synchronized boolean isEmpty() {
+ return signals.isEmpty();
+ }
+
+ /**
+ * Returns the number of registered signal instances.
+ *
+ * @return the number of registered signal instances
+ */
+ public synchronized int size() {
+ return signals.size();
+ }
+
+ /**
+ * Returns the number of registered unique mappings between client signal
+ * ids and the signal instances.
+ *
+ * @return the number of registered client signals
+ */
+ public synchronized int getAllClientSubscriptionsSize() {
+ return clientSignalToSignalMapping.size();
+ }
+
+ /**
+ * Returns the Set of registered client signal ids for the provided
+ * {@code signalId}.
+ *
+ * @param signalId
+ * the signal id, must not be null
+ * @return the Set of registered client signal ids
+ * @throws NullPointerException
+ * if {@code signalId} is null
+ */
+ public synchronized Set getAllClientSignalIdsFor(UUID signalId) {
+ Objects.requireNonNull(signalId, "Signal id must not be null");
+ if (!signals.containsKey(signalId)) {
+ return Set.of();
+ }
+ return clientSignalToSignalMapping.entrySet().stream()
+ .filter(entry -> entry.getValue().equals(signalId))
+ .map(Map.Entry::getKey).collect(Collectors.toUnmodifiableSet());
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java
new file mode 100644
index 0000000000..ccd36073be
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/SignalsHandler.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.handler;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.flow.server.auth.AnonymousAllowed;
+import com.vaadin.hilla.BrowserCallable;
+import com.vaadin.hilla.EndpointInvocationException;
+import com.vaadin.hilla.signals.core.event.ListStateEvent;
+import com.vaadin.hilla.signals.core.registry.SecureSignalsRegistry;
+import jakarta.annotation.Nullable;
+import reactor.core.publisher.Flux;
+
+/**
+ * Handler Endpoint for Fullstack Signals' subscription and update events.
+ */
+@AnonymousAllowed
+@BrowserCallable
+public class SignalsHandler {
+
+ private static final String FEATURE_FLAG_ERROR_MESSAGE = """
+ %n
+ ***********************************************************************************************************************
+ * The Hilla Fullstack Signals API is currently considered experimental and may change in the future. *
+ * To use it you need to explicitly enable it in Copilot, or by adding com.vaadin.experimental.fullstackSignals=true *
+ * to src/main/resources/vaadin-featureflags.properties. *
+ ***********************************************************************************************************************
+ %n"""
+ .stripIndent();
+
+ private final SecureSignalsRegistry registry;
+
+ public SignalsHandler(@Nullable SecureSignalsRegistry registry) {
+ this.registry = registry;
+ }
+
+ /**
+ * Subscribes to a signal.
+ *
+ * @param providerEndpoint
+ * the endpoint that provides the signal
+ * @param providerMethod
+ * the endpoint method that provides the signal
+ * @param clientSignalId
+ * the client signal id
+ *
+ * @return a Flux of JSON events
+ */
+ public Flux subscribe(String providerEndpoint,
+ String providerMethod, String clientSignalId, ObjectNode body,
+ @Nullable String parentClientSignalId) {
+ if (registry == null) {
+ throw new IllegalStateException(
+ String.format(FEATURE_FLAG_ERROR_MESSAGE));
+ }
+ try {
+ if (parentClientSignalId != null) {
+ return subscribe(parentClientSignalId, clientSignalId);
+ }
+ var signal = registry.get(clientSignalId);
+ if (signal != null) {
+ return signal.subscribe().doFinally(
+ (event) -> registry.unsubscribe(clientSignalId));
+ }
+ registry.register(clientSignalId, providerEndpoint, providerMethod,
+ body);
+ return registry.get(clientSignalId).subscribe()
+ .doFinally((event) -> registry.unsubscribe(clientSignalId));
+ } catch (Exception e) {
+ return Flux.error(e);
+ }
+ }
+
+ private Flux subscribe(String parentClientSignalId,
+ String clientSignalId)
+ throws EndpointInvocationException.EndpointAccessDeniedException,
+ EndpointInvocationException.EndpointNotFoundException {
+ var parentSignal = registry.get(parentClientSignalId);
+ if (parentSignal == null) {
+ throw new IllegalStateException(String.format(
+ "Parent Signal not found for parent client signal id: %s",
+ parentClientSignalId));
+ }
+ return parentSignal.subscribe(clientSignalId)
+ .doFinally((event) -> registry.unsubscribe(clientSignalId));
+ }
+
+ /**
+ * Updates a signal with an event.
+ *
+ * @param clientSignalId
+ * the clientSignalId associated with the signal to update
+ * @param event
+ * the event to update with
+ */
+ public void update(String clientSignalId, ObjectNode event)
+ throws EndpointInvocationException.EndpointAccessDeniedException,
+ EndpointInvocationException.EndpointNotFoundException {
+ if (registry == null) {
+ throw new IllegalStateException(
+ String.format(FEATURE_FLAG_ERROR_MESSAGE));
+ }
+ var parentSignalId = ListStateEvent.extractParentSignalId(event);
+ if (parentSignalId != null) {
+ if (registry.get(parentSignalId) == null) {
+ throw new IllegalStateException(String.format(
+ "Parent Signal not found for signal id: %s",
+ parentSignalId));
+ }
+ registry.get(parentSignalId).submit(event);
+ } else {
+ if (registry.get(clientSignalId) == null) {
+ throw new IllegalStateException(
+ String.format("Signal not found for client signal: %s",
+ clientSignalId));
+ }
+ registry.get(clientSignalId).submit(event);
+ }
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/package-info.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/package-info.java
new file mode 100644
index 0000000000..01566a1346
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/handler/package-info.java
@@ -0,0 +1,4 @@
+@NonNullApi
+package com.vaadin.hilla.signals.handler;
+
+import org.springframework.lang.NonNullApi;
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/IncrementOperation.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/IncrementOperation.java
new file mode 100644
index 0000000000..d98ab5d34d
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/IncrementOperation.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+public record IncrementOperation(String operationId, Double value) implements ValueOperation {
+
+ public static IncrementOperation of(String operationId, Double value) {
+ return new IncrementOperation(operationId, value);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ListInsertOperation.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ListInsertOperation.java
new file mode 100644
index 0000000000..eef3e4496e
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ListInsertOperation.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+import com.vaadin.hilla.signals.core.event.ListStateEvent;
+
+public record ListInsertOperation(
+ String operationId,
+ ListStateEvent.InsertPosition position,
+ T value
+) implements ValueOperation {}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ListRemoveOperation.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ListRemoveOperation.java
new file mode 100644
index 0000000000..46dac9a1a1
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ListRemoveOperation.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+import com.vaadin.hilla.signals.core.event.ListStateEvent;
+
+public record ListRemoveOperation(
+ String operationId,
+ ListStateEvent.ListEntry entryToRemove
+) implements SignalOperation {}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/OperationValidator.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/OperationValidator.java
new file mode 100644
index 0000000000..de0b751b09
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/OperationValidator.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+@FunctionalInterface
+public interface OperationValidator {
+ ValidationResult validate(SignalOperation operation);
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ReplaceValueOperation.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ReplaceValueOperation.java
new file mode 100644
index 0000000000..55764d947b
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ReplaceValueOperation.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.core.event.StateEvent;
+
+public record ReplaceValueOperation(String operationId, T expected, T value) implements ValueOperation {
+
+ public static ReplaceValueOperation of(ObjectNode event, Class valueType) {
+ var rawValue = StateEvent.extractValue(event, true);
+ var rawExpected = StateEvent.extractExpected(event, true);
+ return new ReplaceValueOperation<>(StateEvent.extractId(event),
+ StateEvent.convertValue(rawExpected, valueType),
+ StateEvent.convertValue(rawValue, valueType));
+ }
+
+ public static ReplaceValueOperation of(String operationId, T expected, T value) {
+ return new ReplaceValueOperation<>(operationId, expected, value);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/SetValueOperation.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/SetValueOperation.java
new file mode 100644
index 0000000000..1a3d7081b6
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/SetValueOperation.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.signals.core.event.StateEvent;
+
+public record SetValueOperation(String operationId, T value) implements ValueOperation {
+
+ public static SetValueOperation of(ObjectNode event, Class valueType) {
+ var rawValue = StateEvent.extractValue(event, true);
+ return new SetValueOperation<>(StateEvent.extractId(event),
+ StateEvent.convertValue(rawValue, valueType));
+ }
+
+ public static SetValueOperation of(String operationId, T value) {
+ return new SetValueOperation<>(operationId, value);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/SignalOperation.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/SignalOperation.java
new file mode 100644
index 0000000000..28269a8407
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/SignalOperation.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+public interface SignalOperation {
+ String operationId();
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ValidationResult.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ValidationResult.java
new file mode 100644
index 0000000000..a16e292234
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ValidationResult.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+public class ValidationResult {
+
+ public enum Status {
+ ALLOWED, REJECTED
+ }
+
+ private final Status status;
+ private final String errorMessage;
+
+ private ValidationResult(Status status, String errorMessage) {
+ this.status = status;
+ this.errorMessage = errorMessage;
+ }
+
+ private ValidationResult(Status status) {
+ this.status = status;
+ this.errorMessage = null;
+ }
+
+ public Status getStatus() {
+ return status;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+
+ public boolean isOk() {
+ return status == Status.ALLOWED;
+ }
+
+ public boolean isRejected() {
+ return status == Status.REJECTED;
+ }
+
+ public static ValidationResult reject(String errorMessage) {
+ return new ValidationResult(Status.REJECTED, errorMessage);
+ }
+
+ public static ValidationResult allow() {
+ return new ValidationResult(Status.ALLOWED);
+ }
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ValueOperation.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ValueOperation.java
new file mode 100644
index 0000000000..c1fc427a85
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/operation/ValueOperation.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2000-2024 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.vaadin.hilla.signals.operation;
+
+public interface ValueOperation extends SignalOperation {
+ T value();
+}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/package-info.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/package-info.java
new file mode 100644
index 0000000000..99b7c4c60e
--- /dev/null
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/signals/package-info.java
@@ -0,0 +1,4 @@
+@NonNullApi
+package com.vaadin.hilla.signals;
+
+import org.springframework.lang.NonNullApi;
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/EndpointRegistryInitializer.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/EndpointRegistryInitializer.java
index 05a77ca3ec..f1db03929e 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/EndpointRegistryInitializer.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/EndpointRegistryInitializer.java
@@ -1,26 +1,13 @@
package com.vaadin.hilla.startup;
-import com.vaadin.flow.function.DeploymentConfiguration;
import com.vaadin.flow.server.ServiceInitEvent;
import com.vaadin.flow.server.VaadinServiceInitListener;
import com.vaadin.hilla.EndpointController;
-import com.vaadin.hilla.engine.EngineConfiguration;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
-import java.net.MalformedURLException;
-import java.net.URL;
-
@Component
public class EndpointRegistryInitializer implements VaadinServiceInitListener {
- private static final Logger LOGGER = LoggerFactory
- .getLogger(EndpointRegistryInitializer.class);
-
- private static final String OPEN_API_PROD_RESOURCE_PATH = '/'
- + EngineConfiguration.OPEN_API_PATH;
-
private final EndpointController endpointController;
public EndpointRegistryInitializer(EndpointController endpointController) {
@@ -29,28 +16,6 @@ public EndpointRegistryInitializer(EndpointController endpointController) {
@Override
public void serviceInit(ServiceInitEvent event) {
- var deploymentConfig = event.getSource().getDeploymentConfiguration();
- var openApiResource = getOpenApiAsResource(deploymentConfig);
- endpointController.registerEndpoints(openApiResource);
- }
-
- private URL getOpenApiAsResource(DeploymentConfiguration deploymentConfig) {
- if (deploymentConfig.isProductionMode()) {
- return getClass().getResource(OPEN_API_PROD_RESOURCE_PATH);
- }
- var openApiPathInDevMode = deploymentConfig.getProjectFolder().toPath()
- .resolve(deploymentConfig.getBuildFolder())
- .resolve(EngineConfiguration.OPEN_API_PATH);
- try {
- return openApiPathInDevMode.toFile().exists()
- ? openApiPathInDevMode.toUri().toURL()
- : null;
- } catch (MalformedURLException e) {
- LOGGER.debug(String.format(
- "%s Mode: Path %s to resource %s seems to be malformed/could not be parsed. ",
- deploymentConfig.getMode(), openApiPathInDevMode.toUri(),
- EngineConfiguration.OPEN_API_PATH), e);
- return null;
- }
+ endpointController.registerEndpoints();
}
}
diff --git a/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java b/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java
index 85ef0fc72f..6d751f9818 100644
--- a/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java
+++ b/packages/java/endpoint/src/main/java/com/vaadin/hilla/startup/RouteUnifyingServiceInitListener.java
@@ -16,15 +16,23 @@
package com.vaadin.hilla.startup;
+import com.vaadin.flow.server.HandlerHelper;
import com.vaadin.flow.server.ServiceInitEvent;
+import com.vaadin.flow.server.SynchronizedRequestHandler;
+import com.vaadin.flow.server.VaadinRequest;
+import com.vaadin.flow.server.VaadinResponse;
import com.vaadin.flow.server.VaadinServiceInitListener;
+import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.auth.NavigationAccessControl;
import com.vaadin.flow.server.auth.ViewAccessChecker;
import com.vaadin.flow.server.menu.AvailableViewInfo;
-import com.vaadin.flow.server.menu.MenuRegistry;
+import com.vaadin.flow.internal.menu.MenuRegistry;
+import com.vaadin.flow.shared.ApplicationConstants;
+import com.vaadin.flow.shared.JsonConstants;
import com.vaadin.hilla.HillaStats;
import com.vaadin.hilla.route.RouteUnifyingIndexHtmlRequestListener;
import com.vaadin.hilla.route.RouteUtil;
+import com.vaadin.hilla.route.ServerAndClientViewsProvider;
import com.vaadin.hilla.route.RouteUnifyingConfigurationProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,6 +40,7 @@
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
+import java.io.IOException;
import java.util.Map;
/**
@@ -77,15 +86,39 @@ public void serviceInit(ServiceInitEvent event) {
deploymentConfiguration.isReactEnabled());
boolean hasHillaFsRoute = false;
if (deploymentConfiguration.isReactEnabled()) {
- var routeUnifyingIndexHtmlRequestListener = new RouteUnifyingIndexHtmlRequestListener(
+ var serverAndClientViewsProvider = new ServerAndClientViewsProvider(
deploymentConfiguration, accessControl, viewAccessChecker,
routeUnifyingConfigurationProperties
.isExposeServerRoutesToClient());
+ var routeUnifyingIndexHtmlRequestListener = new RouteUnifyingIndexHtmlRequestListener(
+ serverAndClientViewsProvider);
var deploymentMode = deploymentConfiguration.isProductionMode()
? "PRODUCTION"
: "DEVELOPMENT";
event.addIndexHtmlRequestListener(
routeUnifyingIndexHtmlRequestListener);
+ if (!deploymentConfiguration.isProductionMode()) {
+ // Dynamic updates are only useful during development
+ event.addRequestHandler(new SynchronizedRequestHandler() {
+
+ @Override
+ public boolean synchronizedHandleRequest(
+ VaadinSession session, VaadinRequest request,
+ VaadinResponse response) throws IOException {
+ if ("routeinfo".equals(request.getParameter(
+ ApplicationConstants.REQUEST_TYPE_PARAMETER))) {
+ response.setContentType(
+ JsonConstants.JSON_CONTENT_TYPE);
+ response.getWriter()
+ .write(serverAndClientViewsProvider
+ .createFileRoutesJson(request));
+ return true;
+ }
+ return false;
+ }
+
+ });
+ }
LOGGER.debug(
"{} mode: Registered RouteUnifyingIndexHtmlRequestListener.",
deploymentMode);
diff --git a/packages/java/endpoint/src/main/resources/META-INF/services/com.vaadin.flow.hotswap.VaadinHotswapper b/packages/java/endpoint/src/main/resources/META-INF/services/com.vaadin.flow.hotswap.VaadinHotswapper
new file mode 100644
index 0000000000..6e6102c019
--- /dev/null
+++ b/packages/java/endpoint/src/main/resources/META-INF/services/com.vaadin.flow.hotswap.VaadinHotswapper
@@ -0,0 +1 @@
+com.vaadin.hilla.Hotswapper
diff --git a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index ce0bbbe0c1..a90b4907b7 100644
--- a/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/packages/java/endpoint/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1,8 +1,8 @@
com.vaadin.hilla.EndpointController
com.vaadin.hilla.push.PushConfigurer
com.vaadin.hilla.ApplicationContextProvider
-com.vaadin.hilla.crud.CrudConfiguration
com.vaadin.hilla.startup.EndpointRegistryInitializer
com.vaadin.hilla.startup.RouteUnifyingServiceInitListener
com.vaadin.hilla.route.RouteUtil
com.vaadin.hilla.route.RouteUnifyingConfiguration
+com.vaadin.hilla.signals.config.SignalsConfiguration
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java
index 0bc4ebc6c5..447d8523ee 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerConfigurationTest.java
@@ -6,6 +6,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.jackson.JacksonProperties;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
@@ -25,9 +26,32 @@ public class EndpointControllerConfigurationTest {
@Autowired
private EndpointAccessChecker endpointAccessChecker;
+ @Autowired
+ private ConfigurableApplicationContext context;
+
@Test
public void dependenciesAvailable() {
Assert.assertNotNull(endpointRegistry);
Assert.assertNotNull(endpointAccessChecker);
}
+
+ @Test
+ public void testEndpointInvokerUsesQualifiedObjectMapper()
+ throws NoSuchFieldException, IllegalAccessException {
+ var endpointInvoker = context.getBean(EndpointInvoker.class);
+ var objectMapper = context.getBean("hillaEndpointObjectMapper");
+
+ Assert.assertNotNull("EndpointInvoker should not be null",
+ endpointInvoker);
+ Assert.assertNotNull("hillaEndpointObjectMapper should not be null",
+ objectMapper);
+
+ var field = EndpointInvoker.class
+ .getDeclaredField("endpointObjectMapper");
+ field.setAccessible(true);
+
+ Assert.assertSame(
+ "EndpointInvoker should use the qualified hillaEndpointObjectMapper",
+ objectMapper, field.get(endpointInvoker));
+ }
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerDauTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerDauTest.java
new file mode 100644
index 0000000000..df92783dd5
--- /dev/null
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerDauTest.java
@@ -0,0 +1,183 @@
+package com.vaadin.hilla;
+
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.springframework.context.ApplicationContext;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import com.vaadin.flow.function.DeploymentConfiguration;
+import com.vaadin.flow.server.Constants;
+import com.vaadin.flow.server.VaadinContext;
+import com.vaadin.flow.server.VaadinRequest;
+import com.vaadin.flow.server.VaadinRequestInterceptor;
+import com.vaadin.flow.server.VaadinResponse;
+import com.vaadin.flow.server.VaadinServletService;
+import com.vaadin.flow.server.VaadinSession;
+import com.vaadin.flow.server.dau.DauEnforcementException;
+import com.vaadin.flow.server.dau.EnforcementNotificationMessages;
+import com.vaadin.hilla.auth.CsrfChecker;
+import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory;
+import com.vaadin.pro.licensechecker.dau.EnforcementException;
+
+import static com.vaadin.flow.server.dau.DAUUtils.ENFORCEMENT_EXCEPTION_KEY;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+/**
+ * Ensures that DAU tracking and enforcement is applied in Hilla, by calling
+ * Flow start/end request hooks.
+ */
+public class EndpointControllerDauTest {
+
+ EndpointController controller;
+
+ @Before
+ public void setUp() {
+ ServletContext servletContext = Mockito.mock(ServletContext.class);
+ CsrfChecker csrfChecker = new CsrfChecker(servletContext);
+ csrfChecker.setCsrfProtection(false);
+ EndpointRegistry endpointRegistry = new EndpointRegistry(
+ new EndpointNameChecker());
+ ApplicationContext appCtx = Mockito.mock(ApplicationContext.class);
+ EndpointInvoker endpointInvoker = new EndpointInvoker(appCtx,
+ new JacksonObjectMapperFactory.Json().build(),
+ new ExplicitNullableTypeChecker(), servletContext,
+ endpointRegistry);
+ controller = new EndpointController(appCtx, endpointRegistry,
+ endpointInvoker, csrfChecker);
+ }
+
+ @Test
+ public void serveEndpoint_vaadinRequestStartEndHooksInvoked() {
+ MockVaadinService vaadinService = new MockVaadinService();
+ when(vaadinService.getDeploymentConfiguration().isProductionMode())
+ .thenReturn(true);
+ when(vaadinService.getDeploymentConfiguration()
+ .getBooleanProperty(Constants.DAU_TOKEN, false))
+ .thenReturn(true);
+ controller.vaadinService = vaadinService;
+
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+ HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+ when(request.getHeader("X-CSRF-Token")).thenReturn("Vaadin Fusion");
+ controller.serveEndpoint("TEST", "test", null, request, response);
+
+ Mockito.verify(vaadinService.testInterceptor).requestStart(
+ any(VaadinRequest.class), any(VaadinResponse.class));
+ Mockito.verify(vaadinService.testInterceptor)
+ .requestEnd(any(VaadinRequest.class), isNull(), isNull());
+ }
+
+ @Test
+ public void serveEndpoint_dauEnforcement_serviceUnavailableResponse()
+ throws JsonProcessingException {
+ MockVaadinService vaadinService = new MockVaadinService();
+ when(vaadinService.getDeploymentConfiguration().isProductionMode())
+ .thenReturn(true);
+ when(vaadinService.getDeploymentConfiguration()
+ .getBooleanProperty(Constants.DAU_TOKEN, false))
+ .thenReturn(true);
+ controller.vaadinService = vaadinService;
+
+ Map attributes = new HashMap<>();
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+ HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
+ when(request.getHeader("X-CSRF-Token")).thenReturn("Vaadin Fusion");
+ doAnswer(i -> attributes.put(i.getArgument(0), i.getArgument(1)))
+ .when(request).setAttribute(anyString(), any());
+ when(request.getAttribute(anyString()))
+ .then(i -> attributes.get(i. getArgument(0)));
+
+ Mockito.doAnswer(i -> {
+ request.setAttribute(ENFORCEMENT_EXCEPTION_KEY,
+ new EnforcementException("STOP"));
+ return null;
+ }).when(vaadinService.testInterceptor).requestStart(
+ any(VaadinRequest.class), any(VaadinResponse.class));
+
+ ResponseEntity responseEntity = controller.serveEndpoint("TEST",
+ "test", null, request, response);
+
+ Mockito.verify(vaadinService.testInterceptor).requestStart(
+ any(VaadinRequest.class), any(VaadinResponse.class));
+ Mockito.verify(vaadinService.testInterceptor)
+ .requestEnd(any(VaadinRequest.class), isNull(), isNull());
+
+ Assert.assertEquals("Expected 503 response for blocked request",
+ HttpStatus.SERVICE_UNAVAILABLE, responseEntity.getStatusCode());
+ ObjectNode jsonNodes = new ObjectMapper()
+ .readValue(responseEntity.getBody(), ObjectNode.class);
+ EnforcementNotificationMessages expectedError = EnforcementNotificationMessages.DEFAULT;
+ assertEquals(DauEnforcementException.class.getName(),
+ jsonNodes.get("type").asText());
+ assertEquals(expectedError.caption(),
+ jsonNodes.get("message").asText());
+ ObjectNode errorDetails = (ObjectNode) jsonNodes.get("detail");
+ assertEquals(expectedError.caption(),
+ errorDetails.get("caption").asText());
+ assertEquals(expectedError.message(),
+ errorDetails.get("message").asText());
+ if (expectedError.details() != null) {
+ assertEquals(expectedError.details(),
+ errorDetails.get("details").asText());
+ } else {
+ assertTrue(errorDetails.get("details").isNull());
+ }
+ if (expectedError.url() != null) {
+ assertEquals(expectedError.details(),
+ errorDetails.get("url").asText());
+ } else {
+ assertTrue(errorDetails.get("url").isNull());
+ }
+ }
+
+ private static class MockVaadinService extends VaadinServletService {
+
+ private final VaadinRequestInterceptor testInterceptor = Mockito
+ .mock(VaadinRequestInterceptor.class);
+ private final VaadinContext vaadinContext = Mockito
+ .mock(VaadinContext.class);
+ private final DeploymentConfiguration deploymentConfiguration = Mockito
+ .mock(DeploymentConfiguration.class);
+
+ @Override
+ public void requestStart(VaadinRequest request,
+ VaadinResponse response) {
+ testInterceptor.requestStart(request, response);
+ }
+
+ @Override
+ public void requestEnd(VaadinRequest request, VaadinResponse response,
+ VaadinSession session) {
+ testInterceptor.requestEnd(request, response, session);
+ }
+
+ @Override
+ public VaadinContext getContext() {
+ return vaadinContext;
+ }
+
+ @Override
+ public DeploymentConfiguration getDeploymentConfiguration() {
+ return deploymentConfiguration;
+ }
+ }
+}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java
index f212937c6d..12e0040cb1 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerMockBuilder.java
@@ -2,6 +2,8 @@
import static org.mockito.Mockito.mock;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.vaadin.hilla.endpointransfermapper.EndpointTransferMapper;
import org.mockito.Mockito;
import org.springframework.context.ApplicationContext;
@@ -10,11 +12,13 @@
import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory;
import jakarta.servlet.ServletContext;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
/**
* A helper class to build a mocked EndpointController.
*/
public class EndpointControllerMockBuilder {
+ private static final EndpointTransferMapper ENDPOINT_TRANSFER_MAPPER = new EndpointTransferMapper();
private ApplicationContext applicationContext;
private EndpointNameChecker endpointNameChecker = mock(
EndpointNameChecker.class);
@@ -28,8 +32,10 @@ public EndpointController build() {
ServletContext servletContext = Mockito.mock(ServletContext.class);
Mockito.when(csrfChecker.validateCsrfTokenInRequest(Mockito.any()))
.thenReturn(true);
- EndpointInvoker invoker = Mockito
- .spy(new EndpointInvoker(applicationContext, factory,
+ ObjectMapper endpointObjectMapper = createEndpointObjectMapper(
+ applicationContext, factory);
+ EndpointInvoker invoker = Mockito.spy(
+ new EndpointInvoker(applicationContext, endpointObjectMapper,
explicitNullableTypeChecker, servletContext, registry));
EndpointController controller = Mockito.spy(new EndpointController(
applicationContext, registry, invoker, csrfChecker));
@@ -38,6 +44,26 @@ public EndpointController build() {
return controller;
}
+ public static ObjectMapper createEndpointObjectMapper(
+ ApplicationContext applicationContext,
+ JacksonObjectMapperFactory factory) {
+ ObjectMapper endpointObjectMapper = factory != null ? factory.build()
+ : createDefaultEndpointMapper(applicationContext);
+ if (endpointObjectMapper != null) {
+ endpointObjectMapper.registerModule(
+ ENDPOINT_TRANSFER_MAPPER.getJacksonModule());
+ }
+ return endpointObjectMapper;
+ }
+
+ private static ObjectMapper createDefaultEndpointMapper(
+ ApplicationContext applicationContext) {
+ var endpointMapper = new JacksonObjectMapperFactory.Json().build();
+ applicationContext.getBean(Jackson2ObjectMapperBuilder.class)
+ .configure(endpointMapper);
+ return endpointMapper;
+ }
+
public EndpointControllerMockBuilder withApplicationContext(
ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java
index 11c8ed3be4..3af6311432 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointControllerTest.java
@@ -67,6 +67,7 @@
import com.vaadin.hilla.exception.EndpointValidationException;
import com.vaadin.hilla.packages.application.ApplicationComponent;
import com.vaadin.hilla.packages.application.ApplicationEndpoint;
+import com.vaadin.hilla.packages.library.LibraryEndpoint;
import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory;
import com.vaadin.hilla.testendpoint.BridgeMethodTestEndpoint;
@@ -819,13 +820,14 @@ public void should_Never_UseSpringObjectMapper() {
.thenReturn(Collections.emptyMap());
EndpointRegistry registry = new EndpointRegistry(
mock(EndpointNameChecker.class));
-
- EndpointInvoker invoker = new EndpointInvoker(contextMock, null,
- mock(ExplicitNullableTypeChecker.class),
+ var endpointObjectMapper = EndpointControllerMockBuilder
+ .createEndpointObjectMapper(contextMock, null);
+ EndpointInvoker invoker = new EndpointInvoker(contextMock,
+ endpointObjectMapper, mock(ExplicitNullableTypeChecker.class),
mock(ServletContext.class), registry);
new EndpointController(contextMock, registry, invoker, null)
- .registerEndpoints(getDefaultOpenApiResourcePathInDevMode());
+ .registerEndpoints();
verify(contextMock, never()).getBean(ObjectMapper.class);
verify(contextMock, times(1))
@@ -1143,10 +1145,10 @@ public void should_Instantiate_endpoints_correctly() throws Exception {
public void should_Fallback_to_Spring_Context() throws Exception {
// this also tests that an empty definition is not a problem
var endpointRegistry = registerEndpoints("openapi-noendpoints.json");
- // this one is found by Spring
+ // as browser callables are found through Spring, the results are the
+ // same
assertNotNull(endpointRegistry.get("applicationEndpoint"));
- // the others are outside the Spring context
- assertNull(endpointRegistry.get("libraryEndpoint"));
+ assertNotNull(endpointRegistry.get("libraryEndpoint"));
assertNull(endpointRegistry.get("libraryEndpointWithConstructor"));
}
@@ -1163,12 +1165,14 @@ private URL getDefaultOpenApiResourcePathInDevMode() {
private EndpointRegistry registerEndpoints(String openApiFilename) {
var context = Mockito.mock(ApplicationContext.class);
var applicationComponent = new ApplicationComponent();
+ // Suppose that both the "regular" browser callable and the one from a
+ // library are Spring beans
Mockito.doReturn(Map.of("regularEndpoint",
- new ApplicationEndpoint(applicationComponent))).when(context)
+ new ApplicationEndpoint(applicationComponent),
+ "libraryEndpoint", new LibraryEndpoint())).when(context)
.getBeansWithAnnotation(Endpoint.class);
var controller = createVaadinControllerWithApplicationContext(context);
- controller.registerEndpoints(getClass()
- .getResource("/com/vaadin/hilla/packages/" + openApiFilename));
+ controller.registerEndpoints();
return controller.endpointRegistry;
}
@@ -1296,10 +1300,12 @@ private EndpointController createVaadinController(T endpoint,
ApplicationContext mockApplicationContext = mockApplicationContext(
endpoint);
EndpointRegistry registry = new EndpointRegistry(endpointNameChecker);
-
+ ObjectMapper endpointObjectMapper = EndpointControllerMockBuilder
+ .createEndpointObjectMapper(mockApplicationContext,
+ endpointMapperFactory);
EndpointInvoker invoker = Mockito
.spy(new EndpointInvoker(mockApplicationContext,
- endpointMapperFactory, explicitNullableTypeChecker,
+ endpointObjectMapper, explicitNullableTypeChecker,
mock(ServletContext.class), registry));
Mockito.doReturn(accessChecker).when(invoker).getAccessChecker();
@@ -1307,8 +1313,7 @@ private EndpointController createVaadinController(T endpoint,
EndpointController connectController = Mockito
.spy(new EndpointController(mockApplicationContext, registry,
invoker, csrfChecker));
- connectController
- .registerEndpoints(getDefaultOpenApiResourcePathInDevMode());
+ connectController.registerEndpoints();
return connectController;
}
@@ -1336,8 +1341,7 @@ private EndpointController createVaadinControllerWithApplicationContext(
EndpointController hillaController = controllerMockBuilder
.withObjectMapperFactory(new JacksonObjectMapperFactory.Json())
.withApplicationContext(applicationContext).build();
- hillaController
- .registerEndpoints(getDefaultOpenApiResourcePathInDevMode());
+ hillaController.registerEndpoints();
return hillaController;
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java
index 643e5bbb3c..9dbfd4c8a0 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/EndpointInvokerTest.java
@@ -1,5 +1,6 @@
package com.vaadin.hilla;
+import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory;
import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
@@ -66,7 +67,8 @@ public void setUp() {
endpointRegistry = new EndpointRegistry(endpointNameChecker);
- endpointInvoker = new EndpointInvoker(applicationContext, null,
+ endpointInvoker = new EndpointInvoker(applicationContext,
+ new JacksonObjectMapperFactory.Json().build(),
explicitNullableTypeChecker, servletContext, endpointRegistry) {
protected EndpointAccessChecker getAccessChecker() {
return endpointAccessChecker;
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/ExplicitNullableTypeCheckerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/ExplicitNullableTypeCheckerTest.java
index 4e9c839ac3..6520be06d5 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/ExplicitNullableTypeCheckerTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/ExplicitNullableTypeCheckerTest.java
@@ -31,7 +31,7 @@
import java.util.Map;
import java.util.Optional;
-import javax.annotation.Nonnull;
+import org.jspecify.annotations.NonNull;
import org.junit.Assert;
import org.junit.Before;
@@ -406,7 +406,7 @@ public void should_InvokeCheckValueForType_When_AnnotatedNonnull()
.checkValueForAnnotatedElement(notNullValue,
getClass().getMethod("stringNonnull"), false);
- Assert.assertNull("Should allow values with @Nonnull", error);
+ Assert.assertNull("Should allow values with @NonNull", error);
verify(explicitNullableTypeChecker).checkValueForType(notNullValue,
String.class, false);
@@ -500,7 +500,7 @@ public Long methodWithIdAnnotation() {
/**
* Method for testing
*/
- @Nonnull
+ @javax.annotation.Nonnull
public String stringNonnull() {
return "";
}
@@ -511,7 +511,7 @@ static private class Bean {
public String ignoreProperty;
public String description;
transient String transientProperty;
- @Nonnull
+ @NonNull
private String title;
public Bean() {
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullEntity.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullEntity.java
index b593b25775..478d7e5c20 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullEntity.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullEntity.java
@@ -4,13 +4,15 @@
import java.util.List;
import java.util.Map;
+import org.jspecify.annotations.NonNull;
+
public class NonnullEntity {
- @Nonnull
- private final List<@Nonnull String> nonNullableField = new ArrayList<>();
+ @NonNull
+ private final List<@NonNull String> nonNullableField = new ArrayList<>();
- @Nonnull
+ @NonNull
public String nonNullableMethod(
- @Nonnull Map nonNullableParameter) {
+ @NonNull Map nonNullableParameter) {
return nonNullableParameter.getOrDefault("test", "");
}
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullParserTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullParserTest.java
index e2e077e8d6..b602a7948e 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullParserTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullParserTest.java
@@ -17,6 +17,7 @@
import static org.junit.Assert.assertTrue;
public class NonnullParserTest {
+ private static final String ANNOTATION_NAME = "NonNull";
FieldDeclaration field;
MethodDeclaration method;
com.github.javaparser.ast.body.Parameter parameter;
@@ -38,32 +39,33 @@ public void init() throws FileNotFoundException {
@Test
public void should_haveNonNullableField() {
- assertTrue(field.isAnnotationPresent("Nonnull"));
+ assertTrue(field.isAnnotationPresent(ANNOTATION_NAME));
}
@Test
public void should_haveFieldWithNonNullableCollectionItem() {
- assertTrue(field.getVariables().get(0).getType()
- .asClassOrInterfaceType().getTypeArguments().get().get(0)
- .getAnnotations().stream().anyMatch(annotation -> "Nonnull"
- .equals(annotation.getName().asString())));
+ assertTrue(
+ field.getVariables().get(0).getType().asClassOrInterfaceType()
+ .getTypeArguments().get().get(0).getAnnotations()
+ .stream().anyMatch(annotation -> ANNOTATION_NAME
+ .equals(annotation.getName().asString())));
}
@Test
public void should_haveMethodWithNonNullableReturnType() {
- assertTrue(method.isAnnotationPresent("Nonnull"));
+ assertTrue(method.isAnnotationPresent(ANNOTATION_NAME));
}
@Test
public void should_haveMethodWithNonNullableParameter() {
- assertTrue(parameter.isAnnotationPresent("Nonnull"));
+ assertTrue(parameter.isAnnotationPresent(ANNOTATION_NAME));
}
@Test
public void should_haveMethodParameterWithNonNullableCollectionItemType() {
assertTrue(parameter.getType().asClassOrInterfaceType()
.getTypeArguments().get().get(1).getAnnotations().stream()
- .anyMatch(annotation -> "Nonnull"
+ .anyMatch(annotation -> ANNOTATION_NAME
.equals(annotation.getName().asString())));
}
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullReflectionTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullReflectionTest.java
index fed280c5bc..794f1c0908 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullReflectionTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/NonnullReflectionTest.java
@@ -7,6 +7,7 @@
import java.lang.reflect.Parameter;
import java.util.Map;
+import org.jspecify.annotations.NonNull;
import org.junit.Before;
import org.junit.Test;
@@ -27,26 +28,26 @@ public void init() throws NoSuchFieldException, NoSuchMethodException {
@Test
public void should_haveNonNullableField() {
- assertTrue(field.getAnnotatedType().isAnnotationPresent(Nonnull.class));
+ assertTrue(field.getAnnotatedType().isAnnotationPresent(NonNull.class));
}
@Test
public void should_haveFieldWithNonNullableCollectionItem() {
AnnotatedType listItemType = ((AnnotatedParameterizedType) field
.getAnnotatedType()).getAnnotatedActualTypeArguments()[0];
- assertTrue(listItemType.isAnnotationPresent(Nonnull.class));
+ assertTrue(listItemType.isAnnotationPresent(NonNull.class));
}
@Test
public void should_haveMethodWithNonNullableReturnType() {
assertTrue(method.getAnnotatedReturnType()
- .isAnnotationPresent(Nonnull.class));
+ .isAnnotationPresent(NonNull.class));
}
@Test
public void should_haveMethodWithNonNullableParameter() {
assertTrue(parameter.getAnnotatedType()
- .isAnnotationPresent(Nonnull.class));
+ .isAnnotationPresent(NonNull.class));
}
@Test
@@ -54,6 +55,6 @@ public void should_haveMethodParameterWithNonNullableCollectionItemType() {
AnnotatedType mapValueType = ((AnnotatedParameterizedType) parameter
.getAnnotatedType()).getAnnotatedActualTypeArguments()[1];
- assertTrue(mapValueType.isAnnotationPresent(Nonnull.class));
+ assertTrue(mapValueType.isAnnotationPresent(NonNull.class));
}
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/CrudRepositoryServiceTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/CrudRepositoryServiceTest.java
index 707768599c..b1000167bc 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/CrudRepositoryServiceTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/CrudRepositoryServiceTest.java
@@ -1,8 +1,9 @@
package com.vaadin.hilla.crud;
-import com.vaadin.hilla.BrowserCallable;
-import com.vaadin.hilla.EndpointController;
-import com.vaadin.hilla.push.PushConfigurer;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
@@ -18,12 +19,9 @@
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.FluentQuery;
import org.springframework.stereotype.Repository;
-import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringRunner;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Function;
+import com.vaadin.hilla.BrowserCallable;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@@ -35,7 +33,6 @@
CrudRepositoryServiceTest.CustomCrudRepositoryService.class,
CrudRepositoryServiceTest.CustomJpaRepository.class,
CrudRepositoryServiceTest.CustomJpaRepositoryService.class })
-@ContextConfiguration(classes = { CrudConfiguration.class })
@EnableAutoConfiguration
public class CrudRepositoryServiceTest {
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/FilterTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/FilterTest.java
index 034750dbe3..6d47fba0d5 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/FilterTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/FilterTest.java
@@ -31,9 +31,6 @@ public class FilterTest {
@Autowired
private TestRepository repository;
- @Autowired
- private JpaFilterConverter jpaFilterConverter;
-
@Test
public void filterStringPropertyUsingContains() {
setupNames("Jack", "John", "Johnny", "Polly", "Josh");
@@ -304,7 +301,7 @@ public void filterUnknownEnumValue() {
executeFilter(filter);
}
- @Test(expected = IllegalArgumentException.class)
+ @Test(expected = InvalidDataAccessApiUsageException.class)
public void filterNonExistingProperty() {
setupNames("Jack", "John", "Johnny", "Polly", "Josh");
PropertyStringFilter filter = createFilter("foo", Matcher.EQUALS,
@@ -425,7 +422,7 @@ private void assertFilterResult(Filter filter, List result) {
}
private List executeFilter(Filter filter) {
- Specification spec = jpaFilterConverter.toSpec(filter,
+ Specification spec = JpaFilterConverter.toSpec(filter,
TestObject.class);
return repository.findAll(spec);
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/TestApplication.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/TestApplication.java
index 36bb2bbf6b..7f4c62e742 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/TestApplication.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/TestApplication.java
@@ -1,10 +1,7 @@
package com.vaadin.hilla.crud;
import org.springframework.boot.autoconfigure.SpringBootApplication;
-import org.springframework.context.annotation.Import;
-import org.springframework.test.context.ContextConfiguration;
@SpringBootApplication
-@Import(CrudConfiguration.class)
public class TestApplication {
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/filter/FilterTransformerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/filter/FilterTransformerTest.java
index fbf18d4ba3..182fbc2549 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/filter/FilterTransformerTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/crud/filter/FilterTransformerTest.java
@@ -26,9 +26,6 @@ public class FilterTransformerTest {
@Autowired
private TestRepository repository;
- @Autowired
- private JpaFilterConverter jpaFilterConverter;
-
@Test
public void testRemap() {
var testObject = new TestObject();
@@ -88,7 +85,7 @@ public void testRemap() {
});
var filter = transformer.apply(andFilter);
- var spec = jpaFilterConverter.toSpec(filter, TestObject.class);
+ var spec = JpaFilterConverter.toSpec(filter, TestObject.class);
var result = repository.findAll(spec);
assertEquals(2, result.size());
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/rest/EndpointWithRestControllerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/rest/EndpointWithRestControllerTest.java
index abbfde97ec..4809b9319a 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/rest/EndpointWithRestControllerTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/rest/EndpointWithRestControllerTest.java
@@ -82,7 +82,7 @@ public void setUp() throws IOException {
EndpointControllerMockBuilder controllerMockBuilder = new EndpointControllerMockBuilder();
EndpointController controller = controllerMockBuilder
.withApplicationContext(applicationContext).build();
- controller.registerEndpoints(getDefaultOpenApiResourcePathInDevMode());
+ controller.registerEndpoints();
mockMvcForEndpoint = MockMvcBuilders.standaloneSetup(controller)
.build();
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java
index 7107d81bb6..45e8926e71 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUnifyingIndexHtmlRequestListenerTest.java
@@ -13,6 +13,7 @@
import com.vaadin.flow.di.Instantiator;
import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.function.DeploymentConfiguration;
+import com.vaadin.flow.internal.CurrentInstance;
import com.vaadin.flow.server.VaadinContext;
import com.vaadin.flow.server.VaadinRequest;
@@ -23,6 +24,7 @@
import org.jsoup.nodes.DataNode;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
+import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@@ -42,10 +44,10 @@
import com.vaadin.flow.server.auth.MenuAccessControl;
import com.vaadin.flow.server.communication.IndexHtmlResponse;
import com.vaadin.flow.server.menu.AvailableViewInfo;
-import com.vaadin.flow.server.menu.MenuRegistry;
+import com.vaadin.flow.internal.menu.MenuRegistry;
-import static com.vaadin.flow.server.menu.MenuRegistry.FILE_ROUTES_JSON_NAME;
-import static com.vaadin.flow.server.menu.MenuRegistry.FILE_ROUTES_JSON_PROD_PATH;
+import static com.vaadin.flow.internal.menu.MenuRegistry.FILE_ROUTES_JSON_NAME;
+import static com.vaadin.flow.internal.menu.MenuRegistry.FILE_ROUTES_JSON_PROD_PATH;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
@@ -65,8 +67,11 @@ public class RouteUnifyingIndexHtmlRequestListenerTest {
@Rule
public TemporaryFolder projectRoot = new TemporaryFolder();
+ private ServerAndClientViewsProvider serverClientViewsProvider;
+
@Before
public void setUp() throws IOException {
+ MenuRegistry.clearFileRoutesCache();
vaadinService = Mockito.mock(VaadinService.class);
VaadinContext vaadinContext = Mockito.mock(VaadinContext.class);
@@ -79,8 +84,10 @@ public void setUp() throws IOException {
deploymentConfiguration = Mockito.mock(DeploymentConfiguration.class);
Mockito.when(vaadinService.getDeploymentConfiguration())
.thenReturn(deploymentConfiguration);
- requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ serverClientViewsProvider = new ServerAndClientViewsProvider(
deploymentConfiguration, null, null, true);
+ requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ serverClientViewsProvider);
indexHtmlResponse = Mockito.mock(IndexHtmlResponse.class);
vaadinRequest = Mockito.mock(VaadinRequest.class);
@@ -90,6 +97,10 @@ public void setUp() throws IOException {
Mockito.when(vaadinRequest.getUserPrincipal())
.thenReturn(userPrincipal);
+ Mockito.when(vaadinRequest.getService()).thenReturn(vaadinService);
+ Mockito.when(vaadinService.getDeploymentConfiguration())
+ .thenReturn(deploymentConfiguration);
+
final Document document = Mockito.mock(Document.class);
final Element element = new Element("head");
Mockito.when(document.head()).thenReturn(element);
@@ -115,6 +126,8 @@ public void setUp() throws IOException {
.thenReturn(menuAccessControl);
Mockito.when(menuAccessControl.getPopulateClientSideMenu())
.thenReturn(MenuAccessControl.PopulateClientMenu.ALWAYS);
+ Mockito.doCallRealMethod().when(menuAccessControl)
+ .canAccessView(any(AvailableViewInfo.class));
// Add test data for production mode
projectRoot.newFolder("META-INF", "VAADIN");
@@ -122,6 +135,13 @@ public void setUp() throws IOException {
FILE_ROUTES_JSON_PROD_PATH);
copyClientRoutes("clientRoutes.json", productionRouteFile);
+
+ CurrentInstance.set(VaadinRequest.class, vaadinRequest);
+ }
+
+ @After
+ public void tearDown() {
+ CurrentInstance.set(VaadinRequest.class, null);
}
private static List prepareServerRoutes() {
@@ -206,8 +226,7 @@ public void when_productionMode_anonymous_user_should_modifyIndexHtmlResponse_wi
MatcherAssert.assertThat("Generated missing fieldName " + field,
actual.has(field), Matchers.is(true));
MatcherAssert.assertThat("Missing element " + field,
- actual.toString(),
- Matchers.containsString(expected.get(field).toString()));
+ actual.get(field), Matchers.equalTo(expected.get(field)));
} while (elementsFields.hasNext());
}
@@ -260,8 +279,7 @@ public void when_productionMode_authenticated_user_should_modifyIndexHtmlRespons
MatcherAssert.assertThat("Generated missing fieldName " + field,
actual.has(field), Matchers.is(true));
MatcherAssert.assertThat("Missing element " + field,
- actual.toString(),
- Matchers.containsString(expected.get(field).toString()));
+ actual.get(field), Matchers.equalTo(expected.get(field)));
} while (elementsFields.hasNext());
}
@@ -316,8 +334,7 @@ public void when_productionMode_admin_user_should_modifyIndexHtmlResponse_with_a
MatcherAssert.assertThat("Generated missing fieldName " + field,
actual.has(field), Matchers.is(true));
MatcherAssert.assertThat("Missing element " + field,
- actual.toString(),
- Matchers.containsString(expected.get(field).toString()));
+ actual.get(field), Matchers.equalTo(expected.get(field)));
} while (elementsFields.hasNext());
}
@@ -362,8 +379,7 @@ public void when_developmentMode_should_modifyIndexHtmlResponse()
MatcherAssert.assertThat("Generated missing fieldName " + field,
actual.has(field), Matchers.is(true));
MatcherAssert.assertThat("Missing element " + field,
- actual.toString(),
- Matchers.containsString(expected.get(field).toString()));
+ actual.get(field), Matchers.equalTo(expected.get(field)));
} while (elementsFields.hasNext());
}
@@ -375,7 +391,7 @@ public void should_collectServerViews() {
.mockStatic(VaadinService.class)) {
mocked.when(VaadinService::getCurrent).thenReturn(vaadinService);
- views = requestListener.collectServerViews(true);
+ views = serverClientViewsProvider.collectServerViews(true);
}
MatcherAssert.assertThat(views, Matchers.aMapWithSize(4));
MatcherAssert.assertThat(views.get("/bar").title(),
@@ -413,7 +429,8 @@ public void when_productionMode_should_collectClientViews()
menuRegistry.when(() -> MenuRegistry.getClassLoader())
.thenReturn(mockClassLoader);
mocked.when(VaadinService::getCurrent).thenReturn(vaadinService);
- var views = requestListener.collectClientViews(vaadinRequest);
+ var views = serverClientViewsProvider
+ .collectClientViews(vaadinRequest);
MatcherAssert.assertThat(views, Matchers.aMapWithSize(4));
}
}
@@ -430,7 +447,8 @@ public void when_developmentMode_should_collectClientViews()
try (MockedStatic mocked = Mockito
.mockStatic(VaadinService.class)) {
mocked.when(VaadinService::getCurrent).thenReturn(vaadinService);
- var views = requestListener.collectClientViews(vaadinRequest);
+ var views = serverClientViewsProvider
+ .collectClientViews(vaadinRequest);
MatcherAssert.assertThat(views, Matchers.aMapWithSize(4));
}
}
@@ -455,8 +473,10 @@ public void when_exposeServerRoutesToClient_false_serverSideRoutesAreNotInRespon
.thenReturn(true);
Mockito.when(vaadinRequest.isUserInRole(Mockito.anyString()))
.thenReturn(true);
- var requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ var serverClientViewsProvider = new ServerAndClientViewsProvider(
deploymentConfiguration, null, null, false);
+ var requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ serverClientViewsProvider);
requestListener.modifyIndexHtmlResponse(indexHtmlResponse);
}
@@ -492,8 +512,7 @@ public void when_exposeServerRoutesToClient_false_serverSideRoutesAreNotInRespon
MatcherAssert.assertThat("Generated missing fieldName " + field,
actual.has(field), Matchers.is(true));
MatcherAssert.assertThat("Missing element " + field,
- actual.toString(),
- Matchers.containsString(expected.get(field).toString()));
+ actual.get(field), Matchers.equalTo(expected.get(field)));
} while (elementsFields.hasNext());
}
@@ -521,8 +540,10 @@ public void when_exposeServerRoutesToClient_noLayout_serverSideRoutesAreNotInRes
.thenReturn(true);
Mockito.when(vaadinRequest.isUserInRole(Mockito.anyString()))
.thenReturn(true);
- var requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ var serverAndClientViewsProvider = new ServerAndClientViewsProvider(
deploymentConfiguration, null, null, true);
+ var requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ serverAndClientViewsProvider);
requestListener.modifyIndexHtmlResponse(indexHtmlResponse);
}
@@ -556,9 +577,22 @@ public void when_exposeServerRoutesToClient_noLayout_serverSideRoutesAreNotInRes
@Test
public void when_exposeServerRoutesToClient_layoutExists_serverSideRoutesAreInResponse()
throws IOException {
+ assertServerRoutesExposedToClientWhenLayoutExists(
+ "clientRoutesWithLayout.json", "server-and-client-views.json");
+ }
+
+ @Test
+ public void when_exposeServerRoutesToClient_layoutExists_routeWithEmptyPath_serverSideRoutesAreInResponse()
+ throws IOException {
+ assertServerRoutesExposedToClientWhenLayoutExists(
+ "clientRoutesWithLayoutAndIndexView.json",
+ "server-and-client-views-layout-and-index-route.json");
+ }
+ private void assertServerRoutesExposedToClientWhenLayoutExists(
+ String testJsonFile, String expectedJsonFile) throws IOException {
// Use routes with layout
- copyClientRoutes("clientRoutesWithLayout.json", productionRouteFile);
+ copyClientRoutes(testJsonFile, productionRouteFile);
try (MockedStatic mocked = Mockito
.mockStatic(VaadinService.class);
@@ -579,8 +613,10 @@ public void when_exposeServerRoutesToClient_layoutExists_serverSideRoutesAreInRe
.thenReturn(true);
Mockito.when(vaadinRequest.isUserInRole(Mockito.anyString()))
.thenReturn(true);
- var requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ var serverAndClientViewsProvider = new ServerAndClientViewsProvider(
deploymentConfiguration, null, null, true);
+ var requestListener = new RouteUnifyingIndexHtmlRequestListener(
+ serverAndClientViewsProvider);
requestListener.modifyIndexHtmlResponse(indexHtmlResponse);
}
@@ -604,8 +640,8 @@ public void when_exposeServerRoutesToClient_layoutExists_serverSideRoutesAreInRe
final var mapper = new ObjectMapper();
var actual = mapper.readTree(views);
- var expected = mapper.readTree(getClass()
- .getResource("/META-INF/VAADIN/server-and-client-views.json"));
+ var expected = mapper.readTree(
+ getClass().getResource("/META-INF/VAADIN/" + expectedJsonFile));
MatcherAssert.assertThat("Different amount of items", actual.size(),
Matchers.is(expected.size()));
@@ -616,8 +652,7 @@ public void when_exposeServerRoutesToClient_layoutExists_serverSideRoutesAreInRe
MatcherAssert.assertThat("Generated missing fieldName " + field,
actual.has(field), Matchers.is(true));
MatcherAssert.assertThat("Missing element " + field,
- actual.toString(),
- Matchers.containsString(expected.get(field).toString()));
+ actual.get(field), Matchers.equalTo(expected.get(field)));
} while (elementsFields.hasNext());
}
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java
index 6da3a03860..444b481025 100644
--- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java
@@ -36,7 +36,7 @@ public void test_role_allowed() {
AvailableViewInfo config = new AvailableViewInfo("Test",
new String[] { "ROLE_ADMIN" }, false, "/test", false, false,
- null, null, null);
+ null, null, null, false);
routeUtil.setRoutes(Collections.singletonMap("/test", config));
Assert.assertTrue("Route should be allowed for ADMIN role.",
@@ -52,7 +52,7 @@ public void test_role_not_allowed() {
AvailableViewInfo config = new AvailableViewInfo("Test",
new String[] { "ROLE_ADMIN" }, false, "/test", false, false,
- null, null, null);
+ null, null, null, false);
routeUtil.setRoutes(Collections.singletonMap("/test", config));
Assert.assertFalse("USER role should not allow ADMIN route.",
@@ -67,7 +67,7 @@ public void test_login_required() {
request.setUserPrincipal(Mockito.mock(Principal.class));
AvailableViewInfo config = new AvailableViewInfo("Test", null, true,
- "/test", false, false, null, null, null);
+ "/test", false, false, null, null, null, false);
routeUtil.setRoutes(Collections.singletonMap("/test", config));
Assert.assertTrue("Request with user principal should be allowed",
@@ -82,7 +82,7 @@ public void test_login_required_failed() {
request.setUserPrincipal(null);
AvailableViewInfo config = new AvailableViewInfo("Test", null, true,
- "/test", false, false, null, null, null);
+ "/test", false, false, null, null, null, false);
routeUtil.setRoutes(Collections.singletonMap("/test", config));
Assert.assertFalse("No login should be denied access",
@@ -97,11 +97,11 @@ public void test_login_required_on_layout() {
request.setUserPrincipal(null);
AvailableViewInfo pageWithoutLogin = new AvailableViewInfo("Test Page",
- null, false, "/test", false, false, null, null, null);
+ null, false, "/test", false, false, null, null, null, false);
AvailableViewInfo layoutWithLogin = new AvailableViewInfo("Test Layout",
null, true, "", false, false, null,
- Collections.singletonList(pageWithoutLogin), null);
+ Collections.singletonList(pageWithoutLogin), null, false);
routeUtil.setRoutes(Map.ofEntries(entry("/test", pageWithoutLogin),
entry("", layoutWithLogin)));
@@ -118,11 +118,11 @@ public void test_login_required_on_page() {
request.setUserPrincipal(null);
AvailableViewInfo pageWithLogin = new AvailableViewInfo("Test Page",
- null, true, "/test", false, false, null, null, null);
+ null, true, "/test", false, false, null, null, null, false);
AvailableViewInfo layoutWithoutLogin = new AvailableViewInfo(
"Test Layout", null, false, "", false, false, null,
- Collections.singletonList(pageWithLogin), null);
+ Collections.singletonList(pageWithLogin), null, false);
routeUtil.setRoutes(Map.ofEntries(entry("/test", pageWithLogin),
entry("", layoutWithoutLogin)));
@@ -142,7 +142,7 @@ public void test_login_not_required_on_root() {
request.setUserPrincipal(null);
AvailableViewInfo config = new AvailableViewInfo("Root", null, false,
- "", false, false, null, null, null);
+ "", false, false, null, null, null, false);
routeUtil.setRoutes(Collections.singletonMap("", config));
Assert.assertTrue("Login no required should allow access",
diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/ListSignalTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/ListSignalTest.java
new file mode 100644
index 0000000000..c644a3a037
--- /dev/null
+++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/signals/ListSignalTest.java
@@ -0,0 +1,1438 @@
+package com.vaadin.hilla.signals;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.vaadin.hilla.EndpointControllerMockBuilder;
+import com.vaadin.hilla.parser.jackson.JacksonObjectMapperFactory;
+import com.vaadin.hilla.signals.core.event.ListStateEvent;
+import com.vaadin.hilla.signals.core.event.StateEvent;
+import com.vaadin.hilla.signals.core.event.MissingFieldException;
+import com.vaadin.hilla.signals.operation.ListInsertOperation;
+import com.vaadin.hilla.signals.operation.ListRemoveOperation;
+import com.vaadin.hilla.signals.operation.ReplaceValueOperation;
+import com.vaadin.hilla.signals.operation.SetValueOperation;
+import com.vaadin.hilla.signals.operation.ValidationResult;
+import com.vaadin.hilla.signals.operation.ValueOperation;
+import jakarta.annotation.Nullable;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.springframework.context.ApplicationContext;
+import reactor.core.publisher.Flux;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+
+import static com.vaadin.hilla.signals.core.event.ListStateEvent.InsertPosition;
+import static com.vaadin.hilla.signals.core.event.ListStateEvent.ListEntry;
+import static org.junit.Assert.assertTrue;
+
+public class ListSignalTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static final class Entry implements ListEntry {
+ private final UUID id;
+ private UUID prev;
+ private UUID next;
+ private final ValueSignal value;
+
+ public Entry(UUID id, @Nullable UUID prev, @Nullable UUID next, V value,
+ Class valueType) {
+ this.id = id;
+ this.prev = prev;
+ this.next = next;
+ this.value = new ValueSignal(value, valueType);
+ }
+
+ @Override
+ public UUID id() {
+ return id;
+ }
+
+ @Override
+ public UUID previous() {
+ return prev;
+ }
+
+ @Override
+ public UUID next() {
+ return next;
+ }
+
+ @Override
+ public V value() {
+ return value.getValue();
+ }
+
+ @Override
+ public ValueSignal getValueSignal() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o)
+ return true;
+ if (!(o instanceof ListEntry> entry))
+ return false;
+ return Objects.equals(id, entry.id());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(id);
+ }
+ }
+
+ @BeforeClass
+ public static void setup() {
+ var appCtx = Mockito.mock(ApplicationContext.class);
+ var endpointObjectMapper = EndpointControllerMockBuilder
+ .createEndpointObjectMapper(appCtx,
+ new JacksonObjectMapperFactory.Json());
+ Signal.setMapper(endpointObjectMapper);
+ }
+
+ @AfterClass
+ public static void tearDown() {
+ Signal.setMapper(null);
+ }
+
+ @Test
+ public void constructor_withNullArgs_doesNotAcceptNull() {
+ assertThrows(NullPointerException.class,
+ () -> new ListSignal<>((Class>) null));
+ }
+
+ @Test
+ public void getId_returns_not_null() {
+ var listSignal = new ListSignal<>(String.class);
+ assertNotNull(listSignal.getId());
+ }
+
+ @Test
+ public void subscribe_returns_flux_withJsonEvents() {
+ var signal = new ListSignal<>(Person.class);
+
+ var flux = signal.subscribe();
+
+ flux.subscribe(Assert::assertNotNull);
+ }
+
+ @Test
+ public void subscribe_toAnEntry_returns_flux_withJsonEvents() {
+ var listSignal = new ListSignal<>(Person.class);
+ var listFlux = listSignal.subscribe();
+
+ var entryIds = new ArrayList();
+ var counter = new AtomicInteger(0);
+ listFlux.subscribe(eventJson -> {
+ // skip the initial state notification when counter is 0
+ if (counter.get() == 1) {
+ assertTrue(isAccepted(eventJson));
+ entryIds.add(extractEntryId(eventJson));
+ }
+ counter.incrementAndGet();
+ });
+ var evt = createInsertEvent(new Person("John", 42, true),
+ InsertPosition.LAST);
+ listSignal.submit(evt);
+ assertEquals(2, counter.get());
+
+ var entryFlux = listSignal.subscribe(entryIds.get(0));
+ entryFlux.subscribe(Assert::assertNotNull);
+ }
+
+ @Test
+ public void submit_notifies_subscribers_whenInsertingAtLast() {
+ var signal = new ListSignal<>(Person.class);
+ Flux flux = signal.subscribe();
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ var counter = new AtomicInteger(0);
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+
+ if (counter.get() == 0) {
+ // notification for the initial state
+ var entries = extractEntries(eventJson, Person.class,
+ Entry::new);
+ assertEquals(0, entries.size());
+ } else if (counter.get() == 1) {
+ assertTrue(isAccepted(eventJson));
+ }
+ counter.incrementAndGet();
+ });
+
+ var evt = createInsertEvent(new Person(name, age, adult),
+ InsertPosition.LAST);
+ signal.submit(evt);
+
+ assertEquals(2, counter.get());
+
+ var entries = extractEntries(signal.createSnapshotEvent(), Person.class,
+ Entry::new);
+ assertEquals(1, entries.size());
+ var entry = entries.get(0);
+ assertEquals(name, entry.value().getName());
+ assertEquals(age, entry.value().getAge());
+ assertEquals(adult, entry.value().isAdult());
+ }
+
+ @Test
+ public void submit_setEvent_toAnEntry_notifies_subscribersToTheEntry_withCorrectEvents() {
+ var listSignal = new ListSignal<>(Person.class);
+ var listFlux = listSignal.subscribe();
+
+ var entryIds = new ArrayList();
+ var counter = new AtomicInteger(0);
+ listFlux.subscribe(eventJson -> {
+ // skip the initial state notification when counter is 0
+ if (counter.get() > 0) {
+ assertTrue(isAccepted(eventJson));
+ entryIds.add(extractEntryId(eventJson));
+ }
+ counter.incrementAndGet();
+ });
+ var evt = createInsertEvent(new Person("John", 42, true),
+ InsertPosition.LAST);
+ listSignal.submit(evt);
+ var evt2 = createInsertEvent(new Person("Smith", 44, true),
+ InsertPosition.LAST);
+ listSignal.submit(evt2);
+
+ assertEquals(3, counter.get());
+
+ var entries = extractEntries(listSignal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(entries);
+ assertEquals(2, linkedList.size());
+ var entry1 = linkedList.get(0);
+ assertEquals("John", entry1.value().getName());
+ assertEquals(42, entry1.value().getAge());
+ assertTrue(entry1.value().isAdult());
+
+ var entryFlux = listSignal.subscribe(entryIds.get(0));
+ var entryCounter = new AtomicInteger(0);
+ entryFlux.subscribe(eventJson -> {
+ // skip the initial state notification when counter is 0
+ if (entryCounter.get() == 1) {
+ assertEquals(StateEvent.EventType.SET.name().toLowerCase(),
+ eventJson.get(StateEvent.Field.TYPE).asText());
+ assertTrue(isAccepted(eventJson));
+ }
+ entryCounter.incrementAndGet();
+ });
+ var setEvent = createSetEvent(new Person("Jane", 13, false),
+ entryIds.get(0));
+ listSignal.submit(setEvent);
+ assertEquals(2, entryCounter.get());
+
+ entries = extractEntries(listSignal.createSnapshotEvent(), Person.class,
+ Entry::new);
+ linkedList = buildLinkedList(entries);
+ assertEquals(2, linkedList.size());
+ var sameEntry1 = linkedList.get(0);
+ assertEquals("Jane", sameEntry1.value().getName());
+ assertEquals(13, sameEntry1.value().getAge());
+ assertFalse(sameEntry1.value().isAdult());
+ var secondEntry = linkedList.get(1);
+ assertEquals("Smith", secondEntry.value().getName());
+ assertEquals(44, secondEntry.value().getAge());
+ assertTrue(secondEntry.value().isAdult());
+
+ assertEquals(entry1.id(), sameEntry1.id());
+ assertEquals(entryIds.get(1), secondEntry.id().toString());
+
+ // No change is expected in the list signal itself:
+ assertEquals(3, counter.get());
+ }
+
+ @Test
+ public void submit_replaceEvent_toAnEntry_notifies_subscribersToTheEntry_withCorrectEvents() {
+ var listSignal = new ListSignal<>(Person.class);
+ var listFlux = listSignal.subscribe();
+
+ var entryIds = new ArrayList();
+ var counter = new AtomicInteger(0);
+ listFlux.subscribe(eventJson -> {
+ // skip the initial state notification when counter is 0
+ if (counter.get() > 0) {
+ assertTrue(isAccepted(eventJson));
+ entryIds.add(extractEntryId(eventJson));
+ }
+ counter.incrementAndGet();
+ });
+ var evt = createInsertEvent(new Person("John", 42, true),
+ InsertPosition.LAST);
+ listSignal.submit(evt);
+ var evt2 = createInsertEvent(new Person("Smith", 44, true),
+ InsertPosition.LAST);
+ listSignal.submit(evt2);
+
+ assertEquals(3, counter.get());
+
+ var entries = extractEntries(listSignal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(entries);
+ assertEquals(2, linkedList.size());
+ var entry2 = linkedList.get(1);
+ assertEquals("Smith", entry2.value().getName());
+ assertEquals(44, entry2.value().getAge());
+ assertTrue(entry2.value().isAdult());
+
+ var entryFlux = listSignal.subscribe(entryIds.get(1));
+ var entryCounter = new AtomicInteger(0);
+ entryFlux.subscribe(eventJson -> {
+ // skip the initial state notification when counter is 0
+ if (entryCounter.get() == 1) {
+ assertEquals(StateEvent.EventType.REPLACE.name().toLowerCase(),
+ eventJson.get(StateEvent.Field.TYPE).asText());
+ assertTrue(isAccepted(eventJson));
+ }
+ entryCounter.incrementAndGet();
+ });
+ var replaceEvent = createReplaceEvent(new Person("Smith", 44, true),
+ new Person("Jane", 13, false), entryIds.get(1));
+ listSignal.submit(replaceEvent);
+ assertEquals(2, entryCounter.get());
+
+ entries = extractEntries(listSignal.createSnapshotEvent(), Person.class,
+ Entry::new);
+ linkedList = buildLinkedList(entries);
+ assertEquals(2, linkedList.size());
+ var entry1 = linkedList.get(0);
+ assertEquals("John", entry1.value().getName());
+ assertEquals(42, entry1.value().getAge());
+ assertTrue(entry1.value().isAdult());
+ var secondEntry = linkedList.get(1);
+ assertEquals("Jane", secondEntry.value().getName());
+ assertEquals(13, secondEntry.value().getAge());
+ assertFalse(secondEntry.value().isAdult());
+
+ assertEquals(entry2.id(), secondEntry.id());
+ assertEquals(entryIds.get(1), secondEntry.id().toString());
+
+ // No change is expected in the list signal itself:
+ assertEquals(3, counter.get());
+ }
+
+ @Test
+ public void submit_willThrow_when_insertingAtPositionsOtherThanLast() {
+ var signal = new ListSignal<>(Person.class);
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ var person = new Person(name, age, adult);
+
+ assertThrows(UnsupportedOperationException.class, () -> signal
+ .submit(createInsertEvent(person, InsertPosition.FIRST)));
+ assertThrows(UnsupportedOperationException.class, () -> signal
+ .submit(createInsertEvent(person, InsertPosition.BEFORE)));
+ assertThrows(UnsupportedOperationException.class, () -> signal
+ .submit(createInsertEvent(person, InsertPosition.AFTER)));
+ }
+
+ @Test
+ public void submit_many_insertLastEvents_notifiesSubscribersWithCorrectStateChanges() {
+ var signal = new ListSignal<>(Person.class);
+ Flux flux = signal.subscribe();
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ var counter = new AtomicInteger(0);
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+ if (counter.get() == 0) {
+ // notification for the initial state
+ var entries = extractEntries(eventJson, Person.class,
+ Entry::new);
+ assertEquals(0, entries.size());
+ // check snapshot events to also marked as accepted
+ assertTrue(isAccepted(eventJson));
+ } else {
+ assertTrue(isAccepted(eventJson));
+ }
+ counter.incrementAndGet();
+ });
+
+ IntStream.of(1, 2, 3, 4, 5).forEach(i -> {
+ var evt = createInsertEvent(new Person(name + i, age + i, adult),
+ InsertPosition.LAST);
+ signal.submit(evt);
+ });
+ assertEquals(6, counter.get());
+
+ var snapshot = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(snapshot);
+ assertEquals(5, linkedList.size());
+
+ for (int i = 0; i < linkedList.size(); i++) {
+ var entry = linkedList.get(i);
+ assertEquals(name + (i + 1), entry.value().getName());
+ assertEquals(age + (i + 1), entry.value().getAge());
+ if (i < linkedList.size() - 1) {
+ var nextEntry = linkedList.get(i + 1);
+ assertEquals(entry.id(), nextEntry.previous());
+ assertEquals(entry.next(), nextEntry.id());
+ }
+ }
+ }
+
+ @Test
+ public void submit_remove_notifiesWithCorrectStateChanges() {
+ var signal = new ListSignal<>(Person.class);
+ Flux flux = signal.subscribe();
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ var counter = new AtomicInteger(0);
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+ if (counter.get() > 0) {
+ assertTrue(isAccepted(eventJson));
+ }
+ counter.incrementAndGet();
+ });
+
+ var person1 = new Person(name, age, adult);
+ var person2 = new Person(name + 2, age + 2, adult);
+ var person3 = new Person(name + 3, age + 3, adult);
+
+ var insert1 = createInsertEvent(person1, InsertPosition.LAST);
+ var insert2 = createInsertEvent(person2, InsertPosition.LAST);
+ var insert3 = createInsertEvent(person3, InsertPosition.LAST);
+
+ signal.submit(insert1);
+ signal.submit(insert2);
+ signal.submit(insert3);
+
+ var receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(receivedEntries);
+ assertEquals(3, linkedList.size());
+
+ var entry2 = linkedList.get(1);
+ var removeEvent = createRemoveEvent(entry2);
+ signal.submit(removeEvent);
+
+ assertEquals(5, counter.get());
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ assertEquals(2, linkedList.size());
+
+ var entry1 = linkedList.get(0);
+ var entry3 = linkedList.get(1);
+
+ assertEquals(person1.getName(), entry1.value().getName());
+ assertEquals(person1.getAge(), entry1.value().getAge());
+ assertEquals(person3.getName(), entry3.value().getName());
+ assertEquals(person3.getAge(), entry3.value().getAge());
+ }
+
+ @Test
+ public void submit_remove_notifiesWithCorrectStateChanges_whenRemovingTheOnlyEntry() {
+ var signal = new ListSignal<>(Person.class);
+ Flux flux = signal.subscribe();
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+ isAccepted(eventJson);
+ });
+
+ var person1 = new Person(name, age, adult);
+ var insert1 = createInsertEvent(person1, InsertPosition.LAST);
+ signal.submit(insert1);
+
+ var receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(receivedEntries);
+ assertEquals(1, linkedList.size());
+
+ var entry1 = linkedList.get(0);
+ var removeEvent = createRemoveEvent(entry1);
+ signal.submit(removeEvent);
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ assertEquals(0, receivedEntries.size());
+ }
+
+ @Test
+ public void submit_remove_notifiesWithCorrectStateChanges_whenRemovingTheHead() {
+ var signal = new ListSignal<>(Person.class);
+ Flux flux = signal.subscribe();
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+ isAccepted(eventJson);
+ });
+
+ var person1 = new Person(name, age, adult);
+ var person2 = new Person(name + 2, age + 2, adult);
+ var insert1 = createInsertEvent(person1, InsertPosition.LAST);
+ var insert2 = createInsertEvent(person2, InsertPosition.LAST);
+ signal.submit(insert1);
+ signal.submit(insert2);
+
+ var receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(receivedEntries);
+ assertEquals(2, linkedList.size());
+
+ var head = linkedList.get(0);
+ var removeEvent = createRemoveEvent(head);
+ signal.submit(removeEvent);
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ assertEquals(1, linkedList.size());
+
+ var entry2 = linkedList.get(0);
+ assertEquals(person2.getName(), entry2.value().getName());
+ assertEquals(person2.getAge(), entry2.value().getAge());
+
+ var person3 = new Person(name + 4, age + 4, adult);
+ var insert3 = createInsertEvent(person3, InsertPosition.LAST);
+ signal.submit(insert3);
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ assertEquals(2, linkedList.size());
+ var newHead = linkedList.get(0);
+ assertEquals(person2.getName(), newHead.value().getName());
+ assertEquals(person2.getAge(), newHead.value().getAge());
+ }
+
+ @Test
+ public void submit_remove_notifiesWithCorrectStateChanges_whenRemovingTheTail() {
+ var signal = new ListSignal<>(Person.class);
+ Flux flux = signal.subscribe();
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+ isAccepted(eventJson);
+ });
+
+ var person1 = new Person(name, age, adult);
+ var person2 = new Person(name + 2, age + 2, adult);
+ var insert1 = createInsertEvent(person1, InsertPosition.LAST);
+ var insert2 = createInsertEvent(person2, InsertPosition.LAST);
+ signal.submit(insert1);
+ signal.submit(insert2);
+
+ var receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(receivedEntries);
+ assertEquals(2, linkedList.size());
+
+ var tail = linkedList.get(1);
+ var removeEvent = createRemoveEvent(tail);
+ signal.submit(removeEvent);
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ assertEquals(1, linkedList.size());
+
+ var head = linkedList.get(0);
+ assertEquals(person1.getName(), head.value().getName());
+ assertEquals(person1.getAge(), head.value().getAge());
+
+ // insert the second person again
+ signal.submit(insert2);
+
+ var person3 = new Person(name + 4, age + 4, adult);
+ var insert3 = createInsertEvent(person3, InsertPosition.LAST);
+ signal.submit(insert3);
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ tail = linkedList.get(2);
+ removeEvent = createRemoveEvent(tail);
+ signal.submit(removeEvent);
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ var newTail = linkedList.get(1);
+ assertEquals(person2.getName(), newTail.value().getName());
+ assertEquals(person2.getAge(), newTail.value().getAge());
+ }
+
+ @Test
+ public void submit_various_insert_and_remove_notifiesWithCorrectStateChanges() {
+ var signal = new ListSignal<>(Person.class);
+ Flux flux = signal.subscribe();
+
+ var name = "John";
+ var age = 42;
+ var adult = true;
+
+ flux.subscribe(eventJson -> {
+ assertNotNull(eventJson);
+ isAccepted(eventJson);
+ });
+
+ var person1 = new Person(name + 1, age + 1, adult);
+ var person2 = new Person(name + 2, age + 2, adult);
+ var person3 = new Person(name + 3, age + 3, adult);
+ var person4 = new Person(name + 4, age + 4, adult);
+ var person5 = new Person(name + 5, age + 5, adult);
+ var person6 = new Person(name + 6, age + 6, adult);
+
+ var insert1 = createInsertEvent(person1, InsertPosition.LAST);
+ var insert2 = createInsertEvent(person2, InsertPosition.LAST);
+ var insert3 = createInsertEvent(person3, InsertPosition.LAST);
+ var insert4 = createInsertEvent(person4, InsertPosition.LAST);
+ var insert5 = createInsertEvent(person5, InsertPosition.LAST);
+ var insert6 = createInsertEvent(person6, InsertPosition.LAST);
+
+ signal.submit(insert1);
+ signal.submit(insert2);
+ signal.submit(insert3);
+ signal.submit(insert4);
+ signal.submit(insert5);
+
+ var receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ var linkedList = buildLinkedList(receivedEntries);
+ assertEquals(5, linkedList.size());
+
+ var entry1 = linkedList.get(0);
+ var entry2 = linkedList.get(1);
+ var entry3 = linkedList.get(2);
+ var entry4 = linkedList.get(3);
+ var entry5 = linkedList.get(4);
+
+ signal.submit(createRemoveEvent(entry2));
+ signal.submit(createRemoveEvent(entry4));
+
+ signal.submit(insert6);
+
+ signal.submit(createRemoveEvent(entry5));
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ assertEquals(3, linkedList.size());
+
+ var entry6 = linkedList.get(2);
+
+ signal.submit(insert2);
+ signal.submit(insert4);
+
+ signal.submit(createRemoveEvent(entry1));
+ signal.submit(createRemoveEvent(entry3));
+ signal.submit(createRemoveEvent(entry6));
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ assertEquals(2, linkedList.size());
+
+ assertEquals(person2.getName(), linkedList.get(0).value().getName());
+ assertEquals(person4.getName(), linkedList.get(1).value().getName());
+
+ signal.submit(createRemoveEvent(linkedList.get(0)));
+ signal.submit(createRemoveEvent(linkedList.get(1)));
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ assertEquals(0, receivedEntries.size());
+
+ signal.submit(insert6);
+ signal.submit(insert5);
+ signal.submit(insert4);
+ signal.submit(insert3);
+ signal.submit(insert2);
+ signal.submit(insert1);
+
+ receivedEntries = extractEntries(signal.createSnapshotEvent(),
+ Person.class, Entry::new);
+ linkedList = buildLinkedList(receivedEntries);
+ assertEquals(6, linkedList.size());
+
+ for (int i = linkedList.size(); i > 0; i--) {
+ var entry = linkedList.get(6 - i);
+ assertEquals(name + i, entry.value().getName());
+ assertEquals(age + i, entry.value().getAge());
+ }
+ }
+
+ @Test
+ public void withInsertionValidator_doesNotLimitTheRemoveOperation() {
+ ListSignal unrestrictedSignal = new ListSignal<>(String.class);
+ ListSignal noInsertionAllowedSignal = unrestrictedSignal
+ .withOperationValidator(operation -> {
+ if (operation instanceof ListInsertOperation) {
+ return ValidationResult.reject("No insertion allowed");
+ }
+ return ValidationResult.allow();
+ });
+
+ unrestrictedSignal
+ .submit(createInsertEvent("John Normal", InsertPosition.LAST));
+ unrestrictedSignal.submit(
+ createInsertEvent("Jane Executive", InsertPosition.LAST));
+ // make sure restriction is in-tact:
+ noInsertionAllowedSignal.submit(
+ createInsertEvent("Should-be Rejected", InsertPosition.LAST));
+
+ var entries = extractEntries(unrestrictedSignal.createSnapshotEvent(),
+ String.class, Entry::new);
+ assertEquals(2, entries.size());
+ assertEquals(2,
+ extractEntries(noInsertionAllowedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+
+ // remove the first entry through the restricted signal:
+ noInsertionAllowedSignal.submit(createRemoveEvent(entries.get(0)));
+ assertEquals(1,
+ extractEntries(noInsertionAllowedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+ assertEquals(1, extractEntries(unrestrictedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+ }
+
+ @Test
+ public void withInsertionValidator_doesNotChangeSubscriptionBehavior() {
+ ListSignal unrestrictedSignal = new ListSignal<>(String.class);
+ ListSignal noInsertionAllowedSignal = unrestrictedSignal
+ .withOperationValidator(operation -> {
+ if (operation instanceof ListInsertOperation) {
+ return ValidationResult.reject("No insertion allowed");
+ }
+ return ValidationResult.allow();
+ });
+
+ Flux unrestrictedFlux = unrestrictedSignal.subscribe();
+ AtomicInteger unrestrictedCounter = new AtomicInteger(0);
+ unrestrictedFlux.subscribe(eventJson -> {
+ unrestrictedCounter.incrementAndGet();
+ });
+ assertEquals(1, unrestrictedCounter.get()); // initial state
+
+ Flux noInsertionAllowedFlux = noInsertionAllowedSignal
+ .subscribe();
+ AtomicInteger noInsertionAllowedCounter = new AtomicInteger(0);
+ noInsertionAllowedFlux.subscribe(eventJson -> {
+ noInsertionAllowedCounter.incrementAndGet();
+ });
+ assertEquals(1, noInsertionAllowedCounter.get()); // initial state
+
+ unrestrictedSignal
+ .submit(createInsertEvent("John Normal", InsertPosition.LAST));
+ assertEquals(2, unrestrictedCounter.get());
+ assertEquals(2, noInsertionAllowedCounter.get());
+
+ unrestrictedSignal.submit(
+ createInsertEvent("Jane Executive", InsertPosition.LAST));
+ assertEquals(3, unrestrictedCounter.get());
+ assertEquals(3, noInsertionAllowedCounter.get());
+ }
+
+ @Test
+ public void withRemovalValidator_doesNotLimitTheInsertOperation() {
+ ListSignal unrestrictedSignal = new ListSignal<>(String.class);
+ ListSignal noRemovalAllowedSignal = unrestrictedSignal
+ .withOperationValidator(operation -> {
+ if (operation instanceof ListRemoveOperation) {
+ return ValidationResult.reject("No removal allowed");
+ }
+ return ValidationResult.allow();
+ });
+
+ unrestrictedSignal
+ .submit(createInsertEvent("John Normal", InsertPosition.LAST));
+ unrestrictedSignal.submit(
+ createInsertEvent("Jane Executive", InsertPosition.LAST));
+ var entries = extractEntries(
+ noRemovalAllowedSignal.createSnapshotEvent(), String.class,
+ Entry::new);
+
+ // assert that restriction is in-tact:
+ noRemovalAllowedSignal.submit(createRemoveEvent(entries.get(0)));
+ entries = extractEntries(noRemovalAllowedSignal.createSnapshotEvent(),
+ String.class, Entry::new);
+ assertEquals(2, entries.size());
+ assertEquals(2, extractEntries(unrestrictedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+
+ unrestrictedSignal.submit(createRemoveEvent(entries.get(0)));
+ entries = extractEntries(noRemovalAllowedSignal.createSnapshotEvent(),
+ String.class, Entry::new);
+ assertEquals(1, entries.size());
+ assertEquals(1, extractEntries(unrestrictedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+
+ // insert another entry through the restricted signal:
+ noRemovalAllowedSignal.submit(
+ createInsertEvent("Emma Executive", InsertPosition.LAST));
+ assertEquals(2,
+ extractEntries(noRemovalAllowedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+ assertEquals(2, extractEntries(unrestrictedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+ }
+
+ @Test
+ public void withRemovalValidator_doesNotChangeSubscriptionBehavior() {
+ ListSignal unrestrictedSignal = new ListSignal<>(String.class);
+ ListSignal noRemovalAllowedSignal = unrestrictedSignal
+ .withOperationValidator(operation -> {
+ if (operation instanceof ListRemoveOperation) {
+ return ValidationResult.reject("No removal allowed");
+ }
+ return ValidationResult.allow();
+ });
+
+ Flux unrestrictedFlux = unrestrictedSignal.subscribe();
+ AtomicInteger unrestrictedCounter = new AtomicInteger(0);
+ unrestrictedFlux
+ .subscribe(eventJson -> unrestrictedCounter.incrementAndGet());
+ assertEquals(1, unrestrictedCounter.get()); // initial state
+
+ Flux noRemovalAllowedFlux = noRemovalAllowedSignal
+ .subscribe();
+ AtomicInteger noRemovalAllowedCounter = new AtomicInteger(0);
+ noRemovalAllowedFlux.subscribe(
+ eventJson -> noRemovalAllowedCounter.incrementAndGet());
+ assertEquals(1, noRemovalAllowedCounter.get()); // initial state
+
+ unrestrictedSignal
+ .submit(createInsertEvent("John Normal", InsertPosition.LAST));
+ unrestrictedSignal.submit(
+ createInsertEvent("Jane Executive", InsertPosition.LAST));
+ assertEquals(3, unrestrictedCounter.get());
+ assertEquals(3, noRemovalAllowedCounter.get());
+
+ var entries = extractEntries(
+ noRemovalAllowedSignal.createSnapshotEvent(), String.class,
+ Entry::new);
+
+ // updates should be received for the rejected events:
+ noRemovalAllowedSignal.submit(createRemoveEvent(entries.get(0)));
+ assertEquals(4, unrestrictedCounter.get());
+ assertEquals(4, noRemovalAllowedCounter.get());
+
+ unrestrictedSignal.submit(createRemoveEvent(entries.get(0)));
+ assertEquals(5, unrestrictedCounter.get());
+ assertEquals(5, noRemovalAllowedCounter.get());
+
+ unrestrictedSignal.submit(createRemoveEvent(entries.get(1)));
+ assertEquals(6, unrestrictedCounter.get());
+ assertEquals(6, noRemovalAllowedCounter.get());
+ }
+
+ @Test
+ public void withMultipleStructuralValidators_allValidatorsAreApplied() {
+ ListSignal partiallyRestrictedSignal = new ListSignal<>(
+ String.class).withOperationValidator(operation -> {
+ if (operation instanceof ListInsertOperation insOp
+ && insOp.value().startsWith("Joe")) {
+ return ValidationResult.reject("No Joe is allowed");
+ }
+ return ValidationResult.allow();
+ });
+
+ ListSignal readonlyStructureSignal = partiallyRestrictedSignal
+ .withOperationValidator(operation -> {
+ if (operation instanceof ListInsertOperation) {
+ return ValidationResult.reject("No insertion allowed");
+ } else if (operation instanceof ListRemoveOperation) {
+ return ValidationResult.reject("No removal allowed");
+ }
+ return ValidationResult.allow();
+ });
+
+ partiallyRestrictedSignal
+ .submit(createInsertEvent("John Normal", InsertPosition.LAST));
+ partiallyRestrictedSignal.submit(
+ createInsertEvent("Jane Executive", InsertPosition.LAST));
+ partiallyRestrictedSignal.submit(createInsertEvent(
+ "Joe Should-be-rejected", InsertPosition.LAST));
+
+ var entries = extractEntries(
+ readonlyStructureSignal.createSnapshotEvent(), String.class,
+ Entry::new);
+ assertEquals(2, entries.size());
+
+ readonlyStructureSignal.submit(createRemoveEvent(entries.get(0)));
+ assertEquals(2,
+ extractEntries(readonlyStructureSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+
+ readonlyStructureSignal.submit(createRemoveEvent(entries.get(1)));
+ assertEquals(2,
+ extractEntries(readonlyStructureSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+
+ readonlyStructureSignal.submit(
+ createInsertEvent("Emma Executive", InsertPosition.LAST));
+ assertEquals(2,
+ extractEntries(readonlyStructureSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+
+ partiallyRestrictedSignal.submit(
+ createInsertEvent("Emma Executive", InsertPosition.LAST));
+ assertEquals(3,
+ extractEntries(partiallyRestrictedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+ assertEquals(3,
+ extractEntries(readonlyStructureSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+ }
+
+ @Test
+ public void withItemSetValueValidator_doesNotLimitTheOriginalInstance_norOtherOperations() {
+ ListSignal signal = new ListSignal<>(String.class);
+ ListSignal noItemSetValueAllowedSignal = signal
+ .withOperationValidator(operation -> {
+ if (operation instanceof SetValueOperation) {
+ return ValidationResult
+ .reject("No item set value allowed");
+ }
+ return ValidationResult.allow();
+ });
+ // add items through both signal instances:
+ signal.submit(createInsertEvent("John Normal", InsertPosition.LAST));
+ // verify that adding itemSetValueValidator doesn't affect other
+ // operations:
+ noItemSetValueAllowedSignal.submit(
+ createInsertEvent("Jane Executive", InsertPosition.LAST));
+
+ var entries = extractEntries(signal.createSnapshotEvent(), String.class,
+ Entry::new);
+ assertEquals(2, entries.size());
+ // the restricted instance sees the same entries as the original one:
+ assertEquals(2,
+ extractEntries(
+ noItemSetValueAllowedSignal.createSnapshotEvent(),
+ String.class, Entry::new).size());
+
+ var orderedEntries = buildLinkedList(entries);
+ // unrestricted instance allows item set value:
+ var firstSignalId = orderedEntries.get(0).id();
+ signal.submit(
+ createSetEvent("Should-be accepted", firstSignalId.toString()));
+ // the restricted instance doesn't allow item set value:
+ var secondSignalId = orderedEntries.get(1).id();
+ noItemSetValueAllowedSignal.submit(createSetEvent("Should-be Rejected",
+ secondSignalId.toString()));
+
+ entries = extractEntries(signal.createSnapshotEvent(), String.class,
+ Entry::new);
+ orderedEntries = buildLinkedList(entries);
+
+ // verify the change:
+ assertEquals(2, orderedEntries.size());
+ assertEquals("Should-be accepted", orderedEntries.get(0).value());
+ assertEquals("Jane Executive", orderedEntries.get(1).value());
+ assertEquals(secondSignalId, orderedEntries.get(1).id());
+
+ // the item SetValue validator doesn't limit item Replace operation:
+ noItemSetValueAllowedSignal.submit(createReplaceEvent("Jane Executive",
+ "Replace Accepted", secondSignalId.toString()));
+ entries = extractEntries(signal.createSnapshotEvent(), String.class,
+ Entry::new);
+ orderedEntries = buildLinkedList(entries);
+
+ // verify replace operation was successful, even through the restricted
+ // instance:
+ assertEquals(2, orderedEntries.size());
+ assertEquals("Should-be accepted", orderedEntries.get(0).value());
+ assertEquals("Replace Accepted", orderedEntries.get(1).value());
+
+ // verify the restricted instance allows removing the items:
+ noItemSetValueAllowedSignal
+ .submit(createRemoveEvent(orderedEntries.get(1)));
+ entries = extractEntries(signal.createSnapshotEvent(), String.class,
+ Entry::new);
+ assertEquals(1, entries.size());
+ }
+
+ @Test
+ public void withItemReplaceValueValidator_doesNotLimitTheOriginalInstance_norOtherOperations() {
+ ListSignal