From 0943ef5a6165c2c2796eb0dafd2246e58982cc0b Mon Sep 17 00:00:00 2001 From: Zac Sweers Date: Wed, 15 May 2019 20:42:08 -0400 Subject: [PATCH] Allow custom generators (#847) * Extract generatedJsonAdapterName to public API for other generators/consumers * Fix kapt location in tests * Add IDE-generated dependency-reduced-pom.xml to gitignore This always bites me * Add generator property to JsonClass and skip in processor * Opportunistically fix formatting for generateAdapter doc * Extract NullSafeJsonAdapter for delegate testing * Add custom adapter tests * Allow no-moshi constructors for generated adapters * Fix rebase issue * Use something other than nullSafe() for lenient check This no longer propagates lenient * Add missing copyrights * Add top-level class note * Add note about working against Moshi's generated signature * Add missing bit to "requirements for" * Note kotlin requirement relaxed in custom generators * Style --- .gitignore | 1 + .../codegen/JsonClassCodegenProcessor.kt | 2 +- .../kotlin/codgen/GeneratedAdaptersTest.kt | 94 +++++++++++++------ ...ersTest_CustomGeneratedClassJsonAdapter.kt | 32 +++++++ .../java/com/squareup/moshi/JsonAdapter.java | 25 +---- .../java/com/squareup/moshi/JsonClass.java | 54 +++++++++-- .../main/java/com/squareup/moshi/Types.java | 31 ++++++ .../moshi/internal/NullSafeJsonAdapter.java | 55 +++++++++++ .../com/squareup/moshi/internal/Util.java | 32 +++++-- .../com/squareup/moshi/JsonAdapterTest.java | 2 +- .../java/com/squareup/moshi/TypesTest.java | 27 ++++++ 11 files changed, 283 insertions(+), 72 deletions(-) create mode 100644 kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest_CustomGeneratedClassJsonAdapter.kt create mode 100644 moshi/src/main/java/com/squareup/moshi/internal/NullSafeJsonAdapter.java diff --git a/.gitignore b/.gitignore index 226a3f3..505a9aa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ lib target pom.xml.* release.properties +dependency-reduced-pom.xml .idea *.iml diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/JsonClassCodegenProcessor.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/JsonClassCodegenProcessor.kt index ce3834e..e203ced 100644 --- a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/JsonClassCodegenProcessor.kt +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/JsonClassCodegenProcessor.kt @@ -85,7 +85,7 @@ class JsonClassCodegenProcessor : KotlinAbstractProcessor(), KotlinMetadataUtils override fun process(annotations: Set, roundEnv: RoundEnvironment): Boolean { for (type in roundEnv.getElementsAnnotatedWith(annotation)) { val jsonClass = type.getAnnotation(annotation) - if (jsonClass.generateAdapter) { + if (jsonClass.generateAdapter && jsonClass.generator.isEmpty()) { val generator = adapterGenerator(type) ?: continue generator.generateFile(generatedType) .writeTo(filer) diff --git a/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest.kt b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest.kt index 1be73c1..3793881 100644 --- a/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest.kt +++ b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest.kt @@ -26,6 +26,7 @@ import com.squareup.moshi.JsonWriter import com.squareup.moshi.Moshi import com.squareup.moshi.ToJson import com.squareup.moshi.Types +import com.squareup.moshi.internal.NullSafeJsonAdapter import org.assertj.core.api.Assertions.assertThat import org.intellij.lang.annotations.Language import org.junit.Assert.assertNull @@ -1155,37 +1156,70 @@ class GeneratedAdaptersTest { assertThat(decoded).isEqualTo(HasCollectionOfPrimitives(listOf(4, -5, 6))) } - /** - * This is here mostly just to ensure it still compiles. Covers variance, @Json, default values, - * nullability, primitive arrays, and some wacky generics. - */ - @JsonClass(generateAdapter = true) - data class SmokeTestType( - @Json(name = "first_name") val firstName: String, - @Json(name = "last_name") val lastName: String, - val age: Int, - val nationalities: List = emptyList(), - val weight: Float, - val tattoos: Boolean = false, - val race: String?, - val hasChildren: Boolean = false, - val favoriteFood: String? = null, - val favoriteDrink: String? = "Water", - val wildcardOut: MutableList = mutableListOf(), - val nullableWildcardOut: MutableList = mutableListOf(), - val wildcardIn: Array, - val any: List<*>, - val anyTwo: List, - val anyOut: MutableList, - val nullableAnyOut: MutableList, - val favoriteThreeNumbers: IntArray, - val favoriteArrayValues: Array, - val favoriteNullableArrayValues: Array, - val nullableSetListMapArrayNullableIntWithDefault: Set>>>? = null, - val aliasedName: TypeAliasName = "Woah", - val genericAlias: GenericTypeAlias = listOf("Woah") - ) + @JsonClass(generateAdapter = true, generator = "custom") + data class CustomGeneratedClass(val foo: String) + + @Test fun customGenerator_withClassPresent() { + val moshi = Moshi.Builder().build() + val adapter = moshi.adapter(CustomGeneratedClass::class.java) + val unwrapped = (adapter as NullSafeJsonAdapter).delegate() + assertThat(unwrapped).isInstanceOf(GeneratedAdaptersTest_CustomGeneratedClassJsonAdapter::class.java) + } + + @JsonClass(generateAdapter = true, generator = "custom") + data class CustomGeneratedClassMissing(val foo: String) + + @Test fun customGenerator_withClassMissing() { + val moshi = Moshi.Builder().build() + try { + moshi.adapter(CustomGeneratedClassMissing::class.java) + fail() + } catch (e: RuntimeException) { + assertThat(e).hasMessageContaining("Failed to find the generated JsonAdapter class") + } + } } +// Has to be outside to avoid Types seeing an owning class +@JsonClass(generateAdapter = true) +data class NullableTypeParams( + val nullableList: List, + val nullableSet: Set, + val nullableMap: Map, + val nullableT: T?, + val nonNullT: T +) + +/** + * This is here mostly just to ensure it still compiles. Covers variance, @Json, default values, + * nullability, primitive arrays, and some wacky generics. + */ +@JsonClass(generateAdapter = true) +data class SmokeTestType( + @Json(name = "first_name") val firstName: String, + @Json(name = "last_name") val lastName: String, + val age: Int, + val nationalities: List = emptyList(), + val weight: Float, + val tattoos: Boolean = false, + val race: String?, + val hasChildren: Boolean = false, + val favoriteFood: String? = null, + val favoriteDrink: String? = "Water", + val wildcardOut: MutableList = mutableListOf(), + val nullableWildcardOut: MutableList = mutableListOf(), + val wildcardIn: Array, + val any: List<*>, + val anyTwo: List, + val anyOut: MutableList, + val nullableAnyOut: MutableList, + val favoriteThreeNumbers: IntArray, + val favoriteArrayValues: Array, + val favoriteNullableArrayValues: Array, + val nullableSetListMapArrayNullableIntWithDefault: Set>>>? = null, + val aliasedName: TypeAliasName = "Woah", + val genericAlias: GenericTypeAlias = listOf("Woah") +) + typealias TypeAliasName = String typealias GenericTypeAlias = List diff --git a/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest_CustomGeneratedClassJsonAdapter.kt b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest_CustomGeneratedClassJsonAdapter.kt new file mode 100644 index 0000000..efc5a43 --- /dev/null +++ b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codgen/GeneratedAdaptersTest_CustomGeneratedClassJsonAdapter.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2019 Square, 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 com.squareup.moshi.kotlin.codgen + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.kotlin.codgen.GeneratedAdaptersTest.CustomGeneratedClass + +// This also tests custom generated types with no moshi constructor +class GeneratedAdaptersTest_CustomGeneratedClassJsonAdapter : JsonAdapter() { + override fun fromJson(reader: JsonReader): CustomGeneratedClass? { + TODO() + } + + override fun toJson(writer: JsonWriter, value: CustomGeneratedClass?) { + TODO() + } +} diff --git a/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java index 4ae079c..1bb63c9 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonAdapter.java @@ -15,6 +15,7 @@ */ package com.squareup.moshi; +import com.squareup.moshi.internal.NullSafeJsonAdapter; import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.Type; @@ -128,29 +129,7 @@ public abstract class JsonAdapter { * nulls. */ @CheckReturnValue public final JsonAdapter nullSafe() { - final JsonAdapter delegate = this; - return new JsonAdapter() { - @Override public @Nullable T fromJson(JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } else { - return delegate.fromJson(reader); - } - } - @Override public void toJson(JsonWriter writer, @Nullable T value) throws IOException { - if (value == null) { - writer.nullValue(); - } else { - delegate.toJson(writer, value); - } - } - @Override boolean isLenient() { - return delegate.isLenient(); - } - @Override public String toString() { - return delegate + ".nullSafe()"; - } - }; + return new NullSafeJsonAdapter<>(this); } /** diff --git a/moshi/src/main/java/com/squareup/moshi/JsonClass.java b/moshi/src/main/java/com/squareup/moshi/JsonClass.java index 3842daa..bbf55db 100644 --- a/moshi/src/main/java/com/squareup/moshi/JsonClass.java +++ b/moshi/src/main/java/com/squareup/moshi/JsonClass.java @@ -17,6 +17,7 @@ package com.squareup.moshi; import java.lang.annotation.Documented; import java.lang.annotation.Retention; +import java.lang.reflect.Type; import static java.lang.annotation.RetentionPolicy.RUNTIME; @@ -29,13 +30,52 @@ public @interface JsonClass { /** * True to trigger the annotation processor to generate an adapter for this type. * - * There are currently some restrictions on which types that can be used with generated adapters: - * - * * The class must be implemented in Kotlin. - * * The class may not be an abstract class, an inner class, or a local class. - * * All superclasses must be implemented in Kotlin. - * * All properties must be public, protected, or internal. - * * All properties must be either non-transient or have a default value. + *

There are currently some restrictions on which types that can be used with generated + * adapters: + *

    + *
  • + * The class must be implemented in Kotlin (unless using a custom generator, see + * {@link #generator()}). + *
  • + *
  • The class may not be an abstract class, an inner class, or a local class.
  • + *
  • All superclasses must be implemented in Kotlin.
  • + *
  • All properties must be public, protected, or internal.
  • + *
  • All properties must be either non-transient or have a default value.
  • + *
*/ boolean generateAdapter(); + + /** + * An optional custom generator tag used to indicate which generator should be used. If empty, + * Moshi's annotation processor will generate an adapter for the annotated type. If not empty, + * Moshi's processor will skip it and defer to a custom generator. This can be used to allow + * other custom code generation tools to run and still allow Moshi to read their generated + * JsonAdapter outputs. + * + *

Requirements for generated adapter class signatures: + *

    + *
  • + * The generated adapter must subclass {@link JsonAdapter} and be parameterized by this type. + *
  • + *
  • + * {@link Types#generatedJsonAdapterName} should be used for the fully qualified class name in + * order for Moshi to correctly resolve and load the generated JsonAdapter. + *
  • + *
  • The first parameter must be a {@link Moshi} instance.
  • + *
  • + * If generic, a second {@link Type[]} parameter should be declared to accept type arguments. + *
  • + *
+ * + *

Example for a class "CustomType":

{@code
+   *   class CustomTypeJsonAdapter(moshi: Moshi, types: Array) : JsonAdapter() {
+   *     // ...
+   *   }
+   * }
+ * + *

To help ensure your own generator meets requirements above, you can use Moshi’s built-in + * generator to create the API signature to get started, then make your own generator match that + * expected signature. + */ + String generator() default ""; } diff --git a/moshi/src/main/java/com/squareup/moshi/Types.java b/moshi/src/main/java/com/squareup/moshi/Types.java index a5d5c17..1ee9fa4 100644 --- a/moshi/src/main/java/com/squareup/moshi/Types.java +++ b/moshi/src/main/java/com/squareup/moshi/Types.java @@ -49,6 +49,37 @@ public final class Types { private Types() { } + /** + * Resolves the generated {@link JsonAdapter} fully qualified class name for a given + * {@link JsonClass JsonClass-annotated} {@code clazz}. This is the same lookup logic used by + * both the Moshi code generation as well as lookup for any JsonClass-annotated classes. This can + * be useful if generating your own JsonAdapters without using Moshi's first party code gen. + * + * @param clazz the class to calculate a generated JsonAdapter name for. + * @return the resolved fully qualified class name to the expected generated JsonAdapter class. + * Note that this name will always be a top-level class name and not a nested class. + */ + public static String generatedJsonAdapterName(Class clazz) { + if (clazz.getAnnotation(JsonClass.class) == null) { + throw new IllegalArgumentException("Class does not have a JsonClass annotation: " + clazz); + } + return generatedJsonAdapterName(clazz.getName()); + } + + /** + * Resolves the generated {@link JsonAdapter} fully qualified class name for a given + * {@link JsonClass JsonClass-annotated} {@code className}. This is the same lookup logic used by + * both the Moshi code generation as well as lookup for any JsonClass-annotated classes. This can + * be useful if generating your own JsonAdapters without using Moshi's first party code gen. + * + * @param className the fully qualified class to calculate a generated JsonAdapter name for. + * @return the resolved fully qualified class name to the expected generated JsonAdapter class. + * Note that this name will always be a top-level class name and not a nested class. + */ + public static String generatedJsonAdapterName(String className) { + return className.replace("$", "_") + "JsonAdapter"; + } + /** * Checks if {@code annotations} contains {@code jsonQualifier}. * Returns the subset of {@code annotations} without {@code jsonQualifier}, or null if {@code diff --git a/moshi/src/main/java/com/squareup/moshi/internal/NullSafeJsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/internal/NullSafeJsonAdapter.java new file mode 100644 index 0000000..569571a --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/internal/NullSafeJsonAdapter.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2019 Square, 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 com.squareup.moshi.internal; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; +import java.io.IOException; +import javax.annotation.Nullable; + +public final class NullSafeJsonAdapter extends JsonAdapter { + + private final JsonAdapter delegate; + + public NullSafeJsonAdapter(JsonAdapter delegate) { + this.delegate = delegate; + } + + public JsonAdapter delegate() { + return delegate; + } + + @Override public @Nullable T fromJson(JsonReader reader) throws IOException { + if (reader.peek() == JsonReader.Token.NULL) { + return reader.nextNull(); + } else { + return delegate.fromJson(reader); + } + } + + @Override public void toJson(JsonWriter writer, @Nullable T value) throws IOException { + if (value == null) { + writer.nullValue(); + } else { + delegate.toJson(writer, value); + } + } + + @Override public String toString() { + return delegate + ".nullSafe()"; + } +} diff --git a/moshi/src/main/java/com/squareup/moshi/internal/Util.java b/moshi/src/main/java/com/squareup/moshi/internal/Util.java index 09be183..2aff94c 100644 --- a/moshi/src/main/java/com/squareup/moshi/internal/Util.java +++ b/moshi/src/main/java/com/squareup/moshi/internal/Util.java @@ -460,23 +460,35 @@ public final class Util { if (jsonClass == null || !jsonClass.generateAdapter()) { return null; } - String adapterClassName = rawType.getName().replace("$", "_") + "JsonAdapter"; + String adapterClassName = Types.generatedJsonAdapterName(rawType.getName()); try { @SuppressWarnings("unchecked") // We generate types to match. Class> adapterClass = (Class>) Class.forName(adapterClassName, true, rawType.getClassLoader()); + Constructor> constructor; + Object[] args; if (type instanceof ParameterizedType) { - Constructor> constructor - = adapterClass.getDeclaredConstructor(Moshi.class, Type[].class); - constructor.setAccessible(true); - return constructor.newInstance(moshi, ((ParameterizedType) type).getActualTypeArguments()) - .nullSafe(); + Type[] typeArgs = ((ParameterizedType) type).getActualTypeArguments(); + try { + // Common case first + constructor = adapterClass.getDeclaredConstructor(Moshi.class, Type[].class); + args = new Object[] { moshi, typeArgs }; + } catch (NoSuchMethodException e) { + constructor = adapterClass.getDeclaredConstructor(Type[].class); + args = new Object[] { typeArgs }; + } } else { - Constructor> constructor - = adapterClass.getDeclaredConstructor(Moshi.class); - constructor.setAccessible(true); - return constructor.newInstance(moshi).nullSafe(); + try { + // Common case first + constructor = adapterClass.getDeclaredConstructor(Moshi.class); + args = new Object[] { moshi }; + } catch (NoSuchMethodException e) { + constructor = adapterClass.getDeclaredConstructor(); + args = new Object[0]; + } } + constructor.setAccessible(true); + return constructor.newInstance(args).nullSafe(); } catch (ClassNotFoundException e) { throw new RuntimeException( "Failed to find the generated JsonAdapter class for " + rawType, e); diff --git a/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java b/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java index 34968a7..598e892 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java @@ -282,7 +282,7 @@ public final class JsonAdapterTest { @Override public void toJson(JsonWriter writer, @Nullable Boolean value) throws IOException { throw new AssertionError(); } - }.lenient().nullSafe(); + }.lenient().nonNull(); assertThat(adapter.fromJson("true true")).isEqualTo(true); } } diff --git a/moshi/src/test/java/com/squareup/moshi/TypesTest.java b/moshi/src/test/java/com/squareup/moshi/TypesTest.java index 23c1f62..60a005e 100644 --- a/moshi/src/test/java/com/squareup/moshi/TypesTest.java +++ b/moshi/src/test/java/com/squareup/moshi/TypesTest.java @@ -287,6 +287,33 @@ public final class TypesTest { assertThat(annotations).hasSize(0); } + @Test public void generatedJsonAdapterName_strings() { + assertThat(Types.generatedJsonAdapterName("com.foo.Test")).isEqualTo("com.foo.TestJsonAdapter"); + assertThat(Types.generatedJsonAdapterName("com.foo.Test$Bar")).isEqualTo("com.foo.Test_BarJsonAdapter"); + } + + @Test public void generatedJsonAdapterName_class() { + assertThat(Types.generatedJsonAdapterName(TestJsonClass.class)).isEqualTo("com.squareup.moshi.TypesTest_TestJsonClassJsonAdapter"); + } + + @Test public void generatedJsonAdapterName_class_missingJsonClass() { + try { + Types.generatedJsonAdapterName(TestNonJsonClass.class); + fail(); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageContaining("Class does not have a JsonClass annotation"); + } + } + + @JsonClass(generateAdapter = false) + static class TestJsonClass { + + } + + static class TestNonJsonClass { + + } + @JsonQualifier @Target(FIELD) @Retention(RUNTIME)