Repository: line/armeria (2025-07-25T03:08:32Z)

Issue:
HttpJsonTranscodingService throws on empty DELETE body due to gRPC HttpRule mapping
## Summary

When using gRPC-HTTP transcoding with `google.api.http` annotations (`HttpRule`), a `DELETE` method with a mapped body results in an exception in `HttpJsonTranscodingService` if the HTTP request body is empty.

This occurs even though the transcoding specification allows an empty body in certain methods like `DELETE`.

## Problem

In our use case, we defined a gRPC service method with the following annotation:

```proto example
rpc DeleteSomething(DeleteRequest) returns (DeleteResponse) {
  option (google.api.http) = {
    delete: "/v1/resource/{id}"
    body: "*"
  };
}
```
Calling DELETE /v1/resource/123 with an empty body (i.e., Content-Length: 0) triggers the following exception:

```
IllegalArgumentException: Unexpected JSON: , (expected: ObjectNode or null)
    at com.linecorp.armeria.server.grpc.HttpJsonTranscodingService.convertToJson(HttpJsonTranscodingService.java:621)
```

When the HTTP request body is `empty`, Armeria attempts to parse the body via:

```
return mapper.readTree(request.contentUtf8());
```

but mapper.readTree(request.contentUtf8()) result is empty string `""`

This appears related to:
🔗 https://github.com/line/armeria/pull/5307/files
https://github.com/line/armeria/blob/c61af4a36a984daf625557f63c0bdad37f09fe40/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java#L754


