Hyun's Wonderwall

[GDSC Study] 스프링 기반 REST API 개발 - 3주차 | 섹션 3. HATEOAS와 Self-Describtive Message 적용 본문

Study/Java, Spring

[GDSC Study] 스프링 기반 REST API 개발 - 3주차 | 섹션 3. HATEOAS와 Self-Describtive Message 적용

Hyun_! 2023. 11. 20. 06:55

GDSC Ewha 5기_ Spring Boot 스터디

  • 스터디 커리큘럼: 백기선, "스프링 기반 REST API 개발"
  • 3주차 과제 - 섹션 3. HATEOAS와 Self-Describtive Message 적용
    3. (1) 스프링 HATEOAS 소개
    3. (2) 스프링 HATEOAS 적용
    3. (3) 스프링 REST Docs 소개
    3. (4) 스프링 REST Docs 적용
    3. (5) 스프링 REST Docs 각종 문서 조각 생성하기
    3. (6) 스프링 REST Docs 문서 빌드
    3. (7) 테스트용 DB와 설정 분리하기
    3. (8) API 인덱스 만들기

3. (1) 스프링 HATEOAS 소개

Spring HATEOAS: HATEOAS 원칙을 따르는 RESTful API를 개발할 때 REST representation 생성을 편리하게 하도록도와주는 라이브러리

  • HATEOAS(Hypermedia as the Engine of Application State): 클라이언트가 서버로부터 받은 리소스에 대해 어떠한 동작을 수행할 수 있도록 Hypermedia를 사용해 링크를 동적으로 제공하는 것. RESTful API 디자인 원칙 중 하나.
  • 링크 만드는 기능
    - linkTo()
    - methodOn(), slash()로 slash 표현할 수도 있고 등등...
  • 리소스 만드는 기능 (리소스: 응답 본문 데이터 + 링크 정보)
  • 링크 찾아주는 기능
  • 링크
    - href: uri, url
    - rel: 현재 리소스와의 관계를 나타냄 (self, profile, ...)


3. (2) 스프링 HATEOAS 적용

RESTful API로 만들려면 링크 정보를 받을 수 있어야 한다

EventResource 클래스 만듬

getEvent()

 

EntityModel<T> : 도메인 객체를 감싸고, 그에 링크를 추가하는 객체. (Resource + Links)

ResourceSupport is now RepresentationModel

Resource is now EntityModel

Resources is now CollectionModel

PagedResources is now PagedModel

ControllerLinkBuilder is now WebMvcLinkBuilder  //self, update 추가 시

 

https://velog.io/@mmeo0205/REST-API-HATEOAS

 

REST API -HATEOAS

HATEOAS 원칙을 따르는 REST 표현(링크 생성 및 표현)을 쉽게 생성할 수 있는 몇 가지 API 제공HATEOAS 를 사용하기 위해 @EnableEntityLinks, @EnableHypermediaSupport 와 같은설정들을 직접 적용해야 하지만 Spring

velog.io

https://stackoverflow.com/questions/60762394/where-is-spring-resourcesupport

https://blossun.github.io/spring/rest-api/03_02_%EC%8A%A4%ED%94%84%EB%A7%81-HATEOAS-%EC%A0%81%EC%9A%A9/

 

[HATEOAS와 Self-Describtive Message 적용]_02_스프링 HATEOAS 적용

스프링 HATEOAS 적용

blossun.github.io

# EventController
	Event event = modelMapper.map(eventDto, Event.class);
        event.update();
        Event newEvent = this.eventRepository.save(event);
        WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(newEvent.getId());
        URI createdUri = selfLinkBuilder.toUri();
        EventResource eventResource = new EventResource(event);
        eventResource.add(linkTo(EventController.class).withRel("query-events"));
        eventResource.add(selfLinkBuilder.withSelfRel()); // Self
        eventResource.add(selfLinkBuilder.withRel("update-event")); // Update
        return ResponseEntity.created(createdUri).body(eventResource);

 

 

