null

Swagger: как починить неработающие разнородные multipart-запросы

Swagger давно уже можно назвать своего рода стандартом среди инструментов для документирования и тестирования RESTful API. Вместо ручного написания curl’ов для проверки созданных эндпоинтов всегда удобнее воспользоваться веб-интерфейсом Swagger UI. Тем не менее в каждом решении бывают свои недоработки, баги и проблемы. Сегодня рассмотрим одну из проблем, с которой мы столкнулись при использовании Swagger в процессе разработки сервиса на базе фреймворка Spring. Проблема некритичная, но порой раздражающая и тормозящая рабочий процесс, и связана она с особенностью обработки сложных multipart запросов с разнородными компонентами в теле. Перед тем как продолжить, сразу обозначим, что нами используется актуальная спецификация OpenAPI 3.0 (в более ранних версиях Swagger с поддержкой multipart-запросов все, кажется, совсем плохо).

Итак, рассмотрим классический пример. Мы пишем Spring-приложение на Kotlin, написан простой контроллер для обработки входящих запросов, в котором есть эндпоинт, принимающий POST-запросы, содержащие в себе какой-либо файл и дополнительную метаинформацию о файле в формате JSON. Заголовок Content-Type у такого запроса будет multipart/form-data;, при этом запрос у нас сложный, гетерогенный, в котором разные части тела запроса представлены разными типами контента. Упрощенно контроллер будет выглядеть примерно так:

import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile

@RestController
@RequestMapping("/files")
class FilesController(
    private val fileService: FileService,
) {

    @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
    fun processFile(
        @RequestPart("info") metaInfo: FileInfoDto,
        @RequestPart("file") file: MultipartFile,
    ): ResponseEntity<ResultDto> {
        val executionResult = fileService.processNewFile(metaInfo, file.inputStream)
        return ResponseEntity.status(HttpStatus.CREATED).body(executionResult)
    }

}

Содержимое конкретных DTO и сервисов нас сейчас не интересует, просто представим, что все написано без ошибок и соответствует требованиям к системе. С точки зрения программного кода ошибок нет, сервис функционирует и успешно обрабатывает запросы. Но только если они посылаются не из Swagger UI. В веб-интерфейсе отправим примерно такой запрос, добавив произвольный файл

В ответ мы с большой долей вероятности получим нечто вроде:

org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'application/octet-stream' is not supported

Это означает, что Spring "не понял", что за тип данных нам отправляется, посчитав пришедшее на сервер недифференцируемым потоком бинарных данных. Spring не может сопоставить части запроса с описанными нами RequestPart, понять, где мы послали JSON, а где файл, из-за чего все разваливается. Но проблема тут не на стороне приложения, а именно в отсутствии нужной конфигурации для Swagger. Мы это поймем, если в веб-интерфейсе посмотрим curl, который swagger сгенерировал для данного запроса:

curl -X 'POST' \
  'http://localhost:8080/o/api/files' \
  -H 'accept: */*' \
  -H 'Content-Type: multipart/form-data' \
  -F 'info={"authorId": 123, "description": "something was created"}' \
  -F 'file=@img.png;type=image/png'

На первый взгляд может показаться, что все в порядке, но проблема в том, что в строке с info после JSON'а нет куска ;type=application/json

На уровне своей архитектуры OpenAPI 3.0 умеет работать с такими запросами, но нужно немного потрудиться над конфигурацией, чтобы Swagger понял, какая часть запроса каким типом представлена. Файлы Swagger способен распознать и добавить им тип по умолчанию, с JSON строкой же такого не происходит, в схеме нет нужной инструкции, а по умолчанию все строки в multipart-запросах Swagger считает типом text/plain. Таким образом, если Spring понимает, что в RequestPart ожидается JSON и способен его распарсить, то Swagger по умолчанию нет, он до последнего будет считать JSON произвольной строкой и отказываться добавлять в curl нужный тип.

К счастью, есть может и громоздкий, но рабочий способ исправления этой проблемы, который позволит жестко привязать тип контента к заданным частям тела запроса. Делается это с помощью специальных аннотаций swagger. Перепишем представленный ранее контроллер, чтобы все заработало как нужно:

import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Encoding​​​​​​​

@RestController
​​​​​​​@Tag(name="Контроллер с правильной конфигурацией swagger")
@RequestMapping("/files")
class FilesController(
    private val fileService: FileService,
) {

​​​​​​​   @Operation(
        summary = "Демонстрационный эндпоинт",
        requestBody = io.swagger.v3.oas.annotations.parameters.RequestBody(
            content = [
                Content(
                    mediaType = MediaType.MULTIPART_FORM_DATA_VALUE,
                    encoding = [
                        Encoding(name = "info", contentType = "application/json")
                    ]
                )
            ]
        )
    )
    @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
    fun processFile(
        @RequestPart("info") metaInfo: FileInfoDto,
        @RequestPart("file") file: MultipartFile,
    ): ResponseEntity<ResultDto> {
        val executionResult = fileService.processNewFile(metaInfo, file.inputStream)
        return ResponseEntity.status(HttpStatus.CREATED).body(executionResult)
    }

}

После перезапуска приложения все должно заработать, запрос, отправленный из Swagger, обработается успешно, а curl станет генерироваться корректно:

curl -X 'POST' \
  'http://localhost:8080/o/api/files' \
  -H 'accept: */*' \
  -H 'Content-Type: multipart/form-data' \
  -F 'info={
  "authorId": 123,
  "description": "something was created"
};type=application/json' \
  -F 'file=@img.png;type=image/png'

 

Вперед