google / gson

A Java serialization/deserialization library to convert Java Objects into JSON and back
Apache License 2.0
23.26k stars 4.27k forks source link

Distinguish between optional field and field with null value #1558

Open TWiStErRob opened 5 years ago

TWiStErRob commented 5 years ago

JSON allows us to express fields that shouldn't be there and fields that could have a null value. These are different things. In Java with cannot have missing fields, so we use null to represent both of these states. We're interoping with an external non-Java party that has a schema like this:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "nullProp": { "type": [ "string", "null" ] },
    "optionalProp": { "type": "string" }
  },
  "required": [ "nullProp" ]
}

because optionalProp is not listed, it can be omitted from the JSON, but if it exists, it must have a non-null value:

we need to send valid JSON that adheres to the schema (manual tests www.jsonschemavalidator.net)

In Java we represent this with:

class Data {
    @Nullable String nullProp;
    @Nullable String optionalProp;
}

but the serialization behavior needs to be different for these two fields. We've looked at all the combinations of serializeNulls and @JsonAdapter, but couldn't figure out how to omit the optional field when its null, while keeping the null field there if it's null.

Here's a test we've been trying to pass with no luck:

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.*;

class OptionalTest {

    class Data {
        // TODO @JsonAdapter(NullAdapter.class)?
        String nullProp;
        // TODO @JsonAdapter(OptionalAdapter.class)?
        String optionalProp;
    }

    private Gson gson;

    @BeforeEach void setUp() {
        gson = new GsonBuilder()
                // TODO .serializeNulls()?
                // TODO .registerTypeAdapterFactory()?
                .create();
    }

    @Test void bothNull() {
        Data data = new Data();
        data.nullProp = null;
        data.optionalProp = null;

        assertEquals("{\"nullProp\":null}", gson.toJson(data));
    }

    @Test void filledContents() {
        Data data = new Data();
        data.nullProp = "some";
        data.optionalProp = "thing";

        assertEquals("{\"nullProp\":\"some\",\"optionalProp\":\"thing\"}", gson.toJson(data));
    }
}
baurceanu commented 4 years ago

@TWiStErRob hi, try this

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import com.google.gson.*;

class OptionalTest {

    class Data {
        @JsonAdapter(value = JsonNullableTypeAdapterFactory.class, nullSafe = false)
    String nullProp;
    String optionalProp;
    }

    private Gson gson;

    @BeforeEach void setUp() {
        gson = new GsonBuilder().create();
    }

    ... // your tests

    private static class JsonNullableTypeAdapterFactory implements TypeAdapterFactory {
        public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
            final TypeAdapter<T> delegate = gson.getAdapter(type);
            return new TypeAdapter<T>() {
                @Override
                public void write(JsonWriter out, T value) throws IOException {
                    boolean serializeNulls = out.getSerializeNulls();
                    if (value == null && !serializeNulls) {
                        out.setSerializeNulls(true);
                    }
                    try {
                        delegate.write(out, value);
                    } finally {
                        out.setSerializeNulls(serializeNulls);
                    }
                }

                @Override
                public T read(JsonReader in) throws IOException {
                    return delegate.read(in);
                }
            };
        }
    }
}
TWiStErRob commented 4 years ago

Awesome @baurchanu, thank you very much! Now I understand what nullSafe means and its docs :) So neat and kind of simple solution in retrospect.


I flipped it around, because optionals are less frequent:


class OptionalTest {

    class Data {
        String nullProp;
        @JsonAdapter(value = JsonOptionalTypeAdapterFactory.class, nullSafe = false)
        String optionalProp;
    }

    @BeforeEach void setUp() {
        gson = new GsonBuilder()
                .serializeNulls()
                .create();
    }
}
/**
 * Usage:
 * <pre><code>
 *    {@code @}JsonAdapter(value = JsonOptionalTypeAdapterFactory.class, nullSafe = false)
 *     SomeType fieldThatShouldNotAppearInOutputWhenItIsNull;
 * </code></pre>
 * <p>
 * Works the same way regardless of {@link GsonBuilder#serializeNulls()} setup.
 * <p>
 * Note: disabling {@link JsonAdapter#nullSafe()} allows us to decide what to do with null values.
 */
public class JsonOptionalTypeAdapterFactory implements TypeAdapterFactory {
    public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
        final TypeAdapter<T> delegate = gson.getAdapter(type);
        return new TypeAdapter<T>() {
            @Override
            public void write(JsonWriter out, T value) throws IOException {
                boolean originalSerializeNulls = out.getSerializeNulls();
                // Only change behavior if it's null, otherwise it'll affect nested complex type's serialization.
                if (value == null) {
                    // Make com.google.gson.stream.JsonWriter.nullValue skip the deferred name if null value.
                    out.setSerializeNulls(false);
                }
                try {
                    delegate.write(out, value);
                } finally {
                    // Restore original behavior for the rest of the data.
                    out.setSerializeNulls(originalSerializeNulls);
                }
            }

            @Override
            public T read(JsonReader in) throws IOException {
                return delegate.read(in);
            }
        };
    }
}