Skip to content

Commit

Permalink
Qute: add JsonEscaper
Browse files Browse the repository at this point in the history
  • Loading branch information
ogomezdi authored and geoand committed Nov 27, 2024
1 parent 4864b93 commit db83181
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 39 deletions.
15 changes: 14 additions & 1 deletion docs/src/main/asciidoc/qute-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,8 @@ First two elements: {#each myArray.take(2)}{it}{/each} <5>

==== Character Escapes

For HTML and XML templates the `'`, `"`, `<`, `>`, `&` characters are escaped by default if a template variant is set.
For HTML and XML templates the `'`, `"`, `<`, `>`, `&` characters are escaped by default if a corresponding template variant is set.
For JSON templates the `"`, `\` and the control characters (`U+0000` through `U+001F`) are escaped by default if a corresponding template variant is set.

NOTE: In Quarkus, a variant is set automatically for templates located in the `src/main/resources/templates`. By default, the `java.net.URLConnection#getFileNameMap()` is used to determine the content-type of a template file. The additional map of suffixes to content types can be set via `quarkus.qute.content-types`.

Expand All @@ -466,6 +467,7 @@ If you need to render the unescaped value:
1. Either use the `raw` or `safe` properties implemented as extension methods of the `java.lang.Object`,
2. Or wrap the `String` value in a `io.quarkus.qute.RawString`.

.HTML Example
[source,html]
----
<html>
Expand All @@ -478,6 +480,17 @@ If you need to render the unescaped value:

TIP: By default, a template with one of the following content types is escaped: `text/html`, `text/xml`, `application/xml` and `application/xhtml+xml`. However, it's possible to extend this list via the `quarkus.qute.escape-content-types` configuration property.

.JSON Example
[source,json]
----
{
"id": "{valueId.raw}", <1>
"name": "{valueName}" <2>
}
----
<1> `valueId` that resolves to `\nA12345` will be rendered as `\nA12345` that will result in an invalid JSON Object because of the new line inserted inside the string value for the attribute `id`.
<2> `valueName` that resolves to `\tExpressions \n Escapes` will be rendered as `\\tExpressions \\n Escapes`.

[[virtual_methods]]
==== Virtual Methods

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ public class EscapingTest {
.addAsResource(new StringAsset("{text} {other} {text.raw} {text.safe} {item.foo}"),
"templates/bar.txt")
.addAsResource(new StringAsset("{@java.lang.String text}{text} {text.raw} {text.safe}"),
"templates/validation.html"))
"templates/validation.html")
.addAsResource(new StringAsset("{ \"strVal\":\"{strVal}\", \"intVal\":{intVal} }"),
"templates/val.json"))
.overrideConfigKey("quarkus.qute.content-types.xhtml", "application/xhtml+xml")
.overrideConfigKey("quarkus.qute.suffixes", "qute.html,qute.txt,html,txt,xhtml");

Expand Down Expand Up @@ -58,6 +60,12 @@ public void testEscaper() {
item.data("item", new Item()).render());
}

@Test
public void testJsonEscaper() {
assertEquals("{ \"strVal\":\"\\t Foo \\u000b\", \"intVal\":42 }",
engine.getTemplate("val.json").data("strVal", "\t Foo \u000B").data("intVal", 42).render());
}

