From f06c780fea2ba15dffe6acb37bdfd35a95faa018 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Sun, 25 Jan 2026 04:34:20 +0100 Subject: [PATCH] Move to Jackson 3 - Phase 2 Design GitHubJackson interface with methods for reading/writing JSON Implement GitHubJackson2 and GitHubJackson3 Create DefaultGitHubJackson factory with programmatic selection Create GitHubJacksonException wrapper classes Add GitHubBuilder.withJackson() for configuring Jackson implementation Add testing infrastructure for both Jackson versions --- pom.xml | 17 ++ src/main/java/org/kohsuke/github/GitHub.java | 9 +- .../org/kohsuke/github/GitHubBuilder.java | 39 +++- .../java/org/kohsuke/github/GitHubClient.java | 163 +++++++------ .../org/kohsuke/github/GitHubResponse.java | 4 - .../github/internal/DefaultGitHubJackson.java | 97 ++++++++ .../github/internal/GitHubJackson.java | 134 +++++++++++ .../github/internal/GitHubJackson2.java | 194 ++++++++++++++++ .../github/internal/GitHubJackson3.java | 218 ++++++++++++++++++ .../internal/GitHubJacksonException.java | 50 ++++ .../org/kohsuke/github/GitHubJacksonTest.java | 63 +++++ .../internal/DefaultGitHubJacksonTest.java | 62 +++++ .../no-reflect-and-serialization-list | 5 + 13 files changed, 980 insertions(+), 75 deletions(-) create mode 100644 src/main/java/org/kohsuke/github/internal/DefaultGitHubJackson.java create mode 100644 src/main/java/org/kohsuke/github/internal/GitHubJackson.java create mode 100644 src/main/java/org/kohsuke/github/internal/GitHubJackson2.java create mode 100644 src/main/java/org/kohsuke/github/internal/GitHubJackson3.java create mode 100644 src/main/java/org/kohsuke/github/internal/GitHubJacksonException.java create mode 100644 src/test/java/org/kohsuke/github/GitHubJacksonTest.java create mode 100644 src/test/java/org/kohsuke/github/internal/DefaultGitHubJacksonTest.java diff --git a/pom.xml b/pom.xml index e1fbffe519..9bf7939db3 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,7 @@ + com.fasterxml.jackson jackson-bom @@ -114,6 +115,14 @@ pom import + + + tools.jackson + jackson-bom + 3.0.4 + pom + import + junit junit @@ -138,6 +147,7 @@ + com.fasterxml.jackson.core jackson-databind @@ -196,6 +206,13 @@ commons-lang3 3.19.0 + + + + tools.jackson.core + jackson-databind + true + com.github.npathai hamcrest-optional diff --git a/src/main/java/org/kohsuke/github/GitHub.java b/src/main/java/org/kohsuke/github/GitHub.java index 9204660a92..69da63e1fe 100644 --- a/src/main/java/org/kohsuke/github/GitHub.java +++ b/src/main/java/org/kohsuke/github/GitHub.java @@ -30,6 +30,7 @@ import org.kohsuke.github.authorization.ImmutableAuthorizationProvider; import org.kohsuke.github.authorization.UserAuthorizationProvider; import org.kohsuke.github.connector.GitHubConnector; +import org.kohsuke.github.internal.GitHubJackson; import java.io.*; import java.util.*; @@ -379,6 +380,8 @@ private GitHub(GitHubClient client) { * rateLimitChecker * @param authorizationProvider * a authorization provider + * @param jackson + * the Jackson implementation to use for JSON serialization * @throws IOException * Signals that an I/O exception has occurred. */ @@ -388,7 +391,8 @@ private GitHub(GitHubClient client) { GitHubRateLimitHandler rateLimitHandler, GitHubAbuseLimitHandler abuseLimitHandler, GitHubRateLimitChecker rateLimitChecker, - AuthorizationProvider authorizationProvider) throws IOException { + AuthorizationProvider authorizationProvider, + GitHubJackson jackson) throws IOException { if (authorizationProvider instanceof DependentAuthorizationProvider) { ((DependentAuthorizationProvider) authorizationProvider).bind(this); } else if (authorizationProvider instanceof ImmutableAuthorizationProvider @@ -408,7 +412,8 @@ private GitHub(GitHubClient client) { rateLimitHandler, abuseLimitHandler, rateLimitChecker, - authorizationProvider); + authorizationProvider, + jackson); // Ensure we have the login if it is available // This preserves previously existing behavior. Consider removing in future. diff --git a/src/main/java/org/kohsuke/github/GitHubBuilder.java b/src/main/java/org/kohsuke/github/GitHubBuilder.java index 3f762fd059..02b2e796a3 100644 --- a/src/main/java/org/kohsuke/github/GitHubBuilder.java +++ b/src/main/java/org/kohsuke/github/GitHubBuilder.java @@ -5,6 +5,8 @@ import org.kohsuke.github.authorization.ImmutableAuthorizationProvider; import org.kohsuke.github.connector.GitHubConnector; import org.kohsuke.github.connector.GitHubConnectorResponse; +import org.kohsuke.github.internal.DefaultGitHubJackson; +import org.kohsuke.github.internal.GitHubJackson; import java.io.File; import java.io.FileInputStream; @@ -159,6 +161,8 @@ static GitHubBuilder fromCredentials() throws IOException { private GitHubConnector connector; + private GitHubJackson jackson; + private GitHubRateLimitChecker rateLimitChecker = new GitHubRateLimitChecker(); private GitHubRateLimitHandler rateLimitHandler = GitHubRateLimitHandler.WAIT; @@ -189,7 +193,8 @@ public GitHub build() throws IOException { rateLimitHandler, abuseLimitHandler, rateLimitChecker, - authorizationProvider); + authorizationProvider, + jackson != null ? jackson : DefaultGitHubJackson.createDefault()); } /** @@ -277,6 +282,38 @@ public GitHubBuilder withEndpoint(String endpoint) { return this; } + /** + * Configures which Jackson implementation to use for JSON serialization/deserialization. + * + *

+ * By default, Jackson 2.x is used. To use Jackson 3.x, create a Jackson 3 instance using + * {@link DefaultGitHubJackson#createJackson3()} and pass it to this method. + *

+ * + *

Example: Using Jackson 3.x

+ * + *
+     * GitHub github = new GitHubBuilder().withOAuthToken("token")
+     *         .withJackson(DefaultGitHubJackson.createJackson3())
+     *         .build();
+     * 
+ * + *

+ * Note: To use Jackson 3.x, you must add the Jackson 3 {@code tools.jackson.core:jackson-databind} + * dependency to your project. + *

+ * + * @param jackson + * the Jackson implementation to use + * @return the GitHubBuilder + * @see DefaultGitHubJackson#createJackson2() + * @see DefaultGitHubJackson#createJackson3() + */ + public GitHubBuilder withJackson(GitHubJackson jackson) { + this.jackson = jackson; + return this; + } + /** * With jwt token GitHubBuilder. * diff --git a/src/main/java/org/kohsuke/github/GitHubClient.java b/src/main/java/org/kohsuke/github/GitHubClient.java index 7963de57e2..573ebbba51 100644 --- a/src/main/java/org/kohsuke/github/GitHubClient.java +++ b/src/main/java/org/kohsuke/github/GitHubClient.java @@ -1,9 +1,8 @@ package org.kohsuke.github; -import com.fasterxml.jackson.databind.*; -import com.fasterxml.jackson.databind.introspect.VisibilityChecker; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.databind.InjectableValues; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; import org.apache.commons.io.IOUtils; import org.kohsuke.github.authorization.AuthorizationProvider; import org.kohsuke.github.authorization.UserAuthorizationProvider; @@ -11,6 +10,8 @@ import org.kohsuke.github.connector.GitHubConnectorRequest; import org.kohsuke.github.connector.GitHubConnectorResponse; import org.kohsuke.github.function.FunctionThrows; +import org.kohsuke.github.internal.GitHubJackson; +import org.kohsuke.github.internal.GitHubJackson2; import java.io.*; import java.net.*; @@ -25,8 +26,6 @@ import javax.annotation.CheckForNull; import javax.annotation.Nonnull; -import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; -import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; import static java.net.HttpURLConnection.HTTP_ACCEPTED; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; import static java.net.HttpURLConnection.HTTP_MOVED_PERM; @@ -109,14 +108,17 @@ static class RetryRequestException extends IOException { private static final int DEFAULT_MAXIMUM_RETRY_MILLIS = 100; /** The Constant DEFAULT_MINIMUM_RETRY_TIMEOUT_MILLIS. */ private static final int DEFAULT_MINIMUM_RETRY_MILLIS = DEFAULT_MAXIMUM_RETRY_MILLIS; + + /** + * Jackson 2.x specific implementation for backward compatibility with static methods. + *

+ * This is used by {@link #getMappingObjectReader(GitHubConnectorResponse)} and {@link #getMappingObjectWriter()} to + * maintain backward compatibility with code that expects Jackson 2.x ObjectReader/ObjectWriter. + *

+ */ + private static final GitHubJackson2 JACKSON2_STATIC = new GitHubJackson2(); + private static final Logger LOGGER = Logger.getLogger(GitHubClient.class.getName()); - private static final ObjectMapper MAPPER = JsonMapper.builder() - .addModule(new JavaTimeModule()) - .visibility(new VisibilityChecker.Std(NONE, NONE, NONE, NONE, ANY)) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) - .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) - .build(); private static final ThreadLocal sendRequestTraceId = new ThreadLocal<>(); @@ -251,41 +253,6 @@ private static void logRetryConnectionError(IOException e, URL url, int retries) } } - private static GitHubConnectorRequest prepareConnectorRequest(GitHubRequest request, - AuthorizationProvider authorizationProvider) throws IOException { - GitHubRequest.Builder builder = request.toBuilder(); - // if the authentication is needed but no credential is given, try it anyway (so that some calls - // that do work with anonymous access in the reduced form should still work.) - if (!request.allHeaders().containsKey("Authorization")) { - String authorization = authorizationProvider.getEncodedAuthorization(); - if (authorization != null) { - builder.setHeader("Authorization", authorization); - } - } - if (request.header("Accept") == null) { - builder.setHeader("Accept", "application/vnd.github+json"); - } - builder.setHeader("Accept-Encoding", "gzip"); - - builder.setHeader("X-GitHub-Api-Version", "2022-11-28"); - - if (request.hasBody()) { - if (request.body() != null) { - builder.contentType(defaultString(request.contentType(), "application/x-www-form-urlencoded")); - } else { - builder.contentType("application/json"); - Map json = new HashMap<>(); - for (GitHubRequest.Entry e : request.args()) { - json.put(e.key, e.value); - } - builder.with(new ByteArrayInputStream(getMappingObjectWriter().writeValueAsBytes(json))); - } - - } - - return builder.build(); - } - private static boolean shouldIgnoreBody(@Nonnull GitHubConnectorResponse connectorResponse) { if (connectorResponse.statusCode() == HTTP_NOT_MODIFIED) { // special case handling for 304 unmodified, as the content will be "" @@ -311,25 +278,39 @@ private static boolean shouldIgnoreBody(@Nonnull GitHubConnectorResponse connect /** * Helper for {@link #getMappingObjectReader(GitHubConnectorResponse)}. * + *

+ * Note: This method returns a Jackson 2.x {@link ObjectReader}. For Jackson 3.x compatibility, use + * the {@link GitHubJackson} abstraction instead. + *

+ * * @param root * the root GitHub object for this reader * @return an {@link ObjectReader} instance that can be further configured. */ @Nonnull static ObjectReader getMappingObjectReader(@Nonnull GitHub root) { - ObjectReader reader = getMappingObjectReader((GitHubConnectorResponse) null); - ((InjectableValues.Std) reader.getInjectableValues()).addValue(GitHub.class, root); - return reader; + Map injected = JACKSON2_STATIC.createInjectableValues(null); + JACKSON2_STATIC.addGitHubRoot(injected, root); + return JACKSON2_STATIC.getReader(injected); } /** * Gets an {@link ObjectReader}. * + *

* Members of {@link InjectableValues} must be present even if {@code null}, otherwise classes expecting those * values will fail to read. This differs from regular JSONProperties which provide defaults instead of failing. + *

* + *

* Having one spot to create readers and having it take all injectable values is not a great long term solution but * it is sufficient for this first cut. + *

+ * + *

+ * Note: This method returns a Jackson 2.x {@link ObjectReader}. For Jackson 3.x compatibility, use + * the {@link GitHubJackson} abstraction instead. + *

* * @param connectorResponse * the {@link GitHubConnectorResponse} to inject for this reader. @@ -338,31 +319,23 @@ static ObjectReader getMappingObjectReader(@Nonnull GitHub root) { */ @Nonnull static ObjectReader getMappingObjectReader(@CheckForNull GitHubConnectorResponse connectorResponse) { - Map injected = new HashMap<>(); - - // Required or many things break - injected.put(GitHubConnectorResponse.class.getName(), null); - injected.put(GitHub.class.getName(), null); - - if (connectorResponse != null) { - injected.put(GitHubConnectorResponse.class.getName(), connectorResponse); - GitHubConnectorRequest request = connectorResponse.request(); - // This is cheating, but it is an acceptable cheat for now. - if (request instanceof GitHubRequest) { - injected.putAll(((GitHubRequest) connectorResponse.request()).injectedMappingValues()); - } - } - return MAPPER.reader(new InjectableValues.Std(injected)); + Map injected = JACKSON2_STATIC.createInjectableValues(connectorResponse); + return JACKSON2_STATIC.getReader(injected); } /** * Gets an {@link ObjectWriter}. * + *

+ * Note: This method returns a Jackson 2.x {@link ObjectWriter}. For Jackson 3.x compatibility, use + * the {@link GitHubJackson} abstraction instead. + *

+ * * @return an {@link ObjectWriter} instance that can be further configured. */ @Nonnull static ObjectWriter getMappingObjectWriter() { - return MAPPER.writer(); + return JACKSON2_STATIC.getWriter(); } /** @@ -461,6 +434,11 @@ static Map unmodifiableMapOrNull(Map map) private GitHubConnector connector; + /** + * The Jackson implementation used for JSON serialization/deserialization. + */ + private final GitHubJackson jackson; + @Nonnull private final AtomicReference rateLimit = new AtomicReference<>(GHRateLimit.DEFAULT); @@ -489,13 +467,16 @@ static Map unmodifiableMapOrNull(Map map) * the rate limit checker * @param authorizationProvider * the authorization provider + * @param jackson + * the Jackson implementation to use for JSON serialization */ GitHubClient(String apiUrl, GitHubConnector connector, GitHubRateLimitHandler rateLimitHandler, GitHubAbuseLimitHandler abuseLimitHandler, GitHubRateLimitChecker rateLimitChecker, - AuthorizationProvider authorizationProvider) { + AuthorizationProvider authorizationProvider, + GitHubJackson jackson) { if (apiUrl.endsWith("/")) { apiUrl = apiUrl.substring(0, apiUrl.length() - 1); // normalize @@ -513,6 +494,7 @@ static Map unmodifiableMapOrNull(Map map) this.rateLimitHandler = rateLimitHandler; this.abuseLimitHandler = abuseLimitHandler; this.rateLimitChecker = rateLimitChecker; + this.jackson = jackson; } /** @@ -860,6 +842,41 @@ private void noteRateLimit(@Nonnull RateLimitTarget rateLimitTarget, } } + private GitHubConnectorRequest prepareConnectorRequest(GitHubRequest request, + AuthorizationProvider authorizationProvider) throws IOException { + GitHubRequest.Builder builder = request.toBuilder(); + // if the authentication is needed but no credential is given, try it anyway (so that some calls + // that do work with anonymous access in the reduced form should still work.) + if (!request.allHeaders().containsKey("Authorization")) { + String authorization = authorizationProvider.getEncodedAuthorization(); + if (authorization != null) { + builder.setHeader("Authorization", authorization); + } + } + if (request.header("Accept") == null) { + builder.setHeader("Accept", "application/vnd.github+json"); + } + builder.setHeader("Accept-Encoding", "gzip"); + + builder.setHeader("X-GitHub-Api-Version", "2022-11-28"); + + if (request.hasBody()) { + if (request.body() != null) { + builder.contentType(defaultString(request.contentType(), "application/x-www-form-urlencoded")); + } else { + builder.contentType("application/json"); + Map json = new HashMap<>(); + for (GitHubRequest.Entry e : request.args()) { + json.put(e.key, e.value); + } + builder.with(new ByteArrayInputStream(jackson.writeValueAsBytes(json))); + } + + } + + return builder.build(); + } + private GitHubConnectorRequest prepareRedirectRequest(GitHubConnectorResponse connectorResponse, GitHubRequest request) throws IOException { URI requestUri = URI.create(request.url().toString()); @@ -921,6 +938,16 @@ String getEncodedAuthorization() throws IOException { return authorizationProvider.getEncodedAuthorization(); } + /** + * Gets the name of the Jackson implementation being used by this client. + * + * @return the implementation name, e.g., "Jackson 2.21.0" or "Jackson 3.0.3" + */ + @Nonnull + String getJacksonImplementationName() { + return jackson.getImplementationName(); + } + /** * Gets the login. * diff --git a/src/main/java/org/kohsuke/github/GitHubResponse.java b/src/main/java/org/kohsuke/github/GitHubResponse.java index 8ac65391f7..053ef548e2 100644 --- a/src/main/java/org/kohsuke/github/GitHubResponse.java +++ b/src/main/java/org/kohsuke/github/GitHubResponse.java @@ -1,7 +1,6 @@ package org.kohsuke.github; import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.JsonMappingException; import org.apache.commons.io.IOUtils; import org.kohsuke.github.connector.GitHubConnectorResponse; @@ -95,9 +94,6 @@ static T parseBody(GitHubConnectorResponse connectorResponse, Class type) String data = getBodyAsString(connectorResponse); try { - InjectableValues.Std inject = new InjectableValues.Std(); - inject.addValue(GitHubConnectorResponse.class, connectorResponse); - return GitHubClient.getMappingObjectReader(connectorResponse).forType(type).readValue(data); } catch (JsonMappingException | JsonParseException e) { String message = "Failed to deserialize: " + data; diff --git a/src/main/java/org/kohsuke/github/internal/DefaultGitHubJackson.java b/src/main/java/org/kohsuke/github/internal/DefaultGitHubJackson.java new file mode 100644 index 0000000000..1742116cf1 --- /dev/null +++ b/src/main/java/org/kohsuke/github/internal/DefaultGitHubJackson.java @@ -0,0 +1,97 @@ +package org.kohsuke.github.internal; + +/** + * Factory class for creating {@link GitHubJackson} implementations. + * + *

+ * This factory provides methods to create Jackson 2.x or Jackson 3.x implementations for JSON + * serialization/deserialization. + *

+ * + *

Usage

+ * + *

+ * By default, Jackson 2.x is used. To use Jackson 3.x, configure the {@link org.kohsuke.github.GitHubBuilder}: + *

+ * + *
+ * // Using Jackson 2.x (default)
+ * GitHub github = new GitHubBuilder().withOAuthToken("token").build();
+ *
+ * // Using Jackson 3.x
+ * GitHub github = new GitHubBuilder().withOAuthToken("token")
+ *         .withJackson(DefaultGitHubJackson.createJackson3())
+ *         .build();
+ * 
+ * + *

Jackson 3.x Dependencies

+ * + *

+ * To use Jackson 3.x, add the {@code tools.jackson.core:jackson-databind} dependency to your project. + *

+ * + * @author Pierre Villard + * @see GitHubJackson + * @see GitHubJackson2 + * @see GitHubJackson3 + */ +public final class DefaultGitHubJackson { + + /** + * Creates the default {@link GitHubJackson} instance. + * + *

+ * This method returns a Jackson 2.x implementation, which is the default and most stable option. + *

+ * + * @return a GitHubJackson2 instance + */ + public static GitHubJackson createDefault() { + return new GitHubJackson2(); + } + + /** + * Creates a Jackson 2.x implementation. + * + *

+ * Jackson 2.x uses the {@code com.fasterxml.jackson} package and is the default implementation. + *

+ * + * @return a GitHubJackson2 instance + */ + public static GitHubJackson2 createJackson2() { + return new GitHubJackson2(); + } + + /** + * Creates a Jackson 3.x implementation. + * + *

+ * Jackson 3.x uses the {@code tools.jackson} package and requires the Jackson 3.x dependencies to be present on the + * classpath. + *

+ * + * @return a GitHubJackson3 instance + * @throws IllegalStateException + * if Jackson 3.x is not available on the classpath + */ + public static GitHubJackson3 createJackson3() { + if (!isJackson3Available()) { + throw new IllegalStateException("Jackson 3.x is not available on the classpath. " + + "Please add tools.jackson.core:jackson-databind and tools.jackson.datatype:jackson-datatype-jsr310 dependencies."); + } + return new GitHubJackson3(); + } + + /** + * Checks if Jackson 3.x is available on the classpath. + * + * @return true if Jackson 3.x classes can be loaded + */ + public static boolean isJackson3Available() { + return GitHubJackson3.isAvailable(); + } + + private DefaultGitHubJackson() { + } +} diff --git a/src/main/java/org/kohsuke/github/internal/GitHubJackson.java b/src/main/java/org/kohsuke/github/internal/GitHubJackson.java new file mode 100644 index 0000000000..eab9290beb --- /dev/null +++ b/src/main/java/org/kohsuke/github/internal/GitHubJackson.java @@ -0,0 +1,134 @@ +package org.kohsuke.github.internal; + +import org.kohsuke.github.connector.GitHubConnectorResponse; + +import java.io.IOException; +import java.util.Map; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; + +/** + * Interface for JSON serialization/deserialization operations. + * + *

+ * This interface abstracts Jackson-specific operations to allow supporting multiple Jackson versions (2.x and 3.x) + * simultaneously. Implementations handle the version-specific details while providing a consistent API. + *

+ * + *

Available Implementations

+ *
    + *
  • {@link GitHubJackson2} - Jackson 2.x implementation (default)
  • + *
  • {@link GitHubJackson3} - Jackson 3.x implementation (requires additional dependencies)
  • + *
+ * + *

Configuration

+ *

+ * Use {@link org.kohsuke.github.GitHubBuilder#withJackson(GitHubJackson)} to configure which Jackson version to use: + *

+ * + *
+ * // Use Jackson 3.x
+ * GitHub github = new GitHubBuilder().withJackson(DefaultGitHubJackson.createJackson3()).build();
+ * 
+ * + * @author Pierre Villard + * @see DefaultGitHubJackson + * @see GitHubJackson2 + * @see GitHubJackson3 + */ +public interface GitHubJackson { + + /** + * Creates injectable values map with standard GitHub API values pre-populated. + * + * @param connectorResponse + * the connector response (may be null) + * @return a map suitable for passing to read methods + */ + @Nonnull + Map createInjectableValues(@CheckForNull GitHubConnectorResponse connectorResponse); + + /** + * Gets the name/version of this Jackson implementation for logging purposes. + * + * @return a string identifying this implementation (e.g., "Jackson 2.21.0" or "Jackson 3.0.3") + */ + @Nonnull + String getImplementationName(); + + /** + * Reads a JSON string into an object of the specified type. + * + * @param + * the type to deserialize to + * @param json + * the JSON string to parse + * @param type + * the target class + * @param injectedValues + * values to inject during deserialization (may be null) + * @return the deserialized object + * @throws IOException + * if there is an I/O error or parsing error + */ + @CheckForNull + T readValue(@Nonnull String json, @Nonnull Class type, @CheckForNull Map injectedValues) + throws IOException; + + /** + * Reads a JsonNode into an object of the specified type. + * + *

+ * This method handles the version-specific way of reading from a tree node. In Jackson 2.x, this uses + * {@code traverse()}, while in Jackson 3.x it uses a different approach. + *

+ * + * @param + * the type to deserialize to + * @param node + * the JSON node (JsonNode from either Jackson version) + * @param type + * the target class + * @param injectedValues + * values to inject during deserialization (may be null) + * @return the deserialized object + * @throws IOException + * if there is an I/O error or parsing error + */ + @CheckForNull + T readValueFromNode(@Nonnull Object node, + @Nonnull Class type, + @CheckForNull Map injectedValues) throws IOException; + + /** + * Reads a JSON string and updates an existing object instance. + * + * @param + * the type of the object + * @param json + * the JSON string to parse + * @param instance + * the object to update with parsed data + * @param injectedValues + * values to inject during deserialization (may be null) + * @return the updated object instance + * @throws IOException + * if there is an I/O error or parsing error + */ + @CheckForNull + T readValueToUpdate(@Nonnull String json, @Nonnull T instance, @CheckForNull Map injectedValues) + throws IOException; + + /** + * Writes an object to a JSON byte array. + * + * @param value + * the object to serialize + * @return the JSON as a byte array + * @throws IOException + * if there is an I/O error or serialization error + */ + @Nonnull + byte[] writeValueAsBytes(@Nonnull Object value) throws IOException; +} diff --git a/src/main/java/org/kohsuke/github/internal/GitHubJackson2.java b/src/main/java/org/kohsuke/github/internal/GitHubJackson2.java new file mode 100644 index 0000000000..a9d74cfb13 --- /dev/null +++ b/src/main/java/org/kohsuke/github/internal/GitHubJackson2.java @@ -0,0 +1,194 @@ +package org.kohsuke.github.internal; + +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.TreeNode; +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.InjectableValues; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.introspect.VisibilityChecker; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubRequest; +import org.kohsuke.github.connector.GitHubConnectorRequest; +import org.kohsuke.github.connector.GitHubConnectorResponse; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; + +/** + * Jackson 2.x implementation of {@link GitHubJackson}. + * + *

+ * This implementation uses Jackson 2.x APIs ({@code com.fasterxml.jackson.*}). + *

+ * + * @author Pierre Villard + */ +public class GitHubJackson2 implements GitHubJackson { + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .visibility(new VisibilityChecker.Std(Visibility.NONE, + Visibility.NONE, + Visibility.NONE, + Visibility.NONE, + Visibility.ANY)) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .build(); + + private final String version; + + /** + * Creates a new GitHubJackson2 instance. + */ + public GitHubJackson2() { + Version jacksonVersion = MAPPER.version(); + this.version = "Jackson " + jacksonVersion.getMajorVersion() + "." + jacksonVersion.getMinorVersion() + "." + + jacksonVersion.getPatchLevel(); + } + + /** + * Adds a value to the injectable values map for the GitHub root object. + * + * @param injectedValues + * the map to modify + * @param root + * the GitHub root object to add + */ + public void addGitHubRoot(@Nonnull Map injectedValues, @Nonnull GitHub root) { + injectedValues.put(GitHub.class.getName(), root); + } + + @Override + @Nonnull + public Map createInjectableValues(@CheckForNull GitHubConnectorResponse connectorResponse) { + Map injected = new HashMap<>(); + + // Required or many things break + injected.put(GitHubConnectorResponse.class.getName(), null); + injected.put(GitHub.class.getName(), null); + + if (connectorResponse != null) { + injected.put(GitHubConnectorResponse.class.getName(), connectorResponse); + GitHubConnectorRequest request = connectorResponse.request(); + // This is cheating, but it is an acceptable cheat for now. + // GitHubRequest has additional injectable values + if (request instanceof GitHubRequest) { + injected.putAll(((GitHubRequest) request).injectedMappingValues()); + } + } + return injected; + } + + @Override + @Nonnull + public String getImplementationName() { + return version; + } + + /** + * Gets an ObjectReader configured with injectable values. + * + *

+ * This method is exposed for compatibility with code that still needs direct access to ObjectReader. + *

+ * + * @param injectedValues + * values to inject during deserialization + * @return a configured ObjectReader + */ + @Nonnull + public ObjectReader getReader(@CheckForNull Map injectedValues) { + return createReader(injectedValues); + } + + /** + * Gets an ObjectWriter. + * + *

+ * This method is exposed for compatibility with code that still needs direct access to ObjectWriter. + *

+ * + * @return an ObjectWriter + */ + @Nonnull + public ObjectWriter getWriter() { + return MAPPER.writer(); + } + + @Override + @CheckForNull + public T readValue(@Nonnull String json, + @Nonnull Class type, + @CheckForNull Map injectedValues) throws IOException { + try { + ObjectReader reader = createReader(injectedValues); + return reader.forType(type).readValue(json); + } catch (JsonProcessingException e) { + throw new GitHubJacksonException("Failed to deserialize JSON", e); + } + } + + @Override + @CheckForNull + public T readValueFromNode(@Nonnull Object node, + @Nonnull Class type, + @CheckForNull Map injectedValues) throws IOException { + if (!(node instanceof TreeNode)) { + throw new IllegalArgumentException("Node must be a Jackson 2.x TreeNode"); + } + try { + ObjectReader reader = createReader(injectedValues); + return reader.forType(type).readValue(((TreeNode) node).traverse()); + } catch (JsonProcessingException e) { + throw new GitHubJacksonException("Failed to deserialize JSON from node", e); + } + } + + @Override + @CheckForNull + public T readValueToUpdate(@Nonnull String json, + @Nonnull T instance, + @CheckForNull Map injectedValues) throws IOException { + try { + ObjectReader reader = createReader(injectedValues); + return reader.withValueToUpdate(instance).readValue(json); + } catch (JsonProcessingException e) { + throw new GitHubJacksonException("Failed to deserialize JSON", e); + } + } + + @Override + @Nonnull + public byte[] writeValueAsBytes(@Nonnull Object value) throws IOException { + try { + ObjectWriter writer = MAPPER.writer(); + return writer.writeValueAsBytes(value); + } catch (JsonProcessingException e) { + throw new GitHubJacksonException("Failed to serialize object to JSON", e); + } + } + + private ObjectReader createReader(@CheckForNull Map injectedValues) { + if (injectedValues == null || injectedValues.isEmpty()) { + Map defaultValues = new HashMap<>(); + defaultValues.put(GitHubConnectorResponse.class.getName(), null); + defaultValues.put(GitHub.class.getName(), null); + return MAPPER.reader(new InjectableValues.Std(defaultValues)); + } + return MAPPER.reader(new InjectableValues.Std(injectedValues)); + } +} diff --git a/src/main/java/org/kohsuke/github/internal/GitHubJackson3.java b/src/main/java/org/kohsuke/github/internal/GitHubJackson3.java new file mode 100644 index 0000000000..47c096119b --- /dev/null +++ b/src/main/java/org/kohsuke/github/internal/GitHubJackson3.java @@ -0,0 +1,218 @@ +package org.kohsuke.github.internal; + +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubRequest; +import org.kohsuke.github.connector.GitHubConnectorRequest; +import org.kohsuke.github.connector.GitHubConnectorResponse; +import tools.jackson.core.JacksonException; +import tools.jackson.core.Version; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.InjectableValues; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; + +/** + * Jackson 3.x implementation of {@link GitHubJackson}. + * + *

+ * This implementation uses Jackson 3.x APIs ({@code tools.jackson.*}). + *

+ * + *

+ * To use Jackson 3.x, add the {@code tools.jackson.core:jackson-databind} dependency to your project. + *

+ * + *

+ * Then configure the GitHub client to use Jackson 3: + *

+ * + *
+ * GitHub github = new GitHubBuilder().withJackson(DefaultGitHubJackson.createJackson3()).build();
+ * 
+ * + * @author Pierre Villard + */ +public class GitHubJackson3 implements GitHubJackson { + + private static final String JACKSON3_MARKER_CLASS = "tools.jackson.databind.json.JsonMapper"; + + private static final JsonMapper MAPPER = JsonMapper.builder() + // Java 8 date/time support is built-in to Jackson 3.x (no module needed) + .changeDefaultVisibility(vc -> vc.withFieldVisibility(Visibility.ANY) + .withGetterVisibility(Visibility.NONE) + .withSetterVisibility(Visibility.NONE) + .withCreatorVisibility(Visibility.NONE) + .withIsGetterVisibility(Visibility.NONE)) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .build(); + + /** + * Checks if Jackson 3.x is available on the classpath. + * + * @return true if Jackson 3.x classes can be loaded + */ + public static boolean isAvailable() { + try { + Class.forName(JACKSON3_MARKER_CLASS); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + private final String version; + + /** + * Creates a new GitHubJackson3 instance. + */ + public GitHubJackson3() { + Version jacksonVersion = MAPPER.version(); + this.version = "Jackson " + jacksonVersion.getMajorVersion() + "." + jacksonVersion.getMinorVersion() + "." + + jacksonVersion.getPatchLevel(); + } + + /** + * Adds a value to the injectable values map for the GitHub root object. + * + * @param injectedValues + * the map to modify + * @param root + * the GitHub root object to add + */ + public void addGitHubRoot(@Nonnull Map injectedValues, @Nonnull GitHub root) { + injectedValues.put(GitHub.class.getName(), root); + } + + @Override + @Nonnull + public Map createInjectableValues(@CheckForNull GitHubConnectorResponse connectorResponse) { + Map injected = new HashMap<>(); + + // Required or many things break + injected.put(GitHubConnectorResponse.class.getName(), null); + injected.put(GitHub.class.getName(), null); + + if (connectorResponse != null) { + injected.put(GitHubConnectorResponse.class.getName(), connectorResponse); + GitHubConnectorRequest request = connectorResponse.request(); + // GitHubRequest has additional injectable values + if (request instanceof GitHubRequest) { + injected.putAll(((GitHubRequest) request).injectedMappingValues()); + } + } + return injected; + } + + @Override + @Nonnull + public String getImplementationName() { + return version; + } + + /** + * Gets an ObjectReader configured with injectable values. + * + *

+ * This method is exposed for compatibility with code that still needs direct access to ObjectReader. + *

+ * + * @param injectedValues + * values to inject during deserialization + * @return a configured ObjectReader + */ + @Nonnull + public ObjectReader getReader(@CheckForNull Map injectedValues) { + return createReader(injectedValues); + } + + /** + * Gets an ObjectWriter. + * + *

+ * This method is exposed for compatibility with code that still needs direct access to ObjectWriter. + *

+ * + * @return an ObjectWriter + */ + @Nonnull + public ObjectWriter getWriter() { + return MAPPER.writer(); + } + + @Override + @CheckForNull + public T readValue(@Nonnull String json, + @Nonnull Class type, + @CheckForNull Map injectedValues) throws IOException { + try { + ObjectReader reader = createReader(injectedValues); + return reader.forType(type).readValue(json); + } catch (JacksonException e) { + throw new GitHubJacksonException("Failed to deserialize JSON", e); + } + } + + @Override + @CheckForNull + public T readValueFromNode(@Nonnull Object node, + @Nonnull Class type, + @CheckForNull Map injectedValues) throws IOException { + if (!(node instanceof JsonNode)) { + throw new IllegalArgumentException("Node must be a Jackson 3.x JsonNode"); + } + try { + ObjectReader reader = createReader(injectedValues); + return reader.forType(type).readValue((JsonNode) node); + } catch (JacksonException e) { + throw new GitHubJacksonException("Failed to deserialize JSON from node", e); + } + } + + @Override + @CheckForNull + public T readValueToUpdate(@Nonnull String json, + @Nonnull T instance, + @CheckForNull Map injectedValues) throws IOException { + try { + ObjectReader reader = createReader(injectedValues); + return reader.withValueToUpdate(instance).readValue(json); + } catch (JacksonException e) { + throw new GitHubJacksonException("Failed to deserialize JSON", e); + } + } + + @Override + @Nonnull + public byte[] writeValueAsBytes(@Nonnull Object value) throws IOException { + try { + ObjectWriter writer = MAPPER.writer(); + return writer.writeValueAsBytes(value); + } catch (JacksonException e) { + throw new GitHubJacksonException("Failed to serialize object to JSON", e); + } + } + + private ObjectReader createReader(@CheckForNull Map injectedValues) { + if (injectedValues == null || injectedValues.isEmpty()) { + Map defaultValues = new HashMap<>(); + defaultValues.put(GitHubConnectorResponse.class.getName(), null); + defaultValues.put(GitHub.class.getName(), null); + return MAPPER.reader(new InjectableValues.Std(defaultValues)); + } + return MAPPER.reader(new InjectableValues.Std(injectedValues)); + } +} diff --git a/src/main/java/org/kohsuke/github/internal/GitHubJacksonException.java b/src/main/java/org/kohsuke/github/internal/GitHubJacksonException.java new file mode 100644 index 0000000000..b77976531e --- /dev/null +++ b/src/main/java/org/kohsuke/github/internal/GitHubJacksonException.java @@ -0,0 +1,50 @@ +package org.kohsuke.github.internal; + +import java.io.IOException; + +/** + * Wrapper exception for Jackson-specific exceptions. + * + *

+ * This exception wraps Jackson-specific exceptions (from either Jackson 2.x or 3.x) to provide a consistent exception + * type that doesn't expose Jackson version-specific classes to callers. + *

+ * + * @author Pierre Villard + */ +public class GitHubJacksonException extends IOException { + + private static final long serialVersionUID = 1L; + + /** + * Constructs a new GitHubJacksonException with the specified detail message. + * + * @param message + * the detail message + */ + public GitHubJacksonException(String message) { + super(message); + } + + /** + * Constructs a new GitHubJacksonException with the specified detail message and cause. + * + * @param message + * the detail message + * @param cause + * the cause (a Jackson-specific exception) + */ + public GitHubJacksonException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new GitHubJacksonException with the specified cause. + * + * @param cause + * the cause (a Jackson-specific exception) + */ + public GitHubJacksonException(Throwable cause) { + super(cause); + } +} diff --git a/src/test/java/org/kohsuke/github/GitHubJacksonTest.java b/src/test/java/org/kohsuke/github/GitHubJacksonTest.java new file mode 100644 index 0000000000..ae35a8e199 --- /dev/null +++ b/src/test/java/org/kohsuke/github/GitHubJacksonTest.java @@ -0,0 +1,63 @@ +package org.kohsuke.github; + +import org.junit.Test; +import org.kohsuke.github.internal.DefaultGitHubJackson; +import org.kohsuke.github.internal.GitHubJackson; + +import java.io.IOException; + +import static org.hamcrest.CoreMatchers.*; + +/** + * Tests for Jackson implementation selection via {@link GitHubBuilder#withJackson(GitHubJackson)}. + */ +public class GitHubJacksonTest extends AbstractGitHubWireMockTest { + + /** + * Create default GitHubJacksonTest instance. + */ + public GitHubJacksonTest() { + useDefaultGitHub = false; + } + + /** + * Test that the default Jackson implementation is Jackson 2. + * + * @throws IOException + * the io exception + */ + @Test + public void testDefaultJacksonIsJackson2() throws IOException { + gitHub = getGitHubBuilder().build(); + String implementationName = gitHub.getClient().getJacksonImplementationName(); + assertThat(implementationName, startsWith("Jackson 2.")); + } + + /** + * Test that Jackson 2 can be explicitly configured via builder. + * + * @throws IOException + * the io exception + */ + @Test + public void testJackson2ViaBuilder() throws IOException { + gitHub = getGitHubBuilder().withJackson(DefaultGitHubJackson.createJackson2()).build(); + String implementationName = gitHub.getClient().getJacksonImplementationName(); + assertThat(implementationName, startsWith("Jackson 2.")); + } + + /** + * Test that Jackson 3 can be configured via builder when available. + * + * @throws IOException + * the io exception + */ + @Test + public void testJackson3ViaBuilder() throws IOException { + if (DefaultGitHubJackson.isJackson3Available()) { + gitHub = getGitHubBuilder().withJackson(DefaultGitHubJackson.createJackson3()).build(); + String implementationName = gitHub.getClient().getJacksonImplementationName(); + assertThat(implementationName, startsWith("Jackson 3.")); + } + } +} diff --git a/src/test/java/org/kohsuke/github/internal/DefaultGitHubJacksonTest.java b/src/test/java/org/kohsuke/github/internal/DefaultGitHubJacksonTest.java new file mode 100644 index 0000000000..53ed5dd7b6 --- /dev/null +++ b/src/test/java/org/kohsuke/github/internal/DefaultGitHubJacksonTest.java @@ -0,0 +1,62 @@ +package org.kohsuke.github.internal; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Tests for {@link DefaultGitHubJackson} factory. + */ +public class DefaultGitHubJacksonTest { + + /** + * Create default DefaultGitHubJacksonTest instance. + */ + public DefaultGitHubJacksonTest() { + } + + /** + * Test that createDefault returns Jackson 2 implementation. + */ + @Test + public void testCreateDefault() { + GitHubJackson jackson = DefaultGitHubJackson.createDefault(); + assertThat(jackson, notNullValue()); + assertThat(jackson, instanceOf(GitHubJackson2.class)); + assertThat(jackson.getImplementationName(), startsWith("Jackson 2.")); + } + + /** + * Test that createJackson2 returns Jackson 2 implementation. + */ + @Test + public void testCreateJackson2() { + GitHubJackson2 jackson = DefaultGitHubJackson.createJackson2(); + assertThat(jackson, notNullValue()); + assertThat(jackson.getImplementationName(), startsWith("Jackson 2.")); + } + + /** + * Test that createJackson3 returns Jackson 3 implementation when available. + */ + @Test + public void testCreateJackson3WhenAvailable() { + if (DefaultGitHubJackson.isJackson3Available()) { + GitHubJackson3 jackson = DefaultGitHubJackson.createJackson3(); + assertThat(jackson, notNullValue()); + assertThat(jackson.getImplementationName(), startsWith("Jackson 3.")); + } + } + + /** + * Test Jackson 3 availability check. + */ + @Test + public void testJackson3Availability() { + // Since Jackson 3 is now on the classpath (as optional dependency), + // it should be available + boolean available = DefaultGitHubJackson.isJackson3Available(); + assertThat(available, is(true)); + } +} diff --git a/src/test/resources/no-reflect-and-serialization-list b/src/test/resources/no-reflect-and-serialization-list index 4ad893272c..54d1f2b156 100644 --- a/src/test/resources/no-reflect-and-serialization-list +++ b/src/test/resources/no-reflect-and-serialization-list @@ -79,7 +79,12 @@ org.kohsuke.github.function.FunctionThrows org.kohsuke.github.function.InputStreamFunction org.kohsuke.github.function.SupplierThrows org.kohsuke.github.internal.DefaultGitHubConnector +org.kohsuke.github.internal.DefaultGitHubJackson org.kohsuke.github.internal.EnumUtils +org.kohsuke.github.internal.GitHubJackson +org.kohsuke.github.internal.GitHubJackson2 +org.kohsuke.github.internal.GitHubJackson3 +org.kohsuke.github.internal.GitHubJacksonException org.kohsuke.github.internal.Previews org.kohsuke.github.EnterpriseManagedSupport org.kohsuke.github.GHAutolinkBuilder