Skip to content

Commit

Permalink
Add QRest Static and Dynamic content participants
Browse files Browse the repository at this point in the history
  • Loading branch information
ar committed Jul 22, 2019
1 parent 94e4238 commit 80090d5
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 1 deletion.
2 changes: 1 addition & 1 deletion doc/src/asciidoc/book.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ jPOS Extended Edition
=====================
:author: Alejandro Revilla
:email: apr@jpos.org
:jposee_version: 2.2.6-SNAPSHOT
:jposee_version: 2.2.7-SNAPSHOT
:revdate: {localdate}
:revnumber: {jposee_version}
:toc:
Expand Down
81 changes: 81 additions & 0 deletions doc/src/asciidoc/module_qrest.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -300,4 +300,85 @@ Here is a copy of the internal Q2Info route configuration:

If we just call `/q2`, it will output them all.

==== Static and Dynamic HTML content

QRest is by no means a full fledged web server, but it can still serve static and
dynamic HTML pages using the `StaticContent` and `DynamicContent` participants.

Our qrest TXNMGR configuration can include static content like this:

[source,xml]
------------
<participant class="org.jpos.qrest.participant.Router">
<route path="/welcome.html" method="GET" name="welcome" />
</participant>
<group name="welcome">
<participant class="org.jpos.qrest.participant.StaticContent">
<property name="documentRoot" value="html" />
<property name="content" value="welcome.html" />
</participant>
</group>
------------

So a call to `http://localhost:8080/welcome.html` will land in group named `welcome` that
will serve the file `html/welcome.html`. If instead of hitting `welcome.html` the user
tries anything else, it will fail with a 404 error.

In order to serve _any_ file inside the `documentRoot`, one can omit the property
`content`, i.e.:

[source,xml]
------------
<participant class="org.jpos.qrest.participant.Router">
<route path="/static/**" method="GET" name="static" />
</participant>
<group name="static">
<participant class="org.jpos.qrest.participant.StaticContent">
<property name="documentRoot" value="static" />
</participant>
</group>
------------

In this case, any file in the `static` directory will be served, if present.

In addition to static files, QRest can render dynamic content using Freemarker.

The configuration looks like this:

[source,xml]
------------
<participant class="org.jpos.qrest.participant.Router">
<route path="/dynamic" method="GET" name="dynamic" />
</participant>
<group name="dynamic">
<participant class="org.jpos.qrest.participant.DynamicContent">
<property name="documentRoot" value="templates" />
<property name="content" value="dynamic.html" /> <1>
<property name="page.ctx.include" value="_include.html" />
<property name="page.ctx.myprop" value="myvalue" />
</participant>
</group>
------------
<1> For security, the template file has to be specified.

The `DynamicContent` class uses a special qrest Constant `RENDER_CONTEXT` with a
map to be passed to the Freemarker template engine. Properties starting with the
prefix `page.ctx.` will be processed at participant initialization time and
handed to the template engine at process time. In this example, a property called
`include` and `myprop` will be available to the template engine, and can be used to write
a template like this:

[source,html]
-------------
<h1> Dynamic Content </h1>
Processing transaction ${id} <1>
<#include include>
-------------
<1> The 'id' property is also provided by the `DynamicContent` participant using the
transaction id.