@Test
public void testValidation() {
assertEquals("&lt;div&gt; <div> <div>",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import io.quarkus.qute.EvalContext;
import io.quarkus.qute.Expression;
import io.quarkus.qute.HtmlEscaper;
import io.quarkus.qute.JsonEscaper;
import io.quarkus.qute.NamespaceResolver;
import io.quarkus.qute.ParserHook;
import io.quarkus.qute.Qute;
Expand Down Expand Up @@ -157,6 +158,9 @@ public EngineProducer(QuteContext context, QuteConfig config, QuteRuntimeConfig
// Escape some characters for HTML/XML templates
builder.addResultMapper(new HtmlEscaper(List.copyOf(config.escapeContentTypes)));

// Escape some characters for JSON templates
builder.addResultMapper(new JsonEscaper());

// Fallback reflection resolver
builder.addValueResolver(new ReflectionValueResolver());

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.quarkus.qute;

/**
* Makes it possible to replace chars from Basic Multilingual Plane (BMP).
*
* @see Character#isBmpCodePoint(int)
*/
abstract class CharReplacementResultMapper implements ResultMapper {

@Override
public String map(Object result, Expression expression) {
return escape(result.toString());
}

String escape(CharSequence value) {
if (value.length() == 0) {
return value.toString();
}
for (int i = 0; i < value.length(); i++) {
String replacement = replacementFor(value.charAt(i));
if (replacement != null) {
// In most cases we will not need to escape the value at all
return doEscape(value, i, new StringBuilder(value.subSequence(0, i)).append(replacement));
}
}
return value.toString();
}

private String doEscape(CharSequence value, int index, StringBuilder builder) {
int length = value.length();
while (++index < length) {
char c = value.charAt(index);
String replacement = replacementFor(c);
if (replacement != null) {
builder.append(replacement);
} else {
builder.append(c);
}
}
return builder.toString();
}

protected abstract String replacementFor(char c);

}
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package io.quarkus.qute;

import java.util.List;
import java.util.Objects;
import java.util.Optional;

import io.quarkus.qute.TemplateNode.Origin;

public class HtmlEscaper implements ResultMapper {
public class HtmlEscaper extends CharReplacementResultMapper {

private final List<String> escapedContentTypes;

Expand All @@ -26,25 +25,6 @@ public boolean appliesTo(Origin origin, Object result) {
return false;
}

@Override
public String map(Object result, Expression expression) {
return escape(result.toString());
}

String escape(CharSequence value) {
if (Objects.requireNonNull(value).length() == 0) {
return value.toString();
}
for (int i = 0; i < value.length(); i++) {
String replacement = replacementFor(value.charAt(i));
if (replacement != null) {
// In most cases we will not need to escape the value at all
return doEscape(value, i, new StringBuilder(value.subSequence(0, i)).append(replacement));
}
}
return value.toString();
}

private boolean requiresDefaultEscaping(Variant variant) {
String contentType = variant.getContentType();
if (contentType == null) {
Expand All @@ -58,21 +38,7 @@ private boolean requiresDefaultEscaping(Variant variant) {
return false;
}

private String doEscape(CharSequence value, int index, StringBuilder builder) {
int length = value.length();
while (++index < length) {
char c = value.charAt(index);
String replacement = replacementFor(c);
if (replacement != null) {
builder.append(replacement);
} else {
builder.append(c);
}
}
return builder.toString();
}

private String replacementFor(char c) {
protected String replacementFor(char c) {
switch (c) {
case '"':
return "&quot;";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.quarkus.qute;

import java.util.Optional;

import io.quarkus.qute.TemplateNode.Origin;

public class JsonEscaper extends CharReplacementResultMapper {

@Override
public boolean appliesTo(Origin origin, Object result) {
if (result instanceof RawString) {
return false;
}
Optional<Variant> variant = origin.getVariant();
if (variant.isPresent()) {
String contentType = variant.get().getContentType();
if (contentType != null) {
return contentType.startsWith(Variant.APPLICATION_JSON);
}
}
return false;
}

protected String replacementFor(char c) {
// All Unicode characters may be placed within the quotation marks,
// except for the characters that MUST be escaped: quotation mark,
// reverse solidus, and the control characters (U+0000 through U+001F).
// See also https://datatracker.ietf.org/doc/html/rfc8259#autoid-10
switch (c) {
case '"':
return "\\\"";
case '\\':
return "\\\\";
case '\r':
return "\\r";
case '\b':
return "\\b";
case '\n':
return "\\n";
case '\t':
return "\\t";
case '\f':
return "\\f";
case '/':
return "\\/";
default:
return c < 32 ? String.format("\\u%04x", (int) c) : null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ default boolean appliesTo(Origin origin, Object result) {

/**
*
* @param result
* @param result The result, never {@code null}
* @param expression The original expression
* @return the string value
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.quarkus.qute;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.util.Optional;

import org.junit.jupiter.api.Test;

import io.quarkus.qute.TemplateNode.Origin;

public class JsonEscaperTest {

@Test
public void testAppliesTo() {
JsonEscaper json = new JsonEscaper();
Origin jsonOrigin = new Origin() {

@Override
public Optional<Variant> getVariant() {
return Optional.of(Variant.forContentType(Variant.APPLICATION_JSON));
}

@Override
public String getTemplateId() {
return null;
}

@Override
public String getTemplateGeneratedId() {
return null;
}

@Override
public int getLineCharacterStart() {
return 0;
}

@Override
public int getLineCharacterEnd() {
return 0;
}

@Override
public int getLine() {
return 0;
}
};
assertFalse(json.appliesTo(jsonOrigin, new RawString("foo")));
assertTrue(json.appliesTo(jsonOrigin, "foo"));
}

@Test
public void testEscaping() throws IOException {
JsonEscaper json = new JsonEscaper();
assertEquals("Čolek 1", json.escape("Čolek 1"));
assertEquals("\\rČolek\\n", json.escape("\rČolek\n"));
assertEquals("\\tČolek", json.escape("\tČolek"));
assertEquals("\\\"tČolek", json.escape("\"tČolek"));
assertEquals("\\\\tČolek", json.escape("\\tČolek"));
assertEquals("\\\\u005C", json.escape("\\u005C"));
assertEquals("\\u000bČolek", json.escape("\u000BČolek"));
assertEquals("\\\\u000BČolek", json.escape("\\u000BČolek"));
// Control char - start of Header
assertEquals("\\u0001", json.escape("\u0001"));
}

}

0 comments on commit db83181

Please sign in to comment.