Full output
Codex 원문 결과물
ETag 콘텐츠 조회 API 구현 / OpenAI / GPT-5.6 Terra / 실행일 2026-07-25
src/main/java/com/samebrief/content/ArticleResponse.java
package com.samebrief.content;
public record ArticleResponse(
long id,
String title,
String body
) {
}
src/main/java/com/samebrief/content/ArticleService.java
package com.samebrief.content;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class ArticleService {
private final Map<Long, StoredArticle> articles = Map.of(
1L, new StoredArticle(1L, "봄 소식", "새로운 기능을 소개합니다.", 3),
2L, new StoredArticle(2L, "여름 소식", "점검 일정을 안내합니다.", 1)
);
StoredArticle findById(long id) {
if (id <= 0) {
throw new InvalidArticleIdException();
}
StoredArticle article = articles.get(id);
if (article == null) {
throw new ArticleNotFoundException();
}
return article;
}
record StoredArticle(long id, String title, String body, int version) {
}
}
src/main/java/com/samebrief/content/ArticleController.java
package com.samebrief.content;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/articles")
public class ArticleController {
private final ArticleService articleService;
public ArticleController(ArticleService articleService) {
this.articleService = articleService;
}
@GetMapping("/{id}")
public ResponseEntity<ArticleResponse> getArticle(
@PathVariable long id,
@RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch
) {
ArticleService.StoredArticle article = articleService.findById(id);
String etag = "\"article-%d-v%d\"".formatted(article.id(), article.version());
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.header(HttpHeaders.ETAG, etag)
.build();
}
ArticleResponse response =
new ArticleResponse(article.id(), article.title(), article.body());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.ETAG, etag)
.body(response);
}
}
src/main/java/com/samebrief/content/ArticleExceptionHandler.java
package com.samebrief.content;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@RestControllerAdvice
public class ArticleExceptionHandler {
@ExceptionHandler(ArticleNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ErrorResponse handleArticleNotFound() {
return new ErrorResponse(
"ARTICLE_NOT_FOUND",
"콘텐츠를 찾을 수 없습니다."
);
}
@ExceptionHandler({
InvalidArticleIdException.class,
MethodArgumentTypeMismatchException.class
})
@ResponseStatus(HttpStatus.BAD_REQUEST)
ErrorResponse handleInvalidArticleId() {
return new ErrorResponse(
"INVALID_ARTICLE_ID",
"콘텐츠 ID를 확인해 주세요."
);
}
}
record ErrorResponse(String code, String message) {
}
class ArticleNotFoundException extends RuntimeException {
}
class InvalidArticleIdException extends RuntimeException {
}
src/test/java/com/samebrief/content/ArticleControllerTest.java
package com.samebrief.content;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class ArticleControllerTest {
private MockMvc mockMvc;
@BeforeEach
void setUp() {
ArticleService service = new ArticleService();
mockMvc = MockMvcBuilders
.standaloneSetup(new ArticleController(service))
.setControllerAdvice(new ArticleExceptionHandler())
.build();
}
@Test
void returnsArticleOneWithItsEtag() throws Exception {
mockMvc.perform(get("/api/v1/articles/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(header().string(HttpHeaders.ETAG, "\"article-1-v3\""))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("봄 소식"))
.andExpect(jsonPath("$.body").value("새로운 기능을 소개합니다."))
.andExpect(jsonPath("$.version").doesNotExist());
}
@Test
void returnsNotModifiedForMatchingEtag() throws Exception {
mockMvc.perform(get("/api/v1/articles/2")
.header(HttpHeaders.IF_NONE_MATCH, "\"article-2-v1\""))
.andExpect(status().isNotModified())
.andExpect(header().string(HttpHeaders.ETAG, "\"article-2-v1\""))
.andExpect(content().string(""));
}
@Test
void returnsArticleWhenEtagDoesNotMatchCurrentArticle() throws Exception {
mockMvc.perform(get("/api/v1/articles/1")
.header(HttpHeaders.IF_NONE_MATCH, "\"article-2-v1\""))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.ETAG, "\"article-1-v3\""))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.title").value("봄 소식"))
.andExpect(jsonPath("$.body").value("새로운 기능을 소개합니다."));
}
@Test
void returnsNotFoundForUnknownArticle() throws Exception {
mockMvc.perform(get("/api/v1/articles/999"))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("ARTICLE_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다."));
}
@Test
void checksExistenceBeforeComparingEtag() throws Exception {
mockMvc.perform(get("/api/v1/articles/999")
.header(HttpHeaders.IF_NONE_MATCH, "\"article-1-v3\""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("ARTICLE_NOT_FOUND"))
.andExpect(jsonPath("$.message").value("콘텐츠를 찾을 수 없습니다."));
}
@Test
void rejectsNonPositiveAndNonIntegerIds() throws Exception {
assertInvalidArticleId("0");
assertInvalidArticleId("abc");
}
private void assertInvalidArticleId(String id) throws Exception {
mockMvc.perform(get("/api/v1/articles/{id}", id))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("INVALID_ARTICLE_ID"))
.andExpect(jsonPath("$.message").value("콘텐츠 ID를 확인해 주세요."));
}
}
구현 4개와 테스트 1개, 총 5개 파일입니다.
프로젝트 루트에서 다음 명령으로 실행합니다.
./gradlew test
예상 결과는 테스트 6개가 모두 통과하는 것입니다. 마지막 테스트가 0과 abc를 각각 요청하므로 전체 MockMvc 요청 검증은 7건입니다.