}.
+ *
Note on exception propagation
Exceptions thrown by {@link Decoder}s get wrapped in
+ * a {@link DecodeException} unless they are a subclass of {@link FeignException} already, and unless
+ * the client was configured with {@link Feign.Builder#decode404()}.
+ */
+public interface Decoder {
+
+ /**
+ * Decodes an http response into an object corresponding to its {@link
+ * java.lang.reflect.Method#getGenericReturnType() generic return type}. If you need to wrap
+ * exceptions, please do so via {@link DecodeException}.
+ *
+ * @param response the response to decode
+ * @param type {@link java.lang.reflect.Method#getGenericReturnType() generic return type} of
+ * the method corresponding to this {@code response}.
+ * @return instance of {@code type}
+ * @throws IOException will be propagated safely to the caller.
+ * @throws DecodeException when decoding failed due to a checked exception besides IOException.
+ * @throws FeignException when decoding succeeds, but conveys the operation failed.
+ */
+ Object decode(Response response, Type type) throws IOException, DecodeException, FeignException;
+
+ /** Default implementation of {@code Decoder}. */
+ public class Default extends StringDecoder {
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ if (response.status() == 404) return Util.emptyValueOf(type);
+ if (response.body() == null) return null;
+ if (byte[].class.equals(type)) {
+ return Util.toByteArray(response.body().asInputStream());
+ }
+ return super.decode(response, type);
+ }
+ }
+}
diff --git a/core/src/main/java/feign/codec/EncodeException.java b/core/src/main/java/feign/codec/EncodeException.java
new file mode 100644
index 0000000000..aafee3e1ea
--- /dev/null
+++ b/core/src/main/java/feign/codec/EncodeException.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2013 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.codec;
+
+import feign.FeignException;
+
+import static feign.Util.checkNotNull;
+
+/**
+ * Similar to {@code javax.websocket.EncodeException}, raised when a problem occurs encoding a
+ * message. Note that {@code EncodeException} is not an {@code IOException}, nor does it have one
+ * set as its cause.
+ */
+public class EncodeException extends FeignException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param message the reason for the failure.
+ */
+ public EncodeException(String message) {
+ super(checkNotNull(message, "message"));
+ }
+
+ /**
+ * @param message possibly null reason for the failure.
+ * @param cause the cause of the error.
+ */
+ public EncodeException(String message, Throwable cause) {
+ super(message, checkNotNull(cause, "cause"));
+ }
+}
diff --git a/core/src/main/java/feign/codec/Encoder.java b/core/src/main/java/feign/codec/Encoder.java
new file mode 100644
index 0000000000..10729a081e
--- /dev/null
+++ b/core/src/main/java/feign/codec/Encoder.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2013 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.codec;
+
+import java.lang.reflect.Type;
+
+import feign.RequestTemplate;
+import feign.Util;
+
+import static java.lang.String.format;
+
+/**
+ * Encodes an object into an HTTP request body. Like {@code javax.websocket.Encoder}. {@code
+ * Encoder} is used when a method parameter has no {@code @Param} annotation. For example:
+ *
+ *
+ * @POST
+ * @Path("/")
+ * void create(User user);
+ *
+ * Example implementation:
+ *
+ * public class GsonEncoder implements Encoder {
+ * private final Gson gson;
+ *
+ * public GsonEncoder(Gson gson) {
+ * this.gson = gson;
+ * }
+ *
+ * @Override
+ * public void encode(Object object, Type bodyType, RequestTemplate template) {
+ * template.body(gson.toJson(object, bodyType));
+ * }
+ * }
+ *
+ *
+ * Form encoding
If any parameters are found in {@link
+ * feign.MethodMetadata#formParams()}, they will be collected and passed to the Encoder as a map.
+ *
+ *
Ex. The following is a form. Notice the parameters aren't consumed in the request line. A map
+ * including "username" and "password" keys will passed to the encoder, and the body type will be
+ * {@link #MAP_STRING_WILDCARD}.
+ *
+ * @RequestLine("POST /")
+ * Session login(@Param("username") String username, @Param("password") String
+ * password);
+ *
+ */
+public interface Encoder {
+ /** Type literal for {@code Map}, indicating the object to encode is a form. */
+ Type MAP_STRING_WILDCARD = Util.MAP_STRING_WILDCARD;
+
+ /**
+ * Converts objects to an appropriate representation in the template.
+ *
+ * @param object what to encode as the request body.
+ * @param bodyType the type the object should be encoded as. {@link #MAP_STRING_WILDCARD}
+ * indicates form encoding.
+ * @param template the request template to populate.
+ * @throws EncodeException when encoding failed due to a checked exception.
+ */
+ void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException;
+
+ /**
+ * Default implementation of {@code Encoder}.
+ */
+ class Default implements Encoder {
+
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ if (bodyType == String.class) {
+ template.body(object.toString());
+ } else if (bodyType == byte[].class) {
+ template.body((byte[]) object, null);
+ } else if (object != null) {
+ throw new EncodeException(
+ format("%s is not a type supported by this encoder.", object.getClass()));
+ }
+ }
+ }
+}
diff --git a/core/src/main/java/feign/codec/ErrorDecoder.java b/core/src/main/java/feign/codec/ErrorDecoder.java
new file mode 100644
index 0000000000..404563b863
--- /dev/null
+++ b/core/src/main/java/feign/codec/ErrorDecoder.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2013 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.codec;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+import java.util.Date;
+import java.util.Map;
+
+import feign.FeignException;
+import feign.Response;
+import feign.RetryableException;
+
+import static feign.FeignException.errorStatus;
+import static feign.Util.RETRY_AFTER;
+import static feign.Util.checkNotNull;
+import static java.util.Locale.US;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+/**
+ * Allows you to massage an exception into a application-specific one. Converting out to a throttle
+ * exception are examples of this in use.
+ *
+ * Ex:
+ *
+ * class IllegalArgumentExceptionOn404Decoder extends ErrorDecoder {
+ *
+ * @Override
+ * public Exception decode(String methodKey, Response response) {
+ * if (response.status() == 400)
+ * throw new IllegalArgumentException("bad zone name");
+ * return new ErrorDecoder.Default().decode(methodKey, request, response);
+ * }
+ *
+ * }
+ *
+ *
+ * Error handling
+ *
+ * Responses where {@link Response#status()} is not in the 2xx
+ * range are classified as errors, addressed by the {@link ErrorDecoder}. That said, certain RPC
+ * apis return errors defined in the {@link Response#body()} even on a 200 status. For example, in
+ * the DynECT api, a job still running condition is returned with a 200 status, encoded in json.
+ * When scenarios like this occur, you should raise an application-specific exception (which may be
+ * {@link feign.RetryableException retryable}).
+ *
+ * Not Found Semantics
+ * It is commonly the case that 404 (Not Found) status has semantic value in HTTP apis. While
+ * the default behavior is to raise exeception, users can alternatively enable 404 processing via
+ * {@link feign.Feign.Builder#decode404()}.
+ */
+public interface ErrorDecoder {
+
+ /**
+ * Implement this method in order to decode an HTTP {@link Response} when {@link
+ * Response#status()} is not in the 2xx range. Please raise application-specific exceptions where
+ * possible. If your exception is retryable, wrap or subclass {@link RetryableException}
+ *
+ * @param methodKey {@link feign.Feign#configKey} of the java method that invoked the request.
+ * ex. {@code IAM#getUser()}
+ * @param response HTTP response where {@link Response#status() status} is greater than or equal
+ * to {@code 300}.
+ * @return Exception IOException, if there was a network error reading the response or an
+ * application-specific exception decoded by the implementation. If the throwable is retryable, it
+ * should be wrapped, or a subtype of {@link RetryableException}
+ */
+ public Exception decode(String methodKey, Response response);
+
+ public static class Default implements ErrorDecoder {
+
+ private final RetryAfterDecoder retryAfterDecoder = new RetryAfterDecoder();
+
+ @Override
+ public Exception decode(String methodKey, Response response) {
+ FeignException exception = errorStatus(methodKey, response);
+ Date retryAfter = retryAfterDecoder.apply(firstOrNull(response.headers(), RETRY_AFTER));
+ if (retryAfter != null) {
+ return new RetryableException(exception.getMessage(), exception, retryAfter);
+ }
+ return exception;
+ }
+
+ private T firstOrNull(Map> map, String key) {
+ if (map.containsKey(key) && !map.get(key).isEmpty()) {
+ return map.get(key).iterator().next();
+ }
+ return null;
+ }
+ }
+
+ /**
+ * Decodes a {@link feign.Util#RETRY_AFTER} header into an absolute date, if possible.
See Retry-After format
+ */
+ static class RetryAfterDecoder {
+
+ static final DateFormat
+ RFC822_FORMAT =
+ new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", US);
+ private final DateFormat rfc822Format;
+
+ RetryAfterDecoder() {
+ this(RFC822_FORMAT);
+ }
+
+ RetryAfterDecoder(DateFormat rfc822Format) {
+ this.rfc822Format = checkNotNull(rfc822Format, "rfc822Format");
+ }
+
+ protected long currentTimeMillis() {
+ return System.currentTimeMillis();
+ }
+
+ /**
+ * returns a date that corresponds to the first time a request can be retried.
+ *
+ * @param retryAfter String in Retry-After format
+ */
+ public Date apply(String retryAfter) {
+ if (retryAfter == null) {
+ return null;
+ }
+ if (retryAfter.matches("^[0-9]+$")) {
+ long deltaMillis = SECONDS.toMillis(Long.parseLong(retryAfter));
+ return new Date(currentTimeMillis() + deltaMillis);
+ }
+ synchronized (rfc822Format) {
+ try {
+ return rfc822Format.parse(retryAfter);
+ } catch (ParseException ignored) {
+ return null;
+ }
+ }
+ }
+ }
+}
diff --git a/core/src/main/java/feign/codec/StringDecoder.java b/core/src/main/java/feign/codec/StringDecoder.java
new file mode 100644
index 0000000000..261d0357f9
--- /dev/null
+++ b/core/src/main/java/feign/codec/StringDecoder.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2013 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.codec;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+
+import feign.Response;
+import feign.Util;
+
+import static java.lang.String.format;
+
+public class StringDecoder implements Decoder {
+
+ @Override
+ public Object decode(Response response, Type type) throws IOException {
+ Response.Body body = response.body();
+ if (body == null) {
+ return null;
+ }
+ if (String.class.equals(type)) {
+ return Util.toString(body.asReader());
+ }
+ throw new DecodeException(format("%s is not a type supported by this decoder.", type));
+ }
+}
diff --git a/core/src/test/java/feign/BaseApiTest.java b/core/src/test/java/feign/BaseApiTest.java
new file mode 100644
index 0000000000..121f67a29c
--- /dev/null
+++ b/core/src/test/java/feign/BaseApiTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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;
+
+import com.google.gson.reflect.TypeToken;
+
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.lang.reflect.Type;
+import java.util.List;
+
+import feign.codec.Decoder;
+import feign.codec.Encoder;
+
+import static feign.assertj.MockWebServerAssertions.assertThat;
+
+public class BaseApiTest {
+
+ @Rule
+ public final MockWebServer server = new MockWebServer();
+
+ interface BaseApi {
+
+ @RequestLine("GET /api/{key}")
+ Entity get(@Param("key") K key);
+
+ @RequestLine("POST /api")
+ Entities getAll(Keys keys);
+ }
+
+ static class Keys {
+
+ List keys;
+ }
+
+ static class Entity {
+
+ K key;
+ M model;
+ }
+
+ static class Entities {
+
+ List> entities;
+ }
+
+ interface MyApi extends BaseApi {
+
+ }
+
+ @Test
+ public void resolvesParameterizedResult() throws InterruptedException {
+ server.enqueue(new MockResponse().setBody("foo"));
+
+ String baseUrl = server.url("/default").toString();
+
+ Feign.builder()
+ .decoder(new Decoder() {
+ @Override
+ public Object decode(Response response, Type type) {
+ assertThat(type)
+ .isEqualTo(new TypeToken>() {
+ }.getType());
+ return null;
+ }
+ })
+ .target(MyApi.class, baseUrl).get("foo");
+
+ assertThat(server.takeRequest()).hasPath("/default/api/foo");
+ }
+
+ @Test
+ public void resolvesBodyParameter() throws InterruptedException {
+ server.enqueue(new MockResponse().setBody("foo"));
+
+ String baseUrl = server.url("/default").toString();
+
+ Feign.builder()
+ .encoder(new Encoder() {
+ @Override
+ public void encode(Object object, Type bodyType, RequestTemplate template) {
+ assertThat(bodyType)
+ .isEqualTo(new TypeToken