diff --git a/CHANGES.md b/CHANGES.md index 8c6102d9..30ab975c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ ### Version 3.0 * Added support for asynchronous callbacks via `IncrementalCallback` and `IncrementalDecoder.TextStream`. * Wire is now Logger, with configurable Logger.Level. +* Added `feign-gson` codec, used via `new GsonModule()` * changed codec to be similar to [WebSocket JSR 356](http://docs.oracle.com/javaee/7/api/javax/websocket/package-summary.html) * Decoder is now `Decoder.TextStream` * BodyEncoder is now `Encoder.Text` diff --git a/README.md b/README.md index 95195ce3..29ff2e2a 100644 --- a/README.md +++ b/README.md @@ -34,40 +34,9 @@ public static void main(String... args) { } } ``` -### Decoders -The last argument to `Feign.create` specifies how to decode the responses, modeled in Dagger. Here's how it looks to wire in a default gson decoder: - -```java -@Module(overrides = true, library = true) -static class GsonModule { - @Provides(type = SET) Decoder decoder() { - return new Decoder.TextStream() { - Gson gson = new Gson(); - @Override public Object decode(Reader reader, Type type) throws IOException { - try { - return gson.fromJson(reader, type); - } catch (JsonIOException e) { - if (e.getCause() != null && e.getCause() instanceof IOException) { - throw IOException.class.cast(e.getCause()); - } - throw e; - } - } - }; - } -} -``` -Feign doesn't offer a built-in json decoder as you can see above it is very few lines of code to wire yours in. If you are a jackson user, you'd probably thank us for not dragging in a dependency you don't use. +Feign includes a fully functional json codec in the `feign-gson` extension. See the `Decoder` section for how to write your own. -#### Type-specific Decoders -The generic parameter of `Decoder.TextStream` designates which The type parameter is either a concrete type, or `Object`, if your decoder can handle multiple types. To add a type-specific decoder, ensure your type parameter is correct. Here's an example of an xml decoder that will only apply to methods that return `ZoneList`. - -``` -@Provides(type = SET) Decoder zoneListDecoder(Provider handlers) { - return new SAXDecoder(handlers){}; -} -``` ### Asynchronous Incremental Callbacks If specified as the last argument of a method `IncrementalCallback` fires a background task to add new elements to the callback as they are decoded. Think of `IncrementalCallback` as an asynchronous equivalent to a lazy sequence. @@ -91,36 +60,6 @@ IncrementalCallback printlnObserver = new IncrementalCallback`, you'll need to configure an `IncrementalDecoderi.TextStream` or a general one for all types (`IncrementalDecoder.TextStream`). - -Here's how to wire in a reflective incremental json decoder: -```java -@Provides(type = SET) IncrementalDecoder incrementalDecoder(final Gson gson) { - return new IncrementalDecoder.TextStream() { - - @Override - public void decode(Reader reader, Type type, IncrementalCallback incrementalCallback) throws IOException { - JsonReader jsonReader = new JsonReader(reader); - jsonReader.beginArray(); - while (jsonReader.hasNext()) { - try { - incrementalCallback.onNext(gson.fromJson(jsonReader, type)); - } catch (JsonIOException e) { - if (e.getCause() != null && e.getCause() instanceof IOException) { - throw IOException.class.cast(e.getCause()); - } - throw e; - } - } - jsonReader.endArray(); - } - }; -} -``` - - - ### Multiple Interfaces Feign can produce multiple api interfaces. These are defined as `Target` (default `HardCodedTarget`), which allow for dynamic discovery and decoration of requests prior to execution. @@ -134,6 +73,14 @@ You can find [several examples](https://github.com/Netflix/feign/tree/master/fei ### Integrations Feign intends to work well within Netflix and other Open Source communities. Modules are welcome to integrate with your favorite projects! +### Gson +[GsonModule](https://github.com/Netflix/feign/tree/master/feign-gson) adds default encoders and decoders so you get get started with a json api. + +Integration requires you pass `new GsonModule()` to `Feign.create()`, or add it to your graph with Dagger: +```java +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonModule()); +``` + ### JAX-RS [JAXRSModule](https://github.com/Netflix/feign/tree/master/feign-jaxrs) overrides annotation processing to instead use standard ones supplied by the JAX-RS specification. This is currently targeted at the 1.1 spec. @@ -151,6 +98,60 @@ Integration requires you to pass your ribbon client name as the host part of the ```java MyService api = Feign.create(MyService.class, "https://myAppProd", new RibbonModule()); ``` + +### Decoders +The last argument to `Feign.create` allows you to specify additional configuration such as how to decode a responses, modeled in Dagger. + +If any methods in your interface return types besides `void` or `String`, you'll need to configure a `Decoder.TextStream` or a general one for all types (`Decoder.TextStream`). + +The `GsonModule` in the `feign-gson` extension configures a (`Decoder.TextStream`) which parses objects from json using reflection. + +Here's how you could write this yourself, using whatever library you prefer: +```java +@Module(overrides = true, library = true) +static class JsonModule { + @Provides(type = SET) Decoder decoder(final JsonParser parser) { + return new Decoder.TextStream() { + + @Override public Object decode(Reader reader, Type type) throws IOException { + return parser.readJson(reader, type); + } + + }; + } +} +``` +#### Type-specific Decoders +The generic parameter of `Decoder.TextStream` designates which The type parameter is either a concrete type, or `Object`, if your decoder can handle multiple types. To add a type-specific decoder, ensure your type parameter is correct. Here's an example of an xml decoder that will only apply to methods that return `ZoneList`. + +``` +@Provides(type = SET) Decoder zoneListDecoder(Provider handlers) { + return new SAXDecoder(handlers){}; +} +``` +#### Incremental Decoding +The last argument to `Feign.create` allows you to specify additional configuration such as how to decode a responses, modeled in Dagger. + +When using an `IncrementalCallback`, if `T` is not `Void` or `String`, you'll need to configure an `IncrementalDecoder.TextStream` or a general one for all types (`IncrementalDecoder.TextStream`). + +The `GsonModule` in the `feign-gson` extension configures a (`IncrementalDecoder.TextStream`) which parses objects from json using reflection. + +Here's how you could write this yourself, using whatever library you prefer: +```java +@Provides(type = SET) IncrementalDecoder incrementalDecoder(final JsonParser parser) { + return new IncrementalDecoder.TextStream() { + + @Override + public void decode(Reader reader, Type type, IncrementalCallback incrementalCallback) throws IOException { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + incrementalCallback.onNext(parser.readJson(reader, type)); + } + jsonReader.endArray(); + } + }; +} +``` ### Advanced usage and Dagger #### Dagger Feign can be directly wired into Dagger which keeps things at compile time and Android friendly. As opposed to exposing builders for config, Feign intends users to embed their config in Dagger. diff --git a/build.gradle b/build.gradle index 56d1cd43..829eb46d 100644 --- a/build.gradle +++ b/build.gradle @@ -61,7 +61,21 @@ project(':feign-jaxrs') { testCompile 'com.google.guava:guava:14.0.1' testCompile 'com.google.code.gson:gson:2.2.4' testCompile 'org.testng:testng:6.8.1' - testCompile 'com.google.mockwebserver:mockwebserver:20130505' + } +} + +project(':feign-gson') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + compile 'com.google.code.gson:gson:2.2.4' + provided 'com.squareup.dagger:dagger-compiler:1.0.1' + testCompile 'org.testng:testng:6.8.1' } } diff --git a/feign-gson/README.md b/feign-gson/README.md new file mode 100644 index 00000000..206990e7 --- /dev/null +++ b/feign-gson/README.md @@ -0,0 +1,10 @@ +Gson Codec +=================== + +This module adds support for encoding and decoding json via the Gson library. + +Add this to your object graph like so: + +```java +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonModule()); +``` diff --git a/feign-gson/src/main/java/feign/gson/GsonModule.java b/feign-gson/src/main/java/feign/gson/GsonModule.java new file mode 100644 index 00000000..53cc8ac0 --- /dev/null +++ b/feign-gson/src/main/java/feign/gson/GsonModule.java @@ -0,0 +1,127 @@ +/* + * 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.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.InstanceCreator; +import com.google.gson.JsonIOException; +import com.google.gson.TypeAdapter; +import com.google.gson.internal.ConstructorConstructor; +import com.google.gson.internal.bind.MapTypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import dagger.Provides; +import feign.IncrementalCallback; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.IncrementalDecoder; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Map; + +import static dagger.Provides.Type.SET; + +@dagger.Module(library = true, overrides = true) +public final class GsonModule { + + @Provides(type = SET) Encoder encoder(GsonCodec codec) { + return codec; + } + + @Provides(type = SET) Decoder decoder(GsonCodec codec) { + return codec; + } + + @Provides(type = SET) IncrementalDecoder incrementalDecoder(GsonCodec codec) { + return codec; + } + + static class GsonCodec implements Encoder.Text, Decoder.TextStream, IncrementalDecoder.TextStream { + private final Gson gson; + + @Inject GsonCodec(Gson gson) { + this.gson = gson; + } + + @Override public String encode(Object object) throws EncodeException { + return gson.toJson(object); + } + + @Override public Object decode(Reader reader, Type type) throws IOException { + return fromJson(new JsonReader(reader), type); + } + + @Override + public void decode(Reader reader, Type type, IncrementalCallback incrementalCallback) throws IOException { + JsonReader jsonReader = new JsonReader(reader); + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + incrementalCallback.onNext(fromJson(jsonReader, type)); + } + jsonReader.endArray(); + } + + private Object fromJson(JsonReader jsonReader, Type type) throws IOException { + try { + return gson.fromJson(jsonReader, type); + } catch (JsonIOException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } + } + + // deals with scenario where gson Object type treats all numbers as doubles. + @Provides TypeAdapter> doubleToInt() { + return new TypeAdapter>() { + TypeAdapter> delegate = new MapTypeAdapterFactory(new ConstructorConstructor( + Collections.>emptyMap()), false).create(new Gson(), token); + + @Override + public void write(JsonWriter out, Map value) throws IOException { + delegate.write(out, value); + } + + @Override + public Map read(JsonReader in) throws IOException { + Map map = delegate.read(in); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() instanceof Double) { + entry.setValue(Double.class.cast(entry.getValue()).intValue()); + } + } + return map; + } + }.nullSafe(); + } + + @Provides @Singleton Gson gson(TypeAdapter> doubleToInt) { + return new GsonBuilder().registerTypeAdapter(token.getType(), doubleToInt).setPrettyPrinting().create(); + } + + protected final static TypeToken> token = new TypeToken>() { + }; +} diff --git a/feign-gson/src/test/java/feign/gson/GsonModuleTest.java b/feign-gson/src/test/java/feign/gson/GsonModuleTest.java new file mode 100644 index 00000000..9dd61a98 --- /dev/null +++ b/feign-gson/src/test/java/feign/gson/GsonModuleTest.java @@ -0,0 +1,182 @@ +/* + * 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.gson; + +import com.google.gson.reflect.TypeToken; +import dagger.Module; +import dagger.ObjectGraph; +import feign.IncrementalCallback; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.IncrementalDecoder; +import org.testng.annotations.Test; + +import javax.inject.Inject; +import java.io.StringReader; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.fail; + +@Test +public class GsonModuleTest { + + @Test public void providesEncoderDecoderAndIncrementalDecoder() throws Exception { + @Module(includes = GsonModule.class, injects = SetBindings.class) class SetBindings { + @Inject Set encoders; + @Inject Set decoders; + @Inject Set incrementalDecoders; + } + + SetBindings bindings = new SetBindings(); + ObjectGraph.create(bindings).inject(bindings); + + assertEquals(bindings.encoders.size(), 1); + assertEquals(bindings.encoders.iterator().next().getClass(), GsonModule.GsonCodec.class); + assertEquals(bindings.decoders.size(), 1); + assertEquals(bindings.decoders.iterator().next().getClass(), GsonModule.GsonCodec.class); + assertEquals(bindings.incrementalDecoders.size(), 1); + assertEquals(bindings.incrementalDecoders.iterator().next().getClass(), GsonModule.GsonCodec.class); + } + + @Test public void encodesMapObjectNumericalValuesAsInteger() throws Exception { + @Module(includes = GsonModule.class, injects = SetBindings.class) class SetBindings { + @Inject Set encoders; + } + + SetBindings bindings = new SetBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map map = new LinkedHashMap(); + map.put("foo", 1); + + assertEquals(Encoder.Text.class.cast(bindings.encoders.iterator().next()).encode(map), ""// + + "{\n" // + + " \"foo\": 1\n" // + + "}"); + } + + @Test public void encodesFormParams() throws Exception { + @Module(includes = GsonModule.class, injects = SetBindings.class) class SetBindings { + @Inject Set encoders; + } + + SetBindings bindings = new SetBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map form = new LinkedHashMap(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + assertEquals(Encoder.Text.class.cast(bindings.encoders.iterator().next()).encode(form), ""// + + "{\n" // + + " \"foo\": 1,\n" // + + " \"bar\": [\n" // + + " 2,\n" // + + " 3\n" // + + " ]\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; + } + + @Test public void decodes() throws Exception { + @Module(includes = GsonModule.class, injects = SetBindings.class) class SetBindings { + @Inject Set decoders; + } + + SetBindings bindings = new SetBindings(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + assertEquals(Decoder.TextStream.class.cast(bindings.decoders.iterator().next()) + .decode(new StringReader(zonesJson), new TypeToken>() { + }.getType()), zones); + } + + @Test public void decodesIncrementally() throws Exception { + @Module(includes = GsonModule.class, injects = SetBindings.class) class SetBindings { + @Inject Set decoders; + } + + SetBindings bindings = new SetBindings(); + ObjectGraph.create(bindings).inject(bindings); + + final List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + final AtomicInteger index = new AtomicInteger(0); + + IncrementalCallback zoneCallback = new IncrementalCallback() { + + @Override public void onNext(Zone element) { + assertEquals(element, zones.get(index.getAndIncrement())); + } + + @Override public void onSuccess() { + // decoder shouldn't call onSuccess + fail(); + } + + @Override public void onFailure(Throwable cause) { + // decoder shouldn't call onFailure + fail(); + } + }; + + IncrementalDecoder.TextStream.class.cast(bindings.decoders.iterator().next()) + .decode(new StringReader(zonesJson), Zone.class, zoneCallback); + + assertEquals(index.get(), 2); + } + + private String zonesJson = ""// + + "[\n"// + + " {\n"// + + " \"name\": \"denominator.io.\"\n"// + + " },\n"// + + " {\n"// + + " \"name\": \"denominator.io.\",\n"// + + " \"id\": \"ABCD\"\n"// + + " }\n"// + + "]\n"; +} diff --git a/settings.gradle b/settings.gradle index dc5b04ff..f15a2c39 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,2 @@ rootProject.name='feign' -include 'feign-core', 'feign-jaxrs', 'feign-ribbon', 'examples:feign-example-cli' +include 'feign-core', 'feign-gson', 'feign-jaxrs', 'feign-ribbon', 'examples:feign-example-cli'