Browse Source

first commit

master
Jonathan Cobb 4 years ago
commit
2ea8adc4ed
22 changed files with 1473 additions and 0 deletions
  1. +9
    -0
      .gitignore
  2. +202
    -0
      LICENSE.txt
  3. +41
    -0
      README.md
  4. +119
    -0
      pom.xml
  5. +23
    -0
      src/main/java/org/cobbzilla/restex/RestexCaptureTarget.java
  6. +108
    -0
      src/main/java/org/cobbzilla/restex/RestexClientConnection.java
  7. +22
    -0
      src/main/java/org/cobbzilla/restex/RestexClientConnectionFactory.java
  8. +35
    -0
      src/main/java/org/cobbzilla/restex/RestexClientConnectionManager.java
  9. +28
    -0
      src/main/java/org/cobbzilla/restex/RestexClientConnectionRequest.java
  10. +92
    -0
      src/main/java/org/cobbzilla/restex/targets/SimpleCaptureTarget.java
  11. +294
    -0
      src/main/java/org/cobbzilla/restex/targets/TemplateCaptureTarget.java
  12. +69
    -0
      src/main/resources/defaultEntry.hbs
  13. +3
    -0
      src/main/resources/defaultFooter.hbs
  14. +147
    -0
      src/main/resources/defaultHeader.hbs
  15. +85
    -0
      src/main/resources/defaultIndex.hbs
  16. +14
    -0
      src/main/resources/defaultIndexMore.hbs
  17. +135
    -0
      src/test/java/org/cobbzilla/restex/RestexIT.java
  18. +20
    -0
      src/test/resources/testTemplateEntry.hbs
  19. +3
    -0
      src/test/resources/testTemplateFooter.hbs
  20. +6
    -0
      src/test/resources/testTemplateHeader.hbs
  21. +15
    -0
      src/test/resources/testTemplateIndex.hbs
  22. +3
    -0
      src/test/resources/testTemplateIndexMore.hbs

+ 9
- 0
.gitignore View File

@@ -0,0 +1,9 @@
*.iml
.idea
target
tmp
logs
dependency-reduced-pom.xml
velocity.log
*~
build.log

+ 202
- 0
LICENSE.txt View File

@@ -0,0 +1,202 @@

Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

1. Definitions.

"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.

"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.

"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.

"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.

"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.

"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.

"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).

"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.

"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."

"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.

2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.

3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.

4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:

