diff --git a/README.md b/README.md index 74c1fb280..55dda3597 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,17 @@ public class Example { For the lighter weight Jackson Jr, use `JacksonJrEncoder` and `JacksonJrDecoder` from the [Jackson Jr Module](./jackson-jr). +#### Moshi +[Moshi](./moshi) includes an encoder and decoder you can use with a JSON API. +Add `MoshiEncoder` and/or `MoshiDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new MoshiEncoder()) + .decoder(new MoshiDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + #### Sax [SaxDecoder](./sax) allows you to decode XML in a way that is compatible with normal JVM and also Android environments. diff --git a/moshi/README.md b/moshi/README.md new file mode 100644 index 000000000..1ffdb6fae --- /dev/null +++ b/moshi/README.md @@ -0,0 +1,13 @@ +Moshi Codec +=================== + +This module adds support for encoding and decoding JSON via the Moshi library. + +Add `MoshiEncoder` and/or `MoshiDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new MoshiEncoder()) + .decoder(new MoshiDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/moshi/pom.xml b/moshi/pom.xml new file mode 100644 index 000000000..a97784d2c --- /dev/null +++ b/moshi/pom.xml @@ -0,0 +1,58 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 13.0-SNAPSHOT + + + feign-moshi + Feign Moshi + Feign Moshi + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + com.squareup.moshi + moshi + + + + com.google.guava + guava + ${guava.version} + + + + ${project.groupId} + feign-core + test-jar + test + + + diff --git a/moshi/src/main/java/feign/moshi/MoshiDecoder.java b/moshi/src/main/java/feign/moshi/MoshiDecoder.java new file mode 100644 index 000000000..8baf101f2 --- /dev/null +++ b/moshi/src/main/java/feign/moshi/MoshiDecoder.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import static feign.Util.UTF_8; +import static feign.Util.ensureClosed; + +import com.google.common.io.CharStreams; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; +import feign.Response; +import feign.Util; +import feign.codec.Decoder; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; + +public class MoshiDecoder implements Decoder { + private final Moshi moshi; + + public MoshiDecoder(Moshi moshi) { + this.moshi = moshi; + } + + public MoshiDecoder() { + this.moshi = new Moshi.Builder().build(); + } + + public MoshiDecoder(Iterable> adapters) { + this(MoshiFactory.create(adapters)); + } + + @Override + public Object decode(Response response, Type type) throws IOException { + JsonAdapter jsonAdapter = moshi.adapter(type); + + if (response.status() == 404 || response.status() == 204) return Util.emptyValueOf(type); + if (response.body() == null) return null; + + Reader reader = response.body().asReader(UTF_8); + + try { + return parseJson(jsonAdapter, reader); + } catch (JsonDataException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw (IOException) e.getCause(); + } + throw e; + } finally { + ensureClosed(reader); + } + } + + private Object parseJson(JsonAdapter jsonAdapter, Reader reader) throws IOException { + String targetString = CharStreams.toString(reader); + return jsonAdapter.fromJson(targetString); + } +} diff --git a/moshi/src/main/java/feign/moshi/MoshiEncoder.java b/moshi/src/main/java/feign/moshi/MoshiEncoder.java new file mode 100644 index 000000000..0fb60c1b3 --- /dev/null +++ b/moshi/src/main/java/feign/moshi/MoshiEncoder.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import feign.RequestTemplate; +import feign.codec.Encoder; +import java.lang.reflect.Type; + +public class MoshiEncoder implements Encoder { + + private final Moshi moshi; + + public MoshiEncoder() { + this.moshi = new Moshi.Builder().build(); + } + + public MoshiEncoder(Moshi moshi) { + this.moshi = moshi; + } + + public MoshiEncoder(Iterable> adapters) { + this(MoshiFactory.create(adapters)); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + JsonAdapter jsonAdapter = moshi.adapter(bodyType).indent(" "); + template.body(jsonAdapter.toJson(object)); + } +} diff --git a/moshi/src/main/java/feign/moshi/MoshiFactory.java b/moshi/src/main/java/feign/moshi/MoshiFactory.java new file mode 100644 index 000000000..922021c6d --- /dev/null +++ b/moshi/src/main/java/feign/moshi/MoshiFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; + +public class MoshiFactory { + private MoshiFactory() {} + + /** + * Registers JsonAdapter by implicit type. Adds one to read numbers in a {@code Map} as Integers. + */ + static Moshi create(Iterable> adapters) { + Moshi.Builder builder = new Moshi.Builder(); + + for (JsonAdapter adapter : adapters) { + builder.add(adapter.getClass(), adapter); + } + + return builder.build(); + } +} diff --git a/moshi/src/test/java/feign/moshi/MoshiDecoderTest.java b/moshi/src/test/java/feign/moshi/MoshiDecoderTest.java new file mode 100644 index 000000000..d2ba59ed3 --- /dev/null +++ b/moshi/src/test/java/feign/moshi/MoshiDecoderTest.java @@ -0,0 +1,184 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import static feign.Util.UTF_8; +import static feign.assertj.FeignAssertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import feign.Request; +import feign.Response; +import feign.Util; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import org.junit.Test; + +public class MoshiDecoderTest { + + @Test + public void decodes() throws Exception { + + class Zone extends LinkedHashMap { + + 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; + } + + List zones = new LinkedList<>(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + Response response = + Response.builder() + .status(200) + .reason("OK") + .headers(Collections.emptyMap()) + .request( + Request.create( + Request.HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body(zonesJson, UTF_8) + .build(); + + assertEquals(zones, new MoshiDecoder().decode(response, List.class)); + } + + private String zonesJson = + "" // + + "[\n" // + + " {\n" // + + " \"name\": \"denominator.io.\"\n" // + + " },\n" // + + " {\n" // + + " \"name\": \"denominator.io.\",\n" // + + " \"id\": \"ABCD\"\n" // + + " }\n" // + + "]\n"; + + private final String videoGamesJson = + "{\n " + + " \"hero\": {\n " + + " \"enemy\": \"Bowser\",\n " + + " \"name\": \"Luigi\"\n " + + "},\n " + + "\"name\": \"Super Mario\"\n " + + "}"; + + @Test + public void nullBodyDecodesToNull() throws Exception { + Response response = + Response.builder() + .status(204) + .reason("OK") + .headers(Collections.emptyMap()) + .request( + Request.create( + Request.HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .build(); + assertNull(new MoshiDecoder().decode(response, String.class)); + } + + @Test + public void emptyBodyDecodesToNull() throws Exception { + Response response = + Response.builder() + .status(204) + .reason("OK") + .headers(Collections.emptyMap()) + .request( + Request.create( + Request.HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body(new byte[0]) + .build(); + assertNull(new MoshiDecoder().decode(response, String.class)); + } + + /** Enabled via {@link feign.Feign.Builder#dismiss404()} */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = + Response.builder() + .status(404) + .reason("NOT FOUND") + .headers(Collections.emptyMap()) + .request( + Request.create( + Request.HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .build(); + assertThat((byte[]) new MoshiDecoder().decode(response, byte[].class)).isEmpty(); + } + + @Test + public void customDecoder() throws Exception { + final UpperZoneJSONAdapter upperZoneAdapter = new UpperZoneJSONAdapter(); + + MoshiDecoder decoder = new MoshiDecoder(Collections.singleton(upperZoneAdapter)); + + List zones = new LinkedList<>(); + zones.add(new Zone("DENOMINATOR.IO.")); + zones.add(new Zone("DENOMINATOR.IO.", "ABCD")); + + Response response = + Response.builder() + .status(200) + .reason("OK") + .headers(Collections.emptyMap()) + .request( + Request.create( + Request.HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body(zonesJson, UTF_8) + .build(); + + assertEquals(zones, decoder.decode(response, UpperZoneJSONAdapter.class)); + } + + @Test + public void customObjectDecoder() throws Exception { + final JsonAdapter videoGameJsonAdapter = + new Moshi.Builder().build().adapter(VideoGame.class); + + MoshiDecoder decoder = new MoshiDecoder(Collections.singleton(videoGameJsonAdapter)); + + VideoGame videoGame = new VideoGame("Super Mario", "Luigi", "Bowser"); + + Response response = + Response.builder() + .status(200) + .reason("OK") + .headers(Collections.emptyMap()) + .request( + Request.create( + Request.HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body(videoGamesJson, UTF_8) + .build(); + + VideoGame actual = (VideoGame) decoder.decode(response, videoGameJsonAdapter.getClass()); + + assertThat(actual).isEqualToComparingFieldByFieldRecursively(videoGame); + } +} diff --git a/moshi/src/test/java/feign/moshi/MoshiEncoderTest.java b/moshi/src/test/java/feign/moshi/MoshiEncoderTest.java new file mode 100644 index 000000000..fe927caf4 --- /dev/null +++ b/moshi/src/test/java/feign/moshi/MoshiEncoderTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import static feign.assertj.FeignAssertions.assertThat; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import feign.RequestTemplate; +import java.util.*; +import org.junit.Test; + +public class MoshiEncoderTest { + + @Test + public void encodesMapObjectNumericalValuesAsInteger() { + Map map = new LinkedHashMap<>(); + map.put("foo", 1); + + RequestTemplate template = new RequestTemplate(); + new MoshiEncoder().encode(map, Map.class, template); + + assertThat(template) + .hasBody( + "{\n" // + + " \"foo\": 1\n" // + + "}"); + } + + @Test + public void encodesFormParams() { + + Map form = new LinkedHashMap<>(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + RequestTemplate template = new RequestTemplate(); + + new MoshiEncoder().encode(form, Map.class, template); + + assertThat(template) + .hasBody( + "{\n" // + + " \"foo\": 1,\n" // + + " \"bar\": [\n" // + + " 2,\n" // + + " 3\n" // + + " ]\n" // + + "}"); + } + + @Test + public void customEncoder() { + final UpperZoneJSONAdapter upperZoneAdapter = new UpperZoneJSONAdapter(); + + MoshiEncoder encoder = new MoshiEncoder(Collections.singleton(upperZoneAdapter)); + + List zones = new LinkedList<>(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "abcd")); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(zones, UpperZoneJSONAdapter.class, template); + + assertThat(template) + .hasBody( + "" // + + "[\n" // + + " {\n" // + + " \"name\": \"DENOMINATOR.IO.\"\n" // + + " },\n" // + + " {\n" // + + " \"name\": \"DENOMINATOR.IO.\",\n" // + + " \"id\": \"ABCD\"\n" // + + " }\n" // + + "]"); + } + + @Test + public void customObjectEncoder() { + final JsonAdapter videoGameJsonAdapter = + new Moshi.Builder().build().adapter(VideoGame.class); + MoshiEncoder encoder = new MoshiEncoder(Collections.singleton(videoGameJsonAdapter)); + + VideoGame videoGame = new VideoGame("Super Mario", "Luigi", "Bowser"); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(videoGame, videoGameJsonAdapter.getClass(), template); + + assertThat(template) + .hasBody( + "{\n" + + " \"hero\": {\n" + + " \"enemy\": \"Bowser\",\n" + + " \"name\": \"Luigi\"\n" + + " },\n" + + " \"name\": \"Super Mario\"\n" + + "}"); + } +} diff --git a/moshi/src/test/java/feign/moshi/UpperZoneJSONAdapter.java b/moshi/src/test/java/feign/moshi/UpperZoneJSONAdapter.java new file mode 100644 index 000000000..fae5c9f4f --- /dev/null +++ b/moshi/src/test/java/feign/moshi/UpperZoneJSONAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import com.squareup.moshi.*; +import java.io.IOException; +import java.util.LinkedList; +import java.util.Map; + +class UpperZoneJSONAdapter extends JsonAdapter> { + + @ToJson + public void toJson(JsonWriter out, LinkedList value) throws IOException { + out.beginArray(); + for (Zone zone : value) { + out.beginObject(); + for (Map.Entry entry : zone.entrySet()) { + out.name(entry.getKey()).value(entry.getValue().toString().toUpperCase()); + } + out.endObject(); + } + out.endArray(); + } + + @FromJson + public LinkedList fromJson(JsonReader in) throws IOException { + LinkedList zones = new LinkedList<>(); + in.beginArray(); + while (in.hasNext()) { + in.beginObject(); + Zone zone = new Zone(); + while (in.hasNext()) { + zone.put(in.nextName(), in.nextString().toUpperCase()); + } + in.endObject(); + zones.add(zone); + } + in.endArray(); + return zones; + } +} diff --git a/moshi/src/test/java/feign/moshi/VideoGame.java b/moshi/src/test/java/feign/moshi/VideoGame.java new file mode 100644 index 000000000..85eab13b9 --- /dev/null +++ b/moshi/src/test/java/feign/moshi/VideoGame.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import com.squareup.moshi.Json; + +public class VideoGame { + + @Json(name = "name") + public final String name; + + @Json(name = "hero") + public final Hero hero; + + public VideoGame(String name, String hero, String enemy) { + this.name = name; + this.hero = new Hero(hero, enemy); + } + + static class Hero { + @Json(name = "name") + public final String name; + + @Json(name = "enemy") + public final String enemyName; + + Hero(String name, String enemyName) { + this.name = name; + this.enemyName = enemyName; + } + } +} diff --git a/moshi/src/test/java/feign/moshi/Zone.java b/moshi/src/test/java/feign/moshi/Zone.java new file mode 100644 index 000000000..2d3bc0864 --- /dev/null +++ b/moshi/src/test/java/feign/moshi/Zone.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi; + +import java.util.LinkedHashMap; + +public 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; +} diff --git a/moshi/src/test/java/feign/moshi/examples/GithubExample.java b/moshi/src/test/java/feign/moshi/examples/GithubExample.java new file mode 100644 index 000000000..11e2a847b --- /dev/null +++ b/moshi/src/test/java/feign/moshi/examples/GithubExample.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * 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.moshi.examples; + +import feign.Feign; +import feign.Param; +import feign.RequestLine; +import feign.moshi.MoshiDecoder; +import feign.moshi.MoshiEncoder; +import java.util.List; + +public class GithubExample { + + public static void main(String... args) { + GitHub github = + Feign.builder() + .encoder(new MoshiEncoder()) + .decoder(new MoshiDecoder()) + .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 + ")"); + } + } + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + } + + static class Contributor { + + String login; + int contributions; + } +} diff --git a/pom.xml b/pom.xml index 205c27623..93749a405 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,7 @@ example-wikipedia example-wikipedia-with-springboot benchmark + moshi @@ -86,6 +87,7 @@ 32.1.2-jre 1.43.3 2.10.1 + 1.15.0 2.0.9 20230618 @@ -320,6 +322,12 @@ ${gson.version} + + com.squareup.moshi + moshi + ${moshi.version} + + org.assertj assertj-core