diff --git a/build.gradle b/build.gradle index 3617a140..202910b9 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,7 @@ apply plugin: 'samples' ext { springVersion = '4.1.5.RELEASE' + jmustacheVersion = '1.10' } subprojects { @@ -28,6 +29,7 @@ subprojects { } dependencies { dependency 'com.fasterxml.jackson.core:jackson-databind:2.4.6' + dependency "com.samskivert:jmustache:$jmustacheVersion" dependency 'javax.servlet:javax.servlet-api:3.1.0' dependency 'junit:junit:4.12' dependency 'org.hamcrest:hamcrest-core:1.3' diff --git a/docs/src/docs/asciidoc/customizing-snippets.adoc b/docs/src/docs/asciidoc/customizing-snippets.adoc new file mode 100644 index 00000000..94cca79d --- /dev/null +++ b/docs/src/docs/asciidoc/customizing-snippets.adoc @@ -0,0 +1,12 @@ +[[customizing-snippets]] +== Customizing the generated snippets + +Spring REST Docs uses https://mustache.github.io[Mustache] templates to produce the +generated snippets. You can customize the generated snippets by overriding the +{source}spring-restdocs/src/main/resources/org/springframework/restdocs/templates[default +templates]. + +Templates are loaded from the classpath in the `org.springframework.restdocs.templates` +package and each template is named after the snippet that it will produce. For example, to +override the template for the `curl-request.adoc` snippet, create a template named +`curl-request.snippet` in `src/test/resources/org/springframework/restdocs/templates`. \ No newline at end of file diff --git a/docs/src/docs/asciidoc/index.adoc b/docs/src/docs/asciidoc/index.adoc index 99ec9d5f..f6afcf0e 100644 --- a/docs/src/docs/asciidoc/index.adoc +++ b/docs/src/docs/asciidoc/index.adoc @@ -9,7 +9,9 @@ Andy Wilkinson :examples-dir: ../../test/java :github: https://github.com/spring-projects/spring-restdocs -:samples: {github}/tree/{branch-or-tag}/samples +:source: {github}/tree/{branch-or-tag}/ +:samples: {source}/samples +:templates: {source}/spring-restdocs/src/main/resources/org/springframework/restdocs/templates :spring-boot-docs: http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle :spring-framework-docs: http://docs.spring.io/spring/docs/current/spring-framework-reference/htmlsingle @@ -22,6 +24,7 @@ include::introduction.adoc[] include::getting-started.adoc[] include::documenting-your-api.adoc[] include::customizing-responses.adoc[] +include::customizing-snippets.adoc[] include::configuration.adoc[] include::working-with-asciidoctor.adoc[] include::contributing.adoc[] \ No newline at end of file diff --git a/docs/src/docs/asciidoc/working-with-asciidoctor.adoc b/docs/src/docs/asciidoc/working-with-asciidoctor.adoc index dd0d68d2..ce166410 100644 --- a/docs/src/docs/asciidoc/working-with-asciidoctor.adoc +++ b/docs/src/docs/asciidoc/working-with-asciidoctor.adoc @@ -33,8 +33,8 @@ can be used to reference the snippets output directory, for example: === Customizing tables Many of the snippets contain a table in its default configuration. The appearance of the -table can be customized by providing some additional configuration when the snippet is -included. +table can be customized, either by providing some additional configuration when the +snippet is included or by using a custom snippet template. diff --git a/spring-restdocs/build.gradle b/spring-restdocs/build.gradle index 65056ff4..e4c2fd50 100644 --- a/spring-restdocs/build.gradle +++ b/spring-restdocs/build.gradle @@ -29,6 +29,8 @@ sonarRunner { configurations { jacoco + jarjar + jmustache } ext { @@ -38,6 +40,24 @@ ext { ] as String[] } +task jmustacheRepackJar(type: Jar) { repackJar -> + repackJar.baseName = "restdocs-jmustache-repack" + repackJar.version = jmustacheVersion + + doLast() { + project.ant { + taskdef name: "jarjar", classname: "com.tonicsystems.jarjar.JarJarTask", + classpath: configurations.jarjar.asPath + jarjar(destfile: repackJar.archivePath) { + configurations.jmustache.each { originalJar -> + zipfileset(src: originalJar, includes: '**/*.class') + } + rule(pattern: 'com.samskivert.**', result: 'org.springframework.restdocs.@1') + } + } + } +} + dependencies { compile 'com.fasterxml.jackson.core:jackson-databind' compile 'junit:junit' @@ -46,13 +66,23 @@ dependencies { compile 'org.springframework:spring-web' compile 'org.springframework:spring-webmvc' compile 'javax.servlet:javax.servlet-api' + compile files(jmustacheRepackJar) jacoco 'org.jacoco:org.jacoco.agent::runtime' + jarjar 'com.googlecode.jarjar:jarjar:1.3' + jmustache 'com.samskivert:jmustache@jar' testCompile 'org.springframework.hateoas:spring-hateoas' testCompile 'org.mockito:mockito-core' testCompile 'org.hamcrest:hamcrest-core' testCompile 'org.hamcrest:hamcrest-library' } +jar { + dependsOn jmustacheRepackJar + from(zipTree(jmustacheRepackJar.archivePath)) { + include "org/springframework/restdocs/**" + } +} + task sourcesJar(type: Jar) { classifier = 'sources' from project.sourceSets.main.allSource @@ -60,13 +90,12 @@ task sourcesJar(type: Jar) { javadoc { description = "Generates project-level javadoc for use in -javadoc jar" - options.memberLevel = org.gradle.external.javadoc.JavadocMemberLevel.PROTECTED options.author = true options.header = "Spring REST Docs $version" options.docTitle = "${options.header} API" options.links(project.ext.javadocLinks) - options.addStringOption('-quiet') + options.addStringOption '-quiet' } task javadocJar(type: Jar) { @@ -80,7 +109,7 @@ artifacts { } test { - jvmArgs "-javaagent:${configurations.jacoco.asPath}=destfile=${buildDir}/jacoco.exec,includes=org.springframework.restdocs.*" + jvmArgs "-javaagent:${configurations.jacoco.asPath}=destfile=${buildDir}/jacoco.exec,includes=org.springframework.restdocs.*,excludes=org.springframework.restdocs.mustache.*" testLogging { exceptionFormat "full" } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/config/RestDocumentationConfigurer.java b/spring-restdocs/src/main/java/org/springframework/restdocs/config/RestDocumentationConfigurer.java index 8d578146..c8757d18 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/config/RestDocumentationConfigurer.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/config/RestDocumentationConfigurer.java @@ -21,6 +21,9 @@ import java.util.List; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.restdocs.RestDocumentation; +import org.springframework.restdocs.templates.StandardTemplateResourceResolver; +import org.springframework.restdocs.templates.TemplateEngine; +import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine; import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; import org.springframework.test.web.servlet.setup.MockMvcConfigurer; @@ -44,6 +47,8 @@ public class RestDocumentationConfigurer extends MockMvcConfigurerAdapter { private final RequestPostProcessor requestPostProcessor; + private TemplateEngineConfigurer templateEngineConfigurer = new TemplateEngineConfigurer(); + /** * Creates a new {@link RestDocumentationConfigurer}. * @see RestDocumentation#documentationConfiguration() @@ -52,7 +57,8 @@ public class RestDocumentationConfigurer extends MockMvcConfigurerAdapter { this.requestPostProcessor = new ConfigurerApplyingRequestPostProcessor( Arrays. asList(this.uriConfigurer, this.snippetConfigurer, new StepCountConfigurer(), - new ContentLengthHeaderConfigurer())); + new ContentLengthHeaderConfigurer(), + new TemplateEngineConfigurer())); } public UriConfigurer uris() { @@ -63,6 +69,11 @@ public class RestDocumentationConfigurer extends MockMvcConfigurerAdapter { return this.snippetConfigurer; } + public RestDocumentationConfigurer templateEngine(TemplateEngine templateEngine) { + this.templateEngineConfigurer.setTemplateEngine(templateEngine); + return this; + } + @Override public RequestPostProcessor beforeMockMvcCreated( ConfigurableMockMvcBuilder builder, WebApplicationContext context) { @@ -95,6 +106,22 @@ public class RestDocumentationConfigurer extends MockMvcConfigurerAdapter { } + private static class TemplateEngineConfigurer extends AbstractConfigurer { + + private TemplateEngine templateEngine = new MustacheTemplateEngine( + new StandardTemplateResourceResolver()); + + @Override + void apply(MockHttpServletRequest request) { + request.setAttribute(TemplateEngine.class.getName(), this.templateEngine); + } + + void setTemplateEngine(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + + } + private static class ConfigurerApplyingRequestPostProcessor implements RequestPostProcessor { diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java b/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java index 65368dc6..e3161c52 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java @@ -17,12 +17,15 @@ package org.springframework.restdocs.curl; import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Map.Entry; -import org.springframework.restdocs.snippet.DocumentationWriter; -import org.springframework.restdocs.snippet.DocumentationWriter.DocumentationAction; import org.springframework.restdocs.snippet.SnippetWritingResultHandler; +import org.springframework.restdocs.templates.TemplateEngine; import org.springframework.restdocs.util.DocumentableHttpServletRequest; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.StringUtils; @@ -50,18 +53,11 @@ public abstract class CurlDocumentation { * @return the handler that will produce the snippet */ public static SnippetWritingResultHandler documentCurlRequest(String outputDir) { - return new SnippetWritingResultHandler(outputDir, "curl-request") { - - @Override - public void handle(MvcResult result, DocumentationWriter writer) - throws IOException { - writer.shellCommand(new CurlRequestDocumentationAction(writer, result)); - } - }; + return new CurlRequestWritingResultHandler(outputDir); } - private static final class CurlRequestDocumentationAction implements - DocumentationAction { + private static final class CurlRequestWritingResultHandler extends + SnippetWritingResultHandler { private static final String SCHEME_HTTP = "http"; @@ -71,46 +67,52 @@ public abstract class CurlDocumentation { private static final int STANDARD_PORT_HTTPS = 443; - private final DocumentationWriter writer; - - private final MvcResult result; - - CurlRequestDocumentationAction(DocumentationWriter writer, MvcResult result) { - this.writer = writer; - this.result = result; + private CurlRequestWritingResultHandler(String outputDir) { + super(outputDir, "curl-request"); } @Override - public void perform() throws IOException { - DocumentableHttpServletRequest request = new DocumentableHttpServletRequest( - this.result.getRequest()); + public void handle(MvcResult result, PrintWriter writer) throws IOException { + Map context = new HashMap(); + context.put("language", "bash"); + context.put("arguments", getCurlCommandArguments(result)); - this.writer.print("curl '"); + TemplateEngine templateEngine = (TemplateEngine) result.getRequest() + .getAttribute(TemplateEngine.class.getName()); - writeAuthority(request); - writePathAndQueryString(request); - - this.writer.print("'"); - - writeOptionToIncludeHeadersInOutput(); - writeHttpMethodIfNecessary(request); - writeHeaders(request); - - if (request.isMultipartRequest()) { - writeParts(request); - } - - writeContent(request); - - this.writer.println(); + writer.print(templateEngine.compileTemplate("curl-request").render(context)); } - private void writeAuthority(DocumentableHttpServletRequest request) { - this.writer.print(String.format("%s://%s", request.getScheme(), - request.getHost())); + private String getCurlCommandArguments(MvcResult result) throws IOException { + StringWriter command = new StringWriter(); + PrintWriter printer = new PrintWriter(command); + DocumentableHttpServletRequest request = new DocumentableHttpServletRequest( + result.getRequest()); + + printer.print("'"); + writeAuthority(request, printer); + writePathAndQueryString(request, printer); + printer.print("'"); + + writeOptionToIncludeHeadersInOutput(printer); + writeHttpMethodIfNecessary(request, printer); + writeHeaders(request, printer); + + if (request.isMultipartRequest()) { + writeParts(request, printer); + } + + writeContent(request, printer); + + return command.toString(); + } + + private void writeAuthority(DocumentableHttpServletRequest request, + PrintWriter writer) { + writer.print(String.format("%s://%s", request.getScheme(), request.getHost())); if (isNonStandardPort(request)) { - this.writer.print(String.format(":%d", request.getPort())); + writer.print(String.format(":%d", request.getPort())); } } @@ -119,75 +121,75 @@ public abstract class CurlDocumentation { || (SCHEME_HTTPS.equals(request.getScheme()) && request.getPort() != STANDARD_PORT_HTTPS); } - private void writePathAndQueryString(DocumentableHttpServletRequest request) { + private void writePathAndQueryString(DocumentableHttpServletRequest request, + PrintWriter writer) { if (StringUtils.hasText(request.getContextPath())) { - this.writer.print(String.format( + writer.print(String.format( request.getContextPath().startsWith("/") ? "%s" : "/%s", request.getContextPath())); } - this.writer.print(request.getRequestUriWithQueryString()); + writer.print(request.getRequestUriWithQueryString()); } - private void writeOptionToIncludeHeadersInOutput() { - this.writer.print(" -i"); + private void writeOptionToIncludeHeadersInOutput(PrintWriter writer) { + writer.print(" -i"); } - private void writeHttpMethodIfNecessary(DocumentableHttpServletRequest request) { + private void writeHttpMethodIfNecessary(DocumentableHttpServletRequest request, + PrintWriter writer) { if (!request.isGetRequest()) { - this.writer.print(String.format(" -X %s", request.getMethod())); + writer.print(String.format(" -X %s", request.getMethod())); } } - private void writeHeaders(DocumentableHttpServletRequest request) { + private void writeHeaders(DocumentableHttpServletRequest request, + PrintWriter writer) { for (Entry> entry : request.getHeaders().entrySet()) { for (String header : entry.getValue()) { - this.writer.print(String.format(" -H '%s: %s'", entry.getKey(), - header)); + writer.print(String.format(" -H '%s: %s'", entry.getKey(), header)); } } } - private void writeParts(DocumentableHttpServletRequest request) + private void writeParts(DocumentableHttpServletRequest request, PrintWriter writer) throws IOException { for (Entry> entry : request.getMultipartFiles() .entrySet()) { for (MultipartFile file : entry.getValue()) { - this.writer.printf(" -F '%s=", file.getName()); + writer.printf(" -F '%s=", file.getName()); if (!StringUtils.hasText(file.getOriginalFilename())) { - this.writer.append(new String(file.getBytes())); + writer.append(new String(file.getBytes())); } else { - this.writer.printf("@%s", file.getOriginalFilename()); + writer.printf("@%s", file.getOriginalFilename()); } if (StringUtils.hasText(file.getContentType())) { - this.writer.append(";type=").append(file.getContentType()); + writer.append(";type=").append(file.getContentType()); } - this.writer.append("'"); + writer.append("'"); } } } - private void writeContent(DocumentableHttpServletRequest request) - throws IOException { + private void writeContent(DocumentableHttpServletRequest request, + PrintWriter writer) throws IOException { if (request.getContentLength() > 0) { - this.writer - .print(String.format(" -d '%s'", request.getContentAsString())); + writer.print(String.format(" -d '%s'", request.getContentAsString())); } else if (request.isMultipartRequest()) { for (Entry entry : request.getParameterMap().entrySet()) { for (String value : entry.getValue()) { - this.writer.print(String.format(" -F '%s=%s'", entry.getKey(), - value)); + writer.print(String.format(" -F '%s=%s'", entry.getKey(), value)); } } } else if (request.isPostRequest() || request.isPutRequest()) { String queryString = request.getParameterMapAsQueryString(); if (StringUtils.hasText(queryString)) { - this.writer.print(String.format(" -d '%s'", queryString)); + writer.print(String.format(" -d '%s'", queryString)); } } } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java b/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java index 025f17ed..2f7448fd 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java @@ -17,15 +17,19 @@ package org.springframework.restdocs.http; import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Map.Entry; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.restdocs.snippet.DocumentationWriter; -import org.springframework.restdocs.snippet.DocumentationWriter.DocumentationAction; import org.springframework.restdocs.snippet.SnippetWritingResultHandler; +import org.springframework.restdocs.templates.TemplateEngine; import org.springframework.restdocs.util.DocumentableHttpServletRequest; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.StringUtils; @@ -39,6 +43,8 @@ import org.springframework.web.multipart.MultipartFile; */ public abstract class HttpDocumentation { + private static final String MULTIPART_BOUNDARY = "6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm"; + private HttpDocumentation() { } @@ -51,15 +57,7 @@ public abstract class HttpDocumentation { * @return the handler that will produce the snippet */ public static SnippetWritingResultHandler documentHttpRequest(String outputDir) { - return new SnippetWritingResultHandler(outputDir, "http-request") { - - @Override - public void handle(MvcResult result, DocumentationWriter writer) - throws IOException { - writer.codeBlock("http", new HttpRequestDocumentationAction(writer, - result)); - } - }; + return new HttpRequestWritingResultHandler(outputDir); } /** @@ -70,71 +68,126 @@ public abstract class HttpDocumentation { * @return the handler that will produce the snippet */ public static SnippetWritingResultHandler documentHttpResponse(String outputDir) { - return new SnippetWritingResultHandler(outputDir, "http-response") { + return new HttpResponseWritingResultHandler(outputDir); - @Override - public void handle(MvcResult result, DocumentationWriter writer) - throws IOException { - writer.codeBlock("http", new HttpResponseDocumentationAction(writer, - result)); - } - }; } - private static class HttpRequestDocumentationAction implements DocumentationAction { + private static final class HttpRequestWritingResultHandler extends + SnippetWritingResultHandler { - private static final String MULTIPART_BOUNDARY = "6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm"; - - private final DocumentationWriter writer; - - private final MvcResult result; - - HttpRequestDocumentationAction(DocumentationWriter writer, MvcResult result) { - this.writer = writer; - this.result = result; + private HttpRequestWritingResultHandler(String outputDir) { + super(outputDir, "http-request"); } @Override - public void perform() throws IOException { + public void handle(MvcResult result, PrintWriter writer) throws IOException { DocumentableHttpServletRequest request = new DocumentableHttpServletRequest( - this.result.getRequest()); - this.writer.printf("%s %s HTTP/1.1%n", request.getMethod(), - request.getRequestUriWithQueryString()); + result.getRequest()); + Map context = new HashMap(); + context.put("language", "http"); + context.put("method", result.getRequest().getMethod()); + context.put("path", request.getRequestUriWithQueryString()); + context.put("headers", getHeaders(request)); + context.put("requestBody", getRequestBody(request)); + + TemplateEngine templateEngine = (TemplateEngine) result.getRequest() + .getAttribute(TemplateEngine.class.getName()); + + writer.print(templateEngine.compileTemplate("http-request").render(context)); + } + + private List> getHeaders( + DocumentableHttpServletRequest request) { + List> headers = new ArrayList<>(); if (requiresHostHeader(request)) { - writeHeader(HttpHeaders.HOST, request.getHost()); + headers.add(header(HttpHeaders.HOST, request.getHost())); } + for (Entry> header : request.getHeaders().entrySet()) { for (String value : header.getValue()) { if (header.getKey() == HttpHeaders.CONTENT_TYPE && request.isMultipartRequest()) { - writeHeader(header.getKey(), String.format("%s; boundary=%s", - value, MULTIPART_BOUNDARY)); + headers.add(header(header.getKey(), String.format( + "%s; boundary=%s", value, MULTIPART_BOUNDARY))); } else { - this.writer.printf("%s: %s%n", header.getKey(), value); + headers.add(header(header.getKey(), value)); } } } if (requiresFormEncodingContentType(request)) { - writeHeader(HttpHeaders.CONTENT_TYPE, - MediaType.APPLICATION_FORM_URLENCODED_VALUE); + headers.add(header(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE)); } - this.writer.println(); + return headers; + } + + private String getRequestBody(DocumentableHttpServletRequest request) + throws IOException { + StringWriter httpRequest = new StringWriter(); + PrintWriter writer = new PrintWriter(httpRequest); if (request.getContentLength() > 0) { - this.writer.println(request.getContentAsString()); + writer.println(); + writer.print(request.getContentAsString()); } else if (request.isPostRequest() || request.isPutRequest()) { if (request.isMultipartRequest()) { - writeParts(request); + writeParts(request, writer); } else { String queryString = request.getParameterMapAsQueryString(); if (StringUtils.hasText(queryString)) { - this.writer.println(queryString); + writer.println(); + writer.print(queryString); } } } + return httpRequest.toString(); + } + + private void writeParts(DocumentableHttpServletRequest request, PrintWriter writer) + throws IOException { + writer.println(); + for (Entry parameter : request.getParameterMap().entrySet()) { + for (String value : parameter.getValue()) { + writePartBoundary(writer); + writePart(parameter.getKey(), value, null, writer); + writer.println(); + } + } + for (Entry> entry : request.getMultipartFiles() + .entrySet()) { + for (MultipartFile file : entry.getValue()) { + writePartBoundary(writer); + writePart(file, writer); + writer.println(); + } + } + writeMultipartEnd(writer); + } + + private void writePartBoundary(PrintWriter writer) { + writer.printf("--%s%n", MULTIPART_BOUNDARY); + } + + private void writePart(String name, String value, String contentType, + PrintWriter writer) { + writer.printf("Content-Disposition: form-data; name=%s%n", name); + if (StringUtils.hasText(contentType)) { + writer.printf("Content-Type: %s%n", contentType); + } + writer.println(); + writer.print(value); + } + + private void writePart(MultipartFile part, PrintWriter writer) throws IOException { + writePart(part.getName(), new String(part.getBytes()), part.getContentType(), + writer); + } + + private void writeMultipartEnd(PrintWriter writer) { + writer.printf("--%s--", MULTIPART_BOUNDARY); } private boolean requiresHostHeader(DocumentableHttpServletRequest request) { @@ -148,79 +201,55 @@ public abstract class HttpDocumentation { && StringUtils.hasText(request.getParameterMapAsQueryString()); } - private void writeHeader(String name, String value) { - this.writer.printf("%s: %s%n", name, value); - } - - private void writeParts(DocumentableHttpServletRequest request) - throws IOException { - for (Entry parameter : request.getParameterMap().entrySet()) { - for (String value : parameter.getValue()) { - writePartBoundary(); - writePart(parameter.getKey(), value, null); - this.writer.println(); - } - } - for (Entry> entry : request.getMultipartFiles() - .entrySet()) { - for (MultipartFile file : entry.getValue()) { - writePartBoundary(); - writePart(file); - this.writer.println(); - } - } - writeMultipartEnd(); - } - - private void writePartBoundary() { - this.writer.printf("--%s%n", MULTIPART_BOUNDARY); - } - - private void writePart(String name, String value, String contentType) { - this.writer.printf("Content-Disposition: form-data; name=%s%n", name); - if (StringUtils.hasText(contentType)) { - this.writer.printf("Content-Type: %s%n", contentType); - } - this.writer.println(); - this.writer.print(value); - } - - private void writePart(MultipartFile part) throws IOException { - writePart(part.getName(), new String(part.getBytes()), part.getContentType()); - } - - private void writeMultipartEnd() { - this.writer.printf("--%s--%n", MULTIPART_BOUNDARY); + private Map header(String name, String value) { + Map header = new HashMap<>(); + header.put("name", name); + header.put("value", value); + return header; } } - private static final class HttpResponseDocumentationAction implements - DocumentationAction { + private static final class HttpResponseWritingResultHandler extends + SnippetWritingResultHandler { - private final DocumentationWriter writer; - - private final MvcResult result; - - HttpResponseDocumentationAction(DocumentationWriter writer, MvcResult result) { - this.writer = writer; - this.result = result; + private HttpResponseWritingResultHandler(String outputDir) { + super(outputDir, "http-response"); } @Override - public void perform() throws IOException { - HttpStatus status = HttpStatus.valueOf(this.result.getResponse().getStatus()); - this.writer.println(String.format("HTTP/1.1 %d %s", status.value(), - status.getReasonPhrase())); - for (String headerName : this.result.getResponse().getHeaderNames()) { - for (String header : this.result.getResponse().getHeaders(headerName)) { - this.writer.println(String.format("%s: %s", headerName, header)); + public void handle(MvcResult result, PrintWriter writer) throws IOException { + HttpStatus status = HttpStatus.valueOf(result.getResponse().getStatus()); + Map context = new HashMap(); + context.put( + "responseBody", + StringUtils.hasLength(result.getResponse().getContentAsString()) ? String + .format("%n%s", result.getResponse().getContentAsString()) + : ""); + context.put("language", "http"); + context.put("statusCode", status.value()); + context.put("statusReason", status.getReasonPhrase()); + + List> headers = new ArrayList<>(); + context.put("headers", headers); + + for (String headerName : result.getResponse().getHeaderNames()) { + for (String header : result.getResponse().getHeaders(headerName)) { + headers.add(header(headerName, header)); } } - this.writer.println(); - String content = this.result.getResponse().getContentAsString(); - if (StringUtils.hasText(content)) { - this.writer.println(content); - } + + TemplateEngine templateEngine = (TemplateEngine) result.getRequest() + .getAttribute(TemplateEngine.class.getName()); + + writer.print(templateEngine.compileTemplate("http-response").render(context)); + + } + + private Map header(String name, String value) { + Map header = new HashMap<>(); + header.put("name", name); + header.put("value", value); + return header; } } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/LinkSnippetResultHandler.java b/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/LinkSnippetResultHandler.java index 4702930a..6d18c625 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/LinkSnippetResultHandler.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/hypermedia/LinkSnippetResultHandler.java @@ -17,18 +17,17 @@ package org.springframework.restdocs.hypermedia; import java.io.IOException; +import java.io.PrintWriter; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import java.util.Set; -import org.springframework.restdocs.snippet.DocumentationWriter; -import org.springframework.restdocs.snippet.DocumentationWriter.TableAction; -import org.springframework.restdocs.snippet.DocumentationWriter.TableWriter; import org.springframework.restdocs.snippet.SnippetGenerationException; import org.springframework.restdocs.snippet.SnippetWritingResultHandler; +import org.springframework.restdocs.templates.TemplateEngine; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.Assert; @@ -61,10 +60,9 @@ public class LinkSnippetResultHandler extends SnippetWritingResultHandler { } @Override - protected void handle(MvcResult result, DocumentationWriter writer) - throws IOException { + protected void handle(MvcResult result, PrintWriter writer) throws IOException { validate(extractLinks(result)); - writeDocumentationSnippet(writer); + writeDocumentationSnippet(result, writer); } private Map> extractLinks(MvcResult result) throws IOException { @@ -111,19 +109,13 @@ public class LinkSnippetResultHandler extends SnippetWritingResultHandler { } } - private void writeDocumentationSnippet(DocumentationWriter writer) throws IOException { - writer.table(new TableAction() { - - @Override - public void perform(TableWriter tableWriter) throws IOException { - tableWriter.headers("Relation", "Description"); - for (Entry entry : LinkSnippetResultHandler.this.descriptorsByRel - .entrySet()) { - tableWriter.row(entry.getKey(), entry.getValue().getDescription()); - } - } - - }); + private void writeDocumentationSnippet(MvcResult result, PrintWriter writer) + throws IOException { + TemplateEngine templateEngine = (TemplateEngine) result.getRequest() + .getAttribute(TemplateEngine.class.getName()); + Map context = new HashMap<>(); + context.put("links", this.descriptorsByRel.values()); + writer.print(templateEngine.compileTemplate("links").render(context)); } } \ No newline at end of file diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java index 84627c42..1e87a551 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/payload/FieldSnippetResultHandler.java @@ -17,16 +17,17 @@ package org.springframework.restdocs.payload; import java.io.IOException; +import java.io.PrintWriter; import java.io.Reader; +import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import org.springframework.restdocs.snippet.DocumentationWriter; -import org.springframework.restdocs.snippet.DocumentationWriter.TableAction; -import org.springframework.restdocs.snippet.DocumentationWriter.TableWriter; import org.springframework.restdocs.snippet.SnippetWritingResultHandler; +import org.springframework.restdocs.templates.TemplateEngine; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.Assert; @@ -35,7 +36,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; /** * A {@link SnippetWritingResultHandler} that produces a snippet documenting a RESTful * resource's request or response fields. - * + * * @author Andreas Evers * @author Andy Wilkinson */ @@ -49,11 +50,14 @@ public abstract class FieldSnippetResultHandler extends SnippetWritingResultHand private final ObjectMapper objectMapper = new ObjectMapper(); + private final String templateName; + private List fieldDescriptors; - FieldSnippetResultHandler(String outputDir, String filename, + FieldSnippetResultHandler(String outputDir, String type, List descriptors) { - super(outputDir, filename + "-fields"); + super(outputDir, type + "-fields"); + this.templateName = type + "-fields"; for (FieldDescriptor descriptor : descriptors) { Assert.notNull(descriptor.getPath()); Assert.hasText(descriptor.getDescription()); @@ -63,49 +67,46 @@ public abstract class FieldSnippetResultHandler extends SnippetWritingResultHand } @Override - protected void handle(MvcResult result, DocumentationWriter writer) - throws IOException { + protected void handle(MvcResult result, PrintWriter writer) throws IOException { this.fieldValidator.validate(getPayloadReader(result), this.fieldDescriptors); - final Object payload = extractPayload(result); + Object payload = extractPayload(result); - writer.table(new TableAction() { - - @Override - public void perform(TableWriter tableWriter) throws IOException { - tableWriter.headers("Path", "Type", "Description"); - for (Entry entry : FieldSnippetResultHandler.this.descriptorsByPath - .entrySet()) { - FieldDescriptor descriptor = entry.getValue(); - FieldType type = getFieldType(descriptor, payload); - tableWriter.row(entry.getKey().toString(), type.toString(), entry - .getValue().getDescription()); - } + TemplateEngine templateEngine = (TemplateEngine) result.getRequest() + .getAttribute(TemplateEngine.class.getName()); + Map context = new HashMap<>(); + List> fields = new ArrayList<>(); + context.put("fields", fields); + for (Entry entry : this.descriptorsByPath.entrySet()) { + FieldDescriptor descriptor = entry.getValue(); + FieldType type = getFieldType(descriptor, payload); + Map fieldModel = new HashMap<>(); + fieldModel.put("path", entry.getKey()); + fieldModel.put("type", type.toString()); + fieldModel.put("description", descriptor.getDescription()); + fields.add(fieldModel); + } + writer.print(templateEngine.compileTemplate(this.templateName).render(context)); + } + private FieldType getFieldType(FieldDescriptor descriptor, Object payload) { + if (descriptor.getType() != null) { + return descriptor.getType(); + } + else { + try { + return FieldSnippetResultHandler.this.fieldTypeResolver.resolveFieldType( + descriptor.getPath(), payload); } - - private FieldType getFieldType(FieldDescriptor descriptor, Object payload) { - if (descriptor.getType() != null) { - return descriptor.getType(); - } - else { - try { - return FieldSnippetResultHandler.this.fieldTypeResolver - .resolveFieldType(descriptor.getPath(), payload); - } - catch (FieldDoesNotExistException ex) { - String message = "Cannot determine the type of the field '" - + descriptor.getPath() + "' as it is not present in the" - + " payload. Please provide a type using" - + " FieldDescriptor.type(FieldType)."; - throw new FieldTypeRequiredException(message); - } - } + catch (FieldDoesNotExistException ex) { + String message = "Cannot determine the type of the field '" + + descriptor.getPath() + "' as it is not present in the" + + " payload. Please provide a type using" + + " FieldDescriptor.type(FieldType)."; + throw new FieldTypeRequiredException(message); } - - }); - + } } private Object extractPayload(MvcResult result) throws IOException { diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/request/QueryParametersSnippetResultHandler.java b/spring-restdocs/src/main/java/org/springframework/restdocs/request/QueryParametersSnippetResultHandler.java index 1058b0a8..8813d0c8 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/request/QueryParametersSnippetResultHandler.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/request/QueryParametersSnippetResultHandler.java @@ -17,17 +17,16 @@ package org.springframework.restdocs.request; import java.io.IOException; +import java.io.PrintWriter; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; -import java.util.Map.Entry; import java.util.Set; -import org.springframework.restdocs.snippet.DocumentationWriter; -import org.springframework.restdocs.snippet.DocumentationWriter.TableAction; -import org.springframework.restdocs.snippet.DocumentationWriter.TableWriter; import org.springframework.restdocs.snippet.SnippetGenerationException; import org.springframework.restdocs.snippet.SnippetWritingResultHandler; +import org.springframework.restdocs.templates.TemplateEngine; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.Assert; @@ -52,10 +51,9 @@ public class QueryParametersSnippetResultHandler extends SnippetWritingResultHan } @Override - protected void handle(MvcResult result, DocumentationWriter writer) - throws IOException { + protected void handle(MvcResult result, PrintWriter writer) throws IOException { verifyParameterDescriptors(result); - documentParameters(writer); + documentParameters(result, writer); } private void verifyParameterDescriptors(MvcResult result) { @@ -87,19 +85,13 @@ public class QueryParametersSnippetResultHandler extends SnippetWritingResultHan Assert.isTrue(actualParameters.equals(expectedParameters)); } - private void documentParameters(DocumentationWriter writer) throws IOException { - writer.table(new TableAction() { - - @Override - public void perform(TableWriter tableWriter) throws IOException { - tableWriter.headers("Parameter", "Description"); - for (Entry entry : QueryParametersSnippetResultHandler.this.descriptorsByName - .entrySet()) { - tableWriter.row(entry.getKey(), entry.getValue().getDescription()); - } - } - - }); + private void documentParameters(MvcResult result, PrintWriter writer) + throws IOException { + TemplateEngine templateEngine = (TemplateEngine) result.getRequest() + .getAttribute(TemplateEngine.class.getName()); + Map context = new HashMap<>(); + context.put("parameters", this.descriptorsByName.values()); + writer.print(templateEngine.compileTemplate("query-parameters").render(context)); } } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/AsciidoctorWriter.java b/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/AsciidoctorWriter.java deleted file mode 100644 index fe35ef42..00000000 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/AsciidoctorWriter.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright 2014-2015 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.snippet; - -import java.io.IOException; -import java.io.Writer; - -/** - * A {@link DocumentationWriter} that produces output in Asciidoctor. - * - * @author Andy Wilkinson - */ -public class AsciidoctorWriter extends DocumentationWriter { - - private static final String DELIMITER_CODE_BLOCK = "----"; - - private static final String DELIMITER_TABLE = "|==="; - - private final TableWriter tableWriter = new AsciidoctorTableWriter(); - - /** - * Creates a new {@code AsciidoctorWriter} that will write to the given {@code writer} - * @param writer The writer to which output will be written - */ - public AsciidoctorWriter(Writer writer) { - super(writer); - } - - @Override - public void shellCommand(final DocumentationAction action) throws IOException { - codeBlock("bash", new DocumentationAction() { - - @Override - public void perform() throws IOException { - AsciidoctorWriter.this.print("$ "); - action.perform(); - } - }); - } - - @Override - public void codeBlock(String language, DocumentationAction action) throws IOException { - println(); - if (language != null) { - println("[source," + language + "]"); - } - println(DELIMITER_CODE_BLOCK); - action.perform(); - println(DELIMITER_CODE_BLOCK); - println(); - } - - @Override - public void table(TableAction action) throws IOException { - println(); - println(DELIMITER_TABLE); - action.perform(this.tableWriter); - println(DELIMITER_TABLE); - println(); - } - - private final class AsciidoctorTableWriter implements TableWriter { - - @Override - public void headers(String... headers) { - StringBuilder builder = new StringBuilder(); - for (String header : headers) { - builder.append("|"); - builder.append(header); - } - println(builder.toString()); - println(); - } - - @Override - public void row(String... entries) { - for (String entry : entries) { - print("|"); - println(entry); - } - println(); - } - - } - -} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/DocumentationWriter.java b/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/DocumentationWriter.java deleted file mode 100644 index b5634401..00000000 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/DocumentationWriter.java +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2014-2015 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.snippet; - -import java.io.IOException; -import java.io.PrintWriter; -import java.io.Writer; - -/** - * A {@link PrintWriter} that provides additional methods which are useful for producing - * API documentation. - * - * @author Andy Wilkinson - */ -public abstract class DocumentationWriter extends PrintWriter { - - protected DocumentationWriter(Writer writer) { - super(writer, true); - } - - /** - * Calls the given {@code action} to document a shell command. Any prefix necessary - * for the documentation format is written prior to calling the {@code action}. Having - * called the action, any necessary suffix is then written. - * - * @param action the action that will produce the shell command - * @throws IOException if the documentation fails - */ - public abstract void shellCommand(DocumentationAction action) throws IOException; - - /** - * Calls the given {@code action} to document a code block. The code block will be - * annotated as containing code written in the given {@code language}. Any prefix - * necessary for the documentation format is written prior to calling the - * {@code action}. Having called the {@code action}, any necessary suffix is then - * written. - * - * @param language the language in which the code is written - * @param action the action that will produce the code - * @throws IOException if the documentation fails - */ - public abstract void codeBlock(String language, DocumentationAction action) - throws IOException; - - /** - * Calls the given {@code action} to document a table. Any prefix necessary for - * documenting a table is written prior to calling the {@code action}. Having called - * the {@code action}, any necessary suffix is then written. - * - * @param action the action that will produce the table - * @throws IOException if the documentation fails - */ - public abstract void table(TableAction action) throws IOException; - - /** - * Encapsulates an action that outputs some documentation. Typically implemented as a - * lamda or, pre-Java 8, as an anonymous inner class. - * - * @see DocumentationWriter#shellCommand - * @see DocumentationWriter#codeBlock - */ - public interface DocumentationAction { - - /** - * Perform the encapsulated action - * - * @throws IOException if the action fails - */ - void perform() throws IOException; - - } - - /** - * Encapsulates an action that outputs a table. - * - * @see DocumentationWriter#table(TableAction) - */ - public interface TableAction { - - /** - * Perform the encapsulated action - * - * @param tableWriter the writer to be used to write the table - * @throws IOException if the action fails - */ - void perform(TableWriter tableWriter) throws IOException; - - } - - /** - * A writer for producing a table - */ - public interface TableWriter { - - /** - * Writes the table's headers - * - * @param headers the headers - */ - void headers(String... headers); - - /** - * Writes a row in the table - * - * @param entries the entries in the row - */ - void row(String... entries); - - } - -} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/SnippetWritingResultHandler.java b/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/SnippetWritingResultHandler.java index 1bd057c9..1a1dc022 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/SnippetWritingResultHandler.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/snippet/SnippetWritingResultHandler.java @@ -21,6 +21,7 @@ import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.io.OutputStreamWriter; +import java.io.PrintWriter; import java.io.Writer; import org.springframework.restdocs.config.RestDocumentationContext; @@ -43,13 +44,13 @@ public abstract class SnippetWritingResultHandler implements ResultHandler { this.fileName = fileName; } - protected abstract void handle(MvcResult result, DocumentationWriter writer) + protected abstract void handle(MvcResult result, PrintWriter writer) throws IOException; @Override public void handle(MvcResult result) throws IOException { try (Writer writer = createWriter()) { - handle(result, new AsciidoctorWriter(writer)); + handle(result, new PrintWriter(writer)); } } diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/templates/StandardTemplateResourceResolver.java b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/StandardTemplateResourceResolver.java new file mode 100644 index 00000000..eb8fb455 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/StandardTemplateResourceResolver.java @@ -0,0 +1,49 @@ +/* + * Copyright 2014-2015 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; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +/** + * Standard implementation of {@link TemplateResourceResolver}. + *

+ * Templates are resolved by first looking for a resource on the classpath named + * {@code org/springframework/restdocs/templates/{name}.snippet}. If no such + * resource exists {@code default-} is prepended to the name and the classpath is checked + * again. The built-in snippet templates are all named {@code default- name}, thereby + * allowing them to be overridden. + * + * @author Andy Wilkinson + */ +public class StandardTemplateResourceResolver implements TemplateResourceResolver { + + @Override + public Resource resolveTemplateResource(String name) { + ClassPathResource classPathResource = new ClassPathResource( + "org/springframework/restdocs/templates/" + name + ".snippet"); + if (!classPathResource.exists()) { + classPathResource = new ClassPathResource( + "org/springframework/restdocs/templates/default-" + name + ".snippet"); + if (!classPathResource.exists()) { + throw new IllegalStateException("Template named '" + name + + "' could not be resolved"); + } + } + return classPathResource; + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/templates/Template.java b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/Template.java new file mode 100644 index 00000000..302e5f6c --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/Template.java @@ -0,0 +1,22 @@ +package org.springframework.restdocs.templates; + +import java.util.Map; + +/** + * A compiled {@code Template} that can be rendered to a {@link String}. + * + * @author Andy Wilkinson + * + */ +public interface Template { + + /** + * Renders the template to a {@link String} using the given {@code context} for + * variable/property resolution. + * + * @param context The context to use + * @return The rendered template + */ + String render(Map context); + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/templates/TemplateEngine.java b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/TemplateEngine.java new file mode 100644 index 00000000..2ab90571 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/TemplateEngine.java @@ -0,0 +1,42 @@ +/* + * Copyright 2014-2015 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; + +import java.io.IOException; + +import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine; + +/** + * A {@code TemplateEngine} is used to render documentation snippets. + * + * @author Andy Wilkinson + * @see MustacheTemplateEngine + */ +public interface TemplateEngine { + + /** + * Compiles the template at the given {@code path}. Typically, a + * {@link TemplateResourceResolver} will be used to resolve the path into a resource + * that can be read and compiled. + * + * @param path the path of the template + * @return the compiled {@code Template} + * @throws IOException if compilation fails + */ + Template compileTemplate(String path) throws IOException; + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/templates/TemplateResourceResolver.java b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/TemplateResourceResolver.java new file mode 100644 index 00000000..d4259af1 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/TemplateResourceResolver.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014-2015 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; + +import org.springframework.core.io.Resource; + +/** + * A {@code TemplateResourceResolver} is responsible for resolving a name for a template + * into a {@link Resource} from which the template can be read. + * + * @author Andy Wilkinson + */ +public interface TemplateResourceResolver { + + /** + * Resolves a {@link Resource} for the template with the given {@code name}. + * + * @param name the name of the template + * @return the {@code Resource} from which the template can be read + */ + public Resource resolveTemplateResource(String name); + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/templates/mustache/MustacheTemplate.java b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/mustache/MustacheTemplate.java new file mode 100644 index 00000000..52421732 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/mustache/MustacheTemplate.java @@ -0,0 +1,47 @@ +/* + * Copyright 2014-2015 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.util.Map; + +import org.springframework.restdocs.templates.Template; + +/** + * An adapter that exposes a compiled Mustache + * template as a {@link Template}. + * + * @author Andy Wilkinson + */ +public class MustacheTemplate implements Template { + + private final org.springframework.restdocs.mustache.Template delegate; + + /** + * Creates a new {@code MustacheTemplate} that adapts the given {@code delegate}. When + * rendered, the given {@code defaultContext} will be combined with the render context + * prior to executing the delegate. + * @param delegate The delegate to adapt + */ + public MustacheTemplate(org.springframework.restdocs.mustache.Template delegate) { + this.delegate = delegate; + } + + @Override + public String render(Map context) { + return this.delegate.execute(context); + } + +} diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/templates/mustache/MustacheTemplateEngine.java b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/mustache/MustacheTemplateEngine.java new file mode 100644 index 00000000..0b16ae81 --- /dev/null +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/templates/mustache/MustacheTemplateEngine.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014-2015 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.InputStreamReader; + +import org.springframework.core.io.Resource; +import org.springframework.restdocs.mustache.Mustache; +import org.springframework.restdocs.mustache.Mustache.Compiler; +import org.springframework.restdocs.templates.Template; +import org.springframework.restdocs.templates.TemplateEngine; +import org.springframework.restdocs.templates.TemplateResourceResolver; + +/** + * A Mustache-based {@link TemplateEngine} + * implemented using JMustache. + *

+ * Note that JMustache has been repackaged and embedded to prevent classpath conflicts. + * + * @author Andy Wilkinson + */ +public class MustacheTemplateEngine implements TemplateEngine { + + private final Compiler compiler = Mustache.compiler().escapeHTML(false); + + private final TemplateResourceResolver templateResourceResolver; + + /** + * Creates a new {@link MustacheTemplateEngine} that will use the given + * {@code templateResourceResolver} to resolve template paths. + * + * @param templateResourceResolver The resolve to use + */ + public MustacheTemplateEngine(TemplateResourceResolver templateResourceResolver) { + this.templateResourceResolver = templateResourceResolver; + } + + @Override + public Template compileTemplate(String name) throws IOException { + Resource templateResource = this.templateResourceResolver + .resolveTemplateResource(name); + return new MustacheTemplate(this.compiler.compile(new InputStreamReader( + templateResource.getInputStream()))); + } + +} diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-curl-request.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-curl-request.snippet new file mode 100644 index 00000000..8eaef9e1 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-curl-request.snippet @@ -0,0 +1,4 @@ +[source,bash] +---- +$ curl {{arguments}} +---- \ No newline at end of file diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-fields.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-fields.snippet new file mode 100644 index 00000000..497f5a03 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Path|Type|Description + +{{#fields}} +|{{path}} +|{{type}} +|{{description}} + +{{/fields}} +|=== \ No newline at end of file diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-http-request.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-http-request.snippet new file mode 100644 index 00000000..a3e529ae --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-http-request.snippet @@ -0,0 +1,8 @@ +[source,http] +---- +{{method}} {{path}} HTTP/1.1 +{{#headers}} +{{name}}: {{value}} +{{/headers}} +{{requestBody}} +---- \ No newline at end of file diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-http-response.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-http-response.snippet new file mode 100644 index 00000000..fe095d36 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-http-response.snippet @@ -0,0 +1,8 @@ +[source,http] +---- +HTTP/1.1 {{statusCode}} {{statusReason}} +{{#headers}} +{{name}}: {{value}} +{{/headers}} +{{responseBody}} +---- \ No newline at end of file diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-links.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-links.snippet new file mode 100644 index 00000000..b8b45290 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-links.snippet @@ -0,0 +1,9 @@ +|=== +|Relation|Description + +{{#links}} +|{{rel}} +|{{description}} + +{{/links}} +|=== \ No newline at end of file diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-query-parameters.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-query-parameters.snippet new file mode 100644 index 00000000..067e1fb8 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-query-parameters.snippet @@ -0,0 +1,9 @@ +|=== +|Parameter|Description + +{{#parameters}} +|{{name}} +|{{description}} + +{{/parameters}} +|=== \ No newline at end of file diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-request-fields.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-request-fields.snippet new file mode 100644 index 00000000..497f5a03 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-request-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Path|Type|Description + +{{#fields}} +|{{path}} +|{{type}} +|{{description}} + +{{/fields}} +|=== \ No newline at end of file diff --git a/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-response-fields.snippet b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-response-fields.snippet new file mode 100644 index 00000000..497f5a03 --- /dev/null +++ b/spring-restdocs/src/main/resources/org/springframework/restdocs/templates/default-response-fields.snippet @@ -0,0 +1,10 @@ +|=== +|Path|Type|Description + +{{#fields}} +|{{path}} +|{{type}} +|{{description}} + +{{/fields}} +|=== \ No newline at end of file diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java index a7dc2b94..e5aa52a6 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/RestDocumentationIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.restdocs; +import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; @@ -31,6 +32,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -175,6 +178,29 @@ public class RestDocumentationIntegrationTests { + " \"...\"%n } ]%n}"))))); } + @Test + public void customSnippetTemplate() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(new RestDocumentationConfigurer()).build(); + + ClassLoader classLoader = new URLClassLoader(new URL[] { new File( + "src/test/resources/custom-snippet-templates").toURI().toURL() }, + getClass().getClassLoader()); + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(classLoader); + try { + mockMvc.perform(get("/").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(document("custom-snippet-template")); + } + finally { + Thread.currentThread().setContextClassLoader(previous); + } + assertThat(new File( + "build/generated-snippets/custom-snippet-template/curl-request.adoc"), + is(snippet().withContents(equalTo("Custom curl request")))); + } + private void assertExpectedSnippetFilesExist(File directory, String... snippets) { for (String snippet : snippets) { assertTrue(new File(directory, snippet).isFile()); diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/snippet/AsciidoctorWriterTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/snippet/AsciidoctorWriterTests.java deleted file mode 100644 index 15eb2728..00000000 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/snippet/AsciidoctorWriterTests.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2014-2015 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.snippet; - -import static org.junit.Assert.assertEquals; - -import java.io.IOException; -import java.io.StringWriter; -import java.io.Writer; - -import org.junit.Test; -import org.springframework.restdocs.snippet.DocumentationWriter.DocumentationAction; -import org.springframework.restdocs.snippet.DocumentationWriter.TableAction; -import org.springframework.restdocs.snippet.DocumentationWriter.TableWriter; - -/** - * Tests for {@link AsciidoctorWriter} - * - * @author Andy Wilkinson - */ -public class AsciidoctorWriterTests { - - private Writer output = new StringWriter(); - - private DocumentationWriter documentationWriter = new AsciidoctorWriter(this.output); - - @Test - public void codeBlock() throws Exception { - this.documentationWriter.codeBlock("java", new DocumentationAction() { - - @Override - public void perform() throws IOException { - AsciidoctorWriterTests.this.documentationWriter.println("foo"); - } - }); - - String expectedOutput = String.format("%n[source,java]%n----%nfoo%n----%n%n"); - assertEquals(expectedOutput, this.output.toString()); - } - - @Test - public void shellCommand() throws Exception { - this.documentationWriter.shellCommand(new DocumentationAction() { - - @Override - public void perform() throws IOException { - AsciidoctorWriterTests.this.documentationWriter.println("foo"); - } - }); - - String expectedOutput = String.format("%n[source,bash]%n----%n$ foo%n----%n%n"); - assertEquals(expectedOutput, this.output.toString()); - } - - @Test - public void table() throws Exception { - this.documentationWriter.table(new TableAction() { - - @Override - public void perform(TableWriter tableWriter) throws IOException { - tableWriter.headers("One", "Two", "Three"); - tableWriter.row("alpha", "bravo", "charlie"); - tableWriter.row("foo", "bar", "baz"); - } - - }); - String expectedOutput = String - .format("%n|===%n|One|Two|Three%n%n|alpha%n|bravo%n|charlie%n%n|foo%n|bar%n|baz%n%n|===%n%n"); - assertEquals(expectedOutput, this.output.toString()); - System.out.println(this.output.toString()); - } -} diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/test/SnippetMatchers.java b/spring-restdocs/src/test/java/org/springframework/restdocs/test/SnippetMatchers.java index a8cdb4d0..46eecfcd 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/test/SnippetMatchers.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/test/SnippetMatchers.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.io.StringWriter; import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; import java.util.List; import org.hamcrest.BaseMatcher; @@ -100,8 +101,12 @@ public class SnippetMatchers { private String getLinesAsString() { StringWriter writer = new StringWriter(); - for (String line : this.lines) { - writer.append(String.format("%s%n", line)); + Iterator iterator = this.lines.iterator(); + while (iterator.hasNext()) { + writer.append(String.format("%s", iterator.next())); + if (iterator.hasNext()) { + writer.append(String.format("%n")); + } } return writer.toString(); } @@ -111,16 +116,14 @@ public class SnippetMatchers { extends AbstractSnippetContentMatcher { protected AsciidoctorCodeBlockMatcher(String language) { - this.addLine(""); this.addLine("[source," + language + "]"); this.addLine("----"); this.addLine("----"); - this.addLine(""); } @SuppressWarnings("unchecked") public T content(String content) { - this.addLine(-2, content); + this.addLine(-1, content); return (T) this; } @@ -129,7 +132,7 @@ public class SnippetMatchers { public static abstract class HttpMatcher> extends AsciidoctorCodeBlockMatcher> { - private int headerOffset = 4; + private int headerOffset = 3; protected HttpMatcher() { super("http"); @@ -164,7 +167,6 @@ public class SnippetMatchers { public static class AsciidoctorTableMatcher extends AbstractSnippetContentMatcher { private AsciidoctorTableMatcher(String... columns) { - this.addLine(""); this.addLine("|==="); String header = "|" + StringUtils @@ -172,14 +174,13 @@ public class SnippetMatchers { this.addLine(header); this.addLine(""); this.addLine("|==="); - this.addLine(""); } public AsciidoctorTableMatcher row(String... entries) { for (String entry : entries) { - this.addLine(-2, "|" + entry); + this.addLine(-1, "|" + entry); } - this.addLine(-2, ""); + this.addLine(-1, ""); return this; } } diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/test/StubMvcResult.java b/spring-restdocs/src/test/java/org/springframework/restdocs/test/StubMvcResult.java index 1d4d4651..39ef530d 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/test/StubMvcResult.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/test/StubMvcResult.java @@ -19,6 +19,9 @@ package org.springframework.restdocs.test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockServletContext; +import org.springframework.restdocs.templates.StandardTemplateResourceResolver; +import org.springframework.restdocs.templates.TemplateEngine; +import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.RequestBuilder; import org.springframework.web.servlet.FlashMap; @@ -76,6 +79,8 @@ public class StubMvcResult implements MvcResult { private StubMvcResult(MockHttpServletRequest request, MockHttpServletResponse response) { this.request = request; + this.request.setAttribute(TemplateEngine.class.getName(), + new MustacheTemplateEngine(new StandardTemplateResourceResolver())); this.response = response; } diff --git a/spring-restdocs/src/test/resources/custom-snippet-templates/org/springframework/restdocs/templates/curl-request.snippet b/spring-restdocs/src/test/resources/custom-snippet-templates/org/springframework/restdocs/templates/curl-request.snippet new file mode 100644 index 00000000..07f3a48f --- /dev/null +++ b/spring-restdocs/src/test/resources/custom-snippet-templates/org/springframework/restdocs/templates/curl-request.snippet @@ -0,0 +1 @@ +Custom curl request \ No newline at end of file