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'