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
This commit is contained in:
Zac Sweers
2019-05-15 20:42:08 -04:00
committed by Jesse Wilson
parent a5020ddb3c
commit 0943ef5a61
11 changed files with 283 additions and 72 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ lib
target target
pom.xml.* pom.xml.*
release.properties release.properties
dependency-reduced-pom.xml
.idea .idea
*.iml *.iml

View File

@@ -85,7 +85,7 @@ class JsonClassCodegenProcessor : KotlinAbstractProcessor(), KotlinMetadataUtils
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean { override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
for (type in roundEnv.getElementsAnnotatedWith(annotation)) { for (type in roundEnv.getElementsAnnotatedWith(annotation)) {
val jsonClass = type.getAnnotation(annotation) val jsonClass = type.getAnnotation(annotation)
if (jsonClass.generateAdapter) { if (jsonClass.generateAdapter && jsonClass.generator.isEmpty()) {
val generator = adapterGenerator(type) ?: continue val generator = adapterGenerator(type) ?: continue
generator.generateFile(generatedType) generator.generateFile(generatedType)
.writeTo(filer) .writeTo(filer)

View File

@@ -26,6 +26,7 @@ import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson import com.squareup.moshi.ToJson
import com.squareup.moshi.Types import com.squareup.moshi.Types
import com.squareup.moshi.internal.NullSafeJsonAdapter
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -1155,37 +1156,70 @@ class GeneratedAdaptersTest {
assertThat(decoded).isEqualTo(HasCollectionOfPrimitives(listOf(4, -5, 6))) assertThat(decoded).isEqualTo(HasCollectionOfPrimitives(listOf(4, -5, 6)))
} }
/** @JsonClass(generateAdapter = true, generator = "custom")
* This is here mostly just to ensure it still compiles. Covers variance, @Json, default values, data class CustomGeneratedClass(val foo: String)
* nullability, primitive arrays, and some wacky generics.
*/ @Test fun customGenerator_withClassPresent() {
@JsonClass(generateAdapter = true) val moshi = Moshi.Builder().build()
data class SmokeTestType( val adapter = moshi.adapter(CustomGeneratedClass::class.java)
@Json(name = "first_name") val firstName: String, val unwrapped = (adapter as NullSafeJsonAdapter<CustomGeneratedClass>).delegate()
@Json(name = "last_name") val lastName: String, assertThat(unwrapped).isInstanceOf(GeneratedAdaptersTest_CustomGeneratedClassJsonAdapter::class.java)
val age: Int, }
val nationalities: List<String> = emptyList(),
val weight: Float, @JsonClass(generateAdapter = true, generator = "custom")
val tattoos: Boolean = false, data class CustomGeneratedClassMissing(val foo: String)
val race: String?,
val hasChildren: Boolean = false, @Test fun customGenerator_withClassMissing() {
val favoriteFood: String? = null, val moshi = Moshi.Builder().build()
val favoriteDrink: String? = "Water", try {
val wildcardOut: MutableList<out String> = mutableListOf(), moshi.adapter(CustomGeneratedClassMissing::class.java)
val nullableWildcardOut: MutableList<out String?> = mutableListOf(), fail()
val wildcardIn: Array<in String>, } catch (e: RuntimeException) {
val any: List<*>, assertThat(e).hasMessageContaining("Failed to find the generated JsonAdapter class")
val anyTwo: List<Any>, }
val anyOut: MutableList<out Any>, }
val nullableAnyOut: MutableList<out Any?>,
val favoriteThreeNumbers: IntArray,
val favoriteArrayValues: Array<String>,
val favoriteNullableArrayValues: Array<String?>,
val nullableSetListMapArrayNullableIntWithDefault: Set<List<Map<String, Array<IntArray?>>>>? = null,
val aliasedName: TypeAliasName = "Woah",
val genericAlias: GenericTypeAlias = listOf("Woah")
)
} }
// Has to be outside to avoid Types seeing an owning class
@JsonClass(generateAdapter = true)
data class NullableTypeParams<T>(
val nullableList: List<String?>,
val nullableSet: Set<String?>,
val nullableMap: Map<String, String?>,
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<String> = 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<out String> = mutableListOf(),
val nullableWildcardOut: MutableList<out String?> = mutableListOf(),
val wildcardIn: Array<in String>,
val any: List<*>,
val anyTwo: List<Any>,
val anyOut: MutableList<out Any>,
val nullableAnyOut: MutableList<out Any?>,
val favoriteThreeNumbers: IntArray,
val favoriteArrayValues: Array<String>,
val favoriteNullableArrayValues: Array<String?>,
val nullableSetListMapArrayNullableIntWithDefault: Set<List<Map<String, Array<IntArray?>>>>? = null,
val aliasedName: TypeAliasName = "Woah",
val genericAlias: GenericTypeAlias = listOf("Woah")
)
typealias TypeAliasName = String typealias TypeAliasName = String
typealias GenericTypeAlias = List<String> typealias GenericTypeAlias = List<String>

View File

@@ -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<CustomGeneratedClass>() {
override fun fromJson(reader: JsonReader): CustomGeneratedClass? {
TODO()
}
override fun toJson(writer: JsonWriter, value: CustomGeneratedClass?) {
TODO()
}
}

View File

@@ -15,6 +15,7 @@
*/ */
package com.squareup.moshi; package com.squareup.moshi;
import com.squareup.moshi.internal.NullSafeJsonAdapter;
import java.io.IOException; import java.io.IOException;
import java.lang.annotation.Annotation; import java.lang.annotation.Annotation;
import java.lang.reflect.Type; import java.lang.reflect.Type;
@@ -128,29 +129,7 @@ public abstract class JsonAdapter<T> {
* nulls. * nulls.
*/ */
@CheckReturnValue public final JsonAdapter<T> nullSafe() { @CheckReturnValue public final JsonAdapter<T> nullSafe() {
final JsonAdapter<T> delegate = this; return new NullSafeJsonAdapter<>(this);
return new JsonAdapter<T>() {
@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()";
}
};
} }
/** /**

View File

@@ -17,6 +17,7 @@ package com.squareup.moshi;
import java.lang.annotation.Documented; import java.lang.annotation.Documented;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
import java.lang.reflect.Type;
import static java.lang.annotation.RetentionPolicy.RUNTIME; 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. * 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: * <p>There are currently some restrictions on which types that can be used with generated
* * adapters:
* * The class must be implemented in Kotlin. * <ul>
* * The class may not be an abstract class, an inner class, or a local class. * <li>
* * All superclasses must be implemented in Kotlin. * The class must be implemented in Kotlin (unless using a custom generator, see
* * All properties must be public, protected, or internal. * {@link #generator()}).
* * All properties must be either non-transient or have a default value. * </li>
* <li>The class may not be an abstract class, an inner class, or a local class.</li>
* <li>All superclasses must be implemented in Kotlin.</li>
* <li>All properties must be public, protected, or internal.</li>
* <li>All properties must be either non-transient or have a default value.</li>
* </ul>
*/ */
boolean generateAdapter(); 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.
*
* <p>Requirements for generated adapter class signatures:
* <ul>
* <li>
* The generated adapter must subclass {@link JsonAdapter} and be parameterized by this type.
* </li>
* <li>
* {@link Types#generatedJsonAdapterName} should be used for the fully qualified class name in
* order for Moshi to correctly resolve and load the generated JsonAdapter.
* </li>
* <li>The first parameter must be a {@link Moshi} instance.</li>
* <li>
* If generic, a second {@link Type[]} parameter should be declared to accept type arguments.
* </li>
* </ul>
*
* <p>Example for a class "CustomType":<pre>{@code
* class CustomTypeJsonAdapter(moshi: Moshi, types: Array<Type>) : JsonAdapter<CustomType>() {
* // ...
* }
* }</pre>
*
* <p>To help ensure your own generator meets requirements above, you can use Moshis built-in
* generator to create the API signature to get started, then make your own generator match that
* expected signature.
*/
String generator() default "";
} }

View File

@@ -49,6 +49,37 @@ public final class Types {
private 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}. * Checks if {@code annotations} contains {@code jsonQualifier}.
* Returns the subset of {@code annotations} without {@code jsonQualifier}, or null if {@code * Returns the subset of {@code annotations} without {@code jsonQualifier}, or null if {@code

View File

@@ -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<T> extends JsonAdapter<T> {
private final JsonAdapter<T> delegate;
public NullSafeJsonAdapter(JsonAdapter<T> delegate) {
this.delegate = delegate;
}
public JsonAdapter<T> 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()";
}
}

View File

@@ -460,23 +460,35 @@ public final class Util {
if (jsonClass == null || !jsonClass.generateAdapter()) { if (jsonClass == null || !jsonClass.generateAdapter()) {
return null; return null;
} }
String adapterClassName = rawType.getName().replace("$", "_") + "JsonAdapter"; String adapterClassName = Types.generatedJsonAdapterName(rawType.getName());
try { try {
@SuppressWarnings("unchecked") // We generate types to match. @SuppressWarnings("unchecked") // We generate types to match.
Class<? extends JsonAdapter<?>> adapterClass = (Class<? extends JsonAdapter<?>>) Class<? extends JsonAdapter<?>> adapterClass = (Class<? extends JsonAdapter<?>>)
Class.forName(adapterClassName, true, rawType.getClassLoader()); Class.forName(adapterClassName, true, rawType.getClassLoader());
Constructor<? extends JsonAdapter<?>> constructor;
Object[] args;
if (type instanceof ParameterizedType) { if (type instanceof ParameterizedType) {
Constructor<? extends JsonAdapter<?>> constructor Type[] typeArgs = ((ParameterizedType) type).getActualTypeArguments();
= adapterClass.getDeclaredConstructor(Moshi.class, Type[].class); try {
constructor.setAccessible(true); // Common case first
return constructor.newInstance(moshi, ((ParameterizedType) type).getActualTypeArguments()) constructor = adapterClass.getDeclaredConstructor(Moshi.class, Type[].class);
.nullSafe(); args = new Object[] { moshi, typeArgs };
} catch (NoSuchMethodException e) {
constructor = adapterClass.getDeclaredConstructor(Type[].class);
args = new Object[] { typeArgs };
}
} else { } else {
Constructor<? extends JsonAdapter<?>> constructor try {
= adapterClass.getDeclaredConstructor(Moshi.class); // Common case first
constructor.setAccessible(true); constructor = adapterClass.getDeclaredConstructor(Moshi.class);
return constructor.newInstance(moshi).nullSafe(); 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) { } catch (ClassNotFoundException e) {
throw new RuntimeException( throw new RuntimeException(
"Failed to find the generated JsonAdapter class for " + rawType, e); "Failed to find the generated JsonAdapter class for " + rawType, e);

View File

@@ -282,7 +282,7 @@ public final class JsonAdapterTest {
@Override public void toJson(JsonWriter writer, @Nullable Boolean value) throws IOException { @Override public void toJson(JsonWriter writer, @Nullable Boolean value) throws IOException {
throw new AssertionError(); throw new AssertionError();
} }
}.lenient().nullSafe(); }.lenient().nonNull();
assertThat(adapter.fromJson("true true")).isEqualTo(true); assertThat(adapter.fromJson("true true")).isEqualTo(true);
} }
} }

View File

@@ -287,6 +287,33 @@ public final class TypesTest {
assertThat(annotations).hasSize(0); 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 @JsonQualifier
@Target(FIELD) @Target(FIELD)
@Retention(RUNTIME) @Retention(RUNTIME)