스프링 기반 REST API 개발 - 인프런 | 강의
다양한 스프링 기술을 사용하여 Self-Descriptive Message와 HATEOAS(Hypermedia as the engine of application state)를 만족하는 REST API를 개발하는 강의입니다., 스프링으로 REST를 따르는 API를 만들어보...
www.inflearn.com
3.1 스프링 HATEOAS
- 스프링 HATEOAS: 스프링 프로젝트 중 하나로, rest한 리소스를 쉽게 제공해주기 위한 API를 만들 때 편리하게 사용할 수 있는 툴을 제공하는 라이브러리
- 즉, HATEOAS를 만족하는 REST representation을 쉽게 생성할 수 있게 도와주는 API를 제공하는 프로젝트이다.
Spring HATEOAS - Reference Documentation
Example 46. Configuring WebTestClient when using Spring Boot @SpringBootTest @AutoConfigureWebTestClient (1) class WebClientBasedTests { @Test void exampleTest(@Autowired WebTestClient.Builder builder, @Autowired HypermediaWebTestClientConfigurer configure
docs.spring.io
- HATEOAS: REST application architecture component 중 하나로, hypermedia를 사용해 클라이언트가 application server와 정보를 동적으로 주고받을 수 있는 방법이다.
스프링 HATEOAS의 핵심 기능
- 링크 만드는 기능
- 링크: HREF(hypermedia 링크), Rel;현재 이 리소스와의 관계 표현(self, profile, update-event, query-events 등)
- 컨트롤러의 메소드로 만들기
- 문자열로 만들기
- 리소스 만드는 기능
→ 리소스: 전달해주고자 하는 응답, 본문과 링크 정보를 합친 것
3.2 스프링 HATEOAS 적용
Spring HATEOAS를 적용해 REST API 형식을 만들어보자!
https://docs.spring.io/spring-hateoas/docs/1.2.0/reference/html/#server.entity-links
Spring HATEOAS - Reference Documentation
Example 47. Configuring WebTestClient when using Spring Boot @SpringBootTest @AutoConfigureWebTestClient (1) class WebClientBasedTests { @Test void exampleTest(@Autowired WebTestClient.Builder builder, @Autowired HypermediaWebTestClientConfigurer configure
docs.spring.io
PostController.java
@PostMapping
public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) {
if (errors.hasErrors()) {
return ResponseEntity.badRequest().body(errors);
}
eventValidator.validate(eventDto, errors);
if (errors.hasErrors()) {
return ResponseEntity.badRequest().body(errors);
}
Event newEvent = eventRepository.save(modelMapper.map(eventDto, Event.class));
Integer eventId = newEvent.getId();
newEvent.update();
WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(eventId);
URI createdUri = selfLinkBuilder.toUri();
EntityModel eventResource = EntityModel.of(newEvent);
eventResource.add(linkTo(EventController.class).slash(eventId).withSelfRel());
eventResource.add(linkTo(EventController.class).withRel("query-events"));
eventResource.add(selfLinkBuilder.withRel("update-event"));
return ResponseEntity.created(createdUri).body(eventResource);
}
- Spring HATEOAS 1.2.0부터는 Resource → EntityModel로 변경되었기 때문에, EventResource 객체를 따로 만들지 않고 EntityModel을 사용해 구현할 수 있다.
- createEvent 응답에 link 정보를 추가해보자.
https://docs.spring.io/spring-hateoas/docs/1.2.0/reference/html/#server.entity-links
Spring HATEOAS - Reference Documentation
Example 47. Configuring WebTestClient when using Spring Boot @SpringBootTest @AutoConfigureWebTestClient (1) class WebClientBasedTests { @Test void exampleTest(@Autowired WebTestClient.Builder builder, @Autowired HypermediaWebTestClientConfigurer configure
docs.spring.io
EventControllerTests.java
@Test
@TestDescription("정상적으로 이벤트를 생성하는 테스트")
public void createEvent() throws Exception {
EventDto eventDto = EventDto.builder()
.name("Spring")
.description("REST API Development with Spring")
.beginEnrollmentDateTime(LocalDateTime.of(2023, 11, 14, 23, 30))
.closeEnrollmentDateTime(LocalDateTime.of(2023, 11, 24, 23, 59))
.beginEventDateTime(LocalDateTime.of(2023, 11, 25, 12, 30))
.endEventDateTime(LocalDateTime.of(2023, 11, 25, 18, 30))
.basePrice(10000)
.maxPrice(20000)
.limitOfEnrollment(100)
.location("공덕역 프론트원")
.build();
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(eventDto)))
.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))
.andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.name()))
.andExpect(jsonPath("_links.self").exists())
.andExpect(jsonPath("_links.query-events").exists())
.andExpect(jsonPath("_links.update-event").exists())
;
}
- links(self, query-events, update-event)가 잘 반환되는지 추가로 테스트한다.
- 테스트 결과, body값이 다음과 같이 반환된다. links 값들이 잘 들어있는걸 확인할 수 있다.
{
"id":1,
"name":"Spring",
"description":"REST API Development with Spring",
"beginEnrollmentDateTime":"2023-11-14T23:30:00",
"closeEnrollmentDateTime":"2023-11-24T23:59:00",
"beginEventDateTime":"2023-11-25T12:30:00",
"endEventDateTime":"2023-11-25T18:30:00",
"location":"공덕역 프론트원",
"basePrice":10000,
"maxPrice":20000,
"limitOfEnrollment":100,
"offline":true,
"free":false,
"eventStatus":"DRAFT",
"_links":{
"self":{
"href":"http://localhost/api/events/1"
},
"query-events":{
"href":"http://localhost/api/events"
},
"update-event":{
"href":"http://localhost/api/events/1"
}
}
}
3.3 Spring REST Docs
https://docs.spring.io/spring-restdocs/docs/2.0.2.RELEASE/reference/html5/
Spring REST Docs
Document RESTful services by combining hand-written documentation with auto-generated snippets produced with Spring MVC Test.
docs.spring.io
- Spring REST Docs: Spring MVC test를 사용해 REST API 문서의 일부분을 생성해내는 유용한 기술을 제공하는 라이브러리
- 테스트에 사용한 요청과 응답, 응답의 헤더 등의 정보를 사용해 snippets를 자동으로 만들어 준다. 이 snippets를 모아 REST API 문서를 완성할 수 있다.
3.4 Spring REST Docs 적용
- 테스트 클래스에 @AutoConfigureRestDocs 어노테이션을 추가해 REST Docs 자동 설정
EventControllerTests.java
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class)
public class EventControllerTests {
}
- Spring RestDocs 사용을 위해 @AutoConfigureRestDocs 어노테이션을 추가하고, @Import 어노테이션으로 RestDocsConfiguration 클래스를 사용하도록 설정한다.
RestDocsConfiguration.java
@TestConfiguration
public class RestDocsConfiguration {
@Bean
public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer() {
return configurer -> configurer.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint());
}
}
- preprocessor 기능 중 response와 request를 정돈된 형태로 print해주는 prettyPrint()를 사용한다.
설정해주고 테스트를 실행하면 위 사진과 같이 target 패키지 안에 generated-snippets 항목이 생긴다. http-response.adoc 파일을 열어보면 다음과 같이 html으로 작성된 snippet을 확인할 수 있다.
[source,http,options="nowrap"]
----
HTTP/1.1 201 Created
Location: http://localhost:8080/api/events/1
Content-Type: application/hal+json
Content-Length: 724
{
"id" : 1,
"name" : "Spring",
"description" : "REST API Development with Spring",
"beginEnrollmentDateTime" : "2023-11-14T23:30:00",
"closeEnrollmentDateTime" : "2023-11-24T23:59:00",
"beginEventDateTime" : "2023-11-25T12:30:00",
"endEventDateTime" : "2023-11-25T18:30:00",
"location" : "공덕역 프론트원",
"basePrice" : 10000,
"maxPrice" : 20000,
"limitOfEnrollment" : 100,
"offline" : true,
"free" : false,
"eventStatus" : "DRAFT",
"_links" : {
"self" : {
"href" : "http://localhost:8080/api/events/1"
},
"query-events" : {
"href" : "http://localhost:8080/api/events"
},
"update-event" : {
"href" : "http://localhost:8080/api/events/1"
}
}
}
----
3.5 Spring REST Docs 각종 문서 조각 생성
createEvent()
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaTypes.HAL_JSON)
.content(objectMapper.writeValueAsString(eventDto)))
.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))
.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 new event enrollment"),
fieldWithPath("closeEnrollmentDateTime").description("date time of close of of new event 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 of new event")
),
responseHeaders(
headerWithName(HttpHeaders.LOCATION).description("location header"),
headerWithName(HttpHeaders.CONTENT_TYPE).description("content type header")
),
responseFields(
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 new event enrollment"),
fieldWithPath("closeEnrollmentDateTime").description("date time of close of of new event 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 of new event"),
fieldWithPath("free").description("it tells if this event is free or not"),
fieldWithPath("offline").description("it tells if this event is offline or not"),
fieldWithPath("eventStatus").description("event status"),
fieldWithPath("_links.self.href").description("link to self"),
fieldWithPath("_links.query-events.href").description("link to query-event list"),
fieldWithPath("_links.update-event.href").description("link to update existing event")
)
));
- links로 snippets를 추가해주고 테스트를 실행하면 link와 관련된 문서 조각들(snippets)도 같이 생성된다.
- links, requestHeaders, requestFields, responseHeaders, responseFields 문서화 테스트
- links 테스트는 이미 links+linkWithRel()로 확인했는데, 아래 responseFields에서 제외하고 테스트 실행 시 에러가 발생한다. 이는 response body에 있는 모든 field에 대해 테스트하지 않았다는 이유로 발생하는데, 해결책은 두 가지가 있다.
- relaxed prifix 사용(relaxedResponseFields)
- relaxedResponseHeaders를 사용하면 모든 필드를 포함하지 않아도 테스트를 수행할 수 있다.
- 단, 정확한 문서를 생성하지 못한다는 단점이 존재한다.
- 반복되지만 responseFields 안에 모든 필드를 포함하기
- 위 코드에서는 해당 방법으로 에러를 해결했고, 추후 API에 변경 사항이 생겼을 때 REST Docs에도 자동으로 반영될 수 있도록 웬만하면 relaxed를 사용하지 않고 모든 필드를 포함하는게 좋다.
- relaxed prifix 사용(relaxedResponseFields)
3.6 Spring REST Docs 문서 빌드
Spring REST Docs 문서를 빌드하는 과정에서 마주한 에러 위주로 기록
Error: maven-resources-plugin setting
pom.xml
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
- "Plugin 'maven-resources-plugin:3.3.1' not found" 에러가 발생해 intelliJ setting에서 "Use plugin registry" 옵션을 활성화해주어 해결했다.
Maven plugins can not be found in IntelliJ
After updating IntelliJ from version 12 to 13, the following Maven-related plugins cannot be resolved: org.apache.maven.plugins:maven-clean-plugin:2.4.1 org.apache.maven.plugins:maven-deploy-plugin...
stackoverflow.com
Error: Cannot resolve constructor 'Link(String)'
eventResource.add(new Link("/docs/index.html#resources-events-create").withRel("profile"));
Cannot resolve constructor 'Link(String)'
- profile link를 만들어 추가해주는 과정에서 new Link 구문에 "Cannot resolve constructor 'Link(String)" 에러가 발생했다. 아래와 같이 Link.of를 활용해 Link를 생성해 추가한다.
Link profile = Link.of("/docs/index.html#resources-events-create").withRel("profile");
eventResource.add(profile);
Hateoas - No suitable constructor found for Link(java.lang.String)
For a REST API, in the controller I'm applying hateoas. When adding the part of Link in the methods, I get the follow error: Cannot resolve constructor 'Link(String)' In the pom.xml: <dependenc...
stackoverflow.com
3.7 테스트용 DB와 설정 분리
애플리케이션 서버를 PostgreSQL로 바꿔보자
1. PostgreSQL 드라이버 dependency 추가
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
2. Docker로 PostgreSQL 컨테이너 실행
docker run --name ndb -p 5432:5432 -e POSTGRES_PASSWORD=pass -d postgres
3. application.properties 설정 및 hibernate 설정
application.properties
spring.datasource.username=postgres
spring.datasource.password=pass
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
4. application-test.properties 설정
application-test.properties
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.hikari.jdbc-url=jdbc:h2:mem:testdb
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
- 공용으로 사용하는 설정은 application.properties에 두고, 테스트에서만 사용하는 설정은 application-test.properties에 작성하는 방식으로 설정 파일을 관리한다.
3.8 API 인덱스 만들기
다른 리소스에 대한 링크를 제공하는 index를 만들어보자
IndexControllerTest.java
@Test
public void index() throws Exception {
this.mockMvc.perform(get("/api/"))
.andExpect(status().isOk())
.andExpect(jsonPath("_links.events").exists());
}
IndexController.java
@RestController
public class IndexController {
@GetMapping("/api/")
public RepresentationModel index() {
var index = new RepresentationModel<>();
index.add(linkTo(EventController.class).withRel("events"));
return index;
}
}
ErrorsResource.java
public class ErrorsResource extends EntityModel<Errors> {
public static EntityModel<Errors> modelOf(Errors errors) {
EntityModel<Errors> errorsModel = EntityModel.of(errors);
errorsModel.add(linkTo(methodOn(IndexController.class).index()).withRel("index"));
return errorsModel;
}
}
- index로 가는 링크를 제공하는 ErrorsResource를 생성한다. 그에 따라 EventController와 EventControllerTests도 리팩토링 해준다.
EventControllerTests.java
mockMvc.perform(post("/api/events/")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(eventDto)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("errors[0].objectName").exists())
.andExpect(jsonPath("errors[0].defaultMessage").exists())
.andExpect(jsonPath("errors[0].code").exists())
.andExpect(jsonPath("_links.index").exists());
EventController.java
@PostMapping
public ResponseEntity createEvent(@RequestBody @Valid EventDto eventDto, Errors errors) {
if (errors.hasErrors()) {
return badRequest(errors);
}
eventValidator.validate(eventDto, errors);
if (errors.hasErrors()) {
return badRequest(errors);
}
Event newEvent = eventRepository.save(modelMapper.map(eventDto, Event.class));
Integer eventId = newEvent.getId();
newEvent.update();
WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(eventId);
URI createdUri = selfLinkBuilder.toUri();
EntityModel eventResource = EntityModel.of(newEvent);
eventResource.add(linkTo(EventController.class).slash(eventId).withSelfRel());
eventResource.add(linkTo(EventController.class).withRel("query-events"));
eventResource.add(selfLinkBuilder.withRel("update-event"));
Link profile = Link.of("/docs/index.html#resources-events-create").withRel("profile");
eventResource.add(profile);
return ResponseEntity.created(createdUri).body(eventResource);
}
private ResponseEntity badRequest(Errors errors) {
return ResponseEntity.badRequest().body(ErrorsResource.modelOf(errors));
}