diff --git a/mock/README.asciidoc b/mock/README.asciidoc new file mode 100644 index 00000000..819a1f8f --- /dev/null +++ b/mock/README.asciidoc @@ -0,0 +1,47 @@ +# feign-mock + +An easy way to test https://github.com/OpenFeign/feign. Since feign stores most of the logic in annotations, this helps to check if the annotations are correct. + +The original article is available https://velo.github.io/2016/06/05/Testing-feign-clients.html[here] + +If mocking feign clients is easy, testing the logic written in annotations is not! + +To check if you are parsing the request/response properly, the only way is firing a real request. Well, that doesn't seem to be a good path to unit (or even integration) test remote services. Any IO change will affect test stability. + +With feign-mock you can use pre-loaded JSON strings or streams as content for your responses. It also allows you to verify mocked invocations and feign-mock will hit your annotations to make sure everything works. + +##### Example + +``` + private GitHub github; + private MockClient mockClient; + + @Before + public void setup() throws IOException { + mockClient = new MockClient() + .noContent(HttpMethod.PATCH, "/repos/velo/feign-mock/contributors"); + + github = Feign.builder() + .decoder(new GsonDecoder()) + .client(mockClient) + .target(new MockTarget<>(GitHub.class)); + } + + @After + public void tearDown() { + mockClient.verifyStatus(); + } + + @Test + public void missHttpMethod() { + List result = github.patchContributors("velo", "feign-mock"); + assertThat(result, nullValue()); + mockClient.verifyOne(HttpMethod.PATCH, "/repos/velo/feign-mock/contributors"); + } +``` + +This simple test returns no content and verifies that the URL was truly invoked. + +On the mocked client, you can include all URLs and methods you want to mock. + +For more comprehensive examples take a look at https://github.com/OpenFeign/feign/blob/master/mock/src/test/java/feign/mock/MockClientTest.java[MockClientTest]. diff --git a/mock/pom.xml b/mock/pom.xml new file mode 100644 index 00000000..c3f4f293 --- /dev/null +++ b/mock/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 9.7.0-SNAPSHOT + + + feign-mock + Feign Mock + Feign Mock + + + ${project.basedir}/.. + + 1.3 + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-gson + test + + + org.hamcrest + hamcrest-core + ${hamcrest.version} + test + + + org.hamcrest + hamcrest-library + ${hamcrest.version} + test + + + + diff --git a/mock/src/main/java/feign/mock/HttpMethod.java b/mock/src/main/java/feign/mock/HttpMethod.java new file mode 100644 index 00000000..03d65c43 --- /dev/null +++ b/mock/src/main/java/feign/mock/HttpMethod.java @@ -0,0 +1,20 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +public enum HttpMethod { + + GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH + +} diff --git a/mock/src/main/java/feign/mock/MockClient.java b/mock/src/main/java/feign/mock/MockClient.java new file mode 100644 index 00000000..68feec1b --- /dev/null +++ b/mock/src/main/java/feign/mock/MockClient.java @@ -0,0 +1,276 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +import static feign.Util.UTF_8; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import feign.Client; +import feign.Request; +import feign.Response; +import feign.Util; + +public class MockClient implements Client { + + class RequestResponse { + + private final RequestKey requestKey; + + private final Response.Builder responseBuilder; + + public RequestResponse(RequestKey requestKey, Response.Builder responseBuilder) { + this.requestKey = requestKey; + this.responseBuilder = responseBuilder; + } + + } + + public static final Map> EMPTY_HEADERS = Collections.emptyMap(); + + private final List responses = new ArrayList(); + + private final Map> requests = new HashMap>(); + + private boolean sequential; + + private Iterator responseIterator; + + public MockClient() { + } + + public MockClient(boolean sequential) { + this.sequential = sequential; + } + + @Override + public synchronized Response execute(Request request, Request.Options options) throws IOException { + RequestKey requestKey = RequestKey.create(request); + Response.Builder responseBuilder; + if (sequential) { + responseBuilder = executeSequential(requestKey); + } else { + responseBuilder = executeAny(request, requestKey); + } + + return responseBuilder.request(request).build(); + } + + private Response.Builder executeSequential(RequestKey requestKey) { + Response.Builder responseBuilder; + if (responseIterator == null) { + responseIterator = responses.iterator(); + } + if (!responseIterator.hasNext()) { + throw new VerificationAssertionError("Received excessive request %s", requestKey); + } + + RequestResponse expectedRequestResponse = responseIterator.next(); + if (!expectedRequestResponse.requestKey.equalsExtended(requestKey)) { + throw new VerificationAssertionError("Expected %s, but was %s", expectedRequestResponse.requestKey, + requestKey); + } + + responseBuilder = expectedRequestResponse.responseBuilder; + return responseBuilder; + } + + private Response.Builder executeAny(Request request, RequestKey requestKey) { + Response.Builder responseBuilder; + if (requests.containsKey(requestKey)) { + requests.get(requestKey).add(request); + } else { + requests.put(requestKey, new ArrayList(Arrays.asList(request))); + } + + responseBuilder = getResponseBuilder(request, requestKey); + return responseBuilder; + } + + private Response.Builder getResponseBuilder(Request request, RequestKey requestKey) { + Response.Builder responseBuilder = null; + for (RequestResponse requestResponse : responses) { + if (requestResponse.requestKey.equalsExtended(requestKey)) { + responseBuilder = requestResponse.responseBuilder; + // Don't break here, last one should win to be compatible with + // previous + // releases of this library! + } + } + if (responseBuilder == null) { + responseBuilder = Response.builder().status(HttpURLConnection.HTTP_NOT_FOUND).reason("Not mocker") + .headers(request.headers()); + } + return responseBuilder; + } + + public MockClient ok(HttpMethod method, String url, InputStream responseBody) throws IOException { + return ok(RequestKey.builder(method, url).build(), responseBody); + } + + public MockClient ok(HttpMethod method, String url, String responseBody) { + return ok(RequestKey.builder(method, url).build(), responseBody); + } + + public MockClient ok(HttpMethod method, String url, byte[] responseBody) { + return ok(RequestKey.builder(method, url).build(), responseBody); + } + + public MockClient ok(HttpMethod method, String url) { + return ok(RequestKey.builder(method, url).build()); + } + + public MockClient ok(RequestKey requestKey, InputStream responseBody) throws IOException { + return ok(requestKey, Util.toByteArray(responseBody)); + } + + public MockClient ok(RequestKey requestKey, String responseBody) { + return ok(requestKey, responseBody.getBytes(UTF_8)); + } + + public MockClient ok(RequestKey requestKey, byte[] responseBody) { + return add(requestKey, HttpURLConnection.HTTP_OK, responseBody); + } + + public MockClient ok(RequestKey requestKey) { + return ok(requestKey, (byte[]) null); + } + + public MockClient add(HttpMethod method, String url, int status, InputStream responseBody) throws IOException { + return add(RequestKey.builder(method, url).build(), status, responseBody); + } + + public MockClient add(HttpMethod method, String url, int status, String responseBody) { + return add(RequestKey.builder(method, url).build(), status, responseBody); + } + + public MockClient add(HttpMethod method, String url, int status, byte[] responseBody) { + return add(RequestKey.builder(method, url).build(), status, responseBody); + } + + public MockClient add(HttpMethod method, String url, int status) { + return add(RequestKey.builder(method, url).build(), status); + } + + /** + * @param response + *
    + *
  • the status defaults to 0, not 200!
  • + *
  • the internal feign-code requires the headers to be + * set
  • + *
