diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24dcaaf..ecbb75a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,14 +10,7 @@ jobs: fail-fast: false matrix: java-version: - - 8 - - 9 - - 10 - - 11 - - 12 - - 13 - - 14 - - 15 + - 16 steps: - name: Checkout @@ -46,7 +39,7 @@ jobs: run: ./gradlew build check --stacktrace - name: Publish (default branch only) - if: github.repository == 'square/moshi' && github.ref == 'refs/heads/master' && matrix.java-version == '8' + if: github.repository == 'square/moshi' && github.ref == 'refs/heads/master' && matrix.java-version == '16' run: ./gradlew uploadArchives env: ORG_GRADLE_PROJECT_SONATYPE_NEXUS_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} diff --git a/adapters/build.gradle.kts b/adapters/build.gradle.kts index d3e0d4d..aeb4d63 100644 --- a/adapters/build.gradle.kts +++ b/adapters/build.gradle.kts @@ -14,18 +14,9 @@ * limitations under the License. */ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { kotlin("jvm") id("com.vanniktech.maven.publish") - id("ru.vyarus.animalsniffer") -} - -tasks.withType().configureEach { - kotlinOptions { - jvmTarget = "1.6" - } } dependencies { diff --git a/build.gradle.kts b/build.gradle.kts index 1e89334..12d7ead 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,7 +33,6 @@ plugins { id("com.vanniktech.maven.publish") version "0.14.2" apply false id("org.jetbrains.dokka") version "1.4.32" apply false id("com.diffplug.spotless") version "5.12.4" - id("ru.vyarus.animalsniffer") version "1.5.3" apply false id("me.champeau.gradle.japicmp") version "0.2.9" apply false } @@ -44,50 +43,47 @@ spotless { indentWithSpaces(2) endWithNewline() } - // GJF not compatible with JDK 15 yet - if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_15)) { - val externalJavaFiles = arrayOf( - "**/ClassFactory.java", - "**/Iso8601Utils.java", - "**/JsonReader.java", - "**/JsonReaderPathTest.java", - "**/JsonReaderTest.java", - "**/JsonScope.java", - "**/JsonUtf8Reader.java", - "**/JsonUtf8ReaderPathTest.java", - "**/JsonUtf8ReaderTest.java", - "**/JsonUtf8ReaderTest.java", - "**/JsonUtf8Writer.java", - "**/JsonUtf8WriterTest.java", - "**/JsonWriter.java", - "**/JsonWriterPathTest.java", - "**/JsonWriterTest.java", - "**/LinkedHashTreeMap.java", - "**/LinkedHashTreeMapTest.java", - "**/PolymorphicJsonAdapterFactory.java", - "**/RecursiveTypesResolveTest.java", - "**/Types.java", - "**/TypesTest.java" + val externalJavaFiles = arrayOf( + "**/ClassFactory.java", + "**/Iso8601Utils.java", + "**/JsonReader.java", + "**/JsonReaderPathTest.java", + "**/JsonReaderTest.java", + "**/JsonScope.java", + "**/JsonUtf8Reader.java", + "**/JsonUtf8ReaderPathTest.java", + "**/JsonUtf8ReaderTest.java", + "**/JsonUtf8ReaderTest.java", + "**/JsonUtf8Writer.java", + "**/JsonUtf8WriterTest.java", + "**/JsonWriter.java", + "**/JsonWriterPathTest.java", + "**/JsonWriterTest.java", + "**/LinkedHashTreeMap.java", + "**/LinkedHashTreeMapTest.java", + "**/PolymorphicJsonAdapterFactory.java", + "**/RecursiveTypesResolveTest.java", + "**/Types.java", + "**/TypesTest.java" + ) + val configureCommonJavaFormat: JavaExtension.() -> Unit = { + googleJavaFormat("1.11.0") + } + java { + configureCommonJavaFormat() + target("**/*.java") + targetExclude( + "**/spotless.java", + "**/build/**", + *externalJavaFiles ) - val configureCommonJavaFormat: JavaExtension.() -> Unit = { - googleJavaFormat("1.7") - } - java { - configureCommonJavaFormat() - target("**/*.java") - targetExclude( - "**/spotless.java", - "**/build/**", - *externalJavaFiles - ) - licenseHeaderFile("spotless/spotless.java") - } - format("externalJava", JavaExtension::class.java) { - // These don't use our spotless config for header files since we don't want to overwrite the - // existing copyright headers. - configureCommonJavaFormat() - target(*externalJavaFiles) - } + licenseHeaderFile("spotless/spotless.java") + } + format("externalJava", JavaExtension::class.java) { + // These don't use our spotless config for header files since we don't want to overwrite the + // existing copyright headers. + configureCommonJavaFormat() + target(*externalJavaFiles) } kotlin { ktlint(Dependencies.ktlintVersion).userData(mapOf("indent_size" to "2")) @@ -129,15 +125,14 @@ subprojects { // Apply with "java" instead of just "java-library" so kotlin projects get it too pluginManager.withPlugin("java") { configure { - sourceCompatibility = JavaVersion.VERSION_1_7 - targetCompatibility = JavaVersion.VERSION_1_7 + toolchain { + languageVersion.set(JavaLanguageVersion.of(16)) + } } - } - - pluginManager.withPlugin("ru.vyarus.animalsniffer") { - dependencies { - "compileOnly"(Dependencies.AnimalSniffer.annotations) - "signature"(Dependencies.AnimalSniffer.java7Signature) + if (project.name != "records-tests") { + tasks.withType().configureEach { + options.release.set(8) + } } } @@ -146,6 +141,7 @@ subprojects { kotlinOptions { @Suppress("SuspiciousCollectionReassignment") freeCompilerArgs += listOf("-progressive") + jvmTarget = "1.8" } } diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index fae33df..998d087 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -21,11 +21,6 @@ object Dependencies { const val ktlintVersion = "0.41.0" const val okio = "com.squareup.okio:okio:2.10.0" - object AnimalSniffer { - const val annotations = "org.codehaus.mojo:animal-sniffer-annotations:1.16" - const val java7Signature = "org.codehaus.mojo.signature:java17:1.0@signature" - } - object AutoService { private const val version = "1.0" const val annotations = "com.google.auto.service:auto-service-annotations:$version" @@ -53,7 +48,7 @@ object Dependencies { object Testing { const val assertj = "org.assertj:assertj-core:3.11.1" - const val compileTesting = "com.github.tschuchortdev:kotlin-compile-testing:1.4.0" + const val compileTesting = "com.github.tschuchortdev:kotlin-compile-testing:1.4.3" const val junit = "junit:junit:4.13.2" const val truth = "com.google.truth:truth:1.0.1" } diff --git a/examples/src/main/java/com/squareup/moshi/recipes/FallbackEnum.java b/examples/src/main/java/com/squareup/moshi/recipes/FallbackEnum.java index 8b584f1..9edf0ac 100644 --- a/examples/src/main/java/com/squareup/moshi/recipes/FallbackEnum.java +++ b/examples/src/main/java/com/squareup/moshi/recipes/FallbackEnum.java @@ -58,9 +58,9 @@ final class FallbackEnum { if (!(annotation instanceof Fallback)) { return null; } - Class enumType = (Class) rawType; - Enum fallback = Enum.valueOf(enumType, ((Fallback) annotation).value()); - return new FallbackEnumJsonAdapter<>(enumType, fallback); + //noinspection rawtypes + return new FallbackEnumJsonAdapter<>( + (Class) rawType, ((Fallback) annotation).value()); } }; @@ -70,9 +70,9 @@ final class FallbackEnum { final JsonReader.Options options; final T defaultValue; - FallbackEnumJsonAdapter(Class enumType, T defaultValue) { + FallbackEnumJsonAdapter(Class enumType, String fallbackName) { this.enumType = enumType; - this.defaultValue = defaultValue; + this.defaultValue = Enum.valueOf(enumType, fallbackName); try { constants = enumType.getEnumConstants(); nameStrings = new String[constants.length]; diff --git a/gradle.properties b/gradle.properties index 1d7c1a6..b38e0df 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,9 +15,36 @@ # # For Dokka https://github.com/Kotlin/dokka/issues/1405 -org.gradle.jvmargs=-XX:MaxMetaspaceSize=512m +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 \ + --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED -kapt.includeCompileClasspath=false +# TODO move this to DSL in Kotlin 1.5.30 https://youtrack.jetbrains.com/issue/KT-44266 +kotlin.daemon.jvmargs=-Dfile.encoding=UTF-8 \ + --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + +kapt.include.compile.classpath=false GROUP=com.squareup.moshi VERSION_NAME=1.13.0-SNAPSHOT diff --git a/kotlin/codegen/build.gradle.kts b/kotlin/codegen/build.gradle.kts index f9e95ae..1ae9b29 100644 --- a/kotlin/codegen/build.gradle.kts +++ b/kotlin/codegen/build.gradle.kts @@ -27,7 +27,6 @@ plugins { tasks.withType().configureEach { kotlinOptions { - jvmTarget = "1.8" @Suppress("SuspiciousCollectionReassignment") freeCompilerArgs += listOf( "-Xopt-in=com.squareup.kotlinpoet.metadata.KotlinPoetMetadataPreview" @@ -35,10 +34,20 @@ tasks.withType().configureEach { } } -// To make Gradle happy -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 +tasks.withType().configureEach { + // For kapt to work with kotlin-compile-testing + jvmArgs( + "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", + ) } val shade: Configuration = configurations.maybeCreate("compileShaded") diff --git a/kotlin/tests/build.gradle.kts b/kotlin/tests/build.gradle.kts index 11b0ba6..9a2379f 100644 --- a/kotlin/tests/build.gradle.kts +++ b/kotlin/tests/build.gradle.kts @@ -21,6 +21,11 @@ plugins { kotlin("kapt") } +tasks.withType().configureEach { + // ExtendsPlatformClassWithProtectedField tests a case where we set a protected ByteArrayOutputStream.buf field + jvmArgs("--add-opens=java.base/java.io=ALL-UNNAMED") +} + tasks.withType().configureEach { kotlinOptions { @Suppress("SuspiciousCollectionReassignment") diff --git a/moshi/build.gradle.kts b/moshi/build.gradle.kts index 7a6a925..c951ddf 100644 --- a/moshi/build.gradle.kts +++ b/moshi/build.gradle.kts @@ -19,14 +19,49 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") id("com.vanniktech.maven.publish") - id("ru.vyarus.animalsniffer") +} + +val mainSourceSet by sourceSets.named("main") +val java16 by sourceSets.creating { + java { + srcDir("src/main/java16") + } +} + +tasks.named("compileJava16Java") { + javaCompiler.set( + javaToolchains.compilerFor { + languageVersion.set(JavaLanguageVersion.of(16)) + } + ) + options.release.set(16) +} + +// Package our actual RecordJsonAdapter from java16 sources in and denote it as an MRJAR +tasks.named("jar") { + from(java16.output) { + into("META-INF/versions/16") + } + manifest { + attributes("Multi-Release" to "true") + } +} + +configurations { + "java16Implementation" { + extendsFrom(api.get()) + extendsFrom(implementation.get()) + } +} + +tasks.withType().configureEach { + // ExtendsPlatformClassWithProtectedField tests a case where we set a protected ByteArrayOutputStream.buf field + jvmArgs("--add-opens=java.base/java.io=ALL-UNNAMED") } tasks.withType() .configureEach { kotlinOptions { - jvmTarget = "1.6" - if (name.contains("test", true)) { @Suppress("SuspiciousCollectionReassignment") // It's not suspicious freeCompilerArgs += listOf("-Xopt-in=kotlin.ExperimentalStdlibApi") @@ -35,6 +70,8 @@ tasks.withType() } dependencies { + // So the j16 source set can "see" main Moshi sources + "java16Implementation"(mainSourceSet.output) compileOnly(Dependencies.jsr305) api(Dependencies.okio) diff --git a/moshi/records-tests/build.gradle.kts b/moshi/records-tests/build.gradle.kts new file mode 100644 index 0000000..7b6b541 --- /dev/null +++ b/moshi/records-tests/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2021 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 + * + * https://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. + */ + +plugins { + `java-library` +} + +tasks.withType().configureEach { + options.release.set(16) +} + +dependencies { + testImplementation(project(":moshi")) + testCompileOnly(Dependencies.jsr305) + testImplementation(Dependencies.Testing.junit) + testImplementation(Dependencies.Testing.truth) +} diff --git a/moshi/records-tests/src/test/java/com/squareup/moshi/records/RecordsTest.java b/moshi/records-tests/src/test/java/com/squareup/moshi/records/RecordsTest.java new file mode 100644 index 0000000..5703d20 --- /dev/null +++ b/moshi/records-tests/src/test/java/com/squareup/moshi/records/RecordsTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2021 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 + * + * https://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.records; + +import static com.google.common.truth.Truth.assertThat; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import com.squareup.moshi.FromJson; +import com.squareup.moshi.Json; +import com.squareup.moshi.JsonQualifier; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.ToJson; +import com.squareup.moshi.Types; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.junit.Test; + +public final class RecordsTest { + + private final Moshi moshi = new Moshi.Builder().build(); + + @Test + public void smokeTest() throws IOException { + var stringAdapter = moshi.adapter(String.class); + var adapter = + moshi + .newBuilder() + .add(CharSequence.class, stringAdapter) + .add(Types.subtypeOf(CharSequence.class), stringAdapter) + .add(Types.supertypeOf(CharSequence.class), stringAdapter) + .build() + .adapter(SmokeTestType.class); + var instance = + new SmokeTestType( + "John", + "Smith", + 25, + List.of("American"), + 70.5f, + null, + true, + List.of("super wildcards!"), + List.of("extend wildcards!"), + List.of("unbounded"), + List.of("objectList"), + new int[] {1, 2, 3}, + new String[] {"fav", "arrays"}, + Map.of("italian", "pasta"), + Set.of(List.of(Map.of("someKey", new int[] {1}))), + new Map[] {Map.of("Hello", "value")}); + var json = adapter.toJson(instance); + var deserialized = adapter.fromJson(json); + assertThat(deserialized).isEqualTo(instance); + } + + public static record SmokeTestType( + @Json(name = "first_name") String firstName, + @Json(name = "last_name") String lastName, + int age, + List nationalities, + float weight, + Boolean tattoos, // Boxed primitive test + boolean hasChildren, + List superWildcard, + List extendsWildcard, + List unboundedWildcard, + List objectList, + int[] favoriteThreeNumbers, + String[] favoriteArrayValues, + Map foodPreferences, + Set>> setListMapArrayInt, + Map[] nestedArray) { + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SmokeTestType that = (SmokeTestType) o; + return age == that.age + && Float.compare(that.weight, weight) == 0 + && hasChildren == that.hasChildren + && firstName.equals(that.firstName) + && lastName.equals(that.lastName) + && nationalities.equals(that.nationalities) + && Objects.equals(tattoos, that.tattoos) + && superWildcard.equals(that.superWildcard) + && extendsWildcard.equals(that.extendsWildcard) + && unboundedWildcard.equals(that.unboundedWildcard) + && objectList.equals(that.objectList) + && Arrays.equals(favoriteThreeNumbers, that.favoriteThreeNumbers) + && Arrays.equals(favoriteArrayValues, that.favoriteArrayValues) + && foodPreferences.equals(that.foodPreferences) + // && setListMapArrayInt.equals(that.setListMapArrayInt) // Nested array equality doesn't + // carry over + && Arrays.equals(nestedArray, that.nestedArray); + } + + @Override + public int hashCode() { + int result = + Objects.hash( + firstName, + lastName, + age, + nationalities, + weight, + tattoos, + hasChildren, + superWildcard, + extendsWildcard, + unboundedWildcard, + objectList, + foodPreferences, + setListMapArrayInt); + result = 31 * result + Arrays.hashCode(favoriteThreeNumbers); + result = 31 * result + Arrays.hashCode(favoriteArrayValues); + result = 31 * result + Arrays.hashCode(nestedArray); + return result; + } + } + + @Test + public void genericRecord() throws IOException { + var adapter = + moshi.>adapter( + Types.newParameterizedTypeWithOwner( + RecordsTest.class, GenericRecord.class, String.class)); + assertThat(adapter.fromJson("{\"value\":\"Okay!\"}")).isEqualTo(new GenericRecord<>("Okay!")); + } + + public static record GenericRecord(T value) {} + + @Test + public void genericBoundedRecord() throws IOException { + var adapter = + moshi.>adapter( + Types.newParameterizedTypeWithOwner( + RecordsTest.class, GenericBoundedRecord.class, Integer.class)); + assertThat(adapter.fromJson("{\"value\":4}")).isEqualTo(new GenericBoundedRecord<>(4)); + } + + @Test + public void qualifiedValues() throws IOException { + var adapter = moshi.newBuilder().add(new ColorAdapter()).build().adapter(QualifiedValues.class); + assertThat(adapter.fromJson("{\"value\":\"#ff0000\"}")) + .isEqualTo(new QualifiedValues(16711680)); + } + + public static record QualifiedValues(@HexColor int value) {} + + @Retention(RUNTIME) + @JsonQualifier + @interface HexColor {} + + /** Converts strings like #ff0000 to the corresponding color ints. */ + public static class ColorAdapter { + @ToJson + public String toJson(@HexColor int rgb) { + return String.format("#%06x", rgb); + } + + @FromJson + @HexColor + public int fromJson(String rgb) { + return Integer.parseInt(rgb.substring(1), 16); + } + } + + public static record GenericBoundedRecord(T value) {} + + @Test + public void jsonName() throws IOException { + var adapter = moshi.adapter(JsonName.class); + assertThat(adapter.fromJson("{\"actualValue\":3}")).isEqualTo(new JsonName(3)); + } + + public static record JsonName(@Json(name = "actualValue") int value) {} +} diff --git a/moshi/src/main/java/com/squareup/moshi/Moshi.java b/moshi/src/main/java/com/squareup/moshi/Moshi.java index 6a480f2..51a82a6 100644 --- a/moshi/src/main/java/com/squareup/moshi/Moshi.java +++ b/moshi/src/main/java/com/squareup/moshi/Moshi.java @@ -51,6 +51,7 @@ public final class Moshi { BUILT_IN_FACTORIES.add(CollectionJsonAdapter.FACTORY); BUILT_IN_FACTORIES.add(MapJsonAdapter.FACTORY); BUILT_IN_FACTORIES.add(ArrayJsonAdapter.FACTORY); + BUILT_IN_FACTORIES.add(RecordJsonAdapter.FACTORY); BUILT_IN_FACTORIES.add(ClassJsonAdapter.FACTORY); } diff --git a/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.java b/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.java new file mode 100644 index 0000000..0f273c0 --- /dev/null +++ b/moshi/src/main/java/com/squareup/moshi/RecordJsonAdapter.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2021 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 + * + * https://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; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.Set; +import javax.annotation.Nullable; + +/** + * This is just a simple shim for linking in {@link StandardJsonAdapters} and swapped with a real + * implementation in Java 16 via MR Jar. + */ +final class RecordJsonAdapter extends JsonAdapter { + + static final JsonAdapter.Factory FACTORY = + new JsonAdapter.Factory() { + + @Nullable + @Override + public JsonAdapter create( + Type type, Set annotations, Moshi moshi) { + return null; + } + }; + + @Nullable + @Override + public T fromJson(JsonReader reader) throws IOException { + throw new AssertionError(); + } + + @Override + public void toJson(JsonWriter writer, @Nullable T value) throws IOException { + throw new AssertionError(); + } +} diff --git a/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java b/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java new file mode 100644 index 0000000..af52783 --- /dev/null +++ b/moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2021 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 + * + * https://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; + +import static java.lang.invoke.MethodType.methodType; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * A {@link JsonAdapter} that supports Java {@code record} classes via reflection. + * + *

NOTE: Java records require JDK 16 or higher. + */ +final class RecordJsonAdapter extends JsonAdapter { + + static final JsonAdapter.Factory FACTORY = + (type, annotations, moshi) -> { + if (!annotations.isEmpty()) { + return null; + } + + if (!(type instanceof Class) && !(type instanceof ParameterizedType)) { + return null; + } + + var rawType = Types.getRawType(type); + if (!rawType.isRecord()) { + return null; + } + + Map mappedTypeArgs = null; + if (type instanceof ParameterizedType parameterizedType) { + Type[] typeArgs = parameterizedType.getActualTypeArguments(); + var typeVars = rawType.getTypeParameters(); + mappedTypeArgs = new LinkedHashMap<>(typeArgs.length); + for (int i = 0; i < typeArgs.length; ++i) { + var typeVarName = typeVars[i].getName(); + var materialized = typeArgs[i]; + mappedTypeArgs.put(typeVarName, materialized); + } + } + var components = rawType.getRecordComponents(); + var bindings = new LinkedHashMap>(); + var constructorParams = new Class[components.length]; + var lookup = MethodHandles.lookup(); + for (int i = 0, componentsLength = components.length; i < componentsLength; i++) { + RecordComponent component = components[i]; + constructorParams[i] = component.getType(); + var name = component.getName(); + var componentType = component.getGenericType(); + if (componentType instanceof TypeVariable typeVariable) { + var typeVarName = typeVariable.getName(); + if (mappedTypeArgs == null) { + throw new AssertionError( + "No mapped type arguments found for type '" + typeVarName + "'"); + } + var mappedType = mappedTypeArgs.get(typeVarName); + if (mappedType == null) { + throw new AssertionError( + "No materialized type argument found for type '" + typeVarName + "'"); + } + componentType = mappedType; + } + var jsonName = name; + Set qualifiers = null; + for (var annotation : component.getDeclaredAnnotations()) { + if (annotation instanceof Json jsonAnnotation) { + jsonName = jsonAnnotation.name(); + } else { + if (annotation.annotationType().isAnnotationPresent(JsonQualifier.class)) { + if (qualifiers == null) { + qualifiers = new LinkedHashSet<>(); + } + qualifiers.add(annotation); + } + } + } + if (qualifiers == null) { + qualifiers = Collections.emptySet(); + } + var adapter = moshi.adapter(componentType, qualifiers); + MethodHandle accessor; + try { + accessor = lookup.unreflect(component.getAccessor()); + } catch (IllegalAccessException e) { + throw new AssertionError(e); + } + var componentBinding = new ComponentBinding<>(name, jsonName, adapter, accessor); + var replaced = bindings.put(jsonName, componentBinding); + if (replaced != null) { + throw new IllegalArgumentException( + "Conflicting components:\n" + + " " + + replaced.name + + "\n" + + " " + + componentBinding.name); + } + } + + MethodHandle constructor; + try { + constructor = lookup.findConstructor(rawType, methodType(void.class, constructorParams)); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError(e); + } + return new RecordJsonAdapter<>(constructor, rawType.getSimpleName(), bindings).nullSafe(); + }; + + private static record ComponentBinding( + String name, String jsonName, JsonAdapter adapter, MethodHandle accessor) {} + + private final String targetClass; + private final MethodHandle constructor; + private final ComponentBinding[] componentBindingsArray; + private final JsonReader.Options options; + + @SuppressWarnings("ToArrayCallWithZeroLengthArrayArgument") + public RecordJsonAdapter( + MethodHandle constructor, + String targetClass, + Map> componentBindings) { + this.constructor = constructor; + this.targetClass = targetClass; + //noinspection unchecked + this.componentBindingsArray = + componentBindings.values().toArray(new ComponentBinding[componentBindings.size()]); + this.options = + JsonReader.Options.of( + componentBindings.keySet().toArray(new String[componentBindings.size()])); + } + + @Override + public T fromJson(JsonReader reader) throws IOException { + var resultsArray = new Object[componentBindingsArray.length]; + + reader.beginObject(); + while (reader.hasNext()) { + int index = reader.selectName(options); + if (index == -1) { + reader.skipName(); + reader.skipValue(); + continue; + } + var result = componentBindingsArray[index].adapter.fromJson(reader); + resultsArray[index] = result; + } + reader.endObject(); + + try { + //noinspection unchecked + return (T) constructor.invokeWithArguments(resultsArray); + } catch (Throwable e) { + if (e instanceof InvocationTargetException ite) { + Throwable cause = ite.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Error) throw (Error) cause; + throw new RuntimeException(cause); + } else { + throw new AssertionError(e); + } + } + } + + @Override + public void toJson(JsonWriter writer, T value) throws IOException { + writer.beginObject(); + + for (var binding : componentBindingsArray) { + writer.name(binding.jsonName); + try { + binding.adapter.toJson(writer, binding.accessor.invoke(value)); + } catch (Throwable e) { + if (e instanceof InvocationTargetException ite) { + Throwable cause = ite.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Error) throw (Error) cause; + throw new RuntimeException(cause); + } else { + throw new AssertionError(e); + } + } + } + + writer.endObject(); + } + + @Override + public String toString() { + return "JsonAdapter(" + targetClass + ")"; + } +} diff --git a/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java b/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java index 3bd28c4..c0563fd 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonAdapterTest.java @@ -126,7 +126,7 @@ public final class JsonAdapterTest { fail(); } catch (JsonDataException expected) { assertThat(expected).hasMessageThat().isEqualTo("Unexpected null at $[1]"); - assertThat(reader.nextNull()).isNull(); + assertThat(reader.nextNull()).isNull(); } assertThat(toUpperCase.fromJson(reader)).isEqualTo("C"); reader.endArray(); diff --git a/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java b/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java index e9a00b5..9a2a7a5 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonReaderTest.java @@ -565,7 +565,7 @@ public final class JsonReaderTest { assertThat(reader2.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); JsonReader reader3 = newReader("null"); - assertThat(reader3.nextNull()).isNull(); + assertThat(reader3.nextNull()).isNull(); assertThat(reader3.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT); JsonReader reader4 = newReader("123"); diff --git a/moshi/src/test/java/com/squareup/moshi/JsonValueReaderTest.java b/moshi/src/test/java/com/squareup/moshi/JsonValueReaderTest.java index 0b9b6ff..4dc31cc 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonValueReaderTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonValueReaderTest.java @@ -59,7 +59,7 @@ public final class JsonValueReaderTest { assertThat(reader.hasNext()).isTrue(); assertThat(reader.peek()).isEqualTo(JsonReader.Token.NULL); - assertThat(reader.nextNull()).isNull(); + assertThat(reader.nextNull()).isNull(); assertThat(reader.hasNext()).isFalse(); assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_ARRAY); @@ -103,7 +103,7 @@ public final class JsonValueReaderTest { assertThat(reader.peek()).isEqualTo(JsonReader.Token.NAME); assertThat(reader.nextName()).isEqualTo("d"); assertThat(reader.peek()).isEqualTo(JsonReader.Token.NULL); - assertThat(reader.nextNull()).isNull(); + assertThat(reader.nextNull()).isNull(); assertThat(reader.hasNext()).isFalse(); assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_OBJECT); diff --git a/moshi/src/test/java/com/squareup/moshi/JsonValueWriterTest.java b/moshi/src/test/java/com/squareup/moshi/JsonValueWriterTest.java index 304c9d9..da15692 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonValueWriterTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonValueWriterTest.java @@ -152,10 +152,10 @@ public final class JsonValueWriterTest { public void primitiveIntegerTypesEmitLong() throws Exception { JsonValueWriter writer = new JsonValueWriter(); writer.beginArray(); - writer.value(new Byte(Byte.MIN_VALUE)); - writer.value(new Short(Short.MIN_VALUE)); - writer.value(new Integer(Integer.MIN_VALUE)); - writer.value(new Long(Long.MIN_VALUE)); + writer.value(Byte.valueOf(Byte.MIN_VALUE)); + writer.value(Short.valueOf(Short.MIN_VALUE)); + writer.value(Integer.valueOf(Integer.MIN_VALUE)); + writer.value(Long.valueOf(Long.MIN_VALUE)); writer.endArray(); List numbers = @@ -167,8 +167,8 @@ public final class JsonValueWriterTest { public void primitiveFloatingPointTypesEmitDouble() throws Exception { JsonValueWriter writer = new JsonValueWriter(); writer.beginArray(); - writer.value(new Float(0.5f)); - writer.value(new Double(0.5d)); + writer.value(Float.valueOf(0.5f)); + writer.value(Double.valueOf(0.5d)); writer.endArray(); List numbers = Arrays.asList(0.5d, 0.5d); diff --git a/moshi/src/test/java/com/squareup/moshi/JsonWriterTest.java b/moshi/src/test/java/com/squareup/moshi/JsonWriterTest.java index 2bb8265..d0bd4ec 100644 --- a/moshi/src/test/java/com/squareup/moshi/JsonWriterTest.java +++ b/moshi/src/test/java/com/squareup/moshi/JsonWriterTest.java @@ -236,17 +236,17 @@ public final class JsonWriterTest { JsonWriter writer = factory.newWriter(); writer.beginArray(); try { - writer.value(new Double(Double.NaN)); + writer.value(Double.valueOf(Double.NaN)); fail(); } catch (IllegalArgumentException expected) { } try { - writer.value(new Double(Double.NEGATIVE_INFINITY)); + writer.value(Double.valueOf(Double.NEGATIVE_INFINITY)); fail(); } catch (IllegalArgumentException expected) { } try { - writer.value(new Double(Double.POSITIVE_INFINITY)); + writer.value(Double.valueOf(Double.POSITIVE_INFINITY)); fail(); } catch (IllegalArgumentException expected) { } diff --git a/settings.gradle.kts b/settings.gradle.kts index c65b017..d37a0f5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ pluginManagement { rootProject.name = "moshi-root" include(":moshi") include(":moshi:japicmp") +include(":moshi:records-tests") include(":adapters") include(":adapters:japicmp") include(":examples")