스프링

 

 

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

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

 

 

참조 :

1) 스프링 기반 REST API 개발

https://tram-devlog.tistory.com/entry/스프링-기반-REST-API-개발-KSUG-세미나

 

2)에러 발상 처리
https://acet.pe.kr/924

 

 

 

 

 

[3]  HATEOAS와 Self-Describtive Message 적용

 

 

 

 

 

19. 스프링 HATEOAS 소개

 

강의 :   

 

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

 

스프링 HATEOAS

 

 

 

 

 

 

 

 

 

 

 

 

20. 스프링 HATEOAS 적용

 

강의 :   

 

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

 

 

버전 업

 

EventController

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import java.net.URI;

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

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
@RequiredArgsConstructor
@Log4j2
public class EventController {

    private final EventRepository eventRepository;

   // private final  ModelMapper modelMapper;

    private final EventValidator eventValidator;
   


@PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid  EventDto eventDto, Errors errors){
        if(errors.hasErrors()){
            return ResponseEntity.badRequest().body(errors);
        }

        //커스텀 validate 검사
        eventValidator.validate(eventDto, errors);
        if(errors.hasErrors()){
            return ResponseEntity.badRequest().body(errors);
        }

        //modelMapper 오류
        //Event event=modelMapper.map(eventDto, Event.class);
        Event event = eventDto.toEvent();
        Integer eventId = event.getId();
        //유료인지 무료인지 변경처리
        event.update();
        Event newEvent=this.eventRepository.save(event);//저장

        /**
         *
         * ★ 링크 생성하기
         * EntityModel.of(newEvent); Resource 객체를 가져와서 사용
         *
         * **/
        WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(eventId);
        URI createdUri = selfLinkBuilder.toUri();
        log.info("*  createdUri  {} " , createdUri);
        //출력 =>  *  createdUri  http://localhost/api/events

        EntityModel eventResource = EntityModel.of(newEvent);
        //셀프링크 추가 방법
        eventResource.add(linkTo(EventController.class).slash(eventId).withSelfRel());
        //1)링크추가방법
        eventResource.add(linkTo(EventController.class).withRel("query-events"));
        //2)링크추가방법
        eventResource.add(selfLinkBuilder.withRel("update-event"));
        return ResponseEntity.created(createdUri).body(eventResource);
    }



~

 

 

 

package net.macaronics.restapi.events;

import com.fasterxml.jackson.databind.ObjectMapper;
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.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.hateoas.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDateTime;

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
public class EventControllerTests {



    @Test
    @DisplayName("정상적으로 이벤트를 생성하는 테스트")
    public void createEvent() throws  Exception{
        Event event =Event.builder()
                .id(100)
                .name("Spring")
                .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(false)
                .eventStatus(EventStatus.DRAFT)
                .build();

        mockMvc.perform(
                        post("/api/events")
                                .contentType(MediaType.APPLICATION_JSON_UTF8)
                                .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(header().string(HttpHeaders.CONTENT_TYPE, "application/hal+json;charset=UTF-8"))

                .andExpect(jsonPath("free").value(false))
                .andExpect(jsonPath("offline").value(true))
               // .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.toString()))
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.query-events").exists())
                .andExpect(jsonPath("_links.update-event").exists());
    }


~

 

 

한글 깨짐

application.properties

# REST API 에서 한글깨짐으로 인한 UTF-8 세팅
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.force=true

 

 

 

EntityModel 사용방법 예

https://www.inflearn.com/questions/203512/entitymodel-deprecated-어떻게-바꾸면-될까요

// 전체 사용자 목록
@GetMapping("/users2")
public ResponseEntity<CollectionModel<EntityModel<User>>> retrieveUserList2() {
    List<EntityModel<User>> result = new ArrayList<>();
    List<User> users = service.findAll();

    for (User user : users) {
        EntityModel entityModel = EntityModel.of(user);
        entityModel.add(linkTo(methodOn(this.getClass()).retrieveAllUsers()).withSelfRel());

        result.add(entityModel);
    }

    return ResponseEntity.ok(CollectionModel.of(result, linkTo(methodOn(this.getClass()).retrieveAllUsers()).withSelfRel()));
}

 

 

 

 

 

 

 

 

 

 

21. 스프링  REST Docs  소개

 

강의 :   

 

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

 

 

★  => Spring REST Docs 적용 및 최적화 하기

 

https://docs.spring.io/spring-restdocs/docs/2.0.2.RELEASE/reference/html5/

 

REST Docs 코딩

  • andDo(document(“doc-name”, snippets))

  • snippets

    • links()

    • requestParameters() + parameterWithName()

    • pathParameters() + parametersWithName()

    • requestParts() + partWithname()

    • requestPartBody()

    • requestPartFields()

    • requestHeaders() + headerWithName()

    • requestFields() + fieldWithPath()

    • responseHeaders() + headerWithName()

    • responseFields() + fieldWithPath()

    • ...

  • Relaxed*

  • Processor

    • preprocessRequest(prettyPrint())

    • preprocessResponse(prettyPrint())

    • ...

 

Constraint

 

 

 

 

 

 

 

 

 

 

22. 스프링  REST Docs  적용

 

강의 :   

 

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

 

REST Docs 자동 설정

  • @AutoConfigureRestDocs

 

RestDocMockMvc 커스터마이징

  • RestDocsMockMvcConfigurationCustomizer 구현한 빈 등록

  • @TestConfiguration

 

테스트 할 것

  • API 문서 만들기

    • 요청 본문 문서화

    • 응답 본문 문서화

    • 링크 문서화

      • profile 링크 추가

    • 응답 헤더 문서화

 

1)라이브러리 추가

