diff --git a/CHANGELOG.md b/CHANGELOG.md index f4244c02..cd322fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### Version 8.3 +* Adds client implementation for Apache Http Client + ### Version 8.2 * Allows customized request construction by exposing `Request.create()` * Adds JMH benchmark module diff --git a/httpclient/README.md b/httpclient/README.md new file mode 100644 index 00000000..ed20b439 --- /dev/null +++ b/httpclient/README.md @@ -0,0 +1,12 @@ +Apache HttpClient +=================== + +This module directs Feign's http requests to Apache's [HttpClient](https://hc.apache.org/httpcomponents-client-ga/). + +To use HttpClient with Feign, add the HttpClient module to your classpath. Then, configure Feign to use the HttpClient: + +```java +GitHub github = Feign.builder() + .client(new ApacheHttpClient()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/httpclient/build.gradle b/httpclient/build.gradle new file mode 100644 index 00000000..52b18074 --- /dev/null +++ b/httpclient/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'org.apache.httpcomponents:httpclient:4.4.1' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' + testCompile 'com.squareup.okhttp:mockwebserver:2.2.0' + testCompile project(':feign-core').sourceSets.test.output // for assertions +} \ No newline at end of file diff --git a/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java new file mode 100644 index 00000000..27a8f2b5 --- /dev/null +++ b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java @@ -0,0 +1,207 @@ +/* + * Copyright 2015 Netflix, Inc. + * + * 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.httpclient; + +import feign.Client; +import feign.Request; +import feign.Response; +import feign.Util; +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * This module directs Feign's http requests to Apache's + * HttpClient. Ex. + *
+ * GitHub github = Feign.builder().client(new ApacheHttpClient()).target(GitHub.class, + * "https://api.github.com"); + */ +/* + * Based on Square, Inc's Retrofit ApacheClient implementation + */ +public final class ApacheHttpClient implements Client { + private static final String ACCEPT_HEADER_NAME = "Accept"; + + private final HttpClient client; + + public ApacheHttpClient() { + this(HttpClientBuilder.create().build()); + } + + public ApacheHttpClient(HttpClient client) { + this.client = client; + } + + @Override + public Response execute(Request request, Request.Options options) throws IOException { + HttpUriRequest httpUriRequest; + try { + httpUriRequest = toHttpUriRequest(request, options); + } catch (URISyntaxException e) { + throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e); + } + HttpResponse httpResponse = client.execute(httpUriRequest); + return toFeignResponse(httpResponse); + } + + HttpUriRequest toHttpUriRequest(Request request, Request.Options options) throws + UnsupportedEncodingException, MalformedURLException, URISyntaxException { + RequestBuilder requestBuilder = RequestBuilder.create(request.method()); + + //per request timeouts + RequestConfig requestConfig = RequestConfig + .custom() + .setConnectTimeout(options.connectTimeoutMillis()) + .setSocketTimeout(options.readTimeoutMillis()) + .build(); + requestBuilder.setConfig(requestConfig); + + URI uri = new URIBuilder(request.url()).build(); + + //request url + requestBuilder.setUri(uri.getScheme() + "://" + uri.getAuthority() + uri.getPath()); + + //request query params + ListqueryParams = URLEncodedUtils.parse(uri, requestBuilder.getCharset().name()); + for (NameValuePair queryParam: queryParams) { + requestBuilder.addParameter(queryParam); + } + + //request body + if (request.body() != null) { + HttpEntity entity = request.charset() != null ? + new StringEntity(new String(request.body(), request.charset())) : + new ByteArrayEntity(request.body()); + requestBuilder.setEntity(entity); + } + + //request headers + boolean hasAcceptHeader = false; + for (Map.Entry > headerEntry : request.headers().entrySet()) { + String headerName = headerEntry.getKey(); + if (headerName.equalsIgnoreCase(ACCEPT_HEADER_NAME)) { + hasAcceptHeader = true; + } + if (headerName.equalsIgnoreCase(Util.CONTENT_LENGTH) && + requestBuilder.getHeaders(headerName) != null) { + //if the 'Content-Length' header is already present, it's been set from HttpEntity, so we + //won't add it again + continue; + } + + for (String headerValue : headerEntry.getValue()) { + requestBuilder.addHeader(headerName, headerValue); + } + } + //some servers choke on the default accept string, so we'll set it to anything + if (!hasAcceptHeader) { + requestBuilder.addHeader(ACCEPT_HEADER_NAME, "*/*"); + } + + return requestBuilder.build(); + } + + Response toFeignResponse(HttpResponse httpResponse) throws IOException { + StatusLine statusLine = httpResponse.getStatusLine(); + int statusCode = statusLine.getStatusCode(); + + String reason = statusLine.getReasonPhrase(); + + Map > headers = new HashMap >(); + for (Header header : httpResponse.getAllHeaders()) { + String name = header.getName(); + String value = header.getValue(); + + Collection headerValues = headers.get(name); + if (headerValues == null) { + headerValues = new ArrayList (); + headers.put(name, headerValues); + } + headerValues.add(value); + } + + return Response.create(statusCode, reason, headers, toFeignBody(httpResponse)); + } + + Response.Body toFeignBody(HttpResponse httpResponse) throws IOException { + HttpEntity entity = httpResponse.getEntity(); + final Integer length = entity != null && entity.getContentLength() != -1 ? + (int) entity.getContentLength() : + null; + final InputStream input = entity != null ? + new ByteArrayInputStream(EntityUtils.toByteArray(entity)) : + null; + + return new Response.Body() { + + @Override + public void close() throws IOException { + if (input != null) { + input.close(); + } + } + + @Override + public Integer length() { + return length; + } + + @Override + public boolean isRepeatable() { + return false; + } + + @Override + public InputStream asInputStream() throws IOException { + return input; + } + + @Override + public Reader asReader() throws IOException { + return new InputStreamReader(input); + } + }; + } + +} diff --git a/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java new file mode 100644 index 00000000..96e734c6 --- /dev/null +++ b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2015 Netflix, Inc. + * + * 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.httpclient; + +import com.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.rule.MockWebServerRule; +import feign.Feign; +import feign.FeignException; +import feign.Headers; +import feign.RequestLine; +import feign.Response; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import static feign.Util.UTF_8; +import static feign.assertj.MockWebServerAssertions.assertThat; +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; + +public class ApacheHttpClientTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + @Rule + public final MockWebServerRule server = new MockWebServerRule(); + + @Test + public void parsesRequestAndResponse() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar")); + + TestInterface api = Feign.builder() + .client(new ApacheHttpClient()) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.post("foo"); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("OK"); + assertThat(response.headers()) + .containsEntry("Content-Length", asList("3")) + .containsEntry("Foo", asList("Bar")); + assertThat(response.body().asInputStream()) + .hasContentEqualTo(new ByteArrayInputStream("foo".getBytes(UTF_8))); + + assertThat(server.takeRequest()).hasMethod("POST") + .hasPath("/?foo=bar&foo=baz&qux=") + .hasHeaders("Foo: Bar", "Foo: Baz", "Qux: ", "Accept: */*", "Content-Length: 3") + .hasBody("foo"); + } + + @Test + public void parsesErrorResponse() throws IOException, InterruptedException { + thrown.expect(FeignException.class); + thrown.expectMessage("status 500 reading TestInterface#post(String); content:\n" + "ARGHH"); + + server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH")); + + TestInterface api = Feign.builder() + .client(new ApacheHttpClient()) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.post("foo"); + } + + @Test + public void patch() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + server.enqueue(new MockResponse()); + + TestInterface api = Feign.builder() + .client(new ApacheHttpClient()) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + assertEquals("foo", api.patch()); + + assertThat(server.takeRequest()) + .hasHeaders("Accept: text/plain") + .hasNoHeaderNamed("Content-Type") + .hasMethod("PATCH"); + } + + interface TestInterface { + + @RequestLine("POST /?foo=bar&foo=baz&qux=") + @Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: text/plain"}) + Response post(String body); + + @RequestLine("PATCH /") + @Headers("Accept: text/plain") + String patch(); + } +} diff --git a/settings.gradle b/settings.gradle index ccbde471..ecf20616 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,5 @@ rootProject.name='feign' -include 'core', 'sax', 'gson', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j' +include 'core', 'sax', 'gson', 'httpclient', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j' rootProject.children.each { childProject -> childProject.name = 'feign-' + childProject.name