์ด์ ๋ ธ์ ๋ธ๋ก๊ทธ์ Swagger์์ MultipartFile๊ณผ DTO ํ ๋ฒ์ ๋ฐ๋ @RequestPart ์์ฒญ์ ์คํํ ์ ์๋๋ก ๋ง๋ค๊ธฐ (2023.08.29)๋ก๋ถํฐ ๋ง์ด๊ทธ๋ ์ด์ ๋ ๊ธ์ ๋๋ค.
๋ฒ๊ฑฐ๋กญ๊ฒ ์์ฒญ์ ์์ฒญํ ํ์ ์์ด ํ๋์ ์์ฒญ์์ ํ์ผ๊ณผ ๋ฐ์ดํฐ๋ฅผ ์ ์กํ ์ ์๋๋ก ์ปจํธ๋กค๋ฌ๋ฅผ ํ๋ค๊ฒ ๊ตฌํํ๋๋, ํ๋ก ํธ์์ ์ฌ์ฉํ ๋ ์์ฒญ์ด ๋ฐ์ํ๊ณ Swagger์์ ์ ๋๋ก ๋์ํ์ง ์์๋ค. ๋ณธ ํฌ์คํ ์ ๋ ธ๋ ฅ์ด ํ๋์ง ์๋๋ก ๋ฌธ์ ๋ฅผ ํ๋์ฉ ๊ณ ์ณ๋๊ฐ ๊ธฐ๋ก๋ค์ด๋ค.
1. Request body์ Content-type์ด ์ฌ๋ฐ๋ฅด์ง ์์
๋ฌธ์ ์ํฉ
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) {
// ...
2. HttpMediaTypeNotSupportedException: Content type 'application/octet-stream' not supported
๋ง์ฝ 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/json
- reviewImages ⇒ Content-type: image/{image-extension} (ex. image/jpeg)
ํ์ง๋ง Swagger์ ๊ฒฝ์ฐ, ํ๋ผ๋ฏธํฐ๋ง๋ค Content type์ ์ง์ ํ ์๊ฐ ์๋ค.
Swagger์์ createReview ์์ฒญ์ API test ํตํด ์คํํ๊ธฐ ์ํด ๋ฐ์ดํฐ๋ฅผ ์ ๋ ฅํ๋ ๋ชจ์ต
ํด๊ฒฐ๋ฒ
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์ ์ง์ ํ๋ ๊ฒ์ด ์ข๋ค.
- Postman
- React
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)
3. No validator could be found for constraint ‘javax.validation.constraints.NotBlank’ on Enum
๋ฌธ์ ์ํฉ
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
- ์๋ฌด ํ์ ์ด๋ ๊ฐ๋ฅํ๋ค๊ณ ํ์ง๋ง, ์ ํํ๋ Nullableํ ํ์ ์ด์ด์ผ ํ๋ค. ์ฆ, int๋ char์ฒ๋ผ primitive ํ์ ์ ๋ถ๊ฐํ๋ค.
- @NotEmpty : ๋ณธ ์ด๋
ธํ
์ด์
๋ค์ ์ค๋ช
์ ์ฌ์ฉ๊ฐ๋ฅํ ํ์
์ด ๋ช
์๋์ด ์๋ค.
- The annotated element must not be null nor empty. Supported types are:
- CharSequence (length of character sequence is evaluated)
- Collection (collection size is evaluated)
- Map (map size is evaluated)
- Array (array length is evaluated
- The annotated element must not be null nor empty. Supported types are:
@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
https://github.com/swagger-api/swagger-ui/issues/6462