		<dependency>
			<groupId>org.springframework.restdocs</groupId>
			<artifactId>spring-restdocs-mockmvc</artifactId>
			<scope>test</scope>
		</dependency>

 

2) 테스트 코드에  @AutoConfigureRestDocs  어노테이션 추가 및  마지막에  .andDo(document("create-event")); 추가

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class)
public class EventControllerTests {

 @Test
    @DisplayName("정상적으로 이벤트를 생성하는 테스트")
    public void createEvent() throws  Exception{
        Event event =Event.builder()
                .id(100)
                .name("Spring")
                .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(false)
                .eventStatus(EventStatus.DRAFT)
                .build();

        mockMvc.perform(
                        post("/api/events")
                                .contentType(MediaType.APPLICATION_JSON_UTF8)
                                .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(header().string(HttpHeaders.CONTENT_TYPE, "application/hal+json;charset=UTF-8"))

                .andExpect(jsonPath("free").value(false))
                .andExpect(jsonPath("offline").value(true))
               // .andExpect(jsonPath("eventStatus").value(EventStatus.DRAFT.toString()))
                .andExpect(jsonPath("_links.self").exists())
                .andExpect(jsonPath("_links.query-events").exists())
                .andExpect(jsonPath("_links.update-event").exists())

                //스프링  REST Docs  적용
                .andDo(document("create-event"));

    }

 

 

3) 마지막에  추가한다.

 //스프링  REST Docs  적용
                .andDo(document("create-event"));

 

테스트를 실행하면   target 디렉토리의 generated-snippets 에 파일들이 다음과 같이 생성된다.

 

 정렬이 안되어 있어 보기 어려운데 다음과 같이   RestDocsConfiguration  을 추가하면  자동으로 정렬 처리 된다.

 

4) RestDocsConfiguration

package net.macaronics.restapi.common;


import org.springframework.boot.test.autoconfigure.restdocs.RestDocsMockMvcConfigurationCustomizer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;

/**
 * 스프링  REST  Docs  적용후 formatting
 */
@TestConfiguration
public class RestDocsConfiguration {

    @Bean
    public RestDocsMockMvcConfigurationCustomizer restDocsMockMvcConfigurationCustomizer(){
        return configurer->configurer.operationPreprocessors()
                .withRequestDefaults(prettyPrint())
                .withResponseDefaults(prettyPrint());
    }





}

 

테스트 클래스에 다음과 같이 임포트 후 사용

 

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class)
public class EventControllerTests {

 

 

 

 

 

 

 

 

 

 

 

 

23. 스프링  REST Docs  각종 문서 조각 생성하기

 

스프링 REST Docs: 링크, (Req, Res) 필드와 헤더 문서화

 

강의 :   

 

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

 

요청 필드 문서화

  • requestFields() + fieldWithPath()

  • responseFields() + fieldWithPath()

  • requestHeaders() + headerWithName()

  • responseHedaers() + headerWithName()

  • links() + linkWithRel()

 

테스트 할 것

 

Relaxed 접두어

  • 장점: 문서 일부분만 테스트 할 수 있다.