(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and

(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and

(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and

(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.

You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.

5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.

6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.

7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.

8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.

9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.

END OF TERMS AND CONDITIONS

APPENDIX: How to apply the Apache License to your work.

To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.

restex is Copyright 2013 Jonathan Cobb

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.

+ 41
- 0
README.md View File

@@ -0,0 +1,41 @@
restex
======

Capture your REST integration tests and present them as examples for API consumers.

Use this in your JUnit-style test cases to capture request/response conversations with context.

protected static TemplateCaptureTarget apiDocs = new TemplateCaptureTarget("target/api-examples");

@Override
protected HttpClient getHttpClient() {
return new DefaultHttpClient(new RestexClientConnectionManager(apiDocs));
}

@After
public void commitDocCapture () throws Exception {
apiDocs.commit();
}

@AfterClass
public static void finalizeDocCapture () throws Exception {
apiDocs.close();
}

@Test
public void someTest() throws Exception {
apiDocs.startRecording("some class of operations", "this particular operation");
apiDocs.addNote("going to do step #1");
... do step 1, something that will use the HttpClient from getHttpClient() ...
apiDocs.addNote("going to do step #2");
... do step 2, something that will use the HttpClient from getHttpClient() ...
}

@Test
public void anotherTest() throws Exception {
apiDocs.startRecording("some class of operations", "another particular operation");
apiDocs.addNote("going to do step #1");
... do step 1, something that will use the HttpClient from getHttpClient() ...
apiDocs.addNote("going to do step #2");
... do step 2, something that will use the HttpClient from getHttpClient() ...
}

+ 119
- 0
pom.xml View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>

<!--
(c) Copyright 2013 Jonathan Cobb
This code is available under the Apache License, version 2: http://www.apache.org/licenses/LICENSE-2.0.html
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.cobbzilla</groupId>
<artifactId>cobbzilla-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>

<artifactId>restex</artifactId>
<name>restex</name>
<version>1.0.1-SNAPSHOT</version>
<packaging>jar</packaging>

<licenses>
<license>
<name>The Apache Software License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
<distribution>repo</distribution>
</license>
</licenses>

<scm>
<url>http://github.com/cobbzilla/restex</url>
<connection>scm:git:git@github.com:cobbzilla/restex.git</connection>
<developerConnection>scm:git:git@github.com:cobbzilla/restex.git</developerConnection>
</scm>

<dependencies>

<!-- httpclient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>${httpcore.version}</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>${httpclient.version}</version>
</dependency>

<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<version>${slf4j.version}</version>
<scope>runtime</scope>
</dependency>

<!-- Testing -->
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-http</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>

<!-- auto-generate java boilerplate -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>compile</scope>
</dependency>

<!-- handlebars templates -->
<dependency>
<groupId>com.github.jknack</groupId>
<artifactId>handlebars</artifactId>
<version>${handlebars.version}</version>
</dependency>
<dependency>
<groupId>com.github.jknack</groupId>
<artifactId>handlebars-jackson2</artifactId>
<version>${handlebars.version}</version>
</dependency>

<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${commons-io.version}</version>
</dependency>
</dependencies>

<build>
<plugins>
<!-- use Java 11 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.3.2</version>
<configuration>
<source>11</source>
<target>11</target>
<showWarnings>true</showWarnings>
</configuration>
</plugin>
</plugins>
</build>

</project>

+ 23
- 0
src/main/java/org/cobbzilla/restex/RestexCaptureTarget.java View File

@@ -0,0 +1,23 @@
package org.cobbzilla.restex;

import java.io.IOException;

public interface RestexCaptureTarget {

public void requestUri (String method, String uri);

public void requestHeader(String name, String value);

public void requestEntity(String entityData);

public void responseStatus(int statusCode, String reasonPhrase, String protocolVersion);

public void responseHeader(String name, String value);

public void responseEntity(String entityData);

public void commit() throws IOException;

public void setBinaryRequest(String hint);
public void setBinaryResponse(String hint);
}

+ 108
- 0
src/main/java/org/cobbzilla/restex/RestexClientConnection.java View File

@@ -0,0 +1,108 @@
package org.cobbzilla.restex;

import lombok.Cleanup;
import lombok.Delegate;
import org.apache.commons.io.IOUtils;
import org.apache.http.*;
import org.apache.http.conn.ManagedHttpClientConnection;
import org.apache.http.entity.BasicHttpEntity;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringWriter;

public class RestexClientConnection implements ManagedHttpClientConnection {

private interface ConnectionIO {
void sendRequestHeader(HttpRequest httpRequest) throws HttpException, IOException;
void sendRequestEntity(HttpEntityEnclosingRequest httpEntityEnclosingRequest) throws HttpException, IOException;
HttpResponse receiveResponseHeader() throws HttpException, IOException;
void receiveResponseEntity(HttpResponse httpResponse) throws HttpException, IOException;
}

@Delegate(excludes=ConnectionIO.class, types={ManagedHttpClientConnection.class, HttpConnection.class})
private final ManagedHttpClientConnection delegate;

private final RestexCaptureTarget target;

public RestexClientConnection(ManagedHttpClientConnection connection, RestexCaptureTarget target) {
this.delegate = connection;
this.target = target;
}

@Override
public void sendRequestHeader(HttpRequest httpRequest) throws HttpException, IOException {
target.requestUri(httpRequest.getRequestLine().getMethod(), httpRequest.getRequestLine().getUri());
final Header[] headers = httpRequest.getAllHeaders();
for (Header header : headers) {
target.requestHeader(header.getName(), header.getValue());
}
delegate.sendRequestHeader(httpRequest);
}

@Override
public void sendRequestEntity(HttpEntityEnclosingRequest httpEntityEnclosingRequest) throws HttpException, IOException {

// Read the entire request into a String
StringWriter writer = new StringWriter();
final HttpEntity entity = httpEntityEnclosingRequest.getEntity();
final String entityData;
if (entity != null && entity.getContent() != null) {
@Cleanup final InputStreamReader in = new InputStreamReader(entity.getContent());
IOUtils.copyLarge(in, writer);
entityData = writer.toString();
} else {
entityData = "";
}
target.requestEntity(entityData);

// Re-create the entity since we already read the entire InputStream
httpEntityEnclosingRequest.setEntity(buildEntity(entityData));

delegate.sendRequestEntity(httpEntityEnclosingRequest);
}

@Override
public HttpResponse receiveResponseHeader() throws HttpException, IOException {
final HttpResponse httpResponse = delegate.receiveResponseHeader();

target.responseStatus(httpResponse.getStatusLine().getStatusCode(), httpResponse.getStatusLine().getReasonPhrase(), httpResponse.getStatusLine().getProtocolVersion().toString());

final Header[] headers = httpResponse.getAllHeaders();
for (Header header : headers) {
target.responseHeader(header.getName(), header.getValue());
}
return httpResponse;
}

@Override
public void receiveResponseEntity(HttpResponse response) throws HttpException, IOException {

delegate.receiveResponseEntity(response);

// Read the entire response into a String
StringWriter writer = new StringWriter();
final HttpEntity entity = response.getEntity();
final String entityData;
if (entity != null && entity.getContent() != null) {
@Cleanup final InputStreamReader in = new InputStreamReader(entity.getContent());
IOUtils.copyLarge(in, writer);
entityData = writer.toString();
} else {
entityData = null;
}
target.responseEntity(entityData);

// Re-create the entity since we already read the entire InputStream
response.setEntity(buildEntity(entityData));
}

private BasicHttpEntity buildEntity(String entityData) {
final BasicHttpEntity entity = new BasicHttpEntity();
entity.setContentLength(entityData == null ? 0 : entityData.length());
entity.setContent(new ByteArrayInputStream(entityData == null ? "".getBytes() : entityData.getBytes()));
return entity;
}

}

+ 22
- 0
src/main/java/org/cobbzilla/restex/RestexClientConnectionFactory.java View File

@@ -0,0 +1,22 @@
package org.cobbzilla.restex;

import org.apache.http.config.ConnectionConfig;
import org.apache.http.conn.HttpConnectionFactory;
import org.apache.http.conn.ManagedHttpClientConnection;
import org.apache.http.conn.routing.HttpRoute;
import org.apache.http.impl.conn.ManagedHttpClientConnectionFactory;

public class RestexClientConnectionFactory implements HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection> {

private RestexClientConnectionManager connectionManager;
private ManagedHttpClientConnectionFactory connectionFactory = new ManagedHttpClientConnectionFactory();

public RestexClientConnectionFactory(RestexClientConnectionManager connectionManager) {
this.connectionManager = connectionManager;
}

@Override
public ManagedHttpClientConnection create(HttpRoute route, ConnectionConfig config) {
return new RestexClientConnection(connectionFactory.create(route, config), connectionManager.getTarget());
}
}

+ 35
- 0
src/main/java/org/cobbzilla/restex/RestexClientConnectionManager.java View File

@@ -0,0 +1,35 @@
package org.cobbzilla.restex;

import lombok.Delegate;
import lombok.Getter;
import org.apache.http.client.HttpClient;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.BasicHttpClientConnectionManager;

public class RestexClientConnectionManager implements HttpClientConnectionManager {

@Delegate private final BasicHttpClientConnectionManager delegate;
@Getter private final RestexCaptureTarget target;

private static Registry<ConnectionSocketFactory> getDefaultRegistry() {
return RegistryBuilder.<ConnectionSocketFactory>create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", SSLConnectionSocketFactory.getSocketFactory())
.build();
}

public RestexClientConnectionManager(RestexCaptureTarget target) {
this.delegate = new BasicHttpClientConnectionManager(getDefaultRegistry(), new RestexClientConnectionFactory(this));
this.target = target;
}

public HttpClient getHttpClient () {
return HttpClientBuilder.create().setConnectionManager(new RestexClientConnectionManager(target)).build();
}
}

+ 28
- 0
src/main/java/org/cobbzilla/restex/RestexClientConnectionRequest.java View File

@@ -0,0 +1,28 @@
package org.cobbzilla.restex;

import org.apache.http.conn.ConnectionPoolTimeoutException;
import org.apache.http.conn.ConnectionRequest;
import org.apache.http.conn.ManagedHttpClientConnection;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

public class RestexClientConnectionRequest implements ConnectionRequest {

private final ConnectionRequest delegate;
private final RestexCaptureTarget target;

public RestexClientConnectionRequest(ConnectionRequest clientConnectionRequest, RestexCaptureTarget target) {
this.delegate = clientConnectionRequest;
this.target = target;
}

@Override public boolean cancel() { return delegate.cancel(); }

@Override
public ManagedHttpClientConnection get(long timeout, TimeUnit tunit) throws InterruptedException, ExecutionException, ConnectionPoolTimeoutException {
final ManagedHttpClientConnection connection = (ManagedHttpClientConnection) delegate.get(timeout, tunit);
return new RestexClientConnection(connection, target);
}

}

+ 92
- 0
src/main/java/org/cobbzilla/restex/targets/SimpleCaptureTarget.java View File

@@ -0,0 +1,92 @@
package org.cobbzilla.restex.targets;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.cobbzilla.restex.RestexCaptureTarget;

import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@ToString(of={"requestMethod", "requestUri", "requestEntity", "note"})
public class SimpleCaptureTarget implements RestexCaptureTarget {

@Getter @Setter private int statusCode;
@Getter @Setter private String reasonPhrase;
@Getter @Setter private String protocolVersion;

@Getter @Setter private String requestMethod;
@Getter @Setter private String requestUri;
@Getter @Setter private Map<String, String> requestHeaders = new LinkedHashMap<>();
@Getter @Setter private Map<String, String> responseHeaders = new LinkedHashMap<>();

@Getter @Setter private String requestEntity;
@Getter @Setter private String responseEntity;

@Getter @Setter private String binaryRequest;
@Getter @Setter private String binaryResponse;

@Getter @Setter private String note;
public void appendNote (String n) { if (note == null) { note = n ; } else { note += "\n" + n; } }

public List<String> getRequestHeaderList () { return buildHeaderList(requestHeaders); }
public List<String> getResponseHeaderList () { return buildHeaderList(responseHeaders); }

public String getResponseLine () { return statusCode + " " + (reasonPhrase == null ? "" : reasonPhrase) + " " + protocolVersion; }

private List<String> buildHeaderList(Map<String, String> headers) {
List<String> headerList = new ArrayList<>();
for (String header : headers.keySet()) {
headerList.add(header + ": " + headers.get(header));
}
return headerList;
}

@Override public void requestUri (String method, String uri) {
setRequestMethod(method);
setRequestUri(uri);
}

@Override public void requestHeader(String name, String value) { requestHeaders.put(name, value); }

@Override public void requestEntity(String entityData) {
if (binaryRequest != null) {
requestEntity = "Binary request: " + binaryRequest;
} else {
requestEntity = entityData;
}
}

@Override public void responseStatus(int statusCode, String reasonPhrase, String protocolVersion) {
this.statusCode = statusCode; this.reasonPhrase = reasonPhrase; this.protocolVersion = protocolVersion;
}

@Override public void responseHeader(String name, String value) { responseHeaders.put(name, value); }

@Override public void responseEntity(String entityData) {
if (binaryResponse != null) {
responseEntity = "Binary response: " + binaryResponse;
} else {
responseEntity = entityData;
}
}

@Override public void commit() throws IOException {}

public void reset () {
requestUri = null;
requestHeaders = new LinkedHashMap<>();
requestEntity = null;

statusCode = -1;
reasonPhrase = null;
protocolVersion = null;

responseHeaders = new LinkedHashMap<>();
responseEntity = null;
}

}

+ 294
- 0
src/main/java/org/cobbzilla/restex/targets/TemplateCaptureTarget.java View File

@@ -0,0 +1,294 @@
package org.cobbzilla.restex.targets;

import com.github.jknack.handlebars.Handlebars;
import com.github.jknack.handlebars.Helper;
import com.github.jknack.handlebars.Options;
import com.github.jknack.handlebars.Template;
import com.github.jknack.handlebars.io.ClassPathTemplateLoader;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.cobbzilla.restex.RestexCaptureTarget;

import java.io.*;
import java.util.*;

@Slf4j
public class TemplateCaptureTarget implements RestexCaptureTarget {

public static final String SCOPE_HTTP = "http";
public static final String SCOPE_ANCHOR = "anchor";
public static final String SCOPE_FILES = "files";
public static final String HTML_SUFFIX = ".html";

public static final String FOOTER_START = "@@FOOTER@@";
public static final String INDEX_INSERTION_POINT = "@@MORE-INDEX-FILES@@";

public static final String DEFAULT_INDEX_TEMPLATE = "defaultIndex";
public static final String DEFAULT_INDEX_MORE_TEMPLATE = "defaultIndexMore";
public static final String DEFAULT_HEADER_TEMPLATE = "defaultHeader";
public static final String DEFAULT_FOOTER_TEMPLATE = "defaultFooter";
public static final String DEFAULT_ENTRY_TEMPLATE = "defaultEntry";

@Getter private File baseDir;

private final Template indexTemplate;
private final Template indexMoreTemplate;
private final Template headerTemplate;
private final Template footerTemplate;
private final Template entryTemplate;

private SortedSet<ContextFile> contextFiles = new TreeSet<>();
private Map<String, ContextFile> contextFileMap = new HashMap<>();
private final Set<File> filesOpen = new HashSet<>();

// current state, initialized in startRecording, reset in commit
private SimpleCaptureTarget currentCapture = null;
@Getter private final List<SimpleCaptureTarget> captures = new ArrayList<>();
private boolean recording = false;
@Getter private String context = "";
@Getter private String comment = "";

public TemplateCaptureTarget (String baseDir) {
this(new File(baseDir), DEFAULT_INDEX_TEMPLATE, DEFAULT_INDEX_MORE_TEMPLATE, DEFAULT_HEADER_TEMPLATE, DEFAULT_FOOTER_TEMPLATE, DEFAULT_ENTRY_TEMPLATE);
}

public TemplateCaptureTarget (File baseDir) {
this(baseDir, DEFAULT_INDEX_TEMPLATE, DEFAULT_INDEX_MORE_TEMPLATE, DEFAULT_HEADER_TEMPLATE, DEFAULT_FOOTER_TEMPLATE, DEFAULT_ENTRY_TEMPLATE);
}

public TemplateCaptureTarget (File baseDir,
String indexTemplate,
String indexMoreTemplate,
String headerTemplate,
String footerTemplate,
String entryTemplate) {
this.baseDir = baseDir;
if (!baseDir.exists() && !baseDir.mkdirs()) {
throw new IllegalArgumentException("baseDir does not exist and could not be created: "+baseDir.getAbsolutePath());
}

final Handlebars handlebars = new Handlebars(new ClassPathTemplateLoader("/"));
handlebars.registerHelper("nl2br", new Helper<Object>() {
public CharSequence apply(Object src, Options options) {
return src == null || src.toString().isEmpty() ? "" : new Handlebars.SafeString(src.toString().replace("\n", "<br/>"));
}
});

this.indexTemplate = compileOrDie(indexTemplate, handlebars);
this.indexMoreTemplate = compileOrDie(indexMoreTemplate, handlebars);
this.headerTemplate = compileOrDie(headerTemplate, handlebars);
this.footerTemplate = compileOrDie(footerTemplate, handlebars);
this.entryTemplate = compileOrDie(entryTemplate, handlebars);
}

public Template compileOrDie(String template, Handlebars handlebars) {
try {
return handlebars.compile(template);
} catch (IOException e) {
throw new IllegalStateException("Error compiling template '"+ template+"': "+e, e);
}
}

public synchronized void startRecording (String context, String comment) {
if (recording) {
log.warn("startRecording: cannot start "+context+"/"+comment+", already recording "+this.context+"/"+this.comment);
try {
if (!captures.isEmpty()) commit();
} catch (IOException e) {
log.error("startRecording: error committing docs: "+e);
} finally {
reset();
}
}
this.context = context;
this.comment = comment;
currentCapture = new SimpleCaptureTarget();
recording = true;
}

@Override public void requestUri(String method, String uri) {
if (currentCapture != null) currentCapture.requestUri(method, uri);
}

@Override public void requestHeader(String name, String value) { if (recording) currentCapture.requestHeader(name, value); }
@Override public void requestEntity(String entityData) { if (recording) currentCapture.requestEntity(entityData); }
@Override public void responseStatus(int statusCode, String reasonPhrase, String protocolVersion) {
if (recording) currentCapture.responseStatus(statusCode, reasonPhrase, protocolVersion);
}
@Override public void responseHeader(String name, String value) { if (recording) currentCapture.responseHeader(name, value); }
@Override public synchronized void responseEntity(String entityData) {
if (recording) {
currentCapture.responseEntity(entityData);
captures.add(currentCapture);
currentCapture = new SimpleCaptureTarget();
}
}

public void addNote (String note) { if (recording) currentCapture.appendNote(note); }

@Override public void setBinaryRequest (String type) { if (recording) currentCapture.setBinaryRequest(type); }
@Override public void setBinaryResponse (String type) { if (recording) currentCapture.setBinaryResponse(type); }

public synchronized void commit () throws IOException {

final String fileBaseName = context.replaceAll("[^A-Za-z0-9]", "_");
if (fileBaseName == null || fileBaseName.length() == 0) {
log.warn("No context name set (not committing). currentCapture="+currentCapture);
return;
}
final String uriFileName = fileBaseName + HTML_SUFFIX;

ContextFile contextFile = contextFileMap.get(context);
if (contextFile == null) {
contextFile = new ContextFile(context, uriFileName);
contextFileMap.put(context, contextFile);
}
contextFiles.add(contextFile);

String anchor = comment.replaceAll("[^A-Za-z0-9]", "_");
contextFile.add(new ContextExample(anchor, comment));

final File uriFile = new File(baseDir, uriFileName);
final boolean exists = uriFile.exists();

synchronized (filesOpen) {
if (!exists) {
// first time writing to the file, so write the header
filesOpen.add(uriFile);
try (FileWriter writer = new FileWriter(uriFile)) {
render(headerTemplate, writer);
}
} else {
// file exists -- have we written to it yet?
if (!filesOpen.contains(uriFile)) {
// file exists but we have not written to it, so rewrite the file without the footer
removeFooter(uriFile);
}
}
}

try (FileWriter writer = new FileWriter(uriFile, true)) {
// append the entry
renderEntry(entryTemplate, writer, anchor);
}

reset();
}

public synchronized void reset() {
recording = false;
context = "";
comment = "";
captures.clear();
currentCapture = null;
}

public synchronized void close () throws IOException {
if (recording) commit();
for (File f : filesOpen) {
try (FileWriter writer = new FileWriter(f, true)) {
render(footerTemplate, writer);
}
}
final File indexFile = new File(baseDir, "index.html");
if (!indexFile.exists()) {
try (FileWriter writer = new FileWriter(indexFile)) {
renderIndex(indexTemplate, writer);
}
} else {
StringWriter writer = new StringWriter();
renderIndex(indexMoreTemplate, writer);
replaceInFile(indexFile, INDEX_INSERTION_POINT, writer.toString());
}
filesOpen.clear();
contextFiles.clear();
contextFileMap.clear();
}

protected synchronized void apply(final Template template, Writer writer, Map<String, Object> scope) {
synchronized (captures) {
try {
template.apply(scope, writer);
} catch (IOException e) {
throw new IllegalStateException("Error applying template '" + template.filename() + "': " + e, e);
}
}
}

protected void renderEntry(Template template, Writer writer, String anchor) {
Map<String, Object> scope = new HashMap<>();
scope.put(SCOPE_HTTP, this);
scope.put(SCOPE_ANCHOR, anchor);
apply(template, writer, scope);
}

protected void render(Template template, Writer writer) {
Map<String, Object> scope = new HashMap<>();
scope.put(SCOPE_HTTP, this);
apply(template, writer, scope);
}

protected void renderIndex(Template template, Writer writer) {
Map<String, Object> scope = new HashMap<>();
scope.put(SCOPE_FILES, contextFiles);
apply(template, writer, scope);
}

private void removeFooter(File uriFile) throws IOException {
File temp = File.createTempFile(getClass().getSimpleName(), HTML_SUFFIX, uriFile.getParentFile());
try (BufferedReader reader = new BufferedReader(new FileReader(uriFile))) {
try (FileWriter writer = new FileWriter(temp)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains(FOOTER_START)) break;
writer.write(line + "\n");
}
}
}
if (!temp.renameTo(uriFile)) {
throw new IllegalStateException("Error rewriting footer in file: "+uriFile.getAbsolutePath());
}
}

private void replaceInFile(File file, String insertionPoint, String data) throws IOException {
File temp = File.createTempFile(getClass().getSimpleName(), HTML_SUFFIX, file.getParentFile());
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
try (FileWriter writer = new FileWriter(temp)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.contains(insertionPoint)) {
writer.write(data);
}
writer.write(line + "\n");
}
}
}
if (!temp.renameTo(file)) {
throw new IllegalStateException("Error rewriting file: "+file.getAbsolutePath());
}
}

