From ac09055140a5c30e73e9ada16986ef60c38c2138 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 19 Oct 2021 00:49:31 +0200 Subject: feat(web): batched xtext websocket prototype --- .../refinery/language/web/ProblemWebModule.java | 6 + .../language/web/ProblemWebSocketServlet.java | 2 +- .../refinery/language/web/ServerLauncher.java | 2 +- .../language/web/xtext/SimpleServiceContext.java | 64 ------- .../refinery/language/web/xtext/SimpleSession.java | 35 ---- .../language/web/xtext/XtextStatusCode.java | 9 - .../language/web/xtext/XtextWebSocket.java | 208 --------------------- .../web/xtext/XtextWebSocketErrorKind.java | 14 -- .../web/xtext/XtextWebSocketErrorResponse.java | 95 ---------- .../web/xtext/XtextWebSocketOkResponse.java | 83 -------- .../language/web/xtext/XtextWebSocketRequest.java | 87 --------- .../language/web/xtext/XtextWebSocketResponse.java | 11 -- .../language/web/xtext/XtextWebSocketServlet.java | 83 -------- ...oPrecomputedServicesXtextServiceDispatcher.java | 16 ++ .../web/xtext/servlet/ResponseHandler.java | 8 + .../web/xtext/servlet/SimpleServiceContext.java | 63 +++++++ .../language/web/xtext/servlet/SimpleSession.java | 35 ++++ .../web/xtext/servlet/TransactionExecutor.java | 137 ++++++++++++++ .../web/xtext/servlet/XtextStatusCode.java | 9 + .../language/web/xtext/servlet/XtextWebSocket.java | 94 ++++++++++ .../web/xtext/servlet/XtextWebSocketErrorKind.java | 14 ++ .../xtext/servlet/XtextWebSocketErrorResponse.java | 95 ++++++++++ .../xtext/servlet/XtextWebSocketOkResponse.java | 83 ++++++++ .../web/xtext/servlet/XtextWebSocketRequest.java | 97 ++++++++++ .../web/xtext/servlet/XtextWebSocketResponse.java | 11 ++ .../web/xtext/servlet/XtextWebSocketServlet.java | 83 ++++++++ 26 files changed, 753 insertions(+), 691 deletions(-) delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorKind.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/server/NoPrecomputedServicesXtextServiceDispatcher.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/ResponseHandler.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java (limited to 'language-web/src/main') 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 @@ */ package tools.refinery.language.web; +import org.eclipse.xtext.web.server.XtextServiceDispatcher; + +import tools.refinery.language.web.xtext.server.NoPrecomputedServicesXtextServiceDispatcher; /** * Use this class to register additional components to be used within the web application. */ public class ProblemWebModule extends AbstractProblemWebModule { + public Class bindXtextServiceDispatcher() { + return NoPrecomputedServicesXtextServiceDispatcher.class; + } } 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; import org.eclipse.xtext.util.DisposableRegistry; import jakarta.servlet.ServletException; -import tools.refinery.language.web.xtext.XtextWebSocketServlet; +import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; public class ProblemWebSocketServlet extends XtextWebSocketServlet { 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; import jakarta.servlet.DispatcherType; import jakarta.servlet.SessionTrackingMode; -import tools.refinery.language.web.xtext.XtextWebSocketServlet; +import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; public class ServerLauncher { public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; 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 deleted file mode 100644 index 8036b749..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java +++ /dev/null @@ -1,64 +0,0 @@ -package tools.refinery.language.web.xtext; - -import java.util.Map; -import java.util.Set; - -import org.eclipse.xtext.web.server.IServiceContext; -import org.eclipse.xtext.web.server.ISession; -import org.eclipse.xtext.web.server.InvalidRequestException; - -import com.google.common.collect.ImmutableMap; - -record SimpleServiceContext(ISession session, Map parameters) implements IServiceContext { - - public static final String RESOURCE_NAME_PARAMETER = "resource"; - - public static final String CONTENT_TYPE_PARAMETER = "contentType"; - - public static final String STATE_ID_PARAMETER = "requiredStateId"; - - public SimpleServiceContext(ISession session, XtextWebSocketRequest request, String stateId, int index) { - this(session, addPerTransactionData(request, stateId, request.getRequestData().get(index))); - } - - @Override - public Set getParameterKeys() { - return parameters.keySet(); - } - - @Override - public String getParameter(String key) { - return parameters.get(key); - } - - @Override - public ISession getSession() { - return session; - } - - private static Map addPerTransactionData(XtextWebSocketRequest request, String stateId, - Map parameters) { - checkParameters(parameters, RESOURCE_NAME_PARAMETER); - checkParameters(parameters, CONTENT_TYPE_PARAMETER); - checkParameters(parameters, STATE_ID_PARAMETER); - var builder = ImmutableMap.builder(); - builder.putAll(parameters); - if (request.getResourceName() != null) { - builder.put(RESOURCE_NAME_PARAMETER, request.getResourceName()); - } - if (request.getContentType() != null) { - builder.put(CONTENT_TYPE_PARAMETER, request.getContentType()); - } - if (stateId != null) { - builder.put(STATE_ID_PARAMETER, stateId); - } - return builder.build(); - } - - private static void checkParameters(Map parameters, String perTransactionParameter) { - if (parameters.containsKey(perTransactionParameter)) { - throw new InvalidRequestException.InvalidParametersException( - "Parameters map must not contain '" + perTransactionParameter + "' parameter."); - } - } -} 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 deleted file mode 100644 index 691077d0..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java +++ /dev/null @@ -1,35 +0,0 @@ -package tools.refinery.language.web.xtext; - -import java.util.HashMap; -import java.util.Map; - -import org.eclipse.xtext.web.server.ISession; -import org.eclipse.xtext.xbase.lib.Functions.Function0; - -public class SimpleSession implements ISession { - private Map map = new HashMap<>(); - - @Override - public T get(Object key) { - @SuppressWarnings("unchecked") - var value = (T) map.get(key); - return value; - } - - @Override - public T get(Object key, Function0 factory) { - @SuppressWarnings("unchecked") - var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply()); - return value; - } - - @Override - public void put(Object key, Object value) { - map.put(key, value); - } - - @Override - public void remove(Object key) { - map.remove(key); - } -} 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 deleted file mode 100644 index 8ef60108..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java +++ /dev/null @@ -1,9 +0,0 @@ -package tools.refinery.language.web.xtext; - -public final class XtextStatusCode { - public static final int INVALID_JSON = 4007; - - private XtextStatusCode() { - throw new IllegalStateException("This is a static utility class and should not be instantiated directly"); - } -} 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 deleted file mode 100644 index 0849ccb7..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java +++ /dev/null @@ -1,208 +0,0 @@ -package tools.refinery.language.web.xtext; - -import java.io.IOException; -import java.io.Reader; - -import org.eclipse.emf.common.util.URI; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.StatusCode; -import org.eclipse.jetty.websocket.api.WriteCallback; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; -import org.eclipse.jetty.websocket.api.annotations.WebSocket; -import org.eclipse.xtext.resource.IResourceServiceProvider; -import org.eclipse.xtext.web.server.IServiceResult; -import org.eclipse.xtext.web.server.ISession; -import org.eclipse.xtext.web.server.InvalidRequestException; -import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; -import org.eclipse.xtext.web.server.ServiceConflictResult; -import org.eclipse.xtext.web.server.XtextServiceDispatcher; -import org.eclipse.xtext.web.server.contentassist.ContentAssistResult; -import org.eclipse.xtext.web.server.formatting.FormattingResult; -import org.eclipse.xtext.web.server.hover.HoverResult; -import org.eclipse.xtext.web.server.model.DocumentStateResult; -import org.eclipse.xtext.web.server.occurrences.OccurrencesResult; -import org.eclipse.xtext.web.server.persistence.ResourceContentResult; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Strings; -import com.google.gson.Gson; -import com.google.gson.JsonIOException; -import com.google.gson.JsonParseException; -import com.google.inject.Injector; - -@WebSocket -public class XtextWebSocket { - private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); - - private final Gson gson = new Gson(); - - private final ISession session; - - private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; - - public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { - this.session = session; - this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; - } - - @OnWebSocketConnect - public void onConnect(Session webSocketSession) { - LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); - } - - @OnWebSocketClose - public void onClose(Session webSocketSession, int statusCode, String reason) { - if (statusCode == StatusCode.NORMAL) { - LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); - } else { - LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, - reason); - } - } - - @OnWebSocketError - public void onError(Session webSocketSession, Throwable error) { - LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); - } - - @OnWebSocketMessage - public void onMessage(Session webSocketSession, Reader reader) { - XtextWebSocketRequest request; - try { - request = gson.fromJson(reader, XtextWebSocketRequest.class); - } catch (JsonIOException e) { - LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); - if (webSocketSession.isOpen()) { - webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); - } - return; - } catch (JsonParseException e) { - LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e); - webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); - return; - } - var requestData = request.getRequestData(); - if (requestData == null || requestData.isEmpty()) { - // Nothing to do. - return; - } - int nCalls = requestData.size(); - try { - int lastCall = handleTransaction(webSocketSession, request); - for (int index = lastCall + 1; index < nCalls; index++) { - sendReply(webSocketSession, - new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED)); - } - } catch (IOException e) { - LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); - if (webSocketSession.isOpen()) { - webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); - } - } - } - - protected int handleTransaction(Session webSocketSession, XtextWebSocketRequest request) throws IOException { - var requestData = request.getRequestData(); - var stateId = request.getRequiredStateId(); - int index = 0; - try { - var injector = getInjector(request); - var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); - int nCalls = requestData.size(); - for (; index < nCalls; index++) { - var serviceContext = new SimpleServiceContext(session, request, stateId, index); - var service = serviceDispatcher.getService(serviceContext); - var serviceResult = service.getService().apply(); - sendReply(webSocketSession, new XtextWebSocketOkResponse(request, index, serviceResult)); - if (serviceResult instanceof ServiceConflictResult) { - break; - } - var nextStateId = getNextStateId(serviceResult); - if (nextStateId != null) { - stateId = nextStateId; - } - } - } catch (InvalidRequestException e) { - sendReply(webSocketSession, - new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e)); - } catch (RuntimeException e) { - sendReply(webSocketSession, - new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e)); - } - return index; - } - - protected void sendReply(Session webSocketSession, XtextWebSocketResponse response) throws IOException { - var responseString = gson.toJson(response); - webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() { - @Override - public void writeFailed(Throwable x) { - LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); - } - }); - } - - /** - * Get the injector to satisfy the request in the {@code serviceContext}. - * - * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. - * - * @param serviceContext the Xtext service context of the request - * @return the injector for the Xtext language in the request - * @throws UnknownLanguageException if the Xtext language cannot be determined - */ - protected Injector getInjector(XtextWebSocketRequest request) { - IResourceServiceProvider resourceServiceProvider = null; - var resourceName = request.getResourceName(); - if (resourceName == null) { - resourceName = ""; - } - var emfURI = URI.createURI(resourceName); - var contentType = request.getContentType(); - if (Strings.isNullOrEmpty(contentType)) { - resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); - if (resourceServiceProvider == null) { - if (emfURI.toString().isEmpty()) { - throw new UnknownLanguageException( - "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); - } else { - throw new UnknownLanguageException( - "Unable to identify the Xtext language for resource " + emfURI + "."); - } - } - } else { - resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); - if (resourceServiceProvider == null) { - throw new UnknownLanguageException( - "Unable to identify the Xtext language for contentType " + contentType + "."); - } - } - return resourceServiceProvider.get(Injector.class); - } - - protected String getNextStateId(IServiceResult serviceResult) { - if (serviceResult instanceof ContentAssistResult contentAssistResult) { - return contentAssistResult.getStateId(); - } - if (serviceResult instanceof DocumentStateResult documentStateResult) { - return documentStateResult.getStateId(); - } - if (serviceResult instanceof FormattingResult formattingResult) { - return formattingResult.getStateId(); - } - if (serviceResult instanceof HoverResult hoverResult) { - return hoverResult.getStateId(); - } - if (serviceResult instanceof OccurrencesResult occurrencesResult) { - return occurrencesResult.getStateId(); - } - if (serviceResult instanceof ResourceContentResult resourceContentResult) { - return resourceContentResult.getStateId(); - } - return null; - } -} 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/XtextWebSocketErrorKind.java deleted file mode 100644 index 5759f39e..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorKind.java +++ /dev/null @@ -1,14 +0,0 @@ -package tools.refinery.language.web.xtext; - -import com.google.gson.annotations.SerializedName; - -public enum XtextWebSocketErrorKind { - @SerializedName("request") - REQUEST_ERROR, - - @SerializedName("server") - SERVER_ERROR, - - @SerializedName("transaction") - TRANSACTION_CANCELLED, -} 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 deleted file mode 100644 index 1d2cf08a..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java +++ /dev/null @@ -1,95 +0,0 @@ -package tools.refinery.language.web.xtext; - -import java.util.Objects; - -import com.google.gson.annotations.SerializedName; - -public final class XtextWebSocketErrorResponse implements XtextWebSocketResponse { - private String id; - - private int index; - - @SerializedName("error") - private XtextWebSocketErrorKind errorKind; - - @SerializedName("message") - private String errorMessage; - - public XtextWebSocketErrorResponse(String id, int index, XtextWebSocketErrorKind errorKind, String errorMessage) { - super(); - this.id = id; - this.index = index; - this.errorKind = errorKind; - this.errorMessage = errorMessage; - } - - public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind, - String errorMessage) { - this(request.getId(), index, errorKind, errorMessage); - } - - public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind) { - this(request, index, errorKind, (String) null); - } - - public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind, - Throwable t) { - this(request, index, errorKind, t.getMessage()); - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public int getIndex() { - return index; - } - - public void setIndex(int index) { - this.index = index; - } - - public XtextWebSocketErrorKind getErrorKind() { - return errorKind; - } - - public void setErrorKind(XtextWebSocketErrorKind errorKind) { - this.errorKind = errorKind; - } - - public String getErrorMessage() { - return errorMessage; - } - - public void setErrorMessage(String errorMessage) { - this.errorMessage = errorMessage; - } - - @Override - public int hashCode() { - return Objects.hash(errorKind, errorMessage, id, index); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - XtextWebSocketErrorResponse other = (XtextWebSocketErrorResponse) obj; - return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage) - && Objects.equals(id, other.id) && index == other.index; - } - - @Override - public String toString() { - return "XtextWebSocketErrorResponse [id=" + id + ", index=" + index + ", errorKind=" + errorKind - + ", errorMessage=" + errorMessage + "]"; - } -} 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 deleted file mode 100644 index aa453544..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java +++ /dev/null @@ -1,83 +0,0 @@ -package tools.refinery.language.web.xtext; - -import java.util.Objects; - -import org.eclipse.xtext.web.server.IServiceResult; -import org.eclipse.xtext.web.server.IUnwrappableServiceResult; - -import com.google.gson.annotations.SerializedName; - -public final class XtextWebSocketOkResponse implements XtextWebSocketResponse { - private String id; - - private int index; - - @SerializedName("response") - private Object responseData; - - public XtextWebSocketOkResponse(String id, int index, Object responseData) { - super(); - this.id = id; - this.index = index; - this.responseData = responseData; - } - - public XtextWebSocketOkResponse(XtextWebSocketRequest request, int index, IServiceResult result) { - this(request.getId(), index, maybeUnwrap(result)); - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public int getIndex() { - return index; - } - - public void setIndex(int index) { - this.index = index; - } - - public Object getResponseData() { - return responseData; - } - - public void setResponseData(Object responseData) { - this.responseData = responseData; - } - - @Override - public int hashCode() { - return Objects.hash(id, index, responseData); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - XtextWebSocketOkResponse other = (XtextWebSocketOkResponse) obj; - return Objects.equals(id, other.id) && index == other.index && Objects.equals(responseData, other.responseData); - } - - @Override - public String toString() { - return "XtextWebSocketOkResponse [id=" + id + ", index=" + index + ", responseData=" + responseData + "]"; - } - - private static Object maybeUnwrap(IServiceResult result) { - if (result instanceof IUnwrappableServiceResult unwrappableServiceResult - && unwrappableServiceResult.getContent() != null) { - return unwrappableServiceResult.getContent(); - } else { - return result; - } - } -} 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 deleted file mode 100644 index 8aee70a1..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java +++ /dev/null @@ -1,87 +0,0 @@ -package tools.refinery.language.web.xtext; - -import java.util.List; -import java.util.Map; -import java.util.Objects; - -import com.google.gson.annotations.SerializedName; - -public class XtextWebSocketRequest { - private String id; - - @SerializedName("resource") - private String resourceName; - - private String contentType; - - private String requiredStateId; - - @SerializedName("request") - private List> requestData; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getResourceName() { - return resourceName; - } - - public void setResourceName(String resourceName) { - this.resourceName = resourceName; - } - - public String getContentType() { - return contentType; - } - - public void setContentType(String contentType) { - this.contentType = contentType; - } - - public String getRequiredStateId() { - return requiredStateId; - } - - public void setRequiredStateId(String requiredStateId) { - this.requiredStateId = requiredStateId; - } - - public List> getRequestData() { - return requestData; - } - - public void setRequestData(List> requestData) { - this.requestData = requestData; - } - - @Override - public int hashCode() { - return Objects.hash(contentType, id, requestData, requiredStateId, resourceName); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - XtextWebSocketRequest other = (XtextWebSocketRequest) obj; - return Objects.equals(contentType, other.contentType) && Objects.equals(id, other.id) - && Objects.equals(requestData, other.requestData) - && Objects.equals(requiredStateId, other.requiredStateId) - && Objects.equals(resourceName, other.resourceName); - } - - @Override - public String toString() { - return "XtextWebSocketRequest [id=" + id + ", resourceName=" + resourceName + ", contentType=" + contentType - + ", requiredStateId=" + requiredStateId + ", requestData=" + requestData + "]"; - } -} 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 deleted file mode 100644 index 9e15aa69..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package tools.refinery.language.web.xtext; - -public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse { - public String getId(); - - public void setId(String id); - - public int getIndex(); - - public void setIndex(int index); -} 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 deleted file mode 100644 index 5769e9e7..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java +++ /dev/null @@ -1,83 +0,0 @@ -package tools.refinery.language.web.xtext; - -import java.io.IOException; -import java.time.Duration; -import java.util.Set; - -import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest; -import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse; -import org.eclipse.jetty.websocket.server.JettyWebSocketCreator; -import org.eclipse.jetty.websocket.server.JettyWebSocketServlet; -import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory; -import org.eclipse.xtext.resource.IResourceServiceProvider; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import jakarta.servlet.ServletConfig; -import jakarta.servlet.ServletException; - -public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator { - - private static final long serialVersionUID = -3772740838165122685L; - - public static final String ALLOWED_ORIGINS_SEPARATOR = ";"; - - public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin"; - - public static final String XTEXT_SUBPROTOCOL_V1 = "tools.refinery.language.web.xtext.v1"; - - /** - * Maximum message size should be large enough to upload a full model file. - */ - private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; - - private static final Duration IDLE_TIMEOUT = Duration.ofMinutes(10); - - private transient Logger log = LoggerFactory.getLogger(getClass()); - - private transient Set allowedOrigins = null; - - @Override - public void init(ServletConfig config) throws ServletException { - var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM); - if (allowedOriginsStr == null) { - log.warn("All WebSocket origins are allowed! This setting should not be used in production!"); - } else { - allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR)); - log.info("Allowed origins: {}", allowedOrigins); - } - super.init(config); - } - - @Override - protected void configure(JettyWebSocketServletFactory factory) { - factory.setMaxFrameSize(MAX_FRAME_SIZE); - factory.setIdleTimeout(IDLE_TIMEOUT); - factory.addMapping("/", this); - } - - @Override - public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) { - if (allowedOrigins != null) { - var origin = req.getOrigin(); - if (origin == null || !allowedOrigins.contains(origin.toLowerCase())) { - log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin); - try { - resp.sendForbidden("Origin not allowed"); - } catch (IOException e) { - log.error("Cannot send forbidden origin error", e); - } - return null; - } - } - if (req.getSubProtocols().contains(XTEXT_SUBPROTOCOL_V1)) { - resp.setAcceptedSubProtocol(XTEXT_SUBPROTOCOL_V1); - } else { - log.error("None of the subprotocols {} offered by {} are supported", req.getSubProtocols(), - req.getRemoteSocketAddress()); - resp.setAcceptedSubProtocol(null); - } - var session = new SimpleSession(); - return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE); - } -} 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 @@ +package tools.refinery.language.web.xtext.server; + +import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.eclipse.xtext.web.server.model.PrecomputedServiceRegistry; + +import com.google.inject.Singleton; + +@Singleton +public class NoPrecomputedServicesXtextServiceDispatcher extends XtextServiceDispatcher { + @Override + protected void registerPreComputedServices(PrecomputedServiceRegistry registry) { + // Do not register any precomputed services, because we will always send + // requests for any pre-computation in the same websocket message as the + // document update request. + } +} 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 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.io.IOException; + +@FunctionalInterface +public interface ResponseHandler { + void onResponse(XtextWebSocketResponse response) throws IOException; +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java new file mode 100644 index 00000000..945d5db0 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleServiceContext.java @@ -0,0 +1,63 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.Map; +import java.util.Set; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.web.server.InvalidRequestException; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; + +record SimpleServiceContext(ISession session, Map parameters) implements IServiceContext { + + public static final String RESOURCE_NAME_PARAMETER = "resource"; + + public static final String CONTENT_TYPE_PARAMETER = "contentType"; + + public static final String STATE_ID_PARAMETER = "requiredStateId"; + + @Override + public Set getParameterKeys() { + return ImmutableSet.copyOf(parameters.keySet()); + } + + @Override + public String getParameter(String key) { + return parameters.get(key); + } + + @Override + public ISession getSession() { + return session; + } + + public static IServiceContext ofTransaction(ISession session, XtextWebSocketRequest request, String stateId, + int index) { + var parameters = request.getRequestData().get(index); + checkParameters(parameters, RESOURCE_NAME_PARAMETER); + checkParameters(parameters, CONTENT_TYPE_PARAMETER); + checkParameters(parameters, STATE_ID_PARAMETER); + var builder = ImmutableMap.builder(); + builder.putAll(parameters); + if (request.getResourceName() != null) { + builder.put(RESOURCE_NAME_PARAMETER, request.getResourceName()); + } + if (request.getContentType() != null) { + builder.put(CONTENT_TYPE_PARAMETER, request.getContentType()); + } + if (stateId != null) { + builder.put(STATE_ID_PARAMETER, stateId); + } + var allParameters = builder.build(); + return new SimpleServiceContext(session, allParameters); + } + + private static void checkParameters(Map parameters, String perTransactionParameter) { + if (parameters.containsKey(perTransactionParameter)) { + throw new InvalidRequestException.InvalidParametersException( + "Parameters map must not contain '" + perTransactionParameter + "' parameter."); + } + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java new file mode 100644 index 00000000..09c055a2 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/SimpleSession.java @@ -0,0 +1,35 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.xbase.lib.Functions.Function0; + +public class SimpleSession implements ISession { + private Map map = new HashMap<>(); + + @Override + public T get(Object key) { + @SuppressWarnings("unchecked") + var value = (T) map.get(key); + return value; + } + + @Override + public T get(Object key, Function0 factory) { + @SuppressWarnings("unchecked") + var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply()); + return value; + } + + @Override + public void put(Object key, Object value) { + map.put(key, value); + } + + @Override + public void remove(Object key) { + map.remove(key); + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java new file mode 100644 index 00000000..08687097 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/TransactionExecutor.java @@ -0,0 +1,137 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.io.IOException; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.web.server.InvalidRequestException; +import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; +import org.eclipse.xtext.web.server.ServiceConflictResult; +import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.eclipse.xtext.web.server.contentassist.ContentAssistResult; +import org.eclipse.xtext.web.server.formatting.FormattingResult; +import org.eclipse.xtext.web.server.hover.HoverResult; +import org.eclipse.xtext.web.server.model.DocumentStateResult; +import org.eclipse.xtext.web.server.occurrences.OccurrencesResult; +import org.eclipse.xtext.web.server.persistence.ResourceContentResult; + +import com.google.common.base.Strings; +import com.google.inject.Injector; + +public class TransactionExecutor { + private final ISession session; + + private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; + + public TransactionExecutor(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { + this.session = session; + this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; + } + + public void handleRequest(XtextWebSocketRequest request, ResponseHandler handler) throws IOException { + var requestData = request.getRequestData(); + if (requestData == null || requestData.isEmpty()) { + // Nothing to do. + return; + } + int nCalls = requestData.size(); + int lastCall = handleTransaction(request, handler); + for (int index = lastCall + 1; index < nCalls; index++) { + handler.onResponse( + new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.TRANSACTION_CANCELLED)); + } + } + + protected int handleTransaction(XtextWebSocketRequest request, ResponseHandler handler) throws IOException { + var requestData = request.getRequestData(); + var stateId = request.getRequiredStateId(); + int index = 0; + try { + var injector = getInjector(request); + var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); + int nCalls = requestData.size(); + for (; index < nCalls; index++) { + var serviceContext = SimpleServiceContext.ofTransaction(session, request, stateId, index); + var service = serviceDispatcher.getService(serviceContext); + var serviceResult = service.getService().apply(); + handler.onResponse(new XtextWebSocketOkResponse(request, index, serviceResult)); + if (serviceResult instanceof ServiceConflictResult) { + break; + } + var nextStateId = getNextStateId(serviceResult); + if (nextStateId != null) { + stateId = nextStateId; + } + } + } catch (InvalidRequestException e) { + handler.onResponse( + new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.REQUEST_ERROR, e)); + } catch (RuntimeException e) { + handler.onResponse( + new XtextWebSocketErrorResponse(request, index, XtextWebSocketErrorKind.SERVER_ERROR, e)); + } + return index; + } + + /** + * Get the injector to satisfy the request in the {@code serviceContext}. + * + * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. + * + * @param serviceContext the Xtext service context of the request + * @return the injector for the Xtext language in the request + * @throws UnknownLanguageException if the Xtext language cannot be determined + */ + protected Injector getInjector(XtextWebSocketRequest request) { + IResourceServiceProvider resourceServiceProvider = null; + var resourceName = request.getResourceName(); + if (resourceName == null) { + resourceName = ""; + } + var emfURI = URI.createURI(resourceName); + var contentType = request.getContentType(); + if (Strings.isNullOrEmpty(contentType)) { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); + if (resourceServiceProvider == null) { + if (emfURI.toString().isEmpty()) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); + } else { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for resource " + emfURI + "."); + } + } + } else { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); + if (resourceServiceProvider == null) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for contentType " + contentType + "."); + } + } + return resourceServiceProvider.get(Injector.class); + } + + protected String getNextStateId(IServiceResult serviceResult) { + if (serviceResult instanceof ContentAssistResult contentAssistResult) { + return contentAssistResult.getStateId(); + } + if (serviceResult instanceof DocumentStateResult documentStateResult) { + return documentStateResult.getStateId(); + } + if (serviceResult instanceof FormattingResult formattingResult) { + return formattingResult.getStateId(); + } + if (serviceResult instanceof HoverResult hoverResult) { + return hoverResult.getStateId(); + } + if (serviceResult instanceof OccurrencesResult occurrencesResult) { + return occurrencesResult.getStateId(); + } + if (serviceResult instanceof ResourceContentResult resourceContentResult) { + return resourceContentResult.getStateId(); + } + return null; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java new file mode 100644 index 00000000..0cd229e8 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextStatusCode.java @@ -0,0 +1,9 @@ +package tools.refinery.language.web.xtext.servlet; + +public final class XtextStatusCode { + public static final int INVALID_JSON = 4007; + + private XtextStatusCode() { + throw new IllegalStateException("This is a static utility class and should not be instantiated directly"); + } +} 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 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.io.IOException; +import java.io.Reader; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.web.server.ISession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonIOException; +import com.google.gson.JsonParseException; + +@WebSocket +public class XtextWebSocket { + private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); + + private final Gson gson = new Gson(); + + private final TransactionExecutor executor; + + public XtextWebSocket(TransactionExecutor executor) { + this.executor = executor; + } + + public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { + this(new TransactionExecutor(session, resourceServiceProviderRegistry)); + } + + @OnWebSocketConnect + public void onConnect(Session webSocketSession) { + LOG.debug("New websocket connection from {}", webSocketSession.getRemoteAddress()); + } + + @OnWebSocketClose + public void onClose(Session webSocketSession, int statusCode, String reason) { + if (statusCode == StatusCode.NORMAL) { + LOG.debug("{} closed connection normally: {}", webSocketSession.getRemoteAddress(), reason); + } else { + LOG.warn("{} closed connection with status code {}: {}", webSocketSession.getRemoteAddress(), statusCode, + reason); + } + } + + @OnWebSocketError + public void onError(Session webSocketSession, Throwable error) { + LOG.error("Internal websocket error in connection from" + webSocketSession.getRemoteAddress(), error); + } + + @OnWebSocketMessage + public void onMessage(Session webSocketSession, Reader reader) { + XtextWebSocketRequest request; + try { + request = gson.fromJson(reader, XtextWebSocketRequest.class); + } catch (JsonIOException e) { + LOG.error("Cannot read from websocket from" + webSocketSession.getRemoteAddress(), e); + if (webSocketSession.isOpen()) { + webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); + } + return; + } catch (JsonParseException e) { + LOG.warn("Malformed websocket request from" + webSocketSession.getRemoteAddress(), e); + webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); + return; + } + try { + executor.handleRequest(request, response -> sendResponse(webSocketSession, response)); + } catch (IOException e) { + LOG.warn("Cannot initiaite async write to websocket " + webSocketSession.getRemoteAddress(), e); + if (webSocketSession.isOpen()) { + webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); + } + } + } + + protected void sendResponse(Session webSocketSession, XtextWebSocketResponse response) throws IOException { + var responseString = gson.toJson(response); + webSocketSession.getRemote().sendPartialString(responseString, true, new WriteCallback() { + @Override + public void writeFailed(Throwable x) { + LOG.warn("Cannot complete async write to websocket " + webSocketSession.getRemoteAddress(), x); + } + }); + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java new file mode 100644 index 00000000..66ea227f --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorKind.java @@ -0,0 +1,14 @@ +package tools.refinery.language.web.xtext.servlet; + +import com.google.gson.annotations.SerializedName; + +public enum XtextWebSocketErrorKind { + @SerializedName("request") + REQUEST_ERROR, + + @SerializedName("server") + SERVER_ERROR, + + @SerializedName("transaction") + TRANSACTION_CANCELLED, +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java new file mode 100644 index 00000000..3be7df39 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketErrorResponse.java @@ -0,0 +1,95 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebSocketErrorResponse implements XtextWebSocketResponse { + private String id; + + private int index; + + @SerializedName("error") + private XtextWebSocketErrorKind errorKind; + + @SerializedName("message") + private String errorMessage; + + public XtextWebSocketErrorResponse(String id, int index, XtextWebSocketErrorKind errorKind, String errorMessage) { + super(); + this.id = id; + this.index = index; + this.errorKind = errorKind; + this.errorMessage = errorMessage; + } + + public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind, + String errorMessage) { + this(request.getId(), index, errorKind, errorMessage); + } + + public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind) { + this(request, index, errorKind, (String) null); + } + + public XtextWebSocketErrorResponse(XtextWebSocketRequest request, int index, XtextWebSocketErrorKind errorKind, + Throwable t) { + this(request, index, errorKind, t.getMessage()); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public int getIndex() { + return index; + } + + public void setIndex(int index) { + this.index = index; + } + + public XtextWebSocketErrorKind getErrorKind() { + return errorKind; + } + + public void setErrorKind(XtextWebSocketErrorKind errorKind) { + this.errorKind = errorKind; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public int hashCode() { + return Objects.hash(errorKind, errorMessage, id, index); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebSocketErrorResponse other = (XtextWebSocketErrorResponse) obj; + return errorKind == other.errorKind && Objects.equals(errorMessage, other.errorMessage) + && Objects.equals(id, other.id) && index == other.index; + } + + @Override + public String toString() { + return "XtextWebSocketErrorResponse [id=" + id + ", index=" + index + ", errorKind=" + errorKind + + ", errorMessage=" + errorMessage + "]"; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java new file mode 100644 index 00000000..0a841895 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketOkResponse.java @@ -0,0 +1,83 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.Objects; + +import org.eclipse.xtext.web.server.IServiceResult; +import org.eclipse.xtext.web.server.IUnwrappableServiceResult; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebSocketOkResponse implements XtextWebSocketResponse { + private String id; + + private int index; + + @SerializedName("response") + private Object responseData; + + public XtextWebSocketOkResponse(String id, int index, Object responseData) { + super(); + this.id = id; + this.index = index; + this.responseData = responseData; + } + + public XtextWebSocketOkResponse(XtextWebSocketRequest request, int index, IServiceResult result) { + this(request.getId(), index, maybeUnwrap(result)); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public int getIndex() { + return index; + } + + public void setIndex(int index) { + this.index = index; + } + + public Object getResponseData() { + return responseData; + } + + public void setResponseData(Object responseData) { + this.responseData = responseData; + } + + @Override + public int hashCode() { + return Objects.hash(id, index, responseData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebSocketOkResponse other = (XtextWebSocketOkResponse) obj; + return Objects.equals(id, other.id) && index == other.index && Objects.equals(responseData, other.responseData); + } + + @Override + public String toString() { + return "XtextWebSocketOkResponse [id=" + id + ", index=" + index + ", responseData=" + responseData + "]"; + } + + private static Object maybeUnwrap(IServiceResult result) { + if (result instanceof IUnwrappableServiceResult unwrappableServiceResult + && unwrappableServiceResult.getContent() != null) { + return unwrappableServiceResult.getContent(); + } else { + return result; + } + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java new file mode 100644 index 00000000..8be67bc1 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketRequest.java @@ -0,0 +1,97 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public class XtextWebSocketRequest { + private String id; + + @SerializedName("resource") + private String resourceName; + + private String contentType; + + private String requiredStateId; + + @SerializedName("request") + private List> requestData; + + public XtextWebSocketRequest(String id, String resourceName, String contentType, String requiredStateId, + List> requestData) { + super(); + this.id = id; + this.resourceName = resourceName; + this.contentType = contentType; + this.requiredStateId = requiredStateId; + this.requestData = requestData; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getResourceName() { + return resourceName; + } + + public void setResourceName(String resourceName) { + this.resourceName = resourceName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getRequiredStateId() { + return requiredStateId; + } + + public void setRequiredStateId(String requiredStateId) { + this.requiredStateId = requiredStateId; + } + + public List> getRequestData() { + return requestData; + } + + public void setRequestData(List> requestData) { + this.requestData = requestData; + } + + @Override + public int hashCode() { + return Objects.hash(contentType, id, requestData, requiredStateId, resourceName); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebSocketRequest other = (XtextWebSocketRequest) obj; + return Objects.equals(contentType, other.contentType) && Objects.equals(id, other.id) + && Objects.equals(requestData, other.requestData) + && Objects.equals(requiredStateId, other.requiredStateId) + && Objects.equals(resourceName, other.resourceName); + } + + @Override + public String toString() { + return "XtextWebSocketRequest [id=" + id + ", resourceName=" + resourceName + ", contentType=" + contentType + + ", requiredStateId=" + requiredStateId + ", requestData=" + requestData + "]"; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java new file mode 100644 index 00000000..2e7cfbbb --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketResponse.java @@ -0,0 +1,11 @@ +package tools.refinery.language.web.xtext.servlet; + +public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse,XtextWebSocketErrorResponse { + public String getId(); + + public void setId(String id); + + public int getIndex(); + + public void setIndex(int index); +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java new file mode 100644 index 00000000..6d4d2cad --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocketServlet.java @@ -0,0 +1,83 @@ +package tools.refinery.language.web.xtext.servlet; + +import java.io.IOException; +import java.time.Duration; +import java.util.Set; + +import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest; +import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse; +import org.eclipse.jetty.websocket.server.JettyWebSocketCreator; +import org.eclipse.jetty.websocket.server.JettyWebSocketServlet; +import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; + +public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator { + + private static final long serialVersionUID = -3772740838165122685L; + + public static final String ALLOWED_ORIGINS_SEPARATOR = ";"; + + public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin"; + + public static final String XTEXT_SUBPROTOCOL_V1 = "tools.refinery.language.web.xtext.v1"; + + /** + * Maximum message size should be large enough to upload a full model file. + */ + private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; + + private static final Duration IDLE_TIMEOUT = Duration.ofMinutes(10); + + private transient Logger log = LoggerFactory.getLogger(getClass()); + + private transient Set allowedOrigins = null; + + @Override + public void init(ServletConfig config) throws ServletException { + var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM); + if (allowedOriginsStr == null) { + log.warn("All WebSocket origins are allowed! This setting should not be used in production!"); + } else { + allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR)); + log.info("Allowed origins: {}", allowedOrigins); + } + super.init(config); + } + + @Override + protected void configure(JettyWebSocketServletFactory factory) { + factory.setMaxFrameSize(MAX_FRAME_SIZE); + factory.setIdleTimeout(IDLE_TIMEOUT); + factory.addMapping("/", this); + } + + @Override + public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) { + if (allowedOrigins != null) { + var origin = req.getOrigin(); + if (origin == null || !allowedOrigins.contains(origin.toLowerCase())) { + log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin); + try { + resp.sendForbidden("Origin not allowed"); + } catch (IOException e) { + log.error("Cannot send forbidden origin error", e); + } + return null; + } + } + if (req.getSubProtocols().contains(XTEXT_SUBPROTOCOL_V1)) { + resp.setAcceptedSubProtocol(XTEXT_SUBPROTOCOL_V1); + } else { + log.error("None of the subprotocols {} offered by {} are supported", req.getSubProtocols(), + req.getRemoteSocketAddress()); + resp.setAcceptedSubProtocol(null); + } + var session = new SimpleSession(); + return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE); + } +} -- cgit v1.2.3-70-g09d2