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:
parent
a5020ddb3c
commit
0943ef5a61
11 changed files with 283 additions and 72 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -12,6 +12,7 @@ lib
|
|||
target
|
||||
pom.xml.*
|
||||
release.properties
|
||||
dependency-reduced-pom.xml
|
||||
|
||||
.idea
|
||||
*.iml
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,12 +1156,46 @@ class GeneratedAdaptersTest {
|
|||
assertThat(decoded).isEqualTo(HasCollectionOfPrimitives(listOf(4, -5, 6)))
|
||||
}
|
||||
|
||||
/**
|
||||
@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(
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SmokeTestType(
|
||||
@Json(name = "first_name") val firstName: String,
|
||||
@Json(name = "last_name") val lastName: String,
|
||||
val age: Int,
|
||||
|
@ -1184,8 +1219,7 @@ class GeneratedAdaptersTest {
|
|||
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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 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 "";
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()";
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
} else {
|
||||
Constructor<? extends JsonAdapter<?>> constructor
|
||||
= adapterClass.getDeclaredConstructor(Moshi.class);
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance(moshi).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 {
|
||||
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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue