diff --git a/docs/reference/api-versioning.md b/docs/reference/api-versioning.md new file mode 100644 index 00000000..24b647e8 --- /dev/null +++ b/docs/reference/api-versioning.md @@ -0,0 +1,46 @@ +# API 버전 정책 + +> 관련 이슈: #44 + +## 결정 — 헤더로 협상한다 + +| 방식 | 판단 | +|------|------| +| 경로 (`/api/v1/facilities`) | 채택하지 않음 | +| **헤더 (`X-API-Version: 1`)** | **채택** | + +경로 방식을 쓰지 않는 이유: + +- 프런트가 부르는 모든 경로와 `SecurityConfig` 의 인가 규칙이 버전 없는 경로로 짜여 있다. + 경로에 버전을 넣으면 인가 규칙을 두 벌로 유지해야 하고, 한쪽만 고치면 그대로 구멍이 된다. +- 버전이 갈리는 건 보통 일부 API 뿐이다. 헤더 방식은 바뀐 API 만 새 버전 처리를 두면 된다. + +예전 `BaseController` 의 `@RequestMapping("/api/v1")` 은 하위 컨트롤러가 전부 덮어써서 어떤 경로에도 +적용된 적이 없다. 오해만 낳아 지웠다. + +## 규칙 + +| 요청 | 처리 | 응답 헤더 | +|------|------|-----------| +| 헤더 없음 | 현재 버전(1) | `X-API-Version: 1` | +| `X-API-Version: 1` 또는 `v1` | 1 | `X-API-Version: 1` | +| 지원하지 않는 값 (`2`, `abc` …) | **400** `API_VERSION_UNSUPPORTED` | `X-API-Version: 1` | + +- **헤더가 없으면 현재 버전**이다. 지금 클라이언트는 아무것도 바꾸지 않아도 된다. +- 모르는 버전을 400 으로 거절하는 이유: 다른 버전을 기대한 클라이언트에게 조용히 현재 버전을 주면 + 필드가 어긋나도 알아채지 못한다. 이 프로젝트에서 실제로 여러 번 겪은 실패 방식이다. +- 구현: `core/web/ApiVersionFilter`. CORS 허용·노출 헤더에 포함돼 브라우저에서도 읽을 수 있다. +- Swagger 의 모든 API 에 선택 헤더로 표시된다. + +## 호환되지 않는 변경을 할 때 + +필드 삭제·이름 변경·타입 변경·필수값 추가가 여기에 해당한다. 필드 **추가**는 호환 변경이다. + +1. `ApiVersionFilter.SUPPORTED` 에 새 버전을 추가한다 (`CURRENT` 는 아직 올리지 않는다). +2. 바뀌는 API 에서 요청 속성 `ApiVersionFilter.REQUEST_ATTRIBUTE` 로 버전을 보고 응답을 가른다. +3. 프런트가 새 버전 헤더를 보내도록 배포한다. +4. 옛 버전 응답에 `Deprecation`·`Sunset` 헤더를 붙여 종료일을 알린다. +5. 종료일이 지나면 옛 버전을 `SUPPORTED` 에서 빼고 `CURRENT` 를 올린다. + +가능하면 1~5 대신 **새 필드를 추가하고 옛 필드를 유지**하는 호환 변경을 먼저 검토한다. +(예: 정책 검색 응답의 `totalCount` 는 `totalElements` 를 추가한 뒤에도 남겨 두었다.) diff --git a/src/main/java/com/carecode/core/config/SwaggerConfig.java b/src/main/java/com/carecode/core/config/SwaggerConfig.java index 4452d6c5..80e2f0ab 100644 --- a/src/main/java/com/carecode/core/config/SwaggerConfig.java +++ b/src/main/java/com/carecode/core/config/SwaggerConfig.java @@ -29,8 +29,10 @@ public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("CareCode API") - .description("육아 지원 플랫폼 맘편한의 REST API 문서") - .version("1.0.0") + .description("육아 지원 플랫폼 맘편한의 REST API 문서. " + + "API 버전은 `X-API-Version` 요청 헤더로 지정합니다. 생략하면 현재 버전(1)이며, " + + "응답의 `X-API-Version` 헤더가 실제로 처리한 버전입니다.") + .version("1") .contact(new Contact() .name("CareCode Team") .email("dhxogns920@naver.com") @@ -59,6 +61,17 @@ public OpenAPI customOpenAPI() { .description("기본 인증 정보를 입력하세요"))); } + /** 모든 API 에 선택 헤더 X-API-Version 을 문서로 보여 준다. */ + @Bean + public org.springdoc.core.customizers.OperationCustomizer apiVersionHeader() { + return (operation, handlerMethod) -> operation.addParametersItem( + new io.swagger.v3.oas.models.parameters.HeaderParameter() + .name(com.carecode.core.web.ApiVersionFilter.HEADER) + .required(false) + .description("API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400") + .schema(new io.swagger.v3.oas.models.media.StringSchema()._default("1"))); + } + // 환경별 서버 목록 생성 private List createServerList() { List servers = new ArrayList<>(); diff --git a/src/main/java/com/carecode/core/controller/BaseController.java b/src/main/java/com/carecode/core/controller/BaseController.java index 7e783bdb..3aa2b620 100644 --- a/src/main/java/com/carecode/core/controller/BaseController.java +++ b/src/main/java/com/carecode/core/controller/BaseController.java @@ -1,9 +1,12 @@ package com.carecode.core.controller; -import org.springframework.web.bind.annotation.RequestMapping; - -/** 모든 컨트롤러의 기본 클래스 공통 URL 경로와 기본 설정을 제공 */ -@RequestMapping("/api/v1") +/** + * 컨트롤러 공통 상수. + * + *

