Search code examples
javaspringfeignopenfeign

Feign client body type serialization failure with Jackson encoder


I'm working on implementing a Spring service and client and would like to use OpenFeign for the client. The client will be deployed with legacy applications that do not want to incur a dependency on Spring, so I'm using OpenFeign directly instead of via Spring Cloud.

I've run into an issue with the Jackson encoder and the Body type. It seems that the Jackson encoder cannot serialize an interface implementation to an interface type for the client method. e.g. if my client method is createFoo(Foo interface) where Foo is an interface calling the method with createFoo((FooImpl)fooImpl) where FooImpl implements the Foo interface then I get an encoder exception.

I've created an MCCE Gradle project demonstrating the issue here

The client definition is this:


public interface FooClient {


    @RequestLine("POST /submit")
    @Headers("Content-Type: application/json")
    Response createFoo(Foo foo);

    @RequestLine("POST /submit")
    @Headers("Content-Type: application/json")
    Response createFooImpl(FooImpl foo);

    interface Foo { int id(); }

    record FooImpl(int id) implements Foo { }
}

And the failing test demonstrating the issue is this:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class FooClientTest {

    @LocalServerPort int port;

    @Test
    public void clientTest() {
        final FooClient lClient = Feign.builder()
            .encoder(new JacksonEncoder(List.of(
                // Possibly this would be necessary with the original encoder implementation.
//                new FooModule()
            )))
            .target(FooClient.class, String.format("http://localhost:%s", port));

        Response response = lClient.createFooImpl(new FooImpl(10));
        assertThat(response.status()).isEqualTo(404);

        response = lClient.createFoo(new FooImpl(10));
        assertThat(response.status()).isEqualTo(404); // <<===== This fails with the exception below.
    }

    public static class FooModule extends SimpleModule {
        {
            addAbstractTypeMapping(Foo.class, FooImpl.class);
        }
    }

}

The exception is:

feign.codec.EncodeException: No serializer found for class 
codes.asm.feign.mcce.client.FooClient$FooImpl 
and no properties discovered to create 
BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)

This issue was introduced in this commit. It seems to have somehow removed Jackson's ability to map the encoder for the interface to the implementation by explicitly calling for the interface encoder.

JavaType javaType = mapper.getTypeFactory().constructType(bodyType);
template.body(mapper.writerFor(javaType).writeValueAsBytes(object), Util.UTF_8);

Based on some experiments I think the original code would work fine, potentially with some configuration of the encoder via a Module.

As demonstrated in the test, I can work around the issue by typing the client method with the interface implementation, but this is undesirable for a number of reasons in my context.


Solution

  • I've figured out a workaround, but it's quite ugly. Create a Module and add the following serializer. I expect this to be extremely brittle and will likely just abandon the interface and go with a concrete record definition as a DTO.

     addSerializer(new JsonSerializer<Foo>() {
                @Override
                public Class<Foo> handledType() {
                    return Foo.class;
                }
    
                /**
                 * This is an ugly hack to work around this: https://github.com/OpenFeign/feign/issues/1608
                 * Alternative would be to just make Foo a concrete record
                 * instead of an interface.  That may be better.
                 */
                @Override
                public void serialize(Foo value, JsonGenerator gen,
                    SerializerProvider serializers) throws IOException {
                    gen.writeStartObject();
                    final Method[] methods = Foo.class.getMethods();
                    for (Method method : methods) {
                        try {
                            final Object result = method.invoke(value);
                            gen.writePOJOField(method.getName(), result);
                        } catch (IllegalAccessException | InvocationTargetException e) {
                            throw new IllegalArgumentException(String.format("Class %s has method %s which is not an accessible no argument getter", value.getClass(), method.getName()));
                        }
                    }
                    gen.writeEndObject();
                }
            });