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()