@AllArgsConstructor @EqualsAndHashCode(of="context")
class ContextFile implements Comparable {

@Getter @Setter public String context;
@Getter @Setter public String fsPath;

@Getter public final List<ContextExample> examples = new ArrayList<>();
public void add(ContextExample contextExample) { examples.add(contextExample); }

@Override
public int compareTo(Object o) {
return (o instanceof ContextFile) ? context.compareTo(((ContextFile) o).getContext()) : 0;
}
}

@AllArgsConstructor
class ContextExample {
@Getter @Setter public String anchor;
@Getter @Setter public String description;
}
}

+ 69
- 0
src/main/resources/defaultEntry.hbs View File

@@ -0,0 +1,69 @@
<a name="{{anchor}}"></a>

<div class="request_details" style="margin-top: 10px"><p class="title">TEST CASE: {{http.comment}}</p></div>

<table>
<thead>
<tr><td class="summary" colspan="4">SUMMARY</td></tr>
<tr>
<td class="type_column">Type</td>
<td >Request</td>
<td class="response_column">Response</td>
<td >Notes</td>
</tr>
</thead>
<tbody>
{{#each http.captures}}
<tr>
<td nowrap=""><div class="block margin_0">{{requestMethod}}</div></td>
<td nowrap=""><a href="#{{anchor}}_{{@index}}">{{requestUri}}</a></td>
<td nowrap="">{{responseLine}}</td>
<td nowrap="">{{nl2br note}}</td>
</tr>
{{/each}}
</tbody>
</table>

{{#each http.captures}}

<a name="{{anchor}}_{{@index}}"></a>

<div class="request_details">
<div class="block margin_0" style="margin-right: 5px">{{requestMethod}}</div>{{requestUri}}
<div class="request_description">{{nl2br note}}</div>
</div>

<div class="request_response_container">
<p class="request">Request</p>

<p class="item_description">header</p>
<div class="request_header">
{{#each requestHeaderList}}
<p >{{this}}</p>
{{/each}}
</div>

<p class="item_description">body</p>
<div class="request_body">
<pre>{{{requestEntity}}}</pre>
</div>
</div>

<div class="request_response_container">
<p class="request">Response</p>

<p class="item_description">header</p>
<div class="request_header">
<p >{{responseLine}}</p>
{{#each responseHeaderList}}
<p>{{this}}<p/>
{{/each}}
</div>

<p class="item_description">body</p>
<div class="request_body">
<pre>{{{responseEntity}}}</pre>
</div>
</div>

{{/each}}

+ 3
- 0
src/main/resources/defaultFooter.hbs View File

@@ -0,0 +1,3 @@
<!-- @@FOOTER@@ must be first line in footer file -->
</body>
</html>

+ 147
- 0
src/main/resources/defaultHeader.hbs View File

@@ -0,0 +1,147 @@
<html>
<head>
<title>Example HTTP exchanges for {{http.requestUri}}</title>
<style type="text/css">

@font-face {
font-family: "Kimberley";
src: url(http://www.princexml.com/fonts/larabie/ »
kimberle.ttf) format("truetype");
}

*{
font-family: "Arial", sans-serif
}

table{
min-width: 800px;
}

td{
vertical-align: middle;
border: 1px solid #000000;
border-width: 0px 0px 1px 0px;
text-align: left;
padding: 7px;
font-size: 12px;
font-family: Arial;
font-weight: normal;
color: #000000;
}

tr{
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
}

pre{
margin: 0px;
font-size: 13px;
}

.border{
border: 1px solid gray;
}

.centered{
text-align: center
}

.block{
background-color: #03A2CC;
display: inline-block;
padding: 5px;
border-radius: 5px;
color: white;
}

.type_column{
width: 70px;
}

.response_column{
width: 100px;
}

.margin_0{
margin: 0px;
}

.summary{
font-size: 20px;
color: #03A2CC;;
}

.request_details{
height: 28px;
background-color: rgba(156, 156, 156, 0.48);
padding: 10px;
color: #6F6F6F;
margin-top: 35px;
margin-bottom: 25px;
}

.title{
margin: 3px;
font-size: 20px;
}

.request_description{
display: inline-block;
float: right;
margin-top: 4px;
}
.request_header{
width: 500px;
padding: 10px;
color: white;
background-color: #353535;
margin-bottom: 10px;
}
.request_body{
width: 500px;
padding: 10px;
color: white;
background-color: #353535;
margin-bottom: 10px;
}

.request_header>p{
margin: 0px;
font-size: 13px;
}

.request_body>p{
margin: 0px;
font-size: 13px;
}

.item_description{
margin-bottom: 0px;
}

.request{
font-size: 20px;
margin: 0px;
}

.request_response_container{
border: 1px solid rgba(158, 158, 158, 0.52);
border-radius: 5px;
padding: 10px;
margin-bottom: 15px;
}

.main_container{
width: 800px;
margin-left: auto;
margin-right: auto;
}

</style>
</head>
<body>


+ 85
- 0
src/main/resources/defaultIndex.hbs View File

@@ -0,0 +1,85 @@
<html>
<head>
<title>Examples of all HTTP exchanges with this app</title>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<style type="text/css">
a{
color: white;
text-decoration: inherit;
font-variant: small-caps;
font-family: sans-serif;
}
.container{
width: 500px;
padding: 10px;
font-family: sans-serif;
}
.item{ margin: 10px; font-family: sans-serif;}
.subItem{ margin: 5px; font-family: sans-serif;}
.container li > a > span {
float: right;
font-size: 19px;
font-weight: bolder;
font-family: sans-serif;
}
.container li > a > span {
width: 20px;
height: 20px;
text-align: center;
font-family: sans-serif;
}
.container li.open > a > span:after { content: '\25b4'; }
.container li > a > span:after { content: '\25be'; }
.container li { list-style: none; }
.ic{ margin: 5px; }
.itemC{
margin: 5px;
padding: 5px;
padding-top: 9px;
min-height: 22px;
border-radius: 5px;
background-color: #03A2CC;
font-family: sans-serif;
}
</style>
</head>
<body>

<ul class="container">

{{#each files}}
<li class="itemC">
<a class="item" href="{{fsPath}}"><span class="openItem"></span>{{context}}</a>
<ul class="subItem">

{{#each examples}}
<li class="ic">
<a href="{{fsPath}}#{{anchor}}">{{description}}</a>
</li>
{{/each}}

</ul>
</li>
{{/each}}
<!-- @@MORE-INDEX-FILES@@ -->
</ul>

</body>
<script type="text/javascript">
$(document).ready(function () {
var $subitem = $(".subitem");
$subitem.hide();
$('.itemC').removeClass('open');
$('.openItem').on("click", function (e) {
e.preventDefault();
isOpened = $($(this).parent().parent()).hasClass('open');
$('.itemC').removeClass('open');
$subitem.hide();
if(!isOpened){
$('.subitem', $(this).parent().parent()).show();
$($(this).parent().parent()).addClass('open');
}
});
});
</script>
</html>

+ 14
- 0
src/main/resources/defaultIndexMore.hbs View File

@@ -0,0 +1,14 @@
{{#each files}}
<li class="itemC">
<a class="item" href="{{fsPath}}"><span class="openItem"></span>{{context}}</a>
<ul class="subItem">

{{#each examples}}
<li class="ic">
<a href="{{fsPath}}#{{anchor}}">{{description}}</a>
</li>
{{/each}}

</ul>
</li>
{{/each}}

+ 135
- 0
src/test/java/org/cobbzilla/restex/RestexIT.java View File

@@ -0,0 +1,135 @@
package org.cobbzilla.restex;

import lombok.extern.slf4j.Slf4j;
import org.apache.http.Header;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.message.BasicHeader;
import org.cobbzilla.restex.targets.SimpleCaptureTarget;
import org.cobbzilla.restex.targets.TemplateCaptureTarget;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.Map;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

@Slf4j
public class RestexIT {

public static final int TEST_PORT = 19091;

private Server server;

class RestexTestHandler extends AbstractHandler {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
// do nothing
log.info("handle called.");
response.setStatus(200);
final Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
response.addHeader(headerName, request.getHeader(headerName));
}
response.getWriter().write("foo");
response.getWriter().flush();
response.setContentLength("foo".length());
}
}

@Before
public void setUp () throws Exception {
server = new Server(TEST_PORT);
server.setHandler(new RestexTestHandler());
server.start();
}

@After public void tearDown () throws Exception { server.stop(); }

@Test
public void testSimpleCapture () throws Exception {
RestexCaptureTarget target = new SimpleCaptureTarget();
HttpClient httpClient = new RestexClientConnectionManager(target).getHttpClient();
HttpGet httpGet = new HttpGet("http://127.0.0.1:"+TEST_PORT+"/test");
String requestHeaderName1 = "foo-"+System.currentTimeMillis();
String requestHeaderValue1 = "bar-"+System.currentTimeMillis();
httpGet.addHeader(new BasicHeader(requestHeaderName1, requestHeaderValue1));

final HttpResponse response = httpClient.execute(httpGet);
final Map<String, String> responseHeaderMap = buildResponseHeaderMap(response);
assertTrue("didn't find response header", responseHeaderMap.containsKey(requestHeaderName1));
assertEquals("wrong request header value for "+requestHeaderName1, requestHeaderValue1, responseHeaderMap.get(requestHeaderName1));
}

@Test
public void testTemplateCapture () throws Exception {

File tempDir = createTempDir(new File(System.getProperty("java.io.tmpdir")), getClass().getSimpleName());
log.info("Writing to tempDir="+tempDir.getAbsolutePath());

TemplateCaptureTarget target = new TemplateCaptureTarget(tempDir, "testTemplateIndex", "testTemplateIndexMore", "testTemplateHeader", "testTemplateFooter", "testTemplateEntry");
HttpClient httpClient = new RestexClientConnectionManager(target).getHttpClient();

String header1;
String value1;
HttpGet httpGet;
HttpResponse response;

target.startRecording("test1", "a single request of /test goes like this");
httpGet = new HttpGet("http://127.0.0.1:"+TEST_PORT+"/test");
header1 = "foo-"+System.currentTimeMillis();
value1 = "bar-"+System.currentTimeMillis();
httpGet.addHeader(new BasicHeader(header1, value1));
response = httpClient.execute(httpGet);
httpGet.releaseConnection();
target.commit();

target.startRecording("test2", "a request of /test and then /test2 goes like this");
httpGet = new HttpGet("http://127.0.0.1:"+TEST_PORT+"/test");
header1 = "foo2-"+System.currentTimeMillis();
value1 = "bar2-"+System.currentTimeMillis();
httpGet.addHeader(new BasicHeader(header1, value1));
response = httpClient.execute(httpGet);
httpGet.releaseConnection();

httpGet = new HttpGet("http://127.0.0.1:"+TEST_PORT+"/test2");
header1 = "foo-"+System.currentTimeMillis();
value1 = "bar-"+System.currentTimeMillis();
httpGet.addHeader(new BasicHeader(header1, value1));
response = httpClient.execute(httpGet);
httpGet.releaseConnection();

target.close();
}

private Map<String, String> buildResponseHeaderMap(HttpResponse response) {
Map<String, String> headers = new LinkedHashMap<>();
for (Header header : response.getAllHeaders()) {
headers.put(header.getName(), header.getValue());
}
return headers;
}

public static File createTempDir(File parentDir, String prefix) throws IOException {
Path parent = FileSystems.getDefault().getPath(parentDir.getAbsolutePath());
return new File(Files.createTempDirectory(parent, prefix).toAbsolutePath().toString());
}

}

+ 20
- 0
src/test/resources/testTemplateEntry.hbs View File

@@ -0,0 +1,20 @@
<hr/>

<h2>{{http.comment}}</h2>

<h3>Request</h3>
<em>{{http.requestMethod}} {{http.requestUri}}</em>
{{#each http.requestHeaderList}}
{{this}}<br/>
{{/each}}
<br/>
{{http.requestEntity}}

<h3>Response</h3>
{{http.responseLine}}
{{#each http.responseHeaderList}}
{{this}}<br/>
{{/each}}
<br/>
{{http.responseEntity}}


+ 3
- 0
src/test/resources/testTemplateFooter.hbs View File

@@ -0,0 +1,3 @@
<!-- @@FOOTER@@ must be first line in footer file -->
</body>
</html>

+ 6
- 0
src/test/resources/testTemplateHeader.hbs View File

@@ -0,0 +1,6 @@
<html>
<head>
<title>Example HTTP exchanges for {{http.requestUri}}</title>
</head>
<body>


+ 15
- 0
src/test/resources/testTemplateIndex.hbs View File

@@ -0,0 +1,15 @@
<html>
<head>
<title>Examples of all HTTP exchanges with this app</title>
</head>
<body>

<ul>
{{#each files}}
<li><a href="{{fsPath}}">{{context}}</a></li>
{{/each}}
<!-- @@MORE-INDEX-FILES@@ -->
</ul>

</body>
</html>

+ 3
- 0
src/test/resources/testTemplateIndexMore.hbs View File

@@ -0,0 +1,3 @@
{{#each files}}
<li><a href="{{fsPath}}">{{context}}</a></li>
{{/each}}

Loading…
Cancel
Save