diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..55511bf3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# 어떤 이미지로부터 만들지 선택합니다. 이 이미지는 우리 프로젝트를 빌드를 하기 위해서만 사용되는 이미지입니다. 빌드 하기 위해서 모든 파일을 컨테이너로 복사한 후 빌드를 실행합니다. 빌드를 하는 용도로만 사용되고 더 이상 사용되지 않습니다. +FROM openjdk:11 AS builder +# 현재 폴더에서 컨테이너의 현재 폴더로 복사합니다. +COPY . . +# 명령어를 실행합니다. +RUN sed -i 's/\r//' ./gradlew +RUN ["./gradlew", "assemble"] +# 이 이미지가 우리가 실제로 실행시킬 이미지입니다. 마찬가지로 openjdk로 이미지를 사용합니다. +FROM openjdk:11 +# 위의 빌드 이미지로부터 만들어진 jar 파일을 복사합니다. +COPY --from=builder /app/build/libs/app.jar . +# 서버를 실행시키는 명령어입니다. +CMD ["java", "-jar", "app.jar"] diff --git a/app/build.gradle b/app/build.gradle index 991a8b2a..663736cb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,7 +5,11 @@ * For more details take a look at the 'Building Java & JVM projects' chapter in the Gradle * User Manual available at https://docs.gradle.org/6.7/userguide/building_java_projects.html */ - +buildscript { + ext { + asciidocVersion = "2.0.6.RELEASE" + } +} plugins { // Apply the application plugin to add support for building a CLI application in Java. id 'application' @@ -13,6 +17,13 @@ plugins { // Spring id 'org.springframework.boot' version '2.3.5.RELEASE' id 'io.spring.dependency-management' version '1.0.10.RELEASE' + + // asciidoctor + id "org.asciidoctor.jvm.convert" version "3.3.2" +} + +ext { + snippetsDir = file('build/generated-snippets') } sourceCompatibility = '1.8' @@ -22,6 +33,10 @@ configurations { runtimeClasspath { extendsFrom developmentOnly } + compileOnly { + extendsFrom annotationProcessor + } + asciidoctorExt } repositories { @@ -78,6 +93,12 @@ dependencies { testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' } + + //RES DOC Lib + asciidoctorExt "org.springframework.restdocs:spring-restdocs-asciidoctor:${asciidocVersion}" + testImplementation "org.springframework.restdocs:spring-restdocs-mockmvc:${asciidocVersion}" + + implementation 'org.mariadb.jdbc:mariadb-java-client' } application { @@ -89,3 +110,20 @@ tasks.named('test') { // Use junit platform for unit tests. useJUnitPlatform() } +test { + outputs.dir snippetsDir +} + +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn test +} + +bootJar { + dependsOn asciidoctor + copy {// 생성된 html 파일 복사 + from asciidoctor.outputDir + into "src/main/resources/static/docs" + } +} diff --git a/app/src/docs/asciidoc/index.adoc b/app/src/docs/asciidoc/index.adoc index b5fb9b8b..8f3ab1ab 100644 --- a/app/src/docs/asciidoc/index.adoc +++ b/app/src/docs/asciidoc/index.adoc @@ -1,17 +1,81 @@ = 고양이 장난감 가게 API +:toc: left +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toclevels: 1 +:sectlinks: -== GET /products +== 상품 단건 조회 +상품에 대한 자세한 정보를 JSON 형태로 돌려준다. -상품 목록을 JSON 형태로 돌려준다. +=== 요청 +include::{snippets}/product-inquiry/http-request.adoc[] -include::{snippets}/get-products/http-request.adoc[] +include::{snippets}/product-inquiry/path-parameters.adoc[] -include::{snippets}/get-products/http-response.adoc[] +=== 응답 +include::{snippets}/product-inquiry/http-response.adoc[] -== GET /product/{id} +include::{snippets}/product-inquiry/response-fields.adoc[] -상품에 대한 자세한 정보를 JSON 형태로 돌려준다. +=== curl +include::{snippets}/product-inquiry/curl-request.adoc[] + +== 상품 목록 조회 +상품 전체 목록에 대한 정보를 JSON 형태로 돌려준다. + +=== 요청 +include::{snippets}/product-inquiry-all/http-request.adoc[] + +=== 응답 +include::{snippets}/product-inquiry-all/http-response.adoc[] + +include::{snippets}/product-inquiry-all/response-fields.adoc[] + +=== curl +include::{snippets}/product-inquiry-all/curl-request.adoc[] + +== 상품 생성 +상품 생성 후 생성한 상품에 대한 정보를 JSON 형태로 돌려준다. + +=== 요청 +include::{snippets}/product-create/http-request.adoc[] + +=== 응답 +include::{snippets}/product-create/http-response.adoc[] + +include::{snippets}/product-create/response-fields.adoc[] + +=== curl +include::{snippets}/product-create/curl-request.adoc[] + +== 상품 수정 +상품 수정 후 생성한 상품에 대한 정보를 JSON 형태로 돌려준다. + +=== 요청 +include::{snippets}/product-update/http-request.adoc[] + +include::{snippets}/product-update/path-parameters.adoc[] + +=== 응답 +include::{snippets}/product-update/http-response.adoc[] + +include::{snippets}/product-update/response-fields.adoc[] + +=== curl +include::{snippets}/product-update/curl-request.adoc[] + +== 상품 삭제 +상품을 삭제할 아이디를 받으면 상품을 삭제한다. + +=== 요청 +include::{snippets}/product-delete/http-request.adoc[] + +include::{snippets}/product-delete/path-parameters.adoc[] -include::{snippets}/get-product/http-request.adoc[] +=== 응답 +include::{snippets}/product-delete/http-response.adoc[] -include::{snippets}/get-product/http-response.adoc[] +=== curl +include::{snippets}/product-delete/curl-request.adoc[] diff --git a/app/src/main/java/com/codesoom/assignment/dto/ProductData.java b/app/src/main/java/com/codesoom/assignment/dto/ProductData.java index ebf6fd5c..2fb2e09e 100644 --- a/app/src/main/java/com/codesoom/assignment/dto/ProductData.java +++ b/app/src/main/java/com/codesoom/assignment/dto/ProductData.java @@ -16,7 +16,6 @@ @NoArgsConstructor @AllArgsConstructor public class ProductData { - private Long id; @NotBlank @Mapping("name") diff --git a/app/src/main/resources/application.yml b/app/src/main/resources/application.yml index 4fc23938..a4d3f518 100644 --- a/app/src/main/resources/application.yml +++ b/app/src/main/resources/application.yml @@ -1,6 +1,8 @@ spring: datasource: - url: jdbc:h2:~/data/demo + url: jdbc:mariadb://mariadb:3306/test + username: root + password: root1234 jpa: hibernate: ddl-auto: update diff --git a/app/src/test/java/com/codesoom/assignment/controllers/ProductControllerDocTest.java b/app/src/test/java/com/codesoom/assignment/controllers/ProductControllerDocTest.java new file mode 100644 index 00000000..5aaedfec --- /dev/null +++ b/app/src/test/java/com/codesoom/assignment/controllers/ProductControllerDocTest.java @@ -0,0 +1,166 @@ +package com.codesoom.assignment.controllers; + +import com.codesoom.assignment.dto.ProductData; +import com.codesoom.assignment.utils.RestDocsSupporter; +import com.google.common.net.HttpHeaders; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.servlet.ResultActions; + + +import java.util.Arrays; + +import static com.codesoom.assignment.utils.TestHelper.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.springframework.http.MediaType.*; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@DisplayName("ProductController 테스트") +@SuppressWarnings("NonAsciiCharacters") +public class ProductControllerDocTest extends RestDocsSupporter { + + @Test + @DisplayName("상품 전체 조회 테스트") + void RESTDOC_상품_전체_조회() throws Exception { + given(productService.getProducts()) + .willReturn(Arrays.asList(TEST_PRODUCT)); + + ResultActions result = mockMvc.perform(get("/products") + .accept(APPLICATION_JSON)); + + result.andExpect(status().isOk()) + .andDo( + document("product-inquiry-all", + responseFields( + fieldWithPath("[].id").description("상품 아이디"), + fieldWithPath("[].name").description("상품명"), + fieldWithPath("[].price").description("가격"), + fieldWithPath("[].maker").description("제조사"), + fieldWithPath("[].imageUrl").description("이미지 URL") + ) + )); + } + + @Test + @DisplayName("상품 단건 조회 테스트") + void RESTDOC_상품_단건_조회() throws Exception { + given(productService.getProduct(anyLong())) + .willReturn(TEST_PRODUCT); + + ResultActions result = mockMvc.perform(get("/products/{productId}", 1L) + .accept(APPLICATION_JSON)); + + result.andExpect(status().isOk()) + .andDo(document("product-inquiry", pathParameters( + parameterWithName("productId").description("조회할 상품 아이디") + ), + responseFields( + fieldWithPath("id").description("상품 아이디"), + fieldWithPath("name").description("상품명"), + fieldWithPath("price").description("가격"), + fieldWithPath("maker").description("제조사"), + fieldWithPath("imageUrl").description("이미지 URL") + ) + )); + } + + @Test + @DisplayName("상품 생성 테스트") + void RESTDOC_상품_생성() throws Exception { + given(productService.createProduct(any(ProductData.class))) + .willReturn(TEST_PRODUCT); + + ResultActions result = mockMvc.perform(post("/products") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(TEST_PRODUCT_DATA)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + VALID_TOKEN)); + + + result.andExpect(status().isCreated()) + .andDo(document("product-create", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("회원 인증 토큰") + ), + requestFields( + fieldWithPath("name").description("상품명"), + fieldWithPath("price").description("가격"), + fieldWithPath("maker").description("제조사"), + fieldWithPath("imageUrl").description("이미지 URL") + ), + responseFields( + fieldWithPath("id").description("상품 아이디"), + fieldWithPath("name").description("상품명"), + fieldWithPath("price").description("가격"), + fieldWithPath("maker").description("제조사"), + fieldWithPath("imageUrl").description("이미지 URL") + ) + )); + } + + + @Test + @DisplayName("상품 수정 테스트") + void RESTDOC_상품_수정() throws Exception { + given(productService.updateProduct(anyLong(), any(ProductData.class))) + .willReturn(UPDATED_PRODUCT); + + ResultActions result = mockMvc.perform(patch("/products/{productId}", 1L) + .accept(APPLICATION_JSON) + .contentType(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + VALID_TOKEN) + .content(objectMapper.writeValueAsString(UPDATE_PRODUCT_DATA))); + + result.andExpect(status().isOk()) + .andDo(document("product-update", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("회원 인증 토큰") + ), + pathParameters( + parameterWithName("productId").description("수정 할 상품 아이디") + ), + requestFields( + fieldWithPath("name").description("상품명"), + fieldWithPath("price").description("가격"), + fieldWithPath("maker").description("제조사"), + fieldWithPath("imageUrl").description("이미지 URL") + ), + responseFields( + fieldWithPath("id").description("상품 아이디"), + fieldWithPath("name").description("상품명"), + fieldWithPath("price").description("가격"), + fieldWithPath("maker").description("제조사"), + fieldWithPath("imageUrl").description("이미지 URL") + ) + )); + } + + @Test + void RESTDOC_상품_삭제() throws Exception { + given(productService.deleteProduct(anyLong())) + .willReturn(TEST_PRODUCT); + + ResultActions result = mockMvc.perform(delete("/products/{productId}", 1L) + .accept(APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + VALID_TOKEN)); + + result.andExpect(status().isOk()) + .andDo(document("product-delete", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("회원 인증 토큰") + ), + pathParameters( + parameterWithName("productId").description("삭제 할 상품 아이디") + ) + )); + } +} diff --git a/app/src/test/java/com/codesoom/assignment/utils/RestDocsSupporter.java b/app/src/test/java/com/codesoom/assignment/utils/RestDocsSupporter.java new file mode 100644 index 00000000..5a593947 --- /dev/null +++ b/app/src/test/java/com/codesoom/assignment/utils/RestDocsSupporter.java @@ -0,0 +1,69 @@ +package com.codesoom.assignment.utils; + +import com.codesoom.assignment.application.AuthenticationService; +import com.codesoom.assignment.application.ProductService; +import com.codesoom.assignment.application.UserService; +import com.codesoom.assignment.domain.Role; +import com.codesoom.assignment.security.UserAuthentication; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@WebMvcTest +@ExtendWith(RestDocumentationExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +public class RestDocsSupporter { + @Autowired + protected ObjectMapper objectMapper; + + @MockBean + protected ProductService productService; + + @MockBean + protected UserService userService; + + @MockBean + protected AuthenticationService authenticationService; + + @MockBean + protected JwtUtil jwtUtil; + + @Autowired + protected MockMvc mockMvc; + + @BeforeEach + void setUp(final WebApplicationContext context, + final RestDocumentationContextProvider restDocumentation) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(MockMvcRestDocumentation.documentationConfiguration(restDocumentation)) + .alwaysDo(MockMvcResultHandlers.print()) + .addFilters(new CharacterEncodingFilter(StandardCharsets.UTF_8.name(), true)) + .build(); + + SecurityContextHolder.getContext() + .setAuthentication(getUserAuthentication()); + } + + private Authentication getUserAuthentication() { + return new UserAuthentication(1L, + List.of(new Role("USER"), new Role("ADMIN"))); + } +} diff --git a/app/src/test/java/com/codesoom/assignment/utils/TestHelper.java b/app/src/test/java/com/codesoom/assignment/utils/TestHelper.java new file mode 100644 index 00000000..ae3fab24 --- /dev/null +++ b/app/src/test/java/com/codesoom/assignment/utils/TestHelper.java @@ -0,0 +1,89 @@ +package com.codesoom.assignment.utils; + +import com.codesoom.assignment.domain.Product; +import com.codesoom.assignment.domain.User; +import com.codesoom.assignment.dto.ProductData; +import org.junit.jupiter.params.provider.Arguments; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.stream.Stream; + +public class TestHelper { + + public static final String VALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.neCsyNLzy3lQ4o2yliotWT06FwSGZagaHpKdAkjnGGw"; + public static final String OTHER_USER_VALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjJ9.i-iHszAs6H2JFTdm3vOVuN18tb_w6n2FqEYIRtr6gaU"; + public static final String INVALID_TOKEN = VALID_TOKEN + "INVALID"; + public static final String SECRET = "12345678901234567890123456789010"; + public static final String AUTH_NAME = "AUTH_NAME"; + public static final String AUTH_EMAIL = "auth@foo.com"; + public static final String INVALID_EMAIL = AUTH_EMAIL + "INVALID"; + public static final String AUTH_PASSWORD = "12345678"; + public static final String TEST_PRODUCT_NAME = "쥐돌이"; + public static final String TEST_UPDATE_PRODUCT_NAME = "쥐순이"; + public static final String TEST_PRODUCT_MAKER = "냥이월드"; + public static final int TEST_PRODUCT_PRICE = 5000; + public static final String INVALID_PASSWORD = AUTH_PASSWORD + "INVALID"; + public static final MockHttpServletRequest INVALID_SERVLET_REQUEST = new MockHttpServletRequest(); + private static final String TEST_LONG_PASSWORD = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut dignissim ex vitae congue congue. Nunc fermentum tellus leo. Donec malesuada, dolor non euismod suscipit, quam elit scelerisque ligula, in finibus eros justo eu justo. Duis tempor porta odio, id finibus nibh pellentesque congue. Ut et velit eget nibh tincidunt porta et id risus. Vestibulum suscipit ullamcorper varius. Proin eget arcu quam. Cras id feugiat libero. Integer auctor sem nec tempor pellentesque. Donec tempor molestie ex in viverra. Aliquam nec purus consequat purus ullamcorper tristique eu sodales erat. Nunc vitae accumsan orci. Vestibulum dictum ante non hendrerit convallis. Ut eu interdum nisl.\n" + + "\n" + + "Vestibulum et tellus tortor. Maecenas vulputate urna eu massa mattis, eu vulputate magna pretium. Vestibulum at sapien vitae mi tempus elementum at eget ante. Morbi risus dolor, eleifend eu ante sed, commodo aliquam augue. Pellentesque aliquet, tellus ultrices fermentum bibendum, turpis urna mollis mauris, sagittis posuere dolor mi et enim. Quisque mollis vulputate est vel eleifend. Donec nec sollicitudin massa. Sed mattis posuere metus sed dictum. Pellentesque varius est a arcu vulputate sollicitudin.\n" + + "\n" + + "Cras ac diam vehicula, elementum mauris tempus, accumsan lacus. Sed lectus diam, hendrerit a consequat id, eleifend eget libero. Praesent laoreet tempor magna et imperdiet. Aenean dictum non velit id lacinia. Donec congue ante dui, id rutrum ex accumsan at. Nulla ut massa elementum, posuere nunc sit amet, ornare nisl. Pellentesque in dui ipsum. Vivamus placerat velit sit amet tempus efficitur.\n" + + "\n" + + "Donec auctor lacus sit amet neque luctus, vitae tincidunt tortor lobortis. Fusce aliquam sem ut magna sollicitudin, ac vulputate est placerat. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nullam scelerisque augue elit, at bibendum libero efficitur ac. Sed fringilla purus pretium tortor condimentum imperdiet. Praesent in nibh lacinia, euismod enim eu, bibendum felis. Aliquam quis placerat ipsum. Integer dictum volutpat."; + + public static final User AUTH_USER = User.builder() + .name(AUTH_NAME) + .email(AUTH_EMAIL) + .password(AUTH_PASSWORD) + .build(); + + public static final Product TEST_PRODUCT = Product.builder() + .id(1L) + .name(TEST_PRODUCT_NAME) + .maker(TEST_PRODUCT_MAKER) + .price(TEST_PRODUCT_PRICE) + .build(); + + public static final ProductData TEST_PRODUCT_DATA = ProductData.builder() + .name(TEST_PRODUCT_NAME) + .maker(TEST_PRODUCT_MAKER) + .price(TEST_PRODUCT_PRICE) + .build(); + + public static Stream provideInvalidProductRequests() { + return Stream.of( + Arguments.of(ProductData.builder().name("").maker("").price(0).build()), + Arguments.of(ProductData.builder().name("").maker(TEST_PRODUCT_MAKER).price(TEST_PRODUCT_PRICE).build()), + Arguments.of(ProductData.builder().name(TEST_PRODUCT_NAME).maker("").price(TEST_PRODUCT_PRICE).build()), + Arguments.of(ProductData.builder().name(TEST_PRODUCT_NAME).maker(TEST_PRODUCT_MAKER).price(null).build()) + ); + } + + public static final ProductData UPDATE_PRODUCT_DATA = ProductData.builder() + .name(TEST_UPDATE_PRODUCT_NAME) + .maker(TEST_PRODUCT_MAKER) + .price(TEST_PRODUCT_PRICE) + .build(); + + public static final Product UPDATED_PRODUCT = Product.builder() + .id(1L) + .name(TEST_UPDATE_PRODUCT_NAME) + .maker(TEST_PRODUCT_MAKER) + .price(TEST_PRODUCT_PRICE) + .build(); + + public static MockHttpServletRequest getInvalidTokenServletRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + INVALID_TOKEN); + return request; + } + + public static MockHttpServletRequest getValidTokenServletRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + VALID_TOKEN); + return request; + } + + +} diff --git a/app/src/test/resources/application-test.yml b/app/src/test/resources/application-test.yml new file mode 100644 index 00000000..21d9b9fa --- /dev/null +++ b/app/src/test/resources/application-test.yml @@ -0,0 +1,16 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb + username: sa + password: + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create + h2: + console: + enabled: true + +jwt: + secret: "12345678901234567890123456789010"