mirror of
https://github.com/fankes/moshi.git
synced 2025-10-18 23:49:21 +08:00
Implement reflective support for Java Records (#1381)
* Standardize around JDK 8 * Update GJF to support newer JDKs * Fix misc java 8 issues in tests * Prepare java 16/records checking at runtime * Implement real RecordJsonAdapter * Spotless * Prepare build for JDK 16+ * Fix property name for kapt * Small cleanup * Make FallbackEnum java-8 happy * Remove animalsniffer * Fix format * Add opens for ExtendsPlatformClassWithProtectedFields * Return null every time in shim for main tests * Use JDK 16 + release 8 to replace animalsniffer * Simplify accessor accessible handling * Remove manifest attrs * Fix typo * Fix KCT tests + upgrade it * Cover another * Try explicit kotlin daemon args for java 17? * Disable 17-ea for now until kotlin 1.5.30 * Add JsonQualifier and Json(name) tests + fix qualifiers * Ensure constructor is accessible * GJF it properly * GJF 1.11 * Unwrap InvocationTargetException * Use MethodHandle for constructor * Use MethodHandle for accessor too * Revert "Remove manifest attrs" This reverts commit 3eb768fd6904bb5c979aa01c3c182e0fb9329d62. * Proper MR jar * *actually* fix GJF, which wasn't getting applied before We can just enable this everywhere now since we require JDK 16 anyway * Make IDE happy about modules access * Fixup records tests to play nice with modules Gotta be public * Add complex smoke test * Remove comment Not a regression test in this case
This commit is contained in:
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -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 }}
|
||||
|
@@ -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<KotlinCompile>().configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.6"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
@@ -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,8 +43,6 @@ 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",
|
||||
@@ -70,7 +67,7 @@ spotless {
|
||||
"**/TypesTest.java"
|
||||
)
|
||||
val configureCommonJavaFormat: JavaExtension.() -> Unit = {
|
||||
googleJavaFormat("1.7")
|
||||
googleJavaFormat("1.11.0")
|
||||
}
|
||||
java {
|
||||
configureCommonJavaFormat()
|
||||
@@ -88,7 +85,6 @@ spotless {
|
||||
configureCommonJavaFormat()
|
||||
target(*externalJavaFiles)
|
||||
}
|
||||
}
|
||||
kotlin {
|
||||
ktlint(Dependencies.ktlintVersion).userData(mapOf("indent_size" to "2"))
|
||||
target("**/*.kt")
|
||||
@@ -129,15 +125,14 @@ subprojects {
|
||||
// Apply with "java" instead of just "java-library" so kotlin projects get it too
|
||||
pluginManager.withPlugin("java") {
|
||||
configure<JavaPluginExtension> {
|
||||
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<JavaCompile>().configureEach {
|
||||
options.release.set(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +141,7 @@ subprojects {
|
||||
kotlinOptions {
|
||||
@Suppress("SuspiciousCollectionReassignment")
|
||||
freeCompilerArgs += listOf("-progressive")
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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"
|
||||
}
|
||||
|
@@ -58,9 +58,9 @@ final class FallbackEnum {
|
||||
if (!(annotation instanceof Fallback)) {
|
||||
return null;
|
||||
}
|
||||
Class<Enum> enumType = (Class<Enum>) rawType;
|
||||
Enum<?> fallback = Enum.valueOf(enumType, ((Fallback) annotation).value());
|
||||
return new FallbackEnumJsonAdapter<>(enumType, fallback);
|
||||
//noinspection rawtypes
|
||||
return new FallbackEnumJsonAdapter<>(
|
||||
(Class<? extends Enum>) rawType, ((Fallback) annotation).value());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,9 +70,9 @@ final class FallbackEnum {
|
||||
final JsonReader.Options options;
|
||||
final T defaultValue;
|
||||
|
||||
FallbackEnumJsonAdapter(Class<T> enumType, T defaultValue) {
|
||||
FallbackEnumJsonAdapter(Class<T> enumType, String fallbackName) {
|
||||
this.enumType = enumType;
|
||||
this.defaultValue = defaultValue;
|
||||
this.defaultValue = Enum.valueOf(enumType, fallbackName);
|
||||
try {
|
||||
constants = enumType.getEnumConstants();
|
||||
nameStrings = new String[constants.length];
|
||||
|
@@ -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
|
||||
|
@@ -27,7 +27,6 @@ plugins {
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
@Suppress("SuspiciousCollectionReassignment")
|
||||
freeCompilerArgs += listOf(
|
||||
"-Xopt-in=com.squareup.kotlinpoet.metadata.KotlinPoetMetadataPreview"
|
||||
@@ -35,10 +34,20 @@ tasks.withType<KotlinCompile>().configureEach {
|
||||
}
|
||||
}
|
||||
|
||||
// To make Gradle happy
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
tasks.withType<Test>().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")
|
||||
|
@@ -21,6 +21,11 @@ plugins {
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
// ExtendsPlatformClassWithProtectedField tests a case where we set a protected ByteArrayOutputStream.buf field
|
||||
jvmArgs("--add-opens=java.base/java.io=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
kotlinOptions {
|
||||
@Suppress("SuspiciousCollectionReassignment")
|
||||
|
@@ -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<JavaCompile>("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>("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<Test>().configureEach {
|
||||
// ExtendsPlatformClassWithProtectedField tests a case where we set a protected ByteArrayOutputStream.buf field
|
||||
jvmArgs("--add-opens=java.base/java.io=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>()
|
||||
.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<KotlinCompile>()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// So the j16 source set can "see" main Moshi sources
|
||||
"java16Implementation"(mainSourceSet.output)
|
||||
compileOnly(Dependencies.jsr305)
|
||||
api(Dependencies.okio)
|
||||
|
||||
|
30
moshi/records-tests/build.gradle.kts
Normal file
30
moshi/records-tests/build.gradle.kts
Normal file
@@ -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<JavaCompile>().configureEach {
|
||||
options.release.set(16)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(project(":moshi"))
|
||||
testCompileOnly(Dependencies.jsr305)
|
||||
testImplementation(Dependencies.Testing.junit)
|
||||
testImplementation(Dependencies.Testing.truth)
|
||||
}
|
@@ -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<String> nationalities,
|
||||
float weight,
|
||||
Boolean tattoos, // Boxed primitive test
|
||||
boolean hasChildren,
|
||||
List<? super CharSequence> superWildcard,
|
||||
List<? extends CharSequence> extendsWildcard,
|
||||
List<?> unboundedWildcard,
|
||||
List<Object> objectList,
|
||||
int[] favoriteThreeNumbers,
|
||||
String[] favoriteArrayValues,
|
||||
Map<String, String> foodPreferences,
|
||||
Set<List<Map<String, int[]>>> setListMapArrayInt,
|
||||
Map<String, Object>[] 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.<GenericRecord<String>>adapter(
|
||||
Types.newParameterizedTypeWithOwner(
|
||||
RecordsTest.class, GenericRecord.class, String.class));
|
||||
assertThat(adapter.fromJson("{\"value\":\"Okay!\"}")).isEqualTo(new GenericRecord<>("Okay!"));
|
||||
}
|
||||
|
||||
public static record GenericRecord<T>(T value) {}
|
||||
|
||||
@Test
|
||||
public void genericBoundedRecord() throws IOException {
|
||||
var adapter =
|
||||
moshi.<GenericBoundedRecord<Integer>>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 extends Number>(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) {}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
||||
|
@@ -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<T> extends JsonAdapter<T> {
|
||||
|
||||
static final JsonAdapter.Factory FACTORY =
|
||||
new JsonAdapter.Factory() {
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public JsonAdapter<?> create(
|
||||
Type type, Set<? extends Annotation> 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();
|
||||
}
|
||||
}
|
218
moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java
Normal file
218
moshi/src/main/java16/com/squareup/moshi/RecordJsonAdapter.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p><em>NOTE:</em> Java records require JDK 16 or higher.
|
||||
*/
|
||||
final class RecordJsonAdapter<T> extends JsonAdapter<T> {
|
||||
|
||||
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<String, Type> 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<String, ComponentBinding<?>>();
|
||||
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<Annotation> 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<T>(
|
||||
String name, String jsonName, JsonAdapter<T> adapter, MethodHandle accessor) {}
|
||||
|
||||
private final String targetClass;
|
||||
private final MethodHandle constructor;
|
||||
private final ComponentBinding<Object>[] componentBindingsArray;
|
||||
private final JsonReader.Options options;
|
||||
|
||||
@SuppressWarnings("ToArrayCallWithZeroLengthArrayArgument")
|
||||
public RecordJsonAdapter(
|
||||
MethodHandle constructor,
|
||||
String targetClass,
|
||||
Map<String, ComponentBinding<?>> 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 + ")";
|
||||
}
|
||||
}
|
@@ -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.<Object>nextNull()).isNull();
|
||||
}
|
||||
assertThat(toUpperCase.fromJson(reader)).isEqualTo("C");
|
||||
reader.endArray();
|
||||
|
@@ -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.<Object>nextNull()).isNull();
|
||||
assertThat(reader3.peek()).isEqualTo(JsonReader.Token.END_DOCUMENT);
|
||||
|
||||
JsonReader reader4 = newReader("123");
|
||||
|
@@ -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.<Object>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.<Object>nextNull()).isNull();
|
||||
|
||||
assertThat(reader.hasNext()).isFalse();
|
||||
assertThat(reader.peek()).isEqualTo(JsonReader.Token.END_OBJECT);
|
||||
|
@@ -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<Number> 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<Number> numbers = Arrays.<Number>asList(0.5d, 0.5d);
|
||||
|
@@ -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) {
|
||||
}
|
||||
|
@@ -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")
|
||||
|
Reference in New Issue
Block a user