diff --git a/doc/src/asciidoc/module_qrest.adoc b/doc/src/asciidoc/module_qrest.adoc
index beded03995..9305a533c4 100644
--- a/doc/src/asciidoc/module_qrest.adoc
+++ b/doc/src/asciidoc/module_qrest.adoc
@@ -80,6 +80,7 @@ specified in the `queue` property, but you can override those with a route like
<2> Likewise, `POST` starting with `/v2` will get queued to the `TXNMGR.2` too.
+
The TransactionManager is configured like this:
[source,xml]
@@ -382,3 +383,46 @@ Processing transaction ${id} <1>
<1> The 'id' property is also provided by the `DynamicContent` participant using the
transaction id.
+==== CORS configuration
+
+QRest supports CORS that can be configured like this:
+
+[source,xml]
+------------
+
+ ...
+ ...
+
+ max-age="600"
+ allow-null-origin="false"
+ allow-credentials="true">
+ http://jpos.org <2>
+ https://jpos.org
+ GET <3>
+ POST
+ PUT
+ REMOVE
+ Content-Type <4>
+ Authorization
+ consumer-id <5>
+
+
+ ...
+ ...
+
+
+------------
+
+<1> The optional `cors` element supports `max-age`, `allow-null-origin` and `allow-credentials` attributes.
+<2> One or more `origin` elements can be added. If no `origin` element is specified, we assum _any_ origin.
+<3> Multiple `allow-method` elements can be specified.
+<4> Multiple `expose-header` elements can be specified.
+<5> Multiple `request-header` elements can be specified.
+
+[NOTE]
+======
+CORS can be configured on a system-wide basis by not providing a `path` attribute.
+
+The last entry with no path is taken as the system's default.
+======
+
diff --git a/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml b/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
index 351982426c..dd8e6a69df 100644
--- a/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
+++ b/modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
@@ -25,7 +25,7 @@
-
+
diff --git a/modules/qrest/src/main/java/org/jpos/qrest/RestServer.java b/modules/qrest/src/main/java/org/jpos/qrest/RestServer.java
index ebfd5142df..4799f7afe7 100644
--- a/modules/qrest/src/main/java/org/jpos/qrest/RestServer.java
+++ b/modules/qrest/src/main/java/org/jpos/qrest/RestServer.java
@@ -32,6 +32,8 @@
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.*;
+import io.netty.handler.codec.http.cors.CorsConfig;
+import io.netty.handler.codec.http.cors.CorsConfigBuilder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.IdleStateHandler;
import org.jdom2.Element;
@@ -76,6 +78,8 @@ public class RestServer extends QBeanSupport implements Runnable, XmlConfigurabl
private int maxInitialLineLength;
private int maxChunkSize;
private boolean validateHeaders;
+ private Map corsConfig = new LinkedHashMap<>();
+ private CorsConfig defaultCorsConfig;
public static final int DEFAULT_MAX_CONTENT_LENGTH = 512*1024;
@@ -97,8 +101,9 @@ public void initChannel(SocketChannel ch) throws Exception {
if (enableTLS) {
ch.pipeline().addLast(new SslHandler(getSSLEngine(sslContext), true));
}
- ch.pipeline().addLast(new HttpServerCodec(maxInitialLineLength, maxHeaderSize, maxChunkSize, validateHeaders));
- ch.pipeline().addLast(new HttpObjectAggregator(maxContentLength));
+ ch.pipeline()
+ .addLast(new HttpServerCodec(maxInitialLineLength, maxHeaderSize, maxChunkSize, validateHeaders))
+ .addLast(new HttpObjectAggregator(maxContentLength));
ch.pipeline().addLast(new RestSession(RestServer.this));
}
})
@@ -163,6 +168,16 @@ public void queue (FullHttpRequest request, Context ctx) {
sp.out(getQueue(request), ctx, 60000L);
}
+ public CorsConfig getCorsConfig (FullHttpRequest request) {
+ return corsConfig
+ .entrySet()
+ .stream()
+ .filter(e -> request.uri().startsWith(e.getKey()))
+ .map(Map.Entry::getValue)
+ .findFirst()
+ .orElse(defaultCorsConfig);
+ }
+
@Override
public void setConfiguration (Configuration cfg) throws ConfigurationException {
super.setConfiguration(cfg);
@@ -186,6 +201,14 @@ public void setConfiguration (Configuration cfg) throws ConfigurationException {
@Override
public void setConfiguration(Element e) throws ConfigurationException {
try {
+ for (Element c : e.getChildren("cors")) {
+ String path = c.getAttributeValue("path");
+ CorsConfig cc = getCorsConfig(c);
+ if (path != null)
+ corsConfig.put (path, cc);
+ else
+ defaultCorsConfig = cc;
+ }
for (Element r : e.getChildren("route")) {
routes.computeIfAbsent(
r.getAttributeValue("method"),
@@ -288,4 +311,47 @@ private String getQueue(FullHttpRequest request) {
}
return cfg.get("queue");
}
+
+ private CorsConfig getCorsConfig(Element e) {
+ String[] origins = e.getChildren("origin")
+ .stream()
+ .map(Element::getTextTrim)
+ .toArray(String[]::new);
+
+ CorsConfigBuilder ccb = origins.length > 0 ?
+ CorsConfigBuilder.forOrigins(origins) :
+ CorsConfigBuilder.forAnyOrigin();
+
+ if ("true".equalsIgnoreCase(e.getAttributeValue("allow-null-origin", "false")))
+ ccb.allowNullOrigin();
+
+ ccb.exposeHeaders(
+ e.getChildren("expose-header")
+ .stream()
+ .map(Element::getTextTrim)
+ .toArray(String[]::new)
+ );
+ if ("true".equalsIgnoreCase(e.getAttributeValue("allow-credentials", "false")))
+ ccb.allowCredentials();
+
+ long maxAge = Long.parseLong(e.getAttributeValue("max-age", "0"));
+ if (maxAge > 0)
+ ccb.maxAge(maxAge);
+
+ ccb.allowedRequestMethods(
+ e.getChildren("allow-method")
+ .stream()
+ .map(Element::getTextTrim)
+ .map(HttpMethod::valueOf)
+ .toArray(HttpMethod[]::new)
+ );
+
+ ccb.allowedRequestHeaders(
+ e.getChildren("request-header")
+ .stream()
+ .map(Element::getTextTrim)
+ .toArray(String[]::new)
+ );
+ return ccb.build();
+ }
}
diff --git a/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java b/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java
index 102ebe7e82..23e10d37dd 100644
--- a/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java
+++ b/modules/qrest/src/main/java/org/jpos/qrest/RestSession.java
@@ -21,18 +21,26 @@
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.*;
+import io.netty.handler.codec.http.cors.CorsConfig;
+import io.netty.handler.codec.http.cors.CorsHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
+import io.netty.util.AttributeKey;
import io.netty.util.CharsetUtil;
import org.jpos.transaction.Context;
import org.jpos.util.LogEvent;
import org.jpos.util.Logger;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
import static io.netty.buffer.Unpooled.copiedBuffer;
public class RestSession extends ChannelInboundHandlerAdapter {
private RestServer server;
private String contentKey;
+ private AttributeKey httpVersion = AttributeKey.valueOf("httpVersion");
RestSession(RestServer server) {
this.server = server;
@@ -41,11 +49,20 @@ public class RestSession extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ch, Object msg) throws Exception {
- Context ctx = new Context();
if (msg instanceof FullHttpRequest) {
final FullHttpRequest request = (FullHttpRequest) msg;
+ if (request.method().equals(HttpMethod.OPTIONS)) {
+ CorsConfig corsConfig = server.getCorsConfig(request);
+ if (corsConfig != null) {
+ new CorsHandler(corsConfig).channelRead(ch, msg);
+ return;
+ }
+ }
+ Context ctx = new Context();
ctx.put(Constants.SESSION, ch);
ctx.put(Constants.REQUEST, request);
+ ch.channel().attr(httpVersion).set(request.protocolVersion());
+
if (contentKey != null)
ctx.put(contentKey, request.content().toString(CharsetUtil.UTF_8));
server.queue(request, ctx);
@@ -71,8 +88,11 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
}
}
Logger.log(evt);
+
+ HttpVersion version = ctx.channel().attr(httpVersion).get();
+
ctx.writeAndFlush(new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
+ version,
HttpResponseStatus.INTERNAL_SERVER_ERROR,
copiedBuffer(cause.getMessage().getBytes())
));
diff --git a/modules/qrest/src/main/java/org/jpos/qrest/SendResponse.java b/modules/qrest/src/main/java/org/jpos/qrest/SendResponse.java
index d4ef4c95d2..4009258019 100644
--- a/modules/qrest/src/main/java/org/jpos/qrest/SendResponse.java
+++ b/modules/qrest/src/main/java/org/jpos/qrest/SendResponse.java
@@ -21,6 +21,7 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.*;
import io.netty.util.ReferenceCountUtil;
@@ -31,7 +32,6 @@
import org.jpos.transaction.Context;
import java.io.Serializable;
-import java.util.Arrays;
import static io.netty.buffer.Unpooled.copiedBuffer;
import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
@@ -55,7 +55,7 @@ public void commit (long id, Serializable context) {
Context ctx = (Context) context;
ChannelHandlerContext ch = ctx.get(SESSION);
FullHttpRequest request = ctx.get(REQUEST);
- FullHttpResponse response = getResponse(ctx);
+ FullHttpResponse response = getResponse(ctx, request.protocolVersion());
sendResponse(ctx, ch, request, response);
}
@@ -64,7 +64,7 @@ public void abort (long id, Serializable context) {
Context ctx = (Context) context;
ChannelHandlerContext ch = ctx.get(SESSION);
FullHttpRequest request = ctx.get(REQUEST);
- FullHttpResponse response = getResponse(ctx);
+ FullHttpResponse response = getResponse(ctx, request.protocolVersion());
sendResponse(ctx, ch, request, response);
}
@@ -79,18 +79,19 @@ private void sendResponse (Context ctx, ChannelHandlerContext ch, FullHttpReques
headers.set(HttpHeaderNames.CONTENT_TYPE, contentType);
headers.set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
ChannelFuture cf = ch.writeAndFlush(response);
+
if (!keepAlive)
- ch.close();
+ cf.addListener(ChannelFutureListener.CLOSE);
} finally {
ReferenceCountUtil.release(request);
}
}
- private FullHttpResponse error (HttpResponseStatus rc) {
- return new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, rc);
+ private FullHttpResponse error (HttpResponseStatus rc, HttpVersion version) {
+ return new DefaultFullHttpResponse(version, rc);
}
- private FullHttpResponse getResponse (Context ctx) {
+ private FullHttpResponse getResponse (Context ctx, HttpVersion version) {
Object r = ctx.get(RESPONSE);
FullHttpResponse httpResponse;
@@ -111,7 +112,7 @@ private FullHttpResponse getResponse (Context ctx) {
isJson = true;
}
httpResponse = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
+ version,
response.status(),
copiedBuffer(responseBody));
@@ -122,14 +123,14 @@ private FullHttpResponse getResponse (Context ctx) {
httpHeaders.add("Access-Control-Allow-Origin", corsHeader);
} catch (JsonProcessingException e) {
ctx.log(e);
- httpResponse = error(HttpResponseStatus.INTERNAL_SERVER_ERROR);
+ httpResponse = error(HttpResponseStatus.INTERNAL_SERVER_ERROR, version);
}
} else {
Result result = ctx.getResult();
if (result.hasFailures()) {
- httpResponse = error(HttpResponseStatus.valueOf(result.failure().getIrc().irc()));
+ httpResponse = error(HttpResponseStatus.valueOf(result.failure().getIrc().irc()), version);
} else
- httpResponse = error(HttpResponseStatus.NOT_FOUND);
+ httpResponse = error(HttpResponseStatus.NOT_FOUND, version);
}
return httpResponse;
}
diff --git a/modules/qrest/src/test/java/org/jpos/qrest/RestTest.java b/modules/qrest/src/test/java/org/jpos/qrest/RestTest.java
index 8cc8a77625..5ef01495f4 100644
--- a/modules/qrest/src/test/java/org/jpos/qrest/RestTest.java
+++ b/modules/qrest/src/test/java/org/jpos/qrest/RestTest.java
@@ -23,6 +23,7 @@
import org.apache.http.entity.ContentType;
import org.jpos.q2.Q2;
import org.jpos.util.NameRegistrar;
+import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -42,6 +43,7 @@ public static void setUp() throws NameRegistrar.NotFoundException {
RestAssured.baseURI = BASE_URL;
RestAssured.useRelaxedHTTPSValidation();
RestAssured.requestSpecification = new RequestSpecBuilder().build().contentType(APPLICATION_JSON.toString());
+ System.setProperty("test.enabled", "true");
if (q2 == null) {
q2 = new Q2();
q2.start();
@@ -50,6 +52,12 @@ public static void setUp() throws NameRegistrar.NotFoundException {
}
}
+ @AfterAll
+ public static void tearDown() {
+ if (q2 != null)
+ q2.stop();
+ }
+
@Test
public void test404() {
given()