예전에는 여기에 {@code @RequestMapping("/api/v1")} 이 있었지만, 하위 컨트롤러가 전부 자기 + * {@code @RequestMapping} 으로 덮어써서 어떤 경로에도 적용된 적이 없다. "경로 버전을 쓰고 있다" 는 + * 오해만 낳아 지웠다. API 버전은 {@link com.carecode.core.web.ApiVersionFilter} 가 헤더로 다룬다. + */ public abstract class BaseController { // 공통 응답 메시지 상수 diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index d2c646bc..31a23ddb 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -55,9 +55,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowCredentials(true); configuration.setAllowedOriginPatterns(allowedOrigins); - configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "Accept")); + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With", "Accept", "X-API-Version")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - configuration.setExposedHeaders(List.of("Authorization", "X-Refresh-Token")); + configuration.setExposedHeaders(List.of("Authorization", "X-Refresh-Token", "X-API-Version", "X-Request-Id")); return configuration; })) // CORS 활성화 .csrf(AbstractHttpConfigurer::disable) diff --git a/src/main/java/com/carecode/core/web/ApiVersionFilter.java b/src/main/java/com/carecode/core/web/ApiVersionFilter.java new file mode 100644 index 00000000..d9618bc2 --- /dev/null +++ b/src/main/java/com/carecode/core/web/ApiVersionFilter.java @@ -0,0 +1,88 @@ +package com.carecode.core.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Set; + +/** + * API 버전 협상. 버전은 경로가 아니라 {@code X-API-Version} 헤더로 주고받는다. + * + *

경로 버전({@code /api/v1/...})을 쓰지 않는 이유: + *

+ * + *

규칙 ({@code docs/reference/api-versioning.md}): + *

+ */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class ApiVersionFilter extends OncePerRequestFilter { + + public static final String HEADER = "X-API-Version"; + public static final int CURRENT = 1; + public static final Set SUPPORTED = Set.of(1); + + /** 컨트롤러에서 처리 중인 버전을 꺼낼 때 쓴다. 새 버전이 생겨 응답이 갈릴 때 필요하다. */ + public static final String REQUEST_ATTRIBUTE = ApiVersionFilter.class.getName() + ".version"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String raw = request.getHeader(HEADER); + Integer version = parse(raw); + + if (version == null || !SUPPORTED.contains(version)) { + reject(response); + return; + } + + request.setAttribute(REQUEST_ATTRIBUTE, version); + response.setHeader(HEADER, String.valueOf(version)); + filterChain.doFilter(request, response); + } + + /** 헤더가 없으면 현재 버전. 형식이 틀리면 null. */ + static Integer parse(String raw) { + if (raw == null || raw.isBlank()) { + return CURRENT; + } + String value = raw.trim().toLowerCase(Locale.ROOT); + if (value.startsWith("v")) { + value = value.substring(1); + } + if (!value.matches("[0-9]{1,3}")) { + return null; + } + return Integer.parseInt(value); + } + + private void reject(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setHeader(HEADER, String.valueOf(CURRENT)); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + // 헤더 값은 응답에 되돌려 쓰지 않는다. 그대로 넣으면 JSON 을 깨뜨리거나 주입에 쓰일 수 있다. + response.getWriter().write("{\"code\":\"API_VERSION_UNSUPPORTED\"," + + "\"message\":\"지원하지 않는 API 버전입니다. X-API-Version 헤더를 빼거나 지원 버전을 지정하세요.\"," + + "\"supportedVersions\":" + SUPPORTED.stream().sorted().toList() + "}"); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0debc386..737a2a65 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -107,7 +107,7 @@ server: app: api: - base-url: /api/v1 + # 버전은 경로가 아니라 X-API-Version 헤더로 협상한다 (docs/reference/api-versioning.md). version: v1 title: 맘편한 API description: 육아 지원 플랫폼 맘편한의 REST API diff --git a/src/test/java/com/carecode/core/web/ApiVersionFilterTest.java b/src/test/java/com/carecode/core/web/ApiVersionFilterTest.java new file mode 100644 index 00000000..e9aba14a --- /dev/null +++ b/src/test/java/com/carecode/core/web/ApiVersionFilterTest.java @@ -0,0 +1,61 @@ +package com.carecode.core.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("API 버전 헤더") +class ApiVersionFilterTest { + + private final ApiVersionFilter filter = new ApiVersionFilter(); + + @Test + @DisplayName("헤더가 없으면 현재 버전으로 처리하고 응답에 버전을 알린다 — 기존 클라이언트는 그대로 동작") + void missingHeaderMeansCurrent() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/facilities"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).as("다음 필터로 넘어간다").isNotNull(); + assertThat(response.getHeader(ApiVersionFilter.HEADER)).isEqualTo("1"); + assertThat(request.getAttribute(ApiVersionFilter.REQUEST_ATTRIBUTE)).isEqualTo(1); + } + + @ParameterizedTest + @ValueSource(strings = {"1", "v1", "V1", " 1 "}) + void acceptsSupportedForms(String header) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/facilities"); + request.addHeader(ApiVersionFilter.HEADER, header); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isNotNull(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @ParameterizedTest + @ValueSource(strings = {"2", "v9", "abc", "1.0", "