Skip to content

Commit 82889da

Browse files
committed
Support for Apache HttpClient as Feign client
1 parent 6c516c1 commit 82889da

6 files changed

Lines changed: 344 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
### Version 8.3
2+
* Adds client implementation for Apache Http Client
3+
14
### Version 8.2
25
* Adds JMH benchmark module
36
* Enforces source compatibility with animal-sniffer

httpclient/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Apache HttpClient
2+
===================
3+
4+
This module directs Feign's http requests to Apache's [HttpClient](https://hc.apache.org/httpcomponents-client-ga/).
5+
6+
To use HttpClient with Feign, add the HttpClient module to your classpath. Then, configure Feign to use the HttpClient:
7+
8+
```java
9+
GitHub github = Feign.builder()
10+
.client(new ApacheHttpClient())
11+
.target(GitHub.class, "https://api.github.com");
12+
```

httpclient/build.gradle

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apply plugin: 'java'
2+
3+
sourceCompatibility = 1.6
4+
5+
dependencies {
6+
compile project(':feign-core')
7+
compile 'org.apache.httpcomponents:httpclient:4.4.1'
8+
testCompile 'junit:junit:4.12'
9+
testCompile 'org.assertj:assertj-core:1.7.1'
10+
testCompile 'com.squareup.okhttp:mockwebserver:2.2.0'
11+
testCompile project(':feign-core').sourceSets.test.output // for assertions
12+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*
2+
* Copyright 2015 Netflix, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package feign.httpclient;
17+
18+
import feign.Client;
19+
import feign.Request;
20+
import feign.Response;
21+
import feign.Util;
22+
import org.apache.http.Header;
23+
import org.apache.http.HttpEntity;
24+
import org.apache.http.HttpResponse;
25+
import org.apache.http.NameValuePair;
26+
import org.apache.http.StatusLine;
27+
import org.apache.http.client.HttpClient;
28+
import org.apache.http.client.config.RequestConfig;
29+
import org.apache.http.client.methods.HttpUriRequest;
30+
import org.apache.http.client.methods.RequestBuilder;
31+
import org.apache.http.client.utils.URIBuilder;
32+
import org.apache.http.client.utils.URLEncodedUtils;
33+
import org.apache.http.entity.ByteArrayEntity;
34+
import org.apache.http.entity.StringEntity;
35+
import org.apache.http.impl.client.HttpClientBuilder;
36+
import org.apache.http.util.EntityUtils;
37+
38+
import java.io.ByteArrayInputStream;
39+
import java.io.IOException;
40+
import java.io.InputStream;
41+
import java.io.InputStreamReader;
42+
import java.io.Reader;
43+
import java.io.UnsupportedEncodingException;
44+
import java.net.MalformedURLException;
45+
import java.net.URI;
46+
import java.net.URISyntaxException;
47+
import java.util.ArrayList;
48+
import java.util.Collection;
49+
import java.util.HashMap;
50+
import java.util.List;
51+
import java.util.Map;
52+
53+
/**
54+
* This module directs Feign's http requests to Apache's
55+
* <a href="https://hc.apache.org/httpcomponents-client-ga/">HttpClient</a>. Ex.
56+
* <pre>
57+
* GitHub github = Feign.builder().client(new ApacheHttpClient()).target(GitHub.class,
58+
* "https://api.github.com");
59+
*/
60+
/*
61+
* Based on Square, Inc's Retrofit ApacheClient implementation
62+
*/
63+
public final class ApacheHttpClient implements Client {
64+
private static final String ACCEPT_HEADER_NAME = "Accept";
65+
66+
private final HttpClient client;
67+
68+
public ApacheHttpClient() {
69+
this(HttpClientBuilder.create().build());
70+
}
71+
72+
public ApacheHttpClient(HttpClient client) {
73+
this.client = client;
74+
}
75+
76+
@Override
77+
public Response execute(Request request, Request.Options options) throws IOException {
78+
HttpUriRequest httpUriRequest;
79+
try {
80+
httpUriRequest = toHttpUriRequest(request, options);
81+
} catch (URISyntaxException e) {
82+
throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e);
83+
}
84+
HttpResponse httpResponse = client.execute(httpUriRequest);
85+
return toFeignResponse(httpResponse);
86+
}
87+
88+
HttpUriRequest toHttpUriRequest(Request request, Request.Options options) throws
89+
UnsupportedEncodingException, MalformedURLException, URISyntaxException {
90+
RequestBuilder requestBuilder = RequestBuilder.create(request.method());
91+
92+
//per request timeouts
93+
RequestConfig requestConfig = RequestConfig
94+
.custom()
95+
.setConnectTimeout(options.connectTimeoutMillis())
96+
.setSocketTimeout(options.readTimeoutMillis())
97+
.build();
98+
requestBuilder.setConfig(requestConfig);
99+
100+
URI uri = new URIBuilder(request.url()).build();
101+
102+
//request url
103+
requestBuilder.setUri(uri.getScheme() + "://" + uri.getAuthority() + uri.getPath());
104+
105+
//request query params
106+
List<NameValuePair> queryParams = URLEncodedUtils.parse(uri, requestBuilder.getCharset().name());
107+
for (NameValuePair queryParam: queryParams) {
108+
requestBuilder.addParameter(queryParam);
109+
}
110+
111+
//request body
112+
if (request.body() != null) {
113+
HttpEntity entity = request.charset() != null ?
114+
new StringEntity(new String(request.body(), request.charset())) :
115+
new ByteArrayEntity(request.body());
116+
requestBuilder.setEntity(entity);
117+
}
118+
119+
//request headers
120+
boolean hasAcceptHeader = false;
121+
for (Map.Entry<String, Collection<String>> headerEntry : request.headers().entrySet()) {
122+
String headerName = headerEntry.getKey();
123+
if (headerName.equalsIgnoreCase(ACCEPT_HEADER_NAME)) {
124+
hasAcceptHeader = true;
125+
}
126+
if (headerName.equalsIgnoreCase(Util.CONTENT_LENGTH) &&
127+
requestBuilder.getHeaders(headerName) != null) {
128+
//if the 'Content-Length' header is already present, it's been set from HttpEntity, so we
129+
//won't add it again
130+
continue;
131+
}
132+
133+
for (String headerValue : headerEntry.getValue()) {
134+
requestBuilder.addHeader(headerName, headerValue);
135+
}
136+
}
137+
//some servers choke on the default accept string, so we'll set it to anything
138+
if (!hasAcceptHeader) {
139+
requestBuilder.addHeader(ACCEPT_HEADER_NAME, "*/*");
140+
}
141+
142+
return requestBuilder.build();
143+
}
144+
145+
Response toFeignResponse(HttpResponse httpResponse) throws IOException {
146+
StatusLine statusLine = httpResponse.getStatusLine();
147+
int statusCode = statusLine.getStatusCode();
148+
149+
String reason = statusLine.getReasonPhrase();
150+
151+
Map<String, Collection<String>> headers = new HashMap<String, Collection<String>>();
152+
for (Header header : httpResponse.getAllHeaders()) {
153+
String name = header.getName();
154+
String value = header.getValue();
155+
156+
Collection<String> headerValues = headers.get(name);
157+
if (headerValues == null) {
158+
headerValues = new ArrayList<String>();
159+
headers.put(name, headerValues);
160+
}
161+
headerValues.add(value);
162+
}
163+
164+
return Response.create(statusCode, reason, headers, toFeignBody(httpResponse));
165+
}
166+
167+
Response.Body toFeignBody(HttpResponse httpResponse) throws IOException {
168+
HttpEntity entity = httpResponse.getEntity();
169+
final Integer length = entity != null && entity.getContentLength() != -1 ?
170+
(int) entity.getContentLength() :
171+
null;
172+
final InputStream input = entity != null ?
173+
new ByteArrayInputStream(EntityUtils.toByteArray(entity)) :
174+
null;
175+
176+
return new Response.Body() {
177+
178+
@Override
179+
public void close() throws IOException {
180+
if (input != null) {
181+
input.close();
182+
}
183+
}
184+
185+
@Override
186+
public Integer length() {
187+
return length;
188+
}
189+
190+
@Override
191+
public boolean isRepeatable() {
192+
return false;
193+
}
194+
195+
@Override
196+
public InputStream asInputStream() throws IOException {
197+
return input;
198+
}
199+
200+
@Override
201+
public Reader asReader() throws IOException {
202+
return new InputStreamReader(input);
203+
}
204+
};
205+
}
206+
207+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2015 Netflix, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package feign.httpclient;
17+
18+
import com.squareup.okhttp.mockwebserver.MockResponse;
19+
import com.squareup.okhttp.mockwebserver.rule.MockWebServerRule;
20+
import feign.Feign;
21+
import feign.FeignException;
22+
import feign.Headers;
23+
import feign.RequestLine;
24+
import feign.Response;
25+
import org.junit.Rule;
26+
import org.junit.Test;
27+
import org.junit.rules.ExpectedException;
28+
29+
import java.io.ByteArrayInputStream;
30+
import java.io.IOException;
31+
32+
import static feign.Util.UTF_8;
33+
import static feign.assertj.MockWebServerAssertions.assertThat;
34+
import static java.util.Arrays.asList;
35+
import static org.junit.Assert.assertEquals;
36+
37+
public class ApacheHttpClientTest {
38+
39+
@Rule
40+
public final ExpectedException thrown = ExpectedException.none();
41+
@Rule
42+
public final MockWebServerRule server = new MockWebServerRule();
43+
44+
@Test
45+
public void parsesRequestAndResponse() throws IOException, InterruptedException {
46+
server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar"));
47+
48+
TestInterface api = Feign.builder()
49+
.client(new ApacheHttpClient())
50+
.target(TestInterface.class, "http://localhost:" + server.getPort());
51+
52+
Response response = api.post("foo");
53+
54+
assertThat(response.status()).isEqualTo(200);
55+
assertThat(response.reason()).isEqualTo("OK");
56+
assertThat(response.headers())
57+
.containsEntry("Content-Length", asList("3"))
58+
.containsEntry("Foo", asList("Bar"));
59+
assertThat(response.body().asInputStream())
60+
.hasContentEqualTo(new ByteArrayInputStream("foo".getBytes(UTF_8)));
61+
62+
assertThat(server.takeRequest()).hasMethod("POST")
63+
.hasPath("/?foo=bar&foo=baz&qux=")
64+
.hasHeaders("Foo: Bar", "Foo: Baz", "Qux: ", "Accept: */*", "Content-Length: 3")
65+
.hasBody("foo");
66+
}
67+
68+
@Test
69+
public void parsesErrorResponse() throws IOException, InterruptedException {
70+
thrown.expect(FeignException.class);
71+
thrown.expectMessage("status 500 reading TestInterface#post(String); content:\n" + "ARGHH");
72+
73+
server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH"));
74+
75+
TestInterface api = Feign.builder()
76+
.client(new ApacheHttpClient())
77+
.target(TestInterface.class, "http://localhost:" + server.getPort());
78+
79+
api.post("foo");
80+
}
81+
82+
@Test
83+
public void patch() throws IOException, InterruptedException {
84+
server.enqueue(new MockResponse().setBody("foo"));
85+
server.enqueue(new MockResponse());
86+
87+
TestInterface api = Feign.builder()
88+
.client(new ApacheHttpClient())
89+
.target(TestInterface.class, "http://localhost:" + server.getPort());
90+
91+
assertEquals("foo", api.patch());
92+
93+
assertThat(server.takeRequest())
94+
.hasHeaders("Accept: text/plain")
95+
.hasNoHeaderNamed("Content-Type")
96+
.hasMethod("PATCH");
97+
}
98+
99+
interface TestInterface {
100+
101+
@RequestLine("POST /?foo=bar&foo=baz&qux=")
102+
@Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: text/plain"})
103+
Response post(String body);
104+
105+
@RequestLine("PATCH /")
106+
@Headers("Accept: text/plain")
107+
String patch();
108+
}
109+
}

settings.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
rootProject.name='feign'
2-
include 'core', 'sax', 'gson', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j'
2+
include 'core', 'sax', 'gson', 'httpclient', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j'
33

44
rootProject.children.each { childProject ->
55
childProject.name = 'feign-' + childProject.name

0 commit comments

Comments
 (0)