swagger-core icon indicating copy to clipboard operation
swagger-core copied to clipboard

Generic Response Types

Open croman17 opened this issue 4 years ago • 29 comments

When an API returns a generic response type swagger-core:

  1. The swagger-core generated object name is not linked to the API definition response.
  2. Does not provide a means to customize the name of the returned object.

Example:
ResponseBody<List<String>> getTeamMemberNames(Long teamId);

Swagger-core generates:
schema object: ResponseBodyListString (not a name we would want to expose)

  "ResponseBodyListString" : {
    "type" : "object",
    "properties" : {
      "data" : {
        "type" : "array",
        "items" : {
          "type" : "string"
        }
      }
    }
  },

API definition: "/team/membernames" : { "get" : { "operationId" : "getTeamMemberNames", "parameters" : [ { "name" : "teamId", "in" : "path", "required" : true, "schema" : { "type" : "integer", "format" : "int64" } } ], "responses" : { "200" : { "description" : "OK", "content" : { "application/json" : { "schema" : { "$ref" : "#/components/schemas/ResponseBody" } } } }, } } },

You can see above, that the API definition response returns "ResponseBody" the name of the generic class. Rather than the generated ResponseBodyListString object.

Current workaround: We define a custom "doc" class and use that in the Operation annotation.
This requires the doc class and the actual implementation are kept in-sync.

@Operation(summary = "Gets team member names", 
    description = "Gets list of team member names.",
    responses = {
         @ApiResponse(responseCode = "200", description = "OK", 
             content = @Content(schema = @Schema(implementation = TeamMemberNamesResponse.class))),
     })    

croman17 avatar Oct 07 '19 19:10 croman17

This is really bad ... Generics are not supported properly due to this issue.
What can be done ? How can we help ?

idkw avatar Aug 03 '21 17:08 idkw

@idkw @croman17 I am not sure if you were able to resolve the issue or still facing this issue. I was also stuck with this scenario and doing some POCs. I was able to handle this scenario by not providing content definition in the ApiResponse. @Operation(summary = "Gets team member names", description = "Gets list of team member names.", responses = { @ApiResponse(responseCode = "200", description = "OK"), })

Can you try above definition and it should work for generic responses.

Spring boot version: 2.5.4 Spring doc openapi: 1.5.10

amjadaziz817 avatar Sep 06 '21 09:09 amjadaziz817

I am seeing the similar issue. Controller

@GetMapping( "/loads" )
public LoadPage getShipmentLoads
public class LoadPage extends Page<Load>
public class Page<T> {
     String one; //able to see in swagger
     ObjectClass foo; //able to see in swagger
     
     List<T> realInfo; //not able to see in swagger
}

Load class is simple POJO.

If I follow advice I've seen other places by creating a private class in the controller as such it works, but this is not a great solution since Page is in a separate library.

private static class SwaggerLoadPage {

        String one;
        ObjectClass foo;

        List<Load> results;
    }

We are currently trying to migrate from Springfox to OpenApi. This somehow works with SpringFox as is. I think this could also be tied to the fact that Page is in a different library and using different versions of Swagger....

chelsternp44 avatar Sep 10 '21 16:09 chelsternp44

I found the issue on my side, it appeared that if you annotate you class with a @Schema and a name springfox doesn't generate a distinct definition for every variant, but if you don't annotate it, it does generate a definition for every variant. Not sure if this is a bug though ?

TL; DR; :

Don't do this :

@Schema(name="Page")
public class ListResponse<T> {     
     List<T> data;
     // ... other fields
}

But do this :

public class ListResponse<T> {     
     List<T> data; 
     // ... other fields
}

Then when you have a controller with something like :

public ListResponse<Book> getBooks() {
   return new ListResponse<>(List.of(new Book("1")));
}

Then you'll get a proper definition of something like ListResponseBook

idkw avatar Sep 10 '21 16:09 idkw

unfortunately our generic field is nested in a class from a separate library that I don't think we can make changes to easily.

Also we were on SpringFox it was working but with springdoc-openapi it is not

chelsternp44 avatar Sep 10 '21 17:09 chelsternp44

