프로젝트 구조를 설계하고 ERD를 그렸으니, 이제 API 문서를 작성하기로 했다. 요청값과 응답값은 임의로 하드코딩하여 간단한 API 동작을 구현할 것이다.
API 문서 작성 툴 중에선 Spring REST Docs를 사용하기로 했다.
Spring REST Docs는 테스트 코드가 필요해서 미리 API 규격을 잡아 놓는 데에 좋다고 생각한다. 테스트 코드를 미리 만들어두는 편이 후에 개발하고 의도한 대로 만들어졌는지 확인하는 데 좋을 것이다.
Spring REST Docs는 테스트 코드 기반으로 RESTful 문서 생성을 도와주는 도구이다.
보통은 Swagger와 비교하는 듯 하다. 이는 참고 사이트를 보는 걸 추천한다.
먼저, 프로젝트의 Build 를 설정해야 한다.
plugins {
id 'org.asciidoctor.jvm.convert' version '3.3.2' // (1)
}
configurations {
asciidoctorExt // (2)
}
ext {
set('snippetsDir', file("build/generated-snippets")) // (3)
}
dependencies { // (4)
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
tasks.named('test') {
outputs.dir snippetsDir // (5)
useJUnitPlatform()
}
asciidoctor { // (6)
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
build/generated-snippets
를 가리키도록 해준다.src/docs/asciidoc
디렉토리에 .adoc
파일로 작성한다.테스트가 실행되면, 입력(HTTP 요청)과 응답(HTTP 응답)을 어떻게 문서화할 지를 구성하는 문서이다.
프로젝트를 빌드하면 build.gradle
에서 설정했던 snippetsDir 하위에 기본 스니펫들이 생기는데, 해당 스니펫들을 지정했다.
커스텀 스니펫을 생성 후 적용할 수도 있다.
http-request
: HTTP 요청 메시지http-response
: HTTP 응답 메시자request-fields
: 요청 필드response-fields
: 응답 필드operation::index[snippets='http-request,http-response,request-fields,response-fields']
IntelliJ에서는 adoc 문서를 만들면, 화면을 미리 볼 수 있다.
가볍게 테스트 해보기 위해 회원 등록 API를 만들었다.
요청이 들어오면 받아줄 Controller이다.
@RestController
@RequiredArgsConstructor
public class MemberRestAdapter {
private final RegisterMemberUseCase memberService;
@PostMapping("/member")
@ResponseBody
MemberCreateResponse createMember(@RequestBody MemberCreateRequest request) {
Member member = memberService.registMember(request.toMember());
return MemberCreateResponse.from(member);
}
}
도메인 계층의 Service이다.
@UseCase
public class MemberService implements RegisterMemberUseCase, DeregisterMemberUseCase, LoginUseCase {
private final MemberPort memberPort;
...
@Override
public Member registMember(Member member) {
return memberPort.saveMember(member.activate());
}
...
}
영속성 계층의 어댑터이다.
save
메소드 호출@Component
@RequiredArgsConstructor
public class MemberPersistenceAdapter implements MemberPort {
private final MemberRepository memberRepository;
@Override
public Member saveMember(Member member) {
MemberEntity memberEntity = MemberEntity.from(member);
MemberEntity savedMember = memberRepository.save(memberEntity);
return savedMember.toMember();
}
...
}
아직 DB 연동을 하지 않았으므로, 임시 레파지토리를 생성했다. 추후 JPA 연동을 한 뒤에 수정될 것이다.
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, MemberEntity> store = new HashMap<>();
private static Long id = 1L;
@Override
public MemberEntity save(MemberEntity member) {
MemberEntity savedMember = MemberEntity.builder()
.id(id)
.userKey(id.toString())
.status(member.getStatus())
.linkType(member.getLinkType())
.email(member.getEmail())
.userName(member.getUserName())
.nickName(member.getNickName())
.phoneNum(member.getPhoneNum())
.gender(member.getGender())
.birthday(member.getBirthday())
.password(member.getPassword())
.lastLoginAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
store.put(id++, savedMember);
return savedMember;
}
...
}
이제 문서화를 위해 테스트 코드를 작성했다.
Entity에서 LocalDate
와 LocalDateTime
을 사용했는데, 이 타입들로 선언된 변수들이 Array로 인식되는 문제가 있었다.
따라서, Array가 아닌 String으로 가져오도록 ObjectMapper를 사용했다.
@Configuration
public class SpringConfiguration {
@Bean
ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return objectMapper;
}
}
@ExtendWith(RestDocumentationExtension.class) // (1)
@AutoConfigureMockMvc // (2)
RestDocumentationExtension
테스트 클래스를 적용한다.@SpringBootTest
를 사용하는 테스트에서 MockMvc를 사용할 경우 추가한다.@BeforeEach
에서 MockMvc를 구성하는 방법을 제공한다.@Autowired
로 자동주입한다.@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest
@Transactional
@AutoConfigureMockMvc
public class MemberServiceTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
}
MemberCreateRequest
: 저장할 회원 정보mockMvc.perform
로 호출할 API와 예상 결과를 작성한다.post("/member")
: POST로 API 호출content(objectMapper.writeValueAsString(request))
: HTTP 요청 값을 설정한다.andExpect(status().isOk())
: 기대하는 응답 값 (200 OK)andDo(document("index", ...)
: 테스트 결과를 문서화한다.requestFields
: 문서에 표시될 요청 필드들responseFields
: 문서에 표시될 응답 필드들...
public class MemberServiceTest {
...
@Test
void registerMember() throws Exception {
// given
MemberCreateRequest request = MemberCreateRequest.builder()
.linkType(MemberLinkType.NONE)
.email("Test@gmail.com")
.userName("홍길순")
.nickName("테스터")
.phoneNum("010-1111-2222")
.gender(MemberGender.FEMALE)
.birthday(LocalDate.of(1998,1,30))
.password("")
.build();
// when
// then
this.mockMvc.perform(post("/member")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(document("index",
requestFields(
fieldWithPath("linkType").description("회원연동"),
fieldWithPath("email").description("이메일"),
fieldWithPath("userName").description("이름"),
fieldWithPath("nickName").description("닉네임"),
fieldWithPath("phoneNum").description("핸드폰 번호"),
fieldWithPath("gender").description("성별"),
fieldWithPath("birthday").description("생년월일"),
fieldWithPath("password").description("비밀번호")
),
responseFields(
fieldWithPath("userKey").description("회원번호(외부용)"),
fieldWithPath("status").description("회원상태")
)));
}
}
@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest
@Transactional
@AutoConfigureMockMvc
public class MemberServiceTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
@Test
void registerMember() throws Exception {
// given
MemberCreateRequest request = MemberCreateRequest.builder()
.linkType(MemberLinkType.NONE)
.email("Test@gmail.com")
.userName("홍길순")
.nickName("테스터")
.phoneNum("010-1111-2222")
.gender(MemberGender.FEMALE)
.birthday(LocalDate.of(1998,1,30))
.password("")
.build();
// when
// then
this.mockMvc.perform(post("/member")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(document("index",
requestFields(
fieldWithPath("linkType").description("회원연동"),
fieldWithPath("email").description("이메일"),
fieldWithPath("userName").description("이름"),
fieldWithPath("nickName").description("닉네임"),
fieldWithPath("phoneNum").description("핸드폰 번호"),
fieldWithPath("gender").description("성별"),
fieldWithPath("birthday").description("생년월일"),
fieldWithPath("password").description("비밀번호")
),
responseFields(
fieldWithPath("userKey").description("회원번호(외부용)"),
fieldWithPath("status").description("회원상태")
)));
}
}
프로젝트를 빌드하면 자동으로 테스트 수행 후 API 문서를 생성해준다.
./gradlew asciidoctor
IntelliJ에선 아래에 터미널을 띄워 명령어를 입력하면 된다.
생성된 문서는 build/docs/asciidoc/index.html
로 만들어진다.
참고 사이트:
원인은 다음처럼 나온다.
Cannot construct instance of com.flab.funding.infrastructure.adapters.input.data.request.MemberCreateRequest (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
반환할 때 MessageConverter를 사용했는데, Json을 Object로 변환할 때 빈 생성자(기본 생성자)가 있어야 했다.
나는 @Builder
를 사용해서 빈 생성자가 자동 생성이 안되었던 거 같아서, lombok의 @NoArgsContructor
를 이용해 생성자를 추가하기로 했다.
참고 사이트:
@Builder
사용 시 @NoArgsContructor
를 같이 사용하면 오류가 발생했다.
@Builder
를 사용하면 모든 멤버 변수를 파라미터로 받는 기본 생성자를 생성한다.@NoArgsContructor
를 사용하면 Builder에 필요한 생성자가 만들어지지 않아서 오류가 발생한다.@AllArgsConstructor
를 사용해야 한다.@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class MemberCreateRequest {
...
}
참고 사이트:
Jackson은 내부적으로 ObjectMapper를 사용하여 Object와 Json 간의 매핑을 진행한다고 한다.
따라서, ObjectMapper를 Bean으로 등록하여 설정을 일부 변경했다.
JavaTimeModule
모듈 등록@Configuration
public class SpringConfiguration {
@Bean
ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule()); // (1)
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // (2)
return objectMapper;
}
}
cannot deserialize from object value 에러!
[오류] Lombok @Builder 생성자 관련 오류
[java] objectMapper로 object->string(json) 변경 시 LocalDate 를 yyyy-MM-dd 포멧하기