# Komplexe Parameter

# Dateiupload

Um eine oder mehrere Dateien zu empfangen steht FilePart zur Verfügung:

  @PostMapping("/send-file")
  suspend fun file(theFile: FilePart)

TIP

Die Methode wurde mit suspend markiert, um asynchrone Funktionalitäten zu aktivieren, die beim Arbeiten mit Streams und dergleichen fast immer gebraucht werden.

Hier wird ein Multipart POST Request erwartet, der einen Part namens "theFile" enthält. Mittels theFile.filename() kann der originale Dateiname vom Client ausgelesen werden, sofern dieser mitgeschickt wurde. Die Header des Parts finden sich unter theFile.headers(), mit direkten Zugriffen auf wohlbekannte Header, z. B. theFile.headers().contentType.

# Content

Auf die Daten an sich kann mit theFile.content() zugegriffen werden. Hier kommt bei spring-webflux der reaktive Typ Flux<DataBuffer> zurück. Dieser macht es u. a. möglich den Content der Datei weiterzustreamen, ohne diesen vorher komplett einzulesen.

val buffer = theFile.content().awaitSingle()
val bytes = buffer.asInputStream().readBytes()

Soll der Content einfach nur im Dateisystem abgelegt werden, kann transferTo benutzt werden, was ein File oder Path Argument als Ziel erwartet. Auf diese asynchrone Operation kann dann mit awaitSingle() gewartet werden.

theFile.transferTo(Path.of("...")).awaitSingle()

# Gleichnamiger Mehrfachupload

Wurde im Request ein Part mit demselben Namen mehrfach mitgeschickt, so können die Einzelparts mit einem Parameter vom Typ Flux<FilePart> empfangen werden:

  @PostMapping("/send-files")
  suspend fun files(theFile: Flux<FilePart>) {
    theFile.collect {
      logger.info { "got: ${it.filename()}" }
    }
  }

# Klassen

Spring sorgt automatisch für die Umwandlung von JSON zu Klassen und wieder zurück. Am besten modelliert man Datenobjekte, die per JSON ausgetauscht werden, als data class. Als Beispiel:

data class MessageAndNumber(
  val message: String,
  val number: Int
)

Um ein Objekt in der Response als JSON auszugeben, reicht es, wenn die RestController-Methode eine Instanz der data class zurückgibt. Spring wandelt dieses dann mit Jackson automatisch zu JSON um.

  @GetMapping("/get")
  fun get() = MessageAndNumber("get", 1)

Rückgabe bei Aufruf von /get:

{
  "message": "get",
  "number": 1
}

Der umgekehrte Weg ist auch einfach: Um empfangenes JSON automatisch in ein Objekt umzuwandeln, genügt es den entsprechenden Typ als Methodenparameter entgegenzunehmen und diesen mit @RequestBody zu annotieren.

  @PostMapping("/double")
  fun double(@RequestBody man: MessageAndNumber) =
    MessageAndNumber(man.message.repeat(2), man.number * 2)

Request-Body für /double:

{
  "message": "doubletest",
  "number": 10
}

Antwort:

{
  "message": "doubletestdoubletest",
  "number": 20
}

# Sortierung, Paginierung

Bei APIs können auch die unter Data: Repositories beschriebenen Typen zum Festlegen der Sortierung (Sort) und Paginierung (Pageable) benutzt werden. Diese werden einfach als Methodenparameter hinzugefügt. Hier als Beispiel die Paginierung (beinhaltet auch Sortierung).

@GetMapping
fun getUsers(@PageableDefault(size = 100) pageable: Pageable): UserList {
  /* ... */
}

Im Request müssen speziell benannte Parameter in einem bestimmten Format übergeben werden. Die gewünschte Paginierung wird mit page (gewünschte Seite, zero-based, Standard: 0) und size (Ergebnisse pro Seite, Standard: 20). Die Sortierung wird mit sort festgelegt (Standard: keine Sortierung). Fehlen alle Parameter wird also standardmäßig die erste Seite mit maximal 20 Ergebnisse unsortiert angefragt.

Der Parameter sort kann mehrfach übergeben werden, falls nach mehreren Feldern sortiert werden soll. Außerdem können pro Feld die Richtung und ob Groß-/Kleinschreibung ignoriert werden soll angegeben werden. Das Format eines einzelnen sort Parameters sieht so aus: <feld>[,asc|,desc][,ignorecase]. Standard ist asc, kann also auch weggelassen werden.