(https://github.com/line/armeria/blob/c61af4a36a984daf625557f63c0bdad37f09fe40/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java#L709)


Discussion:
@DongHyukki, thanks for the report.

> This occurs even though the transcoding specification allows an empty body in certain methods like DELETE.

Could you provide me with the link to the specification?
@minwoox 

that part was my mistake. (GPT gave me the wrong explanation) The spec doesn't explicitly allow an empty body for DELETE with `body: "*"`, so I appreciate the correction.

DELETE requests with a body are quite common in practice, but in this case, the body seems to be erased and interpreted as request parameters automatically, which causes the problem.
I may have explained it poorly earlier — we're using Envoy proxy, and the issue seems to happen because Envoy rewrites the request based on the gRPC `HttpRule`. As a result, the body gets removed and turned into request parameters, which leads to this behavior. 
( https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api#http )
( https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter )
Thanks for the explanation. I have no idea why it didn't hit this condition:
https://github.com/line/armeria/blob/c61af4a36a984daf625557f63c0bdad37f09fe40/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java#L748

Could you tell me the content type of the request?
@minwoox 

We are using Armeria with gRPC, and we’ve also been serving REST APIs using google.api.http.

However, on the client side, the exposed interface (HTTP DELETE with a body) was called with Content-Type: application/json and a request body. The Envoy proxy running on the server intercepted this and, according to the [HttpRule](https://cloud.google.com/service-infrastructure/docs/service-management/reference/rpc/google.api#http), converted the body into request parameters—effectively discarding the original body.

As a result, within the HttpJsonTranscodingService, we encounter a situation where the request has no body, but the Content-Type is still set to application/json.
@DongHyukki, I think we can simply return`null` when the body is empty. 😉 

 > However, on the client side, the exposed interface (HTTP DELETE with a body) was called with Content-Type: application/json and a request body. The Envoy proxy running on the server intercepted this 

That is interesting by the way. Did the envoy proxy intercept REST call (whose content type is application/json) and convert into another REST call?
@minwoox 

> I think we can simply returnnull when the body is empty.

yes, I thought the same. would it be possible for me to contribute to that part?

> That is interesting by the way. Did the envoy proxy intercept REST call (whose content type is application/json) and convert 
into another REST call?

I think envoy intercepted the REST call and converted it into a gRPC call

> yes, I thought the same. would it be possible for me to contribute to that part?

Of course, it's all yours. 😉 

> I think envoy intercepted the REST call and converted it into a gRPC call

It's not a gRPC call. `HttpJsonTranscodingService` is only called when the call is a REST call. 
I believe Envoy didn't do anything unless you used gRPC-JSON reverse transcoder.

@minwoox 

Ah, I see. It looks like I misunderstood.

As you mentioned, it seems the issue is due to the Envoy transcoder.
However, we inevitably need both REST and gRPC communication between servers, which is why we chose an Armeria-based server.

Thank you so much for explaining everything in detail.

Would it be okay for me to submit a contribution for that part?
I’ve never done this before, so I’m really looking forward to it! 😊
On second thought, I'm not sure if we should relax the request validation condition because an empty request is not a valid JSON object: https://www.json.org/json-en.html
Would you mind researching how other implementations handle an empty request, please?



@minwoox 

sure 
I tried deserializing a byteArray using both kotlinx.serialization and Gson.

```kotlin
test("kotlinx serialization") {
      val byte = byteArrayOf()
      val deserializedByKotlinx = Json.decodeFromStream<JsonElement>(byte.inputStream())
      deserializedByKotlinx shouldNotBe null // actual thrown exception
  }
```

`kotlinx.serialization.json.internal.JsonDecodingException: Cannot read Json element because of unexpected end of the input at path`

```kotlin
test("gson") {
    val gson = Gson()
    val byte = byteArrayOf()
    val reader = InputStreamReader(byte.inputStream(), Charsets.UTF_8)
    val deserializedByGson = gson.fromJson(reader, com.google.gson.JsonElement::class.java)
    result deserializedByGson null // actual null
}
```

I discovered that the upstream doesn't throw an exception when the body is empty, so I think we can handle it in the same way:
https://github.com/grpc-ecosystem/grpc-httpjson-transcoding/blob/master/src/request_message_translator.cc

Let's fix that, @DongHyukki. 😉 
@minwoox 

Ah, so you were referring to the transcoder implementation… Thank you for looking that up for me. 

I used an LLM to help me understand the C++ code, and it seems that the logic mapping request_param to the body doesn’t perform any special error or throw handling based on whether the body is present. 
Should I understand that it should therefore be handled in the same way?

https://github.com/line/armeria/blob/c61af4a36a984daf625557f63c0bdad37f09fe40/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java#L743C1-L758C6

So in this section, simply removing the Content-Type header check would yield the same implementation, right?
I think it's an interesting topic, so I'm leaving a comment.
I think it can be considered an empty json object instead of null.
Or, wouldn't it be possible with a wrapper method like prefix_writer?

However, it seems right to revert to empty json without checking the content type.
> Should I understand that it should therefore be handled in the same way?

Yes, I believe so.

> So in this section, simply removing the Content-Type header check would yield the same implementation, right?

We still need to throw an exception when the body is not empty and there's no content-type or invalid content-type. 

> I think it can be considered an empty json object instead of null.

Yeah, that's right. 😉 
@minwoox 

Thanks! I’ll revise it as you suggested and open a PR.



Patch:
diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java
index 9ae93eb453d..b2df88d0395 100644
--- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java
+++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java
@@ -742,19 +742,27 @@ private static HttpData convertToJson(ServiceRequestContext ctx,
 
     @Nullable
     private static JsonNode getBodyContent(AggregatedHttpRequest request) {
-        @Nullable
-        final MediaType contentType = request.contentType();
-        if (contentType == null || !contentType.isJson()) {
-            if (request.content().isEmpty()) {
-                return null;
+        @Nullable final MediaType contentType = request.contentType();
+        final HttpData bodyContent = request.content();
+        final boolean hasBodyContent = !bodyContent.isEmpty();
+
+        if (contentType != null && contentType.isJson()) {
+            if (!hasBodyContent) {
+                return mapper.createObjectNode();
+            }
+
+            try {
+                return mapper.readTree(bodyContent.toStringUtf8());
+            } catch (JsonProcessingException e) {
+                throw new IllegalArgumentException("Failed to parse JSON request.", e);
             }
-            throw new IllegalArgumentException("Missing or invalid content-type in JSON request.");
         }
-        try {
-            return mapper.readTree(request.contentUtf8());
-        } catch (JsonProcessingException e) {
-            throw new IllegalArgumentException("Failed to parse JSON request.", e);
+
+        if (hasBodyContent) {
+            throw new IllegalArgumentException("Missing or invalid content-type in JSON request.");
         }
+
+        return null;
     }
 
     @VisibleForTesting


Test patch:
diff --git a/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java b/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java
index a1b7e57a54d..e181215b5bc 100644
--- a/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java
@@ -917,7 +917,7 @@ void shouldDenyNonJsonContentType() {
     }
 
     @Test
-    void shouldDenyEmptyJson() {
+    void shouldAcceptEmptyJson() {
         final String emptyJson = "";
         final RequestHeaders headers = RequestHeaders.builder()
                                                      .method(HttpMethod.POST)
@@ -925,7 +925,19 @@ void shouldDenyEmptyJson() {
                                                      .contentType(MediaType.JSON)
                                                      .build();
         final AggregatedHttpResponse response = webClient.execute(headers, emptyJson).aggregate().join();
-        assertThat(response.status()).isEqualTo(HttpStatus.BAD_REQUEST);
+        assertThat(response.status()).isEqualTo(HttpStatus.OK);
+    }
+
+    @Test
+    void shouldAcceptEmptyContentBody() {
+        final HttpData emptyContentBody = HttpData.empty();
+        final RequestHeaders headers = RequestHeaders.builder()
+                .method(HttpMethod.POST)
+                .path("/v1/echo/response_body/repeated")
+                .contentType(MediaType.JSON)
+                .build();
+        final AggregatedHttpResponse response = webClient.execute(headers, emptyContentBody).aggregate().join();
+        assertThat(response.status()).isEqualTo(HttpStatus.OK);
     }
 
     @Test