Korrekturlesen bei SpringDoc-OpenAPI

“So the writer who breeds more words than he needs, is making a chore for the reader who reads.”

Dr. Seuss

Die Bibliothek SpringDoc-OpenAPI ist ein gute Ergänzung für die eigene REST API, um die Endpunkte mit einer Dokumentation zu versehen. Die Bibliothek analysiert die @RestController annotierten Klassen und extrahiert Informationen zu Endpunkten, Parametern, Requests, Responses und Fehlercodes.

Die gefundenen Informationen werden als OpenAPI Dokumentation im YAML oder JSON Format ausgegeben. Das klingt sehr gut, nur leider funktioniert es nicht ohne zusätzliche Eingriffe der Entwickler.

@GetMapping("/trees")
@Secured("ROLE_member")
public PagedModel<EntityModel<AncestorTreeDto>> findAll(Pageable pageable, PagedResourcesAssembler<AncestorTreeDto> assembler) {
  return assembler.toModel(service.findAll(pageable), this::createShortEntityModel);
}

Der hier dargestellte Endpoint liefert das folgende Fragment einer OpenAPI Spezifikation.

"/api/v1/trees": {
  "get": {
    "operationId": "findAll",
    "parameters": [ {
        "name": "pageable", "in": "query", "required": true,
        "schema": { "$ref": "#/components/schemas/Pageable" } 
      }, {
        "name": "assembler", "in": "query", "required": true,
        "schema": { "$ref": "#/components/schemas/PagedResourcesAssemblerPartnerDto" }
      }
    ],
    "responses": {
      "400": { 
	"description": "Bad Request",
        "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "200": {
        "description": "OK",
        "content": { "application/hal+json": { "schema": { "$ref": "#/components/schemas/PagedModelEntityModelAncestorTreeDto" } }
        }
      }
    }
  }
}

Die OpenAPI Beschreibung ist leider nicht sehr hilfreich, weil die beiden erkannten Parameter nicht korrekt sind. Der Parameter pageable ist nicht direkt ein Query Parameter, sondern eine hilfreiche Zusammenfassung der drei optionalen Parameter page, size und sort. Der Parameter assembler ist gar kein Query Parameter, sondern ein Hilfsobjekt, dass durch Parameter Injection zur Verfügung gestellt wird.

Für beide Fälle existieren Annotationen aus dem Swagger und dem SpringDoc Framework und die fehlerhafte Generierung zu korrigieren. Mit der Annotation @PageableAsQueryParam werden die korrekten Parameter page, size und sort hinzugefügt und mit der Annotation @Parameter werden die beiden falschen Parameter ausgeblendet.

@GetMapping("/trees")
@Secured("ROLE_member")
@PageableAsQueryParam
public PagedModel<EntityModel<AncestorTreeDto>> findAll(
     @Parameter(hidden = true) Pageable pageable, @Parameter(hidden = true) PagedResourcesAssembler<AncestorTreeDto> assembler) {
  return assembler.toModel(service.findAll(pageable), this::createShortEntityModel);
}

Durch diese Änderungen werden nun die korrekten Parameter verwendet. Diese Lösung hat aber zwei Mängel und ein grundsätzliches Problem.

