diff options
author | Kristóf Marussy <marussy@mit.bme.hu> | 2021-10-31 19:41:24 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-10-31 19:41:24 +0100 |
commit | 7918d78948de7f349f9948837caf76f2a514c96c (patch) | |
tree | 0f7c24a9e0046dffd719d6a66be4a1f73b7fa4c7 /language-web/src/main/java | |
parent | Merge pull request #7 from golej-marci/language-to-store (diff) | |
parent | chore: bump dependency versions (diff) | |
download | refinery-7918d78948de7f349f9948837caf76f2a514c96c.tar.gz refinery-7918d78948de7f349f9948837caf76f2a514c96c.tar.zst refinery-7918d78948de7f349f9948837caf76f2a514c96c.zip |
Merge pull request #8 from kris7t/cm6
Switch to CodeMirror 6 editor and WebSocket-based transport for Xtext
Diffstat (limited to 'language-web/src/main/java')
29 files changed, 1208 insertions, 385 deletions
diff --git a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java b/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java index cf4c00fa..b13ae95d 100644 --- a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java +++ b/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java | |||
@@ -1,8 +1,11 @@ | |||
1 | package tools.refinery.language.web; | 1 | package tools.refinery.language.web; |
2 | 2 | ||
3 | import java.io.IOException; | 3 | import java.io.IOException; |
4 | import java.time.Duration; | ||
4 | import java.util.regex.Pattern; | 5 | import java.util.regex.Pattern; |
5 | 6 | ||
7 | import org.eclipse.jetty.http.HttpHeader; | ||
8 | |||
6 | import jakarta.servlet.Filter; | 9 | import jakarta.servlet.Filter; |
7 | import jakarta.servlet.FilterChain; | 10 | import jakarta.servlet.FilterChain; |
8 | import jakarta.servlet.FilterConfig; | 11 | import jakarta.servlet.FilterConfig; |
@@ -13,16 +16,11 @@ import jakarta.servlet.http.HttpServletRequest; | |||
13 | import jakarta.servlet.http.HttpServletResponse; | 16 | import jakarta.servlet.http.HttpServletResponse; |
14 | 17 | ||
15 | public class CacheControlFilter implements Filter { | 18 | public class CacheControlFilter implements Filter { |
16 | |||
17 | private static final String CACHE_CONTROL_HEADER = "Cache-Control"; | ||
18 | |||
19 | private static final String EXPIRES_HEADER = "Expires"; | ||
20 | |||
21 | private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)"); | 19 | private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)"); |
22 | 20 | ||
23 | private static final long EXPIRY = 31536000; | 21 | private static final Duration EXPIRY = Duration.ofDays(365); |
24 | 22 | ||
25 | private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY + ", immutable"; | 23 | private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY.toSeconds() + ", immutable"; |
26 | 24 | ||
27 | private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate"; | 25 | private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate"; |
28 | 26 | ||
@@ -36,11 +34,12 @@ public class CacheControlFilter implements Filter { | |||
36 | throws IOException, ServletException { | 34 | throws IOException, ServletException { |
37 | if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) { | 35 | if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) { |
38 | if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) { | 36 | if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) { |
39 | httpResponse.setHeader(CACHE_CONTROL_HEADER, CACHE_CONTROL_CACHE_VALUE); | 37 | httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_CACHE_VALUE); |
40 | httpResponse.setDateHeader(EXPIRES_HEADER, System.currentTimeMillis() + EXPIRY * 1000L); | 38 | httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), |
39 | System.currentTimeMillis() + EXPIRY.toMillis()); | ||
41 | } else { | 40 | } else { |
42 | httpResponse.setHeader(CACHE_CONTROL_HEADER, CACHE_CONTROL_NO_CACHE_VALUE); | 41 | httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_NO_CACHE_VALUE); |
43 | httpResponse.setDateHeader(EXPIRES_HEADER, 0); | 42 | httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), 0); |
44 | } | 43 | } |
45 | } | 44 | } |
46 | chain.doFilter(request, response); | 45 | chain.doFilter(request, response); |
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java index 799a9c64..ec55036f 100644 --- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java +++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebModule.java | |||
@@ -3,9 +3,33 @@ | |||
3 | */ | 3 | */ |
4 | package tools.refinery.language.web; | 4 | package tools.refinery.language.web; |
5 | 5 | ||
6 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
7 | import org.eclipse.xtext.web.server.model.IWebDocumentProvider; | ||
8 | import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; | ||
9 | import org.eclipse.xtext.web.server.occurrences.OccurrencesService; | ||
10 | |||
11 | import tools.refinery.language.web.occurrences.ProblemOccurrencesService; | ||
12 | import tools.refinery.language.web.xtext.server.push.PushServiceDispatcher; | ||
13 | import tools.refinery.language.web.xtext.server.push.PushWebDocumentAccess; | ||
14 | import tools.refinery.language.web.xtext.server.push.PushWebDocumentProvider; | ||
6 | 15 | ||
7 | /** | 16 | /** |
8 | * Use this class to register additional components to be used within the web application. | 17 | * Use this class to register additional components to be used within the web application. |
9 | */ | 18 | */ |
10 | public class ProblemWebModule extends AbstractProblemWebModule { | 19 | public 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 | } | ||
11 | } | 35 | } |
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java index 49457002..df67b521 100644 --- a/language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java +++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java | |||
@@ -1,19 +1,13 @@ | |||
1 | /* | ||
2 | * generated by Xtext 2.25.0 | ||
3 | */ | ||
4 | package tools.refinery.language.web; | 1 | package tools.refinery.language.web; |
5 | 2 | ||
6 | import org.eclipse.xtext.util.DisposableRegistry; | 3 | import org.eclipse.xtext.util.DisposableRegistry; |
7 | 4 | ||
8 | import jakarta.servlet.ServletException; | 5 | import jakarta.servlet.ServletException; |
9 | import tools.refinery.language.web.xtext.XtextServlet; | 6 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; |
10 | 7 | ||
11 | /** | 8 | public class ProblemWebSocketServlet extends XtextWebSocketServlet { |
12 | * Deploy this class into a servlet container to enable DSL-specific services. | ||
13 | */ | ||
14 | public class ProblemServlet extends XtextServlet { | ||
15 | 9 | ||
16 | private static final long serialVersionUID = -9204695886561362912L; | 10 | private static final long serialVersionUID = -7040955470384797008L; |
17 | 11 | ||
18 | private transient DisposableRegistry disposableRegistry; | 12 | private transient DisposableRegistry disposableRegistry; |
19 | 13 | ||
@@ -32,5 +26,4 @@ public class ProblemServlet extends XtextServlet { | |||
32 | } | 26 | } |
33 | super.destroy(); | 27 | super.destroy(); |
34 | } | 28 | } |
35 | |||
36 | } | 29 | } |
diff --git a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java index c253422b..ffd903d0 100644 --- a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java +++ b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java | |||
@@ -9,6 +9,7 @@ import java.net.InetSocketAddress; | |||
9 | import java.net.URI; | 9 | import java.net.URI; |
10 | import java.net.URISyntaxException; | 10 | import java.net.URISyntaxException; |
11 | import java.util.EnumSet; | 11 | import java.util.EnumSet; |
12 | import java.util.Optional; | ||
12 | import java.util.Set; | 13 | import java.util.Set; |
13 | 14 | ||
14 | import org.eclipse.jetty.server.Server; | 15 | import org.eclipse.jetty.server.Server; |
@@ -17,29 +18,36 @@ import org.eclipse.jetty.servlet.DefaultServlet; | |||
17 | import org.eclipse.jetty.servlet.ServletContextHandler; | 18 | import org.eclipse.jetty.servlet.ServletContextHandler; |
18 | import org.eclipse.jetty.servlet.ServletHolder; | 19 | import org.eclipse.jetty.servlet.ServletHolder; |
19 | import org.eclipse.jetty.util.resource.Resource; | 20 | import org.eclipse.jetty.util.resource.Resource; |
21 | import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; | ||
20 | import org.slf4j.Logger; | 22 | import org.slf4j.Logger; |
21 | import org.slf4j.LoggerFactory; | 23 | import org.slf4j.LoggerFactory; |
22 | 24 | ||
23 | import jakarta.servlet.DispatcherType; | 25 | import jakarta.servlet.DispatcherType; |
24 | import jakarta.servlet.SessionTrackingMode; | 26 | import jakarta.servlet.SessionTrackingMode; |
27 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; | ||
25 | 28 | ||
26 | public class ServerLauncher { | 29 | public class ServerLauncher { |
27 | public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; | 30 | public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; |
28 | 31 | ||
29 | public static final int DEFAULT_LISTEN_PORT = 1312; | 32 | public static final int DEFAULT_LISTEN_PORT = 1312; |
30 | 33 | ||
31 | // Use this cookie name for load balancing. | 34 | public static final int DEFAULT_PUBLIC_PORT = 443; |
32 | public static final String SESSION_COOKIE_NAME = "JSESSIONID"; | 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 = ";"; | ||
33 | 41 | ||
34 | private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class); | 42 | private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class); |
35 | 43 | ||
36 | private final Server server; | 44 | private final Server server; |
37 | 45 | ||
38 | public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource) { | 46 | public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource, Optional<String[]> allowedOrigins) { |
39 | server = new Server(bindAddress); | 47 | server = new Server(bindAddress); |
40 | var handler = new ServletContextHandler(); | 48 | var handler = new ServletContextHandler(); |
41 | addSessionHandler(handler); | 49 | addSessionHandler(handler); |
42 | addProblemServlet(handler); | 50 | addProblemServlet(handler, allowedOrigins); |
43 | if (baseResource != null) { | 51 | if (baseResource != null) { |
44 | handler.setBaseResource(baseResource); | 52 | handler.setBaseResource(baseResource); |
45 | handler.setWelcomeFiles(new String[] { "index.html" }); | 53 | handler.setWelcomeFiles(new String[] { "index.html" }); |
@@ -52,12 +60,21 @@ public class ServerLauncher { | |||
52 | private void addSessionHandler(ServletContextHandler handler) { | 60 | private void addSessionHandler(ServletContextHandler handler) { |
53 | var sessionHandler = new SessionHandler(); | 61 | var sessionHandler = new SessionHandler(); |
54 | sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE)); | 62 | sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE)); |
55 | sessionHandler.setSessionCookie(SESSION_COOKIE_NAME); | ||
56 | handler.setSessionHandler(sessionHandler); | 63 | handler.setSessionHandler(sessionHandler); |
57 | } | 64 | } |
58 | 65 | ||
59 | private void addProblemServlet(ServletContextHandler handler) { | 66 | private void addProblemServlet(ServletContextHandler handler, Optional<String[]> allowedOrigins) { |
60 | handler.addServlet(ProblemServlet.class, "/xtext-service/*"); | 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); | ||
61 | } | 78 | } |
62 | 79 | ||
63 | private void addDefaultServlet(ServletContextHandler handler) { | 80 | private void addDefaultServlet(ServletContextHandler handler) { |
@@ -80,7 +97,8 @@ public class ServerLauncher { | |||
80 | try { | 97 | try { |
81 | var bindAddress = getBindAddress(); | 98 | var bindAddress = getBindAddress(); |
82 | var baseResource = getBaseResource(); | 99 | var baseResource = getBaseResource(); |
83 | var serverLauncher = new ServerLauncher(bindAddress, baseResource); | 100 | var allowedOrigins = getAllowedOrigins(); |
101 | var serverLauncher = new ServerLauncher(bindAddress, baseResource, allowedOrigins); | ||
84 | serverLauncher.start(); | 102 | serverLauncher.start(); |
85 | } catch (Exception exception) { | 103 | } catch (Exception exception) { |
86 | LOG.error("Fatal server error", exception); | 104 | LOG.error("Fatal server error", exception); |
@@ -132,4 +150,43 @@ public class ServerLauncher { | |||
132 | // Fall back to just serving a 404. | 150 | // Fall back to just serving a 404. |
133 | return null; | 151 | return null; |
134 | } | 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 | } | ||
135 | } | 192 | } |
diff --git a/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java b/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java new file mode 100644 index 00000000..d32bbb54 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/occurrences/ProblemOccurrencesService.java | |||
@@ -0,0 +1,16 @@ | |||
1 | package tools.refinery.language.web.occurrences; | ||
2 | |||
3 | import org.eclipse.emf.ecore.EObject; | ||
4 | import org.eclipse.xtext.web.server.occurrences.OccurrencesService; | ||
5 | |||
6 | import com.google.inject.Singleton; | ||
7 | |||
8 | import tools.refinery.language.model.problem.NamedElement; | ||
9 | |||
10 | @Singleton | ||
11 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java deleted file mode 100644 index d0ba6a2d..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java +++ /dev/null | |||
@@ -1,107 +0,0 @@ | |||
1 | /** | ||
2 | * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others. | ||
3 | * This program and the accompanying materials are made available under the | ||
4 | * terms of the Eclipse Public License 2.0 which is available at | ||
5 | * http://www.eclipse.org/legal/epl-2.0. | ||
6 | * | ||
7 | * SPDX-License-Identifier: EPL-2.0 | ||
8 | */ | ||
9 | package tools.refinery.language.web.xtext; | ||
10 | |||
11 | import java.io.IOException; | ||
12 | import java.net.URLDecoder; | ||
13 | import java.nio.charset.Charset; | ||
14 | import java.util.Collections; | ||
15 | import java.util.Enumeration; | ||
16 | import java.util.HashMap; | ||
17 | import java.util.Map; | ||
18 | import java.util.Set; | ||
19 | |||
20 | import org.eclipse.xtext.web.server.IServiceContext; | ||
21 | import org.eclipse.xtext.web.server.ISession; | ||
22 | |||
23 | import com.google.common.io.CharStreams; | ||
24 | |||
25 | import jakarta.servlet.http.HttpServletRequest; | ||
26 | |||
27 | /** | ||
28 | * Provides the parameters and metadata of an {@link HttpServletRequest}. | ||
29 | */ | ||
30 | class HttpServiceContext implements IServiceContext { | ||
31 | private final HttpServletRequest request; | ||
32 | |||
33 | private final Map<String, String> parameters = new HashMap<>(); | ||
34 | |||
35 | private HttpSessionWrapper sessionWrapper; | ||
36 | |||
37 | public HttpServiceContext(HttpServletRequest request) throws IOException { | ||
38 | this.request = request; | ||
39 | initializeParameters(); | ||
40 | } | ||
41 | |||
42 | private void initializeParameters() throws IOException { | ||
43 | initializeUrlEncodedParameters(); | ||
44 | initializeRequestParameters(); | ||
45 | if (!parameters.containsKey(IServiceContext.SERVICE_TYPE)) { | ||
46 | String substring = null; | ||
47 | if (request.getPathInfo() != null) { | ||
48 | substring = request.getPathInfo().substring(1); | ||
49 | } | ||
50 | parameters.put(IServiceContext.SERVICE_TYPE, substring); | ||
51 | } | ||
52 | } | ||
53 | |||
54 | private void initializeUrlEncodedParameters() throws IOException { | ||
55 | String[] contentType = null; | ||
56 | if (request.getContentType() != null) { | ||
57 | contentType = request.getContentType().split(";(\\s*)"); | ||
58 | } | ||
59 | if (contentType != null && "application/x-www-form-urlencoded".equals(contentType[0])) { | ||
60 | String charset = null; | ||
61 | if (contentType.length >= 2 && contentType[1].startsWith("charset=")) { | ||
62 | charset = (contentType[1]).substring("charset=".length()); | ||
63 | } else { | ||
64 | charset = Charset.defaultCharset().toString(); | ||
65 | } | ||
66 | String[] encodedParams = CharStreams.toString(request.getReader()).split("&"); | ||
67 | for (String param : encodedParams) { | ||
68 | int nameEnd = param.indexOf("="); | ||
69 | if (nameEnd > 0) { | ||
70 | String key = param.substring(0, nameEnd); | ||
71 | String value = URLDecoder.decode(param.substring(nameEnd + 1), charset); | ||
72 | parameters.put(key, value); | ||
73 | } | ||
74 | } | ||
75 | } | ||
76 | } | ||
77 | |||
78 | private void initializeRequestParameters() { | ||
79 | Enumeration<String> paramNames = request.getParameterNames(); | ||
80 | while (paramNames.hasMoreElements()) { | ||
81 | String name = paramNames.nextElement(); | ||
82 | parameters.put(name, request.getParameter(name)); | ||
83 | } | ||
84 | } | ||
85 | |||
86 | @Override | ||
87 | public Set<String> getParameterKeys() { | ||
88 | return Collections.unmodifiableSet(parameters.keySet()); | ||
89 | } | ||
90 | |||
91 | @Override | ||
92 | public String getParameter(String key) { | ||
93 | return parameters.get(key); | ||
94 | } | ||
95 | |||
96 | @Override | ||
97 | public ISession getSession() { | ||
98 | if (sessionWrapper == null) { | ||
99 | sessionWrapper = new HttpSessionWrapper(request.getSession(true)); | ||
100 | } | ||
101 | return sessionWrapper; | ||
102 | } | ||
103 | |||
104 | public HttpServletRequest getRequest() { | ||
105 | return request; | ||
106 | } | ||
107 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java b/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java deleted file mode 100644 index 8a5e19ba..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java +++ /dev/null | |||
@@ -1,53 +0,0 @@ | |||
1 | /** | ||
2 | * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others. | ||
3 | * This program and the accompanying materials are made available under the | ||
4 | * terms of the Eclipse Public License 2.0 which is available at | ||
5 | * http://www.eclipse.org/legal/epl-2.0. | ||
6 | * | ||
7 | * SPDX-License-Identifier: EPL-2.0 | ||
8 | */ | ||
9 | package tools.refinery.language.web.xtext; | ||
10 | |||
11 | import org.eclipse.xtext.web.server.ISession; | ||
12 | import org.eclipse.xtext.xbase.lib.Functions.Function0; | ||
13 | |||
14 | import jakarta.servlet.http.HttpSession; | ||
15 | |||
16 | /** | ||
17 | * Provides access to the information stored in a {@link HttpSession}. | ||
18 | */ | ||
19 | record HttpSessionWrapper(HttpSession session) implements ISession { | ||
20 | @SuppressWarnings("unchecked") | ||
21 | @Override | ||
22 | public <T> T get(Object key) { | ||
23 | return (T) session.getAttribute(key.toString()); | ||
24 | } | ||
25 | |||
26 | @Override | ||
27 | public <T> T get(Object key, Function0<? extends T> factory) { | ||
28 | synchronized (session) { | ||
29 | T sessionValue = get(key); | ||
30 | if (sessionValue != null) { | ||
31 | return sessionValue; | ||
32 | } else { | ||
33 | T factoryValue = factory.apply(); | ||
34 | put(key, factoryValue); | ||
35 | return factoryValue; | ||
36 | } | ||
37 | } | ||
38 | } | ||
39 | |||
40 | @Override | ||
41 | public void put(Object key, Object value) { | ||
42 | session.setAttribute(key.toString(), value); | ||
43 | } | ||
44 | |||
45 | @Override | ||
46 | public void remove(Object key) { | ||
47 | session.removeAttribute(key.toString()); | ||
48 | } | ||
49 | |||
50 | public HttpSession getSession() { | ||
51 | return session; | ||
52 | } | ||
53 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java deleted file mode 100644 index f39bec12..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java +++ /dev/null | |||
@@ -1,196 +0,0 @@ | |||
1 | /** | ||
2 | * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others. | ||
3 | * This program and the accompanying materials are made available under the | ||
4 | * terms of the Eclipse Public License 2.0 which is available at | ||
5 | * http://www.eclipse.org/legal/epl-2.0. | ||
6 | * | ||
7 | * SPDX-License-Identifier: EPL-2.0 | ||
8 | */ | ||
9 | package tools.refinery.language.web.xtext; | ||
10 | |||
11 | import java.io.IOException; | ||
12 | import java.util.Set; | ||
13 | |||
14 | import org.eclipse.emf.common.util.URI; | ||
15 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
16 | import org.eclipse.xtext.web.server.IServiceContext; | ||
17 | import org.eclipse.xtext.web.server.IServiceResult; | ||
18 | import org.eclipse.xtext.web.server.IUnwrappableServiceResult; | ||
19 | import org.eclipse.xtext.web.server.InvalidRequestException; | ||
20 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
21 | import org.slf4j.Logger; | ||
22 | import org.slf4j.LoggerFactory; | ||
23 | |||
24 | import com.google.common.base.Objects; | ||
25 | import com.google.common.base.Strings; | ||
26 | import com.google.gson.Gson; | ||
27 | import com.google.inject.Injector; | ||
28 | |||
29 | import jakarta.servlet.ServletException; | ||
30 | import jakarta.servlet.http.HttpServlet; | ||
31 | import jakarta.servlet.http.HttpServletRequest; | ||
32 | import jakarta.servlet.http.HttpServletResponse; | ||
33 | |||
34 | /** | ||
35 | * An HTTP servlet for publishing the Xtext services. Include this into your web | ||
36 | * server by creating a subclass that executes the standalone setups of your | ||
37 | * languages in its {@link #init()} method: | ||
38 | * | ||
39 | * <pre> | ||
40 | * @WebServlet(name = "Xtext Services", urlPatterns = "/xtext-service/*") | ||
41 | * class MyXtextServlet extends XtextServlet { | ||
42 | * override init() { | ||
43 | * super.init(); | ||
44 | * MyDslWebSetup.doSetup(); | ||
45 | * } | ||
46 | * } | ||
47 | * </pre> | ||
48 | * | ||
49 | * Use the {@code WebServlet} annotation to register your servlet. The default | ||
50 | * URL pattern for Xtext services is {@code "/xtext-service/*"}. | ||
51 | */ | ||
52 | public class XtextServlet extends HttpServlet { | ||
53 | |||
54 | private static final long serialVersionUID = 7784324070547781918L; | ||
55 | |||
56 | private static final IResourceServiceProvider.Registry SERVICE_PROVIDER_REGISTRY = IResourceServiceProvider.Registry.INSTANCE; | ||
57 | |||
58 | private static final String ENCODING = "UTF-8"; | ||
59 | |||
60 | private static final String INVALID_REQUEST_MESSAGE = "Invalid request ({}): {}"; | ||
61 | |||
62 | private final transient Logger log = LoggerFactory.getLogger(this.getClass()); | ||
63 | |||
64 | private final transient Gson gson = new Gson(); | ||
65 | |||
66 | @Override | ||
67 | protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | ||
68 | try { | ||
69 | super.service(req, resp); | ||
70 | } catch (InvalidRequestException.ResourceNotFoundException exception) { | ||
71 | log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); | ||
72 | resp.sendError(HttpServletResponse.SC_NOT_FOUND, exception.getMessage()); | ||
73 | } catch (InvalidRequestException.InvalidDocumentStateException exception) { | ||
74 | log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); | ||
75 | resp.sendError(HttpServletResponse.SC_CONFLICT, exception.getMessage()); | ||
76 | } catch (InvalidRequestException.PermissionDeniedException exception) { | ||
77 | log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); | ||
78 | resp.sendError(HttpServletResponse.SC_FORBIDDEN, exception.getMessage()); | ||
79 | } catch (InvalidRequestException exception) { | ||
80 | log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); | ||
81 | resp.sendError(HttpServletResponse.SC_BAD_REQUEST, exception.getMessage()); | ||
82 | } | ||
83 | } | ||
84 | |||
85 | @Override | ||
86 | protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | ||
87 | XtextServiceDispatcher.ServiceDescriptor service = getService(req); | ||
88 | if (!service.isHasConflict() && (service.isHasSideEffects() || hasTextInput(service))) { | ||
89 | super.doGet(req, resp); | ||
90 | } else { | ||
91 | doService(service, resp); | ||
92 | } | ||
93 | } | ||
94 | |||
95 | @Override | ||
96 | protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | ||
97 | XtextServiceDispatcher.ServiceDescriptor service = getService(req); | ||
98 | String type = service.getContext().getParameter(IServiceContext.SERVICE_TYPE); | ||
99 | if (!service.isHasConflict() && !Objects.equal(type, "update")) { | ||
100 | super.doPut(req, resp); | ||
101 | } else { | ||
102 | doService(service, resp); | ||
103 | } | ||
104 | } | ||
105 | |||
106 | @Override | ||
107 | protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { | ||
108 | XtextServiceDispatcher.ServiceDescriptor service = getService(req); | ||
109 | String type = service.getContext().getParameter(IServiceContext.SERVICE_TYPE); | ||
110 | if (!service.isHasConflict() | ||
111 | && (!service.isHasSideEffects() && !hasTextInput(service) || Objects.equal(type, "update"))) { | ||
112 | super.doPost(req, resp); | ||
113 | } else { | ||
114 | doService(service, resp); | ||
115 | } | ||
116 | } | ||
117 | |||
118 | protected boolean hasTextInput(XtextServiceDispatcher.ServiceDescriptor service) { | ||
119 | Set<String> parameterKeys = service.getContext().getParameterKeys(); | ||
120 | return parameterKeys.contains("fullText") || parameterKeys.contains("deltaText"); | ||
121 | } | ||
122 | |||
123 | /** | ||
124 | * Retrieve the service metadata for the given request. This involves resolving | ||
125 | * the Guice injector for the respective language, querying the | ||
126 | * {@link XtextServiceDispatcher}, and checking the permission to invoke the | ||
127 | * service. | ||
128 | */ | ||
129 | protected XtextServiceDispatcher.ServiceDescriptor getService(HttpServletRequest request) throws IOException { | ||
130 | HttpServiceContext serviceContext = new HttpServiceContext(request); | ||
131 | Injector injector = getInjector(serviceContext); | ||
132 | XtextServiceDispatcher serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); | ||
133 | return serviceDispatcher.getService(serviceContext); | ||
134 | } | ||
135 | |||
136 | /** | ||
137 | * Invoke the service function of the given service descriptor and write its | ||
138 | * result to the servlet response in Json format. An exception is made for | ||
139 | * {@link IUnwrappableServiceResult}: here the document itself is written into | ||
140 | * the response instead of wrapping it into a Json object. | ||
141 | */ | ||
142 | protected void doService(XtextServiceDispatcher.ServiceDescriptor service, HttpServletResponse response) | ||
143 | throws IOException { | ||
144 | IServiceResult result = service.getService().apply(); | ||
145 | response.setStatus(HttpServletResponse.SC_OK); | ||
146 | response.setCharacterEncoding(ENCODING); | ||
147 | response.setHeader("Cache-Control", "no-cache"); | ||
148 | if (result instanceof IUnwrappableServiceResult unwrapResult && unwrapResult.getContent() != null) { | ||
149 | String contentType = null; | ||
150 | if (unwrapResult.getContentType() != null) { | ||
151 | contentType = unwrapResult.getContentType(); | ||
152 | } else { | ||
153 | contentType = "text/plain"; | ||
154 | } | ||
155 | response.setContentType(contentType); | ||
156 | response.getWriter().write(unwrapResult.getContent()); | ||
157 | } else { | ||
158 | response.setContentType("text/x-json"); | ||
159 | gson.toJson(result, response.getWriter()); | ||
160 | } | ||
161 | } | ||
162 | |||
163 | /** | ||
164 | * Resolve the Guice injector for the language associated with the given | ||
165 | * context. | ||
166 | */ | ||
167 | protected Injector getInjector(HttpServiceContext serviceContext) | ||
168 | throws InvalidRequestException.UnknownLanguageException { | ||
169 | IResourceServiceProvider resourceServiceProvider = null; | ||
170 | String parameter = serviceContext.getParameter("resource"); | ||
171 | if (parameter == null) { | ||
172 | parameter = ""; | ||
173 | } | ||
174 | URI emfURI = URI.createURI(parameter); | ||
175 | String contentType = serviceContext.getParameter("contentType"); | ||
176 | if (Strings.isNullOrEmpty(contentType)) { | ||
177 | resourceServiceProvider = SERVICE_PROVIDER_REGISTRY.getResourceServiceProvider(emfURI); | ||
178 | if (resourceServiceProvider == null) { | ||
179 | if (emfURI.toString().isEmpty()) { | ||
180 | throw new InvalidRequestException.UnknownLanguageException( | ||
181 | "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); | ||
182 | } else { | ||
183 | throw new InvalidRequestException.UnknownLanguageException( | ||
184 | "Unable to identify the Xtext language for resource " + emfURI + "."); | ||
185 | } | ||
186 | } | ||
187 | } else { | ||
188 | resourceServiceProvider = SERVICE_PROVIDER_REGISTRY.getResourceServiceProvider(emfURI, contentType); | ||
189 | if (resourceServiceProvider == null) { | ||
190 | throw new InvalidRequestException.UnknownLanguageException( | ||
191 | "Unable to identify the Xtext language for contentType " + contentType + "."); | ||
192 | } | ||
193 | } | ||
194 | return resourceServiceProvider.get(Injector.class); | ||
195 | } | ||
196 | } \ No newline at end of file | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java new file mode 100644 index 00000000..fe510f51 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/PongResult.java | |||
@@ -0,0 +1,44 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceResult; | ||
6 | |||
7 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java new file mode 100644 index 00000000..2a85afe3 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandler.java | |||
@@ -0,0 +1,8 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import tools.refinery.language.web.xtext.server.message.XtextWebResponse; | ||
4 | |||
5 | @FunctionalInterface | ||
6 | public interface ResponseHandler { | ||
7 | void onResponse(XtextWebResponse response) throws ResponseHandlerException; | ||
8 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java new file mode 100644 index 00000000..34fcb546 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/ResponseHandlerException.java | |||
@@ -0,0 +1,14 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java new file mode 100644 index 00000000..78e00a9e --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/SubscribingServiceContext.java | |||
@@ -0,0 +1,26 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.util.Set; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceContext; | ||
6 | import org.eclipse.xtext.web.server.ISession; | ||
7 | |||
8 | import tools.refinery.language.web.xtext.server.push.PrecomputationListener; | ||
9 | |||
10 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java new file mode 100644 index 00000000..0b417b06 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/TransactionExecutor.java | |||
@@ -0,0 +1,180 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import java.lang.ref.WeakReference; | ||
4 | import java.util.ArrayList; | ||
5 | import java.util.HashMap; | ||
6 | import java.util.List; | ||
7 | import java.util.Map; | ||
8 | |||
9 | import org.eclipse.emf.common.util.URI; | ||
10 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
11 | import org.eclipse.xtext.util.IDisposable; | ||
12 | import org.eclipse.xtext.web.server.IServiceContext; | ||
13 | import org.eclipse.xtext.web.server.IServiceResult; | ||
14 | import org.eclipse.xtext.web.server.ISession; | ||
15 | import org.eclipse.xtext.web.server.InvalidRequestException; | ||
16 | import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; | ||
17 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
18 | import org.slf4j.Logger; | ||
19 | import org.slf4j.LoggerFactory; | ||
20 | |||
21 | import com.google.common.base.Strings; | ||
22 | import com.google.inject.Injector; | ||
23 | |||
24 | import tools.refinery.language.web.xtext.server.message.XtextWebErrorKind; | ||
25 | import tools.refinery.language.web.xtext.server.message.XtextWebErrorResponse; | ||
26 | import tools.refinery.language.web.xtext.server.message.XtextWebOkResponse; | ||
27 | import tools.refinery.language.web.xtext.server.message.XtextWebPushMessage; | ||
28 | import tools.refinery.language.web.xtext.server.message.XtextWebRequest; | ||
29 | import tools.refinery.language.web.xtext.server.push.PrecomputationListener; | ||
30 | import tools.refinery.language.web.xtext.server.push.PushWebDocument; | ||
31 | import tools.refinery.language.web.xtext.servlet.SimpleServiceContext; | ||
32 | |||
33 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java new file mode 100644 index 00000000..f74bae74 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorKind.java | |||
@@ -0,0 +1,11 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import com.google.gson.annotations.SerializedName; | ||
4 | |||
5 | public enum XtextWebErrorKind { | ||
6 | @SerializedName("request") | ||
7 | REQUEST_ERROR, | ||
8 | |||
9 | @SerializedName("server") | ||
10 | SERVER_ERROR, | ||
11 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java new file mode 100644 index 00000000..01d78c31 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebErrorResponse.java | |||
@@ -0,0 +1,79 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java new file mode 100644 index 00000000..8af27247 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebOkResponse.java | |||
@@ -0,0 +1,72 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import org.eclipse.xtext.web.server.IServiceResult; | ||
6 | import org.eclipse.xtext.web.server.IUnwrappableServiceResult; | ||
7 | |||
8 | import com.google.gson.annotations.SerializedName; | ||
9 | |||
10 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java new file mode 100644 index 00000000..c9432e1c --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebPushMessage.java | |||
@@ -0,0 +1,81 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java new file mode 100644 index 00000000..959749f8 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java | |||
@@ -0,0 +1,57 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | import java.util.Map; | ||
4 | import java.util.Objects; | ||
5 | |||
6 | import com.google.gson.annotations.SerializedName; | ||
7 | |||
8 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java new file mode 100644 index 00000000..3bd13047 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebResponse.java | |||
@@ -0,0 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext.server.message; | ||
2 | |||
3 | public sealed interface XtextWebResponse permits XtextWebOkResponse,XtextWebErrorResponse,XtextWebPushMessage { | ||
4 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java new file mode 100644 index 00000000..79a284db --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PrecomputationListener.java | |||
@@ -0,0 +1,15 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceResult; | ||
4 | |||
5 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
6 | |||
7 | @FunctionalInterface | ||
8 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java new file mode 100644 index 00000000..c7b8108d --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushServiceDispatcher.java | |||
@@ -0,0 +1,23 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceContext; | ||
4 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
5 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
6 | |||
7 | import com.google.inject.Singleton; | ||
8 | |||
9 | import tools.refinery.language.web.xtext.server.SubscribingServiceContext; | ||
10 | |||
11 | @Singleton | ||
12 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java new file mode 100644 index 00000000..906b9e30 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocument.java | |||
@@ -0,0 +1,89 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import java.util.ArrayList; | ||
4 | import java.util.HashMap; | ||
5 | import java.util.List; | ||
6 | import java.util.Map; | ||
7 | |||
8 | import org.eclipse.xtext.util.CancelIndicator; | ||
9 | import org.eclipse.xtext.web.server.IServiceResult; | ||
10 | import org.eclipse.xtext.web.server.model.AbstractCachedService; | ||
11 | import org.eclipse.xtext.web.server.model.DocumentSynchronizer; | ||
12 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
13 | import org.slf4j.Logger; | ||
14 | import org.slf4j.LoggerFactory; | ||
15 | |||
16 | import com.google.common.collect.ImmutableList; | ||
17 | |||
18 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
19 | |||
20 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java new file mode 100644 index 00000000..b3666a86 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentAccess.java | |||
@@ -0,0 +1,68 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.service.OperationCanceledManager; | ||
4 | import org.eclipse.xtext.util.CancelIndicator; | ||
5 | import org.eclipse.xtext.util.concurrent.CancelableUnitOfWork; | ||
6 | import org.eclipse.xtext.web.server.IServiceResult; | ||
7 | import org.eclipse.xtext.web.server.model.AbstractCachedService; | ||
8 | import org.eclipse.xtext.web.server.model.IXtextWebDocument; | ||
9 | import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; | ||
10 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
11 | import org.eclipse.xtext.web.server.model.XtextWebDocumentAccess; | ||
12 | import org.eclipse.xtext.web.server.syntaxcoloring.HighlightingService; | ||
13 | import org.eclipse.xtext.web.server.validation.ValidationService; | ||
14 | |||
15 | import com.google.inject.Inject; | ||
16 | |||
17 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java new file mode 100644 index 00000000..fc45f74a --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/push/PushWebDocumentProvider.java | |||
@@ -0,0 +1,33 @@ | |||
1 | package tools.refinery.language.web.xtext.server.push; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.IServiceContext; | ||
4 | import org.eclipse.xtext.web.server.model.DocumentSynchronizer; | ||
5 | import org.eclipse.xtext.web.server.model.IWebDocumentProvider; | ||
6 | import org.eclipse.xtext.web.server.model.XtextWebDocument; | ||
7 | |||
8 | import com.google.inject.Inject; | ||
9 | import com.google.inject.Provider; | ||
10 | import 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 | ||
19 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java new file mode 100644 index 00000000..43e37160 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java | |||
@@ -0,0 +1,26 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.util.Map; | ||
4 | import java.util.Set; | ||
5 | |||
6 | import org.eclipse.xtext.web.server.IServiceContext; | ||
7 | import org.eclipse.xtext.web.server.ISession; | ||
8 | |||
9 | import com.google.common.collect.ImmutableSet; | ||
10 | |||
11 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java new file mode 100644 index 00000000..09c055a2 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java | |||
@@ -0,0 +1,35 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.util.HashMap; | ||
4 | import java.util.Map; | ||
5 | |||
6 | import org.eclipse.xtext.web.server.ISession; | ||
7 | import org.eclipse.xtext.xbase.lib.Functions.Function0; | ||
8 | |||
9 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java new file mode 100644 index 00000000..0cd229e8 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java | |||
@@ -0,0 +1,9 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java new file mode 100644 index 00000000..fd41f213 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java | |||
@@ -0,0 +1,133 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.io.Reader; | ||
5 | |||
6 | import org.eclipse.jetty.websocket.api.Session; | ||
7 | import org.eclipse.jetty.websocket.api.StatusCode; | ||
8 | import org.eclipse.jetty.websocket.api.WriteCallback; | ||
9 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; | ||
10 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; | ||
11 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; | ||
12 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; | ||
13 | import org.eclipse.jetty.websocket.api.annotations.WebSocket; | ||
14 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
15 | import org.eclipse.xtext.web.server.ISession; | ||
16 | import org.slf4j.Logger; | ||
17 | import org.slf4j.LoggerFactory; | ||
18 | |||
19 | import com.google.gson.Gson; | ||
20 | import com.google.gson.JsonIOException; | ||
21 | import com.google.gson.JsonParseException; | ||
22 | |||
23 | import tools.refinery.language.web.xtext.server.ResponseHandler; | ||
24 | import tools.refinery.language.web.xtext.server.ResponseHandlerException; | ||
25 | import tools.refinery.language.web.xtext.server.TransactionExecutor; | ||
26 | import tools.refinery.language.web.xtext.server.message.XtextWebRequest; | ||
27 | import tools.refinery.language.web.xtext.server.message.XtextWebResponse; | ||
28 | |||
29 | @WebSocket | ||
30 | public 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/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java new file mode 100644 index 00000000..942ca380 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java | |||
@@ -0,0 +1,83 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.time.Duration; | ||
5 | import java.util.Set; | ||
6 | |||
7 | import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest; | ||
8 | import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse; | ||
9 | import org.eclipse.jetty.websocket.server.JettyWebSocketCreator; | ||
10 | import org.eclipse.jetty.websocket.server.JettyWebSocketServlet; | ||
11 | import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory; | ||
12 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
13 | import org.slf4j.Logger; | ||
14 | import org.slf4j.LoggerFactory; | ||
15 | |||
16 | import jakarta.servlet.ServletConfig; | ||
17 | import jakarta.servlet.ServletException; | ||
18 | |||
19 | public 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 | } | ||