@chelsternp44 while you work with the maintainer of the separate library to remove the @Schema annotation, you can remove it temporarily on your side at runtime using bytecode manipulation, have a look at https://uzxmx.github.io/add-or-remove-java-annotation-at-runtime.html

This is really hacky but at least it works

idkw avatar Sep 10 '21 17:09 idkw

It turns out that our issue is due to a javax annotation breaking it. We are no longer seeing the issue once we dealt with that.

import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;

...
@XmlAccessorType(XmlAccessType.NONE)

chelsternp44 avatar Sep 13 '21 16:09 chelsternp44

@chelsternp44 Is there any update on this issue ??

varunsufi avatar May 16 '22 13:05 varunsufi

@varunsufi - I did not work on this. We figured out what we were dealing with was an issue on our end

chelsternp44 avatar May 16 '22 14:05 chelsternp44

@chelsternp44 Is there any update on this issue ??

Have you tried this workaround, it has been working great for us so far

https://github.com/swagger-api/swagger-core/issues/3323#issuecomment-917043167

idkw avatar May 16 '22 15:05 idkw

@chelsternp44 Is there any update on this issue ??

Have you tried this workaround, it has been working great for us so far

#3323 (comment)

I don't really remember the details of working on this haha. But based on here https://github.com/swagger-api/swagger-core/issues/3323#issuecomment-918345504 I think we got it working. Long time ago.

chelsternp44 avatar May 16 '22 15:05 chelsternp44

#3323 (comment)

Thank you for discussion for this issue. This is a workaround, but it's still so bad. More code, more class and definition and have to sync them. But how micronaut can do ? https://micronaut-projects.github.io/micronaut-openapi/1.4.1/guide/index.html#schemasAndGenerics. Is this any plan and solution to fix it ? @croman17 @idkw

  • The swagger-core generated object name is not linked to the API definition response. -> Why does api definition only has ResponseBody ? why don't generate with ResponseBody$List$String. Because of swagger core ? I remember springfox can do it.

  • Does not provide a means to customize the name of the returned object. -> new version of spring doc has @Content(@schema). However It can't write (implement= ResponseBody<List<String>> .class) in annotation.

seabird86 avatar May 21 '22 01:05 seabird86

     .....   
        text/plain:
              schema:
                $ref: '#/components/schemas/Response<Money>'
components:
  schemas:
    Response<Money>:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/Money'
    Money:
      type: object
      properties:
        amount:
          type: integer
          format: int32

I see in micronaut, it happen like this

seabird86 avatar May 21 '22 11:05 seabird86

Awesome, I can customize with generic response:

@Bean
    public OperationCustomizer customize(){
        return (operation,method) ->{
            ApiResponses responses = operation.getResponses();
            if (method.getMethod().getReturnType().equals(ResponseEntity.class)){
                Type type = ((ParameterizedType)((ParameterizedType)method.getMethod().getGenericReturnType()).getActualTypeArguments()[0]).getActualTypeArguments()[0];
                ResolvedSchema resolvedSchema = ModelConverters.getInstance().resolveAsResolvedSchema(new AnnotatedType(type));
                Map<String, Schema> schemas = openAPIService.getCalculatedOpenAPI().getComponents().getSchemas();
                schemas.put(resolvedSchema.schema.getName(),resolvedSchema.schema);
                Schema schema = new ObjectSchema()
                        .type("object")
                        .addProperty("data",schemas.get(resolvedSchema.schema.getName()))
                        .name("ResponseBody<%s>".formatted(resolvedSchema.schema.getName()));
                schemas.put("ResponseBody<%s>".formatted(resolvedSchema.schema.getName()),schema);
                responses.addApiResponse("Success",new ApiResponse().content(new Content().addMediaType("application/json",new MediaType().schema(new ObjectSchema().$ref(schema.getName())))));
            }

            return operation;
        };
    }

Then it will be like this:

Success:
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResponseBody<CustomerCreateResponse>'
...
    CustomerCreateResponse:
      type: object
      properties:
        id:
          type: integer
          format: int64
    ResponseBody<CustomerCreateResponse>:
      type: object
      properties:
        data:
          type: object
          properties:
            id:
              type: integer
              format: int64