"/api/v1/trees": {
  "get": {
    "operationId": "findAll",
    "parameters": [ { 
         "name": "page", "in": "query", "description": "Zero-based page index (0..N)",
         "schema": { "type": "integer", "default": 0 }
       }, {
         "name": "size", "in": "query", "description": "The size of the page to be returned",
         "schema": { "type": "integer", "default": 20 }
       }, {
         "name": "sort", "in": "query", "description": "Sorting criteria in the format: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.",
         "schema": { "type": "array", "items": { "type": "string" } }
       }
    ],
    "responses": {
      "400": { 
	"description": "Bad Request",
        "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      },
      "200": {
        "description": "OK",
        "content": { "application/hal+json": { "schema": { "$ref": "#/components/schemas/PagedModelEntityModelAncestorTreeDto" } }
        }
      }
    }
  }
}

Der offensichtliche Mängel ist das Fehlen irgendeiner Beschreibung des Endpoints. Ohne zusätzliche Informationen kann hier aber auch kein Framework weiterhelfen. Eine weitere Annotation erlaubt es aber, eine Beschreibung hinzuzufügen.

@Operation(summary = "Find all ancestor trees", description = "Find all ancestor trees for registered members.")

Der andere, nicht offensichtliche Mängel ist die fehlerhafte Beschreibung des page Parameters. Die Anwendung verwendet die Property spring.data.web.pageable.one-indexed-parameters=true. Damit sollte der Standardwert nicht 0 sondern 1 sein. Die Annotation @PageableAsQueryParam verwendet drei statisch definierte Parameter, eine eigene angepasste Annotation kann diese Problem aber umgehen.

Damit gelangt man zum eigentlichen Problem der bestehenden Lösung. Für jeden Controller und Endpoint müssen diverse Annotation hinzugefügt werden, um die OpenAPI Beschreibung zu vervollständigen oder zu korrigieren. Damit ist der Aufwand zur Dokumentation der API häufig größer als die Implementierung derselben. Dies ist nicht nur eine Qual für den Entwickler, der seine Implementierung dem REST Framework und zusätzlich dem Dokumentationsframework erklären muss. Es ist auch eine Pein für die Kollegen, die diesen Code lesen müssen.

Um diesem Dilemma zu entgehen ,gibt es bei SpringDoc die Möglichkeit, in den Generierungsprozess einzugreifen. Dazu stellt der Framework diverse Customizer Interfaces zur Verfügung. Um die Dokumentation der Parameter eines Endpoints zu verändern, bietet sich der OperationCustomizer an.

Die folgende Klasse implementiert das Interface OperationCustomizer und stellt drei Parameter page, size und sort bereit. Basierend auf den Properties spring.data.web.pageable.one-indexed-parameters und spring.data.web.pageable.default-page-size werden die Parameter page und size konfiguriert.

@Component("operationCustomizer")
public class CustomOperationCustomizer implements OperationCustomizer {
  @Value("${spring.data.web.pageable.one-indexed-parameters:false}")
  private boolean oneIndexedPageable;

  @Value("${spring.data.web.pageable.default-page-size:20}")
  private int defaultPageSize;

  @Value("${spring.data.web.pageable.max-page-size:100}")
  private int maxPageSize;

  private final Parameter page = new Parameter().in(QUERY).description("page index starting with one").name("page")
      .schema(new Schema<Integer>().type("integer"));

  private final Parameter size = new Parameter().in(QUERY).name("size").description("The size of the page to be returned")
      .schema(new Schema<Integer>().type("integer"));

  private final Parameter sort = new Parameter().in(QUERY).name("sort").description("Sorting criteria in the format: property(,(asc|desc))...")
      .schema(new Schema<Integer>().type("string"));

  private final List<Parameter> pageable = List.of(page, size, sort);

  @PostConstruct
  public void setUp() {
    page.getSchema().setDefault(oneIndexedPageable ? 1 : 0);
    size.getSchema().setDefault(defaultPageSize);
  }

  @Override
  public Operation customize(Operation operation, HandlerMethod handlerMethod) {
    List<Parameter> parameters = operation.getParameters();
    if (parameters == null) {
      return operation;
    }
    MethodLocal<Map<String, java.lang.reflect.Parameter>> parameterMap = new MethodLocal<>(
        () -> Stream.of(handlerMethod.getMethod().getParameters())
            .collect(toMap(java.lang.reflect.Parameter::getName, identity())));
    List<Parameter> list = new ArrayList<>();
    for (Parameter parameter : parameters) {
      java.lang.reflect.Parameter p = parameterMap.get().get(parameter.getName());
      if (p == null) {
        list.add(parameter);
      } else if (Pageable.class.equals(p.getType())) {
        list.addAll(pageable);
      } else if (!PagedResourcesAssembler.class.equals(p.getType()))) {
        list.add(parameter);
      }
    }
    operation.parameters(list);
    return operation;
  }
}

In der Methode customize werden alle Parameter vom Typ PagedResourcesAssembler herausgefiltert und Parameter vom Typ Pageable durch die drei bereitgestellten Parameter ersetzt. Auf diese Weise kann der Aufwand an beschreibenden Annotationen an der Methode auf die notwendige @Operation reduziert werden.

@GetMapping("/trees")
@Secured("ROLE_member")
@Operation(summary = "Find all ancestor trees", description = "Find all ancestor trees for registered members.")
public PagedModel<EntityModel<AncestorTreeDto>> findAll(
     Pageable pageable, PagedResourcesAssembler<AncestorTreeDto> assembler) {
  return assembler.toModel(service.findAll(pageable), this::createShortEntityModel);
}

Automatisch generierte OpenAPI Dokumentationen sind nicht immer das, was sich der Software Entwickler wünscht. Durch den Einsatz von eigenen Customizer Implementierungen, lässt sich aber manches korrigieren.