Code Monkey home page Code Monkey logo

jackson-module-model-versioning's Introduction

Jackson Model Versioning Module

Jackson 2.x module for handling versioning of models.

The Problem

Let's say we create an API that accepts the following car data JSON:

{
  "model": "honda:civic",
  "year": 2016,
  "new": "true"
}

Later, we decide that model should be split into two fields (make and model).

{
  "make": "honda",
  "model": "civic",
  "year": 2016,
  "new": "true"
}

Then we decide that new should be actually be retyped and renamed (boolean used).

{
  "make": "honda",
  "model": "civic",
  "year": 2016,
  "used": false
}

By this point, we have three formats of data that clients might be sending to or requesting from our API. Hopefully we had the foresight to implement versioning on the models or API call's parameters.

There are at least a few ways to handle versioning of data for on an API. A decently clean way is to create new classes for each version of the model and use API parameters (ex. HTTP URL or headers) to specify the version of the data. Then write some custom logic to inform the framework of which class that version of the data should bind to.

POST /api/car/v1/     <-  CarV1
GET  /api/car/v1/     ->  List<CarV1>
GET  /api/car/v1/{id} ->  CarV1

POST /api/car/v2/     <-  CarV2
GET  /api/car/v2/     ->  List<CarV2>
GET  /api/car/v2/{id} ->  CarV2
...

But what if there was a way that only required a single model class that could be annotated to control conversion between versions of the raw data before deserialization and after serialization? That is what this Jackson module provides.

Diagram

Diagram

Examples

Note: All examples are using Groovy for brevity, but it is not required.

Basic Usage

Create a model for the newest version of the data in the example above. Annotate the model with the current version and specify what converter class to use when deserializing from a potentially old version of the model to the current version.

@JsonVersionedModel(currentVersion = '3', toCurrentConverterClass = ToCurrentCarConverter)
class Car {
    String make
    String model
    int year
    boolean used
}

Create the "up" converter and provide logic for how old versions should be converted to the current version.

class ToCurrentCarConverter implements VersionedModelConverter {
    @Override
    def ObjectNode convert(ObjectNode modelData, String modelVersion,
                           String targetModelVersion, JsonNodeFactory nodeFactory) {

        // model version is an int
        def version = modelVersion as int

        // version 1 had a single 'model' field that combined 'make' and 'model' with a colon delimiter
        if(version <= 1) {
            def makeAndModel = modelData.get('model').asText().split(':')
            modelData.put('make', makeAndModel[0])
            modelData.put('model', makeAndModel[1])
        }

        // version 1-2 had a 'new' text field instead of a boolean 'used' field
        if(version <= 2)
            modelData.put('used', !Boolean.parseBoolean(modelData.remove('new').asText()))
    }
}

All that's left is to configure the Jackson ObjectMapper with the module and test it out.

def mapper = new ObjectMapper().registerModule(new VersioningModule())

// version 1 JSON -> POJO
def hondaCivic = mapper.readValue(
    '{"model": "honda:civic", "year": 2016, "new": "true", "modelVersion": "1"}',
    Car
)

// POJO -> version 3 JSON
println mapper.writeValueAsString(hondaCivic)
// prints '{"make": "honda", "model": "civic", "year": 2016, "used": false, "modelVersion": "3"}'

Serializing To A Specific Version

Modify the model in the prvious example to also specify what converter class to use when serializing from the current version to a potentially old version of the model. Also add a field (can be a method) that returns the version that the model to be serialized to.

@JsonVersionedModel(currentVersion = '3',
                    toCurrentConverterClass = ToCurrentCarConverter,
                    toPastConverterClass = ToPastCarConverter)
class Car {
    String make
    String model
    int year
    boolean used

    @JsonSerializeToVersion
    String serializeToVersion
}

Create the "down" converter and provide logic for how the current version should be converted to an old version.

class ToPastCarConverter implements VersionedModelConverter {

    @Override
    def ObjectNode convert(ObjectNode modelData, String modelVersion,
                           String targetModelVersion, JsonNodeFactory nodeFactory) {

        // model version is an int
        def version = modelVersion as int
        def targetVersion = targetModelVersion as int

        // version 1 had a single 'model' field that combined 'make' and 'model' with a colon delimiter
        if(targetVersion <= 1 && version > 1)
            modelData.put('model', "${modelData.remove('make').asText()}:${modelData.get('model').asText()}")

        // version 1-2 had a 'new' text field instead of a boolean 'used' field
        if(targetVersion <= 2 && version > 2)
            modelData.put('new', !modelData.remove('used').asBoolean() as String)
    }
}