/*package me.lsh.restapidemo.events;

import com.fasterxml.jackson.annotation.JsonUnwrapped;
import org.springframework.hateoas.RepresentationModel;

public class EventResource extends RepresentationModel {

    @JsonUnwrapped
    private Event event;

    public EventResource(Event event) {
        this.event = event;
    }

    public Event getEvent() {
        return event;
    }
}*/
package me.lsh.restapidemo.events;

import org.springframework.hateoas.EntityModel;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;

public class EventResource extends EntityModel<Event> {

    public EventResource(Event event) {
        super(event);
        add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
    }
}

//빈 아니고 매번 converting해서 써야하는 객체

 

Matchers.not() 이거 어디서 붙인거지...

.andExpect(jsonPath("free").value(false)) 잘못할뻔

private EventStatus eventStatus = EventStatus.DRAFT; 초기화 세팅

실수로 놓칠 뻔 한 것: enum과 문자열을 비교해 버림 https://www.inflearn.com/questions/167701/%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%8B%9C-enum-%EA%B0%92-%EB%B6%88%EC%9D%BC%EC%B9%98

.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))

https://backtony.github.io/spring/2021-04-16-spring-api-1/

 


3. (3) 스프링 REST Docs 소개

Spring MVC Test 실행시 사용한 요청과 응답, 헤더 같은 정보를 사용해 REST API 문서 조각(스니펫)을 만들어주는 라이브러리.

Ascii Docker라는 툴 사용해 플레인 텍스트 문서 -> AScii-DAQ 문법으로 만든 Snippet들 조합 -> HTML 문서로 만들어줌

 

REST Docs 자동 설정 - (Spring Boot) 테스트 위에 이 애노테이션 붙이기만 하면 됨

  • @AutoConfigureRestDocs

실제 사용할 때는 .andDo(document(...)) <- document() 메소드로 현재 이 테스트를 실행한 결과 생성되는 스니펫을 어떤 디렉토리 아래에, 어떤 이름으로 만들지 지정

 

import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;

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.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.*;