1 change: 1 addition & 0 deletions modules/qrest/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dependencies {
compile libraries.jacksonDatabind
compile libraries.nettyHandler
compile libraries.nettyCodecHttp
compile libraries.freemarker
compile jsonSchemaValidatorLibs
testCompile libraries.junit
testCompile libraries.restAssured
Expand Down
29 changes: 29 additions & 0 deletions modules/qrest/src/dist/deploy/30_qrest_txnmgr.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,40 @@
<participant class="org.jpos.qrest.participant.Router">
<route path="/q2**" method="GET" name="q2"/>
<route path="/q2**" method="POST" name="q2"/>
<route path="/welcome.html" method="GET" name="welcome" />
<route path="/dynamic" method="GET" name="dynamic" />
<route path="/" method="GET" name="index" />
<route path="/**" method="GET" name="static" />
</participant>

<group name="q2">
<participant class="org.jpos.qrest.participant.Q2Info" />
</group>
<group name="welcome">
<participant class="org.jpos.qrest.participant.StaticContent">
<property name="documentRoot" value="html" />
<property name="content" value="welcome.html" />
</participant>
</group>
<group name="index">
<participant class="org.jpos.qrest.participant.StaticContent">
<property name="documentRoot" value="html" />
<property name="content" value="index.html" />
</participant>
</group>
<group name="static">
<participant class="org.jpos.qrest.participant.StaticContent">
<property name="documentRoot" value="html" />
</participant>
</group>
<group name="dynamic">
<participant class="org.jpos.qrest.participant.DynamicContent">
<property name="documentRoot" value="templates" />
<property name="content" value="dynamic.html" />
<property name="page.ctx.include" value="_include.html" />
</participant>
</group>

<participant class="org.jpos.qrest.SendResponse" logger="Q2">
<!--<property name="content-type" value="application/json" />-->
</participant>
Expand Down
1 change: 1 addition & 0 deletions modules/qrest/src/main/java/org/jpos/qrest/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ public enum Constants {
QUERYPARAMS,
PATHPARAMS,
RESPONSE,
RENDER_CONTEXT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* jPOS Project [http://jpos.org]
* Copyright (C) 2000-2019 jPOS Software SRL
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package org.jpos.qrest.participant;

import freemarker.cache.FileTemplateLoader;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import org.jpos.core.Configuration;
import org.jpos.core.ConfigurationException;
import org.jpos.transaction.Context;

import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

import static freemarker.template.Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS;
import static org.jpos.qrest.Constants.RENDER_CONTEXT;

public class DynamicContent extends StaticContent {
private freemarker.template.Configuration fcfg;
private Map<String,String> pageContext = new LinkedHashMap<>();

@Override
public int prepare(long id, Serializable context) {
Context ctx = (Context) context;
Map<String,Object> rctx = getRenderContext(ctx);
rctx.put ("id", id);
rctx.putAll(pageContext);
return super.prepare(id, context);
}

@Override
public void setConfiguration(Configuration cfg) throws ConfigurationException {
super.setConfiguration(cfg);
fcfg = new freemarker.template.Configuration(DEFAULT_INCOMPATIBLE_IMPROVEMENTS);
try {
fcfg.setDirectoryForTemplateLoading(documentRoot);
fcfg.setTemplateLoader(new FileTemplateLoader(documentRoot));
} catch (IOException e) {
throw new ConfigurationException (e);
}
cfg.keySet()
.stream()
.filter (s -> s.startsWith("page.ctx"))
.forEach(s -> pageContext.put(s.substring(9), cfg.get(s)));
}

@Override
protected ByteBuf toByteBuf (Context ctx, File f) throws IOException {
Template template = fcfg.getTemplate(f.getName());
StringWriter sw=new StringWriter();
try {
template.process(getRenderContext(ctx),sw);
return Unpooled.wrappedBuffer(sw.toString().getBytes());
} catch (TemplateException e) {
throw new IOException (e);
}
}

private Map<String,Object> getRenderContext (Context ctx) {
Map<String,Object> rctx = ctx.get(RENDER_CONTEXT);
if (rctx == null) {
rctx = new HashMap<>();
ctx.put (RENDER_CONTEXT, rctx);
}
return rctx;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* jPOS Project [http://jpos.org]
* Copyright (C) 2000-2019 jPOS Software SRL
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package org.jpos.qrest.participant;

import java.io.*;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.*;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.handler.codec.http.*;
import org.jpos.core.Configurable;
import org.jpos.core.Configuration;
import org.jpos.core.ConfigurationException;
import org.jpos.transaction.Context;
import org.jpos.transaction.TransactionParticipant;

import javax.activation.MimetypesFileTypeMap;

import static io.netty.handler.codec.http.HttpHeaderNames.*;
import static io.netty.handler.codec.http.HttpResponseStatus.OK;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;
import static org.jpos.qrest.Constants.REQUEST;
import static org.jpos.qrest.Constants.RESPONSE;

public class StaticContent implements TransactionParticipant, Configurable {
protected File content;
protected File documentRoot;
private MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap();
private SimpleDateFormat dateFormatter = new SimpleDateFormat(HTTP_DATE_FORMAT, Locale.US);

private static final String HTTP_DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
private static final String HTTP_DATE_GMT_TIMEZONE = "GMT";
private static final int HTTP_CACHE_SECONDS = 60;

public StaticContent() {
dateFormatter.setTimeZone(TimeZone.getTimeZone(HTTP_DATE_GMT_TIMEZONE));
}

@Override
public int prepare(long id, Serializable context) {
Context ctx = (Context) context;
FullHttpRequest request = ctx.get(REQUEST);
QueryStringDecoder decoder = new QueryStringDecoder(request.uri());
String path = URI.create(decoder.uri()).getPath();

try {
File bodyFile = content != null ? content : jailedFile(documentRoot.getCanonicalFile(), path);
if (!bodyFile.canRead() || !bodyFile.isFile())
throw new IOException ("Unable to read '" + bodyFile + "'");
ByteBuf body = toByteBuf(ctx, bodyFile);
HttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK, body);
setHeaders(response, bodyFile);
ctx.put(RESPONSE, response);
}
catch (AccessDeniedException e) {
ctx.log(e.getMessage());
ctx.put(RESPONSE, new DefaultFullHttpResponse(HTTP_1_1, HttpResponseStatus.FORBIDDEN));
}
catch (IOException e) {
ctx.log(e.getMessage());
ctx.put(RESPONSE, new DefaultFullHttpResponse(HTTP_1_1, HttpResponseStatus.NOT_FOUND));
}
return PREPARED | NO_JOIN | READONLY;
}


@Override
public void setConfiguration(Configuration cfg) throws ConfigurationException {
this.documentRoot = toFile(cfg.get("documentRoot", null));
this.content = toFile(documentRoot, cfg.get("content", null));

if (documentRoot == null)
throw new ConfigurationException ("no documentRoot");
}

protected File toFile (String s) throws ConfigurationException {
File f = null;
if (s != null) {
f = new File(s);
if (!f.canRead())
throw new ConfigurationException ("Can't access '" + f.toString() + "'");
}
return f;
}
protected File toFile (File parent, String s) throws ConfigurationException {
File f = null;
if (s != null) {
f = new File(parent, s);
if (!f.canRead())
throw new ConfigurationException ("Can't access '" + f.toString() + "'");
}
return f;
}

protected ByteBuf toByteBuf (Context ctx, File f) throws IOException {
return Unpooled.wrappedBuffer(Files.readAllBytes(f.toPath()));
}

private void setHeaders(HttpResponse response, File f) {
Calendar time = new GregorianCalendar();
time.add(Calendar.SECOND, HTTP_CACHE_SECONDS);
response.headers().set(DATE, dateFormatter.format(time.getTime()));
response.headers().set(EXPIRES, dateFormatter.format(time.getTime()));
response.headers().set(CACHE_CONTROL, "private, max-age=" + HTTP_CACHE_SECONDS);
response.headers().set(LAST_MODIFIED, dateFormatter.format(f.lastModified()));
response.headers().set(CONTENT_TYPE, mimeTypesMap.getContentType(f.getPath()));
}

private File jailedFile (File parent, String path) throws IOException {
File f = new File(parent, path);
if (!isInTree(parent, f))
throw new AccessDeniedException("Invalid path '" + f.getCanonicalPath() + " not child of " + parent);
return f;

}

private boolean isInTree (File parent, File f) throws IOException {
f = f.getCanonicalFile();
if (f.getParentFile() == null)
return false;
else if (f.getCanonicalFile().getParentFile().equals(parent))
return true;
else
return isInTree (parent, f.getParentFile());
}
}

0 comments on commit 80090d5

Please # to comment.