REST Attribute modifizieren in Spring Boot

Bei der Weiterentwicklung einer REST API kommt es hin und wieder zu veränderten Darstellungen der Attribute. Je nachdem wie der Versionierung der eigenen API gestaltet wurden, ergeben sich eine Reihe von Möglichkeiten mit geänderten Attributen umzugehen.

Das Problem kann mit dem folgenden einfachen Beispiel demonstriert werden. Das Attribut phoneNumber stellt eine internationale Telefonnummer dar.

{
  "phoneNumber":{ 
    "cc": "49",
    "ndc": "521"
    "sn": "112233"
  }
}

Die erste Darstellung stellt die Telefonnummer als Objekt dar und die nächste Darstellung in Form eines String.

{
  "phoneNumber":"+49 521 112233";
}

Die erste Darstellung wurde in Version 1 der API verwendet und nun soll in der Version 2 die zweite Darstellung verwendet werden. Ein Problem tritt auf, wenn beide Versionen von der selben Spring Boot Anwendung angeboten werden sollen.

Der Ausgangspunkt ist die Klasse MemberController mit einer einzelnen Endpoint-Methode.

@RestController
@AllArgsConstructor
public class MemberController {

  private final MemberService service;
  private final MemberMapper mapper;

  @GetMapping("/v1/ancestors/{id}")
  public MemberDto getMember(@PathVariable int memberId) {
    return service.findMember(memberId).map(mapper::map).orElseThrow();
  }
}

Diese Implementierung liefert für ein Vereinsmitglied ein DTO das die Telefonnummer als InternationalPhoneNumber aus der Telephone Bibliothek enthält.

@Getter
@Setter
public class MemberDto {
  // ...
  private InternationalPhoneNumber phoneNumber;
}

Wie kann nun ein zweiter Endpoint hinzugefügt werden, der die InternationalPhoneNumber in einer anderen Form darstellt. Der erste und einfachste Ansatz das Problem zu lösen, ist den Typ des Attribute zu vernachlässigen. Tatsächlich ist es für den Empfänger der REST Antwort völlig unerheblich, mit welchem Typ der Server das Attribut dargestellt hat. Die Antwort ist im JSON Format und woraus sie generiert wurde ist egal.

@RestController
@AllArgsConstructor
public class MemberController {

  private final MemberService service;
  private final MemberMapper mapper;

  @GetMapping("/v1/ancestors/{id}")
  public MemberDto getMember(@PathVariable int memberId) {
    return service.findMember(memberId).map(mapper::map).orElseThrow();
  }

  @GetMapping("/v2/ancestors/{id}")
  public MemberV2Dto getMember(@PathVariable int memberId) {
    return service.findMember(memberId).map(mapper::mapV2).orElseThrow();
  }
}

Die zweite Endpoint-Methode erzeugt ein anderes DTO vom Typ MemberV2Dto, dass ein phoneNumber Attribut vom Typ String enthält. Die Mapper Methode mapV2 unterscheidet sich von der bisherige map Methode, durch die zusätzliche Umwandlung von InternationalPhoneNumber in String.

Die Variante mag trivial erscheinen, ist aber eine valide Lösung für das Problem. Da ältere Versionen einer API nicht lange beibehalten werden, können unnötige Mapper-Methoden und DTO auch nach geraumer Zeit entsorgt werden. Auch gilt bei dieser Lösung das alte Zitat “No code is faster than no code”.

Eine andere Variante um den zweiten Endpoint zu implementieren ist der Einsatz der Jackson Annotation @JsonUnwrapped. Diese Annotation und einige ihrer Geschwister wurden schon im Beitrag Dr. REST oder: Wie ich lernte Jackson zu lieben vorgestellt. Die Annotation sorgt dafür, dass die Attribute des annotierten Attributes so dargestellt werden, als wären sie Attribute des übergeordneten Objektes.

Der folgende Wrapper für das MemberDTO erzeugt die selbe Ausgabe, wie die vorherige Lösung.

public class MemberWrapper {
  private String phoneNumber;

  @JsonUnwrapped 
  @JsonIgnoreProperties("phoneNumber")
  private MemberDto dto;

  public MemberWrapper(MemberDto dto) {
    this.dto = dto;
    phoneNumber = dto.getPhoneNumber().toString();
  }
}

Im Konstruktor wird die Instanz von MemberDto dem Attribute dto zugewiesen und das Attribute phoneNumber erhält die String Darstellung der Telefonnummer aus dem DTO. Die zusätzliche Annotation @JsonIgnoreProperties sorgt dafür, das ursprüngliche Attribute phoneNumber zu verbergen. Sonst gibt es nämlich zwei von ihnen und einen Syntaxfehler.

Ohne die Annotationen würde die folgende JSON Ausgabe erzeugt werden.

{
  "phoneNumber":"+49 521 112233";
  "dto":{
    "phoneNumber":{ 
      "cc": "49",
      "ndc": "521"
      "sn": "112233"
    }
  }
}
}

Die Endpoint Methode für diesen Ansatz sieht ähnlich aus wie die vorherige. Nur für die Version 2 wird ein weiterer Schritt im Stream-Mapping vollführt um das MemberDto in den MemberWrapper zu stecken.

@RestController
@AllArgsConstructor
public class MemberController {
  private final MemberService service;
  private final MemberMapper mapper;