@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class) //Bean 불러오기
public class EventControllerTests {
    @Autowired
    MockMvc mockMvc; //가짜 요청 만들어서 보내고 응답 확인할 수 있는 Test 만들수있음.
    // Slicing Test 웹과 관련된 빈들만 테스트. dispatch sublet이란거 만들어야 함, 웹서버 띄우지x

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @TestDescription("정상적으로 이벤트를 생성하는 테스트")//JUnit5는 이런 기능 Annotation 지원
    public void createEvent() throws Exception {
        EventDto event = EventDto.builder()
                .name("Spring")
                .description("REST API Devlopment with Spring")
                .beginEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 8, 0))
                .closeEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 22, 0))
                .beginEventDateTime(LocalDateTime.of(2023, 11, 15, 19, 0))
                .endEventDateTime(LocalDateTime.of(2023, 11, 15, 21, 0))
                .basePrice(100)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("이대역")
                .build();

        mockMvc.perform(post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON) //확장자 처럼 만들음??
                        .content(objectMapper.writeValueAsString(event))) //안에다 주는게 요청, 하고나면 응답 나옴
                .andDo(print()) //어떤 요청, 어떤 응답 콘솔에서 볼수있
                .andExpect(status().isCreated())
                .andExpect(jsonPath("id").exists())
                .andExpect(header().exists(HttpHeaders.LOCATION))
                .andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE))
                .andExpect(jsonPath("free").value(false))
                .andExpect(jsonPath("offline").value(true)) //기본: false
                .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.query-events").exists())
                .andExpect(jsonPath("_links.update-event").exists())
                .andDo(document("create-event",
                        links(
                                linkWithRel("self").description("link to self"),
                                linkWithRel("query-events").description("link to query events"),
                                linkWithRel("update-event").description("link to update an existing event")
                        ),
                        requestHeaders(
                                headerWithName(HttpHeaders.ACCEPT).description("accept header"),
                                headerWithName(HttpHeaders.CONTENT_TYPE).description("content type header")
                        ),
                        requestFields(
                                fieldWithPath("name").description("name of new event"),
                                fieldWithPath("description").description("description of new event"),
                                fieldWithPath("beginEnrollmentDateTime").description("date time of begin of enrollment"),
                                fieldWithPath("closeEnrollmentDateTime").description("date time of close of enrollment"),
                                fieldWithPath("beginEventDateTime").description("date time of begin of new event"),
                                fieldWithPath("endEventDateTime").description("date time of end of new event"),
                                fieldWithPath("location").description("location of new event"),
                                fieldWithPath("basePrice").description("base price of new event"),
                                fieldWithPath("maxPrice").description("max price of new event"),
                                fieldWithPath("limitOfEnrollment").description("limit of enrollment")

                        ),
                        responseHeaders(
                                headerWithName(HttpHeaders.LOCATION).description("Location header"),
                                headerWithName(HttpHeaders.CONTENT_TYPE).description("Content type")
                        ),
                        responseFields( //relaxedResponseFields 사용시 문서 일부분만 확인
                                fieldWithPath("id").description("identifier of new event"),
                                fieldWithPath("name").description("name of new event"),
                                fieldWithPath("description").description("description of new event"),
                                fieldWithPath("beginEnrollmentDateTime").description("date time of begin of enrollment"),
                                fieldWithPath("closeEnrollmentDateTime").description("date time of close of enrollment"),
                                fieldWithPath("beginEventDateTime").description("date time of begin of new event"),
                                fieldWithPath("endEventDateTime").description("date time of end of new event"),
                                fieldWithPath("location").description("location of new event"),
                                fieldWithPath("basePrice").description("base price of new event"),
                                fieldWithPath("maxPrice").description("max price of new event"),
                                fieldWithPath("limitOfEnrollment").description("limit of enrollment"),
                                fieldWithPath("free").description("it tells if this event is free or not"),
                                fieldWithPath("offline").description("it tells if this event is offline event or not"),
                                fieldWithPath("eventStatus").description("eventStatus"),
                                //오류 해결 위해 재검사
                                fieldWithPath("_links.self.href").description("link to self"),
                                fieldWithPath("_links.query-events.href").description("link to query event lists"),
                                fieldWithPath("_links.update-event.href").description("link to update an existing event")
                        )
                ))
        ;
    }
}

 


3. (4) 스프링 REST Docs 적용

 

 

3. (6) 스프링 REST Docs 문서 빌드

헤헷

 

 

new Link()는 더이상 지원되지 않는다고 해서 Link.of() 사용해 해결했다

 

https://www.inflearn.com/questions/508384/new-link-%EB%A9%94%EC%84%9C%EB%93%9C-%EC%98%A4%EB%A5%98-%EB%82%98%EC%8B%9C%EB%8A%94-%EB%B6%84%EB%93%A4-%EC%A3%BC%EB%AA%A9

 

new Link() 메서드 오류 나시는 분들 주목! - 인프런 | 질문 & 답변

- 학습 관련 질문을 남겨주세요. 상세히 작성하면 더 좋아요! - 먼저 유사한 질문이 있었는지 검색해보세요. - 서로 예의를 지키며 존중하는 문화를 만들어가요. - 잠깐! 인프런 서비스 운영 관련

www.inflearn.com

 

# EventController 클래스의 createEvent 메서드 중 일부
eventResource.add(Link.of("/docs/index.html#resources-events-create").withRel("profile"));
# 한편 EventResource Link... links 오류나서 이렇게 사용중
public EventResource(Event event) {
    super(event);
    add(linkTo(EventController.class).slash(event.getId()).withSelfRel());
}

EventResource 부분

https://velog.io/@shdbtjd8/Spring-put-%EC%9A%94%EC%B2%AD-%EA%B5%AC%ED%98%84

 

[Spring] put 요청 구현

