diff --git a/CHANGES.md b/CHANGES.md index 833a455239..00f5e219f8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ ### Version 5.4.0 * Add `BasicAuthRequestInterceptor` +* Add Jackson integration ### Version 5.3.0 * Split `GsonCodec` into `GsonEncoder` and `GsonDecoder`, which are easy to use with `Feign.Builder` diff --git a/README.md b/README.md index 1d43eacd3f..e2a56ef1ed 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,18 @@ GitHub github = Feign.builder() .target(GitHub.class, "https://api.github.com"); ``` +### Jackson +[JacksonModule](https://github.com/Netflix/feign/tree/master/jackson) adds an encoder and decoder you can use with a JSON API. + +Add `JacksonEncoder` and/or `JacksonDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + ### Sax [SaxDecoder](https://github.com/Netflix/feign/tree/master/sax) allows you to decode XML in a way that is compatible with normal JVM and also Android environments. diff --git a/build.gradle b/build.gradle index 022cce7e8c..3da4379f3c 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,21 @@ project(':feign-gson') { } } +project(':feign-jackson') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + compile 'com.fasterxml.jackson.core:jackson-databind:2.2.2' + testCompile 'org.testng:testng:6.8.5' + testCompile 'com.google.guava:guava:14.0.1' + } +} + project(':feign-jaxrs') { apply plugin: 'java' diff --git a/core/src/main/java/feign/codec/DecodeException.java b/core/src/main/java/feign/codec/DecodeException.java index 5efab25ba6..1671bbdb60 100644 --- a/core/src/main/java/feign/codec/DecodeException.java +++ b/core/src/main/java/feign/codec/DecodeException.java @@ -22,7 +22,7 @@ /** * Similar to {@code javax.websocket.DecodeException}, raised when a problem * occurs decoding a message. Note that {@code DecodeException} is not an - * {@code IOException}, nor have one set as its cause. + * {@code IOException}, nor does it have one set as its cause. */ public class DecodeException extends FeignException { diff --git a/core/src/main/java/feign/codec/EncodeException.java b/core/src/main/java/feign/codec/EncodeException.java index 12d06ba340..bc9c660ca0 100644 --- a/core/src/main/java/feign/codec/EncodeException.java +++ b/core/src/main/java/feign/codec/EncodeException.java @@ -21,8 +21,8 @@ /** * Similar to {@code javax.websocket.EncodeException}, raised when a problem - * occurs decoding a message. Note that {@code DecodeException} is not an - * {@code IOException}, nor have one set as its cause. + * 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 { diff --git a/jackson/README.md b/jackson/README.md new file mode 100644 index 0000000000..a6b8f0fcdc --- /dev/null +++ b/jackson/README.md @@ -0,0 +1,33 @@ +Jackson Codec +=================== + +This module adds support for encoding and decoding JSON via Jackson. + +Add `JacksonEncoder` and/or `JacksonDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +If you want to customize the `ObjectMapper` that is used, provide it to the `JacksonEncoder` and `JacksonDecoder`: + +```java +ObjectMapper mapper = new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(SerializationFeature.INDENT_OUTPUT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + +GitHub github = Feign.builder() + .encoder(new JacksonEncoder(mapper)) + .decoder(new JacksonDecoder(mapper)) + .target(GitHub.class, "https://api.github.com"); +``` + +Alternatively, you can add the encoder and decoder to your Dagger object graph using the provided `JacksonModule` like so: + +```java +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new JacksonModule()); +``` diff --git a/jackson/src/main/java/feign/jackson/JacksonDecoder.java b/jackson/src/main/java/feign/jackson/JacksonDecoder.java new file mode 100644 index 0000000000..83400afc14 --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.java @@ -0,0 +1,53 @@ +/* + * 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.jackson; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.RuntimeJsonMappingException; +import feign.Response; +import feign.codec.Decoder; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; + +public class JacksonDecoder implements Decoder { + private final ObjectMapper mapper; + + public JacksonDecoder() { + this(new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)); + } + + public JacksonDecoder(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override public Object decode(Response response, Type type) throws IOException { + if (response.body() == null) { + return null; + } + Reader reader = response.body().asReader(); + try { + return mapper.readValue(reader, mapper.constructType(type)); + } catch (RuntimeJsonMappingException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } +} diff --git a/jackson/src/main/java/feign/jackson/JacksonEncoder.java b/jackson/src/main/java/feign/jackson/JacksonEncoder.java new file mode 100644 index 0000000000..1cc6895f2b --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java @@ -0,0 +1,46 @@ +/* + * 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.jackson; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; + +public class JacksonEncoder implements Encoder { + private final ObjectMapper mapper; + + public JacksonEncoder() { + this(new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(SerializationFeature.INDENT_OUTPUT, true)); + } + + public JacksonEncoder(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override public void encode(Object object, RequestTemplate template) throws EncodeException { + try { + template.body(mapper.writeValueAsString(object)); + } catch (JsonProcessingException e) { + throw new EncodeException(e.getMessage(), e); + } + } +} diff --git a/jackson/src/main/java/feign/jackson/JacksonModule.java b/jackson/src/main/java/feign/jackson/JacksonModule.java new file mode 100644 index 0000000000..7826118afa --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonModule.java @@ -0,0 +1,103 @@ +/* + * 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.jackson; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import dagger.Provides; +import feign.Feign; +import feign.codec.Decoder; +import feign.codec.Encoder; + +import javax.inject.Singleton; +import java.util.Collections; +import java.util.Set; + +/** + *