seabird86 avatar May 22 '22 15:05 seabird86

@seabird86 great solution!

I have a question about this line: Map<String, Schema> schemas = openAPIService.getCalculatedOpenAPI().getComponents().getSchemas();

I tried to implement this solution adapting to my case, but I couldn't identify how you reached at this implementation: openAPIService.getCalculatedOpenAPI()

santosfyuri avatar Jul 07 '22 13:07 santosfyuri

openAPIService.getCalculatedOpenAPI() -> This way to get the current instance OpenAPI from OpenAPIService. You must have :

@Autowired OpenAPIService openAPIService;

seabird86 avatar Jul 07 '22 23:07 seabird86

@seabird86 Thanks a lot!

santosfyuri avatar Jul 11 '22 13:07 santosfyuri

I found the issue on my side, it appeared that if you annotate you class with a @Schema and a name springfox doesn't generate a distinct definition for every variant, but if you don't annotate it, it does generate a definition for every variant. Not sure if this is a bug though ?

TL; DR; :

Don't do this :

@Schema(name="Page")
public class ListResponse<T> {     
     List<T> data;
     // ... other fields
}

But do this :

public class ListResponse<T> {     
     List<T> data; 
     // ... other fields
}

Then when you have a controller with something like :

public ListResponse<Book> getBooks() {
   return new ListResponse<>(List.of(new Book("1")));
}

Then you'll get a proper definition of something like ListResponseBook

I have the same question.

shadon178 avatar Oct 31 '22 06:10 shadon178

return ResponseEntity<T>

or

ConverterUtils.addResponseWrapperToIgnore(YourOwnResponseEntity.class);

 public class ResponseSupportConverter implements ModelConverter {

	/**
	 * The Spring doc object mapper.
	 */
	private final ObjectMapperProvider springDocObjectMapper;

	/**
	 * Instantiates a new Response support converter.
	 *
	 * @param springDocObjectMapper the spring doc object mapper
	 */
	public ResponseSupportConverter(ObjectMapperProvider springDocObjectMapper) {
		this.springDocObjectMapper = springDocObjectMapper;
	}

	@Override
	public Schema resolve(AnnotatedType type, ModelConverterContext context, Iterator<ModelConverter> chain) {
		JavaType javaType = springDocObjectMapper.jsonMapper().constructType(type.getType());
		if (javaType != null) {
			Class<?> cls = javaType.getRawClass();
			if (isResponseTypeWrapper(cls) && !isFluxTypeWrapper(cls)) {
				JavaType innerType = javaType.getBindings().getBoundType(0);
				if (innerType == null)
					return new StringSchema();
				return context.resolve(new AnnotatedType(innerType)
						.jsonViewAnnotation(type.getJsonViewAnnotation())
						.ctxAnnotations((type.getCtxAnnotations()))
						.resolveAsRef(true));
			}
			else if (isResponseTypeToIgnore(cls))
				return null;
		}
		return (chain.hasNext()) ? chain.next().resolve(type, context, chain) : null;
	}

}

zhangk-info avatar Jan 06 '23 09:01 zhangk-info

I made this, Created a class with generic type named PaginationResponse, that I wanted to use as the response type for swagger. I defined an extra class, named SwaggerCategoryResponse that extends PaginationResponse and it seems to work as expected now.

public class SwaggerCategoryResponse extends PaginationResponse<CategoryResponse>

Imagen2 Imagen1

FranH20 avatar Apr 28 '23 04:04 FranH20

I made this, Created a class with generic type named PaginationResponse, that I wanted to use as the response type for swagger. I defined an extra class, named SwaggerCategoryResponse that extends PaginationResponse and it seems to work as expected now.

public class SwaggerCategoryResponse extends PaginationResponse<CategoryResponse>

Imagen2 Imagen1

a lot of extra work and classes...why this can't be automatically done by spring-doc?

paulux84 avatar May 12 '23 09:05 paulux84

Micronaut nailed it. I think, because of limitation of Annotation for java, so Spring-doc cannot do, and wait some one impove spring-doc again.