지금까지 spring에서 RESTful API를 구현하는 방법에 대해서 공부를 해보았기에 실제로 사용자가 수정 요청을 하면은 요청을 받아서 처리를 하는 로직을 구현해보겠다.테스트 코드를 작성하기에 앞

velog.io

 

 

 

전체적으로 읽어보기

https://backtony.github.io/spring/2021-04-16-spring-api-1/

 

Spring REST API - 정리

Java, JPA, Spring을 주로 다루고 공유합니다.

backtony.github.io

 

 

3.7 DB 관련

https://hongddo.tistory.com/244

 

[Springboot] Failed to configure a DataSource 에러 뜰때

Springboot 는 Start시 기본적으로 DB연결을 시도한다. 하지만 프로젝트를 처음 생성했을 경우 당연히 DB연결정보가 없어서 아래와 같은 에러가 발생한다. Failed to configure a DataSource: 'url' attribute is not s

hongddo.tistory.com

H2 데이터베이스 연동관련 (지금보다 H2 쓰는 S-Day스터디에서 읽어봐야할듯)

https://hoestory.tistory.com/3

 

[Spring]스프링부트 H2 데이터베이스 연동 오류

발생한 오류 org.h2.jdbc.JdbcSQLInvalidAuthorizationSpecException: Wrong user name or password [28000-200 Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'entityManagerFactory' defined in class path res

hoestory.tistory.com

https://unhosted.tistory.com/83

 

JPA data.sql로 초기화 시 에러 해결방법 Error creating bean with name 'dataSourceScriptDatabaseInitializer'

spring에서 jpa를 사용 시 @Entity 어노테이션을 사용하면 ddl이 자동으로 생성되고(모드 설정 가능) 초기 데이터 생성을 위해 resource 디렉토리 아래 data.sql 파일을 insert 문을 넣어 사용하곤 한다. 이렇

unhosted.tistory.com

3. (7) 테스트용 DB와 설정 분리하기

애플리케이션에서 사용하는 DB와 테스트에서 사용하는 DB를 구분하는 방법, 각각 다른 DB를 사용하는 방법을 살펴보자.

설정 파일의 중복을 피하는 방법도 알아볼 것

Docker 사용할 것.

Docker for Windows 설치함

docker run --name ndb -p 5432:5432 -e POSTGRES_PASSWORD=pass -d postgres

 

ndb라는 이름으로 컨테이너 하나 뜸 (영상에서는 rest인데 문서에는 ndb로 되어 있어서 ndb로 함..)

3. (8) API 인덱스 만들기

IndexContorller

package me.lsh.restapidemo.index;

import me.lsh.restapidemo.events.EventController;
import org.springframework.hateoas.RepresentationModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;

@RestController
public class IndexController {

    @GetMapping("/api")
    public RepresentationModel index() {
        var index = new RepresentationModel<>();
        index.add(linkTo(EventController.class).withRel("events"));
        return index; // '\api'로 요청했을 때 이 핸들러가 적절하게 201 응답으로 이 링크 정보를 보냄
    }
}

IndexControllerTests

package me.lsh.restapidemo.index;

import me.lsh.restapidemo.common.RestDocsConfiguration;
import org.junit.Test;
import org.junit.runner.RunWith;
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.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class) //Bean 불러오기
public class IndexControllerTest {

    @Autowired
    MockMvc mOckMvc;

    @Test
    public void index() throws Exception {
        this.mOckMvc.perform(get("/api"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("_links.events").exists())

        ;
    }
}

ErrorsResource로 이제 new로 넘길 수 있음

package me.lsh.restapidemo.common;

import me.lsh.restapidemo.index.IndexController;
import org.springframework.hateoas.EntityModel;
import org.springframework.validation.Errors;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

public class ErrorsResource extends EntityModel<Errors> {

    public ErrorsResource(Errors content) {
        super(content);
        add(linkTo(methodOn(IndexController.class).index()).withRel("index"));
    }
}