diff --git a/docs/openapi.md b/docs/openapi.md index 2011718..5319274 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -27,6 +27,36 @@ Long id, ``` +### 값이 없을 수 있는 응답 필드는 `nullable = true`로 표현한다 (필수) +응답에서 키를 생략하지 않습니다. 값이 없으면 **키는 그대로 실리고 값만 `null`** 입니다. +그래야 스펙(`nullable`)과 실제 응답이 같아지고, 프론트가 "키가 없는 경우"와 "값이 null인 경우"를 +따로 처리하지 않아도 됩니다. + +```java +// 항상 실리고 값이 없으면 null → 프론트 타입은 logoUrl: string | null +@Schema(description = "로고 이미지 URL", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) +String logoUrl, +``` + +- **응답 DTO**: 값이 없을 수 있으면 `REQUIRED` + `nullable = true`. 키가 항상 있으니 `required`에 남습니다. +- **요청 DTO**: 생략해도 되는 입력은 `NOT_REQUIRED`(+ null도 허용하면 `nullable = true`). + 요청은 키를 안 보낼 수 있으므로 응답과 규칙이 다릅니다. +- 빈 목록은 `null`이 아니라 `[]`로 줍니다. +- `@JsonInclude(NON_NULL)`을 붙이거나 `spring.jackson.default-property-inclusion`을 건드리지 마세요. + null을 지우면 스펙과 응답이 어긋납니다. + +`$ref`로 참조되는 객체 필드(예: `Prefill`)에도 같은 규칙을 씁니다. 다만 OpenAPI 3.0은 `$ref` 옆에 +다른 키워드를 둘 수 없어, swagger-core가 `nullable`을 참조 대상 컴포넌트로 밀어 넣습니다(그러면 그 +스키마를 쓰는 모든 자리가 nullable이 됩니다). `NullableSchemaConfig`가 이런 필드를 +`allOf: [$ref] + nullable`로 바꾸고 컴포넌트에 새어 들어간 `nullable`을 걷어냅니다. 작성자는 평소대로 +`nullable = true`만 달면 됩니다. + +`ApiResponse` 래퍼의 `data`/`error`/`code`는 래퍼 종류마다 채워지는 칸이 달라 +`ResponseWrapperSchemaCustomizer`가 스키마별로 `required`/`nullable`을 붙입니다. 필드에 직접 달지 마세요. + +> `OpenApiContractTest`가 nullable 표기와 컴포넌트 오염 여부를 검사합니다. + ### 요청 DTO는 검증 어노테이션을 단다 `@NotBlank`, `@Size`, `@NotNull` 등은 자동으로 `required`/`maxLength` 등으로 스펙에 반영됩니다. @@ -148,6 +178,7 @@ ApiResponse signupGoogle( - 모든 응답 본문은 `application/json`이어야 하며 `*/*`가 남으면 실패한다 - `SecurityConfig` 인증 대상 API는 `bearerAuth`를 선언하고, 401에는 성공 DTO가 아닌 공통 오류 Schema를 사용해야 한다 - 204 응답에는 `content/schema`가 없어야 한다 +- 값이 없을 수 있는 응답 필드는 `nullable`로 노출하고, 그 `nullable`이 공유 컴포넌트로 새지 않아야 한다 --- @@ -155,6 +186,7 @@ ApiResponse signupGoogle( ### 백엔드에서 한 작업 - 모든 응답/에러 스키마에 `required`·`nullable`·타입을 명시해 **codegen 타입이 정확**하도록 정리. + 값이 없는 필드도 키는 항상 실리고 값만 `null`입니다. 타입은 `field: T | null`로 생성됩니다. - 공통 응답 래퍼 `ApiResponse` / 에러 `ErrorResponse` 구조를 스펙에 노출. - 성공: `ApiResponseSampleResponse`, 목록: `ApiResponseListSampleResponse` 형태로 타입 생성됨. - operationId를 `도메인+동작`으로 안정화 (예: `createSample`, `getSampleById`). diff --git a/src/main/java/chaeso/zip/server/auth/application/dto/GoogleAuthResponse.java b/src/main/java/chaeso/zip/server/auth/application/dto/GoogleAuthResponse.java index acd7ac5..1ac6780 100644 --- a/src/main/java/chaeso/zip/server/auth/application/dto/GoogleAuthResponse.java +++ b/src/main/java/chaeso/zip/server/auth/application/dto/GoogleAuthResponse.java @@ -1,31 +1,37 @@ package chaeso.zip.server.auth.application.dto; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.swagger.v3.oas.annotations.media.Schema; /** * 구글 인증 진입({@code POST /auth/google}) 응답. 세 분기(로그인/연결 확인 필요/가입 필요) {@code status} 중 한 상태를 보여주고 - * 해당 분기에 없는 필드는 응답에서 생략. + * 해당 분기에 없는 필드는 null 로 내려간다. */ @Schema(description = "구글 인증 진입 응답") -@JsonInclude(Include.NON_NULL) public record GoogleAuthResponse( @Schema(description = "분기 판별값", requiredMode = Schema.RequiredMode.REQUIRED) Status status, - @Schema(description = "액세스 토큰. 로그인 분기에만 존재", nullable = true) String accessToken, - @Schema(description = "리프레시 토큰. 로그인 분기에만 존재", nullable = true) String refreshToken, - @Schema(description = "액세스 토큰 만료(초)", example = "1800", nullable = true) Long accessTokenExpiresIn, - @Schema(description = "리프레시 토큰 만료(초, 고정값X)", example = "1209600", nullable = true) Long refreshTokenExpiresIn, + @Schema(description = "액세스 토큰. 로그인 분기에만 존재", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String accessToken, + @Schema(description = "리프레시 토큰. 로그인 분기에만 존재", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String refreshToken, + @Schema(description = "액세스 토큰 만료(초)", example = "1800", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Long accessTokenExpiresIn, + @Schema(description = "리프레시 토큰 만료(초, 고정값X)", example = "1209600", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Long refreshTokenExpiresIn, - @Schema(description = "같은 이메일의 로컬 계정이 있어 연결 확인이 필요함. 아직 연결되지 않았다", example = "true", nullable = true) + @Schema(description = "같은 이메일의 로컬 계정이 있어 연결 확인이 필요함. 아직 연결되지 않았다", example = "true", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Boolean linkRequired, - @Schema(description = "연결 대상 계정의 이메일. 어느 계정에 붙는지 사용자가 보고 판단하도록 내려준다", nullable = true) + @Schema(description = "연결 대상 계정의 이메일. 어느 계정에 붙는지 사용자가 보고 판단하도록 내려준다", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String email, - @Schema(description = "가입 이력이 없어 추가정보 입력이 필요함", example = "true", nullable = true) + @Schema(description = "가입 이력이 없어 추가정보 입력이 필요함", example = "true", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Boolean signupRequired, - @Schema(description = "최종가입에 되돌려줄 일회성 티켓", nullable = true) String signupToken, - @Schema(description = "최종가입 폼 프리필 값", requiredMode = Schema.RequiredMode.NOT_REQUIRED) Prefill prefill) { + @Schema(description = "최종가입에 되돌려줄 일회성 티켓", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String signupToken, + @Schema(description = "최종가입 폼 프리필 값", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Prefill prefill) { public enum Status { LOGIN, LINK_REQUIRED, SIGNUP_REQUIRED @@ -35,7 +41,8 @@ public enum Status { public record Prefill( @Schema(description = "구글이 인증한 이메일", example = "user@chaeso.zip", requiredMode = Schema.RequiredMode.REQUIRED) String email, - @Schema(description = "구글 계정 이름. 계정에 이름이 없으면 null", example = "홍길동", nullable = true) + @Schema(description = "구글 계정 이름. 계정에 이름이 없으면 null", example = "홍길동", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String suggestedNickname) { } diff --git a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java index 0addc28..ca5e3ac 100644 --- a/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java +++ b/src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java @@ -306,7 +306,12 @@ ApiResponse loginMethods( "accessToken": "eyJhbGciOiJIUzI1NiJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", "accessTokenExpiresIn": 1800, - "refreshTokenExpiresIn": 1209600 + "refreshTokenExpiresIn": 1209600, + "linkRequired": null, + "email": null, + "signupRequired": null, + "signupToken": null, + "prefill": null } } """; @@ -316,8 +321,15 @@ ApiResponse loginMethods( "success": true, "data": { "status": "LINK_REQUIRED", + "accessToken": null, + "refreshToken": null, + "accessTokenExpiresIn": null, + "refreshTokenExpiresIn": null, "linkRequired": true, - "email": "user@chaeso.zip" + "email": "user@chaeso.zip", + "signupRequired": null, + "signupToken": null, + "prefill": null } } """; @@ -327,6 +339,12 @@ ApiResponse loginMethods( "success": true, "data": { "status": "SIGNUP_REQUIRED", + "accessToken": null, + "refreshToken": null, + "accessTokenExpiresIn": null, + "refreshTokenExpiresIn": null, + "linkRequired": null, + "email": null, "signupRequired": true, "signupToken": "0pxJ3n1Q...", "prefill": { diff --git a/src/main/java/chaeso/zip/server/channel/application/dto/AudienceMetricResponse.java b/src/main/java/chaeso/zip/server/channel/application/dto/AudienceMetricResponse.java index 4e33155..4c7dc12 100644 --- a/src/main/java/chaeso/zip/server/channel/application/dto/AudienceMetricResponse.java +++ b/src/main/java/chaeso/zip/server/channel/application/dto/AudienceMetricResponse.java @@ -8,13 +8,13 @@ public record AudienceMetricResponse( @Schema(description = "지표명", example = "MAU", requiredMode = Schema.RequiredMode.REQUIRED) String metricName, - @Schema(description = "지표 수치값", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "지표 수치값", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal valueNumeric, - @Schema(description = "지표 텍스트값", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "지표 텍스트값", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String valueText, - @Schema(description = "단위", example = "명", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "단위", example = "명", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String unit, - @Schema(description = "집계 기간", example = "월", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "집계 기간", example = "월", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String period) { public static AudienceMetricResponse from(ChannelAudienceMetric metric) { diff --git a/src/main/java/chaeso/zip/server/channel/application/dto/ChannelDetailResponse.java b/src/main/java/chaeso/zip/server/channel/application/dto/ChannelDetailResponse.java index bbb078f..be130c4 100644 --- a/src/main/java/chaeso/zip/server/channel/application/dto/ChannelDetailResponse.java +++ b/src/main/java/chaeso/zip/server/channel/application/dto/ChannelDetailResponse.java @@ -16,38 +16,38 @@ public record ChannelDetailResponse( UUID id, @Schema(description = "채널명", example = "11번가 광고", requiredMode = Schema.RequiredMode.REQUIRED) String name, - @Schema(description = "로고 이미지 URL", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "로고 이미지 URL", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String logoUrl, - @Schema(description = "채널 핵심 요약", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "채널 핵심 요약", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String description, @Schema(description = "대표 업종 코드값", example = "SHOPPING_COMMERCE", requiredMode = Schema.RequiredMode.REQUIRED) Category primaryCategory, - @Schema(description = "매체 유형", example = "DISPLAY", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "매체 유형", example = "DISPLAY", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String mediaType, - @Schema(description = "적합 업종 코드값 목록", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "적합 업종 코드값 목록", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) List suitableCategories, - @Schema(description = "연령대 코드값 목록", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "연령대 코드값 목록", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) List ageBandCodes, - @Schema(description = "대표 연령대", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "대표 연령대", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String primaryAgeBand, - @Schema(description = "대표 성별 코드값", example = "FEMALE", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "대표 성별 코드값", example = "FEMALE", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Gender primaryGender, - @Schema(description = "오디언스 요약", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "오디언스 요약", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String audienceSummary, - @Schema(description = "오디언스 특성", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "오디언스 특성", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String audienceTraits, - @Schema(description = "채널 강점 목록", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "채널 강점 목록", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) List advantages, - @Schema(description = "최소 예산(원)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "최소 예산(원)", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Integer minBudgetWon, - @Schema(description = "최대 예산(원)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "최대 예산(원)", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Integer maxBudgetWon, - @Schema(description = "집행 방식 코드값", example = "SELF", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "집행 방식 코드값", example = "SELF", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) ExecutionType executionType, - @Schema(description = "지원 광고 형식 목록", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "지원 광고 형식 목록", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) List adFormats, - @Schema(description = "지원 타게팅 방식 목록", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "지원 타게팅 방식 목록", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) List targetingMethods, @Schema(description = "채널 광고 상품 목록(상품 없는 채널은 빈 배열)", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/src/main/java/chaeso/zip/server/channel/application/dto/ChannelListItemResponse.java b/src/main/java/chaeso/zip/server/channel/application/dto/ChannelListItemResponse.java index 9520038..a1a83a7 100644 --- a/src/main/java/chaeso/zip/server/channel/application/dto/ChannelListItemResponse.java +++ b/src/main/java/chaeso/zip/server/channel/application/dto/ChannelListItemResponse.java @@ -13,9 +13,9 @@ public record ChannelListItemResponse( @Schema(description = "채널명", example = "11번가 광고", requiredMode = Schema.RequiredMode.REQUIRED) String name, @Schema(description = "로고 이미지 URL", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String logoUrl, - @Schema(description = "채널 핵심 요약", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "채널 핵심 요약", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String description, @Schema(description = "대표 업종 코드값", example = "SHOPPING_COMMERCE", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/src/main/java/chaeso/zip/server/channel/application/dto/PricingResponse.java b/src/main/java/chaeso/zip/server/channel/application/dto/PricingResponse.java index f479ca6..53dece3 100644 --- a/src/main/java/chaeso/zip/server/channel/application/dto/PricingResponse.java +++ b/src/main/java/chaeso/zip/server/channel/application/dto/PricingResponse.java @@ -12,15 +12,15 @@ public record PricingResponse( @Schema(description = "과금 모델 코드값", example = "CPM", requiredMode = Schema.RequiredMode.REQUIRED) PricingModel pricingModel, - @Schema(description = "단가 값", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "단가 값", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal value, - @Schema(description = "단가 상한값(구간형 단가)", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "단가 상한값(구간형 단가)", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal valueMax, - @Schema(description = "단가 적용 단위 기간", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "단가 적용 단위 기간", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String unitPeriod, - @Schema(description = "단가 적용 단위 일수", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "단가 적용 단위 일수", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal unitDays, - @Schema(description = "단가 적용 세그먼트", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "단가 적용 세그먼트", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String segment, @Schema(description = "가격 유형 코드값", example = "LIST", requiredMode = Schema.RequiredMode.REQUIRED) PriceType priceType, @@ -29,7 +29,7 @@ public record PricingResponse( Vat vat, @Schema(description = "통화 코드값", example = "KRW", requiredMode = Schema.RequiredMode.REQUIRED) CurrencyType currency, - @Schema(description = "단가 유효 기간", requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) + @Schema(description = "단가 유효 기간", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String validPeriod) { public static PricingResponse from(ChannelPricing pricing) { diff --git a/src/main/java/chaeso/zip/server/channel/application/dto/ProductResponse.java b/src/main/java/chaeso/zip/server/channel/application/dto/ProductResponse.java index 952b95a..2951f53 100644 --- a/src/main/java/chaeso/zip/server/channel/application/dto/ProductResponse.java +++ b/src/main/java/chaeso/zip/server/channel/application/dto/ProductResponse.java @@ -11,23 +11,23 @@ public record ProductResponse( @Schema(description = "상품 식별자", example = "550e8400-e29b-41d4-a716-446655440000", requiredMode = Schema.RequiredMode.REQUIRED) UUID id, - @Schema(description = "상품명", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "상품명", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String productName, - @Schema(description = "인벤토리 유형", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "인벤토리 유형", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String inventoryType, @Schema(description = "지원 광고 목표 코드값 목록", example = "[\"AWARENESS\", \"TRAFFIC\"]", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) List supportedObjectives, - @Schema(description = "최소 예산(원)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "최소 예산(원)", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Integer minBudgetWon, - @Schema(description = "최대 예산(원)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "최대 예산(원)", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Integer maxBudgetWon, - @Schema(description = "예상 노출수", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "예상 노출수", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Long expectedImpressions, @Schema(description = "예상 클릭수(예상 노출수 × CTR)", - example = "5250", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + example = "5250", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Long expectedClicks, - @Schema(description = "예상 집행 기간", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "예상 집행 기간", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) String expectedPeriod, @Schema(description = "상품 단가 목록", requiredMode = Schema.RequiredMode.REQUIRED) List pricing) { diff --git a/src/main/java/chaeso/zip/server/common/config/NullableSchemaConfig.java b/src/main/java/chaeso/zip/server/common/config/NullableSchemaConfig.java new file mode 100644 index 0000000..dff1fd2 --- /dev/null +++ b/src/main/java/chaeso/zip/server/common/config/NullableSchemaConfig.java @@ -0,0 +1,59 @@ +package chaeso.zip.server.common.config; + +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.lang.annotation.Annotation; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.customizers.PropertyCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class NullableSchemaConfig { + + @Bean + PropertyCustomizer nullableRefPropertyCustomizer() { + return (property, type) -> { + io.swagger.v3.oas.annotations.media.Schema declared = declaredSchema(type); + if (property == null || property.get$ref() == null + || declared == null || !declared.nullable()) { + return property; + } + String description = property.getDescription() != null + ? property.getDescription() + : emptyToNull(declared.description()); + return new ComposedSchema() + .addAllOfItem(new Schema<>().$ref(property.get$ref())) + .description(description) + .nullable(true); + }; + } + + @Bean + OpenApiCustomizer componentNullableStripper() { + return openApi -> { + if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) { + return; + } + openApi.getComponents().getSchemas().values() + .forEach(schema -> schema.setNullable(null)); + }; + } + + private static io.swagger.v3.oas.annotations.media.Schema declaredSchema(AnnotatedType type) { + if (type == null || type.getCtxAnnotations() == null) { + return null; + } + for (Annotation annotation : type.getCtxAnnotations()) { + if (annotation instanceof io.swagger.v3.oas.annotations.media.Schema schema) { + return schema; + } + } + return null; + } + + private static String emptyToNull(String value) { + return value == null || value.isBlank() ? null : value; + } +} diff --git a/src/main/java/chaeso/zip/server/common/config/ResponseWrapperSchemaCustomizer.java b/src/main/java/chaeso/zip/server/common/config/ResponseWrapperSchemaCustomizer.java index c1bf865..96b96db 100644 --- a/src/main/java/chaeso/zip/server/common/config/ResponseWrapperSchemaCustomizer.java +++ b/src/main/java/chaeso/zip/server/common/config/ResponseWrapperSchemaCustomizer.java @@ -1,6 +1,7 @@ package chaeso.zip.server.common.config; import chaeso.zip.server.common.response.ApiResponse; +import io.swagger.v3.oas.models.media.ComposedSchema; import io.swagger.v3.oas.models.media.Schema; import java.util.Map; import org.springdoc.core.customizers.OpenApiCustomizer; @@ -14,6 +15,7 @@ public class ResponseWrapperSchemaCustomizer { private static final String VOID_WRAPPER = WRAPPER + "Void"; private static final String DATA = "data"; private static final String ERROR = "error"; + private static final String CODE = "code"; @Bean public OpenApiCustomizer responseWrapperRequiredFields() { @@ -23,15 +25,29 @@ public OpenApiCustomizer responseWrapperRequiredFields() { return; } schemas.forEach((name, schema) -> { + if (!name.startsWith(WRAPPER)) { + return; + } if (carriesPayload(name)) { require(schema, DATA); + requireNullable(schema, ERROR); } else if (name.equals(WRAPPER)) { require(schema, ERROR); + requireNullable(schema, DATA); + } else { + requireNullable(schema, DATA); + requireNullable(schema, ERROR); } + requireNullable(schema, CODE); }); }; } + private void requireNullable(Schema schema, String field) { + markNullable(schema, field); + require(schema, field); + } + private boolean carriesPayload(String schemaName) { return schemaName.startsWith(WRAPPER) && !schemaName.equals(WRAPPER) @@ -45,4 +61,23 @@ private void require(Schema schema, String field) { schema.addRequiredItem(field); } } + + private void markNullable(Schema schema, String field) { + Schema property = property(schema, field); + if (property == null) { + return; + } + if (property.get$ref() == null) { + property.setNullable(true); + return; + } + schema.getProperties().put(field, new ComposedSchema() + .addAllOfItem(new Schema<>().$ref(property.get$ref())) + .description(property.getDescription()) + .nullable(true)); + } + + private Schema property(Schema schema, String field) { + return schema.getProperties() == null ? null : schema.getProperties().get(field); + } } diff --git a/src/main/java/chaeso/zip/server/common/response/ApiResponse.java b/src/main/java/chaeso/zip/server/common/response/ApiResponse.java index 7109264..0aac1c1 100644 --- a/src/main/java/chaeso/zip/server/common/response/ApiResponse.java +++ b/src/main/java/chaeso/zip/server/common/response/ApiResponse.java @@ -1,7 +1,5 @@ package chaeso.zip.server.common.response; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; import lombok.Getter; @@ -20,20 +18,20 @@ * @param 응답 본문 타입 */ @Getter -@JsonInclude(Include.NON_NULL) @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class ApiResponse { @Schema(description = "요청 성공 여부", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) private final boolean success; - @Schema(description = "성공 시 응답 본문. 실패 시 생략") + @Schema(description = "성공 시 응답 본문. 실패 시 null") private final T data; - @Schema(description = "실패 시 에러 정보. 성공 시 생략") + @Schema(description = "실패 시 에러 정보. 성공 시 null") private final ErrorResponse error; - @Schema(description = "성공 안내 코드. 안내할 것이 없으면 응답에서 생략") + @Schema(description = "성공 안내 코드. 안내할 것이 없으면 null", + example = "GOOGLE_ACCOUNT_LINKED") private final String code; public static ApiResponse success(T data) { diff --git a/src/main/java/chaeso/zip/server/onboarding/presentation/dto/AdHistoryRequest.java b/src/main/java/chaeso/zip/server/onboarding/presentation/dto/AdHistoryRequest.java index 681d2d9..8757ad2 100644 --- a/src/main/java/chaeso/zip/server/onboarding/presentation/dto/AdHistoryRequest.java +++ b/src/main/java/chaeso/zip/server/onboarding/presentation/dto/AdHistoryRequest.java @@ -16,26 +16,32 @@ */ @Schema(description = "과거 광고 집행 실적 수동입력 1건") public record AdHistoryRequest( - @Schema(description = "카탈로그 채널 id. 검색바에서 고른 경우에만 보낸다", nullable = true) + @Schema(description = "카탈로그 채널 id. 검색바에서 고른 경우에만 보낸다", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) UUID channelId, @Schema(description = "채널명 원문", example = "인스타그램", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 255) String channelNameRaw, - @Schema(description = "집행 예산(원)", example = "3000000", nullable = true) + @Schema(description = "집행 예산(원)", example = "3000000", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) @PositiveOrZero Long budgetWon, - @Schema(description = "노출수", example = "250000", nullable = true) + @Schema(description = "노출수", example = "250000", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) @PositiveOrZero Long impressions, - @Schema(description = "클릭수", example = "3000", nullable = true) + @Schema(description = "클릭수", example = "3000", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) @PositiveOrZero Long clicks, - @Schema(description = "전환수", example = "120", nullable = true) + @Schema(description = "전환수", example = "120", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) @PositiveOrZero Long conversions, - @Schema(description = "집행 기간(일수). 오늘 기준 최근 N일", example = "30", nullable = true) + @Schema(description = "집행 기간(일수). 오늘 기준 최근 N일", example = "30", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, nullable = true) @Positive @Max(3650) Integer periodDays) { /** periodDays를 오늘을 종료일로 삼아 시작일/종료일로 환산. periodDays가 없으면 둘 다 {@code null}. */ diff --git a/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java b/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java index 440fe45..66dce2e 100644 --- a/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java +++ b/src/main/java/chaeso/zip/server/recommendation/application/dto/RecommendationItemResponse.java @@ -3,14 +3,11 @@ import chaeso.zip.server.channel.domain.vo.PricingModel; import chaeso.zip.server.estimation.application.dto.CountRangeResponse; import chaeso.zip.server.recommendation.domain.RecommendationSnapshot; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.util.UUID; @Schema(description = "추천 채널") -@JsonInclude(Include.NON_NULL) public record RecommendationItemResponse( @Schema(description = "채널 식별자", example = "550e8400-e29b-41d4-a716-446655440000", requiredMode = Schema.RequiredMode.REQUIRED) @@ -26,25 +23,25 @@ public record RecommendationItemResponse( @Schema(description = "주요 타깃", example = "20~40대 여성", requiredMode = Schema.RequiredMode.REQUIRED) String primaryTarget, - @Schema(description = "클릭당 비용(원). 예상 클릭이 없어 환산할 수 없으면 생략", example = "150", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "클릭당 비용(원). 예상 클릭이 없어 환산할 수 없으면 null", example = "150", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal cpcWon, - @Schema(description = "대표 단가의 과금 방식. 등록된 단가가 없으면 생략", example = "CPM", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "대표 단가의 과금 방식. 등록된 단가가 없으면 null", example = "CPM", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) PricingModel pricingModel, - @Schema(description = "최소 집행 예산(원). 등록된 단가가 없으면 생략", example = "3000000", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "최소 집행 예산(원). 등록된 단가가 없으면 null", example = "3000000", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Long minBudgetWon, - @Schema(description = "예상 노출 수 범위. 추정 불가 시 생략", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "예상 노출 수 범위. 추정 불가 시 null", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) CountRangeResponse estImpressions, - @Schema(description = "예상 클릭 수 범위. 추정 불가 시 생략", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "예상 클릭 수 범위. 추정 불가 시 null", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) CountRangeResponse estClicks, @Schema(description = "온보딩 예산(상한)으로 집행 가능한지 여부", requiredMode = Schema.RequiredMode.REQUIRED) boolean isExecutable, - @Schema(description = "집행에 부족한 금액(원). 집행 가능하면 생략", example = "500000", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "집행에 부족한 금액(원). 집행 가능하면 null", example = "500000", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Long shortfallWon) { /** diff --git a/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java b/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java index ad882d9..2d5af70 100644 --- a/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java +++ b/src/main/java/chaeso/zip/server/recommendation/presentation/RecommendationApiDocs.java @@ -36,7 +36,8 @@ public interface RecommendationApiDocs { "minBudgetWon": 3000, "estImpressions": { "min": 2833333, "max": 3833333 }, "estClicks": { "min": 70833, "max": 95833 }, - "isExecutable": true + "isExecutable": true, + "shortfallWon": null }, { "channelId": "9c1e8c2a-3f4d-4a5b-9c6d-7e8f9a0b1c2e", @@ -74,7 +75,8 @@ public interface RecommendationApiDocs { "minBudgetWon": 3000, "estImpressions": { "min": 2833333, "max": 3833333 }, "estClicks": { "min": 70833, "max": 95833 }, - "isExecutable": true + "isExecutable": true, + "shortfallWon": null } ] } diff --git a/src/main/java/chaeso/zip/server/sample/application/dto/SampleResponse.java b/src/main/java/chaeso/zip/server/sample/application/dto/SampleResponse.java index 80179a2..5e04ef6 100644 --- a/src/main/java/chaeso/zip/server/sample/application/dto/SampleResponse.java +++ b/src/main/java/chaeso/zip/server/sample/application/dto/SampleResponse.java @@ -16,7 +16,7 @@ public record SampleResponse( String name, @Schema(description = "생성 시각", requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt, - @Schema(description = "수정 시각", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "수정 시각", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) LocalDateTime updatedAt) { public static SampleResponse from(Sample sample) { diff --git a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java index 67b0e35..b9da7b6 100644 --- a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java +++ b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationItemResponse.java @@ -7,46 +7,43 @@ import chaeso.zip.server.estimation.domain.vo.EstimationResult; import chaeso.zip.server.simulation.domain.BasisNote; import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.util.UUID; @Schema(description = "매체별 시뮬레이션 결과") -@JsonInclude(Include.NON_NULL) public record SimulationItemResponse( @Schema(description = "채널 id", requiredMode = Schema.RequiredMode.REQUIRED) UUID channelId, @Schema(description = "채널명", example = "11번가 광고", requiredMode = Schema.RequiredMode.REQUIRED) String channelName, - @Schema(description = "추정 근거가 된 대표 상품 id. 단가 정보가 없으면 생략", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "추정 근거가 된 대표 상품 id. 단가 정보가 없으면 null", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) UUID channelProductId, @Schema(description = "배분 예산(원). 0 은 미집행", example = "1000000", requiredMode = Schema.RequiredMode.REQUIRED) long allocatedBudgetWon, @Schema(description = "전체 예산 대비 배분 비율(%)", example = "40", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal allocationPct, - @Schema(description = "추정 노출 수 범위. 추정 불가 시 생략", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "추정 노출 수 범위. 추정 불가 시 null", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) CountRangeResponse estImpressions, - @Schema(description = "추정 클릭 수 범위. 추정 불가 시 생략", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "추정 클릭 수 범위. 추정 불가 시 null", requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) CountRangeResponse estClicks, @Schema(description = """ 클릭당 비용(원). 클릭당 과금 매체는 단가 그대로, 그 외 매체는 \ - 배분 예산 / 예상 클릭 수(중앙값)로 환산한다. 예상 클릭이 없으면 생략""", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + 배분 예산 / 예상 클릭 수(중앙값)로 환산한다. 예상 클릭이 없으면 null""", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal cpcWon, @Schema(description = """ 1000회 노출당 단가(원). 대표 단가가 CPM 일 때만 채워진다. \ 화면에는 쓰지 않고 어떤 단가로 추정했는지 남기는 값""", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) BigDecimal cpmWon, @Schema(description = "배분 예산으로 집행 가능한지 여부", requiredMode = Schema.RequiredMode.REQUIRED) boolean isExecutable, - @Schema(description = "집행에 부족한 금액(원). 집행 가능하면 생략", example = "500000", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "집행에 부족한 금액(원). 집행 가능하면 null", example = "500000", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) Long shortfallWon, @Schema(description = "산출 근거 고지", requiredMode = Schema.RequiredMode.REQUIRED) String basisNote) { diff --git a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java index adb8d66..bd93a28 100644 --- a/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java +++ b/src/main/java/chaeso/zip/server/simulation/application/dto/SimulationResponse.java @@ -2,17 +2,14 @@ import chaeso.zip.server.onboarding.domain.vo.CampaignPeriod; import chaeso.zip.server.simulation.domain.entity.BudgetSimulation; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import java.util.UUID; @Schema(description = "예산 시뮬레이션 결과") -@JsonInclude(Include.NON_NULL) public record SimulationResponse( - @Schema(description = "저장된 시뮬레이션 id. 저장 전 계산 결과에는 생략된다", - requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @Schema(description = "저장된 시뮬레이션 id. 저장 전 계산 결과에서는 null", + requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) UUID simulationId, @Schema(description = "총 예산(원)", example = "3000000", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java b/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java index 469d684..fba4d81 100644 --- a/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java +++ b/src/main/java/chaeso/zip/server/simulation/presentation/SimulationApiDocs.java @@ -29,6 +29,7 @@ public interface SimulationApiDocs { { "success": true, "data": { + "simulationId": null, "totalBudgetWon": 3000000, "period": "M1", "totalEstImpressions": 1150000, @@ -46,14 +47,21 @@ public interface SimulationApiDocs { "cpcWon": 150, "cpmWon": 3000, "isExecutable": true, + "shortfallWon": null, "basisNote": "매체 소개서 기반 / VAT 별도 가정 / CTR 미제공 시 전체 평균 CTR 적용" }, { "channelId": "9c1e8c2a-3f4d-4a5b-9c6d-7e8f9a0b1c2e", "channelName": "당근마켓 광고", + "channelProductId": null, "allocatedBudgetWon": 1000000, "allocationPct": 33.3, + "estImpressions": null, + "estClicks": null, + "cpcWon": null, + "cpmWon": null, "isExecutable": false, + "shortfallWon": null, "basisNote": "견적 문의 필요 (등록된 단가 정보 없음) / 매체 소개서 기반 / VAT 별도 가정 / CTR 미제공 시 전체 평균 CTR 적용" } ] @@ -83,6 +91,7 @@ public interface SimulationApiDocs { "cpcWon": 150, "cpmWon": 3000, "isExecutable": true, + "shortfallWon": null, "basisNote": "매체 소개서 기반 / VAT 별도 가정 / CTR 미제공 시 전체 평균 CTR 적용" } ] diff --git a/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java b/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java index 39ea27d..c24e0e2 100644 --- a/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java +++ b/src/test/java/chaeso/zip/server/channel/presentation/ChannelControllerTest.java @@ -1,6 +1,7 @@ package chaeso.zip.server.channel.presentation; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; @@ -221,6 +222,8 @@ PricingModel.CPM, new BigDecimal("3000"), null, "월", null, null, .andExpect(jsonPath("$.data.products[0].id").value(productId.toString())) .andExpect(jsonPath("$.data.products[0].expectedClicks").value(5250)) .andExpect(jsonPath("$.data.products[0].ctr").doesNotExist()) + .andExpect(jsonPath("$.data.logoUrl").value(nullValue())) + .andExpect(jsonPath("$.data.products[0].pricing[0].valueMax").value(nullValue())) .andExpect(jsonPath("$.data.products[0].pricing[0].pricingModel").value("CPM")) .andExpect(jsonPath("$.data.products[0].pricing[0].vat").value("EXCLUDED")) .andExpect(jsonPath("$.data.audienceMetrics[0].metricName").value("MAU")) diff --git a/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java b/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java index 80d738f..8824afc 100644 --- a/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java +++ b/src/test/java/chaeso/zip/server/docs/OpenApiContractTest.java @@ -243,30 +243,46 @@ void successWrappersRequireData() throws Exception { } @Test - @DisplayName("data가 없는 래퍼는 data를 required로 올리지 않는다") - void wrappersWithoutDataDoNotRequireIt() throws Exception { + @DisplayName("래퍼의 data/error/code 는 모두 required 로 노출한다") + void wrapperSlotsAreAlwaysRequired() throws Exception { JsonNode spec = loadOpenApiSpec(); JsonNode schemas = spec.path("components").path("schemas"); - for (String name : List.of("ApiResponse", "ApiResponseVoid")) { + for (String name : List.of("ApiResponse", "ApiResponseVoid", "ApiResponseSampleResponse")) { assertThat(requiredFields(schemas.path(name))) - .as("%s 는 data가 없는 응답에 쓰이므로 required 가 아니어야 합니다", name) - .doesNotContain("data"); + .as("%s 의 세 칸은 null 로라도 항상 실리므로 required 여야 합니다", name) + .contains("data", "error", "code"); } } @Test - @DisplayName("오류 응답 래퍼는 error를 required로 노출한다") - void errorWrapperRequiresError() throws Exception { + @DisplayName("래퍼에서 값이 보장되는 칸만 non-null 이고 나머지는 nullable 이다") + void onlyGuaranteedWrapperSlotIsNonNull() throws Exception { JsonNode spec = loadOpenApiSpec(); JsonNode schemas = spec.path("components").path("schemas"); - assertThat(requiredFields(schemas.path("ApiResponse"))) - .as("ApiResponse 는 오류 응답 전용이므로 error가 required 여야 합니다") - .contains("error"); - assertThat(requiredFields(schemas.path("ApiResponseVoid"))) - .as("ApiResponseVoid 는 성공 응답이므로 error가 required 가 아니어야 합니다") - .doesNotContain("error"); + assertNonNullProperty(schemas.path("ApiResponseSampleResponse"), "data"); + assertNullableProperty(schemas.path("ApiResponseSampleResponse"), "error"); + assertNonNullProperty(schemas.path("ApiResponse"), "error"); + assertNullableProperty(schemas.path("ApiResponse"), "data"); + + assertNullableProperty(schemas.path("ApiResponseVoid"), "data"); + assertNullableProperty(schemas.path("ApiResponseVoid"), "error"); + + assertNullableProperty(schemas.path("ApiResponseSampleResponse"), "code"); + assertNullableProperty(schemas.path("ApiResponse"), "code"); + } + + private void assertNullableProperty(JsonNode schema, String propertyName) { + assertThat(schema.path("properties").path(propertyName).path("nullable").asBoolean()) + .as("%s 는 그 래퍼에서 값이 보장되지 않으므로 nullable 이어야 합니다", propertyName) + .isTrue(); + } + + private void assertNonNullProperty(JsonNode schema, String propertyName) { + assertThat(schema.path("properties").path(propertyName).path("nullable").asBoolean()) + .as("%s 는 그 래퍼에서 값이 보장되므로 nullable 이 아니어야 합니다", propertyName) + .isFalse(); } @Test @@ -299,11 +315,6 @@ void wrapperSchemasSplitBySuccess() throws Exception { }); } - private List requiredFields(JsonNode schema) { - List required = new ArrayList<>(); - schema.path("required").forEach(field -> required.add(field.asText())); - return required; - } } @Nested @@ -311,24 +322,46 @@ private List requiredFields(JsonNode schema) { class DtoSchemaContracts { @Test - @DisplayName("실제 null이 가능한 채널 필드는 nullable로 노출한다") - void nullableChannelResponseFieldsArePublished() throws Exception { + @DisplayName("값이 없을 수 있는 응답 필드는 nullable 로 노출한다") + void optionalResponseFieldsArePublishedAsNullable() throws Exception { JsonNode spec = loadOpenApiSpec(); assertNullableProperties(spec, "PricingResponse", "value", "valueMax", "unitPeriod", "unitDays", "segment", "validPeriod"); assertNullableProperties(spec, "AudienceMetricResponse", "valueNumeric", "valueText", "unit", "period"); + assertNullableProperties(spec, "ApiResponse", "data", "code"); } @Test - @DisplayName("공유 컴포넌트 스키마는 nullable을 노출하지 않는다") - void componentSchemasAreNeverNullable() throws Exception { + @DisplayName("nullable 인 필드도 키는 항상 실리므로 required 에 남는다") + void nullablePropertiesStayRequired() throws Exception { JsonNode spec = loadOpenApiSpec(); - spec.path("components").path("schemas").properties().forEach(entry -> + JsonNode schema = spec.path("components").path("schemas").path("PricingResponse"); + assertThat(requiredFields(schema)) + .as("nullable 필드는 값이 null 이어도 키가 실리므로 required 여야 합니다") + .contains("value", "valueMax", "unitPeriod", "unitDays", "segment", "validPeriod"); + } + + @Test + @DisplayName("$ref 객체 필드의 nullable 은 공유 컴포넌트를 오염시키지 않는다") + void nullableRefPropertiesDoNotLeakIntoComponents() throws Exception { + JsonNode spec = loadOpenApiSpec(); + JsonNode schemas = spec.path("components").path("schemas"); + + // $ref 옆에 nullable 을 둘 수 없는 3.0 제약 때문에 allOf 로 감싸 노출한다 + JsonNode prefill = schemas.path("GoogleAuthResponse").path("properties").path("prefill"); + assertThat(prefill.path("nullable").asBoolean()) + .as("GoogleAuthResponse.prefill 은 nullable 이어야 합니다") + .isTrue(); + assertThat(prefill.path("allOf").path(0).path("$ref").asText()) + .isEqualTo("#/components/schemas/Prefill"); + + schemas.properties().forEach(entry -> assertThat(entry.getValue().path("nullable").asBoolean()) - .as("%s는 nullable일 수 없습니다. 필드에는 nullable 대신 requiredMode = NOT_REQUIRED를 사용하세요.", entry.getKey()) + .as("%s 컴포넌트는 nullable 일 수 없습니다. 필드의 nullable 이 새어 들어갔는지 " + + "NullableSchemaConfig 를 확인하세요.", entry.getKey()) .isFalse()); } } @@ -399,6 +432,12 @@ private void assertNullableProperties(JsonNode spec, String schemaName, } } + private List requiredFields(JsonNode schema) { + List required = new ArrayList<>(); + schema.path("required").forEach(field -> required.add(field.asText())); + return required; + } + private boolean requiresBearerAuth(JsonNode operation) { JsonNode security = operation.path("security"); if (!security.isArray()) { diff --git a/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java b/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java index 8dee6a4..9387349 100644 --- a/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java +++ b/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java @@ -1,11 +1,9 @@ package chaeso.zip.server.recommendation.presentation; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -92,7 +90,7 @@ void getRecommendations_success() throws Exception { .andExpect(jsonPath("$.data[0].estImpressions.min").value(850000)) .andExpect(jsonPath("$.data[0].estClicks.max").value(28750)) .andExpect(jsonPath("$.data[0].isExecutable").value(true)) - .andExpect(content().string(not(containsString("shortfallWon")))); + .andExpect(jsonPath("$.data[0].shortfallWon").value(nullValue())); } @Test