Skip to content

Commit

Permalink
Merge pull request #269 from jpos/feature/qrest-cors
Browse files Browse the repository at this point in the history
Feature/qrest cors
  • Loading branch information
ar authored Nov 24, 2022
2 parents 060eaac + ce94c57 commit 958fe41
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 16 deletions.
44 changes: 44 additions & 0 deletions doc/src/asciidoc/module_qrest.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
------------
<qrest class='org.jpos.qrest.RestServer' logger='Q2'>
...
...
<cors path="/api/abc" <1>
max-age="600"
allow-null-origin="false"
allow-credentials="true">
<origin>http://jpos.org</origin> <2>
<origin>https://jpos.org</origin>
<allow-method>GET</allow-method> <3>
<allow-method>POST</allow-method>
<allow-method>PUT</allow-method>
<allow-method>REMOVE</allow-method>
<expose-header>Content-Type</expose-header> <4>
<expose-header>Authorization</expose-header>
<request-header>consumer-id</request-header> <5>
</cors>
<cors path="/api/xyz" ...>
...
...
</cors>
</qrest>
------------

<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.
======

2 changes: 1 addition & 1 deletion modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
</group>
<group name="upload_file">
<participant class="org.jpos.qrest.ExtractFile" />
<participant class="org.jpos.qrest.test.participant.DumpFile" />
<participant class="org.jpos.qrest.test.participant.DumpFile" enabled="${test.enabled:false}" />
</group>
<group name="index">
<participant class="org.jpos.qrest.participant.StaticContent">
Expand Down
70 changes: 68 additions & 2 deletions modules/qrest/src/main/java/org/jpos/qrest/RestServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +78,8 @@ public class RestServer extends QBeanSupport implements Runnable, XmlConfigurabl
private int maxInitialLineLength;
private int maxChunkSize;
private boolean validateHeaders;
private Map<String,CorsConfig> corsConfig = new LinkedHashMap<>();
private CorsConfig defaultCorsConfig;

public static final int DEFAULT_MAX_CONTENT_LENGTH = 512*1024;

Expand All @@ -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));
}
})
Expand Down Expand Up @@ -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);
Expand All @@ -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"),
Expand Down Expand Up @@ -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();
}
}
24 changes: 22 additions & 2 deletions modules/qrest/src/main/java/org/jpos/qrest/RestSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -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> httpVersion = AttributeKey.valueOf("httpVersion");

RestSession(RestServer server) {
this.server = server;
Expand All @@ -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);
Expand All @@ -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())
));
Expand Down
23 changes: 12 additions & 11 deletions modules/qrest/src/main/java/org/jpos/qrest/SendResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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;

Expand All @@ -111,7 +112,7 @@ private FullHttpResponse getResponse (Context ctx) {
isJson = true;
}
httpResponse = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
version,
response.status(),
copiedBuffer(responseBody));

Expand All @@ -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;
}
Expand Down
8 changes: 8 additions & 0 deletions modules/qrest/src/test/java/org/jpos/qrest/RestTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
Expand All @@ -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()
Expand Down

0 comments on commit 958fe41

Please sign in to comment.