  • 단점: 정확한 문서를 생성하지 못한다.

 

 

 

 

 다음과 같이   requestHeaders , requestFields , responseHeaders  ,responseFields 추가하면은 아래 이미지와 같이  파일들 추가 생성된다.

 

이때 주의 할점은 요청 파라미터 및 응답파리미터와 와 동일하게 빠짐없이 작성해 줘야 오류가 발행하지 않으며,

만약에 생략을 할 경우에는 relaxedR 를 붙여 줘서 적용 해야 한다.

 

                //스프링  REST Docs  적용
                .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("id").description("id of new Event"),
                               fieldWithPath("beginEnrollmentDateTime").description("beginEnrollmentDateTime of new event"),
                               fieldWithPath("name").description("name of new Enrollment"),
                               fieldWithPath("description").description("ddescription of new event"),
                               fieldWithPath("closeEnrollmentDateTime").description("closeEnrollmentDateTime  of  new event"),
                               fieldWithPath("beginEventDateTime").description("beginEventDateTime of new event"),
                                fieldWithPath("endEventDateTime").description("endEventDateTime  of  new event"),
                                fieldWithPath("location").description("location  of  new event"),
                                fieldWithPath("basePrice").description("basePrice  of  new event"),
                                fieldWithPath("maxPrice").description("maxPrice of new event"),
                                fieldWithPath("limitOfEnrollment").description("limitOfEnrollment of new event"),
                                fieldWithPath("offline").description("offline of new event"),
                                fieldWithPath("free").description("free of new event"),
                                fieldWithPath("eventStatus").description("eventStatus of new Enrollment")

                        ),

                        responseHeaders(
                                headerWithName(HttpHeaders.LOCATION).description("Location header"),
                                headerWithName(HttpHeaders.CONTENT_TYPE).description("Content type")
                        ),
                        
                        // relaxedResponseFields 응답의 일부분만 해당하는 자료 문서화
                        responseFields(

                                fieldWithPath("id").description("id of new Event"),
                                fieldWithPath("beginEnrollmentDateTime").description("beginEnrollmentDateTime of new event"),
                                fieldWithPath("name").description("name of new Enrollment"),
                                fieldWithPath("description").description("ddescription of new event"),
                                fieldWithPath("closeEnrollmentDateTime").description("closeEnrollmentDateTime  of  new event"),
                                fieldWithPath("beginEventDateTime").description("beginEventDateTime of new event"),
                                fieldWithPath("endEventDateTime").description("endEventDateTime  of  new event"),
                                fieldWithPath("location").description("location  of  new event"),
                                fieldWithPath("basePrice").description("basePrice  of  new event"),
                                fieldWithPath("maxPrice").description("maxPrice of new event"),
                                fieldWithPath("limitOfEnrollment").description("limitOfEnrollment of new event"),
                                fieldWithPath("offline").description("offline of new event"),
                                fieldWithPath("free").description("free of new event"),
                                fieldWithPath("eventStatus").description("eventStatus of new Enrollment"),



                                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")
                        )
                ));

 

 

 

 

 

 

 

 

 

 

 

 

25. 스프링 REST  Docs  문서빌드

 

강의 :   

 

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

 

현재 문서

참조 :

https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/#getting-started

 

 

1) 스프링부트 3.0  이상 -   다음plugin  코드를  추가한다.

 

		<!--	추가-->
			<plugin>
				<groupId>org.asciidoctor</groupId>
				<artifactId>asciidoctor-maven-plugin</artifactId>
				<version>2.2.1</version>
				<executions>
					<execution>
						<id>generate-docs</id>
						<phase>prepare-package</phase>
						<goals>
							<goal>process-asciidoc</goal>
						</goals>
						<configuration>
							<backend>html</backend>
							<doctype>book</doctype>
						</configuration>
					</execution>
				</executions>
				<dependencies>
					<dependency>
						<groupId>org.springframework.restdocs</groupId>
						<artifactId>spring-restdocs-asciidoctor</artifactId>
						<version>3.0.0</version>
					</dependency>
				</dependencies>
			</plugin>
			<plugin>
				<artifactId>maven-resources-plugin</artifactId>
				<version>2.7</version>
				<executions>
					<execution>
						<id>copy-resources</id>
						<phase>prepare-package</phase>
						<goals>
							<goal>copy-resources</goal>
						</goals>
						<configuration>
							<outputDirectory>
								${project.build.outputDirectory}/static/docs
							</outputDirectory>
							<resources>
								<resource>
									<directory>
										${project.build.directory}/generated-docs
									</directory>
								</resource>
							</resources>
						</configuration>
					</execution>
				</executions>
			</plugin>

 

 

 

 