+ */ + public MockClient add(HttpMethod method, String url, Response.Builder response) { + return add(RequestKey.builder(method, url).build(), response); + } + + public MockClient add(RequestKey requestKey, int status, InputStream responseBody) throws IOException { + return add(requestKey, status, Util.toByteArray(responseBody)); + } + + public MockClient add(RequestKey requestKey, int status, String responseBody) { + return add(requestKey, status, responseBody.getBytes(UTF_8)); + } + + public MockClient add(RequestKey requestKey, int status, byte[] responseBody) { + return add(requestKey, + Response.builder().status(status).reason("Mocked").headers(EMPTY_HEADERS).body(responseBody)); + } + + public MockClient add(RequestKey requestKey, int status) { + return add(requestKey, status, (byte[]) null); + } + + public MockClient add(RequestKey requestKey, Response.Builder response) { + responses.add(new RequestResponse(requestKey, response)); + return this; + } + + public MockClient add(HttpMethod method, String url, Response response) { + return this.add(method, url, response.toBuilder()); + } + + public MockClient noContent(HttpMethod method, String url) { + return add(method, url, HttpURLConnection.HTTP_NO_CONTENT); + } + + public Request verifyOne(HttpMethod method, String url) { + return verifyTimes(method, url, 1).get(0); + } + + public List verifyTimes(final HttpMethod method, final String url, final int times) { + if (times < 0) { + throw new IllegalArgumentException("times must be a non negative number"); + } + + if (times == 0) { + verifyNever(method, url); + return Collections.emptyList(); + } + + RequestKey requestKey = RequestKey.builder(method, url).build(); + if (!requests.containsKey(requestKey)) { + throw new VerificationAssertionError("Wanted: '%s' but never invoked!", requestKey); + } + + List result = requests.get(requestKey); + if (result.size() != times) { + throw new VerificationAssertionError("Wanted: '%s' to be invoked: '%s' times but got: '%s'!", requestKey, + times, result.size()); + } + + return result; + } + + public void verifyNever(HttpMethod method, String url) { + RequestKey requestKey = RequestKey.builder(method, url).build(); + if (requests.containsKey(requestKey)) { + throw new VerificationAssertionError("Do not wanted: '%s' but was invoked!", requestKey); + } + } + + /** + * To be called in an @After method: + * + *
+	 * @After
+	 * public void tearDown() {
+	 *     mockClient.verifyStatus();
+	 * }
+	 * 
+ */ + public void verifyStatus() { + if (sequential) { + boolean unopenedIterator = responseIterator == null && !responses.isEmpty(); + if (unopenedIterator || responseIterator.hasNext()) { + throw new VerificationAssertionError("More executions were expected"); + } + } + } + + public void resetRequests() { + requests.clear(); + } + +} diff --git a/mock/src/main/java/feign/mock/MockTarget.java b/mock/src/main/java/feign/mock/MockTarget.java new file mode 100644 index 00000000..391e3964 --- /dev/null +++ b/mock/src/main/java/feign/mock/MockTarget.java @@ -0,0 +1,49 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +import feign.Request; +import feign.RequestTemplate; +import feign.Target; + +public class MockTarget implements Target { + + private final Class type; + + public MockTarget(Class type) { + this.type = type; + } + + @Override + public Class type() { + return type; + } + + @Override + public String name() { + return type.getSimpleName(); + } + + @Override + public String url() { + return ""; + } + + @Override + public Request apply(RequestTemplate input) { + input.insert(0, url()); + return input.request(); + } + +} diff --git a/mock/src/main/java/feign/mock/RequestKey.java b/mock/src/main/java/feign/mock/RequestKey.java new file mode 100644 index 00000000..4ce92764 --- /dev/null +++ b/mock/src/main/java/feign/mock/RequestKey.java @@ -0,0 +1,173 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +import static feign.Util.UTF_8; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import feign.Request; +import feign.Util; + +public class RequestKey { + + public static class Builder { + + private final HttpMethod method; + + private final String url; + + private Map> headers; + + private Charset charset; + + private byte[] body; + + private Builder(HttpMethod method, String url) { + this.method = method; + this.url = url; + } + + public Builder headers(Map> headers) { + this.headers = headers; + return this; + } + + public Builder charset(Charset charset) { + this.charset = charset; + return this; + } + + public Builder body(String body) { + return body(body.getBytes(UTF_8)); + } + + public Builder body(byte[] body) { + this.body = body; + return this; + } + + public RequestKey build() { + return new RequestKey(this); + } + + } + + public static Builder builder(HttpMethod method, String url) { + return new Builder(method, url); + } + + public static RequestKey create(Request request) { + return new RequestKey(request); + } + + private static String buildUrl(Request request) { + try { + return URLDecoder.decode(request.url(), Util.UTF_8.name()); + } catch (final UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + private final HttpMethod method; + + private final String url; + + private final Map> headers; + + private final Charset charset; + + private final byte[] body; + + private RequestKey(Builder builder) { + this.method = builder.method; + this.url = builder.url; + this.headers = builder.headers; + this.charset = builder.charset; + this.body = builder.body; + } + + private RequestKey(Request request) { + this.method = HttpMethod.valueOf(request.method()); + this.url = buildUrl(request); + this.headers = request.headers(); + this.charset = request.charset(); + this.body = request.body(); + } + + public HttpMethod getMethod() { + return method; + } + + public String getUrl() { + return url; + } + + public Map> getHeaders() { + return headers; + } + + public Charset getCharset() { + return charset; + } + + public byte[] getBody() { + return body; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((method == null) ? 0 : method.hashCode()); + result = prime * result + ((url == null) ? 0 : url.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + final RequestKey other = (RequestKey) obj; + if (method != other.method) return false; + if (url == null) { + if (other.url != null) return false; + } else if (!url.equals(other.url)) return false; + return true; + } + + public boolean equalsExtended(Object obj) { + if (equals(obj)) { + RequestKey other = (RequestKey) obj; + boolean headersEqual = other.headers == null || headers == null || headers.equals(other.headers); + boolean charsetEqual = other.charset == null || charset == null || charset.equals(other.charset); + boolean bodyEqual = other.body == null || body == null || Arrays.equals(other.body, body); + return headersEqual && charsetEqual && bodyEqual; + } + return false; + } + + @Override + public String toString() { + return String.format("Request [%s %s: %s headers and %s]", method, url, + headers == null ? "without" : "with " + headers.size(), + charset == null ? "no charset" : "charset " + charset); + } + +} diff --git a/mock/src/main/java/feign/mock/VerificationAssertionError.java b/mock/src/main/java/feign/mock/VerificationAssertionError.java new file mode 100644 index 00000000..421611f6 --- /dev/null +++ b/mock/src/main/java/feign/mock/VerificationAssertionError.java @@ -0,0 +1,24 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +public class VerificationAssertionError extends AssertionError { + + private static final long serialVersionUID = -3302777023656958993L; + + public VerificationAssertionError(String message, Object... arguments) { + super(String.format(message, arguments)); + } + +} diff --git a/mock/src/test/java/feign/mock/MockClientSequentialTest.java b/mock/src/test/java/feign/mock/MockClientSequentialTest.java new file mode 100644 index 00000000..acd310af --- /dev/null +++ b/mock/src/test/java/feign/mock/MockClientSequentialTest.java @@ -0,0 +1,168 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +import static feign.Util.toByteArray; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.List; + +import javax.net.ssl.HttpsURLConnection; + +import org.junit.Before; +import org.junit.Test; + +import feign.Body; +import feign.Feign; +import feign.FeignException; +import feign.Param; +import feign.RequestLine; +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.gson.GsonDecoder; + +public class MockClientSequentialTest { + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}/contributors?client_id={client_id}") + List contributors(@Param("client_id") String clientId, @Param("owner") String owner, + @Param("repo") String repo); + + @RequestLine("PATCH /repos/{owner}/{repo}/contributors") + List patchContributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("POST /repos/{owner}/{repo}/contributors") + @Body("%7B\"login\":\"{login}\",\"type\":\"{type}\"%7D") + Contributor create(@Param("owner") String owner, @Param("repo") String repo, @Param("login") String login, + @Param("type") String type); + + } + + static class Contributor { + + String login; + + int contributions; + + } + + class AssertionDecoder implements Decoder { + + private final Decoder delegate; + + public AssertionDecoder(Decoder delegate) { + this.delegate = delegate; + } + + @Override + public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException { + assertThat(response.request(), notNullValue()); + + return delegate.decode(response, type); + } + + } + + private GitHub githubSequential; + + private MockClient mockClientSequential; + + @Before + public void setup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/fixtures/contributors.json")) { + byte[] data = toByteArray(input); + + mockClientSequential = new MockClient(true); + githubSequential = Feign.builder().decoder(new AssertionDecoder(new GsonDecoder())) + .client(mockClientSequential + .add(HttpMethod.GET, "/repos/netflix/feign/contributors", HttpsURLConnection.HTTP_OK, data) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=55", + HttpsURLConnection.HTTP_NOT_FOUND) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=7 7", + HttpsURLConnection.HTTP_INTERNAL_ERROR, new ByteArrayInputStream(data)) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors", + Response.builder().status(HttpsURLConnection.HTTP_OK) + .headers(MockClient.EMPTY_HEADERS).body(data))) + .target(new MockTarget<>(GitHub.class)); + } + } + + @Test + public void sequentialRequests() throws Exception { + githubSequential.contributors("netflix", "feign"); + try { + githubSequential.contributors("55", "netflix", "feign"); + fail(); + } catch (FeignException e) { + assertThat(e.status(), equalTo(HttpsURLConnection.HTTP_NOT_FOUND)); + } + try { + githubSequential.contributors("7 7", "netflix", "feign"); + fail(); + } catch (FeignException e) { + assertThat(e.status(), equalTo(HttpsURLConnection.HTTP_INTERNAL_ERROR)); + } + githubSequential.contributors("netflix", "feign"); + + mockClientSequential.verifyStatus(); + } + + @Test + public void sequentialRequestsCalledTooLess() throws Exception { + githubSequential.contributors("netflix", "feign"); + try { + mockClientSequential.verifyStatus(); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), startsWith("More executions")); + } + } + + @Test + public void sequentialRequestsCalledTooMany() throws Exception { + sequentialRequests(); + + try { + githubSequential.contributors("netflix", "feign"); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("excessive")); + } + } + + @Test + public void sequentialRequestsInWrongOrder() throws Exception { + try { + githubSequential.contributors("7 7", "netflix", "feign"); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), startsWith("Expected Request [")); + } + } + +} diff --git a/mock/src/test/java/feign/mock/MockClientTest.java b/mock/src/test/java/feign/mock/MockClientTest.java new file mode 100644 index 00000000..27eb949b --- /dev/null +++ b/mock/src/test/java/feign/mock/MockClientTest.java @@ -0,0 +1,261 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +import static feign.Util.toByteArray; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.List; + +import javax.net.ssl.HttpsURLConnection; + +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Test; + +import feign.Body; +import feign.Feign; +import feign.FeignException; +import feign.Param; +import feign.Request; +import feign.RequestLine; +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.gson.GsonDecoder; + +public class MockClientTest { + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}/contributors?client_id={client_id}") + List contributors(@Param("client_id") String clientId, @Param("owner") String owner, + @Param("repo") String repo); + + @RequestLine("PATCH /repos/{owner}/{repo}/contributors") + List patchContributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("POST /repos/{owner}/{repo}/contributors") + @Body("%7B\"login\":\"{login}\",\"type\":\"{type}\"%7D") + Contributor create(@Param("owner") String owner, @Param("repo") String repo, @Param("login") String login, + @Param("type") String type); + + } + + static class Contributor { + + String login; + + int contributions; + + } + + class AssertionDecoder implements Decoder { + + private final Decoder delegate; + + public AssertionDecoder(Decoder delegate) { + this.delegate = delegate; + } + + @Override + public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException { + assertThat(response.request(), notNullValue()); + + return delegate.decode(response, type); + } + + } + + private GitHub github; + + private MockClient mockClient; + + @Before + public void setup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/fixtures/contributors.json")) { + byte[] data = toByteArray(input); + mockClient = new MockClient(); + github = Feign.builder().decoder(new AssertionDecoder(new GsonDecoder())) + .client(mockClient.ok(HttpMethod.GET, "/repos/netflix/feign/contributors", data) + .ok(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=55") + .ok(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=7 7", + new ByteArrayInputStream(data)) + .ok(HttpMethod.POST, "/repos/netflix/feign/contributors", + "{\"login\":\"velo\",\"contributions\":0}") + .noContent(HttpMethod.PATCH, "/repos/velo/feign-mock/contributors") + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=1234567890", + HttpsURLConnection.HTTP_NOT_FOUND) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=123456789", + HttpsURLConnection.HTTP_INTERNAL_ERROR, new ByteArrayInputStream(data)) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=123456789", + HttpsURLConnection.HTTP_INTERNAL_ERROR, "") + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=123456789", + HttpsURLConnection.HTTP_INTERNAL_ERROR, data)) + .target(new MockTarget<>(GitHub.class)); + } + } + + @Test + public void hitMock() { + List contributors = github.contributors("netflix", "feign"); + assertThat(contributors, hasSize(30)); + mockClient.verifyStatus(); + } + + @Test + public void missMock() { + try { + github.contributors("velo", "feign-mock"); + fail(); + } catch (FeignException e) { + assertThat(e.getMessage(), Matchers.containsString("404")); + } + } + + @Test + public void missHttpMethod() { + try { + github.patchContributors("netflix", "feign"); + fail(); + } catch (FeignException e) { + assertThat(e.getMessage(), Matchers.containsString("404")); + } + } + + @Test + public void paramsEncoding() { + List contributors = github.contributors("7 7", "netflix", "feign"); + assertThat(contributors, hasSize(30)); + mockClient.verifyStatus(); + } + + @Test + public void verifyInvocation() { + Contributor contribution = github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + // making sure it received a proper response + assertThat(contribution, notNullValue()); + assertThat(contribution.login, equalTo("velo")); + assertThat(contribution.contributions, equalTo(0)); + + List results = mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 1); + assertThat(results, hasSize(1)); + + byte[] body = mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors").body(); + assertThat(body, notNullValue()); + + String message = new String(body); + assertThat(message, containsString("velo_at_github")); + assertThat(message, containsString("preposterous hacker")); + + mockClient.verifyStatus(); + } + + @Test + public void verifyNone() { + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 1); + + try { + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 0); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("Do not wanted")); + assertThat(e.getMessage(), containsString("POST")); + assertThat(e.getMessage(), containsString("/repos/netflix/feign/contributors")); + } + + try { + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 3); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("Wanted")); + assertThat(e.getMessage(), containsString("POST")); + assertThat(e.getMessage(), containsString("/repos/netflix/feign/contributors")); + assertThat(e.getMessage(), containsString("'3'")); + assertThat(e.getMessage(), containsString("'1'")); + } + } + + @Test + public void verifyNotInvoked() { + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + List results = mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 0); + assertThat(results, hasSize(0)); + try { + mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors"); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("Wanted")); + assertThat(e.getMessage(), containsString("POST")); + assertThat(e.getMessage(), containsString("/repos/netflix/feign/contributors")); + assertThat(e.getMessage(), containsString("never invoked")); + } + } + + @Test + public void verifyNegative() { + try { + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", -1); + fail(); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("non negative")); + } + } + + @Test + public void verifyMultipleRequests() { + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + Request result = mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors"); + assertThat(result, notNullValue()); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + List results = mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 2); + assertThat(results, hasSize(2)); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + results = mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 3); + assertThat(results, hasSize(3)); + + mockClient.verifyStatus(); + } + + @Test + public void resetRequests() { + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + Request result = mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors"); + assertThat(result, notNullValue()); + + mockClient.resetRequests(); + + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + } + +} diff --git a/mock/src/test/java/feign/mock/MockTargetTest.java b/mock/src/test/java/feign/mock/MockTargetTest.java new file mode 100644 index 00000000..e1f39dda --- /dev/null +++ b/mock/src/test/java/feign/mock/MockTargetTest.java @@ -0,0 +1,36 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +public class MockTargetTest { + + private MockTarget target; + + @Before + public void setup() { + target = new MockTarget<>(MockTargetTest.class); + } + + @Test + public void test() { + assertThat(target.name(), equalTo("MockTargetTest")); + } + +} diff --git a/mock/src/test/java/feign/mock/RequestKeyTest.java b/mock/src/test/java/feign/mock/RequestKeyTest.java new file mode 100644 index 00000000..a9dadff1 --- /dev/null +++ b/mock/src/test/java/feign/mock/RequestKeyTest.java @@ -0,0 +1,159 @@ +/** + * Copyright 2012-2018 The Feign 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 feign.mock; + +import static org.hamcrest.Matchers.both; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import feign.Request; + +public class RequestKeyTest { + + private RequestKey requestKey; + + @Before + public void setUp() { + Map> map = new HashMap<>(); + map.put("my-header", Arrays.asList("val")); + requestKey = RequestKey.builder(HttpMethod.GET, "a").headers(map).charset(StandardCharsets.UTF_16) + .body("content").build(); + } + + @Test + public void builder() throws Exception { + assertThat(requestKey.getMethod(), equalTo(HttpMethod.GET)); + assertThat(requestKey.getUrl(), equalTo("a")); + assertThat(requestKey.getHeaders().entrySet(), hasSize(1)); + assertThat(requestKey.getHeaders().get("my-header"), equalTo((Collection) Arrays.asList("val"))); + assertThat(requestKey.getCharset(), equalTo(StandardCharsets.UTF_16)); + } + + @Test + public void create() throws Exception { + Map> map = new HashMap<>(); + map.put("my-header", Arrays.asList("val")); + Request request = Request.create("GET", "a", map, "content".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_16); + requestKey = RequestKey.create(request); + + assertThat(requestKey.getMethod(), equalTo(HttpMethod.GET)); + assertThat(requestKey.getUrl(), equalTo("a")); + assertThat(requestKey.getHeaders().entrySet(), hasSize(1)); + assertThat(requestKey.getHeaders().get("my-header"), equalTo((Collection) Arrays.asList("val"))); + assertThat(requestKey.getCharset(), equalTo(StandardCharsets.UTF_16)); + assertThat(requestKey.getBody(), equalTo("content".getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void checkHashes() { + RequestKey requestKey1 = RequestKey.builder(HttpMethod.GET, "a").build(); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "b").build(); + + assertThat(requestKey1.hashCode(), not(equalTo(requestKey2.hashCode()))); + assertThat(requestKey1, not(equalTo(requestKey2))); + } + + @Test + public void equalObject() { + assertThat(requestKey, not(equalTo(new Object()))); + } + + @Test + public void equalNull() { + assertThat(requestKey, not(equalTo(null))); + } + + @Test + public void equalPost() { + RequestKey requestKey1 = RequestKey.builder(HttpMethod.GET, "a").build(); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.POST, "a").build(); + + assertThat(requestKey1.hashCode(), not(equalTo(requestKey2.hashCode()))); + assertThat(requestKey1, not(equalTo(requestKey2))); + } + + @Test + public void equalSelf() { + assertThat(requestKey.hashCode(), equalTo(requestKey.hashCode())); + assertThat(requestKey, equalTo(requestKey)); + } + + @Test + public void equalMinimum() { + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey, equalTo(requestKey2)); + } + + @Test + public void equalExtra() { + Map> map = new HashMap<>(); + map.put("my-other-header", Arrays.asList("other value")); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").headers(map) + .charset(StandardCharsets.ISO_8859_1).build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey, equalTo(requestKey2)); + } + + @Test + public void equalsExtended() { + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey.equalsExtended(requestKey2), equalTo(true)); + } + + @Test + public void equalsExtendedExtra() { + Map> map = new HashMap<>(); + map.put("my-other-header", Arrays.asList("other value")); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").headers(map) + .charset(StandardCharsets.ISO_8859_1).build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey.equalsExtended(requestKey2), equalTo(false)); + } + + @Test + public void testToString() throws Exception { + assertThat(requestKey.toString(), startsWith("Request [GET a: ")); + assertThat(requestKey.toString(), both(containsString(" with 1 ")).and(containsString(" UTF-16]"))); + } + + @Test + public void testToStringSimple() throws Exception { + requestKey = RequestKey.builder(HttpMethod.GET, "a").build(); + + assertThat(requestKey.toString(), startsWith("Request [GET a: ")); + assertThat(requestKey.toString(), both(containsString(" without ")).and(containsString(" no charset"))); + } + +} +// diff --git a/mock/src/test/resources/fixtures/contributors.json b/mock/src/test/resources/fixtures/contributors.json new file mode 100644 index 00000000..f677c796 --- /dev/null +++ b/mock/src/test/resources/fixtures/contributors.json @@ -0,0 +1,602 @@ +[ + { + "login": "adriancole", + "id": 64215, + "avatar_url": "https://avatars.githubusercontent.com/u/64215?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/adriancole", + "html_url": "https://github.com/adriancole", + "followers_url": "https://api.github.com/users/adriancole/followers", + "following_url": "https://api.github.com/users/adriancole/following{/other_user}", + "gists_url": "https://api.github.com/users/adriancole/gists{/gist_id}", + "starred_url": "https://api.github.com/users/adriancole/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/adriancole/subscriptions", + "organizations_url": "https://api.github.com/users/adriancole/orgs", + "repos_url": "https://api.github.com/users/adriancole/repos", + "events_url": "https://api.github.com/users/adriancole/events{/privacy}", + "received_events_url": "https://api.github.com/users/adriancole/received_events", + "type": "User", + "site_admin": false, + "contributions": 297 + }, + { + "login": "quidryan", + "id": 360255, + "avatar_url": "https://avatars.githubusercontent.com/u/360255?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/quidryan", + "html_url": "https://github.com/quidryan", + "followers_url": "https://api.github.com/users/quidryan/followers", + "following_url": "https://api.github.com/users/quidryan/following{/other_user}", + "gists_url": "https://api.github.com/users/quidryan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/quidryan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/quidryan/subscriptions", + "organizations_url": "https://api.github.com/users/quidryan/orgs", + "repos_url": "https://api.github.com/users/quidryan/repos", + "events_url": "https://api.github.com/users/quidryan/events{/privacy}", + "received_events_url": "https://api.github.com/users/quidryan/received_events", + "type": "User", + "site_admin": false, + "contributions": 43 + }, + { + "login": "rspieldenner", + "id": 782102, + "avatar_url": "https://avatars.githubusercontent.com/u/782102?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/rspieldenner", + "html_url": "https://github.com/rspieldenner", + "followers_url": "https://api.github.com/users/rspieldenner/followers", + "following_url": "https://api.github.com/users/rspieldenner/following{/other_user}", + "gists_url": "https://api.github.com/users/rspieldenner/gists{/gist_id}", + "starred_url": "https://api.github.com/users/rspieldenner/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/rspieldenner/subscriptions", + "organizations_url": "https://api.github.com/users/rspieldenner/orgs", + "repos_url": "https://api.github.com/users/rspieldenner/repos", + "events_url": "https://api.github.com/users/rspieldenner/events{/privacy}", + "received_events_url": "https://api.github.com/users/rspieldenner/received_events", + "type": "User", + "site_admin": false, + "contributions": 14 + }, + { + "login": "davidmc24", + "id": 447825, + "avatar_url": "https://avatars.githubusercontent.com/u/447825?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/davidmc24", + "html_url": "https://github.com/davidmc24", + "followers_url": "https://api.github.com/users/davidmc24/followers", + "following_url": "https://api.github.com/users/davidmc24/following{/other_user}", + "gists_url": "https://api.github.com/users/davidmc24/gists{/gist_id}", + "starred_url": "https://api.github.com/users/davidmc24/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/davidmc24/subscriptions", + "organizations_url": "https://api.github.com/users/davidmc24/orgs", + "repos_url": "https://api.github.com/users/davidmc24/repos", + "events_url": "https://api.github.com/users/davidmc24/events{/privacy}", + "received_events_url": "https://api.github.com/users/davidmc24/received_events", + "type": "User", + "site_admin": false, + "contributions": 12 + }, + { + "login": "ahus1", + "id": 3957921, + "avatar_url": "https://avatars.githubusercontent.com/u/3957921?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/ahus1", + "html_url": "https://github.com/ahus1", + "followers_url": "https://api.github.com/users/ahus1/followers", + "following_url": "https://api.github.com/users/ahus1/following{/other_user}", + "gists_url": "https://api.github.com/users/ahus1/gists{/gist_id}", + "starred_url": "https://api.github.com/users/ahus1/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/ahus1/subscriptions", + "organizations_url": "https://api.github.com/users/ahus1/orgs", + "repos_url": "https://api.github.com/users/ahus1/repos", + "events_url": "https://api.github.com/users/ahus1/events{/privacy}", + "received_events_url": "https://api.github.com/users/ahus1/received_events", + "type": "User", + "site_admin": false, + "contributions": 6 + }, + { + "login": "allenxwang", + "id": 1728105, + "avatar_url": "https://avatars.githubusercontent.com/u/1728105?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/allenxwang", + "html_url": "https://github.com/allenxwang", + "followers_url": "https://api.github.com/users/allenxwang/followers", + "following_url": "https://api.github.com/users/allenxwang/following{/other_user}", + "gists_url": "https://api.github.com/users/allenxwang/gists{/gist_id}", + "starred_url": "https://api.github.com/users/allenxwang/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/allenxwang/subscriptions", + "organizations_url": "https://api.github.com/users/allenxwang/orgs", + "repos_url": "https://api.github.com/users/allenxwang/repos", + "events_url": "https://api.github.com/users/allenxwang/events{/privacy}", + "received_events_url": "https://api.github.com/users/allenxwang/received_events", + "type": "User", + "site_admin": false, + "contributions": 5 + }, + { + "login": "nmiyake", + "id": 4267425, + "avatar_url": "https://avatars.githubusercontent.com/u/4267425?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/nmiyake", + "html_url": "https://github.com/nmiyake", + "followers_url": "https://api.github.com/users/nmiyake/followers", + "following_url": "https://api.github.com/users/nmiyake/following{/other_user}", + "gists_url": "https://api.github.com/users/nmiyake/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nmiyake/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nmiyake/subscriptions", + "organizations_url": "https://api.github.com/users/nmiyake/orgs", + "repos_url": "https://api.github.com/users/nmiyake/repos", + "events_url": "https://api.github.com/users/nmiyake/events{/privacy}", + "received_events_url": "https://api.github.com/users/nmiyake/received_events", + "type": "User", + "site_admin": false, + "contributions": 4 + }, + { + "login": "Drdoteam", + "id": 4572139, + "avatar_url": "https://avatars.githubusercontent.com/u/4572139?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/Drdoteam", + "html_url": "https://github.com/Drdoteam", + "followers_url": "https://api.github.com/users/Drdoteam/followers", + "following_url": "https://api.github.com/users/Drdoteam/following{/other_user}", + "gists_url": "https://api.github.com/users/Drdoteam/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Drdoteam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Drdoteam/subscriptions", + "organizations_url": "https://api.github.com/users/Drdoteam/orgs", + "repos_url": "https://api.github.com/users/Drdoteam/repos", + "events_url": "https://api.github.com/users/Drdoteam/events{/privacy}", + "received_events_url": "https://api.github.com/users/Drdoteam/received_events", + "type": "User", + "site_admin": false, + "contributions": 4 + }, + { + "login": "spencergibb", + "id": 594085, + "avatar_url": "https://avatars.githubusercontent.com/u/594085?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spencergibb", + "html_url": "https://github.com/spencergibb", + "followers_url": "https://api.github.com/users/spencergibb/followers", + "following_url": "https://api.github.com/users/spencergibb/following{/other_user}", + "gists_url": "https://api.github.com/users/spencergibb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spencergibb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spencergibb/subscriptions", + "organizations_url": "https://api.github.com/users/spencergibb/orgs", + "repos_url": "https://api.github.com/users/spencergibb/repos", + "events_url": "https://api.github.com/users/spencergibb/events{/privacy}", + "received_events_url": "https://api.github.com/users/spencergibb/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "jacob-meacham", + "id": 1624811, + "avatar_url": "https://avatars.githubusercontent.com/u/1624811?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/jacob-meacham", + "html_url": "https://github.com/jacob-meacham", + "followers_url": "https://api.github.com/users/jacob-meacham/followers", + "following_url": "https://api.github.com/users/jacob-meacham/following{/other_user}", + "gists_url": "https://api.github.com/users/jacob-meacham/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jacob-meacham/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jacob-meacham/subscriptions", + "organizations_url": "https://api.github.com/users/jacob-meacham/orgs", + "repos_url": "https://api.github.com/users/jacob-meacham/repos", + "events_url": "https://api.github.com/users/jacob-meacham/events{/privacy}", + "received_events_url": "https://api.github.com/users/jacob-meacham/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "pnepywoda", + "id": 13909400, + "avatar_url": "https://avatars.githubusercontent.com/u/13909400?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/pnepywoda", + "html_url": "https://github.com/pnepywoda", + "followers_url": "https://api.github.com/users/pnepywoda/followers", + "following_url": "https://api.github.com/users/pnepywoda/following{/other_user}", + "gists_url": "https://api.github.com/users/pnepywoda/gists{/gist_id}", + "starred_url": "https://api.github.com/users/pnepywoda/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/pnepywoda/subscriptions", + "organizations_url": "https://api.github.com/users/pnepywoda/orgs", + "repos_url": "https://api.github.com/users/pnepywoda/repos", + "events_url": "https://api.github.com/users/pnepywoda/events{/privacy}", + "received_events_url": "https://api.github.com/users/pnepywoda/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "santhosh-tekuri", + "id": 1112271, + "avatar_url": "https://avatars.githubusercontent.com/u/1112271?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/santhosh-tekuri", + "html_url": "https://github.com/santhosh-tekuri", + "followers_url": "https://api.github.com/users/santhosh-tekuri/followers", + "following_url": "https://api.github.com/users/santhosh-tekuri/following{/other_user}", + "gists_url": "https://api.github.com/users/santhosh-tekuri/gists{/gist_id}", + "starred_url": "https://api.github.com/users/santhosh-tekuri/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/santhosh-tekuri/subscriptions", + "organizations_url": "https://api.github.com/users/santhosh-tekuri/orgs", + "repos_url": "https://api.github.com/users/santhosh-tekuri/repos", + "events_url": "https://api.github.com/users/santhosh-tekuri/events{/privacy}", + "received_events_url": "https://api.github.com/users/santhosh-tekuri/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "bstick12", + "id": 1146861, + "avatar_url": "https://avatars.githubusercontent.com/u/1146861?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/bstick12", + "html_url": "https://github.com/bstick12", + "followers_url": "https://api.github.com/users/bstick12/followers", + "following_url": "https://api.github.com/users/bstick12/following{/other_user}", + "gists_url": "https://api.github.com/users/bstick12/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bstick12/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bstick12/subscriptions", + "organizations_url": "https://api.github.com/users/bstick12/orgs", + "repos_url": "https://api.github.com/users/bstick12/repos", + "events_url": "https://api.github.com/users/bstick12/events{/privacy}", + "received_events_url": "https://api.github.com/users/bstick12/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "oillio", + "id": 205051, + "avatar_url": "https://avatars.githubusercontent.com/u/205051?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/oillio", + "html_url": "https://github.com/oillio", + "followers_url": "https://api.github.com/users/oillio/followers", + "following_url": "https://api.github.com/users/oillio/following{/other_user}", + "gists_url": "https://api.github.com/users/oillio/gists{/gist_id}", + "starred_url": "https://api.github.com/users/oillio/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/oillio/subscriptions", + "organizations_url": "https://api.github.com/users/oillio/orgs", + "repos_url": "https://api.github.com/users/oillio/repos", + "events_url": "https://api.github.com/users/oillio/events{/privacy}", + "received_events_url": "https://api.github.com/users/oillio/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "stromnet", + "id": 668449, + "avatar_url": "https://avatars.githubusercontent.com/u/668449?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/stromnet", + "html_url": "https://github.com/stromnet", + "followers_url": "https://api.github.com/users/stromnet/followers", + "following_url": "https://api.github.com/users/stromnet/following{/other_user}", + "gists_url": "https://api.github.com/users/stromnet/gists{/gist_id}", + "starred_url": "https://api.github.com/users/stromnet/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/stromnet/subscriptions", + "organizations_url": "https://api.github.com/users/stromnet/orgs", + "repos_url": "https://api.github.com/users/stromnet/repos", + "events_url": "https://api.github.com/users/stromnet/events{/privacy}", + "received_events_url": "https://api.github.com/users/stromnet/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "qualidafial", + "id": 38629, + "avatar_url": "https://avatars.githubusercontent.com/u/38629?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/qualidafial", + "html_url": "https://github.com/qualidafial", + "followers_url": "https://api.github.com/users/qualidafial/followers", + "following_url": "https://api.github.com/users/qualidafial/following{/other_user}", + "gists_url": "https://api.github.com/users/qualidafial/gists{/gist_id}", + "starred_url": "https://api.github.com/users/qualidafial/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/qualidafial/subscriptions", + "organizations_url": "https://api.github.com/users/qualidafial/orgs", + "repos_url": "https://api.github.com/users/qualidafial/repos", + "events_url": "https://api.github.com/users/qualidafial/events{/privacy}", + "received_events_url": "https://api.github.com/users/qualidafial/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "amit-git", + "id": 2767034, + "avatar_url": "https://avatars.githubusercontent.com/u/2767034?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/amit-git", + "html_url": "https://github.com/amit-git", + "followers_url": "https://api.github.com/users/amit-git/followers", + "following_url": "https://api.github.com/users/amit-git/following{/other_user}", + "gists_url": "https://api.github.com/users/amit-git/gists{/gist_id}", + "starred_url": "https://api.github.com/users/amit-git/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/amit-git/subscriptions", + "organizations_url": "https://api.github.com/users/amit-git/orgs", + "repos_url": "https://api.github.com/users/amit-git/repos", + "events_url": "https://api.github.com/users/amit-git/events{/privacy}", + "received_events_url": "https://api.github.com/users/amit-git/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "dstepanov", + "id": 666879, + "avatar_url": "https://avatars.githubusercontent.com/u/666879?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/dstepanov", + "html_url": "https://github.com/dstepanov", + "followers_url": "https://api.github.com/users/dstepanov/followers", + "following_url": "https://api.github.com/users/dstepanov/following{/other_user}", + "gists_url": "https://api.github.com/users/dstepanov/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dstepanov/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dstepanov/subscriptions", + "organizations_url": "https://api.github.com/users/dstepanov/orgs", + "repos_url": "https://api.github.com/users/dstepanov/repos", + "events_url": "https://api.github.com/users/dstepanov/events{/privacy}", + "received_events_url": "https://api.github.com/users/dstepanov/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "asukhyy", + "id": 891597, + "avatar_url": "https://avatars.githubusercontent.com/u/891597?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/asukhyy", + "html_url": "https://github.com/asukhyy", + "followers_url": "https://api.github.com/users/asukhyy/followers", + "following_url": "https://api.github.com/users/asukhyy/following{/other_user}", + "gists_url": "https://api.github.com/users/asukhyy/gists{/gist_id}", + "starred_url": "https://api.github.com/users/asukhyy/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/asukhyy/subscriptions", + "organizations_url": "https://api.github.com/users/asukhyy/orgs", + "repos_url": "https://api.github.com/users/asukhyy/repos", + "events_url": "https://api.github.com/users/asukhyy/events{/privacy}", + "received_events_url": "https://api.github.com/users/asukhyy/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "carlossg", + "id": 23651, + "avatar_url": "https://avatars.githubusercontent.com/u/23651?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/carlossg", + "html_url": "https://github.com/carlossg", + "followers_url": "https://api.github.com/users/carlossg/followers", + "following_url": "https://api.github.com/users/carlossg/following{/other_user}", + "gists_url": "https://api.github.com/users/carlossg/gists{/gist_id}", + "starred_url": "https://api.github.com/users/carlossg/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/carlossg/subscriptions", + "organizations_url": "https://api.github.com/users/carlossg/orgs", + "repos_url": "https://api.github.com/users/carlossg/repos", + "events_url": "https://api.github.com/users/carlossg/events{/privacy}", + "received_events_url": "https://api.github.com/users/carlossg/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "christopherlakey", + "id": 1859690, + "avatar_url": "https://avatars.githubusercontent.com/u/1859690?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/christopherlakey", + "html_url": "https://github.com/christopherlakey", + "followers_url": "https://api.github.com/users/christopherlakey/followers", + "following_url": "https://api.github.com/users/christopherlakey/following{/other_user}", + "gists_url": "https://api.github.com/users/christopherlakey/gists{/gist_id}", + "starred_url": "https://api.github.com/users/christopherlakey/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/christopherlakey/subscriptions", + "organizations_url": "https://api.github.com/users/christopherlakey/orgs", + "repos_url": "https://api.github.com/users/christopherlakey/repos", + "events_url": "https://api.github.com/users/christopherlakey/events{/privacy}", + "received_events_url": "https://api.github.com/users/christopherlakey/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "dsyer", + "id": 124075, + "avatar_url": "https://avatars.githubusercontent.com/u/124075?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/dsyer", + "html_url": "https://github.com/dsyer", + "followers_url": "https://api.github.com/users/dsyer/followers", + "following_url": "https://api.github.com/users/dsyer/following{/other_user}", + "gists_url": "https://api.github.com/users/dsyer/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dsyer/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dsyer/subscriptions", + "organizations_url": "https://api.github.com/users/dsyer/orgs", + "repos_url": "https://api.github.com/users/dsyer/repos", + "events_url": "https://api.github.com/users/dsyer/events{/privacy}", + "received_events_url": "https://api.github.com/users/dsyer/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "aspyker", + "id": 260750, + "avatar_url": "https://avatars.githubusercontent.com/u/260750?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/aspyker", + "html_url": "https://github.com/aspyker", + "followers_url": "https://api.github.com/users/aspyker/followers", + "following_url": "https://api.github.com/users/aspyker/following{/other_user}", + "gists_url": "https://api.github.com/users/aspyker/gists{/gist_id}", + "starred_url": "https://api.github.com/users/aspyker/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/aspyker/subscriptions", + "organizations_url": "https://api.github.com/users/aspyker/orgs", + "repos_url": "https://api.github.com/users/aspyker/repos", + "events_url": "https://api.github.com/users/aspyker/events{/privacy}", + "received_events_url": "https://api.github.com/users/aspyker/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "FrEaKmAn", + "id": 232901, + "avatar_url": "https://avatars.githubusercontent.com/u/232901?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/FrEaKmAn", + "html_url": "https://github.com/FrEaKmAn", + "followers_url": "https://api.github.com/users/FrEaKmAn/followers", + "following_url": "https://api.github.com/users/FrEaKmAn/following{/other_user}", + "gists_url": "https://api.github.com/users/FrEaKmAn/gists{/gist_id}", + "starred_url": "https://api.github.com/users/FrEaKmAn/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/FrEaKmAn/subscriptions", + "organizations_url": "https://api.github.com/users/FrEaKmAn/orgs", + "repos_url": "https://api.github.com/users/FrEaKmAn/repos", + "events_url": "https://api.github.com/users/FrEaKmAn/events{/privacy}", + "received_events_url": "https://api.github.com/users/FrEaKmAn/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "htynkn", + "id": 659135, + "avatar_url": "https://avatars.githubusercontent.com/u/659135?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/htynkn", + "html_url": "https://github.com/htynkn", + "followers_url": "https://api.github.com/users/htynkn/followers", + "following_url": "https://api.github.com/users/htynkn/following{/other_user}", + "gists_url": "https://api.github.com/users/htynkn/gists{/gist_id}", + "starred_url": "https://api.github.com/users/htynkn/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/htynkn/subscriptions", + "organizations_url": "https://api.github.com/users/htynkn/orgs", + "repos_url": "https://api.github.com/users/htynkn/repos", + "events_url": "https://api.github.com/users/htynkn/events{/privacy}", + "received_events_url": "https://api.github.com/users/htynkn/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "jebeaudet", + "id": 3722096, + "avatar_url": "https://avatars.githubusercontent.com/u/3722096?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/jebeaudet", + "html_url": "https://github.com/jebeaudet", + "followers_url": "https://api.github.com/users/jebeaudet/followers", + "following_url": "https://api.github.com/users/jebeaudet/following{/other_user}", + "gists_url": "https://api.github.com/users/jebeaudet/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jebeaudet/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jebeaudet/subscriptions", + "organizations_url": "https://api.github.com/users/jebeaudet/orgs", + "repos_url": "https://api.github.com/users/jebeaudet/repos", + "events_url": "https://api.github.com/users/jebeaudet/events{/privacy}", + "received_events_url": "https://api.github.com/users/jebeaudet/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "jmcampanini", + "id": 316848, + "avatar_url": "https://avatars.githubusercontent.com/u/316848?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/jmcampanini", + "html_url": "https://github.com/jmcampanini", + "followers_url": "https://api.github.com/users/jmcampanini/followers", + "following_url": "https://api.github.com/users/jmcampanini/following{/other_user}", + "gists_url": "https://api.github.com/users/jmcampanini/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jmcampanini/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jmcampanini/subscriptions", + "organizations_url": "https://api.github.com/users/jmcampanini/orgs", + "repos_url": "https://api.github.com/users/jmcampanini/repos", + "events_url": "https://api.github.com/users/jmcampanini/events{/privacy}", + "received_events_url": "https://api.github.com/users/jmcampanini/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "Randgalt", + "id": 264818, + "avatar_url": "https://avatars.githubusercontent.com/u/264818?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/Randgalt", + "html_url": "https://github.com/Randgalt", + "followers_url": "https://api.github.com/users/Randgalt/followers", + "following_url": "https://api.github.com/users/Randgalt/following{/other_user}", + "gists_url": "https://api.github.com/users/Randgalt/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Randgalt/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Randgalt/subscriptions", + "organizations_url": "https://api.github.com/users/Randgalt/orgs", + "repos_url": "https://api.github.com/users/Randgalt/repos", + "events_url": "https://api.github.com/users/Randgalt/events{/privacy}", + "received_events_url": "https://api.github.com/users/Randgalt/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "VanRoy", + "id": 1958756, + "avatar_url": "https://avatars.githubusercontent.com/u/1958756?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/VanRoy", + "html_url": "https://github.com/VanRoy", + "followers_url": "https://api.github.com/users/VanRoy/followers", + "following_url": "https://api.github.com/users/VanRoy/following{/other_user}", + "gists_url": "https://api.github.com/users/VanRoy/gists{/gist_id}", + "starred_url": "https://api.github.com/users/VanRoy/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/VanRoy/subscriptions", + "organizations_url": "https://api.github.com/users/VanRoy/orgs", + "repos_url": "https://api.github.com/users/VanRoy/repos", + "events_url": "https://api.github.com/users/VanRoy/events{/privacy}", + "received_events_url": "https://api.github.com/users/VanRoy/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "mhurne", + "id": 677354, + "avatar_url": "https://avatars.githubusercontent.com/u/677354?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/mhurne", + "html_url": "https://github.com/mhurne", + "followers_url": "https://api.github.com/users/mhurne/followers", + "following_url": "https://api.github.com/users/mhurne/following{/other_user}", + "gists_url": "https://api.github.com/users/mhurne/gists{/gist_id}", + "starred_url": "https://api.github.com/users/mhurne/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/mhurne/subscriptions", + "organizations_url": "https://api.github.com/users/mhurne/orgs", + "repos_url": "https://api.github.com/users/mhurne/repos", + "events_url": "https://api.github.com/users/mhurne/events{/privacy}", + "received_events_url": "https://api.github.com/users/mhurne/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + } +] diff --git a/pom.xml b/pom.xml index 7c2f1870..847ec66a 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,7 @@ sax slf4j java8 + mock benchmark @@ -110,6 +111,12 @@ Spencer Gibb spencer@gibb.us + + velo + Marvin Herman Froeder + velo br at gmail dot com + about.me/velo +