Add codecs for JSON support with Protobuf

Prior to this commit, WebFlux had Protobuf codecs for managing the
`Message`/`"application/x-protobuf"` encoding and decoding.
The `com.google.protobuf:protobuf-java-util` library has additional
support for JSON (de)serialization, but this is not supported by
existing codecs.

This commit adds the new `ProtobufJsonEncode` and `ProtobufJsonDecoder`
classes that support this use case. Note, the `ProtobufJsonDecoder` has
a significant limitation: it cannot decode JSON arrays as
`Flux<Message>` because there is no available non-blocking parser able
to tokenize JSON arrays into streams of `Databuffer`. Instead,
applications should decode to `Mono<List<Message>>` which causes
additional buffering but is properly supported.

Closes gh-25457
This commit is contained in:
Brian Clozel
2024-06-18 15:48:46 +02:00
parent 24bbc6d80d
commit 17abb4d25d
6 changed files with 560 additions and 5 deletions

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* 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
*
* https://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 org.springframework.http.codec.protobuf;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.ResolvableType;
import org.springframework.core.codec.DecodingException;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.testfixture.codec.AbstractDecoderTests;
import org.springframework.http.MediaType;
import org.springframework.protobuf.Msg;
import org.springframework.protobuf.SecondMsg;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ProtobufJsonDecoder}.
* @author Brian Clozel
*/
public class ProtobufJsonDecoderTests extends AbstractDecoderTests<ProtobufJsonDecoder> {
private Msg msg1 = Msg.newBuilder().setFoo("Foo").setBlah(SecondMsg.newBuilder().setBlah(123).build()).build();
public ProtobufJsonDecoderTests() {
super(new ProtobufJsonDecoder());
}
@Test
@Override
protected void canDecode() throws Exception {
ResolvableType msgType = ResolvableType.forClass(Msg.class);
assertThat(this.decoder.canDecode(msgType, null)).isFalse();
assertThat(this.decoder.canDecode(msgType, MediaType.APPLICATION_JSON)).isTrue();
assertThat(this.decoder.canDecode(msgType, MediaType.APPLICATION_PROTOBUF)).isFalse();
assertThat(this.decoder.canDecode(ResolvableType.forClass(Object.class), MediaType.APPLICATION_JSON)).isFalse();
}
@Test
@Override
protected void decode() throws Exception {
ResolvableType msgType = ResolvableType.forClass(Msg.class);
Flux<DataBuffer> input = Flux.just(dataBuffer("[{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}"),
dataBuffer(",{\"foo\":\"Bar\",\"blah\":{\"blah\":456}}"),
dataBuffer("]"));
testDecode(input, msgType, step -> step.consumeErrorWith(error -> assertThat(error).isInstanceOf(UnsupportedOperationException.class)),
MediaType.APPLICATION_JSON, null);
}
@Test
@Override
protected void decodeToMono() throws Exception {
DataBuffer dataBuffer = dataBuffer("{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}");
testDecodeToMonoAll(Mono.just(dataBuffer), Msg.class, step -> step
.expectNext(this.msg1)
.verifyComplete());
}
@Test
void exceedMaxSize() {
this.decoder.setMaxMessageSize(1);
DataBuffer first = dataBuffer("{\"foo\":\"Foo\",");
DataBuffer second = dataBuffer("\"blah\":{\"blah\":123}}");
testDecodeToMono(Flux.just(first, second), Msg.class, step -> step.verifyError(DecodingException.class));
}
private DataBuffer dataBuffer(String json) {
return this.bufferFactory.wrap(json.getBytes());
}
}

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* 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
*
* https://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 org.springframework.http.codec.protobuf;
import java.nio.charset.StandardCharsets;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.testfixture.codec.AbstractEncoderTests;
import org.springframework.core.testfixture.io.buffer.DataBufferTestUtils;
import org.springframework.http.MediaType;
import org.springframework.protobuf.Msg;
import org.springframework.protobuf.SecondMsg;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.core.ResolvableType.forClass;
/**
* Tests for {@link ProtobufJsonEncoder}.
* @author Brian Clozel
*/
class ProtobufJsonEncoderTests extends AbstractEncoderTests<ProtobufJsonEncoder> {
private Msg msg1 =
Msg.newBuilder().setFoo("Foo").setBlah(SecondMsg.newBuilder().setBlah(123).build()).build();
private Msg msg2 =
Msg.newBuilder().setFoo("Bar").setBlah(SecondMsg.newBuilder().setBlah(456).build()).build();
public ProtobufJsonEncoderTests() {
super(new ProtobufJsonEncoder(JsonFormat.printer().omittingInsignificantWhitespace()));
}
@Override
@Test
protected void canEncode() throws Exception {
assertThat(this.encoder.canEncode(forClass(Msg.class), null)).isFalse();
assertThat(this.encoder.canEncode(forClass(Msg.class), MediaType.APPLICATION_JSON)).isTrue();
assertThat(this.encoder.canEncode(forClass(Msg.class), MediaType.APPLICATION_NDJSON)).isFalse();
assertThat(this.encoder.canEncode(forClass(Object.class), MediaType.APPLICATION_JSON)).isFalse();
}
@Override
@Test
protected void encode() throws Exception {
Mono<Message> input = Mono.just(this.msg1);
ResolvableType inputType = forClass(Msg.class);
testEncode(input, inputType, MediaType.APPLICATION_JSON, null, step -> step
.assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, "{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}"))
.verifyComplete());
testEncodeError(input, inputType, MediaType.APPLICATION_JSON, null);
testEncodeCancel(input, inputType, MediaType.APPLICATION_JSON, null);
}
@Test
void encodeEmptyMono() {
Mono<Message> input = Mono.empty();
ResolvableType inputType = forClass(Msg.class);
Flux<DataBuffer> result = this.encoder.encode(input, this.bufferFactory, inputType,
MediaType.APPLICATION_JSON, null);
StepVerifier.create(result)
.verifyComplete();
}
@Test
void encodeStream() {
Flux<Message> input = Flux.just(this.msg1, this.msg2);
ResolvableType inputType = forClass(Msg.class);
testEncode(input, inputType, MediaType.APPLICATION_JSON, null, step -> step
.assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, "[{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}"))
.assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, ",{\"foo\":\"Bar\",\"blah\":{\"blah\":456}}"))
.assertNext(dataBuffer -> assertBufferEqualsJson(dataBuffer, "]"))
.verifyComplete());
}
@Test
void encodeEmptyFlux() {
Flux<Message> input = Flux.empty();
ResolvableType inputType = forClass(Msg.class);
Flux<DataBuffer> result = this.encoder.encode(input, this.bufferFactory, inputType,
MediaType.APPLICATION_JSON, null);
StepVerifier.create(result)
.assertNext(buffer -> assertBufferEqualsJson(buffer, "["))
.assertNext(buffer -> assertBufferEqualsJson(buffer, "]"))
.verifyComplete();
}
private void assertBufferEqualsJson(DataBuffer actual, String expected) {
byte[] bytes = DataBufferTestUtils.dumpBytes(actual);
String json = new String(bytes, StandardCharsets.UTF_8);
assertThat(json).isEqualTo(expected);
DataBufferUtils.release(actual);
}
}