2)  이미지와 같이 디렉토리 위치해(디렉토리 생성할것)  index.adoc 파일을 생성해 준다.

src/docs/asciidoc/index.adoc

 

 

 

 

3) index.adoc 파일 내용

= Natural REST API Guide
백기선;
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:
:operation-curl-request-title: Example request
:operation-http-response-title: Example response

[[overview]]
= 개요

[[overview-http-verbs]]
== HTTP 동사

본 REST API에서 사용하는 HTTP 동사(verbs)는 가능한한 표준 HTTP와 REST 규약을 따릅니다.

|===
| 동사 | 용례

| `GET`
| 리소스를 가져올 때 사용

| `POST`
| 새 리소스를 만들 때 사용

| `PUT`
| 기존 리소스를 수정할 때 사용

| `PATCH`
| 기존 리소스의 일부를 수정할 때 사용

| `DELETE`
| 기존 리소스를 삭제할 떄 사용
|===

[[overview-http-status-codes]]
== HTTP 상태 코드

본 REST API에서 사용하는 HTTP 상태 코드는 가능한한 표준 HTTP와 REST 규약을 따릅니다.

|===
| 상태 코드 | 용례

| `200 OK`
| 요청을 성공적으로 처리함

| `201 Created`
| 새 리소스를 성공적으로 생성함. 응답의 `Location` 헤더에 해당 리소스의 URI가 담겨있다.

| `204 No Content`
| 기존 리소스를 성공적으로 수정함.

| `400 Bad Request`
| 잘못된 요청을 보낸 경우. 응답 본문에 더 오류에 대한 정보가 담겨있다.

| `404 Not Found`
| 요청한 리소스가 없음.
|===

[[overview-errors]]
== 오류

에러 응답이 발생했을 때 (상태 코드 >= 400), 본문에 해당 문제를 기술한 JSON 객체가 담겨있다. 에러 객체는 다음의 구조를 따른다.

include::{snippets}/errors/response-fields.adoc[]

예를 들어, 잘못된 요청으로 이벤트를 만들려고 했을 때 다음과 같은 `400 Bad Request` 응답을 받는다.

include::{snippets}/errors/http-response.adoc[]

[[overview-hypermedia]]
== 하이퍼미디어

본 REST API는 하이퍼미디어와 사용하며 응답에 담겨있는 리소스는 다른 리소스에 대한 링크를 가지고 있다.
응답은 http://stateless.co/hal_specification.html[Hypertext Application from resource to resource. Language (HAL)] 형식을 따른다.
링크는 `_links`라는 키로 제공한다. 본 API의 사용자(클라이언트)는 URI를 직접 생성하지 않아야 하며, 리소스에서 제공하는 링크를 사용해야 한다.

[[resources]]
= 리소스

[[resources-index]]
== 인덱스

인덱스는 서비스 진입점을 제공한다.


[[resources-index-access]]
=== 인덱스 조회

`GET` 요청을 사용하여 인덱스에 접근할 수 있다.

operation::index[snippets='response-body,http-response,links']

[[resources-events]]
== 이벤트

이벤트 리소스는 이벤트를 만들거나 조회할 때 사용한다.

[[resources-events-list]]
=== 이벤트 목록 조회

`GET` 요청을 사용하여 서비스의 모든 이벤트를 조회할 수 있다.

operation::get-events[snippets='response-fields,curl-request,http-response,links']

[[resources-events-create]]
=== 이벤트 생성

`POST` 요청을 사용해서 새 이벤트를 만들 수 있다.

operation::create-event[snippets='request-fields,curl-request,http-response,links']

[[resources-events-get]]
=== 이벤트 조회

`Get` 요청을 사용해서 기존 이벤트 하나를 조회할 수 있다.

operation::get-event[snippets='request-fields,curl-request,http-response,links']

[[resources-events-update]]
=== 이벤트 수정

`PUT` 요청을 사용해서 기존 이벤트를 수정할 수 있다.

operation::update-event[snippets='request-fields,curl-request,http-response,links']

 

 

 

4) 오른쪽 maven 메뉴에서 package 클릭후 빌드하면 끝

 

 

 

