Avoid problems caused by | characters in table cell content

Previously, a | character in a cell of an Asciidoctor table would
break that table's formatting as it would be interpretted as a
delimiter for the cell. The problem also affects Markdown tables where
the | character is not within backticks.

This commit addresses the problem with Asciidoctor tables by
introducing a JMustache Lambda that escapes | characters by prefixing
them with a backslash.

Unfortunately, a complete solution to the problem when using Markdown
is not as straightforward. Markdown only requires a | character to be
espaced when it is not within backticks. Determing this isn't
straightforward as the text would have to be parsed, taking into
account the possibility of backticks themselves being escaped.
Instead, this commit attempts to avoid the problem in a different way.

The most likely source of a | character is when documenting a
`@Pattern` constraint that uses a | character in its regular
expression. To improve the readability of the regular expression this
commit wraps it in backticks, thereby formatting it in a monospaced
font in both Asciidoctor and Markdown and also avoiding any escaping
problems with the latter.

Closes gh-232
See gh-230
This commit is contained in:
Andy Wilkinson
2016-05-24 10:50:32 +01:00
parent 65e55ac15c
commit 404b99b0a4
27 changed files with 393 additions and 29 deletions

View File

@@ -36,7 +36,9 @@ import org.springframework.restdocs.snippet.StandardWriterResolver;
import org.springframework.restdocs.snippet.WriterResolver;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.mustache.AsciidoctorTableCellContentLambda;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
import org.springframework.test.util.ReflectionTestUtils;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.instanceOf;
@@ -164,6 +166,34 @@ public class RestDocumentationConfigurerTests {
is(equalTo(TemplateFormats.markdown())));
}
@SuppressWarnings("unchecked")
@Test
public void asciidoctorTableCellContentLambaIsInstalledWhenUsingAsciidoctorTemplateFormat() {
Map<String, Object> configuration = new HashMap<>();
this.configurer.apply(configuration, createContext());
TemplateEngine templateEngine = (TemplateEngine) configuration
.get(TemplateEngine.class.getName());
MustacheTemplateEngine mustacheTemplateEngine = (MustacheTemplateEngine) templateEngine;
Map<String, Object> templateContext = (Map<String, Object>) ReflectionTestUtils
.getField(mustacheTemplateEngine, "context");
assertThat(templateContext, hasEntry(equalTo("tableCellContent"),
instanceOf(AsciidoctorTableCellContentLambda.class)));
}
@SuppressWarnings("unchecked")
@Test
public void asciidoctorTableCellContentLambaIsNotInstalledWhenUsingNonAsciidoctorTemplateFormat() {
Map<String, Object> configuration = new HashMap<>();
this.configurer.snippetConfigurer.withTemplateFormat(TemplateFormats.markdown());
this.configurer.apply(configuration, createContext());
TemplateEngine templateEngine = (TemplateEngine) configuration
.get(TemplateEngine.class.getName());
MustacheTemplateEngine mustacheTemplateEngine = (MustacheTemplateEngine) templateEngine;
Map<String, Object> templateContext = (Map<String, Object>) ReflectionTestUtils
.getField(mustacheTemplateEngine, "context");
assertThat(templateContext.size(), equalTo(0));
}
private RestDocumentationContext createContext() {
ManualRestDocumentation manualRestDocumentation = new ManualRestDocumentation(
"build");

View File

@@ -118,7 +118,7 @@ public class ConstraintDescriptionsTests {
@Test
public void pattern() {
assertThat(this.constraintDescriptions.descriptionsForProperty("pattern"),
contains("Must match the regular expression '[A-Z][a-z]+'"));
contains("Must match the regular expression `[A-Z][a-z]+`"));
}
@Test

View File

@@ -137,7 +137,7 @@ public class ResourceBundleConstraintDescriptionResolverTests {
@Test
public void defaultMessagePattern() {
assertThat(constraintDescriptionForField("pattern"),
is(equalTo("Must match the regular expression '[A-Z][a-z]+'")));
is(equalTo("Must match the regular expression `[A-Z][a-z]+`")));
}
@Test

View File

@@ -24,6 +24,7 @@ import org.junit.Test;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
import org.springframework.restdocs.test.OperationBuilder;
@@ -165,4 +166,23 @@ public class RequestHeadersSnippetTests extends AbstractSnippetTests {
.header("Connection", "keep-alive").build());
}
@Test
public void tableCellContentIsEscapedWhenNecessary() throws IOException {
this.snippet.expectRequestHeaders("request-with-escaped-headers").withContents(
tableWithHeader("Name", "Description").row(escapeIfNecessary("`Foo|Bar`"),
escapeIfNecessary("one|two")));
new RequestHeadersSnippet(
Arrays.asList(headerWithName("Foo|Bar").description("one|two")))
.document(operationBuilder("request-with-escaped-headers")
.request("http://localhost").header("Foo|Bar", "baz")
.build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
}

View File

@@ -24,6 +24,7 @@ import org.junit.Test;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
import org.springframework.restdocs.test.OperationBuilder;
@@ -155,4 +156,22 @@ public class ResponseHeadersSnippetTests extends AbstractSnippetTests {
.build());
}
@Test
public void tableCellContentIsEscapedWhenNecessary() throws IOException {
this.snippet.expectResponseHeaders("response-with-escaped-headers").withContents(
tableWithHeader("Name", "Description").row(escapeIfNecessary("`Foo|Bar`"),
escapeIfNecessary("one|two")));
new ResponseHeadersSnippet(
Arrays.asList(headerWithName("Foo|Bar").description("one|two")))
.document(operationBuilder("response-with-escaped-headers")
.response().header("Foo|Bar", "baz").build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
}

View File

@@ -24,6 +24,7 @@ import org.junit.Test;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
@@ -171,4 +172,22 @@ public class LinksSnippetTests extends AbstractSnippetTests {
.and(new LinkDescriptor("b").description("two"))
.document(operationBuilder("additional-descriptors").build());
}
@Test
public void tableCellContentIsEscapedWhenNecessary() throws IOException {
this.snippet.expectLinks("links-with-escaped-content")
.withContents(tableWithHeader("Relation", "Description").row(
escapeIfNecessary("`Foo|Bar`"), escapeIfNecessary("one|two")));
new LinksSnippet(new StubLinkExtractor().withLinks(new Link("Foo|Bar", "foo")),
Arrays.asList(new LinkDescriptor("Foo|Bar").description("one|two")))
.document(operationBuilder("links-with-escaped-content").build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
}

View File

@@ -26,6 +26,7 @@ import org.springframework.http.MediaType;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
@@ -227,4 +228,25 @@ public class RequestFieldsSnippetTests extends AbstractSnippetTests {
.content("{\"a\": {\"b\": 5, \"c\": \"charlie\"}}").build());
}
@Test
public void requestWithFieldsWithEscapedContent() throws IOException {
this.snippet.expectRequestFields("request-fields-with-escaped-content")
.withContents(tableWithHeader("Path", "Type", "Description").row(
escapeIfNecessary("`Foo|Bar`"), escapeIfNecessary("`one|two`"),
escapeIfNecessary("three|four")));
new RequestFieldsSnippet(Arrays.asList(
fieldWithPath("Foo|Bar").type("one|two").description("three|four")))
.document(operationBuilder("request-fields-with-escaped-content")
.request("http://localhost").content("{\"Foo|Bar\": 5}")
.build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
}

View File

@@ -26,6 +26,7 @@ import org.springframework.http.MediaType;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
@@ -283,4 +284,24 @@ public class ResponseFieldsSnippetTests extends AbstractSnippetTests {
.content("{\"a\": {\"b\": 5, \"c\": \"charlie\"}}").build());
}
@Test
public void responseWithFieldsWithEscapedContent() throws IOException {
this.snippet.expectResponseFields("response-fields-with-escaped-content")
.withContents(tableWithHeader("Path", "Type", "Description").row(
escapeIfNecessary("`Foo|Bar`"), escapeIfNecessary("`one|two`"),
escapeIfNecessary("three|four")));
new ResponseFieldsSnippet(Arrays.asList(
fieldWithPath("Foo|Bar").type("one|two").description("three|four")))
.document(operationBuilder("response-fields-with-escaped-content")
.response().content("{\"Foo|Bar\": 5}").build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
}

View File

@@ -192,9 +192,37 @@ public class PathParametersSnippetTests extends AbstractSnippetTests {
.build());
}
@Test
public void pathParametersWithEscapedContent() throws IOException {
this.snippet.expectPathParameters("path-parameters-with-escaped-content")
.withContents(tableWithTitleAndHeader(getTitle("{Foo|Bar}"), "Parameter",
"Description").row(escapeIfNecessary("`Foo|Bar`"),
escapeIfNecessary("one|two")));
RequestDocumentation
.pathParameters(parameterWithName("Foo|Bar").description("one|two"))
.document(operationBuilder("path-parameters-with-escaped-content")
.attribute(RestDocumentationGenerator.ATTRIBUTE_NAME_URL_TEMPLATE,
"{Foo|Bar}")
.build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
private String getTitle() {
return this.templateFormat == TemplateFormats.asciidoctor() ? "/{a}/{b}"
: "`/{a}/{b}`";
return getTitle("/{a}/{b}");
}
private String getTitle(String title) {
if (this.templateFormat.equals(TemplateFormats.asciidoctor())) {
return title;
}
return "`" + title + "`";
}
}

View File

@@ -24,6 +24,7 @@ import org.junit.Test;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
@@ -194,4 +195,23 @@ public class RequestParametersSnippetTests extends AbstractSnippetTests {
.param("b", "bravo").build());
}
@Test
public void requestParametersWithEscapedContent() throws IOException {
this.snippet.expectRequestParameters("request-parameters-with-escaped-content")
.withContents(tableWithHeader("Parameter", "Description").row(
escapeIfNecessary("`Foo|Bar`"), escapeIfNecessary("one|two")));
RequestDocumentation
.requestParameters(parameterWithName("Foo|Bar").description("one|two"))
.document(operationBuilder("request-parameters-with-escaped-content")
.request("http://localhost").param("Foo|Bar", "baz").build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
}

View File

@@ -24,6 +24,7 @@ import org.junit.Test;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
@@ -180,4 +181,23 @@ public class RequestPartsSnippetTests extends AbstractSnippetTests {
.part("b", "bravo".getBytes()).build());
}
@Test
public void requestPartsWithEscapedContent() throws IOException {
this.snippet.expectRequestParts("request-parts-with-escaped-content")
.withContents(tableWithHeader("Part", "Description").row(
escapeIfNecessary("`Foo|Bar`"), escapeIfNecessary("one|two")));
RequestDocumentation.requestParts(partWithName("Foo|Bar").description("one|two"))
.document(operationBuilder("request-parts-with-escaped-content")
.request("http://localhost").part("Foo|Bar", "baz".getBytes())
.build());
}
private String escapeIfNecessary(String input) {
if (this.templateFormat.equals(TemplateFormats.markdown())) {
return input;
}
return input.replace("|", "\\|");
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2014-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.restdocs.templates.mustache;
import java.io.IOException;
import java.io.StringWriter;
import org.junit.Test;
import org.springframework.restdocs.mustache.Template.Fragment;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link AsciidoctorTableCellContentLambda}.
*
* @author Andy Wilkinson
*/
public class AsciidoctorTableCellContentLambdaTests {
@Test
public void verticalBarCharactersAreEscaped() throws IOException {
Fragment fragment = mock(Fragment.class);
given(fragment.execute()).willReturn("|foo|bar|baz|");
StringWriter writer = new StringWriter();
new AsciidoctorTableCellContentLambda().execute(fragment, writer);
assertThat(writer.toString(), is(equalTo("\\|foo\\|bar\\|baz\\|")));
}
@Test
public void escapedVerticalBarCharactersAreNotEscapedAgain() throws IOException {
Fragment fragment = mock(Fragment.class);
given(fragment.execute()).willReturn("\\|foo|bar\\|baz|");
StringWriter writer = new StringWriter();
new AsciidoctorTableCellContentLambda().execute(fragment, writer);
assertThat(writer.toString(), is(equalTo("\\|foo\\|bar\\|baz\\|")));
}
}

View File

@@ -29,6 +29,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.restdocs.ManualRestDocumentation;
import org.springframework.restdocs.RestDocumentationContext;
import org.springframework.restdocs.mustache.Mustache;
import org.springframework.restdocs.operation.Operation;
import org.springframework.restdocs.operation.OperationRequest;
import org.springframework.restdocs.operation.OperationRequestFactory;
@@ -45,6 +46,7 @@ import org.springframework.restdocs.templates.StandardTemplateResourceResolver;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.templates.mustache.AsciidoctorTableCellContentLambda;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
/**
@@ -93,9 +95,13 @@ public class OperationBuilder {
public Operation build() {
if (this.attributes.get(TemplateEngine.class.getName()) == null) {
Map<String, Object> templateContext = new HashMap<>();
templateContext.put("tableCellContent",
new AsciidoctorTableCellContentLambda());
this.attributes.put(TemplateEngine.class.getName(),
new MustacheTemplateEngine(
new StandardTemplateResourceResolver(this.templateFormat)));
new StandardTemplateResourceResolver(this.templateFormat),
Mustache.compiler().escapeHTML(false), templateContext));
}
RestDocumentationContext context = createContext();
this.attributes.put(RestDocumentationContext.class.getName(), context);