스프링

 

 

중급자를 위해 준비한
[백엔드] 강의입니다.

다양한 스프링 기술을 사용하여 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> 사용하기

 

테스트 할 때 Pageable 파라미터 제공하는 방법

  • 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 구현

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16436&category=questionDetail&tab=curriculum

 

테스트할것

조회하는 이벤트가 있는 경우 이벤트 리소스 확인
링크
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 구현

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16437&category=questionDetail&tab=curriculum

 

수정하려는 이벤트가 없는 경우 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. 테스트코드 리팩토링

 

강의 :   

 

https://www.inflearn.com/course/lecture?courseSlug=spring_rest-api&unitId=16438&category=questionDetail&tab=curriculum

 

 

여러 컨트롤러 간의 중복 코드 제거하기

  • 클래스 상속을 사용하는 방법

  • @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;

}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

인간의 행위는 다음 7가지 중 하나, 또는 그 이상의 원인을 가지고 있다. 그것은 기회, 본성, 강제, 습관, 이성, 정열, 그리고 희망이다. -아리스토텔레스

댓글 ( 4)

댓글 남기기

작성