중급자를 위해 준비한
[백엔드] 강의입니다.
다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발하는 강의입니다.
✍️
이런 걸
배워요!
Self-Describtive Message와 HATEOAS를 만족하는 REST API를 이해
다양한 스프링 기술을 활용하여 REST API 개발
스프링 HATEOAS와 스프링 REST Docs 프로젝트 활용
테스트 주도 개발(TDD)
스프링으로 REST를 따르는 API를 만들어보자!
백기선의 스프링 기반 REST API 개발
스프링 기반 REST API 개발
이 강의에서는 다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발합니다.
그런 REST API로 괜찮은가
2017년 네이버가 주관한 개발자 컨퍼런스 Deview에서 그런 REST API로 괜찮은가라는 이응준님의 발표가 있었습니다. 현재 REST API로 불리는 대부분의 API가 실제로는 로이 필딩이 정의한 REST를 따르고 있지 않으며, 그 중에서도 특히 Self-Descriptive Message와 HATEOAS가 지켜지지 않음을 지적했고, 그에 대한 대안을 제시되었습니다.
이번 강의는 해당 발표에 영감을 얻어 만들어졌습니다. 2018년 11월에 KSUG에서 동일한 이름으로 세미나를 진행한 경험이 있습니다. 4시간이라는 짧지 않은 발표였지만, 빠르게 진행하느라 충분히 설명하지 못하고 넘어갔던 부분이 있었습니다. 내용을 더 보충하고, 또 해결하려는 문제에 대한 여러 선택지를 제공하는 것이 좋을 것 같아 이 강의를 만들게 되었습니다.
또한 이 강의에서는 제가 주로 사용하는 IntelliJ 단축키도 함께 설명하고 있습니다.
인프런 :
강의 : https://www.inflearn.com/course/spring_rest-api#
강의 자료 : https://docs.google.com/document/d/1GFo3W6XxqhxDVVqxiSEtqkuVCX93Tdb3xzINRtTIx10/edit
강의 소스 :
https://gitlab.com/whiteship/natural
https://github.com/keesun/study
ksug201811restapi
이번 강좌에서는 다음의 다양한 스프링 기술을 사용하여 REST API를 개발합니다.
스프링 프레임워크
스프링 부트
스프링 데이터 JPA
스프링 HATEOAS
스프링 REST Docs
스프링 시큐리티 OAuth2
또한 개발은 테스트 주도 개발(TDD)로 진행하기 때문에 평소 테스트 또는 TDD에 관심있던 개발자에게도 이번 강좌가 도움이 될 것으로 기대합니다.
사전 학습
스프링 프레임워크 핵심 기술 (필수)
스프링 부트 개념과 활용 (필수)
스프링 데이터 JPA (선택)
학습 목표
Self-Describtive Message와 HATEOAS를 만족하는 REST API를 이해합니다.
다양한 스프링 기술을 활용하여 REST API를 개발할 수 있습니다.
스프링 HATEOAS와 스프링 REST Docs 프로젝트를 활용할 수 있습니다.
테스트 주도 개발(TDD)에 익숙해 집니다.
REST API 는 다음 두가지를 만족해야 한다.
1) Self-Describtive Message
2) HATEOAS
[4] 이벤트 조회 및 수정 REST API 개발
28. 이벤트 목록조회 API 구현
강의 :
https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16435&tab=curriculum
페이징, 정렬 어떻게 하지?
스프링 데이터 JPA가 제공하는 Pageable
Page<Event>에 안에 들어있는 Event 들은 리소스로 어떻게 변경할까?
하나씩 순회하면서 직접 EventResource로 맵핑을 시킬까..
PagedResourceAssembler<T> 사용하기
page: 0부터 시작
size: 기본값 20
sort: property,property(,ASC|DESC)
테스트할것
Event 목록 Page 정보와 함께 받기 content[0].id 확인 pageable 경로 확인 Sort과 Paging 확인 30개를 만들고, 10개 사이즈로 두번째 페이지 조회하면 이전, 다음 페이지로 가는 링크가 있어야 한다. 이벤트 이름순으로 정렬하기 page 관련 링크 Event를 EventResource로 변환해서 받기 각 이벤트 마다 self 링크 확인 self profile (create) 문서화
1) EventController
★ ResponseEntity<PagedModel<EntityModel<Event>>> EntityModel<Event> 를PagedModel<Entity> 로 감싸주고 이것을 다시
ResponseEntity 로 감싸주었다.
@Controller @RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE) @RequiredArgsConstructor @Log4j2 public class EventController { /**페이징 처리 */ @GetMapping public ResponseEntity<PagedModel<EntityModel<Event>>> queryEvent(Pageable pageable , PagedResourcesAssembler<Event> assembler){ Page<Event> page = this.eventRepository.findAll(pageable); PagedModel<EntityModel<Event>> entityModel=assembler.toModel(page,e-> EventResource.of(e)); //링크 추가 entityModel.add(Link.of("/docs/index.html#resource-events-list").withRel("profile")); return ResponseEntity.ok(entityModel); } }
2) ★ 다음과 같은 형식으로 Resource 만들어 처리해 주면된다.
EventResource
package net.macaronics.restapi.events; import org.springframework.hateoas.EntityModel; import org.springframework.hateoas.Link; import org.springframework.hateoas.RepresentationModel; import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder; import java.net.URI; import java.util.ArrayList; import java.util.List; import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; /** * ★★ 기본 소스 */ public class EventResource extends EntityModel<Event> { private static WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class); private EventResource(){ } public static EntityModel<Event> of(Event event, String profile){ List<Link> links = getSelfLink(event); links.add(Link.of(profile, "profile")); return EntityModel.of(event, links); } public static EntityModel<Event> of(Event event){ List<Link> links = getSelfLink(event); return EntityModel.of(event, links); } private static List<Link> getSelfLink(Event event) { selfLinkBuilder.slash(event.getId()); List<Link> links = new ArrayList<>(); links.add(selfLinkBuilder.withSelfRel()); return links; } public static URI getCreatedUri(Event event) { return selfLinkBuilder.slash(event.getId()).toUri(); } }
3) EventControllerTests
~ @Test @TestDescription("30개의 이벤트를 10개씩 두번째 페이지 조회하기") public void queryEvents() throws Exception{ //Given IntStream.range(0, 30).forEach(this::generateEvent); //when this.mockMvc.perform(get("/api/events") .param("page", "1") .param("size", "10") .param("sort", "name,DESC") ) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("page").exists()) .andExpect(jsonPath("_embedded.eventList[0]._links.self").exists()) .andExpect(jsonPath("_links.self").exists()) .andExpect(jsonPath("_links.profile").exists()) .andDo(document("query_events")); } private void generateEvent(int index) { Event event=Event.builder() .name("event "+index) .description("test event") .build(); this.eventRepository.save(event); }
출력=>
{ "_embedded": { "eventList": [{ "id": 27, "name": "event 26", "description": "test event", "beginEnrollmentDateTime": null, "closeEnrollmentDateTime": null, "beginEventDateTime": null, "endEventDateTime": null, "location": null, "basePrice": 0, "maxPrice": 0, "limitOfEnrollment": 0, "offline": false, "free": false, "eventStatus": null, "_links": { "self": { "href": "http://localhost:8080/api/events" } } }, `~ ` "_links": { "first": { "href": "http://localhost:8080/api/events?page=0&size=10&sort=name,desc" }, "prev": { "href": "http://localhost:8080/api/events?page=0&size=10&sort=name,desc" }, "self": { "href": "http://localhost:8080/api/events?page=1&size=10&sort=name,desc" }, "next": { "href": "http://localhost:8080/api/events?page=2&size=10&sort=name,desc" }, "last": { "href": "http://localhost:8080/api/events?page=2&size=10&sort=name,desc" }, "profile": { "href": "/docs/index.html#resource-events-create" } }, "page": { "size": 10, "totalElements": 30, "totalPages": 3, "number": 1 } }
29. 이벤트 한건 조회 API 구현
강의 :
테스트할것
조회하는 이벤트가 있는 경우 이벤트 리소스 확인 링크 self profile (update) 이벤트 데이터 조회하는 이벤트가 없는 경우 404 응답 확인
1)EventController
@GetMapping(value = "/{id}") public ResponseEntity<?> getEvent(@PathVariable("id") Integer id) { Optional<Event> optionalEvent = this.eventRepository.findById(id); if(optionalEvent.isEmpty()){ return ResponseEntity.notFound().build(); } Event event =optionalEvent.get(); EntityModel<Event> entityModel = EventResource.of(event); entityModel.add(Link.of("/docs/index.html#resource-events-get").withRel("profile")); return ResponseEntity.ok(entityModel); }
2)EventControllerTests
import com.fasterxml.jackson.databind.ObjectMapper; import net.macaronics.restapi.common.RestDocsConfiguration; import net.macaronics.restapi.common.TestDescription; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.hateoas.MediaTypes; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.time.LocalDateTime; import java.util.stream.IntStream; import static org.springframework.restdocs.headers.HeaderDocumentation.*; import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel; import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.links; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.payload.PayloadDocumentation.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs @Import(RestDocsConfiguration.class) @ActiveProfiles("test") public class EventControllerTests { @Test @DisplayName("기존의 이벤트를 하나 조회하기") public void getEvent() throws Exception{ //Given Event event= this.generateEvent(100); //When & Then this.mockMvc.perform( get("/api/events/{id}",event.getId())) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("name").exists()) .andExpect(jsonPath("id").exists()) .andExpect(jsonPath("_links.self").exists()) .andExpect(jsonPath("_links.profile").exists()) .andDo(document("get-an-event")); } @Test @DisplayName("없는 이벤트를 조회 했을때 404 응답받기") public void getEvent404() throws Exception{ //When & Then this.mockMvc.perform(get("/api/events/11883")) .andExpect(status().isNotFound()); } private Event generateEvent(int index) { Event event=Event.builder() .name("event "+index) .description("test event") .build(); return this.eventRepository.save(event); } }
30. 이벤트 수정 API 구현
강의 :
수정하려는 이벤트가 없는 경우 404 NOT_FOUND 입력 데이터 (데이터 바인딩)가 이상한 경우에 400 BAD_REQUEST 도메인 로직으로 데이터 검증 실패하면 400 BAD_REQUEST (권한이 충분하지 않은 경우에 403 FORBIDDEN) 정상적으로 수정한 경우에 이벤트 리소스 응답 200 OK 링크 수정한 이벤트 데이터
1)EventContoller
/** 수정하기 */ @PatchMapping("/{id}") public ResponseEntity<?> updateEvent(@PathVariable("id") Integer id, @RequestBody @Valid EventDto eventDto, Errors errors){ Optional<Event> optionalEvent = this.eventRepository.findById(id); if(optionalEvent.isEmpty()) return ResponseEntity.notFound().build(); if(errors.hasErrors())return badRequest(errors); eventValidator.validate(eventDto, errors); if(errors.hasErrors())return badRequest(errors); //커스텀 에러 로직상 에러잡기 Event existEvent=optionalEvent.get(); existEvent.updateEvent(eventDto); //서비스 객체를 만들지 않아서 더티체킹이 일어나지 않는다. 따라서, repository 실질적으로 저장처리 eventRepository.save(existEvent); EntityModel<Event> entityModel = EventResource.of(existEvent); entityModel.add(Link.of("/docs/index.html#resource-events-update").withRel("profile")); return ResponseEntity.ok(entityModel); } private ResponseEntity<EntityModel> badRequest(Errors errors) { return ResponseEntity.badRequest().body(errorsResource.addLink(errors)); }
2) EventControllerTests
@Test @DisplayName("1)수정하기 - 이벤트 정상적으로 수정하기") public void updateEvent() throws Exception{ //Given Event event=this.generateEvent(200); String eventName="Updated event"; EventDto eventDto = event.toEventDto(); eventDto.setName(eventName); this.mockMvc.perform(patch("/api/events/{id}", event.getId()) .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(eventDto)) ) .andExpect(status().isOk()) .andDo(print()) .andExpect(jsonPath("name").value(eventName)) .andExpect(jsonPath("_links.self").exists()) .andExpect(jsonPath("_links.profile").exists()) .andDo(document("update-event", links( linkWithRel("self").description("link to self"), linkWithRel("profile").description("link to update an existing event") // linkWithRel("query-events").description("link to query event"), // linkWithRel("query-events").description("link to query event"), // linkWithRel("query-events").description("link to query event") ///이하 생략 ) )); } @Test @DisplayName("2)수정하기 - 입력값이 비어있는 경우에 이벤트 수정 실패") public void updateEvent400_Empty() throws Exception{ //Given Event event=this.generateEvent(200); EventDto eventDto =new EventDto(); this.mockMvc.perform(patch("/api/events/{id}", event.getId()) .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(eventDto)) ) .andExpect(status().isBadRequest()) .andDo(print()); } @Test @DisplayName("3)수정하기 -입력값이 잘못된 경우에 이벤트 수정 실패") public void updateEvent400_Wrong() throws Exception{ //Given Event event=this.generateEvent(200); EventDto eventDto =event.toEventDto(); eventDto.setBasePrice(2000000000); eventDto.setMaxPrice(200000); this.mockMvc.perform(patch("/api/events/{id}", event.getId()) .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(eventDto)) ) .andExpect(status().isBadRequest()) .andDo(print()); } @Test @DisplayName("4)수정하기 - 존재하지 않는 이벤트 수정 실패") public void updateEvent404_notFound() throws Exception{ //Given Event event=this.generateEvent(200); EventDto eventDto =event.toEventDto(); this.mockMvc.perform(patch("/api/events/188888888") .contentType(MediaType.APPLICATION_JSON_UTF8) .accept(MediaTypes.HAL_JSON) .content(objectMapper.writeValueAsString(eventDto)) ) .andExpect(status().isNotFound()) .andDo(print()); } private Event generateEvent(int index) { Event event=Event.builder() .name("event "+index) .description("REST API Development with Spring") .beginEnrollmentDateTime(LocalDateTime.of(2023, 05, 06, 19 , 20 )) .closeEnrollmentDateTime(LocalDateTime.of(2023, 05, 20, 20 , 20)) .beginEventDateTime(LocalDateTime.of(2023, 05, 20, 20, 20)) .endEventDateTime(LocalDateTime.of(2023, 11, 26, 20, 20)) .basePrice(100) .maxPrice(200) .limitOfEnrollment(100) .location("강남역 D2 스타텀 팩토리") .free(false) .offline(true) .eventStatus(EventStatus.DRAFT) .build(); return this.eventRepository.save(event); }
31. 테스트코드 리팩토링
강의 :
여러 컨트롤러 간의 중복 코드 제거하기
클래스 상속을 사용하는 방법
@Ignore 애노테이션으로 테스트로 간주되지 않도록 설정
BaseControllerTest
package net.macaronics.restapi.common; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Ignore; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs @Import(RestDocsConfiguration.class) @ActiveProfiles("test") @Ignore //테스트 클래스로 사용하지 않는다 public class BaseControllerTest { @Autowired public MockMvc mockMvc; @Autowired public ObjectMapper objectMapper; }
댓글 ( 4)
댓글 남기기