이전 노션 블로그의 Swagger에서 MultipartFile과 DTO 한 번에 받는 @RequestPart 요청을 실행할 수 있도록 만들기 (2023.08.29)로부터 마이그레이션된 글입니다.
번거롭게 요청을 요청할 필요 없이 하나의 요청에서 파일과 데이터를 전송할 수 있도록 컨트롤러를 힘들게 구현했더니, 프론트에서 사용할 때 요청이 발생하고 Swagger에서 제대로 동작하지 않았다. 본 포스팅은 노력이 헛되지 않도록 문제를 하나씩 고쳐나간 기록들이다.
MultipartFile와 DTO를 한 번에 받을 수 있는 요청을 만들때, 다른 여타 요청들처럼 @PostMapping에 URL만 설정하면 Swagger의 Request body 섹션에서 올바르게 보지도 않을뿐더러 API Test할 때 파일(사진)을 넣을수도 없다.
Request body섹션의 Content type을 보면 알 수 있는데, ‘application/json’으로 설정되어 있다. Postman이나 클라이언트에서는 올바르게 Content type을 지정해서 보내기 때문에 문제를 못느낄 수도 있지만, Swagger는 디폴트값인 application/json으로 설정하기 때문에 이런 문제가 발생하는 것이다.
해결법은 간단하다. @PostMapping
에 consumes = MediaType.*MULTIPART_FORM_DATA_VALUE
* 을 추가해주면 된다.
@PostMapping(value = "{partnerDomain}/products/{travelProductPartnerCustomId}/reviews"**, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)**
public ResponseEntity<Void> createReview(@PathVariable String partnerDomain,
@PathVariable String travelProductPartnerCustomId,
@Valid @RequestPart ReviewCreateRequest reviewCreateRequest,
@RequestPart(required = false) List<MultipartFile> reviewImageFiles) {
// ...
만약 MultipartFile와 DTO를 한 번에 받을 수 있는 요청을 만들기 위해, @ModelAttribute
혹은 @RequestPart
을 쓰는 사람이라면 이 에러를 한 번씩은 봤을 것이다.
HttpMediaTypeNotSupportedException: Content type 'application/octet-stream' not supported
전송받은 파라미터들의 Content-type이 올바르지 않은 타입인 ‘application/octet-stream’으로 들어와서 이다. 예시와 함께 설명하겠다.
아래는 프로젝트 리뷰메이트의 ReviewController의 리뷰 생성 요청이다.
@PostMapping(value = "/api/widget/v1/{partnerDomain}/products/{travelProductPartnerCustomId}/reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Void> createReview(@PathVariable String partnerDomain,
@PathVariable String travelProductPartnerCustomId,
@Valid @RequestPart ReviewCreateRequest reviewCreateRequest,
@RequestPart(required = false) List<MultipartFile> reviewImageFiles) {
Long reviewId = reviewService.create(partnerDomain, travelProductPartnerCustomId, reviewCreateRequest, reviewImageFiles);
return ResponseEntity.created(URI.create("/api/widget/v1/reviews/" + reviewId)).build();
}
ReviewCreateRequest
라는 생성할 리뷰 데이터 DTO와 reviewImages
라는 리스트 형태의 MultipartFile인 파라미터들을 @RequestPart
로 요청받고 있다. 이 경우 파라미터를 첨부하는 쪽에서 명확히 Content type을 명시해야 한다.
reviewCreateRequest
⇒ Content-type: application/jsonreviewImages
⇒ Content-type: image/{image-extension} (ex. image/jpeg)하지만 Swagger의 경우, 파라미터마다 Content type을 지정할 수가 없다.
HttpMessageConverter나 HttpMessageConverter를 구현하는 커스텀 컨버터 컴포넌트를 추가해야 한다. 그리고 ‘application/octet-stream’ 사용을 비활성화함으로서, 에러를 방지할 수 있다. 실제로 구현할 클래스는 AbstractJackson2HttpMessageConverter로 충분하다.
@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
protected boolean canWrite(MediaType mediaType) {
return false;
}
}
⚠️ 본 문제가 해결되더라도 파라미터 별로 Content type을 지정할 수 있는 경우에는 올바른 사용법대로 파라미터별로 Content type을 지정하는 것이 좋다.
let reviewForm = new FormData();
const data = {
title : reviewInput.title ,
// ~~~
}
reviewForm.append('reviewImages', image);
reviewForm.append("reviewCreateRequest", new Blob([JSON.stringify(data)], { type: "application/json" }));
axios
.post(`/reviews`, reviewForm)
DTO를 Swagger 문서화하는 과정에서 가장 많이 사용하는 것이 @NotNull
@NotBlank
이다. Enum 타입의 필드의 경우, 엄밀히 따지면 문자열 형태로 데이터를 전달받기 때문에 @NotBlank
을 사용하지였지만 다음의 에러가 발생하였다.
javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'javax.validation.constraints.NotBlank' validating type 'com.xxx.xxx.xxx.xxx’
에러가 발생한 DTO는 다음과 같다.
public class SingleTravelProductCreateRequest {
@NotBlank
@Schema(description = "파트너사가 정의하는 상품 커스텀 ID (unique)\n\n⚠️ 서로 절대 겹치면 안됨", example = "PRODUCT-0001")
private String partnerCustomId;
@NotBlank
@Schema(description = "상품명", example = "신라더스테이 호텧")
private String name;
@NotBlank
@Schema(description = "여행상품 카테고리", example = "ACCOMMODATION")
private SingleTravelProductCategory singleTravelProductCategory;
// ...
}
에러를 해석해보자면 적절한 Validator가 없다는 뜻인데, 일반적으로 사용되는 Validator 어노테이션을 살펴보자
@NotBlank
: The annotated element must not be null and must contain at least one non-whitespace character. Accepts CharSequence.String
, CharSequence
)@NotNull
: The annotated element must not be null. Accepts any type@NotEmpty
: 본 어노테이션들은 설명에 사용가능한 타입이 명시되어 있다.@NotBlank
는 문자열이어야 한데, Enum은 결국 자료형이 문자열이 아니기 때문이다. 따라서 empty 혹은 null이 아닌 Enum을 validation하기 위해서는 커스텀 Validator를 만들어야 한다. 다음 두 가지를 추가하고,
public class EnumValidValidator implements ConstraintValidator<EnumNotNull, Enum<?>> {
@Override
public void initialize(EnumNotNull constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
return value != null;
}
}
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy=EnumValidValidator.class)
public @interface EnumNotNull {
String message() default "Invalid value";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
DTO를 다음과 같이 수정하면 정상 작동한다.
public class SingleTravelProductCreateRequest {
@NotBlank
@Schema(description = "파트너사가 정의하는 상품 커스텀 ID (unique)\n\n⚠️ 서로 절대 겹치면 안됨", example = "PRODUCT-0001")
private String partnerCustomId;
@NotBlank
@Schema(description = "상품명", example = "신라더스테이 호텧")
private String name;
@EnumNotNull
@Schema(description = "여행상품 카테고리", example = "ACCOMMODATION")
private SingleTravelProductCategory singleTravelProductCategory;
// ...
}
https://velog.io/@jsb100800/Spring-boot-Swagger-issue1
https://velog.io/@hm5395/Spring-Boot-content-type-applicationoctet-stream-not-supported-오류-해결
https://stackoverflow.com/questions/16230291/requestpart-with-mixed-multipart-request-spring-mvc-3-2