Jackson3 support with opt-in jackson2 support#2299
Conversation
Follow the Spring Boot 4 pattern: Jackson 3 is the default for DGS, and Jackson 2 is available as an opt-in via the graphql-dgs-jackson2 module. Existing Jackson 2 code continues to work without changes. Core changes: - DgsJsonMapper interface abstracts Jackson version from DGS internals - Jackson3DgsJsonMapper: default implementation using Jackson 3 - graphql-dgs-jackson2: new module with Jackson2DgsJsonMapper and DgsJackson2AutoConfiguration (activates when module is on classpath) - BaseDgsQueryExecutor.objectMapper/.parseContext: kept as deprecated lazy vals for backward compat (HIDDEN from new code, helpful error if Jackson 2 absent at runtime) - json-path upgraded to 3.0.0 for Jackson 3 provider support Client changes (zero binary breaks): - GraphQLClientResponse interface extracted from GraphQLResponse - New Jackson 3 client classes: Jackson3CustomGraphQLClient, Jackson3RestClientGraphQLClient, Jackson3WebClientGraphQLClient, Jackson3CustomMonoGraphQLClient, Jackson3GraphQLResponse - Existing Jackson 2 client classes deprecated (not removed/renamed) - Jackson 3 clients fully decoupled from Jackson 2 class loading Autoconfiguration: - graphql-dgs-jackson2 on classpath -> Jackson 2 used (no config needed) - graphql-dgs-jackson2 absent -> Jackson 3 used - dgs.graphql.preferred-json-mapper=jackson3 overrides when jackson2 module is present - Test slice support via EnableDgsTest/EnableDgsMockMvcTest .imports Example projects prove both directions work in isolation: - graphql-dgs-example-jackson3-only: no Jackson 2 excludes needed - graphql-dgs-example-jackson2-only: excludes Jackson 3, works standalone Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sjohnr
left a comment
There was a problem hiding this comment.
@jjacobs44, this is great work to get to this point! I have some comments below, but the most important thing (worth discussing if it's not clear from my comments) is around GraphQLClient. See comments on Jackson3RestClientGraphQLClient.
To expand more generally, I think a breaking change of some kind is needed to actually smooth out migration, as well as keeping the framework healthy. I would be in favor of a minor (or major) release to separately introduce the new GraphQLClientResponse interface. You could then also introduce a small breaking change to return that type from non-deprecated GraphQLClient methods, and also remove the deprecated methods (or increase deprecation level to error). Maybe some other cleanup and deprecations could be done as needed to provide visibility for other coming changes.
Afterwards, this PR could be part of a follow-up major release to switch over the default to Jackson 3 and introduce this support with the ability to fall-back to Jackson 2. Any additional necessary breaking changes would then be on the table, but would be much smoother for users since the groundwork is laid in the previous release.
| * dgs.graphql.preferred-json-mapper=jackson3 | ||
| */ | ||
| @AutoConfiguration( | ||
| beforeName = ["com.netflix.graphql.dgs.springgraphql.autoconfig.DgsSpringGraphQLAutoConfiguration"], |
There was a problem hiding this comment.
Could a compileOnly dep on graphql-dgs-spring-graphql allow the non-string reference? I am thinking it's not at risk of cycles since the jackson2 module is an opt-in?
dcf5aef to
e459502
Compare
|
I'm really on the fence about what to do with the client interfaces. However, if we "fix" this now, we're introducing a breaking change, and there is a lot of use of these interfaces and classes. One alternative idea, could we introduce the new stuff in a new separate module, so we can make this depend on the classspath? |
| message = "Use DgsJsonMapper instead. This field requires Jackson 2 on the classpath.", | ||
| level = DeprecationLevel.HIDDEN, | ||
| ) | ||
| val objectMapper: com.fasterxml.jackson.databind.ObjectMapper by lazy { jackson2ObjectMapper } |
There was a problem hiding this comment.
It looks like this is unused?
There was a problem hiding this comment.
Removed all usages in the framework but it's a public api so removing it is technically breaking (some people are calling BaseDgsQueryExecutor.objectMapper). Very few internal usages though so I could remove it and proactively move users off of it.
paulbakker
left a comment
There was a problem hiding this comment.
Approach looks good, few minor things I noticed in the code
| * Jackson-agnostic blocking GraphQL client contract. Implemented by the new `Dgs*` client | ||
| * classes and (for back-compat) by the deprecated [GraphQLClient]. | ||
| */ | ||
| interface DgsGraphQLClient { |
There was a problem hiding this comment.
Feel free to push back on this, but since we have an opportunity to start new, I'm wondering if these interfaces are doing too much. Rather than all of these overloads that every client has to implement we have one method.
interface DgsGraphQLClient {
fun executeQuery(request: DgsGraphQLRequest): DgsGraphQLResponse
}
Where DgsGraphQLRequest represents the request and is not a function of the client itself and uses a builder pattern for all the required and optional params which is a cleaner API contract for future changes.
Likewise, we can trim the DgsGraphQLResponse to stable caller contracts
interface DgsGraphQLResponse {
val errors: List<GraphQLError>
fun hasErrors(): Boolean
fun <T> dataAsObject(clazz: Class<T>): T
fun <T> extractValue(path: String): T
fun <T> extractValueAsObject(path: String, clazz: Class<T>): T
fun <T> extractValueAsObject(path: String, typeRef: TypeRef<T>): T
}
The other response fields move to DefaultDgsGraphQLResponse
Where this might be more challenging is in migrations. :(
There was a problem hiding this comment.
I'm on the fence with this so wanna hear others opinions. client.executeQuery("{ foo }"); becoming client.executeQuery(DgsGraphQLRequest.builder().query("{ foo }").build()); is quite a ergonomics downgrade for users imo, but I agree with the general point you're making about the complexity of the overloads not being ideal. Same thing for your comment below.
There was a problem hiding this comment.
The academic angle here is if builders become too much boilerplate and telescoping constructors reek of code smell, we have an API that's simply too flexible.
The decision then becomes refactoring. Use more static factory methods, focus only on required constructor params and moving optionals to configuration setters.
There was a problem hiding this comment.
We don't need to answer all of these questions in this major version, but I think we'll eventually have to break away from this pattern as client functionality grows.
| ) : DgsMonoGraphQLClient { | ||
| constructor(webclient: WebClient) : this(webclient, Consumer {}) | ||
|
|
||
| constructor(webclient: WebClient, headersConsumer: Consumer<HttpHeaders>) : |
There was a problem hiding this comment.
General nit: This (and other client variants) carries forward the previous multi-constructor overloads of the old clients. Consider if a builder pattern would be cleaner here to prevent a future 6th or 7th constructor.
Similar to my previous comment though can make migrations a bit busier.
sjohnr
left a comment
There was a problem hiding this comment.
This looks great @jjacobs44! Only thing I am thinking about is surface area of the API. For changes like this in OSS, I like to look for opportunities to reduce visibility, and open it up later if/when users see a need for accessing the classes and provide that feedback.
You might consider making some/all of the following classes internal:
DgsJsonMapperMissingException→ internal — zero risk, only thrown inside the auto-configJackson3DgsJsonMapperAdapter→ internal — no external instantiation found; client wiring detailJackson2DgsJsonMapper→ internal — only constructed within graphql-dgs-jackson2Jackson3DgsJsonMapperConfigurationinner class → internal open class — Spring auto-config inner classDefaultDgsGraphQLResponse→ internal (judgment call) — its KDoc invites users to construct it for tests; either commit to that or hide it
There are a few others where it would be nice to hide (e.g. Jackson2DgsJsonMapperAdapter, DgsJackson2AutoConfiguration, Jackson3DgsJsonMapper) but I believe these are needed/referenced in other places and can't be reduced.
|
|
||
| package com.netflix.graphql.dgs.diagnostics | ||
|
|
||
| class DgsJsonMapperMissingException : RuntimeException() |
There was a problem hiding this comment.
This exception has no message. It might not produce very nice details for DX? Since you have a failure analyzer, might be a moot point but still probably would be nice to include a message.
Summary
Following the precedent that OSS Spring Boot 4 set, these changes make Jackson 3 the default that's autoconfigured for the framework to use and Jackson 2 remains available as an opt-in via the
graphql-dgs-jackson2module.Core changes
DgsJsonMapperinterface abstracts Jackson version from DGS internalsJackson3DgsJsonMapper: default implementation using Jackson 3graphql-dgs-jackson2: new module withJackson2DgsJsonMapperandDgsJackson2AutoConfigurationBaseDgsQueryExecutor.objectMapper/.parseContext: kept as deprecated lazy vals for backward compatibility (HIDDENfrom new code, helpful error if Jackson 2 absent at runtime). I could also just remove them, it would be breaking but I don't think there's many usagesClient changes
DgsGraphQLResponseinterface extracted fromGraphQLResponseDgsCustomGraphQLClient,DgsRestClientGraphQLClient,DgsWebClientGraphQLClient,DgsCustomMonoGraphQLClient, etc..Example projects
graphql-dgs-example-jackson3-only| OSS default works, Jackson 2 clients fail to loadgraphql-dgs-example-jackson2-only| Jackson 2 opt-in works, Jackson 3 clients fail to loadgraphql-dgs-example-jackson-both| Mixed classpath works, Jackson 2 wins autoconfig unless configuration specifies otherwise, both client sets usableWhy New Classes/Interfaces for the Client Module?
In the client module we have multiple cases where ObjectMapper is exposed in the public api (constructor). I evaluated a couple approaches for how to support jackson3 in this case. After discussing internally option 3 was picked for the best overall migration story for libraries
Approach 1: Constructor Overloads
Add
JsonMapper(Jackson 3) overloads alongside existingObjectMapperconstructors on the same class.This is very cleanly backwards compatible giving people a clean migration path, but requires both jackson2 and 3 to be on the consumers compile classpath. The whole point of this effort is to unblock people from having to have jackson2 on any part of their classpath if they'd like to embrace jackson3.
Approach 2: Change Primary Constructor to Abstraction
Change the clients' primary constructor from
ObjectMapperto a version-agnosticClientMappersealed interface.This would move us away from exposing jackson directly in the api which has it's advantages long term. It's ultimately a breaking change for everyone constructing clients today with
ObjectMapperwhich we'd like to avoid.Approach 3: New Classes w/ Abstraction in Primary Constructors (what this pr implements)
Similar to Approach 2 but putting these into new classes avoids the breaking aspect of the change. We will backport the abstractions to give libraries on older versions the opportunity to migrate before fully removing the old versions of the classes.
We admit the naming is confusing in the interim period, but we deem it worth it and hope the deprecation warnings/kdoc will be informative enough.