seabird86 avatar May 14 '23 06:05 seabird86

Thank you for your inspiration @seabird86 . In springdoc 1.7.0 (with swagger-models-2.2.9), I change the code for supply the generic type support

my generic type java bean is

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Resp<T> {
    @Schema(description = "0: ok; 1: failed")
    private int code;

    @Schema(description = "ok or failed")
    private String msg;

    @Schema(description = "data", nullable = true)
    private T data;
}

and I add this config to support generic type

@Configuration
public class OpenApiConfig {

    private Map<String, Schema> respSchemas = new ConcurrentHashMap<>();

    @Bean
    public OpenApiCustomiser addGenericSchemaToOpenApi() {
        return openApi -> {
            Map<String, Schema> schemas = openApi.getComponents().getSchemas();

            Set<String> shouldRemoveRespSchemas = schemas.entrySet()
                    .stream()
                    .filter(kv -> {
                        if (!kv.getKey().startsWith(Resp.class.getSimpleName())) {
                            return false;
                        }
                        Map fieldsProperties = kv.getValue().getProperties();
                        Field[] respFields = Resp.class.getDeclaredFields();
                        if (fieldsProperties.size() != respFields.length) {
                            return false;
                        }

                        Set<String> respFieldSet = Arrays.stream(respFields)
                                .map(Field::getName)
                                .collect(Collectors.toSet());

                        if (Sets.intersection(fieldsProperties.keySet(), respFieldSet).size() != respFields.length) {
                            return false;
                        }
                        return true;
                    })
                    .map(Entry::getKey)
                    .collect(Collectors.toSet());

            shouldRemoveRespSchemas.forEach(schemas::remove);
            schemas.putAll(respSchemas);
        };
    }

    @Bean
    public OperationCustomizer customize() {
        // 1. collect RespXxx
        // 2. replace $ref RespXxx to Resp<Xxx>

        return (operation,method) -> {
            ApiResponses responses = operation.getResponses();
            if (method.getMethod().getReturnType().equals(Resp.class)) {

                ResolvedSchema baseRespSchema = ModelConverters.getInstance()
                        .resolveAsResolvedSchema(new AnnotatedType(Resp.class));

                Map<String, Schema> fieldsSchema = Maps.newLinkedHashMap();
                fieldsSchema.putAll(baseRespSchema.schema.getProperties());

                Class actualTypeArgument = (Class) ((ParameterizedTypeImpl) method.getMethod()
                        .getGenericReturnType()).getActualTypeArguments()[0];
                ResolvedSchema resolvedSchema = ModelConverters.getInstance()
                        .resolveAsResolvedSchema(new AnnotatedType(actualTypeArgument));
                String respSchemaName;
                if (resolvedSchema.schema != null) {
                    // override data field schema
                    if (resolvedSchema.referencedSchemas.isEmpty()) {
                        fieldsSchema.put("data", resolvedSchema.schema);
                    } else {
                        fieldsSchema.put("data", new ObjectSchema().$ref(actualTypeArgument.getSimpleName()));
                    }

                    respSchemas.putAll(resolvedSchema.referencedSchemas);

                    respSchemaName = "Resp<" + actualTypeArgument.getSimpleName() + ">";
                } else {
                    // override data field schema
                    Schema originDataSchema = fieldsSchema.get("data");
                    fieldsSchema.put("data", new MapSchema()
                            .description(originDataSchema.getDescription())
                            .nullable(originDataSchema.getNullable()));

                    respSchemaName = "Resp<Map>";
                }

                Schema schema = new ObjectSchema().type("object")
                        .properties(fieldsSchema)
                        .name(respSchemaName);

                // // replace ref '#/components/schemas/RespXxx' to '#/components/schemas/Resp<Xxx>'
                for (ApiResponse apiResponse : responses.values()) {
                    for (MediaType mediaType : apiResponse.getContent().values()) {
                        Schema originApiResponseSchema = mediaType.getSchema();
                        if (originApiResponseSchema.get$ref() != null
                                && originApiResponseSchema.get$ref().startsWith("#/components/schemas/Resp")) {
                            originApiResponseSchema.$ref(schema.getName());
                        }
                    }
                }

                respSchemas.put(respSchemaName, schema);
            }

            return operation;
        };
    }
}