5) 빌드에 성공하면 target  디렉토리에   generated-docs 디렉토리가 생성 되며 index.html 파일 생성되게 된다.

index.html   파일 선택후 마우스 우클릭후 해당 디렉토리 경로  복사후 브라우저창에서 열면

rest api 문서가 생성된것을 볼수 있다.

file:///F:/Study/JAVA/%EB%B0%B1%EA%B8%B0%EC%84%A0/RestAPI/rest-api-width-spring/target/generated-docs/index.html#resources-events-update_request_fields

 

 

 

 

 

다음과 같이  간단하게  API 문서가 생성 된다.

 

http://localhost:8080/docs/index.html

 

 

6) profile 링크 추가

EventController 에서 profile 링크 추가

eventResource.add(Link.of("/docs/index.html#resource-events-create").withRel("profile"));

 

 

 

7)테스트 코드에서 추가 후 빌드하면 진정한 REST API 개발  문서 완료 

 

    linkWithRel("profile").description("link to update an existing event")


   responseFields(

 fieldWithPath("_links.profile.href").description("link to profile event")
)



 

 

깃 소스

https://github.com/braverokmc79/rest-api-width-spring/commit/ac5df4a571c541d03f8332f59145990186beaa96

 

 

 

 

 

 

 

 

 

 

 

 

26. 테스트용  DB와 설정 분리하기

 

강의 :   

 

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

 

 

Scripts

Here, I memo scripts that I have used during development.

Postgres

Run Postgres Container

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

 

This cmdlet will create Postgres instance so that you can connect to a database with:

  • database: postgres
  • username: postgres
  • password: pass
  • post: 5432

Getting into the Postgres container

docker exec -i -t ndb bash

 

Then you will see the containers bash as a root user.

Connect to a database

psql -d postgres -U postgres

 

Query Databases

\l

 

Query Tables

\dt

 

Quit

\q

 

application.properties

Datasource

spring.datasource.username=postgres
spring.datasource.password=pass
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
spring.datasource.driver-class-name=org.postgresql.Driver

 

Hibernate

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

 

Test Database

spring.datasource.username=sa
spring.datasource.password=
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver

 

 

 

애플리케이션 설정과 테스트 설정 중복 어떻게 줄일 것인가?

  • 프로파일과 @ActiveProfiles 활용

 

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

 

 

test 디렉토리에 다음 이미지처럼 resource  폴더를 생성후 

application-test.properties  파일을 생성해 준다.

 

 

 

인텔리제이에서 인식해 줄수 있도록 다음과 같이 설정해 준다.

 

 

 

테스트  클래스에 어노테이션 추가

@ActiveProfiles("test")
public class EventControllerTests {

 

 

 

 

 

 

 

 

 

 

27. API 인덱스 만들기

 

강의 :   

 

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

 

 

 

1) IndexController 을 다음과 같이 생성해 준다.

 

IndexController

package net.macaronics.restapi.index;

import net.macaronics.restapi.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(){
        RepresentationModel index=new RepresentationModel();
        index.add(linkTo(EventController.class).withRel("events"));
        return index;
    }

}

 

 

2) IndexControllerTest 생성

package net.macaronics.restapi.index;

import com.fasterxml.jackson.databind.ObjectMapper;
import net.macaronics.restapi.common.RestDocsConfiguration;
import net.macaronics.restapi.events.Event;
import net.macaronics.restapi.events.EventStatus;
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.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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@Import(RestDocsConfiguration.class)
@ActiveProfiles("test")
public class IndexControllerTest {


    @Autowired
    MockMvc mockMvc;


    @Autowired
    ObjectMapper objectMapper;


    @Test
    public void index() throws  Exception{
        this.mockMvc.perform(
                get("/api")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .accept(MediaTypes.HAL_JSON)
        ).andExpect(status().isOk())
                .andDo(print())
                .andExpect(jsonPath("_links.events").exists());

    }


    @Test
    @DisplayName("인덱스 입력 값이 잘못된 경우에 에러가 발생하는 테스트")
    public void createEvent_Bad_Request_Wrong_Input() throws Exception{
        Event event =Event.builder()
                .name("Spring")
                .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(10000)
                .maxPrice(200)
                .limitOfEnrollment(100)
                .location("4.강남역 D2 스타텀 팩토리")
                .eventStatus(EventStatus.PUBLISHED)
                .build();

        this.mockMvc.perform(MockMvcRequestBuilders.post("/api/events")
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaTypes.HAL_JSON)
                        .content(objectMapper.writeValueAsString(event)))
                .andDo((print()))
                .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()); // 추가
    }


}

 

 