Make a slight modification to the test code from the previous example to set the serializeToVersion field.

def mapper = new ObjectMapper().registerModule(new VersioningModule())

// version 1 JSON -> POJO
def hondaCivic = mapper.readValue(
    '{"model": "honda:civic", "year": 2016, "new": "true", "modelVersion": "1"}',
    Car
)

// set the new serializeToVersion field to '2'
hondaCivic.serializeToVersion = '2'

// POJO -> version 2 JSON
println mapper.writeValueAsString(hondaCivic)
// prints '{"make": "honda", "model": "civic", "year": 2016, "new": "true", "modelVersion": "2"}'

Serializing to the Source Model's Version

Using the defaultToSource flag on @JsonSerializeToVersion will set the specified serializeToVersion property to the original model's version. This is useful when wanting to deserialize a model and reserialize in the same version format.

@JsonVersionedModel(currentVersion = '3',
                    toCurrentConverterClass = ToCurrentCarConverter,
                    toPastConverterClass = ToPastCarConverter)
class Car {
    String make
    String model
    int year
    boolean used

    @JsonSerializeToVersion(defaultToSource = true)
    String serializeToVersion
}

Running the same test code from above now yields a different result where the serialized output model's version matches the deserialized input model's version:

def mapper = new ObjectMapper().registerModule(new VersioningModule())

// version 1 JSON -> POJO
def hondaCivic = mapper.readValue(
    '{"model": "honda:civic", "year": 2016, "new": "true", "modelVersion": "1"}',
    Car
)

// POJO -> version 1 JSON
println mapper.writeValueAsString(hondaCivic)
// prints '{"model": "honda:civic", "year": 2016, "new": "true", "modelVersion": "1"}'

More Examples

See the tests under src/test/groovy for more.

Compatibility

  • Requires Java 6 or higher
  • Requires Jackson 2.2 or higher (tested with Jackson 2.2 - 2.8).

Getting Started with Gradle

dependencies {
    compile 'com.github.jonpeterson:jackson-module-model-versioning:1.2.2'
}

Getting Started with Maven

<dependency>
    <groupId>com.github.jonpeterson</groupId>
    <artifactId>jackson-module-model-versioning</artifactId>
    <version>1.2.2</version>
</dependency>

jackson-module-model-versioning's People

Contributors

brentryan avatar jonpeterson avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

jackson-module-model-versioning's Issues

Allow stateful converters

Say for instance that you change your API by replacing a nested type with a reference. Then in your "pastConverter" you would probably need a database lookup. That would be easier to achieve if the converters could be stateful instead of being created new each time, like they are now.

Example:
Say your API contains a Person type:

Person {
   socialSecurityNumber [String],
   firstName [String],
   lastName [String],
   address: [Address]
}

and you remove the address field and replace it with addressId. Then to be backwards compatible you would need to lookup the address from your database.

It looks like it could be achieved by allowing the VersioningModule-class to accept a list of converter instances or some kind of ConverterRepository.

how to use custom serializer AND retain model version

I have a versioned model class which has a field that i would like to use a custom serializer on:

@JsonVersionedModel(currentVersion = "1", toCurrentConverterClass = ToCurrentLoanPolicyOverrideConverter.class)
public class LoanPolicyOverride extends LaserData {

    @JsonSerialize(using = PolicyIdSerializer.class)
    private Policy policy;

}

But when I do the modelVersion attribute is lost in the serialized json.

Here the serializer should only outut the id of the Policy object, i have tried two approaches for the serializer:

  1. Simple approach like this:
public void serialize(
        Policy value, JsonGenerator jgen, SerializerProvider provider)
        throws IOException, JsonProcessingException {

    jgen.writeStartObject();
    jgen.writeStringField("id", value.getId());
    jgen.writeEndObject();
}
  1. approach that modifies the object so only the id field is set:
