Skip to content

Commit

Permalink
Productionize + AdaptedBy (#81)
Browse files Browse the repository at this point in the history
* Publish records and java-sealed-reflect

* Publish on JDK 15 now

* Tweak doc

* Fix dep

* Add bounded generic test

* Add new AdaptedBy support

* Fix copyright
  • Loading branch information
ZacSweers authored Jan 27, 2021
1 parent ea8c44f commit ef46973
Show file tree
Hide file tree
Showing 14 changed files with 376 additions and 10 deletions.
5 changes: 1 addition & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,10 @@ jobs:
uses: actions/setup-java@v1
with:
java-version: ${{ matrix.java_version }}
- name: Configure Gradle
# Initial gradle configuration, install dependencies, etc
run: ./gradlew help
- name: Build project
run: ./gradlew build check -Pmoshix.useKsp=${{ matrix.ksp_enabled }} -Pksp.incremental=${{ matrix.ksp_incremental_enabled }} --stacktrace
- name: Upload snapshot (main only)
run: |
./gradlew --stop && jps|grep -E 'KotlinCompileDaemon|GradleDaemon'| awk '{print $1}'| xargs kill -9 || true
./gradlew uploadArchives -PSONATYPE_NEXUS_USERNAME=${{ secrets.SONATYPE_USERNAME }} -PSONATYPE_NEXUS_PASSWORD=${{ secrets.SONATYPE_PASSWORD }}
if: github.ref == 'refs/heads/main' && github.event_name == 'push' && matrix.java_version == '1.8' && !matrix.ksp_enabled
if: github.ref == 'refs/heads/main' && github.event_name == 'push' && matrix.java_version == '15' && !matrix.ksp_enabled
1 change: 1 addition & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Releasing
3. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the new version)
4. `git tag -a X.Y.Z -m "Version X.Y.Z"` (where X.Y.Z is the new version)
5. `./gradlew clean uploadArchives --no-daemon --no-parallel`
* Make sure to run this with JDK 15
6. `./gradlew closeAndReleaseRepository`
7. Update the `gradle.properties` to the next SNAPSHOT version.
8. `git commit -am "Prepare next development version."`
Expand Down
28 changes: 28 additions & 0 deletions moshi-adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,31 @@ data class Message(
@JsonString val data: String
)
```

### `AdaptedBy`

An annotation that indicates the Moshi `JsonAdapter` or `JsonAdapter.Factory` to use
with a class or property (as a `@JsonQualifier`).

```Kotlin
val moshi = Moshi.Builder()
.add(AdaptedBy.Factory())
.build()

@AdaptedBy(StringAliasAdapter::class)
data class StringAlias(val value: String)