에러처리시 메인화면으로 이동 처리를 위해

 

ErrosResource

@Component  로 구성해 줘야 한다.

package net.macaronics.restapi.common;

import net.macaronics.restapi.index.IndexController;
import org.springframework.hateoas.EntityModel;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;

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


@Component
public class ErrorsResource{
    public EntityModel addLink(Errors content) {
        EntityModel<Errors> entityModel = EntityModel.of(content);
        entityModel.add(linkTo(methodOn(IndexController.class).index()).withRel("index"));
        return entityModel;
    }
}

 

EventController

    private final ErrorsResource errorsResource;
   

 @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid  EventDto eventDto, Errors errors) throws Exception {

        if (errors.hasErrors()) {
            //  return ResponseEntity.badRequest().body(errors);
            return badRequest(errors);
        }

        //커스텀 validate 검사
        eventValidator.validate(eventDto, errors);
        if(errors.hasErrors()){
            // return ResponseEntity.badRequest().body(errors);
            return badRequest(errors);
        }

~


    private ResponseEntity<EntityModel> badRequest(Errors errors) {
        return ResponseEntity.badRequest().body(errorsResource.addLink(errors));
    }

 

package net.macaronics.restapi.events;


import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import net.macaronics.restapi.common.ErrorsResource;
import org.springframework.hateoas.*;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import java.net.URI;

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

@Controller
@RequestMapping(value = "/api/events", produces = MediaTypes.HAL_JSON_VALUE)
@RequiredArgsConstructor
@Log4j2
public class EventController {

    private final EventRepository eventRepository;

   // private final  ModelMapper modelMapper;

    private final EventValidator eventValidator;

    private final ErrorsResource errorsResource;


    /**
     *  methodOn  사용
     * @return
     */
//    @PostMapping("/method")
//    public ResponseEntity createEventMethod(){
//        WebMvcLinkBuilder webMvcLinkBuilder = linkTo(methodOn(EventController.class).createEvent(null));
//        return ResponseEntity.created(webMvcLinkBuilder.toUri()).build();
//    }


    @PostMapping
    public ResponseEntity createEvent(@RequestBody @Valid  EventDto eventDto, Errors errors) throws Exception {

        if (errors.hasErrors()) {
            //  return ResponseEntity.badRequest().body(errors);
            return badRequest(errors);
        }

        //커스텀 validate 검사
        eventValidator.validate(eventDto, errors);
        if(errors.hasErrors()){
            // return ResponseEntity.badRequest().body(errors);
            return badRequest(errors);
        }

        //modelMapper 오류
        //Event event=modelMapper.map(eventDto, Event.class);
        Event event = eventDto.toEvent();
        Integer eventId = event.getId();
        //유료인지 무료인지 변경처리
        event.update();
        Event newEvent=this.eventRepository.save(event);//저장

        /**
         *
         * ★ 링크 생성하기
         * EntityModel.of(newEvent); Resource 객체를 가져와서 사용
         *
         * **/
        WebMvcLinkBuilder selfLinkBuilder = linkTo(EventController.class).slash(eventId);
        URI createdUri = selfLinkBuilder.toUri();
        log.info("*  createdUri  {} " , createdUri);
        //출력 =>  *  createdUri  http://localhost/api/events

        EntityModel eventResource = EntityModel.of(newEvent);
        //셀프링크 추가 방법
        eventResource.add(linkTo(EventController.class).slash(eventId).withSelfRel());
        //1)링크추가방법
        eventResource.add(linkTo(EventController.class).withRel("query-events"));
        //2)링크추가방법
        eventResource.add(selfLinkBuilder.withRel("update-event"));
        eventResource.add(Link.of("/docs/index.html#resource-events-create").withRel("profile"));
        return ResponseEntity.created(createdUri).body(eventResource);
    }

    private ResponseEntity<EntityModel> badRequest(Errors errors) {
        return ResponseEntity.badRequest().body(errorsResource.addLink(errors));
    }


}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

실물의 형상이 굽으면 그 그림자도 또한 굽다. 좋은 결과를 얻기를 바란다면 좋은 행위를 해야 한다. -관자

댓글 ( 4)

댓글 남기기

작성