public void serialize(
        Policy value, JsonGenerator jgen, SerializerProvider provider)
        throws IOException, JsonProcessingException {
    if (value == null) {
        jgen.writeNull();
    } else {
        Policy simple = Policy.builder(value.getClassification().getClassificationCode().getCode()).id(value.getId()).build();
        BeanSerializerFactory.instance.createSerializer(provider, SimpleType.construct(Policy.class)).serialize(simple, jgen, provider);
    }
}

Any ideas on how to use a custom serializer AND keep the model version?

deserializers break when using with immutables

immutables/immutables#461

@jonpeterson I'm trying to hunt this issue down and I'm stumped a bit.

Let me know if you have any ideas.

@Immutable
@JsonSerialize(as = ImmutableEntity.class)
@JsonDeserialize(as = ImmutableEntity.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonVersionedModel(
        currentVersion = "1",
        defaultDeserializeToVersion = "0",
        versionToSuppressPropertySerialization = "0",
        toCurrentConverterClass = EntityCurrentConverter.class,
        toPastConverterClass = EntityPastConverter.class
)
@Value.Style(additionalJsonAnnotations = {JsonVersionedModel.class})
public interface Entity {

    int MIN_LEN_ANOTHER_FIELD = 2;
    int MAX_LEN_ANOTHER_FIELD = 10;

    @JsonSerializeToVersion(defaultToSource = true)
    @Nullable
    String getSerializeToVersion();

    /**
     * @return The field
     */
    String field();

    /**
     * @return another field
     */
    @Size(min = MIN_LEN_ANOTHER_FIELD, max = MAX_LEN_ANOTHER_FIELD)
    String getAnotherField();
}

Essentially using jackson to deserialize a string like:

        ObjectMapper m = new ObjectMapper();
        m.registerModule(new VersioningModule());

        String json
                = "{\"field\":\"f\",\"anotherField\":\"a\",\"modelVersion\":\"1\"}";

        Entity pe = m.readValue(json, Entity.class);

Winds up causing issues because VersionedModelDeserializer.deserialize() gets called twice and the modelVersion is removed in the 2nd pass making it seem as if the modelVersion should be "0" when it should be "1".

Thoughts?

Got exception when use @JsonIdentityInfo

com.fasterxml.jackson.databind.JsonMappingException: [no message for java.lang.NullPointerException]

at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:303)
at com.fasterxml.jackson.databind.ObjectMapper._configAndWriteValue(ObjectMapper.java:3681)
at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(ObjectMapper.java:3057)
at org.redcross.rco.account.persistence.util.CurrentVersionModelConverterFactoryTest.convert(CurrentVersionModelConverterFactoryTest.java:24)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)

Caused by: java.lang.NullPointerException
at com.fasterxml.jackson.databind.ser.impl.WritableObjectId.writeAsField(WritableObjectId.java:74)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase._serializeObjectId(BeanSerializerBase.java:648)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase._serializeWithObjectId(BeanSerializerBase.java:635)
at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeWithType(BeanSerializerBase.java:567)
at com.github.jonpeterson.jackson.module.versioning.VersionedModelSerializer.doSerialize(VersionedModelSerializer.java:88)
at com.github.jonpeterson.jackson.module.versioning.VersionedModelSerializer.serializeWithType(VersionedModelSerializer.java:77)
at com.fasterxml.jackson.databind.ser.impl.TypeWrappedSerializer.serialize(TypeWrappedSerializer.java:32)
at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:292)
... 25 more

VersioningModule clashes with other modules' settings

Hi,
Not sure if this library is maintained.
But anyway for documentation purposes, I've encountered the following issue:

    public static class NoopVersionedModelConverter implements VersionedModelConverter {

        @Override
        public ObjectNode convert(ObjectNode modelData,
                                  String modelVersion,
                                  String targetModelVersion,
                                  JsonNodeFactory nodeFactory) {
            return modelData;
        }
    }

    @JsonVersionedModel(currentVersion = "1", toPastConverterClass = NoopVersionedModelConverter.class)
    private static class Versioned {

        private OffsetDateTime offsetDateTime = null;

        public Versioned() {
        }

        public OffsetDateTime getOffsetDateTime() {
            return offsetDateTime;
        }

        public void setOffsetDateTime(OffsetDateTime offsetDateTime) {
            this.offsetDateTime = offsetDateTime;
        }
    }

    @Test
    public void test() throws JsonProcessingException {
        JavaTimeModule javaTimeModule = new JavaTimeModule();
        VersioningModule versioningModule = new VersioningModule();
        ObjectMapper objectMapper = new ObjectMapper().registerModule(javaTimeModule).registerModule(versioningModule);
        OffsetDateTime original = OffsetDateTime.of(2022, 8, 9, 10, 10, 10, 10, ZoneOffset.UTC);
        Versioned versioned = new Versioned();
        versioned.setOffsetDateTime(original);
        String json = objectMapper.writeValueAsString(versioned);
        Versioned after = objectMapper.readValue(json, Versioned.class);
        Assertions.assertEquals(original, after.getOffsetDateTime()); // fails
    }