Im Beispiel sieht man auch die optionale Parameter-Annotation @PageableDefault. Mit dieser kann man die Standardwerte für Sortierung und Paginierung anpassen, falls sie nicht im Request übergeben wurden.

Ein Aufruf der obigen API mit dieser URL

/api/users/?page=3&size=6&sort=age,desc&sort=name,ignorecase

bedeutet also:

  • maximal sechs Ergebnisse pro Seite
  • die vierte Seite anzeigen
  • erst sortieren nach age, absteigend
  • dann sortieren nach name, aufsteigen, Groß-/Kleinschreibung ignorieren

WARNING

Leider unterstützt Spring Boot 2.4.2 diese Typen noch nicht automatisch. Es bedarf aber lediglich des folgenden Codes und schon werden die Typen unterstützt:

import org.springframework.context.annotation.Configuration
import org.springframework.data.web.ReactivePageableHandlerMethodArgumentResolver
import org.springframework.data.web.ReactiveSortHandlerMethodArgumentResolver
import org.springframework.web.reactive.config.WebFluxConfigurer
import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer

@Configuration
class ArgumentResolvers : WebFluxConfigurer {
  override fun configureArgumentResolvers(configurer: ArgumentResolverConfigurer) {
    configurer.addCustomResolver(ReactiveSortHandlerMethodArgumentResolver())
    configurer.addCustomResolver(ReactivePageableHandlerMethodArgumentResolver())
  }
}

# Statische Ressourcen

Spring Boot liefert standardmäßig Ressourcen, die im Classpath unter /static (oder /public, /resources, /META-INF/resources) liegen automatisch unterhalb von / aus. Falls dort eine index.html Datei vorhanden ist, wird diese automatisch auch direkt als / ausgeliefert.

Dabei werden auch Client Header wie If-Unmodified-Since und If-None-Match ausgewertet und ggf. eine passende 304 (Not Modified) Response ohne Body gesendet.

Um die Standard-Orte, in denen nach statischen Ressourcen gesucht wird, zu ändern, kann die folgende Konfiguration überschrieben werden (es können auch mehrere Elemente kommagetrennt eingetragen, sowie anstatt classpath: auch file: benutzt werden):

spring.web.resources.static-locations=classpath:/my-static-files

Gefundene Dateien werden, wie gesagt, standardmäßig direkt mit dem Pattern /** angeboten, falls dort etwas angefordert wurde, zu dem es sonst keine passende Route gibt. Dies kann ebenfalls geändert werden:

spring.webflux.static-path-pattern=/my-app/**

# SPAs

Für eine Single Page Applications (SPA) ist es wichtig, dass auch bei Routen, zu denen keine physische Datei existiert, die index.html Datei ausgeliefert wird. Um die korrekte Anzeige je nach Pfad kümmert sich dann ein Router im Frontendcode.

Hier ist eine Erweiterung für Spring Webflux, welche Browser-Requests, die nicht mit /api anfangen und zu der es keine definierter Route oder statische Datei gibt, mit der CLasspathdatei /static/index.html beantwortet:

import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.annotation.Order
import org.springframework.core.io.Resource
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Mono

@Configuration
class SpaFallback {
  @Bean
  @Order(-2)
  fun handler(
    @Value("classpath:/static/index.html") indexHtml: Resource
  ) = ErrorWebExceptionHandler { exchange, ex ->
    val request = exchange.request

    when {
      // browser GET requests that don't start with /api
      request.method == HttpMethod.GET
        && request.headers.accept.contains(MediaType.TEXT_HTML)
        && !request.uri.path.startsWith("/api")
        && ex is ResponseStatusException && ex.status == HttpStatus.NOT_FOUND -> {
        val response = exchange.response

        response.statusCode = HttpStatus.OK
        response.headers.contentType = MediaType.TEXT_HTML

        val buffer = response.bufferFactory().wrap(indexHtml.inputStream.readBytes())
        response.writeWith(Mono.just(buffer))
      }
      else -> {
        // let other errors fall through
        Mono.error(ex)
      }
    }
  }
}

Hierbei werden allerdings nicht die Caching Header gesetzt, da der ErrorWebExceptionHandler eher low-level läuft.