  @GetMapping("/v1/ancestors/{id}")
  public MemberDto getMember(@PathVariable int memberId) {
    return service.findMember(memberId).map(mapper::map).orElseThrow();
  }

  @GetMapping("/v2/ancestors/{id}")
  public MemberDto getMember(@PathVariable int memberId) {
    return service.findMember(memberId).map(mapper::map).map(MemberWrapper::new).orElseThrow();
  }
}

Die ersten beiden Lösungen sind Variationen der gleichen Grundidee, nämlich für zwei unterschiedliche Darstellungen zwei unterschiedliche Datentypen zu verwenden.

Die dritte Lösung hingegen verwendet für beide Darstellungen die Klasse InternationalPhonenumber. Dafür muss aber etwas tiefer in die Trickkiste von Spring Boot und Jackson hineingegriffen werden. Davon abgesehen, ob man diese Lösung gut findet oder ablehnt, zeigt sie eine weitere Möglichkeit in Spring Boot um in die Generierung von JSON Responses einzugreifen.

Die erste Zutat für die Lösung ist ein Jackson Filter am DTO, damit wird bei der Serialisierung ein Filter aufgerufen, der unter dem Namen simple-telephone registriert ist.

@Getter
@Setter
@JsonFilter("simple-telephone")
public class MemberDto {
  // ...
  private InternationalPhoneNumber phoneNumber;
}

Dieser Filter wird nur bei MemberDto Instanzen genutzt, dafür muss er aber dem Jackson ObjectMapper bekannt gemacht werden. Wichtig ist auch, dass dies dynamisch passieren muss. Denn für die Version 1 wird ein anderer Filter benötigt wie für die Version 2.

Die zweite Zutat ist eine eigene Annotation @ApiVersion an der Endpoint-Methode für die Version zwei. Durch sie können die Versionen beim Aufruf unterschieden werden. Es gibt sicherlich auch andere Möglichkeiten, aber Annotationen sind bei Spring Boot nicht ganz unüblich.

@RestController
@AllArgsConstructor
public class MemberController {
  private final MemberService service;
  private final MemberMapper mapper;

  @GetMapping("/v1/ancestors/{id}")
  public MemberDto getMember(@PathVariable int memberId) {
    return service.findMember(memberId).map(mapper::map).orElseThrow();
  }

  @ApiVersion("v2")
  @GetMapping("/v2/ancestors/{id}")
  public MemberDto getMember(@PathVariable int memberId) {
    return service.findMember(memberId).map(mapper::map).orElseThrow();
  }
}

Die Implementieren für beide Endpoint-Methoden unterscheiden sich nur in der einzelnen Annotation. Die ganze Magie für die unterschiedlichen Ausgaben wirkt in der Klasse SimpleTelephoneControllerAdvice. Sie ist die letzte Zutat und ein von der Klasse AbstractMappingJacksonResponseBodyAdvice abgeleiteter RestControllerAdvice. Ihre Methode beforeBodyWriteInternal wird direkt vor dem Schreiben der JSON Response aufgerufen und erlaubt die Abfrage und Modifikation diverser beteiligter Objekte.

@RestControllerAdvice
@Slf4j
class SimpleTelephoneControllerAdvice extends AbstractMappingJacksonResponseBodyAdvice {
  private static SimpleFilterProvider DEFAULT = new SimpleFilterProvider(singletonMap("simple-telephone", serializeAll()));
  private static SimpleFilterProvider V2 = createFilterProviderV2();

  @Override
  protected void beforeBodyWriteInternal(MappingJacksonValue bodyContainer, MediaType contentType,
      MethodParameter returnType, ServerHttpRequest request, ServerHttpResponse response) {
    String version = Optional.ofNullable(returnType.getMethod().getAnnotation(ApiVersion.class)).map(ApiVersion::value)
        .filter(Predicate.not(String::isBlank)).orElse("v1");
    bodyContainer.setFilters("v1".equals(version) ? DEFAULT : V2);
  }

  private static SimpleFilterProvider createFilterProviderV2() {
    SimpleFilterProvider filterProvider = new SimpleFilterProvider();
    filterProvider.addFilter("simple-telephone", new SimpleBeanPropertyFilter() {
      @Override
      public void serializeAsField(Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer) throws Exception {
        if (include(writer)) {
          Object value = writer.getMember().getValue(pojo);
          if (value instanceof InternationalPhoneNumber internationalPhoneNumber) {
            jgen.writeStringField(writer.getName(), internationalPhoneNumber.toString());
          } else {
            writer.serializeAsField(pojo, jgen, provider);
          }
        }
      }
    });
    return filterProvider;
  }
}

Anhand des MethodParameter Parameters wird geprüft, ob es eine Response mit der Version 1 oder der Version2 ist. Bei der Version 1 wird auf dem bodyContainer Parameter ein Standard Filter gesetzt, der alle Attribute durchlässt. Er entspricht dem NULL Pattern und ist nötig, weil sich ansonsten Jackson über einen fehlenden Filter beschwert. Alle Attribute werden behandelt, als gäbe es keinen Filter und die Instanzen von InternationalPhoneNumber werden als Objekt geschrieben.

Bei der Version 2 wird für die Attribute geprüft, ob sie vom Typ InternationalPhoneNumber sind. Ist dies der Fall, dann werden sie als String Attribute geschrieben- Alle anderen Attribute werden normal verarbeitet.

Schreibe einen Kommentar