Repository: square/retrofit
Issue: Support CBOR in JacksonConverterFactory

I am using [CBOR](http://cbor.io/) for request/response encoding. I am creating Jackson `ObjectMapper` with `CBORFactory` like this:

```java
ObjectMapper mapper = new ObjectMapper(new CBORFactory())
	.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);
```

I then tried to use the existing `JacksonConverterFactory` like this:

```java
Retrofit retrofit = new Retrofit.Builder()
	.client(client)
	.baseUrl("http://" + host + ":" + port + "/")
	.addConverterFactory(JacksonConverterFactory.create(mapper))
	.build();
```

But this failed with an exception, because `JacksonConverterFactory` insists on reading/writing documents as text while CBOR requires binary I/O.

I have implemented custom converter as a workaround. It would be nice to have CBOR supported simply by passing in custom `ObjectMapper`. Switching to binary I/O should have no negative impact on JSON serialization AFAIK.

Issue comments:
Now standard Jackson binary format backends contains avro, cbor, protobuf and smile,the `JacksonConverterFactory ` can switch to binary format when the `ObjectMapper` support binary format data.please make a pull request.
Eclipse didn't like the project for some reason. I don't have time to investigate it right now. Anyone with working build/tests should have easy time implementing this.

In [JacksonResponseBodyConverter](https://github.com/square/retrofit/blob/master/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonResponseBodyConverter.java), replace `value.charStream()` with `value.byteStream()`.

In [JacksonRequestBodyConverter](https://github.com/square/retrofit/blob/master/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonRequestBodyConverter.java), turn `MEDIA_TYPE` into configurable parameter (passed from [JacksonConverterFactory](https://github.com/square/retrofit/blob/master/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonConverterFactory.java)).

And perhaps add a test case in [JacksonConverterFactoryTest](https://github.com/square/retrofit/blob/master/retrofit-converters/jackson/src/test/java/retrofit2/converter/jackson/JacksonConverterFactoryTest.java).

Pull request review comments:

Patch that resolved the issue:
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7dcf136962..7718c6b32b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,6 +20,7 @@ robovm = "2.3.14"
 kotlinx-serialization = "1.6.3"
 autoService = "1.1.1"
 incap = "1.0.0"
+jackson = "2.16.1"
 
 [libraries]
 androidPlugin = { module = "com.android.tools.build:gradle", version = "8.2.2" }
@@ -67,7 +68,8 @@ rxjava3 = { module = "io.reactivex.rxjava3:rxjava", version = "3.1.8" }
 reactiveStreams = { module = "org.reactivestreams:reactive-streams", version = "1.0.4" }
 scalaLibrary = { module = "org.scala-lang:scala-library", version = "2.13.12" }
 gson = { module = "com.google.code.gson:gson", version = "2.10.1" }
-jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind", version = "2.16.1" }
+jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
+jacksonDataformatCbor = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor", version.ref = "jackson" }
 jaxbApi = { module = "javax.xml.bind:jaxb-api", version = "2.3.1" }
 jaxbImpl = { module = "org.glassfish.jaxb:jaxb-runtime", version = "4.0.4" }
 jaxb3Api = { module = "jakarta.xml.bind:jakarta.xml.bind-api", version = "3.0.1" }
diff --git a/retrofit-converters/jackson/build.gradle b/retrofit-converters/jackson/build.gradle
index 1c6f7ec5bd..cbb4f49a3f 100644
--- a/retrofit-converters/jackson/build.gradle
+++ b/retrofit-converters/jackson/build.gradle
@@ -9,6 +9,7 @@ dependencies {
   testImplementation libs.junit
   testImplementation libs.truth
   testImplementation libs.mockwebserver
+  testImplementation libs.jacksonDataformatCbor
 }
 
 jar {
diff --git a/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonConverterFactory.java b/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonConverterFactory.java
index afe395233f..0bf0b13556 100644
--- a/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonConverterFactory.java
+++ b/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonConverterFactory.java
@@ -21,6 +21,7 @@
 import com.fasterxml.jackson.databind.ObjectWriter;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Type;
+import okhttp3.MediaType;
 import okhttp3.RequestBody;
 import okhttp3.ResponseBody;
 import retrofit2.Converter;
@@ -35,22 +36,33 @@
  * instance} last to allow the other converters a chance to see their types.
  */
 public final class JacksonConverterFactory extends Converter.Factory {
+  private static final MediaType DEFAULT_MEDIA_TYPE =
+      MediaType.get("application/json; charset=UTF-8");
+
   /** Create an instance using a default {@link ObjectMapper} instance for conversion. */
   public static JacksonConverterFactory create() {
-    return create(new ObjectMapper());
+    return new JacksonConverterFactory(new ObjectMapper(), DEFAULT_MEDIA_TYPE);
   }
 
   /** Create an instance using {@code mapper} for conversion. */
-  @SuppressWarnings("ConstantConditions") // Guarding public API nullability.
   public static JacksonConverterFactory create(ObjectMapper mapper) {
+    return create(mapper, DEFAULT_MEDIA_TYPE);
+  }
+
+  /** Create an instance using {@code mapper} and {@code mediaType} for conversion. */
+  @SuppressWarnings("ConstantConditions") // Guarding public API nullability.
+  public static JacksonConverterFactory create(ObjectMapper mapper, MediaType mediaType) {
     if (mapper == null) throw new NullPointerException("mapper == null");
-    return new JacksonConverterFactory(mapper);
+    if (mediaType == null) throw new NullPointerException("mediaType == null");
+    return new JacksonConverterFactory(mapper, mediaType);
   }
 
   private final ObjectMapper mapper;
+  private final MediaType mediaType;
 
-  private JacksonConverterFactory(ObjectMapper mapper) {
+  private JacksonConverterFactory(ObjectMapper mapper, MediaType mediaType) {
     this.mapper = mapper;
+    this.mediaType = mediaType;
   }
 
   @Override
@@ -69,6 +81,6 @@ public Converter<?, RequestBody> requestBodyConverter(
       Retrofit retrofit) {
     JavaType javaType = mapper.getTypeFactory().constructType(type);
     ObjectWriter writer = mapper.writerFor(javaType);
-    return new JacksonRequestBodyConverter<>(writer);
+    return new JacksonRequestBodyConverter<>(writer, mediaType);
   }
 }
diff --git a/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonRequestBodyConverter.java b/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonRequestBodyConverter.java
index 257067675a..fb1ceb74b7 100644
--- a/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonRequestBodyConverter.java
+++ b/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonRequestBodyConverter.java
@@ -22,17 +22,17 @@
 import retrofit2.Converter;
 
 final class JacksonRequestBodyConverter<T> implements Converter<T, RequestBody> {
-  private static final MediaType MEDIA_TYPE = MediaType.get("application/json; charset=UTF-8");
-
   private final ObjectWriter adapter;
+  private final MediaType mediaType;
 
-  JacksonRequestBodyConverter(ObjectWriter adapter) {
+  JacksonRequestBodyConverter(ObjectWriter adapter, MediaType mediaType) {
     this.adapter = adapter;
+    this.mediaType = mediaType;
   }
 
   @Override
   public RequestBody convert(T value) throws IOException {
     byte[] bytes = adapter.writeValueAsBytes(value);
-    return RequestBody.create(MEDIA_TYPE, bytes);
+    return RequestBody.create(mediaType, bytes);
   }
 }
diff --git a/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonResponseBodyConverter.java b/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonResponseBodyConverter.java
index 0e733b35f0..0e8826e4f5 100644
--- a/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonResponseBodyConverter.java
+++ b/retrofit-converters/jackson/src/main/java/retrofit2/converter/jackson/JacksonResponseBodyConverter.java
@@ -30,7 +30,7 @@ final class JacksonResponseBodyConverter<T> implements Converter<ResponseBody, T
   @Override
   public T convert(ResponseBody value) throws IOException {
     try {
-      return adapter.readValue(value.charStream());
+      return adapter.readValue(value.byteStream());
     } finally {
       value.close();
     }


Test patch:
diff --git a/retrofit-converters/jackson/src/test/java/retrofit2/converter/jackson/JacksonCborConverterFactoryTest.java b/retrofit-converters/jackson/src/test/java/retrofit2/converter/jackson/JacksonCborConverterFactoryTest.java
new file mode 100644
index 0000000000..66debe4dec
--- /dev/null
+++ b/retrofit-converters/jackson/src/test/java/retrofit2/converter/jackson/JacksonCborConverterFactoryTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2024 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package retrofit2.converter.jackson;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.dataformat.cbor.databind.CBORMapper;
+import java.io.IOException;
+import okhttp3.MediaType;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import okio.Buffer;
+import okio.ByteString;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import retrofit2.Call;
+import retrofit2.Response;
+import retrofit2.Retrofit;
+import retrofit2.http.Body;
+import retrofit2.http.POST;
+
+public class JacksonCborConverterFactoryTest {
+  static class IntWrapper {
+    public int value;
+
+    public IntWrapper(int v) {
+      value = v;
+    }
+
+    protected IntWrapper() {}
+  }
+
+  interface Service {
+    @POST("/")
+    Call<IntWrapper> post(@Body IntWrapper person);
+  }
+
+  @Rule public final MockWebServer server = new MockWebServer();
+
+  private Service service;
+
+  @Before
+  public void setUp() {
+    Retrofit retrofit =
+        new Retrofit.Builder()
+            .baseUrl(server.url("/"))
+            .addConverterFactory(
+                JacksonConverterFactory.create(new CBORMapper(), MediaType.get("application/cbor")))
+            .build();
+    service = retrofit.create(Service.class);
+  }
+
+  @Test
+  public void post() throws IOException, InterruptedException {
+    server.enqueue(
+        new MockResponse()
+            .setBody(new Buffer().write(ByteString.decodeHex("bf6576616c7565182aff"))));
+
+    Call<IntWrapper> call = service.post(new IntWrapper(12));
+    Response<IntWrapper> response = call.execute();
+    assertThat(response.body().value).isEqualTo(42);
+
+    RecordedRequest request = server.takeRequest();
+    assertThat(request.getBody().readByteString())
+        .isEqualTo(ByteString.decodeHex("bf6576616c75650cff"));
+    assertThat(request.getHeader("Content-Type")).isEqualTo("application/cbor");
+  }
+}