Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ private void validateSpecNode(
if (dependsOn != null) {
validateDependsOn(rootPath, path, siblingKeys, siblingProperties, dependsOn, errors);
}
JsonNode placeSearchTargets = schema.get("x-place-search-targets");
if (placeSearchTargets != null) {
validatePlaceSearchTargets(rootPath, path, schema, siblingKeys, placeSearchTargets, errors);
}
JsonNode readOnly = schema.get("x-read-only");
if (readOnly != null && !readOnly.isBoolean()) {
errors.add(fieldPath(rootPath, path) + " x-read-only는 boolean이어야 합니다.");
}

JsonNode properties = schema.path("properties");
if (properties.isObject()) {
Expand Down Expand Up @@ -107,6 +115,33 @@ private void validateSpecNode(
}
}

private void validatePlaceSearchTargets(
String rootPath,
ArrayDeque<String> path,
JsonNode schema,
Set<String> siblingKeys,
JsonNode placeSearchTargets,
List<String> errors) {
String fieldPath = fieldPath(rootPath, path);
if (!"address-search".equals(schema.path("x-field-style").asText())) {
errors.add(fieldPath + " x-place-search-targets는 address-search 필드에만 지정할 수 있습니다.");
}
if (!placeSearchTargets.isObject() || placeSearchTargets.isEmpty()) {
errors.add(fieldPath + " x-place-search-targets는 비어있지 않은 object여야 합니다.");
return;
}
for (Map.Entry<String, JsonNode> target : placeSearchTargets.properties()) {
if (!siblingKeys.contains(target.getKey())) {
errors.add(fieldPath + " 장소검색 대상 key가 같은 scope에 없습니다: " + target.getKey());
}
if (!target.getValue().isTextual()
|| !Set.of("placeName", "id", "address").contains(target.getValue().asText())) {
errors.add(
fieldPath + " 장소검색 source는 placeName, id, address 중 하나여야 합니다: " + target.getKey());
}
}
}

private void validateDependsOn(
String rootPath,
ArrayDeque<String> path,
Expand Down
38 changes: 31 additions & 7 deletions bottlenote-mono/src/main/resources/openapi/curation/program.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "프로그램",
"description": "행사 기간과 장소, 여러 프로그램 및 프로그램별 위스키 라인업을 함께 발행하는 행사형 큐레이션.",
"version": "3.0.0"
"version": "3.0.1"
},
"x-curation": {
"code": "PROGRAM",
Expand Down Expand Up @@ -35,30 +35,45 @@
},
"placeName": {
"type": "string",
"description": "행사 대표 장소명",
"description": "행사 대표 장소명. 장소검색으로 선택한다.",
"example": "코엑스",
"maxLength": 100,
"x-field-style": "address-search",
"x-display-name": "장소명"
"x-display-name": "장소명",
"x-place-search-targets": {
"placeName": "placeName",
"kakaoPlaceId": "id",
"address": "address"
}
},
"kakaoPlaceId": {
"type": "string",
"description": "Kakao Places 장소 ID",
"description": "Kakao Places 장소 ID. 장소검색 선택값으로 자동 저장한다.",
"example": "27288225",
"minLength": 1,
"maxLength": 20,
"pattern": "^\\d+$",
"x-field-style": "address-search",
"x-field-style": "hidden",
"x-display-name": "Kakao 장소 ID"
},
"address": {
"type": "string",
"description": "행사 대표 주소",
"description": "행사 대표 주소. 장소검색 선택값으로 자동 설정되며 직접 수정할 수 없다.",
"example": "서울 강남구 영동대로 513",
"maxLength": 200,
"x-field-style": "address-search",
"x-field-style": "plain-text",
"x-read-only": true,
"x-display-name": "장소 및 주소"
},
"detailAddress": {
"type": "string",
"description": "행사 상세 주소. 건물, 층, 호 등 주소를 보완해 직접 입력한다.",
"example": "B홀 2층",
"maxLength": 200,
"nullable": true,
"x-field-style": "plain-text",
"x-display-name": "상세 주소"
},
"detailLocation": {
"type": "string",
"description": "행사 상세 위치",
Expand Down Expand Up @@ -426,6 +441,15 @@
"x-field-style": "plain-text",
"x-display-name": "장소 및 주소"
},
"detailAddress": {
"type": "string",
"description": "행사 상세 주소. 건물, 층, 호 등 주소를 보완해 직접 입력한다.",
"example": "B홀 2층",
"maxLength": 200,
"nullable": true,
"x-field-style": "plain-text",
"x-display-name": "상세 주소"
},
"detailLocation": {
"type": "string",
"description": "행사 상세 위치",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "위스키 시음회",
"description": "시음회 날짜, 장소, 참가 정보와 시음 위스키 라인업을 함께 발행하는 이벤트형 큐레이션.",
"version": "3.0.0"
"version": "3.0.1"
},
"x-curation": {
"code": "WHISKY_TASTING_EVENT",
Expand Down Expand Up @@ -36,28 +36,34 @@
},
"placeName": {
"type": "string",
"description": "시음회 장소명",
"description": "시음회 장소명. 장소검색으로 선택한다.",
"example": "보틀노트 테이스팅룸",
"maxLength": 100,
"x-field-style": "address-search",
"x-display-name": "장소명"
"x-display-name": "장소명",
"x-place-search-targets": {
"placeName": "placeName",
"kakaoPlaceId": "id",
"barAddress": "address"
}
},
"kakaoPlaceId": {
"type": "string",
"description": "Kakao Places 장소 ID",
"description": "Kakao Places 장소 ID. 장소검색 선택값으로 자동 저장한다.",
"example": "27288225",
"minLength": 1,
"maxLength": 20,
"pattern": "^\\d+$",
"x-field-style": "address-search",
"x-field-style": "hidden",
"x-display-name": "Kakao 장소 ID"
},
"barAddress": {
"type": "string",
"description": "장소 및 바 주소",
"description": "장소 및 바 주소. 장소검색 선택값으로 자동 설정되며 직접 수정할 수 없다.",
"example": "서울 강남구 테헤란로 123",
"maxLength": 200,
"x-field-style": "plain-text",
"x-read-only": true,
"x-display-name": "장소 및 바(bar) 주소"
},
"detailAddress": {
Expand Down Expand Up @@ -319,7 +325,7 @@
"minLength": 1,
"maxLength": 20,
"pattern": "^\\d+$",
"x-field-style": "address-search",
"x-field-style": "hidden",
"x-display-name": "Kakao 장소 ID"
},
"barAddress": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,16 @@ class CurationPlaceFieldContractTest {
private final CurationFeedProjector projector = new CurationFeedProjector(OBJECT_MAPPER);

@Test
@DisplayName("장소검색이 필요한 필드는 address-search 렌더링 계약을 쓴다")
void requestSpec_placeSearchFields_useAddressSearchStyle() throws IOException {
@DisplayName("장소검색은 장소명 하나에서 수행하고 선택값을 자동 대상 필드에 매핑한다")
void requestSpec_placeSearch_usesPlaceNameAsTheOnlySearchInput() throws IOException {
JsonNode tasting = OBJECT_MAPPER.valueToTree(schema(TASTING, "Request")).path("properties");
assertThat(tasting.path("placeName").path("x-field-style").asText())
.isEqualTo("address-search");
assertThat(tasting.path("kakaoPlaceId").path("x-field-style").asText())
.isEqualTo("address-search");
assertPlaceSearchContract(tasting, "barAddress");

JsonNode program = OBJECT_MAPPER.valueToTree(schema(PROGRAM, "Request")).path("properties");
assertThat(program.path("placeName").path("x-field-style").asText())
.isEqualTo("address-search");
assertThat(program.path("address").path("x-field-style").asText()).isEqualTo("address-search");
assertThat(program.path("kakaoPlaceId").path("x-field-style").asText())
.isEqualTo("address-search");
assertPlaceSearchContract(program, "address");
assertThat(program.path("detailAddress").path("x-field-style").asText())
.isEqualTo("plain-text");
assertThat(program.path("detailAddress").path("nullable").asBoolean()).isTrue();
}

@Test
Expand Down Expand Up @@ -171,6 +167,19 @@ void validateSpec_allSchemas_areValid() throws IOException {
}
}

private static void assertPlaceSearchContract(JsonNode properties, String addressKey) {
JsonNode placeName = properties.path("placeName");
assertThat(placeName.path("x-field-style").asText()).isEqualTo("address-search");
JsonNode targets = placeName.path("x-place-search-targets");
assertThat(targets.path("placeName").asText()).isEqualTo("placeName");
assertThat(targets.path("kakaoPlaceId").asText()).isEqualTo("id");
assertThat(targets.path(addressKey).asText()).isEqualTo("address");

assertThat(properties.path("kakaoPlaceId").path("x-field-style").asText()).isEqualTo("hidden");
assertThat(properties.path(addressKey).path("x-field-style").asText()).isEqualTo("plain-text");
assertThat(properties.path(addressKey).path("x-read-only").asBoolean()).isTrue();
}

private static Map<String, Object> legacyPayload() {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("eventDate", "2026-06-21");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,41 @@ class CurationSpecMetadataValidatorTest {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private final CurationPayloadValidator validator = new CurationPayloadValidator(OBJECT_MAPPER);

@Test
@DisplayName("x-place-search-target 대상 key가 같은 properties scope에 없으면 스펙 검증에 실패한다")
void validateSpec_존재하지_않는_placeSearchTarget_key_실패() throws Exception {
Map<String, Object> schema =
OBJECT_MAPPER.readValue(
"""
{
"type": "object",
"properties": {
"placeName": {
"type": "string",
"x-field-style": "address-search",
"x-place-search-targets": {
"placeName": "placeName",
"address": "address",
"missingPlaceId": "id"
}
},
"address": {
"type": "string"
}
}
}
""",
MAP_TYPE);

assertThat(validator.validateSpec("test#Request", new MapBackedSchema(schema)))
.anySatisfy(
error ->
assertThat(error)
.contains("장소검색 대상 key가 같은 scope에 없습니다")
.contains("test#Request.placeName")
.contains("missingPlaceId"));
}

@Test
@DisplayName("x-depends-on 대상 key가 같은 properties scope에 없으면 스펙 검증에 실패한다")
void validateSpec_존재하지_않는_dependency_key_실패() throws Exception {
Expand Down
Loading