diff options
22 files changed, 402 insertions, 103 deletions
diff --git a/gradle.properties b/gradle.properties index 5c0df1d3..1020bfc9 100644 --- a/gradle.properties +++ b/gradle.properties | |||
@@ -5,6 +5,7 @@ hamcrestVersion=2.2 | |||
5 | jettyVersion=11.0.6 | 5 | jettyVersion=11.0.6 |
6 | jmhVersion=1.32 | 6 | jmhVersion=1.32 |
7 | junitVersion=5.8.1 | 7 | junitVersion=5.8.1 |
8 | mockitoVersion=4.0.0 | ||
8 | mweVersion=1.6.2.M1 | 9 | mweVersion=1.6.2.M1 |
9 | mwe2Version=2.12.2.M1 | 10 | mwe2Version=2.12.2.M1 |
10 | nodeVersion=14.18.0 | 11 | nodeVersion=14.18.0 |
diff --git a/gradle/junit.gradle b/gradle/junit.gradle index da0cea8c..44a463c7 100644 --- a/gradle/junit.gradle +++ b/gradle/junit.gradle | |||
@@ -5,6 +5,8 @@ dependencies { | |||
5 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" | 5 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" |
6 | testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}" | 6 | testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}" |
7 | testImplementation "org.hamcrest:hamcrest:${hamcrestVersion}" | 7 | testImplementation "org.hamcrest:hamcrest:${hamcrestVersion}" |
8 | testImplementation "org.mockito:mockito-core:${mockitoVersion}" | ||
9 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" | ||
8 | } | 10 | } |
9 | 11 | ||
10 | def jacocoTestReport = tasks.named('jacocoTestReport') | 12 | def jacocoTestReport = tasks.named('jacocoTestReport') |
diff --git a/language-web/build.gradle b/language-web/build.gradle index df23bc91..fc2c9bba 100644 --- a/language-web/build.gradle +++ b/language-web/build.gradle | |||
@@ -1,5 +1,6 @@ | |||
1 | apply plugin: 'java' | 1 | apply plugin: 'java' |
2 | apply from: "${rootDir}/gradle/xtext-common.gradle" | 2 | apply from: "${rootDir}/gradle/xtext-common.gradle" |
3 | apply from: "${rootDir}/gradle/junit.gradle" | ||
3 | 4 | ||
4 | dependencies { | 5 | dependencies { |
5 | implementation project(':refinery-language') | 6 | implementation project(':refinery-language') |
@@ -11,6 +12,7 @@ dependencies { | |||
11 | implementation "org.eclipse.jetty.websocket:websocket-jetty-server:${jettyVersion}" | 12 | implementation "org.eclipse.jetty.websocket:websocket-jetty-server:${jettyVersion}" |
12 | implementation "org.slf4j:slf4j-simple:${slf4JVersion}" | 13 | implementation "org.slf4j:slf4j-simple:${slf4JVersion}" |
13 | implementation "org.slf4j:log4j-over-slf4j:${slf4JVersion}" | 14 | implementation "org.slf4j:log4j-over-slf4j:${slf4JVersion}" |
15 | testImplementation "org.eclipse.xtext:org.eclipse.xtext.testing:${xtextVersion}" | ||
14 | } | 16 | } |
15 | 17 | ||
16 | def generateXtextLanguage = project(':refinery-language').tasks.named('generateXtextLanguage') | 18 | def generateXtextLanguage = project(':refinery-language').tasks.named('generateXtextLanguage') |
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..04f38414 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,15 @@ | |||
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 | |||
8 | import tools.refinery.language.web.xtext.server.NoPrecomputedServicesXtextServiceDispatcher; | ||
6 | 9 | ||
7 | /** | 10 | /** |
8 | * Use this class to register additional components to be used within the web application. | 11 | * Use this class to register additional components to be used within the web application. |
9 | */ | 12 | */ |
10 | public class ProblemWebModule extends AbstractProblemWebModule { | 13 | public class ProblemWebModule extends AbstractProblemWebModule { |
14 | public Class<? extends XtextServiceDispatcher> bindXtextServiceDispatcher() { | ||
15 | return NoPrecomputedServicesXtextServiceDispatcher.class; | ||
16 | } | ||
11 | } | 17 | } |
diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java index 9ffd6557..df67b521 100644 --- a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java +++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java | |||
@@ -3,7 +3,7 @@ package tools.refinery.language.web; | |||
3 | import org.eclipse.xtext.util.DisposableRegistry; | 3 | import org.eclipse.xtext.util.DisposableRegistry; |
4 | 4 | ||
5 | import jakarta.servlet.ServletException; | 5 | import jakarta.servlet.ServletException; |
6 | import tools.refinery.language.web.xtext.XtextWebSocketServlet; | 6 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; |
7 | 7 | ||
8 | public class ProblemWebSocketServlet extends XtextWebSocketServlet { | 8 | public class ProblemWebSocketServlet extends XtextWebSocketServlet { |
9 | 9 | ||
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 f6311070..a71d8e93 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 | |||
@@ -23,7 +23,7 @@ import org.slf4j.LoggerFactory; | |||
23 | 23 | ||
24 | import jakarta.servlet.DispatcherType; | 24 | import jakarta.servlet.DispatcherType; |
25 | import jakarta.servlet.SessionTrackingMode; | 25 | import jakarta.servlet.SessionTrackingMode; |
26 | import tools.refinery.language.web.xtext.XtextWebSocketServlet; | 26 | import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; |
27 | 27 | ||
28 | public class ServerLauncher { | 28 | public class ServerLauncher { |
29 | public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; | 29 | public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; |
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java b/language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java new file mode 100644 index 00000000..6660d6ac --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java | |||
@@ -0,0 +1,16 @@ | |||
1 | package tools.refinery.language.web.xtext.server; | ||
2 | |||
3 | import org.eclipse.xtext.web.server.XtextServiceDispatcher; | ||
4 | import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; | ||
5 | |||
6 | import com.google.inject.Singleton; | ||
7 | |||
8 | @Singleton | ||
9 | public class NoPrecomputedServicesXtextServiceDispatcher extends XtextServiceDispatcher { | ||
10 | @Override | ||
11 | protected void registerPreComputedServices(PrecomputedServiceRegistry registry) { | ||
12 | // Do not register any precomputed services, because we will always send | ||
13 | // requests for any pre-computation in the same websocket message as the | ||
14 | // document update request. | ||
15 | } | ||
16 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java new file mode 100644 index 00000000..b1fcbc8b --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java | |||
@@ -0,0 +1,8 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.io.IOException; | ||
4 | |||
5 | @FunctionalInterface | ||
6 | public interface ResponseHandler { | ||
7 | void onResponse(XtextWebSocketResponse response) throws IOException; | ||
8 | } | ||
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/servlet/SimpleServiceContext.java index 8036b749..945d5db0 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import java.util.Map; | 3 | import java.util.Map; |
4 | import java.util.Set; | 4 | import java.util.Set; |
@@ -8,6 +8,7 @@ import org.eclipse.xtext.web.server.ISession; | |||
8 | import org.eclipse.xtext.web.server.InvalidRequestException; | 8 | import org.eclipse.xtext.web.server.InvalidRequestException; |
9 | 9 | ||
10 | import com.google.common.collect.ImmutableMap; | 10 | import com.google.common.collect.ImmutableMap; |
11 | import com.google.common.collect.ImmutableSet; | ||
11 | 12 | ||
12 | record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext { | 13 | record SimpleServiceContext(ISession session, Map<String, String> parameters) implements IServiceContext { |
13 | 14 | ||
@@ -17,13 +18,9 @@ record SimpleServiceContext(ISession session, Map<String, String> parameters) im | |||
17 | 18 | ||
18 | public static final String STATE_ID_PARAMETER = "requiredStateId"; | 19 | public static final String STATE_ID_PARAMETER = "requiredStateId"; |
19 | 20 | ||
20 | public SimpleServiceContext(ISession session, XtextWebSocketRequest request, String stateId, int index) { | ||
21 | this(session, addPerTransactionData(request, stateId, request.getRequestData().get(index))); | ||
22 | } | ||
23 | |||
24 | @Override | 21 | @Override |
25 | public Set<String> getParameterKeys() { | 22 | public Set<String> getParameterKeys() { |
26 | return parameters.keySet(); | 23 | return ImmutableSet.copyOf(parameters.keySet()); |
27 | } | 24 | } |
28 | 25 | ||
29 | @Override | 26 | @Override |
@@ -36,8 +33,9 @@ record SimpleServiceContext(ISession session, Map<String, String> parameters) im | |||
36 | return session; | 33 | return session; |
37 | } | 34 | } |
38 | 35 | ||
39 | private static Map<String, String> addPerTransactionData(XtextWebSocketRequest request, String stateId, | 36 | public static IServiceContext ofTransaction(ISession session, XtextWebSocketRequest request, String stateId, |
40 | Map<String, String> parameters) { | 37 | int index) { |
38 | var parameters = request.getRequestData().get(index); | ||
41 | checkParameters(parameters, RESOURCE_NAME_PARAMETER); | 39 | checkParameters(parameters, RESOURCE_NAME_PARAMETER); |
42 | checkParameters(parameters, CONTENT_TYPE_PARAMETER); | 40 | checkParameters(parameters, CONTENT_TYPE_PARAMETER); |
43 | checkParameters(parameters, STATE_ID_PARAMETER); | 41 | checkParameters(parameters, STATE_ID_PARAMETER); |
@@ -52,7 +50,8 @@ record SimpleServiceContext(ISession session, Map<String, String> parameters) im | |||
52 | if (stateId != null) { | 50 | if (stateId != null) { |
53 | builder.put(STATE_ID_PARAMETER, stateId); | 51 | builder.put(STATE_ID_PARAMETER, stateId); |
54 | } | 52 | } |
55 | return builder.build(); | 53 | var allParameters = builder.build(); |
54 | return new SimpleServiceContext(session, allParameters); | ||
56 | } | 55 | } |
57 | 56 | ||
58 | private static void checkParameters(Map<String, String> parameters, String perTransactionParameter) { | 57 | private static void checkParameters(Map<String, String> parameters, String perTransactionParameter) { |
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/servlet/SimpleSession.java index 691077d0..09c055a2 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import java.util.HashMap; | 3 | import java.util.HashMap; |
4 | import java.util.Map; | 4 | import java.util.Map; |
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/servlet/TransactionExecutor.java index 0849ccb7..08687097 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java | |||
@@ -1,17 +1,8 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import java.io.IOException; | 3 | import java.io.IOException; |
4 | import java.io.Reader; | ||
5 | 4 | ||
6 | import org.eclipse.emf.common.util.URI; | 5 | 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.OnWebSocketClose; | ||
11 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; | ||
12 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; | ||
13 | import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; | ||
14 | import org.eclipse.jetty.websocket.api.annotations.WebSocket; | ||
15 | import org.eclipse.xtext.resource.IResourceServiceProvider; | 6 | import org.eclipse.xtext.resource.IResourceServiceProvider; |
16 | import org.eclipse.xtext.web.server.IServiceResult; | 7 | import org.eclipse.xtext.web.server.IServiceResult; |
17 | import org.eclipse.xtext.web.server.ISession; | 8 | import org.eclipse.xtext.web.server.ISession; |
@@ -25,87 +16,35 @@ import org.eclipse.xtext.web.server.hover.HoverResult; | |||
25 | import org.eclipse.xtext.web.server.model.DocumentStateResult; | 16 | import org.eclipse.xtext.web.server.model.DocumentStateResult; |
26 | import org.eclipse.xtext.web.server.occurrences.OccurrencesResult; | 17 | import org.eclipse.xtext.web.server.occurrences.OccurrencesResult; |
27 | import org.eclipse.xtext.web.server.persistence.ResourceContentResult; | 18 | import org.eclipse.xtext.web.server.persistence.ResourceContentResult; |
28 | import org.slf4j.Logger; | ||
29 | import org.slf4j.LoggerFactory; | ||
30 | 19 | ||
31 | import com.google.common.base.Strings; | 20 | import com.google.common.base.Strings; |
32 | import com.google.gson.Gson; | ||
33 | import com.google.gson.JsonIOException; | ||
34 | import com.google.gson.JsonParseException; | ||
35 | import com.google.inject.Injector; | 21 | import com.google.inject.Injector; |
36 | 22 | ||
37 | @WebSocket | 23 | public class TransactionExecutor { |
38 | public class XtextWebSocket { | ||
39 | private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); | ||
40 | |||
41 | private final Gson gson = new Gson(); | ||
42 | |||
43 | private final ISession session; | 24 | private final ISession session; |
44 | 25 | ||
45 | private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; | 26 | private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; |
46 | 27 | ||
47 | public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { | 28 | public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { |
48 | this.session = session; | 29 | this.session = session; |
49 | this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; | 30 | this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; |
50 | } | 31 | } |
51 | 32 | ||
52 | @OnWebSocketConnect | 33 | public void handleRequest(XtextWebSocketRequest request, ResponseHandler handler) throws IOException { |
53 | public void onConnect(Session webSocketSession) { | ||
54 | LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); | ||
55 | } | ||
56 | |||
57 | @OnWebSocketClose | ||
58 | public void onClose(Session webSocketSession, int statusCode, String reason) { | ||
59 | if (statusCode == StatusCode.NORMAL) { | ||
60 | LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); | ||
61 | } else { | ||
62 | LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, | ||
63 | reason); | ||
64 | } | ||
65 | } | ||
66 | |||
67 | @OnWebSocketError | ||
68 | public void onError(Session webSocketSession, Throwable error) { | ||
69 | LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); | ||
70 | } | ||
71 | |||
72 | @OnWebSocketMessage | ||
73 | public void onMessage(Session webSocketSession, Reader reader) { | ||
74 | XtextWebSocketRequest request; | ||
75 | try { | ||
76 | request = gson.fromJson(reader, XtextWebSocketRequest.class); | ||
77 | } catch (JsonIOException e) { | ||
78 | LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); | ||
79 | if (webSocketSession.isOpen()) { | ||
80 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); | ||
81 | } | ||
82 | return; | ||
83 | } catch (JsonParseException e) { | ||
84 | LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e); | ||
85 | webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); | ||
86 | return; | ||
87 | } | ||
88 | var requestData = request.getRequestData(); | 34 | var requestData = request.getRequestData(); |
89 | if (requestData == null || requestData.isEmpty()) { | 35 | if (requestData == null || requestData.isEmpty()) { |
90 | // Nothing to do. | 36 | // Nothing to do. |
91 | return; | 37 | return; |
92 | } | 38 | } |
93 | int nCalls = requestData.size(); | 39 | int nCalls = requestData.size(); |
94 | try { | 40 | int lastCall = handleTransaction(request, handler); |
95 | int lastCall = handleTransaction(webSocketSession, request); | 41 | for (int index = lastCall + 1; index < nCalls; index++) { |
96 | for (int index = lastCall + 1; index < nCalls; index++) { | 42 | handler.onResponse( |
97 | sendReply(webSocketSession, | 43 | new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED)); |
98 | new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED)); | ||
99 | } | ||
100 | } catch (IOException e) { | ||
101 | LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); | ||
102 | if (webSocketSession.isOpen()) { | ||
103 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); | ||
104 | } | ||
105 | } | 44 | } |
106 | } | 45 | } |
107 | 46 | ||
108 | protected int handleTransaction(Session webSocketSession, XtextWebSocketRequest request) throws IOException { | 47 | protected int handleTransaction(XtextWebSocketRequest request, ResponseHandler handler) throws IOException { |
109 | var requestData = request.getRequestData(); | 48 | var requestData = request.getRequestData(); |
110 | var stateId = request.getRequiredStateId(); | 49 | var stateId = request.getRequiredStateId(); |
111 | int index = 0; | 50 | int index = 0; |
@@ -114,10 +53,10 @@ public class XtextWebSocket { | |||
114 | var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); | 53 | var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); |
115 | int nCalls = requestData.size(); | 54 | int nCalls = requestData.size(); |
116 | for (; index < nCalls; index++) { | 55 | for (; index < nCalls; index++) { |
117 | var serviceContext = new SimpleServiceContext(session, request, stateId, index); | 56 | var serviceContext = SimpleServiceContext.ofTransaction(session, request, stateId, index); |
118 | var service = serviceDispatcher.getService(serviceContext); | 57 | var service = serviceDispatcher.getService(serviceContext); |
119 | var serviceResult = service.getService().apply(); | 58 | var serviceResult = service.getService().apply(); |
120 | sendReply(webSocketSession, new XtextWebSocketOkResponse(request, index, serviceResult)); | 59 | handler.onResponse(new XtextWebSocketOkResponse(request, index, serviceResult)); |
121 | if (serviceResult instanceof ServiceConflictResult) { | 60 | if (serviceResult instanceof ServiceConflictResult) { |
122 | break; | 61 | break; |
123 | } | 62 | } |
@@ -127,25 +66,15 @@ public class XtextWebSocket { | |||
127 | } | 66 | } |
128 | } | 67 | } |
129 | } catch (InvalidRequestException e) { | 68 | } catch (InvalidRequestException e) { |
130 | sendReply(webSocketSession, | 69 | handler.onResponse( |
131 | new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e)); | 70 | new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e)); |
132 | } catch (RuntimeException e) { | 71 | } catch (RuntimeException e) { |
133 | sendReply(webSocketSession, | 72 | handler.onResponse( |
134 | new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e)); | 73 | new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e)); |
135 | } | 74 | } |
136 | return index; | 75 | return index; |
137 | } | 76 | } |
138 | 77 | ||
139 | protected void sendReply(Session webSocketSession, XtextWebSocketResponse response) throws IOException { | ||
140 | var responseString = gson.toJson(response); | ||
141 | webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() { | ||
142 | @Override | ||
143 | public void writeFailed(Throwable x) { | ||
144 | LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); | ||
145 | } | ||
146 | }); | ||
147 | } | ||
148 | |||
149 | /** | 78 | /** |
150 | * Get the injector to satisfy the request in the {@code serviceContext}. | 79 | * Get the injector to satisfy the request in the {@code serviceContext}. |
151 | * | 80 | * |
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/servlet/XtextStatusCode.java index 8ef60108..0cd229e8 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | public final class XtextStatusCode { | 3 | public final class XtextStatusCode { |
4 | public static final int INVALID_JSON = 4007; | 4 | public static final int INVALID_JSON = 4007; |
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..25f67545 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java | |||
@@ -0,0 +1,94 @@ | |||
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 | @WebSocket | ||
24 | public class XtextWebSocket { | ||
25 | private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); | ||
26 | |||
27 | private final Gson gson = new Gson(); | ||
28 | |||
29 | private final TransactionExecutor executor; | ||
30 | |||
31 | public XtextWebSocket(TransactionExecutor executor) { | ||
32 | this.executor = executor; | ||
33 | } | ||
34 | |||
35 | public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { | ||
36 | this(new TransactionExecutor(session, resourceServiceProviderRegistry)); | ||
37 | } | ||
38 | |||
39 | @OnWebSocketConnect | ||
40 | public void onConnect(Session webSocketSession) { | ||
41 | LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); | ||
42 | } | ||
43 | |||
44 | @OnWebSocketClose | ||
45 | public void onClose(Session webSocketSession, int statusCode, String reason) { | ||
46 | if (statusCode == StatusCode.NORMAL) { | ||
47 | LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); | ||
48 | } else { | ||
49 | LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, | ||
50 | reason); | ||
51 | } | ||
52 | } | ||
53 | |||
54 | @OnWebSocketError | ||
55 | public void onError(Session webSocketSession, Throwable error) { | ||
56 | LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); | ||
57 | } | ||
58 | |||
59 | @OnWebSocketMessage | ||
60 | public void onMessage(Session webSocketSession, Reader reader) { | ||
61 | XtextWebSocketRequest request; | ||
62 | try { | ||
63 | request = gson.fromJson(reader, XtextWebSocketRequest.class); | ||
64 | } catch (JsonIOException e) { | ||
65 | LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); | ||
66 | if (webSocketSession.isOpen()) { | ||
67 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); | ||
68 | } | ||
69 | return; | ||
70 | } catch (JsonParseException e) { | ||
71 | LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e); | ||
72 | webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); | ||
73 | return; | ||
74 | } | ||
75 | try { | ||
76 | executor.handleRequest(request, response -> sendResponse(webSocketSession, response)); | ||
77 | } catch (IOException e) { | ||
78 | LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); | ||
79 | if (webSocketSession.isOpen()) { | ||
80 | webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); | ||
81 | } | ||
82 | } | ||
83 | } | ||
84 | |||
85 | protected void sendResponse(Session webSocketSession, XtextWebSocketResponse response) throws IOException { | ||
86 | var responseString = gson.toJson(response); | ||
87 | webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() { | ||
88 | @Override | ||
89 | public void writeFailed(Throwable x) { | ||
90 | LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); | ||
91 | } | ||
92 | }); | ||
93 | } | ||
94 | } | ||
diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java index 5759f39e..66ea227f 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorKind.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import com.google.gson.annotations.SerializedName; | 3 | import com.google.gson.annotations.SerializedName; |
4 | 4 | ||
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/servlet/XtextWebSocketErrorResponse.java index 1d2cf08a..3be7df39 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import java.util.Objects; | 3 | import java.util.Objects; |
4 | 4 | ||
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/servlet/XtextWebSocketOkResponse.java index aa453544..0a841895 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import java.util.Objects; | 3 | import java.util.Objects; |
4 | 4 | ||
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/servlet/XtextWebSocketRequest.java index 8aee70a1..8be67bc1 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import java.util.List; | 3 | import java.util.List; |
4 | import java.util.Map; | 4 | import java.util.Map; |
@@ -19,6 +19,16 @@ public class XtextWebSocketRequest { | |||
19 | @SerializedName("request") | 19 | @SerializedName("request") |
20 | private List<Map<String, String>> requestData; | 20 | private List<Map<String, String>> requestData; |
21 | 21 | ||
22 | public XtextWebSocketRequest(String id, String resourceName, String contentType, String requiredStateId, | ||
23 | List<Map<String, String>> requestData) { | ||
24 | super(); | ||
25 | this.id = id; | ||
26 | this.resourceName = resourceName; | ||
27 | this.contentType = contentType; | ||
28 | this.requiredStateId = requiredStateId; | ||
29 | this.requestData = requestData; | ||
30 | } | ||
31 | |||
22 | public String getId() { | 32 | public String getId() { |
23 | return id; | 33 | return id; |
24 | } | 34 | } |
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/servlet/XtextWebSocketResponse.java index 9e15aa69..2e7cfbbb 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse { | 3 | public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse { |
4 | public String getId(); | 4 | public String getId(); |
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/servlet/XtextWebSocketServlet.java index 5769e9e7..6d4d2cad 100644 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java | |||
@@ -1,4 +1,4 @@ | |||
1 | package tools.refinery.language.web.xtext; | 1 | package tools.refinery.language.web.xtext.servlet; |
2 | 2 | ||
3 | import java.io.IOException; | 3 | import java.io.IOException; |
4 | import java.time.Duration; | 4 | import java.time.Duration; |
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java new file mode 100644 index 00000000..08230335 --- /dev/null +++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/AwaitTerminationExecutorServiceProvider.java | |||
@@ -0,0 +1,34 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import java.util.ArrayList; | ||
4 | import java.util.List; | ||
5 | import java.util.concurrent.ExecutorService; | ||
6 | import java.util.concurrent.TimeUnit; | ||
7 | |||
8 | import org.eclipse.xtext.ide.ExecutorServiceProvider; | ||
9 | |||
10 | import com.google.inject.Singleton; | ||
11 | |||
12 | @Singleton | ||
13 | public class AwaitTerminationExecutorServiceProvider extends ExecutorServiceProvider { | ||
14 | private List<ExecutorService> servicesToShutDown = new ArrayList<>(); | ||
15 | |||
16 | @Override | ||
17 | protected ExecutorService createInstance(String key) { | ||
18 | var instance = super.createInstance(key); | ||
19 | servicesToShutDown.add(instance); | ||
20 | return instance; | ||
21 | } | ||
22 | |||
23 | @Override | ||
24 | public void dispose() { | ||
25 | super.dispose(); | ||
26 | for (var executorService : servicesToShutDown) { | ||
27 | try { | ||
28 | executorService.awaitTermination(1, TimeUnit.SECONDS); | ||
29 | } catch (InterruptedException e) { | ||
30 | // Continue normally. | ||
31 | } | ||
32 | } | ||
33 | } | ||
34 | } | ||
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java new file mode 100644 index 00000000..3493c9eb --- /dev/null +++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/ProblemWebInjectorProvider.java | |||
@@ -0,0 +1,47 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import org.eclipse.xtext.ide.ExecutorServiceProvider; | ||
4 | import org.eclipse.xtext.util.DisposableRegistry; | ||
5 | import org.eclipse.xtext.util.Modules2; | ||
6 | |||
7 | import com.google.inject.Guice; | ||
8 | import com.google.inject.Injector; | ||
9 | |||
10 | import tools.refinery.language.ide.ProblemIdeModule; | ||
11 | import tools.refinery.language.tests.ProblemInjectorProvider; | ||
12 | import tools.refinery.language.web.ProblemWebModule; | ||
13 | import tools.refinery.language.web.ProblemWebSetup; | ||
14 | |||
15 | public class ProblemWebInjectorProvider extends ProblemInjectorProvider { | ||
16 | |||
17 | protected Injector internalCreateInjector() { | ||
18 | return new ProblemWebSetup() { | ||
19 | @Override | ||
20 | public Injector createInjector() { | ||
21 | return Guice.createInjector( | ||
22 | Modules2.mixin(createRuntimeModule(), new ProblemIdeModule(), createWebModule())); | ||
23 | } | ||
24 | }.createInjectorAndDoEMFRegistration(); | ||
25 | } | ||
26 | |||
27 | protected ProblemWebModule createWebModule() { | ||
28 | // Await termination of the executor service to avoid race conditions between | ||
29 | // between the tasks in the service and the {@link | ||
30 | // org.eclipse.xtext.testing.extensions.InjectionExtension}. | ||
31 | return new ProblemWebModule() { | ||
32 | @SuppressWarnings("unused") | ||
33 | public Class<? extends ExecutorServiceProvider> bindExecutorServiceProvider() { | ||
34 | return AwaitTerminationExecutorServiceProvider.class; | ||
35 | } | ||
36 | }; | ||
37 | } | ||
38 | |||
39 | @Override | ||
40 | public void restoreRegistry() { | ||
41 | // Also make sure to dispose any IDisposable instances (that may depend on the | ||
42 | // global state) created by Xtext before restoring the global state. | ||
43 | var disposableRegistry = getInjector().getInstance(DisposableRegistry.class); | ||
44 | disposableRegistry.dispose(); | ||
45 | super.restoreRegistry(); | ||
46 | } | ||
47 | } | ||
diff --git a/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java new file mode 100644 index 00000000..6ad82d7f --- /dev/null +++ b/language-web/src/test/java/tools/refinery/language/web/xtext/servlet/TransactionExecutorTest.java | |||
@@ -0,0 +1,151 @@ | |||
1 | package tools.refinery.language.web.xtext.servlet; | ||
2 | |||
3 | import static org.hamcrest.MatcherAssert.assertThat; | ||
4 | import static org.hamcrest.Matchers.equalTo; | ||
5 | import static org.hamcrest.Matchers.hasProperty; | ||
6 | import static org.hamcrest.Matchers.instanceOf; | ||
7 | import static org.junit.jupiter.api.Assertions.fail; | ||
8 | import static org.mockito.Mockito.mock; | ||
9 | import static org.mockito.Mockito.times; | ||
10 | import static org.mockito.Mockito.verify; | ||
11 | |||
12 | import java.io.IOException; | ||
13 | import java.util.List; | ||
14 | import java.util.Map; | ||
15 | import java.util.UUID; | ||
16 | |||
17 | import org.eclipse.xtext.resource.IResourceServiceProvider; | ||
18 | import org.eclipse.xtext.testing.InjectWith; | ||
19 | import org.eclipse.xtext.testing.extensions.InjectionExtension; | ||
20 | import org.eclipse.xtext.web.server.ServiceConflictResult; | ||
21 | import org.eclipse.xtext.web.server.model.DocumentStateResult; | ||
22 | import org.eclipse.xtext.web.server.validation.ValidationResult; | ||
23 | import org.hamcrest.Matcher; | ||
24 | import org.junit.jupiter.api.BeforeEach; | ||
25 | import org.junit.jupiter.api.Test; | ||
26 | import org.junit.jupiter.api.extension.ExtendWith; | ||
27 | import org.mockito.ArgumentCaptor; | ||
28 | import org.mockito.junit.jupiter.MockitoExtension; | ||
29 | |||
30 | import com.google.inject.Inject; | ||
31 | |||
32 | @ExtendWith(MockitoExtension.class) | ||
33 | @ExtendWith(InjectionExtension.class) | ||
34 | @InjectWith(ProblemWebInjectorProvider.class) | ||
35 | class TransactionExecutorTest { | ||
36 | private static final String RESOURCE_NAME = "test.problem"; | ||
37 | |||
38 | private static final String INVALID_STATE_ID = "<invalid_state>"; | ||
39 | |||
40 | private static final String TEST_PROBLEM = """ | ||
41 | class Person { | ||
42 | Person friend[0..*] opposite friend | ||
43 | } | ||
44 | |||
45 | friend(a, b). | ||
46 | """; | ||
47 | |||
48 | private static final Map<String, String> UPDATE_FULL_TEXT_PARAMS = Map.of("serviceType", "update", "fullText", | ||
49 | TEST_PROBLEM); | ||
50 | |||
51 | private static final Map<String, String> VALIDATE_PARAMS = Map.of("serviceType", "validate"); | ||
52 | |||
53 | @Inject | ||
54 | private IResourceServiceProvider.Registry resourceServiceProviderRegistry; | ||
55 | |||
56 | private TransactionExecutor transactionExecutor; | ||
57 | |||
58 | @BeforeEach | ||
59 | void beforeEach() { | ||
60 | transactionExecutor = new TransactionExecutor(new SimpleSession(), resourceServiceProviderRegistry); | ||
61 | } | ||
62 | |||
63 | @Test | ||
64 | void emptyBatchTest() { | ||
65 | performBatchRequest(null); | ||
66 | } | ||
67 | |||
68 | @Test | ||
69 | void fullTextUpdateTest() { | ||
70 | var response = performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); | ||
71 | assertThat(response, hasResponseData(instanceOf(DocumentStateResult.class))); | ||
72 | } | ||
73 | |||
74 | @Test | ||
75 | void validationAfterFullTextUpdateInSameBatchTest() { | ||
76 | var response = performBatchRequest(null, UPDATE_FULL_TEXT_PARAMS, VALIDATE_PARAMS).get(1); | ||
77 | assertThat(response, hasResponseData(instanceOf(ValidationResult.class))); | ||
78 | } | ||
79 | |||
80 | @Test | ||
81 | void validationAfterFullTextUpdateInDifferentBatchTest() { | ||
82 | var stateId = updateFullText(); | ||
83 | var validateResponse = performSingleRequest(stateId, VALIDATE_PARAMS); | ||
84 | assertThat(validateResponse, hasResponseData(instanceOf(ValidationResult.class))); | ||
85 | } | ||
86 | |||
87 | @Test | ||
88 | void conflictTest() { | ||
89 | updateFullText(); | ||
90 | var response = performSingleRequest(INVALID_STATE_ID, VALIDATE_PARAMS); | ||
91 | assertThat(response, hasResponseData(instanceOf(ServiceConflictResult.class))); | ||
92 | } | ||
93 | |||
94 | @Test | ||
95 | void transactionCancelledDueToConflictTest() { | ||
96 | updateFullText(); | ||
97 | var response = performBatchRequest(INVALID_STATE_ID, VALIDATE_PARAMS, VALIDATE_PARAMS).get(1); | ||
98 | assertThat(response, hasErrorKind(equalTo(XtextWebSocketErrorKind.TRANSACTION_CANCELLED))); | ||
99 | } | ||
100 | |||
101 | @SafeVarargs | ||
102 | private List<XtextWebSocketResponse> performBatchRequest(String requiredStateId, Map<String, String>... params) { | ||
103 | var id = UUID.randomUUID().toString(); | ||
104 | var request = new XtextWebSocketRequest(id, RESOURCE_NAME, null, requiredStateId, List.of(params)); | ||
105 | |||
106 | var responseHandler = mock(ResponseHandler.class); | ||
107 | try { | ||
108 | transactionExecutor.handleRequest(request, responseHandler); | ||
109 | } catch (IOException e) { | ||
110 | fail("Unexpected IOException", e); | ||
111 | } | ||
112 | |||
113 | var captor = ArgumentCaptor.forClass(XtextWebSocketResponse.class); | ||
114 | int nParams = params.length; | ||
115 | try { | ||
116 | verify(responseHandler, times(nParams)).onResponse(captor.capture()); | ||
117 | } catch (IOException e) { | ||
118 | throw new RuntimeException("Mockito threw unexcepted exception", e); | ||
119 | } | ||
120 | var allResponses = captor.getAllValues(); | ||
121 | for (int i = 0; i < nParams; i++) { | ||
122 | var response = allResponses.get(i); | ||
123 | assertThat(response, hasProperty("id", equalTo(id))); | ||
124 | assertThat(response, hasProperty("index", equalTo(i))); | ||
125 | } | ||
126 | return allResponses; | ||
127 | } | ||
128 | |||
129 | private XtextWebSocketResponse performSingleRequest(String requiredStateId, Map<String, String> param) { | ||
130 | return performBatchRequest(requiredStateId, param).get(0); | ||
131 | } | ||
132 | |||
133 | private String updateFullText() { | ||
134 | var updateResponse = (XtextWebSocketOkResponse) performSingleRequest(null, UPDATE_FULL_TEXT_PARAMS); | ||
135 | var documentStateResult = (DocumentStateResult) updateResponse.getResponseData(); | ||
136 | var stateId = documentStateResult.getStateId(); | ||
137 | if (INVALID_STATE_ID.equals(stateId)) { | ||
138 | throw new RuntimeException("Service returned unexpected stateId: " + stateId); | ||
139 | } | ||
140 | return stateId; | ||
141 | } | ||
142 | |||
143 | private static Matcher<XtextWebSocketResponse> hasResponseData(Matcher<?> responseDataMatcher) { | ||
144 | return hasProperty("responseData", responseDataMatcher); | ||
145 | } | ||
146 | |||
147 | private static Matcher<XtextWebSocketResponse> hasErrorKind( | ||
148 | Matcher<? extends XtextWebSocketErrorKind> errorKindMatcher) { | ||
149 | return hasProperty("errorKind", errorKindMatcher); | ||
150 | } | ||
151 | } | ||