Custom serializers/deserializers

+ *
+ * In order to specify custom json parsing, Jackson's {@code ObjectMapper} supports {@link JsonSerializer serializers} + * and {@link JsonDeserializer deserializers}, which can be bundled together in {@link Module modules}. + *

+ *
+ * Here's an example of adding a custom module. + *

+ *

+ * public class ObjectIdSerializer extends StdSerializer<ObjectId> {
+ *     public ObjectIdSerializer() {
+ *         super(ObjectId.class);
+ *     }
+ *
+ *     @Override
+ *     public void serialize(ObjectId value, JsonGenerator jsonGenerator, SerializerProvider provider) throws IOException {
+ *         jsonGenerator.writeString(value.toString());
+ *     }
+ * }
+ *
+ * public class ObjectIdDeserializer extends StdDeserializer<ObjectId> {
+ *     public ObjectIdDeserializer() {
+ *         super(ObjectId.class);
+ *     }
+ *
+ *     @Override
+ *     public ObjectId deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException {
+ *         return ObjectId.massageToObjectId(jsonParser.getValueAsString());
+ *     }
+ * }
+ *
+ * public class ObjectIdModule extends SimpleModule {
+ *     public ObjectIdModule() {
+ *         // first deserializers
+ *         addDeserializer(ObjectId.class, new ObjectIdDeserializer());
+ *
+ *         // then serializers:
+ *         addSerializer(ObjectId.class, new ObjectIdSerializer());
+ *     }
+ * }
+ *
+ * @Provides(type = Provides.Type.SET)
+ * Module objectIdModule() {
+ *     return new ObjectIdModule();
+ * }
+ * 
+ */ +@dagger.Module(injects = Feign.class, addsTo = Feign.Defaults.class) +public final class JacksonModule { + @Provides Encoder encoder(ObjectMapper mapper) { + return new JacksonEncoder(mapper); + } + + @Provides Decoder decoder(ObjectMapper mapper) { + return new JacksonDecoder(mapper); + } + + @Provides @Singleton ObjectMapper mapper(Set modules) { + return new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(SerializationFeature.INDENT_OUTPUT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModules(modules); + } + + @Provides(type = Provides.Type.SET_VALUES) Set noDefaultModules() { + return Collections.emptySet(); + } +} diff --git a/jackson/src/test/java/feign/jackson/JacksonModuleTest.java b/jackson/src/test/java/feign/jackson/JacksonModuleTest.java new file mode 100644 index 0000000000..c13583f6db --- /dev/null +++ b/jackson/src/test/java/feign/jackson/JacksonModuleTest.java @@ -0,0 +1,184 @@ +package feign.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.google.common.reflect.TypeToken; +import dagger.Module; +import dagger.ObjectGraph; +import dagger.Provides; +import feign.RequestTemplate; +import feign.Response; +import feign.codec.Decoder; +import feign.codec.Encoder; +import org.testng.annotations.Test; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.*; + +import static org.testng.Assert.assertEquals; + +@Test +public class JacksonModuleTest { + @Module(includes = JacksonModule.class, injects = EncoderAndDecoderBindings.class) + static class EncoderAndDecoderBindings { + @Inject + Encoder encoder; + @Inject + Decoder decoder; + } + + @Test + public void providesEncoderDecoder() throws Exception { + EncoderAndDecoderBindings bindings = new EncoderAndDecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + assertEquals(bindings.encoder.getClass(), JacksonEncoder.class); + assertEquals(bindings.decoder.getClass(), JacksonDecoder.class); + } + + @Module(includes = JacksonModule.class, injects = EncoderBindings.class) + static class EncoderBindings { + @Inject Encoder encoder; + } + + @Test public void encodesMapObjectNumericalValuesAsInteger() throws Exception { + EncoderBindings bindings = new EncoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map map = new LinkedHashMap(); + map.put("foo", 1); + + RequestTemplate template = new RequestTemplate(); + bindings.encoder.encode(map, template); + assertEquals(template.body(), ""// + + "{\n" // + + " \"foo\" : 1\n" // + + "}"); + } + + @Test public void encodesFormParams() throws Exception { + EncoderBindings bindings = new EncoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map form = new LinkedHashMap(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + RequestTemplate template = new RequestTemplate(); + bindings.encoder.encode(form, template); + assertEquals(template.body(), ""// + + "{\n" // + + " \"foo\" : 1,\n" // + + " \"bar\" : [ 2, 3 ]\n" // + + "}"); + } + + static class Zone extends LinkedHashMap { + Zone() { + // for reflective instantiation. + } + + Zone(String name) { + this(name, null); + } + + Zone(String name, String id) { + put("name", name); + if (id != null) { + put("id", id); + } + } + + private static final long serialVersionUID = 1L; + } + + @Module(includes = JacksonModule.class, injects = DecoderBindings.class) + static class DecoderBindings { + @Inject Decoder decoder; + } + + @Test public void decodes() throws Exception { + DecoderBindings bindings = new DecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + Response response = Response.create(200, "OK", Collections.>emptyMap(), zonesJson); + assertEquals(bindings.decoder.decode(response, new TypeToken>() { + }.getType()), zones); + } + + @Test public void nullBodyDecodesToNull() throws Exception { + DecoderBindings bindings = new DecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Response response = Response.create(204, "OK", Collections.>emptyMap(), null); + assertEquals(bindings.decoder.decode(response, String.class), null); + } + + private String zonesJson = ""// + + "[\n"// + + " {\n"// + + " \"name\": \"denominator.io.\"\n"// + + " },\n"// + + " {\n"// + + " \"name\": \"denominator.io.\",\n"// + + " \"id\": \"ABCD\"\n"// + + " }\n"// + + "]\n"; + + static class ZoneDeserializer extends StdDeserializer { + public ZoneDeserializer() { + super(Zone.class); + } + + @Override + public Zone deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + Zone zone = new Zone(); + jp.nextToken(); + while (jp.nextToken() != JsonToken.END_OBJECT) { + String name = jp.getCurrentName(); + String value = jp.getValueAsString(); + if (value != null) { + zone.put(name, value.toUpperCase()); + } + } + return zone; + } + } + + static class ZoneModule extends SimpleModule { + public ZoneModule() { + addDeserializer(Zone.class, new ZoneDeserializer()); + } + } + + @Module(includes = JacksonModule.class, injects = CustomJacksonModule.class) + static class CustomJacksonModule { + @Inject Decoder decoder; + + @Provides(type = Provides.Type.SET) + com.fasterxml.jackson.databind.Module upperZone() { + return new ZoneModule(); + } + } + + @Test public void customDecoder() throws Exception { + CustomJacksonModule bindings = new CustomJacksonModule(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("DENOMINATOR.IO.")); + zones.add(new Zone("DENOMINATOR.IO.", "ABCD")); + + Response response = Response.create(200, "OK", Collections.>emptyMap(), zonesJson); + assertEquals(bindings.decoder.decode(response, new TypeToken>() { + }.getType()), zones); + } +} diff --git a/jackson/src/test/java/feign/jackson/examples/GitHubExample.java b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java new file mode 100644 index 0000000000..24f490efb3 --- /dev/null +++ b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java @@ -0,0 +1,40 @@ +package feign.jackson.examples; + +import feign.Feign; +import feign.RequestLine; +import feign.jackson.JacksonDecoder; + +import javax.inject.Named; +import java.util.List; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Named("owner") String owner, @Named("repo") String repo); + } + + static class Contributor { + private String login; + private int contributions; + + void setLogin(String login) { + this.login = login; + } + + void setContributions(int contributions) { + this.contributions = contributions; + } + } + + public static void main(String... args) throws InterruptedException { + GitHub github = Feign.builder().decoder(new JacksonDecoder()).target(GitHub.class, "https://api.github.com"); + System.out.println("Let's fetch and print a list of the contributors to this library."); + List contributors = github.contributors("netflix", "feign"); + for (Contributor contributor : contributors) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + } +} diff --git a/settings.gradle b/settings.gradle index b7b41a0482..8dac555cc9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,5 @@ rootProject.name='feign' -include 'core', 'sax', 'gson', 'jaxrs', 'ribbon', 'example-github', 'example-wikipedia' +include 'core', 'sax', 'gson', 'jackson', 'jaxrs', 'ribbon', 'example-github', 'example-wikipedia' rootProject.children.each { childProject -> childProject.name = 'feign-' + childProject.name