org.opentest4j.AssertionFailedError: 
Expected :2022-08-09T10:10:10.000000010Z
Actual   :2022-08-09T10:10:10Z

The issue seems to be in VersionedModelSerializer line 95:

ObjectNode modelData = factory.createParser(buffer.toByteArray()).readValueAsTree();

The buffer passed to createParser contains the nano-time but the resulting ObjectNode holds the offsetDateTime with E notation thereby losing the nano value. When not the VersioningModule is not registered on the ObjectMapper, the OffsetDateTime object is (de)serialized correctly.

How to prevent modelVersion parameter in response?

@JsonVersionedModel(propertyName = "modelVersion")
private String modelVersion;

How can I prevent the model version field to be written into the response as json field? As of the sourcecode this is not supported yet, right?

I tried adding @JsonIgnore to the field, but that destroys the module converter...

Migration from JsonSubTypes with empty super type fails

Given the types MySuperDto, MySubDto, MyEmptySubDto, see below.
For version 1 the super type added a the field superField, but was empty at version 0.

Reading this Json, at version 0, with a reader for the super type MySuperDto

{
   "type" : "MyEmptySubDto"
}

will fail throwing

com.fasterxml.jackson.databind.JsonMappingException: value must be a JSON object
 at [Source: (String)"{
  "type" : "MyEmptySubDto"}"; line: 2, column: 27]

	at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:274)
	at com.fasterxml.jackson.databind.DeserializationContext.mappingException(DeserializationContext.java:1844)
	at com.github.jonpeterson.jackson.module.versioning.VersionedModelDeserializer.deserialize(VersionedModelDeserializer.java:75)
	at com.fasterxml.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer._deserializeTypedForId(AsPropertyTypeDeserializer.java:130)
	at com.fasterxml.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer.deserializeTypedFromObject(AsPropertyTypeDeserializer.java:97)
	at com.fasterxml.jackson.databind.deser.AbstractDeserializer.deserializeWithType(AbstractDeserializer.java:254)
	at com.fasterxml.jackson.databind.deser.impl.TypeWrappedDeserializer.deserialize(TypeWrappedDeserializer.java:68)
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4218)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3214)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3182)

Details on sub types

    @JsonTypeInfo(
            use = JsonTypeInfo.Id.NAME,
            property = "type"
    )
    @JsonSubTypes({
            @JsonSubTypes.Type(value = MySubDto.class, name = "MySubDto"),
            @JsonSubTypes.Type(value = MyEmptySubDto.class, name = "MyEmptySubDto")
    })
    @JsonVersionedModel(
            currentVersion = "1",
            defaultDeserializeToVersion = "0",
            toCurrentConverterClass = ToCurrentMySuperDto.class)
    static abstract class MySuperDto {

        @JsonProperty(required = true)
        public String superField;

        @JsonCreator
        public MySuperDto(
                @JsonProperty(required = true, value = "superField") String superField
        ) {
            this.superField = superField;
        }
    }

    static class MySubDto extends MySuperDto {
        @JsonProperty
        String subField;

        @JsonCreator
        public MySubDto(
                @JsonProperty(required = true, value = "superField") String superField,
                @JsonProperty(required = true, value = "subField") String subField
        ) {
            super(superField);
            this.subField = subField;
        }
    }

    static class MyEmptySubDto extends MySuperDto {
        @JsonCreator
        public MyEmptySubDto(
                @JsonProperty(required = true, value = "superField") String superField
        ) {
            super(superField);
        }
    }

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.