And finally swagger-ui can identify the generic / nested structure: image

924060929 avatar May 25 '23 07:05 924060929

If you wanna just to change the default name generation behaviour for generic types globally, you could replace default TypeNameResolver with your custom implementation. You need to override method

protected String nameForGenericType(JavaType type, Set<Options> options)

with your logic.

For example:

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.apache.commons.text.WordUtils;

import com.fasterxml.jackson.databind.JavaType;

import io.swagger.v3.core.jackson.TypeNameResolver;
import io.swagger.v3.core.util.PrimitiveType;

public class CustomTypeNameResolver extends TypeNameResolver {

    @Override
    protected String nameForGenericType(JavaType type, Set<Options> options) {
        String baseName = this.nameForClass(type, options);
        int count = type.containedTypeCount();

        List<String> genericNames = new ArrayList<>();
        for (int i = 0; i < count; ++i) {
            JavaType arg = type.containedType(i);
            String argName = PrimitiveType.fromType(arg) != null ? this.nameForClass(arg, options)
                    : this.nameForType(arg, options);
            genericNames.add(WordUtils.capitalize(argName));
        }
        String generic = genericNames.stream().collect(Collectors.joining(", ", "<", ">"));
        return baseName + generic; // will return "Example<Foo, Bar>" 
    }
}

RodionKorneev avatar Jun 16 '23 06:06 RodionKorneev

i made it just using the annotation @Schema at my generic data .my commonResult bean is

@Slf4j
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Schema(description = "the common response")
public class CommonResult<T> extends ErrorInfo implements Serializable{

   
    @Schema(description = "responseCode", type = "Integer")
    @NotNull
    private Integer code;
    
    @Schema(description = "responseMsg", type = "String")
    private String message;
    //  using the subTypes  to support generic response type
    @Schema(description  = "response data",subTypes = Objects.class)

    private T data;

    @Schema(description = "request id", type = "String")
    private String requestId;


}

and my dependency is springfox 3.0.0

          <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-boot-starter</artifactId>
                <version>3.0.0</version>
            </dependency>

And finally swagger-ui can identify the generic / nested structure: image

taku-hua avatar Sep 21 '23 12:09 taku-hua

i made it just using the annotation @Schema at my generic data .my commonResult bean is

@Slf4j
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@Schema(description = "the common response")
public class CommonResult<T> extends ErrorInfo implements Serializable{

   
    @Schema(description = "responseCode", type = "Integer")
    @NotNull
    private Integer code;
    
    @Schema(description = "responseMsg", type = "String")
    private String message;
    //  using the subTypes  to support generic response type
    @Schema(description  = "response data",subTypes = Objects.class)

    private T data;

    @Schema(description = "request id", type = "String")
    private String requestId;


}

and my dependency is springfox 3.0.0

          <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-boot-starter</artifactId>
                <version>3.0.0</version>
            </dependency>

And finally swagger-ui can identify the generic / nested structure: image

It solved my problem.

ytk929 avatar Sep 25 '23 10:09 ytk929

Is there an official solution to this problem? don't use a custom method to implementing like this: image

Springdoc directly calls the method inside swagger-core, but Springfox has its own method of parsing the paradigm.

public class DefaultGenericTypeNamingStrategy implements GenericTypeNamingStrategy {
  private static final String OPEN = "«";
  private static final String CLOSE = "»";
  private static final String DELIM = ",";
  //other code xxxx
}

cloudlessa avatar Nov 27 '23 02:11 cloudlessa

Why would I want ResponseBody<List<String>> to be mapped to ResponseBodyListString in the first place? This does not make sense, I don't want a new type to be generated. I want ResponseBody to be generated and then probably items.type should contain all possible types found in the endpoints? (in this case only string). Is there any way to do that?

atrifyllis avatar Feb 13 '24 20:02 atrifyllis

There's a blog post on this topic: https://json-schema.org/blog/posts/dynamicref-and-generics

Maybe it will be helpful

AlexElin avatar Apr 15 '24 16:04 AlexElin