diff options
author | Kristóf Marussy <kristof@marussy.com> | 2021-10-11 20:53:54 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2021-10-31 19:26:10 +0100 |
commit | 7e9289a754e1829ebb04efcce0a4a2d52a22b122 (patch) | |
tree | 9445ffd0f67f7e4d804acd1a7c17a67b9016d461 /language-web/src/main/java/tools | |
parent | feat(web): simplify contextual parsing (diff) | |
download | refinery-7e9289a754e1829ebb04efcce0a4a2d52a22b122.tar.gz refinery-7e9289a754e1829ebb04efcce0a4a2d52a22b122.tar.zst refinery-7e9289a754e1829ebb04efcce0a4a2d52a22b122.zip |
feat(web): add websocket server
Diffstat (limited to 'language-web/src/main/java/tools')
15 files changed, 519 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/ProblemServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java index 49457002..9ffd6557 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.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..0942b680 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; |
@@ -22,24 +23,28 @@ import org.slf4j.LoggerFactory; | |||
22 | 23 | ||
23 | import jakarta.servlet.DispatcherType; | 24 | import jakarta.servlet.DispatcherType; |
24 | import jakarta.servlet.SessionTrackingMode; | 25 | import jakarta.servlet.SessionTrackingMode; |
26 | import tools.refinery.language.web.xtext.XtextWebSocketServlet; | ||
25 | 27 | ||
26 | public class ServerLauncher { | 28 | public class ServerLauncher { |
27 | public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; | 29 | public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; |
28 | 30 | ||
29 | public static final int DEFAULT_LISTEN_PORT = 1312; | 31 | public static final int DEFAULT_LISTEN_PORT = 1312; |
30 | 32 | ||
31 | // Use this cookie name for load balancing. | 33 | public static final int DEFAULT_PUBLIC_PORT = 443; |
32 | public static final String SESSION_COOKIE_NAME = "JSESSIONID"; | 34 | |
35 | public static final int HTTP_DEFAULT_PORT = 80; | ||
36 | |||
37 | public static final int HTTPS_DEFAULT_PORT = 443; | ||
33 | 38 | ||
34 | private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class); | 39 | private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class); |
35 | 40 | ||
36 | private final Server server; | 41 | private final Server server; |
37 | 42 | ||
38 | public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource) { | 43 | public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource, Optional<String[]> allowedOrigins) { |
39 | server = new Server(bindAddress); | 44 | server = new Server(bindAddress); |
40 | var handler = new ServletContextHandler(); | 45 | var handler = new ServletContextHandler(); |
41 | addSessionHandler(handler); | 46 | addSessionHandler(handler); |
42 | addProblemServlet(handler); | 47 | addProblemServlet(handler, allowedOrigins); |
43 | if (baseResource != null) { | 48 | if (baseResource != null) { |
44 | handler.setBaseResource(baseResource); | 49 | handler.setBaseResource(baseResource); |
45 | handler.setWelcomeFiles(new String[] { "index.html" }); | 50 | handler.setWelcomeFiles(new String[] { "index.html" }); |
@@ -52,12 +57,20 @@ public class ServerLauncher { | |||
52 | private void addSessionHandler(ServletContextHandler handler) { | 57 | private void addSessionHandler(ServletContextHandler handler) { |
53 | var sessionHandler = new SessionHandler(); | 58 | var sessionHandler = new SessionHandler(); |
54 | sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE)); | 59 | sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE)); |
55 | sessionHandler.setSessionCookie(SESSION_COOKIE_NAME); | ||
56 | handler.setSessionHandler(sessionHandler); | 60 | handler.setSessionHandler(sessionHandler); |
57 | } | 61 | } |
58 | 62 | ||
59 | private void addProblemServlet(ServletContextHandler handler) { | 63 | private void addProblemServlet(ServletContextHandler handler, Optional<String[]> allowedOrigins) { |
60 | handler.addServlet(ProblemServlet.class, "/xtext-service/*"); | 64 | var problemServletHolder = new ServletHolder(ProblemWebSocketServlet.class); |
65 | if (allowedOrigins.isEmpty()) { | ||
66 | LOG.warn("All WebSocket origins are allowed! This setting should not be used in production!"); | ||
67 | } else { | ||
68 | var allowedOriginsString = String.join(XtextWebSocketServlet.ALLOWED_ORIGINS_SEPARATOR, | ||
69 | allowedOrigins.get()); | ||
70 | problemServletHolder.setInitParameter(XtextWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM, | ||
71 | allowedOriginsString); | ||
72 | } | ||
73 | handler.addServlet(problemServletHolder, "/xtext-service/*"); | ||
61 | } | 74 | } |
62 | 75 | ||
63 | private void addDefaultServlet(ServletContextHandler handler) { | 76 | private void addDefaultServlet(ServletContextHandler handler) { |
@@ -80,7 +93,8 @@ public class ServerLauncher { | |||
80 | try { | 93 | try { |
81 | var bindAddress = getBindAddress(); | 94 | var bindAddress = getBindAddress(); |
82 | var baseResource = getBaseResource(); | 95 | var baseResource = getBaseResource(); |
83 | var serverLauncher = new ServerLauncher(bindAddress, baseResource); | 96 | var allowedOrigins = getAllowedOrigins(); |
97 | var serverLauncher = new ServerLauncher(bindAddress, baseResource, allowedOrigins); | ||
84 | serverLauncher.start(); | 98 | serverLauncher.start(); |
85 | } catch (Exception exception) { | 99 | } catch (Exception exception) { |
86 | LOG.error("Fatal server error", exception); | 100 | LOG.error("Fatal server error", exception); |
@@ -132,4 +146,43 @@ public class ServerLauncher { | |||
132 | // Fall back to just serving a 404. | 146 | // Fall back to just serving a 404. |
133 | return null; | 147 | return null; |
134 | } | 148 | } |
149 | |||
150 | private static String getPublicHost() { | ||
151 | var publicHost = System.getenv("PUBLIC_HOST"); | ||
152 | if (publicHost != null) { | ||
153 | return publicHost.toLowerCase(); | ||
154 | } | ||
155 | return null; | ||
156 | } | ||
157 | |||
158 | private static int getPublicPort() { | ||
159 | var portStr = System.getenv("PUBLIC_PORT"); | ||
160 | if (portStr != null) { | ||
161 | return Integer.parseInt(portStr); | ||
162 | } | ||
163 | return DEFAULT_LISTEN_PORT; | ||
164 | } | ||
165 | |||
166 | private static Optional<String[]> getAllowedOrigins() { | ||
167 | var allowedOrigins = System.getenv("ALLOWED_ORIGINS"); | ||
168 | if (allowedOrigins != null) { | ||
169 | return Optional.of(allowedOrigins.split(XtextWebSocketServlet.ALLOWED_ORIGINS_SEPARATOR)); | ||
170 | } | ||
171 | return getAllowedOriginsFromPublicHostAndPort(); | ||
172 | } | ||
173 | |||
174 | private static Optional<String[]> getAllowedOriginsFromPublicHostAndPort() { | ||
175 | var publicHost = getPublicHost(); | ||
176 | if (publicHost == null) { | ||
177 | return Optional.empty(); | ||
178 | } | ||
179 | int publicPort = getPublicPort(); | ||
180 | var scheme = publicPort == HTTPS_DEFAULT_PORT ? "https" : "http"; | ||
181 | var urlWithPort = String.format("%s://%s:%d", scheme, publicHost, publicPort); | ||
182 | if (publicPort == HTTPS_DEFAULT_PORT || publicPort == HTTP_DEFAULT_PORT) { | ||
183 | var urlWithoutPort = String.format("%s://%s", scheme, publicHost); | ||
184 | return Optional.of(new String[] { urlWithPort, urlWithoutPort }); | ||
185 | } | ||
186 | return Optional.of(new String[] { urlWithPort }); | ||
187 | } | ||
135 | } | 188 | } |
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/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java new file mode 100644 index 00000000..1ec5b235 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java | |||
@@ -0,0 +1,27 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
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 | record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext { | ||
12 | |||
13 | @Override | ||
14 | public Set<String> getParameterKeys() { | ||
15 | return ImmutableSet.copyOf(parameters.keySet()); | ||
16 | } | ||
17 | |||
18 | @Override | ||
19 | public String getParameter(String key) { | ||
20 | return parameters.get(key); | ||
21 | } | ||
22 | |||
23 | @Override | ||
24 | public ISession getSession() { | ||
25 | return session; | ||
26 | } | ||
27 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java new file mode 100644 index 00000000..691077d0 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java | |||
@@ -0,0 +1,35 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
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/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/XtextStatusCode.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java new file mode 100644 index 00000000..8ef60108 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java | |||
@@ -0,0 +1,9 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
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/XtextWebSocket.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java new file mode 100644 index 00000000..c76ef12f --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java | |||
@@ -0,0 +1,137 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | import java.io.Reader; | ||
5 | |||
6 | import org.eclipse.emf.common.util.URI; | ||
7 | import org.eclipse.jetty.websocket.api.Session; | ||
8 | import org.eclipse.jetty.websocket.api.StatusCode; | ||
9 | import org.eclipse.jetty.websocket.api.WriteCallback; | ||
10 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; | ||
11 | import org.eclipse.jetty.websocket.api.annotations.WebSocket; | ||
12 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
13 | import org.eclipse.xtext.web.server.IServiceContext; | ||
14 | import org.eclipse.xtext.web.server.ISession; | ||
15 | import org.eclipse.xtext.web.server.IUnwrappableServiceResult; | ||
16 | import org.eclipse.xtext.web.server.InvalidRequestException; | ||
17 | import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; | ||
18 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
19 | import org.slf4j.Logger; | ||
20 | import org.slf4j.LoggerFactory; | ||
21 | |||
22 | import com.google.common.base.Strings; | ||
23 | import com.google.gson.Gson; | ||
24 | import com.google.gson.JsonIOException; | ||
25 | import com.google.gson.JsonParseException; | ||
26 | import com.google.inject.Injector; | ||
27 | |||
28 | @WebSocket | ||
29 | public class XtextWebSocket implements WriteCallback { | ||
30 | private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); | ||
31 | |||
32 | private final Gson gson = new Gson(); | ||
33 | |||
34 | private final ISession session; | ||
35 | |||
36 | private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; | ||
37 | |||
38 | public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { | ||
39 | this.session = session; | ||
40 | this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; | ||
41 | } | ||
42 | |||
43 | @OnWebSocketMessage | ||
44 | public void onMessage(Session webSocketSession, Reader reader) { | ||
45 | XtextWebSocketRequest request; | ||
46 | try { | ||
47 | request = gson.fromJson(reader, XtextWebSocketRequest.class); | ||
48 | } catch (JsonIOException e) { | ||
49 | LOG.error("Cannot read from websocket " + webSocketSession.getRemoteAddress(), e); | ||
50 | if (webSocketSession.isOpen()) { | ||
51 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); | ||
52 | } | ||
53 | return; | ||
54 | } catch (JsonParseException e) { | ||
55 | LOG.warn("Malformed websocket request from " + webSocketSession.getRemoteAddress(), e); | ||
56 | webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); | ||
57 | return; | ||
58 | } | ||
59 | var serviceContext = new SimpleServiceContext(session, request.getRequestData()); | ||
60 | var response = handleMessage(serviceContext); | ||
61 | response.setId(request.getId()); | ||
62 | var responseString = gson.toJson(response); | ||
63 | try { | ||
64 | webSocketSession.getRemote().sendPartialString(responseString, true, this); | ||
65 | } catch (IOException e) { | ||
66 | LOG.warn("Cannot initiaite async write to websocket to " + webSocketSession.getRemoteAddress(), e); | ||
67 | if (webSocketSession.isOpen()) { | ||
68 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); | ||
69 | } | ||
70 | } | ||
71 | } | ||
72 | |||
73 | @Override | ||
74 | public void writeFailed(Throwable x) { | ||
75 | LOG.warn("Cannot complete async write to websocket", x); | ||
76 | } | ||
77 | |||
78 | protected XtextWebSocketResponse handleMessage(IServiceContext serviceContext) { | ||
79 | try { | ||
80 | var injector = getInjector(serviceContext); | ||
81 | var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); | ||
82 | var service = serviceDispatcher.getService(serviceContext); | ||
83 | var serviceResult = service.getService().apply(); | ||
84 | var response = new XtextWebSocketOkResponse(); | ||
85 | if (serviceResult instanceof IUnwrappableServiceResult unwrappableServiceResult | ||
86 | && unwrappableServiceResult.getContent() != null) { | ||
87 | response.setResponseData(unwrappableServiceResult.getContent()); | ||
88 | } else { | ||
89 | response.setResponseData(serviceResult); | ||
90 | } | ||
91 | return response; | ||
92 | } catch (InvalidRequestException e) { | ||
93 | LOG.warn("Invalid request", e); | ||
94 | var error = new XtextWebSocketErrorResponse(); | ||
95 | error.setErrorMessage(e.getMessage()); | ||
96 | return error; | ||
97 | } | ||
98 | } | ||
99 | |||
100 | /** | ||
101 | * Get the injector to satisfy the request in the {@code serviceContext}. | ||
102 | * | ||
103 | * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. | ||
104 | * | ||
105 | * @param serviceContext the Xtext service context of the request | ||
106 | * @return the injector for the Xtext language in the request | ||
107 | * @throws UnknownLanguageException if the Xtext language cannot be determined | ||
108 | */ | ||
109 | protected Injector getInjector(IServiceContext serviceContext) { | ||
110 | IResourceServiceProvider resourceServiceProvider = null; | ||
111 | var resourceName = serviceContext.getParameter("resource"); | ||
112 | if (resourceName == null) { | ||
113 | resourceName = ""; | ||
114 | } | ||
115 | var emfURI = URI.createURI(resourceName); | ||
116 | var contentType = serviceContext.getParameter("contentType"); | ||
117 | if (Strings.isNullOrEmpty(contentType)) { | ||
118 | resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); | ||
119 | if (resourceServiceProvider == null) { | ||
120 | if (emfURI.toString().isEmpty()) { | ||
121 | throw new UnknownLanguageException( | ||
122 | "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); | ||
123 | } else { | ||
124 | throw new UnknownLanguageException( | ||
125 | "Unable to identify the Xtext language for resource " + emfURI + "."); | ||
126 | } | ||
127 | } | ||
128 | } else { | ||
129 | resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); | ||
130 | if (resourceServiceProvider == null) { | ||
131 | throw new UnknownLanguageException( | ||
132 | "Unable to identify the Xtext language for contentType " + contentType + "."); | ||
133 | } | ||
134 | } | ||
135 | return resourceServiceProvider.get(Injector.class); | ||
136 | } | ||
137 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java new file mode 100644 index 00000000..87b300b4 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java | |||
@@ -0,0 +1,52 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public final class XtextWebSocketErrorResponse implements XtextWebSocketResponse { | ||
8 | private String id; | ||
9 | |||
10 | @SerializedName("error") | ||
11 | private String errorMessage; | ||
12 | |||
13 | @Override | ||
14 | public String getId() { | ||
15 | return id; | ||
16 | } | ||
17 | |||
18 | @Override | ||
19 | public void setId(String id) { | ||
20 | this.id = id; | ||
21 | } | ||
22 | |||
23 | public String getErrorMessage() { | ||
24 | return errorMessage; | ||
25 | } | ||
26 | |||
27 | public void setErrorMessage(String errorMessage) { | ||
28 | this.errorMessage = errorMessage; | ||
29 | } | ||
30 | |||
31 | @Override | ||
32 | public int hashCode() { | ||
33 | return Objects.hash(errorMessage, id); | ||
34 | } | ||
35 | |||
36 | @Override | ||
37 | public boolean equals(Object obj) { | ||
38 | if (this == obj) | ||
39 | return true; | ||
40 | if (obj == null) | ||
41 | return false; | ||
42 | if (getClass() != obj.getClass()) | ||
43 | return false; | ||
44 | XtextWebSocketErrorResponse other = (XtextWebSocketErrorResponse) obj; | ||
45 | return Objects.equals(errorMessage, other.errorMessage) && Objects.equals(id, other.id); | ||
46 | } | ||
47 | |||
48 | @Override | ||
49 | public String toString() { | ||
50 | return "XtextWebSocketError [id=" + id + ", errorMessage=" + errorMessage + "]"; | ||
51 | } | ||
52 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java new file mode 100644 index 00000000..4ef1768b --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java | |||
@@ -0,0 +1,52 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
2 | |||
3 | import java.util.Objects; | ||
4 | |||
5 | import com.google.gson.annotations.SerializedName; | ||
6 | |||
7 | public final class XtextWebSocketOkResponse implements XtextWebSocketResponse { | ||
8 | private String id; | ||
9 | |||
10 | @SerializedName("response") | ||
11 | private Object responseData; | ||
12 | |||
13 | @Override | ||
14 | public String getId() { | ||
15 | return id; | ||
16 | } | ||
17 | |||
18 | @Override | ||
19 | public void setId(String id) { | ||
20 | this.id = id; | ||
21 | } | ||
22 | |||
23 | public Object getResponseData() { | ||
24 | return responseData; | ||
25 | } | ||
26 | |||
27 | public void setResponseData(Object responseData) { | ||
28 | this.responseData = responseData; | ||
29 | } | ||
30 | |||
31 | @Override | ||
32 | public int hashCode() { | ||
33 | return Objects.hash(id, responseData); | ||
34 | } | ||
35 | |||
36 | @Override | ||
37 | public boolean equals(Object obj) { | ||
38 | if (this == obj) | ||
39 | return true; | ||
40 | if (obj == null) | ||
41 | return false; | ||
42 | if (getClass() != obj.getClass()) | ||
43 | return false; | ||
44 | XtextWebSocketOkResponse other = (XtextWebSocketOkResponse) obj; | ||
45 | return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData); | ||
46 | } | ||
47 | |||
48 | @Override | ||
49 | public String toString() { | ||
50 | return "XtextWebSocketResponse [id=" + id + ", responseData=" + responseData + "]"; | ||
51 | } | ||
52 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java new file mode 100644 index 00000000..e34bf73a --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java | |||
@@ -0,0 +1,51 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
2 | |||
3 | import java.util.Map; | ||
4 | import java.util.Objects; | ||
5 | |||
6 | import com.google.gson.annotations.SerializedName; | ||
7 | |||
8 | public class XtextWebSocketRequest { | ||
9 | private String id; | ||
10 | |||
11 | @SerializedName("request") | ||
12 | private Map<String, String> requestData; | ||
13 | |||
14 | public String getId() { | ||
15 | return id; | ||
16 | } | ||
17 | |||
18 | public void setId(String id) { | ||
19 | this.id = id; | ||
20 | } | ||
21 | |||
22 | public Map<String, String> getRequestData() { | ||
23 | return requestData; | ||
24 | } | ||
25 | |||
26 | public void setRequestData(Map<String, String> request) { | ||
27 | this.requestData = request; | ||
28 | } | ||
29 | |||
30 | @Override | ||
31 | public int hashCode() { | ||
32 | return Objects.hash(id, requestData); | ||
33 | } | ||
34 | |||
35 | @Override | ||
36 | public boolean equals(Object obj) { | ||
37 | if (this == obj) | ||
38 | return true; | ||
39 | if (obj == null) | ||
40 | return false; | ||
41 | if (getClass() != obj.getClass()) | ||
42 | return false; | ||
43 | XtextWebSocketRequest other = (XtextWebSocketRequest) obj; | ||
44 | return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData); | ||
45 | } | ||
46 | |||
47 | @Override | ||
48 | public String toString() { | ||
49 | return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]"; | ||
50 | } | ||
51 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java new file mode 100644 index 00000000..df0c228e --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java | |||
@@ -0,0 +1,7 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
2 | |||
3 | public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse, XtextWebSocketErrorResponse { | ||
4 | public String getId(); | ||
5 | |||
6 | public void setId(String id); | ||
7 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java new file mode 100644 index 00000000..0de6c358 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java | |||
@@ -0,0 +1,75 @@ | |||
1 | package tools.refinery.language.web.xtext; | ||
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 | /** | ||
28 | * Maximum message size should be large enough to upload a full model file. | ||
29 | */ | ||
30 | private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; | ||
31 | |||
32 | private static final Duration IDLE_TIMEOUT = Duration.ofMinutes(10); | ||
33 | |||
34 | private transient Logger log = LoggerFactory.getLogger(getClass()); | ||
35 | |||
36 | private transient Set<String> allowedOrigins = null; | ||
37 | |||
38 | @Override | ||
39 | public void init(ServletConfig config) throws ServletException { | ||
40 | var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM); | ||
41 | if (allowedOriginsStr != null) { | ||
42 | allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR)); | ||
43 | log.info("Allowed origins: {}", allowedOrigins); | ||
44 | } else { | ||
45 | log.warn("All WebSocket origins are allowed! This setting should not be used in production!"); | ||
46 | } | ||
47 | super.init(config); | ||
48 | } | ||
49 | |||
50 | @Override | ||
51 | protected void configure(JettyWebSocketServletFactory factory) { | ||
52 | factory.setMaxFrameSize(MAX_FRAME_SIZE); | ||
53 | factory.setIdleTimeout(IDLE_TIMEOUT); | ||
54 | factory.addMapping("/", this); | ||
55 | } | ||
56 | |||
57 | @Override | ||
58 | public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) { | ||
59 | if (allowedOrigins != null) { | ||
60 | var origin = req.getOrigin(); | ||
61 | if (origin != null && !allowedOrigins.contains(origin.toLowerCase())) { | ||
62 | log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin); | ||
63 | try { | ||
64 | resp.sendForbidden("Origin not allowed"); | ||
65 | } catch (IOException e) { | ||
66 | log.error("Cannot send forbidden origin error", e); | ||
67 | } | ||
68 | return null; | ||
69 | } | ||
70 | } | ||
71 | log.debug("New connection from {}", req.getRemoteSocketAddress()); | ||
72 | var session = new SimpleSession(); | ||
73 | return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE); | ||
74 | } | ||
75 | } | ||