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
pom.xml.*
release.properties
dependency-reduced-pom.xml
.idea
*.iml

View file

@ -85,7 +85,7 @@ class JsonClassCodegenProcessor : KotlinAbstractProcessor(), KotlinMetadataUtils
override fun process(annotations: Set<TypeElement>, 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)

View file

@ -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<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")
)
@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<CustomGeneratedClass>).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<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 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;
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<T> {
* nulls.
*/
@CheckReturnValue public final JsonAdapter<T> nullSafe() {
final JsonAdapter<T> delegate = 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()";
}
};
return new NullSafeJsonAdapter<>(this);
}
/**

View file

@ -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.
* <p>There are currently some restrictions on which types that can be used with generated
* adapters:
* <ul>
* <li>
* The class must be implemented in Kotlin (unless using a custom generator, see
* {@link #generator()}).
* </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();
/**
* 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() {
}
/**
* 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

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()) {
return null;
}
String adapterClassName = rawType.getName().replace("$", "_") + "JsonAdapter";
String adapterClassName = Types.generatedJsonAdapterName(rawType.getName());
try {
@SuppressWarnings("unchecked") // We generate types to match.
Class<? extends JsonAdapter<?>> adapterClass = (Class<? extends JsonAdapter<?>>)
Class.forName(adapterClassName, true, rawType.getClassLoader());
Constructor<? extends JsonAdapter<?>> constructor;
Object[] args;
if (type instanceof ParameterizedType) {
Constructor<? extends JsonAdapter<?>> 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<? extends JsonAdapter<?>> 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);

View file

@ -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);
}
}

View file

@ -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)