+ * By default, Jackson 2.x is used. Call this method to use Jackson 3.x instead. + *
+ * + *
+ * GitHub github = new GitHubBuilder().withOAuthToken("token").useJackson3().build();
+ *
+ *
+ * + * Note: To use Jackson 3.x, you must add the Jackson 3 {@code tools.jackson.core:jackson-databind} + * dependency to your project. + *
+ * + * @return the GitHubBuilder + * @throws IllegalStateException + * if Jackson 3.x is not available on the classpath + */ + public GitHubBuilder useJackson3() { + this.jackson = DefaultGitHubJackson.createJackson3(); + return this; + } + /** * Adds a {@link GitHubAbuseLimitHandler} to this {@link 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+ * 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* 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+ * 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+ * This factory provides methods to create Jackson 2.x or Jackson 3.x implementations for JSON + * serialization/deserialization. + *
+ * + *+ * 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").useJackson3().build();
+ *
+ *
+ * + * 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..b2e63ecfe8 --- /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. + *
+ * + *+ * Use {@link org.kohsuke.github.GitHubBuilder#useJackson3()} to configure the client to use Jackson 3.x: + *
+ * + *+ * // Use Jackson 3.x + * GitHub github = new GitHubBuilder().useJackson3().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
+ * 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+ * 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+ * 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+ * 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+ * 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().useJackson3().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
+ * 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+ * 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+ * 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..dd7376f3de --- /dev/null +++ b/src/test/java/org/kohsuke/github/GitHubJacksonTest.java @@ -0,0 +1,49 @@ +package org.kohsuke.github; + +import org.junit.Test; +import org.kohsuke.github.internal.DefaultGitHubJackson; + +import java.io.IOException; + +import static org.hamcrest.CoreMatchers.*; + +/** + * Tests for Jackson implementation selection via {@link GitHubBuilder#useJackson3()}. + */ +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 3 can be configured via builder when available. + * + * @throws IOException + * the io exception + */ + @Test + public void testJackson3ViaBuilder() throws IOException { + if (DefaultGitHubJackson.isJackson3Available()) { + gitHub = getGitHubBuilder().useJackson3().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