class StringAliasAdapter : JsonAdapter<StringAlias>() {
override fun fromJson(reader: JsonReader): StringAlias? {
return StringAlias(reader.nextString())
}

override fun toJson(writer: JsonWriter, value: StringAlias?) {
if (value == null) {
writer.nullValue()
return
}
writer.value(value.value)
}
}
```
1 change: 1 addition & 0 deletions moshi-adapters/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ tasks.named<KotlinCompile>("compileTestKotlin") {
dependencies {
implementation(Dependencies.Moshi.moshi)
kaptTest(Dependencies.Moshi.codegen)
testImplementation(Dependencies.Moshi.kotlin)
testImplementation(Dependencies.Testing.junit)
testImplementation(Dependencies.Testing.truth)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (C) 2014 Google 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 dev.zacsweers.moshix.adapters

import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonQualifier
import com.squareup.moshi.Moshi
import com.squareup.moshi.rawType
import java.lang.reflect.Type
import kotlin.annotation.AnnotationRetention.RUNTIME
import kotlin.annotation.AnnotationTarget.CLASS
import kotlin.annotation.AnnotationTarget.FIELD
import kotlin.annotation.AnnotationTarget.PROPERTY
import kotlin.reflect.KClass

/**
* An annotation that indicates the Moshi [JsonAdapter] or [JsonAdapter.Factory] to use
* with a class or property. The adapter class must have a public default constructor.
*
* Here is an example of how this annotation is used:
* ```
* @AdaptedBy(UserJsonAdapter::class)
* class User(val firstNam: String, val lastName: String)
*
* class UserJsonAdapter : JsonAdapter<User>() {
* override fun toJson(writer: JsonWriter, user: User) {
* // implement write: combine firstName and lastName into name
* writer.beginObject()
* writer.name("name")
* writer.value(user.firstName + " " + user.lastName)
* writer.endObject()
* // implement the toJson method
* }
* override fun User fromJson(JsonReader reader) {
* // implement read: split name into firstName and lastName
* reader.beginObject()
* reader.nextName()
* val nameParts = reader.nextString().split(" ")
* reader.endObject()
* return User(nameParts[0], nameParts[1])
* }
* }
* ```
*
* Since `User` class specified `UserJsonAdapter` in `@AdaptedBy` annotation, it
* will be invoked to encode/decode `User` instances.
*
* Here is an example of how to apply this annotation to a property as a [JsonQualifier].
*
* ```
* class Gadget(
* @AdaptedBy(UserJsonAdapter2::class) val user: User
* )
* ```
*
* The class referenced by this annotation must be either a [JsonAdapter]
* or a [JsonAdapter.Factory].
* Using [JsonAdapter.Factory] makes it possible to delegate
* to the enclosing [Moshi] instance.
*
* @property adapter Either a [JsonAdapter] or [JsonAdapter.Factory].
* @property nullSafe Set to false to be able to handle null values within the adapter, default value is true.
*/
@JsonQualifier
@Retention(RUNTIME)
@Target(CLASS, PROPERTY, FIELD)
public annotation class AdaptedBy(
val adapter: KClass<*>,
val nullSafe: Boolean = true
) {
public class Factory : JsonAdapter.Factory {
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
var adaptedBy: AdaptedBy?
var nextAnnotations: MutableSet<Annotation>? = null
val rawType = type.rawType
adaptedBy = rawType.getAnnotation(AdaptedBy::class.java)
if (adaptedBy == null) {
for (annotation in annotations) {
if (annotation is AdaptedBy) {
adaptedBy = annotation
} else {
if (nextAnnotations == null) {
nextAnnotations = mutableSetOf()
}
nextAnnotations.add(annotation)
}
}
}

if (adaptedBy == null) return null
val adapterClass = adaptedBy.adapter
val javaClass = adapterClass.java
val adapter = when {
JsonAdapter.Factory::class.java.isAssignableFrom(javaClass) -> {
val factory = javaClass.getDeclaredConstructor()
.newInstance() as JsonAdapter.Factory
factory.create(type, nextAnnotations.orEmpty(), moshi)
}
JsonAdapter::class.java.isAssignableFrom(javaClass) -> {
javaClass.getDeclaredConstructor()
.newInstance() as JsonAdapter<*>
}
else -> {
error(
"Invalid attempt to bind an instance of ${javaClass.name} as a @AdaptedBy for $type. @AdaptedBy " +
"value must be a JsonAdapter or JsonAdapter.Factory."
)
}
} ?: return null

return if (adaptedBy.nullSafe) {
adapter.nullSafe()
} else {
adapter
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Keep @JsonAdapter annotations at runtime
-keep interface dev.zacsweers.moshix.adapters.AdaptedBy { *; }

# Keep empty default constructors of Moshi adapter types in case they're referenced by @AdaptedBy annotations
-if public class * extends com.squareup.moshi.JsonAdapter
-keepclassmembers class <1> {
public <init>();
}
-if public class * implements com.squareup.moshi.JsonAdapter.Factory
-keepclassmembers class <1> {
public <init>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package dev.zacsweers.moshix.adapters

import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.squareup.moshi.rawType
import org.junit.Test
import java.lang.reflect.Type

class AdaptedByTest {

private val moshi = Moshi.Builder()
.add(AdaptedBy.Factory())
.addLast(KotlinJsonAdapterFactory())
.build()

@Test
fun adapterProperty() {
val adapter = moshi.adapter<StringAliasHolderAdapter>()
val instance = adapter.fromJson("{\"alias\":\"value\"}")
assertThat(instance).isEqualTo(StringAliasHolderAdapter(StringAlias("value")))
}

@Test
fun factoryProperty() {
val adapter = moshi.adapter<StringAliasHolderFactory>()
val instance = adapter.fromJson("{\"alias\":\"value\"}")
assertThat(instance).isEqualTo(StringAliasHolderFactory(StringAlias("value")))
}

data class StringAliasHolderAdapter(
@AdaptedBy(StringAliasAdapter::class) val alias: StringAlias
)

data class StringAliasHolderFactory(
@AdaptedBy(StringAliasFactory::class) val alias: StringAlias
)

data class StringAlias(val value: String)

class StringAliasFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
return if (type.rawType == StringAlias::class.java) {
StringAliasAdapter()
} else {
null
}
}
}

class StringAliasAdapter : JsonAdapter<StringAlias>() {
override fun fromJson(reader: JsonReader): StringAlias? {
return StringAlias(reader.nextString())
}

override fun toJson(writer: JsonWriter, value: StringAlias?) {
if (value == null) {
writer.nullValue()
return
}
writer.value(value.value)
}
}

@Test
fun annotatedAdapterClass() {
val adapter = moshi.adapter<AnnotatedStringAlias>()
val instance = adapter.fromJson("\"value\"")
assertThat(instance).isEqualTo(AnnotatedStringAlias("value"))
}

@AdaptedBy(AnnotatedStringAliasAdapter::class)
data class AnnotatedStringAlias(val value: String)

class AnnotatedStringAliasAdapter : JsonAdapter<AnnotatedStringAlias>() {
override fun fromJson(reader: JsonReader): AnnotatedStringAlias? {
return AnnotatedStringAlias(reader.nextString())
}

override fun toJson(writer: JsonWriter, value: AnnotatedStringAlias?) {
if (value == null) {
writer.nullValue()
return
}
writer.value(value.value)
}
}

@Test
fun annotatedFactoryClass() {
val adapter = moshi.adapter<AnnotatedFactoryStringAlias>()
val instance = adapter.fromJson("\"value\"")
assertThat(instance).isEqualTo(AnnotatedFactoryStringAlias("value"))
}

@AdaptedBy(AnnotatedFactoryStringAliasFactory::class)
data class AnnotatedFactoryStringAlias(val value: String)

class AnnotatedFactoryStringAliasFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
return if (type.rawType == AnnotatedFactoryStringAlias::class.java) {
AnnotatedFactoryStringAliasAdapter()
} else {
null
}
}
}

class AnnotatedFactoryStringAliasAdapter : JsonAdapter<AnnotatedFactoryStringAlias>() {
override fun fromJson(reader: JsonReader): AnnotatedFactoryStringAlias? {
return AnnotatedFactoryStringAlias(reader.nextString())
}

override fun toJson(writer: JsonWriter, value: AnnotatedFactoryStringAlias?) {
if (value == null) {
writer.nullValue()
return
}
writer.value(value.value)
}
}

@Test
fun classUsingAnnotatedClassesAsProperties() {
val adapter = moshi.adapter<ClassUsingAnnotatedClasses>()
val instance = adapter.fromJson("{\"alias1\":\"value\",\"alias2\":\"value\"}")
assertThat(instance).isEqualTo(
ClassUsingAnnotatedClasses(
AnnotatedStringAlias("value"),
AnnotatedFactoryStringAlias("value")
)
)
}

data class ClassUsingAnnotatedClasses(
val alias1: AnnotatedStringAlias,
val alias2: AnnotatedFactoryStringAlias
)

@Test
fun nullSafeHandling() {
val adapter = moshi.adapter(NullHandlingStringAlias::class.java)
val instance = adapter.fromJson("null")
assertThat(instance).isEqualTo(NullHandlingStringAlias("null"))
}

@AdaptedBy(NullHandlingStringAliasAdapter::class, nullSafe = false)
data class NullHandlingStringAlias(val value: String)

class NullHandlingStringAliasAdapter : JsonAdapter<NullHandlingStringAlias>() {
override fun fromJson(reader: JsonReader): NullHandlingStringAlias? {
val value = if (reader.peek() == JsonReader.Token.NULL) {
reader.nextNull<String>()
"null"
} else {
reader.nextString()
}
return NullHandlingStringAlias(value)
}

override fun toJson(writer: JsonWriter, value: NullHandlingStringAlias?) {
if (value == null) {
writer.nullValue()
return
}
writer.value(value.value)
}
}
// TODO
// nullsafe
}
Loading

0 comments on commit ef46973

Please sign in to comment.