aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/language-web/src
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects/language-web/src')
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java52
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java35
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java25
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java29
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java192
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java16
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java44
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java8
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java14
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java26
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java180
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java11
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java79
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java72
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java81
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java57
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java4
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java15
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java23
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java89
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java68
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java33
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java26
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java35
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java9
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java133
-rw-r--r--subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java83
-rw-r--r--subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java204
-rw-r--r--subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java42
-rw-r--r--subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java47
-rw-r--r--subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java109
-rw-r--r--subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java98
-rw-r--r--subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java165
33 files changed, 2104 insertions, 0 deletions
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java
new file mode 100644
index 00000000..b13ae95d
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java
@@ -0,0 +1,52 @@
1package tools.refinery.language.web;
2
3import java.io.IOException;
4import java.time.Duration;
5import java.util.regex.Pattern;
6
7import org.eclipse.jetty.http.HttpHeader;
8
9import jakarta.servlet.Filter;
10import jakarta.servlet.FilterChain;
11import jakarta.servlet.FilterConfig;
12import jakarta.servlet.ServletException;
13import jakarta.servlet.ServletRequest;
14import jakarta.servlet.ServletResponse;
15import jakarta.servlet.http.HttpServletRequest;
16import jakarta.servlet.http.HttpServletResponse;
17
18public class CacheControlFilter implements Filter {
19 private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)");
20
21 private static final Duration EXPIRY = Duration.ofDays(365);
22
23 private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY.toSeconds() + ", immutable";
24
25 private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate";
26
27 @Override
28 public void init(FilterConfig filterConfig) throws ServletException {
29 // Nothing to initialize.
30 }
31
32 @Override
33 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
34 throws IOException, ServletException {
35 if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) {
36 if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) {
37 httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_CACHE_VALUE);
38 httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(),
39 System.currentTimeMillis() + EXPIRY.toMillis());
40 } else {
41 httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_NO_CACHE_VALUE);
42 httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), 0);
43 }
44 }
45 chain.doFilter(request, response);
46 }
47
48 @Override
49 public void destroy() {
50 // Nothing to dispose.
51 }
52}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
new file mode 100644
index 00000000..ec55036f
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java
@@ -0,0 +1,35 @@
1/*
2 * generated by Xtext 2.25.0
3 */
4package tools.refinery.language.web;
5
6import org.eclipse.xtext.web.server.XtextServiceDispatcher;
7import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
8import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
9import org.eclipse.xtext.web.server.occurrences.OccurrencesService;
10
11import tools.refinery.language.web.occurrences.ProblemOccurrencesService;
12import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher;
13import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess;
14import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider;
15
16/**
17 * Use this class to register additional components to be used within the web application.
18 */
19public class ProblemWebModule extends AbstractProblemWebModule {
20 public Class<? extends IWebDocumentProvider> bindIWebDocumentProvider() {
21 return PushWebDocumentProvider.class;
22 }
23
24 public Class<? extends XtextWebDocumentAccess> bindXtextWebDocumentAccess() {
25 return PushWebDocumentAccess.class;
26 }
27
28 public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() {
29 return PushServiceDispatcher.class;
30 }
31
32 public Class<? extends OccurrencesService> bindOccurrencesService() {
33 return ProblemOccurrencesService.class;
34 }
35}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java
new file mode 100644
index 00000000..4738bc80
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSetup.java
@@ -0,0 +1,25 @@
1/*
2 * generated by Xtext 2.25.0
3 */
4package tools.refinery.language.web;
5
6import org.eclipse.xtext.util.Modules2;
7
8import com.google.inject.Guice;
9import com.google.inject.Injector;
10
11import tools.refinery.language.ProblemRuntimeModule;
12import tools.refinery.language.ProblemStandaloneSetup;
13import tools.refinery.language.ide.ProblemIdeModule;
14
15/**
16 * Initialization support for running Xtext languages in web applications.
17 */
18public class ProblemWebSetup extends ProblemStandaloneSetup {
19
20 @Override
21 public Injector createInjector() {
22 return Guice.createInjector(Modules2.mixin(new ProblemRuntimeModule(), new ProblemIdeModule(), new ProblemWebModule()));
23 }
24
25}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
new file mode 100644
index 00000000..df67b521
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java
@@ -0,0 +1,29 @@
1package tools.refinery.language.web;
2
3import org.eclipse.xtext.util.DisposableRegistry;
4
5import jakarta.servlet.ServletException;
6import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
7
8public class ProblemWebSocketServlet extends XtextWebSocketServlet {
9
10 private static final long serialVersionUID = -7040955470384797008L;
11
12 private transient DisposableRegistry disposableRegistry;
13
14 @Override
15 public void init() throws ServletException {
16 super.init();
17 var injector = new ProblemWebSetup().createInjectorAndDoEMFRegistration();
18 this.disposableRegistry = injector.getInstance(DisposableRegistry.class);
19 }
20
21 @Override
22 public void destroy() {
23 if (disposableRegistry != null) {
24 disposableRegistry.dispose();
25 disposableRegistry = null;
26 }
27 super.destroy();
28 }
29}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
new file mode 100644
index 00000000..ffd903d0
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java
@@ -0,0 +1,192 @@
1/*
2 * generated by Xtext 2.25.0
3 */
4package tools.refinery.language.web;
5
6import java.io.File;
7import java.io.IOException;
8import java.net.InetSocketAddress;
9import java.net.URI;
10import java.net.URISyntaxException;
11import java.util.EnumSet;
12import java.util.Optional;
13import java.util.Set;
14
15import org.eclipse.jetty.server.Server;
16import org.eclipse.jetty.server.session.SessionHandler;
17import org.eclipse.jetty.servlet.DefaultServlet;
18import org.eclipse.jetty.servlet.ServletContextHandler;
19import org.eclipse.jetty.servlet.ServletHolder;
20import org.eclipse.jetty.util.resource.Resource;
21import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
22import org.slf4j.Logger;
23import org.slf4j.LoggerFactory;
24
25import jakarta.servlet.DispatcherType;
26import jakarta.servlet.SessionTrackingMode;
27import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
28
29public class ServerLauncher {
30 public static final String DEFAULT_LISTEN_ADDRESS = "localhost";
31
32 public static final int DEFAULT_LISTEN_PORT = 1312;
33
34 public static final int DEFAULT_PUBLIC_PORT = 443;
35
36 public static final int HTTP_DEFAULT_PORT = 80;
37
38 public static final int HTTPS_DEFAULT_PORT = 443;
39
40 public static final String ALLOWED_ORIGINS_SEPARATOR = ";";
41
42 private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class);
43
44 private final Server server;
45
46 public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource, Optional<String[]> allowedOrigins) {
47 server = new Server(bindAddress);
48 var handler = new ServletContextHandler();
49 addSessionHandler(handler);
50 addProblemServlet(handler, allowedOrigins);
51 if (baseResource != null) {
52 handler.setBaseResource(baseResource);
53 handler.setWelcomeFiles(new String[] { "index.html" });
54 addDefaultServlet(handler);
55 }
56 handler.addFilter(CacheControlFilter.class, "/*", EnumSet.of(DispatcherType.REQUEST));
57 server.setHandler(handler);
58 }
59
60 private void addSessionHandler(ServletContextHandler handler) {
61 var sessionHandler = new SessionHandler();
62 sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE));
63 handler.setSessionHandler(sessionHandler);
64 }
65
66 private void addProblemServlet(ServletContextHandler handler, Optional<String[]> allowedOrigins) {
67 var problemServletHolder = new ServletHolder(ProblemWebSocketServlet.class);
68 if (allowedOrigins.isEmpty()) {
69 LOG.warn("All WebSocket origins are allowed! This setting should not be used in production!");
70 } else {
71 var allowedOriginsString = String.join(XtextWebSocketServlet.ALLOWED_ORIGINS_SEPARATOR,
72 allowedOrigins.get());
73 problemServletHolder.setInitParameter(XtextWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM,
74 allowedOriginsString);
75 }
76 handler.addServlet(problemServletHolder, "/xtext-service");
77 JettyWebSocketServletContainerInitializer.configure(handler, null);
78 }
79
80 private void addDefaultServlet(ServletContextHandler handler) {
81 var defaultServletHolder = new ServletHolder(DefaultServlet.class);
82 var isWindows = System.getProperty("os.name").toLowerCase().contains("win");
83 // Avoid file locking on Windows: https://stackoverflow.com/a/4985717
84 // See also the related Jetty ticket:
85 // https://github.com/eclipse/jetty.project/issues/2925
86 defaultServletHolder.setInitParameter("useFileMappedBuffer", isWindows ? "false" : "true");
87 handler.addServlet(defaultServletHolder, "/");
88 }
89
90 public void start() throws Exception {
91 server.start();
92 LOG.info("Server started on {}", server.getURI());
93 server.join();
94 }
95
96 public static void main(String[] args) {
97 try {
98 var bindAddress = getBindAddress();
99 var baseResource = getBaseResource();
100 var allowedOrigins = getAllowedOrigins();
101 var serverLauncher = new ServerLauncher(bindAddress, baseResource, allowedOrigins);
102 serverLauncher.start();
103 } catch (Exception exception) {
104 LOG.error("Fatal server error", exception);
105 System.exit(1);
106 }
107 }
108
109 private static String getListenAddress() {
110 var listenAddress = System.getenv("LISTEN_ADDRESS");
111 if (listenAddress == null) {
112 return DEFAULT_LISTEN_ADDRESS;
113 }
114 return listenAddress;
115 }
116
117 private static int getListenPort() {
118 var portStr = System.getenv("LISTEN_PORT");
119 if (portStr != null) {
120 return Integer.parseInt(portStr);
121 }
122 return DEFAULT_LISTEN_PORT;
123 }
124
125 private static InetSocketAddress getBindAddress() {
126 var listenAddress = getListenAddress();
127 var listenPort = getListenPort();
128 return new InetSocketAddress(listenAddress, listenPort);
129 }
130
131 private static Resource getBaseResource() throws IOException, URISyntaxException {
132 var baseResourceOverride = System.getenv("BASE_RESOURCE");
133 if (baseResourceOverride != null) {
134 // If a user override is provided, use it.
135 return Resource.newResource(baseResourceOverride);
136 }
137 var indexUrlInJar = ServerLauncher.class.getResource("/webapp/index.html");
138 if (indexUrlInJar != null) {
139 // If the app is packaged in the jar, serve it.
140 var webRootUri = URI.create(indexUrlInJar.toURI().toASCIIString().replaceFirst("/index.html$", "/"));
141 return Resource.newResource(webRootUri);
142 }
143 // Look for unpacked production artifacts (convenience for running from IDE).
144 var unpackedResourcePathComponents = new String[] { System.getProperty("user.dir"), "build", "webpack",
145 "production" };
146 var unpackedResourceDir = new File(String.join(File.separator, unpackedResourcePathComponents));
147 if (unpackedResourceDir.isDirectory()) {
148 return Resource.newResource(unpackedResourceDir);
149 }
150 // Fall back to just serving a 404.
151 return null;
152 }
153
154 private static String getPublicHost() {
155 var publicHost = System.getenv("PUBLIC_HOST");
156 if (publicHost != null) {
157 return publicHost.toLowerCase();
158 }
159 return null;
160 }
161
162 private static int getPublicPort() {
163 var portStr = System.getenv("PUBLIC_PORT");
164 if (portStr != null) {
165 return Integer.parseInt(portStr);
166 }
167 return DEFAULT_LISTEN_PORT;
168 }
169
170 private static Optional<String[]> getAllowedOrigins() {
171 var allowedOrigins = System.getenv("ALLOWED_ORIGINS");
172 if (allowedOrigins != null) {
173 return Optional.of(allowedOrigins.split(ALLOWED_ORIGINS_SEPARATOR));
174 }
175 return getAllowedOriginsFromPublicHostAndPort();
176 }
177
178 private static Optional<String[]> getAllowedOriginsFromPublicHostAndPort() {
179 var publicHost = getPublicHost();
180 if (publicHost == null) {
181 return Optional.empty();
182 }
183 int publicPort = getPublicPort();
184 var scheme = publicPort == HTTPS_DEFAULT_PORT ? "https" : "http";
185 var urlWithPort = String.format("%s://%s:%d", scheme, publicHost, publicPort);
186 if (publicPort == HTTPS_DEFAULT_PORT || publicPort == HTTP_DEFAULT_PORT) {
187 var urlWithoutPort = String.format("%s://%s", scheme, publicHost);
188 return Optional.of(new String[] { urlWithPort, urlWithoutPort });
189 }
190 return Optional.of(new String[] { urlWithPort });
191 }
192}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java
new file mode 100644
index 00000000..d32bbb54
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java
@@ -0,0 +1,16 @@
1package tools.refinery.language.web.occurrences;
2
3import org.eclipse.emf.ecore.EObject;
4import org.eclipse.xtext.web.server.occurrences.OccurrencesService;
5
6import com.google.inject.Singleton;
7
8import tools.refinery.language.model.problem.NamedElement;
9
10@Singleton
11public class ProblemOccurrencesService extends OccurrencesService {
12 @Override
13 protected boolean filter(EObject element) {
14 return super.filter(element) && element instanceof NamedElement;
15 }
16}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
new file mode 100644
index 00000000..fe510f51
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java
@@ -0,0 +1,44 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Objects;
4
5import org.eclipse.xtext.web.server.IServiceResult;
6
7public class PongResult implements IServiceResult {
8 private String pong;
9
10 public PongResult(String pong) {
11 super();
12 this.pong = pong;
13 }
14
15 public String getPong() {
16 return pong;
17 }
18
19 public void setPong(String pong) {
20 this.pong = pong;
21 }
22
23 @Override
24 public int hashCode() {
25 return Objects.hash(pong);
26 }
27
28 @Override
29 public boolean equals(Object obj) {
30 if (this == obj)
31 return true;
32 if (obj == null)
33 return false;
34 if (getClass() != obj.getClass())
35 return false;
36 PongResult other = (PongResult) obj;
37 return Objects.equals(pong, other.pong);
38 }
39
40 @Override
41 public String toString() {
42 return "PongResult [pong=" + pong + "]";
43 }
44}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
new file mode 100644
index 00000000..2a85afe3
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java
@@ -0,0 +1,8 @@
1package tools.refinery.language.web.xtext.server;
2
3import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
4
5@FunctionalInterface
6public interface ResponseHandler {
7 void onResponse(XtextWebResponse response) throws ResponseHandlerException;
8}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
new file mode 100644
index 00000000..34fcb546
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java
@@ -0,0 +1,14 @@
1package tools.refinery.language.web.xtext.server;
2
3public class ResponseHandlerException extends Exception {
4
5 private static final long serialVersionUID = 3589866922420268164L;
6
7 public ResponseHandlerException(String message, Throwable cause) {
8 super(message, cause);
9 }
10
11 public ResponseHandlerException(String message) {
12 super(message);
13 }
14}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
new file mode 100644
index 00000000..78e00a9e
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java
@@ -0,0 +1,26 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.util.Set;
4
5import org.eclipse.xtext.web.server.IServiceContext;
6import org.eclipse.xtext.web.server.ISession;
7
8import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
9
10public record SubscribingServiceContext(IServiceContext delegate, PrecomputationListener subscriber)
11 implements IServiceContext {
12 @Override
13 public Set<String> getParameterKeys() {
14 return delegate.getParameterKeys();
15 }
16
17 @Override
18 public String getParameter(String key) {
19 return delegate.getParameter(key);
20 }
21
22 @Override
23 public ISession getSession() {
24 return delegate.getSession();
25 }
26}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
new file mode 100644
index 00000000..0b417b06
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java
@@ -0,0 +1,180 @@
1package tools.refinery.language.web.xtext.server;
2
3import java.lang.ref.WeakReference;
4import java.util.ArrayList;
5import java.util.HashMap;
6import java.util.List;
7import java.util.Map;
8
9import org.eclipse.emf.common.util.URI;
10import org.eclipse.xtext.resource.IResourceServiceProvider;
11import org.eclipse.xtext.util.IDisposable;
12import org.eclipse.xtext.web.server.IServiceContext;
13import org.eclipse.xtext.web.server.IServiceResult;
14import org.eclipse.xtext.web.server.ISession;
15import org.eclipse.xtext.web.server.InvalidRequestException;
16import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException;
17import org.eclipse.xtext.web.server.XtextServiceDispatcher;
18import org.slf4j.Logger;
19import org.slf4j.LoggerFactory;
20
21import com.google.common.base.Strings;
22import com.google.inject.Injector;
23
24import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind;
25import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse;
26import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse;
27import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage;
28import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
29import tools.refinery.language.web.xtext.server.push.PrecomputationListener;
30import tools.refinery.language.web.xtext.server.push.PushWebDocument;
31import tools.refinery.language.web.xtext.servlet.SimpleServiceContext;
32
33public class TransactionExecutor implements IDisposable, PrecomputationListener {
34 private static final Logger LOG = LoggerFactory.getLogger(TransactionExecutor.class);
35
36 private final ISession session;
37
38 private final IResourceServiceProvider.Registry resourceServiceProviderRegistry;
39
40 private final Map<String, WeakReference<PushWebDocument>> subscriptions = new HashMap<>();
41
42 private ResponseHandler responseHandler;
43
44 private Object callPendingLock = new Object();
45
46 private boolean callPending;
47
48 private List<XtextWebPushMessage> pendingPushMessages = new ArrayList<>();
49
50 public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
51 this.session = session;
52 this.resourceServiceProviderRegistry = resourceServiceProviderRegistry;
53 }
54
55 public void setResponseHandler(ResponseHandler responseHandler) {
56 this.responseHandler = responseHandler;
57 }
58
59 public void handleRequest(XtextWebRequest request) throws ResponseHandlerException {
60 var serviceContext = new SimpleServiceContext(session, request.getRequestData());
61 var ping = serviceContext.getParameter("ping");
62 if (ping != null) {
63 responseHandler.onResponse(new XtextWebOkResponse(request, new PongResult(ping)));
64 return;
65 }
66 synchronized (callPendingLock) {
67 if (callPending) {
68 LOG.error("Reentrant request detected");
69 }
70 if (!pendingPushMessages.isEmpty()) {
71 LOG.error("{} push messages got stuck without a pending request", pendingPushMessages.size());
72 }
73 callPending = true;
74 }
75 try {
76 var injector = getInjector(serviceContext);
77 var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class);
78 var service = serviceDispatcher.getService(new SubscribingServiceContext(serviceContext, this));
79 var serviceResult = service.getService().apply();
80 responseHandler.onResponse(new XtextWebOkResponse(request, serviceResult));
81 } catch (InvalidRequestException e) {
82 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.REQUEST_ERROR, e));
83 } catch (RuntimeException e) {
84 responseHandler.onResponse(new XtextWebErrorResponse(request, XtextWebErrorKind.SERVER_ERROR, e));
85 } finally {
86 synchronized (callPendingLock) {
87 for (var message : pendingPushMessages) {
88 try {
89 responseHandler.onResponse(message);
90 } catch (ResponseHandlerException | RuntimeException e) {
91 LOG.error("Error while flushing push message", e);
92 }
93 }
94 pendingPushMessages.clear();
95 callPending = false;
96 }
97 }
98 }
99
100 @Override
101 public void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName,
102 IServiceResult serviceResult) throws ResponseHandlerException {
103 var message = new XtextWebPushMessage(resourceId, stateId, serviceName, serviceResult);
104 synchronized (callPendingLock) {
105 // If we're currently responding to a call we must delay any push messages until
106 // the reply is sent, because push messages relating to the new state id must be
107 // sent after the response with the new state id so that the client knows about
108 // the new state when it receives the push message.
109 if (callPending) {
110 pendingPushMessages.add(message);
111 } else {
112 responseHandler.onResponse(message);
113 }
114 }
115 }
116
117 @Override
118 public void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
119 PushWebDocument previousDocument = null;
120 var previousSubscription = subscriptions.get(resourceId);
121 if (previousSubscription != null) {
122 previousDocument = previousSubscription.get();
123 }
124 if (previousDocument == document) {
125 return;
126 }
127 if (previousDocument != null) {
128 previousDocument.removePrecomputationListener(this);
129 }
130 subscriptions.put(resourceId, new WeakReference<>(document));
131 }
132
133 /**
134 * Get the injector to satisfy the request in the {@code serviceContext}.
135 *
136 * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}.
137 *
138 * @param serviceContext the Xtext service context of the request
139 * @return the injector for the Xtext language in the request
140 * @throws UnknownLanguageException if the Xtext language cannot be determined
141 */
142 protected Injector getInjector(IServiceContext context) {
143 IResourceServiceProvider resourceServiceProvider = null;
144 var resourceName = context.getParameter("resource");
145 if (resourceName == null) {
146 resourceName = "";
147 }
148 var emfURI = URI.createURI(resourceName);
149 var contentType = context.getParameter("contentType");
150 if (Strings.isNullOrEmpty(contentType)) {
151 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI);
152 if (resourceServiceProvider == null) {
153 if (emfURI.toString().isEmpty()) {
154 throw new UnknownLanguageException(
155 "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'.");
156 } else {
157 throw new UnknownLanguageException(
158 "Unable to identify the Xtext language for resource " + emfURI + ".");
159 }
160 }
161 } else {
162 resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType);
163 if (resourceServiceProvider == null) {
164 throw new UnknownLanguageException(
165 "Unable to identify the Xtext language for contentType " + contentType + ".");
166 }
167 }
168 return resourceServiceProvider.get(Injector.class);
169 }
170
171 @Override
172 public void dispose() {
173 for (var subscription : subscriptions.values()) {
174 var document = subscription.get();
175 if (document != null) {
176 document.removePrecomputationListener(this);
177 }
178 }
179 }
180}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
new file mode 100644
index 00000000..f74bae74
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java
@@ -0,0 +1,11 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import com.google.gson.annotations.SerializedName;
4
5public enum XtextWebErrorKind {
6 @SerializedName("request")
7 REQUEST_ERROR,
8
9 @SerializedName("server")
10 SERVER_ERROR,
11}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
new file mode 100644
index 00000000..01d78c31
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java
@@ -0,0 +1,79 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebErrorResponse implements XtextWebResponse {
8 private String id;
9
10 @SerializedName("error")
11 private XtextWebErrorKind errorKind;
12
13 @SerializedName("message")
14 private String errorMessage;
15
16 public XtextWebErrorResponse(String id, XtextWebErrorKind errorKind, String errorMessage) {
17 super();
18 this.id = id;
19 this.errorKind = errorKind;
20 this.errorMessage = errorMessage;
21 }
22
23 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind,
24 String errorMessage) {
25 this(request.getId(), errorKind, errorMessage);
26 }
27
28 public XtextWebErrorResponse(XtextWebRequest request, XtextWebErrorKind errorKind, Throwable t) {
29 this(request, errorKind, t.getMessage());
30 }
31
32 public String getId() {
33 return id;
34 }
35
36 public void setId(String id) {
37 this.id = id;
38 }
39
40 public XtextWebErrorKind getErrorKind() {
41 return errorKind;
42 }
43
44 public void setErrorKind(XtextWebErrorKind errorKind) {
45 this.errorKind = errorKind;
46 }
47
48 public String getErrorMessage() {
49 return errorMessage;
50 }
51
52 public void setErrorMessage(String errorMessage) {
53 this.errorMessage = errorMessage;
54 }
55
56 @Override
57 public int hashCode() {
58 return Objects.hash(errorKind, errorMessage, id);
59 }
60
61 @Override
62 public boolean equals(Object obj) {
63 if (this == obj)
64 return true;
65 if (obj == null)
66 return false;
67 if (getClass() != obj.getClass())
68 return false;
69 XtextWebErrorResponse other = (XtextWebErrorResponse) obj;
70 return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage)
71 && Objects.equals(id, other.id);
72 }
73
74 @Override
75 public String toString() {
76 return "XtextWebSocketErrorResponse [id=" + id + ", errorKind=" + errorKind + ", errorMessage=" + errorMessage
77 + "]";
78 }
79}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
new file mode 100644
index 00000000..8af27247
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java
@@ -0,0 +1,72 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import org.eclipse.xtext.web.server.IServiceResult;
6import org.eclipse.xtext.web.server.IUnwrappableServiceResult;
7
8import com.google.gson.annotations.SerializedName;
9
10public final class XtextWebOkResponse implements XtextWebResponse {
11 private String id;
12
13 @SerializedName("response")
14 private Object responseData;
15
16 public XtextWebOkResponse(String id, Object responseData) {
17 super();
18 this.id = id;
19 this.responseData = responseData;
20 }
21
22 public XtextWebOkResponse(XtextWebRequest request, IServiceResult result) {
23 this(request.getId(), maybeUnwrap(result));
24 }
25
26 public String getId() {
27 return id;
28 }
29
30 public void setId(String id) {
31 this.id = id;
32 }
33
34 public Object getResponseData() {
35 return responseData;
36 }
37
38 public void setResponseData(Object responseData) {
39 this.responseData = responseData;
40 }
41
42 @Override
43 public int hashCode() {
44 return Objects.hash(id, responseData);
45 }
46
47 @Override
48 public boolean equals(Object obj) {
49 if (this == obj)
50 return true;
51 if (obj == null)
52 return false;
53 if (getClass() != obj.getClass())
54 return false;
55 XtextWebOkResponse other = (XtextWebOkResponse) obj;
56 return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData);
57 }
58
59 @Override
60 public String toString() {
61 return "XtextWebSocketOkResponse [id=" + id + ", responseData=" + responseData + "]";
62 }
63
64 private static Object maybeUnwrap(IServiceResult result) {
65 if (result instanceof IUnwrappableServiceResult unwrappableServiceResult
66 && unwrappableServiceResult.getContent() != null) {
67 return unwrappableServiceResult.getContent();
68 } else {
69 return result;
70 }
71 }
72}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
new file mode 100644
index 00000000..c9432e1c
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java
@@ -0,0 +1,81 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Objects;
4
5import com.google.gson.annotations.SerializedName;
6
7public final class XtextWebPushMessage implements XtextWebResponse {
8 @SerializedName("resource")
9 private String resourceId;
10
11 private String stateId;
12
13 private String service;
14
15 @SerializedName("push")
16 private Object pushData;
17
18 public XtextWebPushMessage(String resourceId, String stateId, String service, Object pushData) {
19 super();
20 this.resourceId = resourceId;
21 this.stateId = stateId;
22 this.service = service;
23 this.pushData = pushData;
24 }
25
26 public String getResourceId() {
27 return resourceId;
28 }
29
30 public void setResourceId(String resourceId) {
31 this.resourceId = resourceId;
32 }
33
34 public String getStateId() {
35 return stateId;
36 }
37
38 public void setStateId(String stateId) {
39 this.stateId = stateId;
40 }
41
42 public String getService() {
43 return service;
44 }
45
46 public void setService(String service) {
47 this.service = service;
48 }
49
50 public Object getPushData() {
51 return pushData;
52 }
53
54 public void setPushData(Object pushData) {
55 this.pushData = pushData;
56 }
57
58 @Override
59 public int hashCode() {
60 return Objects.hash(pushData, resourceId, service, stateId);
61 }
62
63 @Override
64 public boolean equals(Object obj) {
65 if (this == obj)
66 return true;
67 if (obj == null)
68 return false;
69 if (getClass() != obj.getClass())
70 return false;
71 XtextWebPushMessage other = (XtextWebPushMessage) obj;
72 return Objects.equals(pushData, other.pushData) && Objects.equals(resourceId, other.resourceId)
73 && Objects.equals(service, other.service) && Objects.equals(stateId, other.stateId);
74 }
75
76 @Override
77 public String toString() {
78 return "XtextWebPushMessage [resourceId=" + resourceId + ", stateId=" + stateId + ", service=" + service
79 + ", pushData=" + pushData + "]";
80 }
81}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
new file mode 100644
index 00000000..959749f8
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java
@@ -0,0 +1,57 @@
1package tools.refinery.language.web.xtext.server.message;
2
3import java.util.Map;
4import java.util.Objects;
5
6import com.google.gson.annotations.SerializedName;
7
8public class XtextWebRequest {
9 private String id;
10
11 @SerializedName("request")
12 private Map<String, String> requestData;
13
14 public XtextWebRequest(String id, Map<String, String> requestData) {
15 super();
16 this.id = id;
17 this.requestData = requestData;
18 }
19
20 public String getId() {
21 return id;
22 }
23
24 public void setId(String id) {
25 this.id = id;
26 }
27
28 public Map<String, String> getRequestData() {
29 return requestData;
30 }
31
32 public void setRequestData(Map<String, String> requestData) {
33 this.requestData = requestData;
34 }
35
36 @Override
37 public int hashCode() {
38 return Objects.hash(id, requestData);
39 }
40
41 @Override
42 public boolean equals(Object obj) {
43 if (this == obj)
44 return true;
45 if (obj == null)
46 return false;
47 if (getClass() != obj.getClass())
48 return false;
49 XtextWebRequest other = (XtextWebRequest) obj;
50 return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData);
51 }
52
53 @Override
54 public String toString() {
55 return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]";
56 }
57}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
new file mode 100644
index 00000000..3bd13047
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java
@@ -0,0 +1,4 @@
1package tools.refinery.language.web.xtext.server.message;
2
3public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage {
4}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
new file mode 100644
index 00000000..79a284db
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java
@@ -0,0 +1,15 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceResult;
4
5import tools.refinery.language.web.xtext.server.ResponseHandlerException;
6
7@FunctionalInterface
8public interface PrecomputationListener {
9 void onPrecomputedServiceResult(String resourceId, String stateId, String serviceName, IServiceResult serviceResult)
10 throws ResponseHandlerException;
11
12 default void onSubscribeToPrecomputationEvents(String resourceId, PushWebDocument document) {
13 // Nothing to handle by default.
14 }
15}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
new file mode 100644
index 00000000..c7b8108d
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java
@@ -0,0 +1,23 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.XtextServiceDispatcher;
5import org.eclipse.xtext.web.server.model.XtextWebDocument;
6
7import com.google.inject.Singleton;
8
9import tools.refinery.language.web.xtext.server.SubscribingServiceContext;
10
11@Singleton
12public class PushServiceDispatcher extends XtextServiceDispatcher {
13
14 @Override
15 protected XtextWebDocument getFullTextDocument(String fullText, String resourceId, IServiceContext context) {
16 var document = super.getFullTextDocument(fullText, resourceId, context);
17 if (document instanceof PushWebDocument pushWebDocument
18 && context instanceof SubscribingServiceContext subscribingContext) {
19 pushWebDocument.addPrecomputationListener(subscribingContext.subscriber());
20 }
21 return document;
22 }
23}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
new file mode 100644
index 00000000..906b9e30
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java
@@ -0,0 +1,89 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import java.util.ArrayList;
4import java.util.HashMap;
5import java.util.List;
6import java.util.Map;
7
8import org.eclipse.xtext.util.CancelIndicator;
9import org.eclipse.xtext.web.server.IServiceResult;
10import org.eclipse.xtext.web.server.model.AbstractCachedService;
11import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
12import org.eclipse.xtext.web.server.model.XtextWebDocument;
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16import com.google.common.collect.ImmutableList;
17
18import tools.refinery.language.web.xtext.server.ResponseHandlerException;
19
20public class PushWebDocument extends XtextWebDocument {
21 private static final Logger LOG = LoggerFactory.getLogger(PushWebDocument.class);
22
23 private final List<PrecomputationListener> precomputationListeners = new ArrayList<>();
24
25 private final Map<Class<?>, IServiceResult> precomputedServices = new HashMap<>();
26
27 public PushWebDocument(String resourceId, DocumentSynchronizer synchronizer) {
28 super(resourceId, synchronizer);
29 if (resourceId == null) {
30 throw new IllegalArgumentException("resourceId must not be null");
31 }
32 }
33
34 public boolean addPrecomputationListener(PrecomputationListener listener) {
35 synchronized (precomputationListeners) {
36 if (precomputationListeners.contains(listener)) {
37 return false;
38 }
39 precomputationListeners.add(listener);
40 listener.onSubscribeToPrecomputationEvents(getResourceId(), this);
41 return true;
42 }
43 }
44
45 public boolean removePrecomputationListener(PrecomputationListener listener) {
46 synchronized (precomputationListeners) {
47 return precomputationListeners.remove(listener);
48 }
49 }
50
51 public <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, String serviceName,
52 CancelIndicator cancelIndicator, boolean logCacheMiss) {
53 var result = getCachedServiceResult(service, cancelIndicator, logCacheMiss);
54 if (result == null) {
55 LOG.error("{} service returned null result", serviceName);
56 return;
57 }
58 var serviceClass = service.getClass();
59 var previousResult = precomputedServices.get(serviceClass);
60 if (previousResult != null && previousResult.equals(result)) {
61 return;
62 }
63 precomputedServices.put(serviceClass, result);
64 notifyPrecomputationListeners(serviceName, result);
65 }
66
67 private <T extends IServiceResult> void notifyPrecomputationListeners(String serviceName, T result) {
68 var resourceId = getResourceId();
69 var stateId = getStateId();
70 List<PrecomputationListener> copyOfListeners;
71 synchronized (precomputationListeners) {
72 copyOfListeners = ImmutableList.copyOf(precomputationListeners);
73 }
74 var toRemove = new ArrayList<PrecomputationListener>();
75 for (var listener : copyOfListeners) {
76 try {
77 listener.onPrecomputedServiceResult(resourceId, stateId, serviceName, result);
78 } catch (ResponseHandlerException e) {
79 LOG.error("Delivering precomputation push message failed", e);
80 toRemove.add(listener);
81 }
82 }
83 if (!toRemove.isEmpty()) {
84 synchronized (precomputationListeners) {
85 precomputationListeners.removeAll(toRemove);
86 }
87 }
88 }
89}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
new file mode 100644
index 00000000..b3666a86
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java
@@ -0,0 +1,68 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.service.OperationCanceledManager;
4import org.eclipse.xtext.util.CancelIndicator;
5import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork;
6import org.eclipse.xtext.web.server.IServiceResult;
7import org.eclipse.xtext.web.server.model.AbstractCachedService;
8import org.eclipse.xtext.web.server.model.IXtextWebDocument;
9import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry;
10import org.eclipse.xtext.web.server.model.XtextWebDocument;
11import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess;
12import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService;
13import org.eclipse.xtext.web.server.validation.ValidationService;
14
15import com.google.inject.Inject;
16
17public class PushWebDocumentAccess extends XtextWebDocumentAccess {
18
19 @Inject
20 private PrecomputedServiceRegistry preComputedServiceRegistry;
21
22 @Inject
23 private OperationCanceledManager operationCanceledManager;
24
25 private PushWebDocument pushDocument;
26
27 @Override
28 protected void init(XtextWebDocument document, String requiredStateId, boolean skipAsyncWork) {
29 super.init(document, requiredStateId, skipAsyncWork);
30 if (document instanceof PushWebDocument newPushDocument) {
31 pushDocument = newPushDocument;
32 }
33 }
34
35 @Override
36 protected void performPrecomputation(CancelIndicator cancelIndicator) {
37 if (pushDocument == null) {
38 super.performPrecomputation(cancelIndicator);
39 return;
40 }
41 for (AbstractCachedService<? extends IServiceResult> service : preComputedServiceRegistry
42 .getPrecomputedServices()) {
43 operationCanceledManager.checkCanceled(cancelIndicator);
44 precomputeServiceResult(service, false);
45 }
46 }
47
48 protected <T extends IServiceResult> void precomputeServiceResult(AbstractCachedService<T> service, boolean logCacheMiss) {
49 var serviceName = getPrecomputedServiceName(service);
50 readOnly(new CancelableUnitOfWork<Void, IXtextWebDocument>() {
51 @Override
52 public java.lang.Void exec(IXtextWebDocument d, CancelIndicator cancelIndicator) throws Exception {
53 pushDocument.precomputeServiceResult(service, serviceName, cancelIndicator, logCacheMiss);
54 return null;
55 }
56 });
57 }
58
59 protected String getPrecomputedServiceName(AbstractCachedService<? extends IServiceResult> service) {
60 if (service instanceof ValidationService) {
61 return "validate";
62 }
63 if (service instanceof HighlightingService) {
64 return "highlight";
65 }
66 throw new IllegalArgumentException("Unknown precomputed service: " + service);
67 }
68}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
new file mode 100644
index 00000000..fc45f74a
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java
@@ -0,0 +1,33 @@
1package tools.refinery.language.web.xtext.server.push;
2
3import org.eclipse.xtext.web.server.IServiceContext;
4import org.eclipse.xtext.web.server.model.DocumentSynchronizer;
5import org.eclipse.xtext.web.server.model.IWebDocumentProvider;
6import org.eclipse.xtext.web.server.model.XtextWebDocument;
7
8import com.google.inject.Inject;
9import com.google.inject.Provider;
10import com.google.inject.Singleton;
11
12/**
13 * Based on
14 * {@link org.eclipse.xtext.web.server.model.IWebDocumentProvider.DefaultImpl}.
15 *
16 * @author Kristóf Marussy
17 */
18@Singleton
19public class PushWebDocumentProvider implements IWebDocumentProvider {
20 @Inject
21 private Provider<DocumentSynchronizer> synchronizerProvider;
22
23 @Override
24 public XtextWebDocument get(String resourceId, IServiceContext serviceContext) {
25 if (resourceId == null) {
26 return new XtextWebDocument(resourceId, synchronizerProvider.get());
27 } else {
28 // We only need to send push messages if a resourceId is specified.
29 return new PushWebDocument(resourceId,
30 serviceContext.getSession().get(DocumentSynchronizer.class, () -> this.synchronizerProvider.get()));
31 }
32 }
33}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
new file mode 100644
index 00000000..43e37160
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java
@@ -0,0 +1,26 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.Map;
4import java.util.Set;
5
6import org.eclipse.xtext.web.server.IServiceContext;
7import org.eclipse.xtext.web.server.ISession;
8
9import com.google.common.collect.ImmutableSet;
10
11public record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext {
12 @Override
13 public Set<String> getParameterKeys() {
14 return ImmutableSet.copyOf(parameters.keySet());
15 }
16
17 @Override
18 public String getParameter(String key) {
19 return parameters.get(key);
20 }
21
22 @Override
23 public ISession getSession() {
24 return session;
25 }
26}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
new file mode 100644
index 00000000..09c055a2
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java
@@ -0,0 +1,35 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.util.HashMap;
4import java.util.Map;
5
6import org.eclipse.xtext.web.server.ISession;
7import org.eclipse.xtext.xbase.lib.Functions.Function0;
8
9public class SimpleSession implements ISession {
10 private Map<Object, Object> map = new HashMap<>();
11
12 @Override
13 public <T> T get(Object key) {
14 @SuppressWarnings("unchecked")
15 var value = (T) map.get(key);
16 return value;
17 }
18
19 @Override
20 public <T> T get(Object key, Function0<? extends T> factory) {
21 @SuppressWarnings("unchecked")
22 var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply());
23 return value;
24 }
25
26 @Override
27 public void put(Object key, Object value) {
28 map.put(key, value);
29 }
30
31 @Override
32 public void remove(Object key) {
33 map.remove(key);
34 }
35}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
new file mode 100644
index 00000000..0cd229e8
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java
@@ -0,0 +1,9 @@
1package tools.refinery.language.web.xtext.servlet;
2
3public final class XtextStatusCode {
4 public static final int INVALID_JSON = 4007;
5
6 private XtextStatusCode() {
7 throw new IllegalStateException("This is a static utility class and should not be instantiated directly");
8 }
9}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
new file mode 100644
index 00000000..fd41f213
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java
@@ -0,0 +1,133 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4import java.io.Reader;
5
6import org.eclipse.jetty.websocket.api.Session;
7import org.eclipse.jetty.websocket.api.StatusCode;
8import org.eclipse.jetty.websocket.api.WriteCallback;
9import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
10import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
11import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
12import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
13import org.eclipse.jetty.websocket.api.annotations.WebSocket;
14import org.eclipse.xtext.resource.IResourceServiceProvider;
15import org.eclipse.xtext.web.server.ISession;
16import org.slf4j.Logger;
17import org.slf4j.LoggerFactory;
18
19import com.google.gson.Gson;
20import com.google.gson.JsonIOException;
21import com.google.gson.JsonParseException;
22
23import tools.refinery.language.web.xtext.server.ResponseHandler;
24import tools.refinery.language.web.xtext.server.ResponseHandlerException;
25import tools.refinery.language.web.xtext.server.TransactionExecutor;
26import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
27import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
28
29@WebSocket
30public class XtextWebSocket implements WriteCallback, ResponseHandler {
31 private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class);
32
33 private final Gson gson = new Gson();
34
35 private final TransactionExecutor executor;
36
37 private Session webSocketSession;
38
39 public XtextWebSocket(TransactionExecutor executor) {
40 this.executor = executor;
41 executor.setResponseHandler(this);
42 }
43
44 public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) {
45 this(new TransactionExecutor(session, resourceServiceProviderRegistry));
46 }
47
48 @OnWebSocketConnect
49 public void onConnect(Session webSocketSession) {
50 if (this.webSocketSession != null) {
51 LOG.error("Websocket session onConnect when already connected");
52 return;
53 }
54 LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress());
55 this.webSocketSession = webSocketSession;
56 }
57
58 @OnWebSocketClose
59 public void onClose(int statusCode, String reason) {
60 executor.dispose();
61 if (webSocketSession == null) {
62 return;
63 }
64 if (statusCode == StatusCode.NORMAL || statusCode == StatusCode.SHUTDOWN) {
65 LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason);
66 } else {
67 LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode,
68 reason);
69 }
70 webSocketSession = null;
71 }
72
73 @OnWebSocketError
74 public void onError(Throwable error) {
75 if (webSocketSession == null) {
76 return;
77 }
78 LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error);
79 }
80
81 @OnWebSocketMessage
82 public void onMessage(Reader reader) {
83 if (webSocketSession == null) {
84 LOG.error("Trying to receive message when websocket is disconnected");
85 return;
86 }
87 XtextWebRequest request;
88 try {
89 request = gson.fromJson(reader, XtextWebRequest.class);
90 } catch (JsonIOException e) {
91 LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e);
92 if (webSocketSession.isOpen()) {
93 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload");
94 }
95 return;
96 } catch (JsonParseException e) {
97 LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e);
98 webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload");
99 return;
100 }
101 try {
102 executor.handleRequest(request);
103 } catch (ResponseHandlerException e) {
104 LOG.warn("Cannot write websocket response", e);
105 if (webSocketSession.isOpen()) {
106 webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write response");
107 }
108 }
109 }
110
111 @Override
112 public void onResponse(XtextWebResponse response) throws ResponseHandlerException {
113 if (webSocketSession == null) {
114 throw new ResponseHandlerException("Trying to send message when websocket is disconnected");
115 }
116 var responseString = gson.toJson(response);
117 try {
118 webSocketSession.getRemote().sendPartialString(responseString, true, this);
119 } catch (IOException e) {
120 throw new ResponseHandlerException(
121 "Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e);
122 }
123 }
124
125 @Override
126 public void writeFailed(Throwable x) {
127 if (webSocketSession == null) {
128 LOG.error("Cannot complete async write to disconnected websocket", x);
129 return;
130 }
131 LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x);
132 }
133}
diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
new file mode 100644
index 00000000..942ca380
--- /dev/null
+++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java
@@ -0,0 +1,83 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import java.io.IOException;
4import java.time.Duration;
5import java.util.Set;
6
7import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;
8import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;
9import org.eclipse.jetty.websocket.server.JettyWebSocketCreator;
10import org.eclipse.jetty.websocket.server.JettyWebSocketServlet;
11import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory;
12import org.eclipse.xtext.resource.IResourceServiceProvider;
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16import jakarta.servlet.ServletConfig;
17import jakarta.servlet.ServletException;
18
19public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator {
20
21 private static final long serialVersionUID = -3772740838165122685L;
22
23 public static final String ALLOWED_ORIGINS_SEPARATOR = ";";
24
25 public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin";
26
27 public static final String XTEXT_SUBPROTOCOL_V1 = "tools.refinery.language.web.xtext.v1";
28
29 /**
30 * Maximum message size should be large enough to upload a full model file.
31 */
32 private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L;
33
34 private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30);
35
36 private transient Logger log = LoggerFactory.getLogger(getClass());
37
38 private transient Set<String> allowedOrigins = null;
39
40 @Override
41 public void init(ServletConfig config) throws ServletException {
42 var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM);
43 if (allowedOriginsStr == null) {
44 log.warn("All WebSocket origins are allowed! This setting should not be used in production!");
45 } else {
46 allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR));
47 log.info("Allowed origins: {}", allowedOrigins);
48 }
49 super.init(config);
50 }
51
52 @Override
53 protected void configure(JettyWebSocketServletFactory factory) {
54 factory.setMaxFrameSize(MAX_FRAME_SIZE);
55 factory.setIdleTimeout(IDLE_TIMEOUT);
56 factory.addMapping("/", this);
57 }
58
59 @Override
60 public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) {
61 if (allowedOrigins != null) {
62 var origin = req.getOrigin();
63 if (origin == null || !allowedOrigins.contains(origin.toLowerCase())) {
64 log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin);
65 try {
66 resp.sendForbidden("Origin not allowed");
67 } catch (IOException e) {
68 log.error("Cannot send forbidden origin error", e);
69 }
70 return null;
71 }
72 }
73 if (req.getSubProtocols().contains(XTEXT_SUBPROTOCOL_V1)) {
74 resp.setAcceptedSubProtocol(XTEXT_SUBPROTOCOL_V1);
75 } else {
76 log.error("None of the subprotocols {} offered by {} are supported", req.getSubProtocols(),
77 req.getRemoteSocketAddress());
78 resp.setAcceptedSubProtocol(null);
79 }
80 var session = new SimpleSession();
81 return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE);
82 }
83}
diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java
new file mode 100644
index 00000000..a26ce040
--- /dev/null
+++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/ProblemWebSocketServletIntegrationTest.java
@@ -0,0 +1,204 @@
1package tools.refinery.language.web;
2
3import static org.hamcrest.MatcherAssert.assertThat;
4import static org.hamcrest.Matchers.equalTo;
5import static org.hamcrest.Matchers.hasSize;
6import static org.hamcrest.Matchers.instanceOf;
7import static org.hamcrest.Matchers.startsWith;
8import static org.junit.jupiter.api.Assertions.assertThrows;
9
10import java.io.IOException;
11import java.net.InetSocketAddress;
12import java.net.URI;
13import java.util.concurrent.CompletableFuture;
14import java.util.concurrent.CompletionException;
15
16import org.eclipse.jetty.http.HttpHeader;
17import org.eclipse.jetty.http.HttpStatus;
18import org.eclipse.jetty.server.Server;
19import org.eclipse.jetty.servlet.ServletContextHandler;
20import org.eclipse.jetty.servlet.ServletHolder;
21import org.eclipse.jetty.websocket.api.Session;
22import org.eclipse.jetty.websocket.api.StatusCode;
23import org.eclipse.jetty.websocket.api.annotations.WebSocket;
24import org.eclipse.jetty.websocket.api.exceptions.UpgradeException;
25import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
26import org.eclipse.jetty.websocket.client.WebSocketClient;
27import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
28import org.eclipse.xtext.testing.GlobalRegistries;
29import org.eclipse.xtext.testing.GlobalRegistries.GlobalStateMemento;
30import org.junit.jupiter.api.AfterEach;
31import org.junit.jupiter.api.BeforeEach;
32import org.junit.jupiter.api.Test;
33import org.junit.jupiter.params.ParameterizedTest;
34import org.junit.jupiter.params.provider.ValueSource;
35
36import tools.refinery.language.web.tests.WebSocketIntegrationTestClient;
37import tools.refinery.language.web.xtext.servlet.XtextStatusCode;
38import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet;
39
40class ProblemWebSocketServletIntegrationTest {
41 private static int SERVER_PORT = 28080;
42
43 private static String SERVLET_URI = "/xtext-service";
44
45 private GlobalStateMemento stateBeforeInjectorCreation;
46
47 private Server server;
48
49 private WebSocketClient client;
50
51 @BeforeEach
52 void beforeEach() throws Exception {
53 stateBeforeInjectorCreation = GlobalRegistries.makeCopyOfGlobalState();
54 client = new WebSocketClient();
55 client.start();
56 }
57
58 @AfterEach
59 void afterEach() throws Exception {
60 client.stop();
61 client = null;
62 if (server != null) {
63 server.stop();
64 server = null;
65 }
66 stateBeforeInjectorCreation.restoreGlobalState();
67 stateBeforeInjectorCreation = null;
68 }
69
70 @Test
71 void updateTest() {
72 startServer(null);
73 var clientSocket = new UpdateTestClient();
74 var session = connect(clientSocket, null, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
75 assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(),
76 equalTo(XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1));
77 clientSocket.waitForTestResult();
78 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
79 var responses = clientSocket.getResponses();
80 assertThat(responses, hasSize(5));
81 assertThat(responses.get(0), equalTo("{\"id\":\"foo\",\"response\":{\"stateId\":\"-80000000\"}}"));
82 assertThat(responses.get(1), startsWith(
83 "{\"resource\":\"test.problem\",\"stateId\":\"-80000000\",\"service\":\"highlight\",\"push\":{\"regions\":["));
84 assertThat(responses.get(2), equalTo(
85 "{\"resource\":\"test.problem\",\"stateId\":\"-80000000\",\"service\":\"validate\",\"push\":{\"issues\":[]}}"));
86 assertThat(responses.get(3), equalTo("{\"id\":\"bar\",\"response\":{\"stateId\":\"-7fffffff\"}}"));
87 assertThat(responses.get(4), startsWith(
88 "{\"resource\":\"test.problem\",\"stateId\":\"-7fffffff\",\"service\":\"highlight\",\"push\":{\"regions\":["));
89 }
90
91 @WebSocket
92 public static class UpdateTestClient extends WebSocketIntegrationTestClient {
93 @Override
94 protected void arrange(Session session, int responsesReceived) throws IOException {
95 switch (responsesReceived) {
96 case 0 -> session.getRemote().sendString(
97 "{\"id\":\"foo\",\"request\":{\"resource\":\"test.problem\",\"serviceType\":\"update\",\"fullText\":\"class Person.\n\"}}");
98 case 3 -> session.getRemote().sendString(
99 "{\"id\":\"bar\",\"request\":{\"resource\":\"test.problem\",\"serviceType\":\"update\",\"requiredStateId\":\"-80000000\",\"deltaText\":\"indiv q.\nnode(q).\n\",\"deltaOffset\":\"0\",\"deltaReplaceLength\":\"0\"}}");
100 case 5 -> session.close();
101 }
102 }
103 }
104
105 @Test
106 void badSubProtocolTest() {
107 startServer(null);
108 var clientSocket = new CloseImmediatelyTestClient();
109 var session = connect(clientSocket, null, "<invalid sub-protocol>");
110 assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(), equalTo(null));
111 clientSocket.waitForTestResult();
112 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
113 }
114
115 @WebSocket
116 public static class CloseImmediatelyTestClient extends WebSocketIntegrationTestClient {
117 @Override
118 protected void arrange(Session session, int responsesReceived) throws IOException {
119 session.close();
120 }
121 }
122
123 @Test
124 void subProtocolNegotiationTest() {
125 startServer(null);
126 var clientSocket = new CloseImmediatelyTestClient();
127 var session = connect(clientSocket, null, "<invalid sub-protocol>", XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
128 assertThat(session.getUpgradeResponse().getAcceptedSubProtocol(),
129 equalTo(XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1));
130 clientSocket.waitForTestResult();
131 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
132 }
133
134 @Test
135 void invalidJsonTest() {
136 startServer(null);
137 var clientSocket = new InvalidJsonTestClient();
138 connect(clientSocket, null, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
139 clientSocket.waitForTestResult();
140 assertThat(clientSocket.getCloseStatusCode(), equalTo(XtextStatusCode.INVALID_JSON));
141 }
142
143 @WebSocket
144 public static class InvalidJsonTestClient extends WebSocketIntegrationTestClient {
145 @Override
146 protected void arrange(Session session, int responsesReceived) throws IOException {
147 session.getRemote().sendString("<invalid json>");
148 }
149 }
150
151 @ParameterizedTest(name = "Origin: {0}")
152 @ValueSource(strings = { "https://refinery.example", "https://refinery.example:443", "HTTPS://REFINERY.EXAMPLE" })
153 void validOriginTest(String origin) {
154 startServer("https://refinery.example;https://refinery.example:443");
155 var clientSocket = new CloseImmediatelyTestClient();
156 connect(clientSocket, origin, XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1);
157 clientSocket.waitForTestResult();
158 assertThat(clientSocket.getCloseStatusCode(), equalTo(StatusCode.NORMAL));
159 }
160
161 @Test
162 void invalidOriginTest() {
163 startServer("https://refinery.example;https://refinery.example:443");
164 var clientSocket = new CloseImmediatelyTestClient();
165 var exception = assertThrows(CompletionException.class,
166 () -> connect(clientSocket, "https://invalid.example", XtextWebSocketServlet.XTEXT_SUBPROTOCOL_V1));
167 var innerException = exception.getCause();
168 assertThat(innerException, instanceOf(UpgradeException.class));
169 assertThat(((UpgradeException) innerException).getResponseStatusCode(), equalTo(HttpStatus.FORBIDDEN_403));
170 }
171
172 private void startServer(String allowedOrigins) {
173 server = new Server(new InetSocketAddress(SERVER_PORT));
174 var handler = new ServletContextHandler();
175 var holder = new ServletHolder(ProblemWebSocketServlet.class);
176 if (allowedOrigins != null) {
177 holder.setInitParameter(ProblemWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM, allowedOrigins);
178 }
179 handler.addServlet(holder, SERVLET_URI);
180 JettyWebSocketServletContainerInitializer.configure(handler, null);
181 server.setHandler(handler);
182 try {
183 server.start();
184 } catch (Exception e) {
185 throw new RuntimeException("Failed to start websocket server");
186 }
187 }
188
189 private Session connect(Object webSocketClient, String origin, String... subProtocols) {
190 var upgradeRequest = new ClientUpgradeRequest();
191 if (origin != null) {
192 upgradeRequest.setHeader(HttpHeader.ORIGIN.name(), origin);
193 }
194 upgradeRequest.setSubProtocols(subProtocols);
195 CompletableFuture<Session> sessionFuture;
196 try {
197 sessionFuture = client.connect(webSocketClient, URI.create("ws://localhost:" + SERVER_PORT + SERVLET_URI),
198 upgradeRequest);
199 } catch (IOException e) {
200 throw new AssertionError("Unexpected exception while connection to websocket", e);
201 }
202 return sessionFuture.join();
203 }
204}
diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java
new file mode 100644
index 00000000..b70d0ed5
--- /dev/null
+++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/AwaitTerminationExecutorServiceProvider.java
@@ -0,0 +1,42 @@
1package tools.refinery.language.web.tests;
2
3import java.util.ArrayList;
4import java.util.List;
5import java.util.concurrent.ExecutorService;
6
7import org.eclipse.xtext.ide.ExecutorServiceProvider;
8
9import com.google.inject.Singleton;
10
11@Singleton
12public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider {
13 private List<RestartableCachedThreadPool> servicesToShutDown = new ArrayList<>();
14
15 @Override
16 protected ExecutorService createInstance(String key) {
17 var instance = new RestartableCachedThreadPool();
18 synchronized (servicesToShutDown) {
19 servicesToShutDown.add(instance);
20 }
21 return instance;
22 }
23
24 public void waitForAllTasksToFinish() {
25 synchronized (servicesToShutDown) {
26 for (var executorService : servicesToShutDown) {
27 executorService.waitForAllTasksToFinish();
28 }
29 }
30 }
31
32 @Override
33 public void dispose() {
34 super.dispose();
35 synchronized (servicesToShutDown) {
36 for (var executorService : servicesToShutDown) {
37 executorService.waitForTermination();
38 }
39 servicesToShutDown.clear();
40 }
41 }
42}
diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java
new file mode 100644
index 00000000..43c12faa
--- /dev/null
+++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/ProblemWebInjectorProvider.java
@@ -0,0 +1,47 @@
1package tools.refinery.language.web.tests;
2
3import org.eclipse.xtext.ide.ExecutorServiceProvider;
4import org.eclipse.xtext.util.DisposableRegistry;
5import org.eclipse.xtext.util.Modules2;
6
7import com.google.inject.Guice;
8import com.google.inject.Injector;
9
10import tools.refinery.language.ide.ProblemIdeModule;
11import tools.refinery.language.tests.ProblemInjectorProvider;
12import tools.refinery.language.web.ProblemWebModule;
13import tools.refinery.language.web.ProblemWebSetup;
14
15public class ProblemWebInjectorProvider extends ProblemInjectorProvider {
16
17 protected Injector internalCreateInjector() {
18 return new ProblemWebSetup() {
19 @Override
20 public Injector createInjector() {
21 return Guice.createInjector(
22 Modules2.mixin(createRuntimeModule(), new ProblemIdeModule(), createWebModule()));
23 }
24 }.createInjectorAndDoEMFRegistration();
25 }
26
27 protected ProblemWebModule createWebModule() {
28 // Await termination of the executor service to avoid race conditions between
29 // the tasks in the service and the {@link
30 // org.eclipse.xtext.testing.extensions.InjectionExtension}.
31 return new ProblemWebModule() {
32 @SuppressWarnings("unused")
33 public Class<? extends ExecutorServiceProvider> bindExecutorServiceProvider() {
34 return AwaitTerminationExecutorServiceProvider.class;
35 }
36 };
37 }
38
39 @Override
40 public void restoreRegistry() {
41 // Also make sure to dispose any IDisposable instances (that may depend on the
42 // global state) created by Xtext before restoring the global state.
43 var disposableRegistry = getInjector().getInstance(DisposableRegistry.class);
44 disposableRegistry.dispose();
45 super.restoreRegistry();
46 }
47}
diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java
new file mode 100644
index 00000000..1468273d
--- /dev/null
+++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/RestartableCachedThreadPool.java
@@ -0,0 +1,109 @@
1package tools.refinery.language.web.tests;
2
3import java.util.Collection;
4import java.util.List;
5import java.util.concurrent.Callable;
6import java.util.concurrent.ExecutionException;
7import java.util.concurrent.ExecutorService;
8import java.util.concurrent.Executors;
9import java.util.concurrent.Future;
10import java.util.concurrent.TimeUnit;
11import java.util.concurrent.TimeoutException;
12
13import org.slf4j.Logger;
14import org.slf4j.LoggerFactory;
15
16public class RestartableCachedThreadPool implements ExecutorService {
17 private static final Logger LOG = LoggerFactory.getLogger(RestartableCachedThreadPool.class);
18
19 private ExecutorService delegate;
20
21 public RestartableCachedThreadPool() {
22 delegate = createExecutorService();
23 }
24
25 public void waitForAllTasksToFinish() {
26 delegate.shutdown();
27 waitForTermination();
28 delegate = createExecutorService();
29 }
30
31 public void waitForTermination() {
32 try {
33 delegate.awaitTermination(1, TimeUnit.SECONDS);
34 } catch (InterruptedException e) {
35 LOG.warn("Interrupted while waiting for delegate executor to stop", e);
36 }
37 }
38
39 protected ExecutorService createExecutorService() {
40 return Executors.newCachedThreadPool();
41 }
42
43 @Override
44 public boolean awaitTermination(long arg0, TimeUnit arg1) throws InterruptedException {
45 return delegate.awaitTermination(arg0, arg1);
46 }
47
48 @Override
49 public void execute(Runnable arg0) {
50 delegate.execute(arg0);
51 }
52
53 @Override
54 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> arg0, long arg1, TimeUnit arg2)
55 throws InterruptedException {
56 return delegate.invokeAll(arg0, arg1, arg2);
57 }
58
59 @Override
60 public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> arg0) throws InterruptedException {
61 return delegate.invokeAll(arg0);
62 }
63
64 @Override
65 public <T> T invokeAny(Collection<? extends Callable<T>> arg0, long arg1, TimeUnit arg2)
66 throws InterruptedException, ExecutionException, TimeoutException {
67 return delegate.invokeAny(arg0, arg1, arg2);
68 }
69
70 @Override
71 public <T> T invokeAny(Collection<? extends Callable<T>> arg0) throws InterruptedException, ExecutionException {
72 return delegate.invokeAny(arg0);
73 }
74
75 @Override
76 public boolean isShutdown() {
77 return delegate.isShutdown();
78 }
79
80 @Override
81 public boolean isTerminated() {
82 return delegate.isTerminated();
83 }
84
85 @Override
86 public void shutdown() {
87 delegate.shutdown();
88 }
89
90 @Override
91 public List<Runnable> shutdownNow() {
92 return delegate.shutdownNow();
93 }
94
95 @Override
96 public <T> Future<T> submit(Callable<T> arg0) {
97 return delegate.submit(arg0);
98 }
99
100 @Override
101 public <T> Future<T> submit(Runnable arg0, T arg1) {
102 return delegate.submit(arg0, arg1);
103 }
104
105 @Override
106 public Future<?> submit(Runnable arg0) {
107 return delegate.submit(arg0);
108 }
109}
diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java
new file mode 100644
index 00000000..49464d27
--- /dev/null
+++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/tests/WebSocketIntegrationTestClient.java
@@ -0,0 +1,98 @@
1package tools.refinery.language.web.tests;
2
3import static org.junit.jupiter.api.Assertions.fail;
4
5import java.io.IOException;
6import java.time.Duration;
7import java.util.ArrayList;
8import java.util.List;
9
10import org.eclipse.jetty.websocket.api.Session;
11import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
12import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
13import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
14import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
15
16public abstract class WebSocketIntegrationTestClient {
17 private static long TIMEOUT_MILLIS = Duration.ofSeconds(1).toMillis();
18
19 private boolean finished = false;
20
21 private Object lock = new Object();
22
23 private Throwable error;
24
25 private int closeStatusCode;
26
27 private List<String> responses = new ArrayList<>();
28
29 public int getCloseStatusCode() {
30 return closeStatusCode;
31 }
32
33 public List<String> getResponses() {
34 return responses;
35 }
36
37 @OnWebSocketConnect
38 public void onConnect(Session session) {
39 arrangeAndCatchErrors(session);
40 }
41
42 private void arrangeAndCatchErrors(Session session) {
43 try {
44 arrange(session, responses.size());
45 } catch (Exception e) {
46 finishedWithError(e);
47 }
48 }
49
50 protected abstract void arrange(Session session, int responsesReceived) throws IOException;
51
52 @OnWebSocketClose
53 public void onClose(int statusCode, String reason) {
54 closeStatusCode = statusCode;
55 testFinished();
56 }
57
58 @OnWebSocketError
59 public void onError(Throwable error) {
60 finishedWithError(error);
61 }
62
63 @OnWebSocketMessage
64 public void onMessage(Session session, String message) {
65 responses.add(message);
66 arrangeAndCatchErrors(session);
67 }
68
69 private void finishedWithError(Throwable t) {
70 error = t;
71 testFinished();
72 }
73
74 private void testFinished() {
75 synchronized (lock) {
76 finished = true;
77 lock.notify();
78 }
79 }
80
81 public void waitForTestResult() {
82 synchronized (lock) {
83 if (!finished) {
84 try {
85 lock.wait(TIMEOUT_MILLIS);
86 } catch (InterruptedException e) {
87 fail("Unexpected InterruptedException", e);
88 }
89 }
90 }
91 if (!finished) {
92 fail("Test still not finished after timeout");
93 }
94 if (error != null) {
95 fail("Unexpected exception in websocket thread", error);
96 }
97 }
98}
diff --git a/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java b/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java
new file mode 100644
index 00000000..5b8fedba
--- /dev/null
+++ b/subprojects/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java
@@ -0,0 +1,165 @@
1package tools.refinery.language.web.xtext.servlet;
2
3import static org.hamcrest.MatcherAssert.assertThat;
4import static org.hamcrest.Matchers.equalTo;
5import static org.hamcrest.Matchers.hasProperty;
6import static org.hamcrest.Matchers.instanceOf;
7import static org.mockito.Mockito.mock;
8import static org.mockito.Mockito.times;
9import static org.mockito.Mockito.verify;
10
11import java.util.List;
12import java.util.Map;
13
14import org.eclipse.emf.common.util.URI;
15import org.eclipse.xtext.resource.IResourceServiceProvider;
16import org.eclipse.xtext.testing.InjectWith;
17import org.eclipse.xtext.testing.extensions.InjectionExtension;
18import org.eclipse.xtext.web.server.model.DocumentStateResult;
19import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingResult;
20import org.eclipse.xtext.web.server.validation.ValidationResult;
21import org.junit.jupiter.api.BeforeEach;
22import org.junit.jupiter.api.Test;
23import org.junit.jupiter.api.extension.ExtendWith;
24import org.mockito.ArgumentCaptor;
25import org.mockito.junit.jupiter.MockitoExtension;
26
27import com.google.inject.Inject;
28
29import tools.refinery.language.web.tests.AwaitTerminationExecutorServiceProvider;
30import tools.refinery.language.web.tests.ProblemWebInjectorProvider;
31import tools.refinery.language.web.xtext.server.ResponseHandler;
32import tools.refinery.language.web.xtext.server.ResponseHandlerException;
33import tools.refinery.language.web.xtext.server.TransactionExecutor;
34import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse;
35import tools.refinery.language.web.xtext.server.message.XtextWebRequest;
36import tools.refinery.language.web.xtext.server.message.XtextWebResponse;
37
38@ExtendWith(MockitoExtension.class)
39@ExtendWith(InjectionExtension.class)
40@InjectWith(ProblemWebInjectorProvider.class)
41class TransactionExecutorTest {
42 private static final String RESOURCE_NAME = "test.problem";
43
44 private static final String PROBLEM_CONTENT_TYPE = "application/x-tools.refinery.problem";
45
46 private static final String TEST_PROBLEM = """
47 class Person {
48 Person[0..*] friend opposite friend
49 }
50
51 friend(a, b).
52 """;
53
54 private static final Map<String, String> UPDATE_FULL_TEXT_PARAMS = Map.of("resource", RESOURCE_NAME, "serviceType",
55 "update", "fullText", TEST_PROBLEM);
56
57 @Inject
58 private IResourceServiceProvider.Registry resourceServiceProviderRegistry;
59
60 @Inject
61 private AwaitTerminationExecutorServiceProvider executorServices;
62
63 private TransactionExecutor transactionExecutor;
64
65 @BeforeEach
66 void beforeEach() {
67 transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry);
68 }
69
70 @Test
71 void updateFullTextTest() throws ResponseHandlerException {
72 var captor = newCaptor();
73 var stateId = updateFullText(captor);
74 assertThatPrecomputedMessagesAreReceived(stateId, captor.getAllValues());
75 }
76
77 @Test
78 void updateDeltaTextHighlightAndValidationChange() throws ResponseHandlerException {
79 var stateId = updateFullText();
80 var responseHandler = sendRequestAndWaitForAllResponses(
81 new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId",
82 stateId, "deltaText", "individual q.\nnode(q).\n<invalid text>\n", "deltaOffset", "0", "deltaReplaceLength", "0")));
83
84 var captor = newCaptor();
85 verify(responseHandler, times(3)).onResponse(captor.capture());
86 var newStateId = getStateId("bar", captor.getAllValues().get(0));
87 assertThatPrecomputedMessagesAreReceived(newStateId, captor.getAllValues());
88 }
89
90 @Test
91 void updateDeltaTextHighlightChangeOnly() throws ResponseHandlerException {
92 var stateId = updateFullText();
93 var responseHandler = sendRequestAndWaitForAllResponses(
94 new XtextWebRequest("bar", Map.of("resource", RESOURCE_NAME, "serviceType", "update", "requiredStateId",
95 stateId, "deltaText", "indiv q.\nnode(q).\n", "deltaOffset", "0", "deltaReplaceLength", "0")));
96
97 var captor = newCaptor();
98 verify(responseHandler, times(2)).onResponse(captor.capture());
99 var newStateId = getStateId("bar", captor.getAllValues().get(0));
100 assertHighlightingResponse(newStateId, captor.getAllValues().get(1));
101 }
102
103 @Test
104 void fullTextWithoutResourceTest() throws ResponseHandlerException {
105 var resourceServiceProvider = resourceServiceProviderRegistry
106 .getResourceServiceProvider(URI.createFileURI(RESOURCE_NAME));
107 resourceServiceProviderRegistry.getContentTypeToFactoryMap().put(PROBLEM_CONTENT_TYPE, resourceServiceProvider);
108 var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo",
109 Map.of("contentType", PROBLEM_CONTENT_TYPE, "fullText", TEST_PROBLEM, "serviceType", "validate")));
110
111 var captor = newCaptor();
112 verify(responseHandler).onResponse(captor.capture());
113 var response = captor.getValue();
114 assertThat(response, hasProperty("id", equalTo("foo")));
115 assertThat(response, hasProperty("responseData", instanceOf(ValidationResult.class)));
116 }
117
118 private ArgumentCaptor<XtextWebResponse> newCaptor() {
119 return ArgumentCaptor.forClass(XtextWebResponse.class);
120 }
121
122 private String updateFullText() throws ResponseHandlerException {
123 return updateFullText(newCaptor());
124 }
125
126 private String updateFullText(ArgumentCaptor<XtextWebResponse> captor) throws ResponseHandlerException {
127 var responseHandler = sendRequestAndWaitForAllResponses(new XtextWebRequest("foo", UPDATE_FULL_TEXT_PARAMS));
128
129 verify(responseHandler, times(3)).onResponse(captor.capture());
130 return getStateId("foo", captor.getAllValues().get(0));
131 }
132
133 private ResponseHandler sendRequestAndWaitForAllResponses(XtextWebRequest request) throws ResponseHandlerException {
134 var responseHandler = mock(ResponseHandler.class);
135 transactionExecutor.setResponseHandler(responseHandler);
136 transactionExecutor.handleRequest(request);
137 executorServices.waitForAllTasksToFinish();
138 return responseHandler;
139 }
140
141 private String getStateId(String requestId, XtextWebResponse okResponse) {
142 assertThat(okResponse, hasProperty("id", equalTo(requestId)));
143 assertThat(okResponse, hasProperty("responseData", instanceOf(DocumentStateResult.class)));
144 return ((DocumentStateResult) ((XtextWebOkResponse) okResponse).getResponseData()).getStateId();
145 }
146
147 private void assertThatPrecomputedMessagesAreReceived(String stateId, List<XtextWebResponse> responses) {
148 assertHighlightingResponse(stateId, responses.get(1));
149 assertValidationResponse(stateId, responses.get(2));
150 }
151
152 private void assertHighlightingResponse(String stateId, XtextWebResponse highlightingResponse) {
153 assertThat(highlightingResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME)));
154 assertThat(highlightingResponse, hasProperty("stateId", equalTo(stateId)));
155 assertThat(highlightingResponse, hasProperty("service", equalTo("highlight")));
156 assertThat(highlightingResponse, hasProperty("pushData", instanceOf(HighlightingResult.class)));
157 }
158
159 private void assertValidationResponse(String stateId, XtextWebResponse validationResponse) {
160 assertThat(validationResponse, hasProperty("resourceId", equalTo(RESOURCE_NAME)));
161 assertThat(validationResponse, hasProperty("stateId", equalTo(stateId)));
162 assertThat(validationResponse, hasProperty("service", equalTo("validate")));
163 assertThat(validationResponse, hasProperty("pushData", instanceOf(ValidationResult.class)));
164 }
165}