├── .github ├── CODEOWNERS └── pull_request_template.md ├── .gitignore ├── README.md ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── readme-img ├── spring-rest-docs-example.png ├── spring-rest-docs-openapi-example.png └── springdocs-example.png ├── settings.gradle ├── src ├── docs │ └── asciidoc │ │ ├── index.adoc │ │ ├── order.adoc │ │ └── product.adoc ├── main │ ├── java │ │ └── com │ │ │ └── kurly │ │ │ └── tet │ │ │ └── guide │ │ │ └── springrestdocs │ │ │ ├── Application.java │ │ │ ├── application │ │ │ ├── order │ │ │ │ └── OrderFacade.java │ │ │ └── product │ │ │ │ └── ProductFacade.java │ │ │ ├── common │ │ │ ├── exception │ │ │ │ └── BusinessException.java │ │ │ └── util │ │ │ │ ├── JsonUtils.java │ │ │ │ ├── LocalDateTimeUtils.java │ │ │ │ ├── LocalDateUtils.java │ │ │ │ └── LocalTimeUtils.java │ │ │ ├── config │ │ │ ├── Constant.java │ │ │ └── SpringDocOpenApiConfig.java │ │ │ ├── domain │ │ │ ├── BusinessCode.java │ │ │ ├── DescriptionEnum.java │ │ │ ├── exception │ │ │ │ ├── OrderNotFoundException.java │ │ │ │ └── ProductNotFoundException.java │ │ │ ├── order │ │ │ │ ├── OrderDto.java │ │ │ │ └── OrderStatus.java │ │ │ └── product │ │ │ │ ├── ProductDto.java │ │ │ │ └── ProductStatus.java │ │ │ └── infrastructure │ │ │ ├── package-info.java │ │ │ └── web │ │ │ ├── common │ │ │ ├── advisor │ │ │ │ ├── ApiResponseJsonProcessingException.java │ │ │ │ └── ApiResponseWrappingAdvisor.java │ │ │ ├── component │ │ │ │ ├── LocalDateJsonComponent.java │ │ │ │ ├── LocalDateTimeJsonComponent.java │ │ │ │ └── LocalTimeJsonComponent.java │ │ │ ├── dto │ │ │ │ ├── ApiResponse.java │ │ │ │ ├── ApiResponseGenerator.java │ │ │ │ ├── PageRequest.java │ │ │ │ └── PageResponse.java │ │ │ └── handler │ │ │ │ └── GlobalRestControllerExceptionHandler.java │ │ │ ├── order │ │ │ ├── OrderRestController.java │ │ │ ├── OrderSearchCondition.java │ │ │ └── command │ │ │ │ ├── OrderCreateCommand.java │ │ │ │ └── OrderPaymentCommand.java │ │ │ ├── package-info.java │ │ │ └── product │ │ │ ├── ProductCreateCommand.java │ │ │ ├── ProductModifyCommand.java │ │ │ ├── ProductRestController.java │ │ │ └── ProductSearchCondition.java │ └── resources │ │ ├── application-springdoc.yml │ │ └── application.yml └── test │ ├── java │ └── com │ │ └── kurly │ │ └── tet │ │ └── guide │ │ └── springrestdocs │ │ ├── ApplicationTests.java │ │ ├── annotations │ │ ├── MockTest.java │ │ └── RestDocsTest.java │ │ ├── application │ │ └── order │ │ │ └── OrderFacadeTest.java │ │ ├── documenation │ │ ├── DocumentFormatGenerator.java │ │ ├── DocumentFormatGeneratorTest.java │ │ ├── DocumentUtils.java │ │ └── MockMvcFactory.java │ │ └── infrastructure │ │ └── web │ │ ├── dto │ │ └── PageResponseTest.java │ │ ├── order │ │ ├── OrderRestControllerDocsTest.java │ │ └── OrderRestControllerTest.java │ │ └── product │ │ ├── ProductRestControllerDocsTest.java │ │ └── ProductRestControllerTest.java │ └── resources │ ├── application.yml │ └── org │ └── springframework │ └── restdocs │ └── templates │ └── asciidoctor │ ├── README.md │ ├── curl-request.snippet │ ├── http-request.snippet │ ├── http-response.snippet │ ├── httpie-request.snippet │ ├── request-fields.snippet │ ├── request-parameters.snippet │ └── response-fields.snippet └── swagger-ui ├── favicon-16x16.png ├── favicon-32x32.png ├── index.css ├── swagger-initializer.js ├── swagger-ui-bundle.js ├── swagger-ui-bundle.js.map ├── swagger-ui-standalone-preset.js ├── swagger-ui-standalone-preset.js.map ├── swagger-ui.css ├── swagger-ui.css.map ├── swagger-ui.html ├── swagger-ui.js └── swagger-ui.js.map /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # 2 | # 코드소유자(Code Owner) 정보 3 | # @see https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 4 | # 코드 변경 후 Pull Request(PR)이 생성되면 자동으로 코드리뷰요청을 받게되는 사람을 기재합니다. 5 | # CODEOWNERS 에 기재된 사람에게 자동으로 코드리뷰가 요청됩니다. 6 | # 7 | 8 | # Global owners: Repository 내 모든 것에 소유권한을 가지고 있는 사람 9 | * @honeymon-enterprise -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | ## 작업개요 3 | 4 | 5 | * see https://kurly0521.atlassian.net/browse/{Jira Project}- 6 | 7 | ## 작업내용 8 | * 9 | 10 | ## 코멘트 11 | * -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | .envrc 8 | 9 | ### STS ### 10 | .apt_generated 11 | .classpath 12 | .factorypath 13 | .project 14 | .settings 15 | .springBeans 16 | .sts4-cache 17 | bin/ 18 | !**/src/main/**/bin/ 19 | !**/src/test/**/bin/ 20 | 21 | ### IntelliJ IDEA ### 22 | .idea 23 | *.iws 24 | *.iml 25 | *.ipr 26 | out/ 27 | !**/src/main/**/out/ 28 | !**/src/test/**/out/ 29 | BOOT-INF 30 | 31 | ### NetBeans ### 32 | /nbproject/private/ 33 | /nbbuild/ 34 | /dist/ 35 | /nbdist/ 36 | /.nb-gradle/ 37 | 38 | ### VS Code ### 39 | .vscode/ 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spring Rest Docs 사용 가이드 2 | ================================== 3 | 4 | 5 | 6 | 7 |
8 | 목차(Table of Contents) 9 |
    10 |
  1. 11 | 프로젝트 소개 12 |
  2. 13 | 시작하기 14 | 18 |
  3. 19 |
  4. 부록
  5. 20 |
21 |
22 | 23 | 24 | ## 프로젝트 소개 25 | 'Spring REST Docs 가이드'를 위한 **예제프로젝트** 입니다. 26 | 27 | ['Spring Boot'][url-spring-boot]를 기반으로 하고 있으며 간단하게 ["Spring REST Docs"](url-spring-rest-docs-project) 와 ["Swagger-UI"](url-swagger-ui) 를 활용한 API문서를 제공합니다. 28 | 29 |

(최상단 이동)

30 | 31 | 32 | ## 시작하기 33 | 34 | 프로젝트를 로컬에서 시작하려면 다음 안내를 따라주시면 됩니다. 35 | 36 | ### 요구사항 37 | * Java 17 이상 38 | * Java 설치방법: [여러 개의 JDK를 설치하고 선택해서 사용하기](https://blog.benelog.net/installing-jdk.html) 39 | 40 | ### 설치 41 | 42 | 1. 리포지토리 복제(Clone the repo) 43 | ```sh 44 | git clone git@github.com:thefarmersfront/spring-rest-docs-guide.git 45 | cd spring-rest-docs-guide 46 | ``` 47 | 48 | 2. 프로젝트 구성하기(Build project) 49 | ```sh 50 | ./gradlew clean build 51 | ``` 52 | 53 | 3. API문서생성 54 | ```sh 55 | ./gradlew clean restDocsTest 56 | ``` 57 | 58 | 4. 생성문서 확인 59 | 1. Spring REST Docs: `build/docs/index.html` 60 | 2. SwaggerUI: `api-spec/openapi3.yaml` 61 | 62 | 5. 애플리케이션 실행 63 | ```sh 64 | ./gradlew apiBuild 65 | cd build/libs 66 | java -jar application.jar 67 | ``` 68 | 69 | ### 확인 70 | #### Spring REST Docs 71 | [http://localhost:8080/docs/index.html]() 72 | > ![Spring REST Docs 예제화면](readme-img/spring-rest-docs-example.png) 73 | 74 | #### Spring REST Docs - OpenAPI Specification Integration 75 | [http://localhost:8080/swagger/swagger-ui.html]() 76 | > ![Spring REST Docs - OpenAPI Integration 예제화면](readme-img/spring-rest-docs-openapi-example.png) 77 | 78 | #### Springdoc 79 | [http://localhost:8080/swagger-ui/index.html]() 80 | > ![Springdocs 예제화면](readme-img/springdocs-example.png) 81 | 82 | 83 |

(최상단 이동)

84 | 85 | 86 | ## 부록 87 | 88 | * [여러 개의 JDK를 설치하고 선택해서 사용하기](https://blog.benelog.net/installing-jdk.html) 89 | * [Spring REST Docs](https://spring.io/projects/spring-restdocs) 90 | * [Github - Spring REST Docs](https://github.com/spring-projects/spring-restdocs) 91 | * [Spring REST Docs - Reference Documentation](https://docs.spring.io/spring-restdocs/docs/current/reference/html5/) 92 | * [Asciidoctor](https://asciidoctor.org/) 93 | * [Gradle - org.asciidoctor.jvm.convert`](https://plugins.gradle.org/plugin/org.asciidoctor.jvm.convert) 94 | * [Springdoc - Openapi](https://springdoc.org/) 95 | * [Swagger annotation](https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations) 96 | 97 | 98 | 99 | [url-spring-boot]: https://spring.io/projects/spring-boot/ 100 | [url-spring-boot-ref-doc]: https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/ 101 | [url-spring-rest-docs-project]: https://spring.io/projects/spring-restdocs/ 102 | [url-spring-rest-docs-ref-doc]: https://docs.spring.io/spring-restdocs/docs/current/reference/html5/ 103 | [url-swagger-io]: https://swagger.io/ 104 | [url-swagger-ui]: https://swagger.io/tools/swagger-ui/ -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "org.springframework.boot" version "2.7.3" 3 | id "io.spring.dependency-management" version "1.0.13.RELEASE" 4 | id "org.asciidoctor.jvm.convert" version "3.3.2" 5 | id "com.gorylenko.gradle-git-properties" version "2.4.1" 6 | id "com.epages.restdocs-api-spec" version "0.16.2" 7 | id "java" 8 | } 9 | 10 | group = "${projectGroup}" 11 | version = "${projectVersion}" 12 | sourceCompatibility = JavaVersion.VERSION_17 13 | 14 | configurations { 15 | compileOnly { 16 | extendsFrom annotationProcessor 17 | } 18 | asciidoctorExt 19 | } 20 | 21 | repositories { 22 | mavenCentral() 23 | } 24 | 25 | ext { 26 | set("snippetsDir", file("build/generated-snippets")) 27 | } 28 | 29 | dependencies { 30 | implementation("org.springframework.boot:spring-boot-starter-web") 31 | implementation("org.springframework.boot:spring-boot-starter-validation") 32 | implementation("com.google.code.findbugs:jsr305:3.0.2") 33 | //@see springdoc-openapi getting started.Spring REST Docs API specification Integration 44 | testImplementation("com.epages:restdocs-api-spec:0.16.2") 45 | testImplementation("com.epages:restdocs-api-spec-mockmvc:0.16.2") 46 | 47 | asciidoctorExt("org.springframework.restdocs:spring-restdocs-asciidoctor") 48 | } 49 | 50 | tasks.named("test") { 51 | outputs.dir snippetsDir 52 | useJUnitPlatform() 53 | } 54 | 55 | tasks.register("restDocsTest", Test) { 56 | outputs.dir snippetsDir 57 | useJUnitPlatform { 58 | includeTags("restDocs") 59 | } 60 | 61 | finalizedBy "asciidoctor" 62 | finalizedBy "openapi3" 63 | } 64 | 65 | /** 66 | *

67 | * 개발환경에서만 API문서를 제공하고자 한다면 빌드태스크에 asciidoctor 를 추가합니다. 68 | * asciidoctor 가 수행될 때 통합테스트 실행 태스크 restDocsTest 를 수행하기에 69 | * restDocsTest 를 추가로 수행하지 않아도 됩니다. 70 | *

71 | * @see Asciidoctor Gradle Plugin Suite 72 | * 73 | * ex)./gradlew clean asciidoctor build 74 | */ 75 | tasks.named("asciidoctor") { 76 | dependsOn restDocsTest 77 | 78 | inputs.dir snippetsDir 79 | configurations "asciidoctorExt" 80 | baseDirFollowsSourceDir() // 원본파일작업은 .adoc 디렉터리 기준 81 | } 82 | 83 | openapi3 { 84 | servers = [ 85 | { url = 'http://localhost:8080' }, 86 | ] 87 | title = 'spring-rest-docs-guide' 88 | description = 'Spring REST Docs 테스트 생성물 생성시 추가생성되는 OpenAPI 문서이용' 89 | version = "${project.version}" 90 | format = 'yaml' 91 | } 92 | 93 | tasks.register("apiBuild", GradleBuild) { 94 | tasks = ["clean", "restDocsTest", "build"] 95 | } 96 | 97 | springBoot { 98 | buildInfo() 99 | } 100 | 101 | gitProperties { 102 | dateFormat = "yyyy-MM-dd'T'HH:mm:ss.Zz" 103 | dateFormatTimeZone = "Asia/Seoul" 104 | failOnNoGitDirectory = false 105 | } 106 | 107 | bootJar { 108 | from("swagger-ui") { 109 | into "BOOT-INF/classes/static/swagger" 110 | } 111 | from("${asciidoctor.outputDir}") { 112 | into "BOOT-INF/classes/static/docs" 113 | } 114 | from("build/api-spec") { 115 | into "BOOT-INF/classes/static/swagger" 116 | } 117 | 118 | archiveFileName.set "application.jar" 119 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | projectGroup=com.kurly.tet 2 | projectVersion=1.0.0 3 | 4 | org.gradle.cache=false 5 | org.gradle.parallel=true 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thefarmersfront/spring-rest-docs-guide/7fd8b90007b12d1509b55c17bc4e06fd5292daee/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /readme-img/spring-rest-docs-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thefarmersfront/spring-rest-docs-guide/7fd8b90007b12d1509b55c17bc4e06fd5292daee/readme-img/spring-rest-docs-example.png -------------------------------------------------------------------------------- /readme-img/spring-rest-docs-openapi-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thefarmersfront/spring-rest-docs-guide/7fd8b90007b12d1509b55c17bc4e06fd5292daee/readme-img/spring-rest-docs-openapi-example.png -------------------------------------------------------------------------------- /readme-img/springdocs-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thefarmersfront/spring-rest-docs-guide/7fd8b90007b12d1509b55c17bc4e06fd5292daee/readme-img/springdocs-example.png -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'spring-rest-docs-guide' 2 | -------------------------------------------------------------------------------- /src/docs/asciidoc/index.adoc: -------------------------------------------------------------------------------- 1 | = Spring REST Docs 가이드 2 | 김지헌(팀 엔지니어링 팀), 3 | v2022.09.28, 2022-09-28 4 | :doctype: book 5 | :icons: font 6 | :source-highlighter: coderay 7 | :toc: left 8 | :toc-title: 목차 9 | :toclevels: 3 10 | :sectlinks: 11 | :sectnums: 12 | 13 | == 개요 14 | 이 API문서는 'Spring REST Docs 가이드' 프로젝트의 산출물입니다. 15 | 16 | 17 | === API 서버 경로 18 | [cols="2,5,3"] 19 | |==== 20 | |환경 |DNS |비고 21 | |개발(dev) | link:[] |API 문서 제공 22 | |베타(beta) | link:[] |API 문서 제공 23 | |운영(prod) | link:[] |API 문서 미제공 24 | |==== 25 | 26 | [NOTE] 27 | ==== 28 | 해당 프로젝트 API문서는 개발환경까지 노출되는 것을 권장합니다. + 29 | ==== 30 | 31 | [CAUTION] 32 | ==== 33 | 운영환경에 노출될 경우 보안 관련 문제가 발생할 수 있습니다. 34 | ==== 35 | 36 | === 응답형식 37 | 프로젝트는 다음과 같은 응답형식을 제공합니다. 38 | 39 | ==== 정상(200, OK) 40 | 41 | |==== 42 | |응답데이터가 없는 경우|응답데이터가 있는 경우 43 | 44 | a|[source,json] 45 | ---- 46 | { 47 | "code": "0000", // 정상인 경우 '0000' 48 | "message": "OK", // 정상인 경우 'OK' 49 | "data": null 50 | } 51 | ---- 52 | 53 | a|[source,json] 54 | ---- 55 | { 56 | "code": "0000", // 정상인 경우 '0000' 57 | "message": "OK", // 정상인 경우 'OK' 58 | "data": { 59 | "name": "honeymon-enterprise" 60 | } 61 | } 62 | ---- 63 | |==== 64 | 65 | ==== 상태코드(HttpStatus) 66 | 응답시 다음과 같은 응답상태 헤더, 응답코드 및 응답메시지를 제공합니다. 67 | 68 | [cols="3,1,3,3"] 69 | |==== 70 | |HttpStatus |코드 |메시지 |설명 71 | 72 | |`OK(200)` |`0000` |"OK" |정상 응답 73 | |`INTERNAL_SERVER_ERROR(500)`|`S5XX` |"알 수 없는 에러가 발생했습니다. 관리자에게 문의하세요." |서버 내부 오류 74 | |`FORBIDDEN(403)`|`C403` |"[AccessDenied] 잘못된 접근입니다." |비인가 접속입니다. 75 | |`BAD_REQUEST(400)`|`C400` |"잘못된 요청입니다. 요청내용을 확인하세요." |요청값 누락 혹은 잘못된 기입 76 | |`NOT_FOUND(404)`|`C404` |"상황에 따라 다름" |요청값 누락 혹은 잘못된 기입 77 | 78 | |==== 79 | 80 | == API 81 | 82 | //상품 83 | include::product.adoc[] 84 | 85 | //주문 86 | include::order.adoc[] -------------------------------------------------------------------------------- /src/docs/asciidoc/order.adoc: -------------------------------------------------------------------------------- 1 | == 주문(Order) 2 | 주문을 등록하고 `결제(PAID) -> 출하(SHIPPED) -> 배송완료(COMPLETED)` 처리를 할 수 있습니다. 3 | 4 | === 주문검색 5 | 주문정보를 회원번호(memberNo)과 주문번호(orderNo)를 이용해서 검색할 수 있습니다. 6 | 페이징 처리된 목록을 제공합니다. 7 | 8 | |==== 9 | |속성 |설명 10 | 11 | |`memberNo` |회원번호 12 | |`orderNo` |주문번호 13 | |`page` |페이지(0 부터 시작) 14 | |`size` |페이지당 조회건수(최소: 1, 최대: 1000) 15 | 16 | |==== 17 | 18 | 19 | [discrete] 20 | ==== 요청 21 | include::{snippets}/get-v1-get-orders/curl-request.adoc[] 22 | include::{snippets}/get-v1-get-orders/httpie-request.adoc[] 23 | include::{snippets}/get-v1-get-orders/http-request.adoc[] 24 | include::{snippets}/get-v1-get-orders/request-parameters.adoc[] 25 | 26 | 27 | [discrete] 28 | ==== 응답 29 | include::{snippets}/get-v1-get-orders/http-response.adoc[] 30 | include::{snippets}/get-v1-get-orders/response-fields.adoc[] 31 | 32 | 33 | === 주문생성 34 | 주문을 새롭게 등록합니다. 35 | 36 | [discrete] 37 | ==== 요청 38 | include::{snippets}/post-v1-create-order/curl-request.adoc[] 39 | include::{snippets}/post-v1-create-order/httpie-request.adoc[] 40 | include::{snippets}/post-v1-create-order/http-request.adoc[] 41 | include::{snippets}/post-v1-create-order/request-fields.adoc[] 42 | 43 | 44 | [discrete] 45 | ==== 응답 46 | include::{snippets}/post-v1-create-order/http-response.adoc[] 47 | include::{snippets}/post-v1-create-order/response-fields.adoc[] 48 | 49 | === 주문결제 50 | 주문을결제합니다. 51 | 52 | [discrete] 53 | ==== 요청 54 | include::{snippets}/put-v1-payment-order/curl-request.adoc[] 55 | include::{snippets}/put-v1-payment-order/httpie-request.adoc[] 56 | include::{snippets}/put-v1-payment-order/http-request.adoc[] 57 | include::{snippets}/put-v1-payment-order/request-fields.adoc[] 58 | include::{snippets}/put-v1-payment-order/path-parameters.adoc[] 59 | 60 | 61 | [discrete] 62 | ==== 응답 63 | include::{snippets}/put-v1-payment-order/http-response.adoc[] 64 | include::{snippets}/put-v1-payment-order/response-fields.adoc[] 65 | 66 | === 주문상세조회 67 | 주문상세정보를 조회합니다. 68 | 69 | [discrete] 70 | ==== 요청 71 | include::{snippets}/get-v1-get-order/curl-request.adoc[] 72 | include::{snippets}/get-v1-get-order/httpie-request.adoc[] 73 | include::{snippets}/get-v1-get-order/http-request.adoc[] 74 | include::{snippets}/get-v1-get-order/path-parameters.adoc[] 75 | 76 | 77 | [discrete] 78 | ==== 응답 79 | include::{snippets}/get-v1-get-order/http-response.adoc[] 80 | include::{snippets}/get-v1-get-order/response-fields.adoc[] 81 | 82 | 83 | === 주문출하 84 | 주문상태를 출하로 변경합니다. 85 | 86 | [WARNING] 87 | ==== 88 | 결제완료(`PAID`)상태여야 합니다. 89 | ==== 90 | 91 | [discrete] 92 | ==== 요청 93 | include::{snippets}/put-v1-shipping-order/curl-request.adoc[] 94 | include::{snippets}/put-v1-shipping-order/httpie-request.adoc[] 95 | include::{snippets}/put-v1-shipping-order/http-request.adoc[] 96 | include::{snippets}/put-v1-shipping-order/path-parameters.adoc[] 97 | 98 | 99 | [discrete] 100 | ==== 응답 101 | include::{snippets}/put-v1-shipping-order/http-response.adoc[] 102 | include::{snippets}/put-v1-shipping-order/response-fields.adoc[] 103 | 104 | === 주문배송완료 105 | 주문상태를 완료상태로 변경합니다. 106 | 107 | [WARNING] 108 | ==== 109 | 출하완료(`SHIPPED`)상태여야 합니다. 110 | ==== 111 | 112 | [discrete] 113 | ==== 요청 114 | include::{snippets}/put-v1-complete-order/curl-request.adoc[] 115 | include::{snippets}/put-v1-complete-order/httpie-request.adoc[] 116 | include::{snippets}/put-v1-complete-order/http-request.adoc[] 117 | include::{snippets}/put-v1-complete-order/path-parameters.adoc[] 118 | 119 | 120 | [discrete] 121 | ==== 응답 122 | include::{snippets}/put-v1-complete-order/http-response.adoc[] 123 | include::{snippets}/put-v1-complete-order/response-fields.adoc[] -------------------------------------------------------------------------------- /src/docs/asciidoc/product.adoc: -------------------------------------------------------------------------------- 1 | == 상품(Product) 2 | 상품정보에 대한 검색/등록/상세조회/변경/삭제 기능을 제공합니다. 3 | 4 | 5 | === 상품검색 6 | 상품정보를 상품명(productName)과 상품번호(productNo)를 이용해서 검색할 수 있습니다. 7 | 페이징 처리된 목록을 제공합니다. 8 | 9 | |==== 10 | |속성 |설명 11 | 12 | |`page` |페이지(0 부터 시작) 13 | |`size` |페이지당 조회건수(최소: 1, 최대: 1000) 14 | 15 | |==== 16 | 17 | 18 | [discrete] 19 | ==== 요청 20 | include::{snippets}/get-v1-get-products/curl-request.adoc[] 21 | include::{snippets}/get-v1-get-products/httpie-request.adoc[] 22 | include::{snippets}/get-v1-get-products/http-request.adoc[] 23 | include::{snippets}/get-v1-get-products/request-parameters.adoc[] 24 | 25 | 26 | [discrete] 27 | ==== 응답 28 | include::{snippets}/get-v1-get-products/http-response.adoc[] 29 | include::{snippets}/get-v1-get-products/response-fields.adoc[] 30 | 31 | 32 | === 상품생성 33 | 상품을 새롭게 등록합니다. 34 | 35 | [discrete] 36 | ==== 요청 37 | include::{snippets}/post-v1-create-product/curl-request.adoc[] 38 | include::{snippets}/post-v1-create-product/httpie-request.adoc[] 39 | include::{snippets}/post-v1-create-product/http-request.adoc[] 40 | include::{snippets}/post-v1-create-product/request-fields.adoc[] 41 | 42 | 43 | [discrete] 44 | ==== 응답 45 | include::{snippets}/post-v1-create-product/http-response.adoc[] 46 | include::{snippets}/post-v1-create-product/response-fields.adoc[] 47 | 48 | 49 | === 상품상세조회 50 | 상품세부정보를 조회합니다. 51 | 52 | [discrete] 53 | ==== 요청 54 | include::{snippets}/get-v1-get-product/curl-request.adoc[] 55 | include::{snippets}/get-v1-get-product/httpie-request.adoc[] 56 | include::{snippets}/get-v1-get-product/http-request.adoc[] 57 | include::{snippets}/get-v1-get-product/path-parameters.adoc[] 58 | 59 | 60 | [discrete] 61 | ==== 응답 62 | include::{snippets}/get-v1-get-product/http-response.adoc[] 63 | include::{snippets}/get-v1-get-product/response-fields.adoc[] 64 | 65 | 66 | === 상품상세변경 67 | 상품세부정보를 변경합니다. 68 | 69 | [discrete] 70 | ==== 요청 71 | include::{snippets}/put-v1-modify-product/curl-request.adoc[] 72 | include::{snippets}/put-v1-modify-product/httpie-request.adoc[] 73 | include::{snippets}/put-v1-modify-product/http-request.adoc[] 74 | include::{snippets}/put-v1-modify-product/path-parameters.adoc[] 75 | include::{snippets}/put-v1-modify-product/request-fields.adoc[] 76 | 77 | 78 | [discrete] 79 | ==== 응답 80 | include::{snippets}/put-v1-modify-product/http-response.adoc[] 81 | include::{snippets}/put-v1-modify-product/response-fields.adoc[] 82 | 83 | 84 | 85 | === 상품삭제 86 | 상품을 삭제합니다. 87 | 88 | [discrete] 89 | ==== 요청 90 | include::{snippets}/delete-v1-delete-product/curl-request.adoc[] 91 | include::{snippets}/delete-v1-delete-product/httpie-request.adoc[] 92 | include::{snippets}/delete-v1-delete-product/http-request.adoc[] 93 | include::{snippets}/put-v1-modify-product/path-parameters.adoc[] 94 | 95 | 96 | [discrete] 97 | ==== 응답 98 | include::{snippets}/delete-v1-delete-product/http-response.adoc[] -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/Application.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class Application { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(Application.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/application/order/OrderFacade.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.application.order; 2 | 3 | import com.kurly.tet.guide.springrestdocs.application.product.ProductFacade; 4 | import com.kurly.tet.guide.springrestdocs.domain.exception.OrderNotFoundException; 5 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderDto; 6 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageRequest; 7 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageResponse; 8 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.order.OrderSearchCondition; 9 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command.OrderCreateCommand; 10 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command.OrderPaymentCommand; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.util.ArrayList; 14 | import java.util.List; 15 | import java.util.concurrent.atomic.AtomicLong; 16 | import java.util.stream.Collectors; 17 | 18 | @Component 19 | public class OrderFacade { 20 | private final AtomicLong orderIdCreator = new AtomicLong(0); 21 | private final List orders = new ArrayList<>(); 22 | private final ProductFacade productFacade; 23 | 24 | public OrderFacade(ProductFacade productFacade) { 25 | this.productFacade = productFacade; 26 | } 27 | 28 | public PageResponse search(OrderSearchCondition searchCondition, PageRequest pageRequest) { 29 | var filteredOrders = this.orders.stream() 30 | .filter(searchCondition::filter) 31 | .collect(Collectors.toList()); 32 | 33 | return new PageResponse<>(filteredOrders, pageRequest, this.orders.size()); 34 | } 35 | 36 | public OrderDto create(OrderCreateCommand createCommand) { 37 | var findProducts = createCommand.getProductIds().stream().map(productFacade::getProduct).collect(Collectors.toList()); 38 | var createOrder = createCommand.createOrder(orderIdCreator.incrementAndGet(), findProducts); 39 | orders.add(createOrder); 40 | return createOrder; 41 | } 42 | 43 | public OrderDto findByOrderNo(String orderNo) { 44 | return orders.stream() 45 | .filter(it -> it.getOrderNo().equals(orderNo)) 46 | .findAny() 47 | .orElseThrow(() -> new OrderNotFoundException(orderNo)); 48 | } 49 | 50 | public OrderDto payment(String orderNo, OrderPaymentCommand paymentCommand) { 51 | var order = findByOrderNo(orderNo); 52 | paymentCommand.payment(order); 53 | return order; 54 | } 55 | 56 | public OrderDto shipping(String orderNo) { 57 | var order = findByOrderNo(orderNo); 58 | order.shipping(); 59 | return order; 60 | } 61 | 62 | public OrderDto complete(String orderNo) { 63 | var order = findByOrderNo(orderNo); 64 | order.complete(); 65 | return order; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/application/product/ProductFacade.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.application.product; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.exception.ProductNotFoundException; 4 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 5 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageRequest; 6 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageResponse; 7 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.product.ProductCreateCommand; 8 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.product.ProductModifyCommand; 9 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.product.ProductSearchCondition; 10 | import org.springframework.stereotype.Component; 11 | 12 | import javax.annotation.PostConstruct; 13 | import java.util.ArrayList; 14 | import java.util.List; 15 | import java.util.concurrent.atomic.AtomicLong; 16 | import java.util.stream.Collectors; 17 | 18 | /** 19 | * 대충... 20 | */ 21 | @Component 22 | public class ProductFacade { 23 | private final AtomicLong productIdCreator = new AtomicLong(0); 24 | private final List products = new ArrayList<>(); 25 | 26 | @PostConstruct 27 | public void setUp() { 28 | products.add(new ProductDto(productIdCreator.incrementAndGet(), "TEST" + productIdCreator.incrementAndGet(), String.format("%05d", productIdCreator.incrementAndGet()))); 29 | products.add(new ProductDto(productIdCreator.incrementAndGet(), "TEST" + productIdCreator.incrementAndGet(), String.format("%05d", productIdCreator.incrementAndGet()))); 30 | products.add(new ProductDto(productIdCreator.incrementAndGet(), "TEST" + productIdCreator.incrementAndGet(), String.format("%05d", productIdCreator.incrementAndGet()))); 31 | products.add(new ProductDto(productIdCreator.incrementAndGet(), "TEST" + productIdCreator.incrementAndGet(), String.format("%05d", productIdCreator.incrementAndGet()))); 32 | products.add(new ProductDto(productIdCreator.incrementAndGet(), "TEST" + productIdCreator.incrementAndGet(), String.format("%05d", productIdCreator.incrementAndGet()))); 33 | } 34 | 35 | public PageResponse search(ProductSearchCondition searchCondition, PageRequest pageRequest) { 36 | var filteredProducts = this.products.stream() 37 | .filter(searchCondition::filter) 38 | .collect(Collectors.toList());; 39 | 40 | return new PageResponse<>(filteredProducts, pageRequest, this.products.size()); 41 | } 42 | 43 | public ProductDto create(ProductCreateCommand createCommand) { 44 | var createProduct = createCommand.createDto(productIdCreator.incrementAndGet()); 45 | 46 | products.add(createProduct); 47 | 48 | return createProduct; 49 | } 50 | 51 | public ProductDto getProduct(Long productId) { 52 | return products.stream() 53 | .filter(it -> it.getId().equals(productId)).findFirst() 54 | .orElseThrow(() -> new ProductNotFoundException(productId)); 55 | } 56 | 57 | public ProductDto modify(Long productId, ProductModifyCommand modifyCommand) { 58 | var targetProduct = getProduct(productId); 59 | modifyCommand.modify(targetProduct); 60 | 61 | return targetProduct; 62 | } 63 | 64 | public void remove(Long productId) { 65 | products.removeIf(it -> it.getId().equals(productId)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/common/exception/BusinessException.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.common.exception; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.util.JsonUtils; 4 | import org.springframework.http.HttpStatus; 5 | import org.springframework.lang.NonNull; 6 | 7 | import java.util.Map; 8 | 9 | public abstract class BusinessException extends RuntimeException { 10 | 11 | protected BusinessException() { 12 | } 13 | 14 | protected BusinessException(String message) { 15 | super(message); 16 | } 17 | 18 | protected BusinessException(String message, Throwable cause) { 19 | super(message, cause); 20 | } 21 | 22 | protected BusinessException(Throwable cause) { 23 | super(cause); 24 | } 25 | 26 | protected BusinessException(@NonNull Map messageFields) { 27 | super(JsonUtils.toJson(messageFields)); 28 | } 29 | 30 | protected BusinessException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { 31 | super(message, cause, enableSuppression, writableStackTrace); 32 | } 33 | 34 | public abstract HttpStatus getHttpStatus(); 35 | 36 | public abstract String getErrorCode(); 37 | 38 | public abstract boolean isNecessaryToLog(); 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/common/util/JsonUtils.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.common.util; 2 | 3 | import com.fasterxml.jackson.core.type.TypeReference; 4 | import com.fasterxml.jackson.databind.DeserializationFeature; 5 | import com.fasterxml.jackson.databind.JsonNode; 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import com.fasterxml.jackson.databind.SerializationFeature; 8 | import com.fasterxml.jackson.databind.type.CollectionType; 9 | import com.fasterxml.jackson.databind.type.TypeFactory; 10 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 11 | import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer; 12 | import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; 13 | import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; 14 | import com.fasterxml.jackson.datatype.jsr310.ser.ZonedDateTimeSerializer; 15 | 16 | import java.io.IOException; 17 | import java.io.InputStream; 18 | import java.time.LocalDateTime; 19 | import java.time.ZonedDateTime; 20 | import java.time.format.DateTimeFormatter; 21 | import java.util.Collections; 22 | import java.util.List; 23 | 24 | import static java.util.Objects.isNull; 25 | 26 | public class JsonUtils { 27 | 28 | private static final ObjectMapper MAPPER; 29 | 30 | static { 31 | MAPPER = new ObjectMapper(); 32 | 33 | JavaTimeModule javaTimeModule = new JavaTimeModule(); 34 | javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); 35 | javaTimeModule.addDeserializer(LocalDateTime.class, 36 | new LocalDateTimeDeserializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); 37 | javaTimeModule.addSerializer(ZonedDateTime.class, 38 | new ZonedDateTimeSerializer(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); 39 | javaTimeModule.addDeserializer(ZonedDateTime.class, InstantDeserializer.ZONED_DATE_TIME); 40 | 41 | MAPPER.registerModule(javaTimeModule); 42 | MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); 43 | MAPPER.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); 44 | } 45 | 46 | 47 | private JsonUtils() { 48 | throw new UnsupportedOperationException(); 49 | } 50 | 51 | public static ObjectMapper getMapper() { 52 | return MAPPER; 53 | } 54 | 55 | public static T fromJson(InputStream inputStream, Class clazz) { 56 | if (isNull(inputStream)) { 57 | return null; 58 | } 59 | 60 | try { 61 | return MAPPER.readValue(inputStream, clazz); 62 | } catch (IOException e) { 63 | throw new JsonDecodeException(e); 64 | } 65 | } 66 | 67 | public static T fromJson(String json, Class clazz) { 68 | if (isNull(json)) { 69 | return null; 70 | } 71 | 72 | try { 73 | return MAPPER.readValue(json, clazz); 74 | } catch (IOException e) { 75 | throw new JsonDecodeException(e); 76 | } 77 | } 78 | 79 | public static T fromJson(JsonNode jsonNode, Class clazz) { 80 | if (isNull(jsonNode)) { 81 | return null; 82 | } 83 | 84 | try { 85 | return MAPPER.readValue(jsonNode.traverse(), clazz); 86 | } catch (IOException e) { 87 | throw new JsonDecodeException(e); 88 | } 89 | } 90 | 91 | public static T fromJson(InputStream inputStream, TypeReference typeReference) { 92 | if (isNull(inputStream)) { 93 | return null; 94 | } 95 | 96 | try { 97 | return MAPPER.readValue(inputStream, typeReference); 98 | } catch (IOException e) { 99 | throw new JsonDecodeException(e); 100 | } 101 | } 102 | 103 | public static T fromJson(String json, TypeReference typeReference) { 104 | if (isNull(json)) { 105 | return null; 106 | } 107 | 108 | try { 109 | return MAPPER.readValue(json, typeReference); 110 | } catch (IOException e) { 111 | throw new JsonDecodeException(e); 112 | } 113 | } 114 | 115 | public static T fromJson(JsonNode jsonNode, TypeReference typeReference) { 116 | if (isNull(jsonNode)) { 117 | return null; 118 | } 119 | 120 | try { 121 | return MAPPER.readValue(jsonNode.traverse(), typeReference); 122 | } catch (IOException e) { 123 | throw new JsonDecodeException(e); 124 | } 125 | } 126 | 127 | public static T fromJson(byte[] bytes, TypeReference typeReference) { 128 | if (isNull(bytes) || bytes.length == 0) { 129 | return null; 130 | } 131 | 132 | try { 133 | return MAPPER.readValue(bytes, typeReference); 134 | } catch (IOException e) { 135 | throw new JsonDecodeException(e); 136 | } 137 | } 138 | 139 | public static T fromJson(byte[] bytes, Class clazz) { 140 | if (isNull(bytes) || bytes.length == 0) { 141 | return null; 142 | } 143 | 144 | try { 145 | return MAPPER.readValue(bytes, clazz); 146 | } catch (IOException e) { 147 | throw new JsonDecodeException(e); 148 | } 149 | } 150 | 151 | public static JsonNode fromJson(InputStream inputStream) { 152 | if (isNull(inputStream)) { 153 | return null; 154 | } 155 | 156 | try { 157 | return MAPPER.readTree(inputStream); 158 | } catch (IOException e) { 159 | throw new JsonDecodeException(e); 160 | } 161 | } 162 | 163 | public static JsonNode fromJson(String json) { 164 | if (isNull(json)) { 165 | return null; 166 | } 167 | 168 | try { 169 | return MAPPER.readTree(json); 170 | } catch (IOException e) { 171 | throw new JsonDecodeException(e); 172 | } 173 | } 174 | 175 | public static List fromJsonArray(InputStream inputStream, Class clazz) { 176 | if (isNull(inputStream)) { 177 | return Collections.emptyList(); 178 | } 179 | 180 | CollectionType collectionType = TypeFactory.defaultInstance().constructCollectionType(List.class, clazz); 181 | 182 | try { 183 | return MAPPER.readValue(inputStream, collectionType); 184 | } catch (IOException e) { 185 | throw new JsonDecodeException(e); 186 | } 187 | } 188 | 189 | public static List fromJsonArray(String json, Class clazz) { 190 | if (isNull(json)) { 191 | return Collections.emptyList(); 192 | } 193 | 194 | CollectionType collectionType = TypeFactory.defaultInstance().constructCollectionType(List.class, clazz); 195 | 196 | try { 197 | return MAPPER.readValue(json, collectionType); 198 | } catch (IOException e) { 199 | throw new JsonDecodeException(e); 200 | } 201 | } 202 | 203 | public static String toJson(final Object object) { 204 | try { 205 | return MAPPER.writeValueAsString(object); 206 | } catch (IOException e) { 207 | throw new JsonEncodeException(e); 208 | } 209 | } 210 | 211 | public static byte[] toJsonByte(final Object object) { 212 | try { 213 | return MAPPER.writeValueAsBytes(object); 214 | } catch (IOException e) { 215 | throw new JsonEncodeException(e); 216 | } 217 | } 218 | 219 | public static String toPrettyJson(final Object object) { 220 | try { 221 | return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(object); 222 | } catch (IOException e) { 223 | throw new JsonEncodeException(e); 224 | } 225 | } 226 | 227 | public static String get(JsonNode jsonNode) { 228 | if (jsonNode == null || jsonNode.isNull()) { 229 | return null; 230 | } 231 | 232 | return jsonNode.asText(); 233 | } 234 | 235 | public static T convertValue(Object obj, TypeReference clazz) { 236 | return MAPPER.convertValue(obj, clazz); 237 | } 238 | 239 | public static class JsonEncodeException extends RuntimeException { 240 | 241 | private static final long serialVersionUID = 4975703115049362769L; 242 | 243 | public JsonEncodeException(Throwable cause) { 244 | super(cause); 245 | } 246 | } 247 | 248 | public static class JsonDecodeException extends RuntimeException { 249 | 250 | private static final long serialVersionUID = -2651564042039413190L; 251 | 252 | public JsonDecodeException(Throwable cause) { 253 | super(cause); 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/common/util/LocalDateTimeUtils.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.common.util; 2 | 3 | import com.kurly.tet.guide.springrestdocs.config.Constant; 4 | 5 | import java.time.LocalDate; 6 | import java.time.LocalDateTime; 7 | import java.time.LocalTime; 8 | import java.time.format.DateTimeFormatter; 9 | 10 | import static com.kurly.tet.guide.springrestdocs.config.Constant.FORMAT_LOCAL_DATE_TIME; 11 | import static java.util.Objects.isNull; 12 | 13 | public class LocalDateTimeUtils { 14 | private LocalDateTimeUtils() { 15 | throw new UnsupportedOperationException("Utility class."); 16 | } 17 | 18 | /** 19 | * LocalDateTime 변환 20 | * 21 | * @param source 변환문자열 22 | * @return yyyy-MM-dd'T'HH:mm:ss 형식 {@link LocalDateTime} 23 | */ 24 | public static LocalDateTime toLocalDateTime(String source) { 25 | return toLocalDateTime(source, FORMAT_LOCAL_DATE_TIME); 26 | } 27 | 28 | /** 29 | * LocalDateTime 변환 30 | * 31 | * @param source 변환문자열 32 | * @param dateTimeFormat pattern 33 | * @return pattern 에 맞춰 변환된 {@link LocalDateTime} 34 | */ 35 | public static LocalDateTime toLocalDateTime(String source, String dateTimeFormat) { 36 | if (isNull(source) || source.isEmpty()) { 37 | return null; 38 | } 39 | 40 | return LocalDateTime.parse(source, DateTimeFormatter.ofPattern(dateTimeFormat)); 41 | } 42 | 43 | /** 44 | * LocalDateTime to 문자열 변환 45 | * 46 | * @param source 원천 47 | * @return "yyyy-MM-dd'T'HH:mm:ss" 형식으로 변환된 문자열 48 | */ 49 | public static String toString(LocalDateTime source) { 50 | return toString(source, FORMAT_LOCAL_DATE_TIME); 51 | } 52 | 53 | /** 54 | * LocalDateTime to 문자열 변환 55 | * 56 | * @param source 원천 57 | * @param dateTimeFormat pattern 58 | * @return pattern 형식으로 변환된 문자열 59 | */ 60 | public static String toString(LocalDateTime source, String dateTimeFormat) { 61 | if (isNull(source)) { 62 | return null; 63 | } 64 | 65 | return source.format(DateTimeFormatter.ofPattern(dateTimeFormat)); 66 | } 67 | 68 | /** 69 | * 해당일 시작시 70 | * 71 | * @param source 원천일 72 | * @return 원천일 00:00:00 73 | */ 74 | public static LocalDateTime ofFirst(LocalDate source) { 75 | return LocalDateTime.of(source, LocalTime.MIN); 76 | } 77 | 78 | /** 79 | * 해당일 종료시 80 | * 81 | * @param source 원천일 82 | * @return 원천일 23:59:59.999999999 83 | */ 84 | public static LocalDateTime ofLast(LocalDate source) { 85 | return LocalDateTime.of(source, LocalTime.MAX); 86 | } 87 | 88 | /** 89 | * 해당월 시작일 시작시 90 | * 91 | * @param source 원천일 92 | * @return month-01 00:00:00 93 | */ 94 | public static LocalDateTime ofMonthFirstDateTime(LocalDate source) { 95 | return ofFirst(LocalDateUtils.ofMonthFirstDay(source)); 96 | } 97 | 98 | /** 99 | * 해당월 마지막날 마지막시 100 | * 101 | * @param source 원천일 102 | * @return month-lengthOfMonth 23:59:59.999999999 103 | */ 104 | public static LocalDateTime ofMonthLastDateTime(LocalDate source) { 105 | return ofLast(LocalDateUtils.ofMonthLastDay(source)); 106 | } 107 | 108 | /** 109 | * localDateTime을 Asia/Seoul ZoneDateTime 문자열로 변환합니다. 110 | * 111 | * @return Asia/Seoul ZoneDateTime 문자열 112 | */ 113 | public static String convertZoneDateTimeFormat(final LocalDateTime source) { 114 | if (isNull(source)) { 115 | return null; 116 | } 117 | 118 | return source.atZone(Constant.DEFAULT_ZONE_ID) 119 | .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/common/util/LocalDateUtils.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.common.util; 2 | 3 | import java.time.LocalDate; 4 | import java.time.format.DateTimeFormatter; 5 | 6 | import static com.kurly.tet.guide.springrestdocs.config.Constant.FORMAT_LOCAL_DATE; 7 | import static java.util.Objects.isNull; 8 | 9 | public class LocalDateUtils { 10 | public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; 11 | public static final int FIRST_DAY_OF_MONTH = 1; 12 | 13 | private LocalDateUtils() { 14 | throw new IllegalStateException("Util class."); 15 | } 16 | 17 | /** 18 | * {@link LocalDate} 변환 19 | * 20 | * @param source 원천 문자열 21 | * @return "yyyy-MM-dd" 형식으로 변환된 {@link LocalDate} 22 | */ 23 | public static LocalDate toLocalDate(String source) { 24 | return toLocalDate(source, FORMAT_LOCAL_DATE); 25 | } 26 | 27 | /** 28 | * {@link LocalDate} 변환 29 | * 30 | * @param source 원천문자열 31 | * @param dateFormat 변환날짜형식 32 | * @return 지정형식으로 변환된 {@link LocalDate} 33 | */ 34 | public static LocalDate toLocalDate(String source, String dateFormat) { 35 | if (isNull(source) || source.isEmpty()) { 36 | return null; 37 | } 38 | 39 | return LocalDate.parse(source, DateTimeFormatter.ofPattern(dateFormat)); 40 | } 41 | 42 | /** 43 | * 문자열 변환 44 | * 45 | * @param source 원천일 46 | * @return yyyy-MM-dd 형식 문자열 47 | */ 48 | public static String toString(LocalDate source) { 49 | if (isNull(source)) { 50 | return null; 51 | } 52 | 53 | return source.format(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)); 54 | } 55 | 56 | /** 57 | * 문자열 변환 58 | * 59 | * @param source 원천일 60 | * @param dateFormat 변환날짜형식 61 | * @return '변환날짜형식'으로 변환된 문자열 62 | */ 63 | public static String toString(LocalDate source, String dateFormat) { 64 | return source.format(DateTimeFormatter.ofPattern(dateFormat)); 65 | } 66 | 67 | /** 68 | * 해당월 첫번째 날 69 | * 70 | * @param source 원천일 71 | * @return 1 of source 72 | */ 73 | public static LocalDate ofMonthFirstDay(LocalDate source) { 74 | return source.withDayOfMonth(FIRST_DAY_OF_MONTH); 75 | } 76 | 77 | /** 78 | * 해당월 마지막 날 79 | * 80 | * @param source 원천일 81 | * @return lengthOfMonth of source 82 | */ 83 | public static LocalDate ofMonthLastDay(LocalDate source) { 84 | return source.withDayOfMonth(source.lengthOfMonth()); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/common/util/LocalTimeUtils.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.common.util; 2 | 3 | import java.time.LocalTime; 4 | import java.time.format.DateTimeFormatter; 5 | import java.util.Objects; 6 | 7 | import static com.kurly.tet.guide.springrestdocs.config.Constant.FORMAT_LOCAL_TIME; 8 | import static java.util.Objects.isNull; 9 | 10 | public class LocalTimeUtils { 11 | private LocalTimeUtils() { 12 | throw new IllegalStateException("Util class."); 13 | } 14 | 15 | /** 16 | * {@link LocalTime} 변환 17 | * 18 | * @param source 원천문자열 19 | * @return "HH:mm:ss" 형식으로 변환된 {@link LocalTime} 20 | */ 21 | public static LocalTime toLocalTime(String source) { 22 | return toLocalTime(source, FORMAT_LOCAL_TIME); 23 | } 24 | 25 | /** 26 | * {@link LocalTime} 변환 27 | * 28 | * @param source 원천문자열 29 | * @param timeFormat 변환시간형식 30 | * @return null or "HH:mm:ss" 형식으로 변환된 {@link LocalTime} 31 | */ 32 | public static LocalTime toLocalTime(String source, String timeFormat) { 33 | if (isNull(source) || source.isEmpty()) { 34 | return null; 35 | } 36 | 37 | return LocalTime.parse(source, DateTimeFormatter.ofPattern(timeFormat)); 38 | } 39 | 40 | /** 41 | * 문자열 변환 42 | * 43 | * @param source 원천 44 | * @return "HH:mm:ss" 형식으로 변환된 문자열 45 | */ 46 | public static String toString(LocalTime source) { 47 | return toString(source, FORMAT_LOCAL_TIME); 48 | } 49 | 50 | /** 51 | * 문자열 변환 52 | * 53 | * @param source 원천 54 | * @param timeFormat 변환시간형식 55 | * @return '변환시간형식'으로 변환된 문자열 or null 56 | */ 57 | public static String toString(LocalTime source, String timeFormat) { 58 | if (Objects.isNull(source)) { 59 | return null; 60 | } 61 | return source.format(DateTimeFormatter.ofPattern(timeFormat)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/config/Constant.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.config; 2 | 3 | import java.time.ZoneId; 4 | 5 | /** 6 | * 애플리케이션에서 공통으로 사용되는 불변변수를 선언하는 목적으로 사용합니다. 7 | */ 8 | public class Constant { 9 | public static final String PROFILE_TEST = "test"; 10 | public static final String PROFILE_LOCAL = "local"; 11 | public static final String PROFILE_DEV = "dev"; 12 | public static final String PROFILE_STAGE = "stg"; 13 | public static final String PROFILE_PERFORMANCE = "perf"; 14 | public static final String PROFILE_PRODUCTION = "prod"; 15 | 16 | public static final String HOST_LOCAL = "12.0.0.1"; 17 | public static final String HOST_DEV = ""; // 개발DNS 는 배포에 따라 맞춰 진행 18 | 19 | public static final String FORMAT_LOCAL_DATE_TIME = "yyyy-MM-dd'T'HH:mm:ss"; 20 | public static final String FORMAT_LOCAL_DATE = "yyyy-MM-dd"; 21 | public static final String FORMAT_LOCAL_TIME = "HH:mm:ss"; 22 | 23 | public static final ZoneId DEFAULT_ZONE_ID = ZoneId.of("Asia/Seoul"); 24 | 25 | private Constant() { 26 | throw new UnsupportedOperationException("불변변수용 클래스"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/config/SpringDocOpenApiConfig.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.config; 2 | 3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition; 4 | import io.swagger.v3.oas.annotations.info.Contact; 5 | import io.swagger.v3.oas.annotations.info.Info; 6 | import org.springframework.context.annotation.Configuration; 7 | 8 | /** 9 | * springdoc-opeanpi 를 통해 생성되는 Swagger 정의 10 | * 11 | * @see OpenAPI Specification - v3.0.3 12 | * @see OpenAPI Annotations 13 | */ 14 | @OpenAPIDefinition( 15 | info = @Info( 16 | title = "Spring REST Docs 가이드(Swagger 구성) 응?", 17 | version = "v2022.09.26", 18 | description = """ 19 | Spring REST Docs 가이드를 작성하려고 했는데, 일이 너무 커저버린 건에 대해서 반성중.... 20 | 생각외로 간단하게 적용가능해서 놀람""", 21 | contact = @Contact(url = "https://helloworld.kurly.com/", name = "김지헌(팀 엔지니어링 팀)", email = "jiheon.kim@kurlycorp.com") 22 | ) 23 | ) 24 | @Configuration 25 | public class SpringDocOpenApiConfig { 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/BusinessCode.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain; 2 | 3 | import lombok.Getter; 4 | 5 | @Getter 6 | public enum BusinessCode { 7 | //COMMON 8 | SUCCESS("0000","정상"), 9 | 10 | //Client 11 | BAD_REQUEST("C400", "잘못된 요청입니다. 요청내용을 확인하세요."), 12 | NOT_FOUND("C404", "요청내용을 찾을 수 없습니다. 요청내용을 확인하세요."), 13 | UNAUTHORIZED("C401", "인증되지 않았습니다. 인증을 확인하세요."), 14 | FORBIDDEN("C403", "권한이 없습니다. 권한을 확인하세요."), 15 | 16 | //Application 17 | INTERNAL_SERVER_ERROR("S500", "시스템 내부오류가 발생했습니다. 담당자에게 문의바랍니다."), 18 | 19 | 20 | ; 21 | 22 | private final String code; 23 | private final String description; 24 | 25 | BusinessCode(String code, String description) { 26 | this.code = code; 27 | this.description = description; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/DescriptionEnum.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain; 2 | 3 | /** 4 | * 외부 노출되는 enum 에 선언 5 | */ 6 | public interface DescriptionEnum { 7 | String getCode(); 8 | String getDescription(); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/exception/OrderNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain.exception; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.exception.BusinessException; 4 | import org.springframework.http.HttpStatus; 5 | 6 | public class OrderNotFoundException extends BusinessException { 7 | public OrderNotFoundException(String orderNo) { 8 | super(String.format("주문(주문번호: %s)을 찾을 수 없습니다.", orderNo)); 9 | } 10 | 11 | @Override 12 | public HttpStatus getHttpStatus() { 13 | return HttpStatus.NOT_FOUND; 14 | } 15 | 16 | @Override 17 | public String getErrorCode() { 18 | return "C404"; 19 | } 20 | 21 | @Override 22 | public boolean isNecessaryToLog() { 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/exception/ProductNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain.exception; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.exception.BusinessException; 4 | import org.springframework.http.HttpStatus; 5 | 6 | public class ProductNotFoundException extends BusinessException { 7 | public ProductNotFoundException(Long id) { 8 | super(String.format("상품(ID: %d)을 찾을 수 없습니다.", id)); 9 | } 10 | 11 | @Override 12 | public HttpStatus getHttpStatus() { 13 | return HttpStatus.NOT_FOUND; 14 | } 15 | 16 | @Override 17 | public String getErrorCode() { 18 | return "C404"; 19 | } 20 | 21 | @Override 22 | public boolean isNecessaryToLog() { 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/order/OrderDto.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain.order; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 4 | import lombok.AccessLevel; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.util.Assert; 8 | 9 | import java.time.LocalDateTime; 10 | import java.util.List; 11 | 12 | @Getter 13 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 14 | public class OrderDto { 15 | private Long id; 16 | private String memberNo; 17 | private String orderNo; 18 | private OrderStatus orderStatus; 19 | private List products; 20 | private LocalDateTime orderDateTime; // 주문일시 21 | 22 | private Long paymentMoney; // 결제금액 23 | private LocalDateTime paymentDateTime; // 결제일시 24 | 25 | private LocalDateTime shipmentDateTime; // 출하일시 26 | private LocalDateTime completedDateTime; // 완료일시 27 | 28 | /** 29 | * 주문정보 30 | * 31 | * @param id 식별번호 32 | * @param memberNo 회원번호 33 | * @param orderNo 주문번호 34 | * @param products 주문상품목록 35 | */ 36 | public OrderDto(Long id, 37 | String memberNo, 38 | String orderNo, 39 | List products) { 40 | this.id = id; 41 | this.memberNo = memberNo; 42 | this.orderNo = orderNo; 43 | this.products = products; 44 | 45 | this.orderStatus = OrderStatus.ORDERED; 46 | this.orderDateTime = LocalDateTime.now(); 47 | } 48 | 49 | public String getOrderStatusDescription() { 50 | return getOrderStatus().getDescription(); 51 | } 52 | 53 | /** 54 | * 결제 55 | * 56 | * @param paymentMoney 결제금액 57 | */ 58 | public void payment(Long paymentMoney) { 59 | Assert.isTrue(this.orderStatus == OrderStatus.ORDERED, "주문상태가 '주문완료' 상태여야 합니다."); 60 | Assert.notNull(paymentMoney, "결제금액(paymentMoney)은 필수입력값입니다."); 61 | Assert.isTrue(paymentMoney > 0, "결제금액(paymentMoney)은 0보다 커야합니다."); 62 | 63 | this.orderStatus = OrderStatus.PAID; 64 | this.paymentMoney = paymentMoney; 65 | this.paymentDateTime = LocalDateTime.now(); 66 | } 67 | 68 | public void shipping() { 69 | Assert.isTrue(this.orderStatus == OrderStatus.PAID, "주문상태가 '결제완료' 상태여야 합니다."); 70 | 71 | this.orderStatus = OrderStatus.SHIPPED; 72 | this.shipmentDateTime = LocalDateTime.now(); 73 | } 74 | 75 | public void complete() { 76 | Assert.isTrue(this.orderStatus == OrderStatus.SHIPPED, "주문상태가 '출하완료' 상태여야 합니다."); 77 | 78 | this.orderStatus = OrderStatus.COMPLETED; 79 | this.completedDateTime = LocalDateTime.now(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/order/OrderStatus.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain.order; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.DescriptionEnum; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public enum OrderStatus implements DescriptionEnum { 8 | ORDERED("주문완료"), 9 | PAID("결제완료"), 10 | SHIPPED("출하완료"), 11 | COMPLETED("배송완료"), 12 | ; 13 | 14 | private String description; 15 | 16 | OrderStatus(String description) { 17 | this.description = description; 18 | } 19 | 20 | @Override 21 | public String getCode() { 22 | return name(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/product/ProductDto.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain.product; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | 7 | import javax.validation.constraints.NotBlank; 8 | import javax.validation.constraints.NotNull; 9 | import java.time.LocalDateTime; 10 | 11 | @Getter 12 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 13 | public class ProductDto { 14 | private Long id; 15 | private String productName; 16 | private String productNo; 17 | private ProductStatus productStatus; 18 | private LocalDateTime created; 19 | private LocalDateTime modified; 20 | 21 | public ProductDto(Long id, String productName, String productNo) { 22 | this.id = id; 23 | this.productName = productName; 24 | this.productNo = productNo; 25 | this.productStatus = ProductStatus.CREATED; 26 | 27 | LocalDateTime now = LocalDateTime.now(); 28 | this.created = now; 29 | this.modified = now; 30 | } 31 | 32 | public String getProductStatusDescription() { 33 | return getProductStatus().getDescription(); 34 | } 35 | 36 | public void modify(@NotBlank String productName, @NotBlank String productNo, @NotNull ProductStatus productStatus) { 37 | this.productName = productName; 38 | this.productNo = productNo; 39 | this.productStatus = productStatus; 40 | this.modified = LocalDateTime.now(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/domain/product/ProductStatus.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.domain.product; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.DescriptionEnum; 4 | import lombok.Getter; 5 | 6 | import java.util.Set; 7 | 8 | @Getter 9 | public enum ProductStatus implements DescriptionEnum { 10 | CREATED("생성"), 11 | ACTIVATED("활성화"), 12 | ARCHIVED("보관처리됨"), 13 | ; 14 | 15 | private String description; 16 | 17 | ProductStatus(String description) { 18 | this.description = description; 19 | } 20 | 21 | @Override 22 | public String getCode() { 23 | return name(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/package-info.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure; -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/advisor/ApiResponseJsonProcessingException.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.advisor; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.exception.BusinessException; 4 | import org.springframework.http.HttpStatus; 5 | 6 | public class ApiResponseJsonProcessingException extends BusinessException { 7 | private static final String ERROR_CODE = "S599"; 8 | 9 | private static final HttpStatus HTTP_STATUS = HttpStatus.INTERNAL_SERVER_ERROR; 10 | 11 | public ApiResponseJsonProcessingException(Throwable cause) { 12 | super(cause); 13 | } 14 | 15 | @Override 16 | public HttpStatus getHttpStatus() { 17 | return HTTP_STATUS; 18 | } 19 | 20 | @Override 21 | public String getErrorCode() { 22 | return ERROR_CODE; 23 | } 24 | 25 | @Override 26 | public boolean isNecessaryToLog() { 27 | return true; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/advisor/ApiResponseWrappingAdvisor.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.advisor; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.util.JsonUtils; 4 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.ApiResponseGenerator; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.core.GenericTypeResolver; 7 | import org.springframework.core.MethodParameter; 8 | import org.springframework.core.ResolvableType; 9 | import org.springframework.http.HttpEntity; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.http.converter.ByteArrayHttpMessageConverter; 13 | import org.springframework.http.converter.HttpMessageConverter; 14 | import org.springframework.http.converter.ResourceHttpMessageConverter; 15 | import org.springframework.http.converter.StringHttpMessageConverter; 16 | import org.springframework.http.server.ServerHttpRequest; 17 | import org.springframework.http.server.ServerHttpResponse; 18 | import org.springframework.http.server.ServletServerHttpResponse; 19 | import org.springframework.lang.NonNull; 20 | import org.springframework.lang.Nullable; 21 | import org.springframework.web.bind.annotation.RestControllerAdvice; 22 | import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; 23 | 24 | import java.lang.reflect.Type; 25 | import java.util.Objects; 26 | 27 | /** 28 | * 웹요청 처리결과 응답값을 봉투패턴(Envelop pattern)으로 일정한 데이터 형식으로 가공 29 | *
 30 |  * {
 31 |  *     "code": "0000",
 32 |  *     "message": "OK",
 33 |  *     "data": { data }
 34 |  * }
 35 |  * 
36 | */ 37 | @Slf4j 38 | @RestControllerAdvice(basePackages = { 39 | "com.kurly.tet.guide.springrestdocs.infrastructure.web.product", 40 | "com.kurly.tet.guide.springrestdocs.infrastructure.web.order" 41 | }) 42 | public class ApiResponseWrappingAdvisor implements ResponseBodyAdvice { 43 | 44 | private Type getGenericType(MethodParameter returnType) { 45 | if (HttpEntity.class.isAssignableFrom(returnType.getParameterType())) { 46 | return ResolvableType.forType(returnType.getGenericParameterType()).getGeneric().getType(); 47 | } else { 48 | return returnType.getGenericParameterType(); 49 | } 50 | } 51 | 52 | @Override 53 | public boolean supports(MethodParameter returnType, Class> converterType) { 54 | Type type = GenericTypeResolver.resolveType(getGenericType(returnType), 55 | returnType.getContainingClass()); 56 | 57 | if(returnType.getExecutable().getName().toUpperCase().contains("HEALTH")) { // healthCheck 58 | return false; 59 | } 60 | 61 | if (Void.class.getName().equals(type.getTypeName())) { 62 | return false; 63 | } 64 | 65 | return !converterType.isAssignableFrom(ByteArrayHttpMessageConverter.class) && 66 | !converterType.isAssignableFrom(ResourceHttpMessageConverter.class); 67 | } 68 | 69 | @Override 70 | public Object beforeBodyWrite(@Nullable Object body, 71 | @NonNull MethodParameter returnType, 72 | @NonNull MediaType selectedContentType, 73 | @NonNull Class> selectedConverterType, 74 | @NonNull ServerHttpRequest request, 75 | @NonNull ServerHttpResponse response) { 76 | 77 | HttpStatus responseStatus = HttpStatus.valueOf( 78 | ((ServletServerHttpResponse) response).getServletResponse().getStatus() 79 | ); 80 | 81 | if (Objects.isNull(body)) { 82 | return responseStatus.isError() ? ApiResponseGenerator.fail() : ApiResponseGenerator.success(); 83 | } 84 | 85 | var apiResponse = responseStatus.isError() ? ApiResponseGenerator.fail(body) : ApiResponseGenerator.success(body); 86 | log.trace("[ApiResponse] {}", apiResponse); 87 | 88 | if (selectedConverterType.isAssignableFrom(StringHttpMessageConverter.class)) { 89 | try { 90 | response.getHeaders().setContentType(MediaType.APPLICATION_JSON); 91 | return JsonUtils.toJson(apiResponse); 92 | } catch (JsonUtils.JsonEncodeException jpe) { 93 | log.warn("JSON 처리 중 오류 발생", jpe); 94 | throw new ApiResponseJsonProcessingException(jpe); 95 | } 96 | } 97 | 98 | return apiResponse; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/component/LocalDateJsonComponent.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.component; 2 | 3 | import com.fasterxml.jackson.core.JacksonException; 4 | import com.fasterxml.jackson.core.JsonGenerator; 5 | import com.fasterxml.jackson.core.JsonParser; 6 | import com.fasterxml.jackson.databind.DeserializationContext; 7 | import com.fasterxml.jackson.databind.JsonDeserializer; 8 | import com.fasterxml.jackson.databind.JsonSerializer; 9 | import com.fasterxml.jackson.databind.SerializerProvider; 10 | import com.kurly.tet.guide.springrestdocs.common.util.LocalDateUtils; 11 | import org.springframework.boot.jackson.JsonComponent; 12 | 13 | import java.io.IOException; 14 | import java.time.LocalDate; 15 | 16 | @JsonComponent 17 | public class LocalDateJsonComponent { 18 | public static class Serializer extends JsonSerializer { 19 | @Override 20 | public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers) throws IOException { 21 | gen.writeString(LocalDateUtils.toString(value)); 22 | } 23 | } 24 | 25 | public static class Deserializer extends JsonDeserializer { 26 | @Override 27 | public LocalDate deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException { 28 | return LocalDateUtils.toLocalDate(p.getText()); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/component/LocalDateTimeJsonComponent.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.component; 2 | 3 | import com.fasterxml.jackson.core.JacksonException; 4 | import com.fasterxml.jackson.core.JsonGenerator; 5 | import com.fasterxml.jackson.core.JsonParser; 6 | import com.fasterxml.jackson.databind.DeserializationContext; 7 | import com.fasterxml.jackson.databind.JsonDeserializer; 8 | import com.fasterxml.jackson.databind.JsonSerializer; 9 | import com.fasterxml.jackson.databind.SerializerProvider; 10 | import com.kurly.tet.guide.springrestdocs.common.util.LocalDateTimeUtils; 11 | import com.kurly.tet.guide.springrestdocs.common.util.LocalDateUtils; 12 | import com.kurly.tet.guide.springrestdocs.common.util.LocalTimeUtils; 13 | import org.springframework.boot.jackson.JsonComponent; 14 | 15 | import java.io.IOException; 16 | import java.time.LocalDateTime; 17 | 18 | @JsonComponent 19 | public class LocalDateTimeJsonComponent { 20 | public static class Serializer extends JsonSerializer { 21 | 22 | @Override 23 | public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { 24 | gen.writeString(LocalDateTimeUtils.toString(value)); 25 | } 26 | } 27 | 28 | public static class Deserializer extends JsonDeserializer { 29 | 30 | @Override 31 | public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException { 32 | return LocalDateTimeUtils.toLocalDateTime(p.getText()); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/component/LocalTimeJsonComponent.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.component; 2 | 3 | import com.fasterxml.jackson.core.JacksonException; 4 | import com.fasterxml.jackson.core.JsonGenerator; 5 | import com.fasterxml.jackson.core.JsonParser; 6 | import com.fasterxml.jackson.databind.DeserializationContext; 7 | import com.fasterxml.jackson.databind.JsonDeserializer; 8 | import com.fasterxml.jackson.databind.JsonSerializer; 9 | import com.fasterxml.jackson.databind.SerializerProvider; 10 | import com.kurly.tet.guide.springrestdocs.common.util.LocalDateTimeUtils; 11 | import com.kurly.tet.guide.springrestdocs.common.util.LocalTimeUtils; 12 | import org.springframework.boot.jackson.JsonComponent; 13 | 14 | import java.io.IOException; 15 | import java.time.LocalTime; 16 | 17 | @JsonComponent 18 | public class LocalTimeJsonComponent { 19 | public static class Serializer extends JsonSerializer { 20 | @Override 21 | public void serialize(LocalTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { 22 | gen.writeString(LocalTimeUtils.toString(value)); 23 | } 24 | } 25 | 26 | public static class Deserializer extends JsonDeserializer { 27 | 28 | @Override 29 | public LocalTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException { 30 | return LocalTimeUtils.toLocalTime(p.getText()); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/dto/ApiResponse.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.BusinessCode; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Getter 8 | @NoArgsConstructor 9 | public class ApiResponse { 10 | private String code; 11 | private String message; 12 | private T data; 13 | 14 | public ApiResponse(BusinessCode businessCode) { 15 | this.code = businessCode.getCode(); 16 | this.message = businessCode.getDescription(); 17 | } 18 | 19 | public ApiResponse(String code, String message) { 20 | this.code = code; 21 | this.message = message; 22 | } 23 | 24 | public ApiResponse(String code, String message, T data) { 25 | this.code = code; 26 | this.message = message; 27 | this.data = data; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/dto/ApiResponseGenerator.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.BusinessCode; 4 | 5 | public class ApiResponseGenerator { 6 | private static final ApiResponse SUCCESS = new ApiResponse<>(BusinessCode.SUCCESS); 7 | private static final ApiResponse FAILURE = new ApiResponse<>(BusinessCode.INTERNAL_SERVER_ERROR); 8 | 9 | private ApiResponseGenerator() { 10 | throw new UnsupportedOperationException("Utility class."); 11 | } 12 | 13 | /** 14 | * 기본적인 성공응답 15 | * 16 | * @return new ApiResponse<>("0000", "OK"); 17 | */ 18 | public static ApiResponse success() { 19 | return SUCCESS; 20 | } 21 | 22 | /** 23 | * 성공 응답데이터 제공 24 | * 25 | * @param data 응답데이터 26 | * @return new ApiResponse<>("0000", "OK", data); 27 | */ 28 | public static ApiResponse success(D data) { 29 | return new ApiResponse<>(SUCCESS.getCode(), SUCCESS.getMessage(), data); 30 | } 31 | 32 | /** 33 | * 기본적인 실패응답 34 | * 35 | * @return new ApiResponse<>("5000", "Internal Server Error"); 36 | */ 37 | public static ApiResponse fail() { 38 | return FAILURE; 39 | } 40 | 41 | /** 42 | * 실패 응답데이터 제공 43 | * 44 | * @param data 응답데이터 45 | * @return new ApiResponse<>("5000", "Internal Server Error", data); 46 | */ 47 | public static ApiResponse fail(D data) { 48 | return new ApiResponse<>(FAILURE.getCode(), FAILURE.getMessage(), data); 49 | } 50 | 51 | /** 52 | * 실패응답 53 | * 54 | * @param code 실패코드 55 | * @param message 실패메시지 56 | * @return new ApiResponse<>(code, message, null); 57 | */ 58 | public static ApiResponse fail(String code, String message) { 59 | return new ApiResponse<>(code, message, null); 60 | } 61 | 62 | /** 63 | * 응답값 처리(서버에서 응답한 경우는 요청성공(success == true)으로 판단한다) 64 | * 65 | * @param code 응답코드 66 | * @param message 응답메시지 67 | * @param data 응답데이터 68 | * @param 응답데이터 클래스 69 | * @return new ApiResponse<>(code, message, data); 70 | */ 71 | public static ApiResponse of(String code, String message, D data) { 72 | return new ApiResponse<>(code, message, data); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/dto/PageRequest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | import javax.validation.constraints.Max; 10 | import javax.validation.constraints.Min; 11 | 12 | @Schema(description = "페이징요청") 13 | @Getter 14 | @Setter 15 | @NoArgsConstructor 16 | public class PageRequest { 17 | @Schema(description = "페이지번호", required = true, minimum = "0") 18 | @Min(value = 0) 19 | private int page; 20 | @Schema(description = "페이지 내 조회건수(1000건 이상 불가)", required = true, minimum = "0", maximum = "1000") 21 | @Min(value = 0) 22 | @Max(value = 1000) 23 | private int size; 24 | 25 | public PageRequest(int page, int size) { 26 | if (page < 0) { 27 | throw new IllegalArgumentException("페이지(page)는 0보다 커야합니다."); 28 | } 29 | 30 | if (size < 1) { 31 | throw new IllegalArgumentException("페이지크기(size)는 1보다 커야 합니다."); 32 | } 33 | 34 | this.page = page; 35 | this.size = size; 36 | } 37 | 38 | public static PageRequest of(int page, int size) { 39 | return new PageRequest(page, size); 40 | } 41 | 42 | @JsonIgnore 43 | public long getOffset() { 44 | return (long) page * (long) size; 45 | } 46 | } -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/dto/PageResponse.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.AccessLevel; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import lombok.Setter; 8 | 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | import java.util.Optional; 12 | 13 | @Getter 14 | @NoArgsConstructor 15 | public class PageResponse { 16 | private List content = new ArrayList<>(); 17 | private PageRequest pageRequest; 18 | private long total; 19 | 20 | public PageResponse(List content, PageRequest pageRequest, long total) { 21 | this.content.addAll(content); 22 | this.pageRequest = pageRequest; 23 | this.total = Optional.of(pageRequest) 24 | .filter(it -> !content.isEmpty()) 25 | .filter(it -> pageRequest.getOffset() + it.getSize() > total) 26 | .map(it -> pageRequest.getOffset() + content.size()) 27 | .orElse(total); 28 | } 29 | 30 | public int getTotalPages() { 31 | return this.pageRequest.getSize() == 0 ? 1 : (int) Math.ceil(getTotal() / (double) this.pageRequest.getSize()); 32 | } 33 | 34 | @JsonProperty("hasPrevious") 35 | public boolean hasPrevious(){ 36 | return this.pageRequest.getPage() > 0; 37 | } 38 | 39 | @JsonProperty("isFirst") 40 | public boolean isFirst() { 41 | return !hasPrevious(); 42 | } 43 | 44 | @JsonProperty("hasNext") 45 | public boolean hasNext() { 46 | return this.pageRequest.getPage() + 1 < getTotalPages(); 47 | } 48 | 49 | @JsonProperty("isLast") 50 | public boolean isLast() { 51 | return !hasNext(); 52 | } 53 | 54 | @JsonProperty("hasContent") 55 | public boolean hasContent() { 56 | return !getContent().isEmpty(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/common/handler/GlobalRestControllerExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.common.handler; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.exception.BusinessException; 4 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.ApiResponse; 5 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.ApiResponseGenerator; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.core.convert.ConversionFailedException; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.http.converter.HttpMessageNotReadableException; 11 | import org.springframework.validation.BindException; 12 | import org.springframework.web.HttpMediaTypeNotAcceptableException; 13 | import org.springframework.web.HttpMediaTypeNotSupportedException; 14 | import org.springframework.web.bind.MethodArgumentNotValidException; 15 | import org.springframework.web.bind.MissingServletRequestParameterException; 16 | import org.springframework.web.bind.annotation.ExceptionHandler; 17 | import org.springframework.web.bind.annotation.ResponseStatus; 18 | import org.springframework.web.bind.annotation.RestControllerAdvice; 19 | import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; 20 | 21 | import java.nio.file.AccessDeniedException; 22 | 23 | import static com.kurly.tet.guide.springrestdocs.domain.BusinessCode.*; 24 | 25 | @Slf4j 26 | @RestControllerAdvice 27 | public class GlobalRestControllerExceptionHandler { 28 | @ExceptionHandler(BusinessException.class) 29 | protected ResponseEntity> handle(BusinessException exception) { 30 | if (exception.isNecessaryToLog()) { 31 | log.error("[BusinessException] {}", exception.getMessage(), exception); 32 | } 33 | 34 | return ResponseEntity 35 | .status(exception.getHttpStatus()) 36 | .body(ApiResponseGenerator.fail(exception.getErrorCode(), exception.getMessage())); 37 | } 38 | 39 | @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 40 | @ExceptionHandler(Throwable.class) 41 | protected ApiResponse handle(Throwable throwable) { 42 | log.error("[InternalServerError]{}", throwable.getMessage(), throwable); 43 | 44 | return ApiResponseGenerator.fail(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getDescription()); 45 | } 46 | 47 | @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 48 | @ExceptionHandler(ConversionFailedException.class) 49 | protected ApiResponse handle(ConversionFailedException exception) { 50 | Throwable cause = exception.getCause(); 51 | if (cause instanceof IllegalArgumentException illegalArgumentException) { 52 | return this.handle(illegalArgumentException); 53 | } 54 | 55 | log.error("[InternalServerError][ConversionFailed] {}", exception.getMessage(), exception); 56 | 57 | return ApiResponseGenerator.fail(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getDescription()); 58 | } 59 | 60 | @ResponseStatus(HttpStatus.FORBIDDEN) 61 | @ExceptionHandler(AccessDeniedException.class) 62 | protected ApiResponse handle(AccessDeniedException exception) { 63 | log.info("[AccessDenied] {}", exception.getMessage()); 64 | return ApiResponseGenerator.fail(FORBIDDEN.getCode(), FORBIDDEN.getDescription()); 65 | } 66 | 67 | @ResponseStatus(HttpStatus.BAD_REQUEST) 68 | @ExceptionHandler({ 69 | IllegalArgumentException.class, 70 | MethodArgumentNotValidException.class, 71 | MissingServletRequestParameterException.class, 72 | MethodArgumentTypeMismatchException.class, 73 | HttpMessageNotReadableException.class, 74 | HttpMediaTypeNotSupportedException.class, 75 | HttpMediaTypeNotAcceptableException.class, 76 | BindException.class 77 | }) 78 | protected ApiResponse handle(Exception exception) { 79 | log.info("[BadRequest] {}", exception.getMessage(), exception); 80 | 81 | return ApiResponseGenerator.fail(BAD_REQUEST.getCode(), BAD_REQUEST.getDescription()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/order/OrderRestController.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.order; 2 | 3 | import com.kurly.tet.guide.springrestdocs.application.order.OrderFacade; 4 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderDto; 5 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageRequest; 6 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageResponse; 7 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command.OrderCreateCommand; 8 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command.OrderPaymentCommand; 9 | import io.swagger.v3.oas.annotations.Operation; 10 | import io.swagger.v3.oas.annotations.Parameter; 11 | import io.swagger.v3.oas.annotations.media.Content; 12 | import io.swagger.v3.oas.annotations.media.Schema; 13 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 14 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 15 | import io.swagger.v3.oas.annotations.tags.Tag; 16 | import org.springframework.http.HttpStatus; 17 | import org.springframework.web.bind.annotation.*; 18 | 19 | import javax.validation.Valid; 20 | 21 | @Tag(name = "orders", description = "주문(Order)API") 22 | @RestController 23 | public class OrderRestController { 24 | private final OrderFacade orderFacade; 25 | 26 | public OrderRestController(OrderFacade orderFacade) { 27 | this.orderFacade = orderFacade; 28 | } 29 | 30 | /** 31 | * 주문검색 32 | * 33 | * @param searchCondition 검색조건[회원번호] 34 | * @param pageRequest 페이징조건(page=페이지번호,size=페이지 내 건수) 35 | * @return 페이징된 검색주문내역 36 | */ 37 | @Operation(summary = "주문내역을 검색한다.") 38 | @GetMapping("/orders") 39 | public PageResponse search(@Parameter(description = "주문검색조건") OrderSearchCondition searchCondition, 40 | @Valid PageRequest pageRequest) { 41 | return orderFacade.search(searchCondition, pageRequest); 42 | } 43 | 44 | @Operation(summary = "주문을 생성한다.", 45 | requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "주문을 생성합니다.", required = true , content = @Content(schema = @Schema(implementation = OrderCreateCommand.class))), 46 | responses = { 47 | @ApiResponse(responseCode = "201", description = "정상적으로 생성완료"), 48 | @ApiResponse(responseCode = "400", description = "회원번호 혹은 상품번호를 누락한 경우 발생", 49 | content = {@Content(schema = @Schema(example = "{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}"))}), 50 | @ApiResponse(responseCode = "403", description = "미구현"), 51 | } 52 | ) 53 | @ResponseStatus(HttpStatus.CREATED) 54 | @PostMapping("/orders") 55 | public OrderDto create(@Valid @RequestBody OrderCreateCommand createCommand) { 56 | return orderFacade.create(createCommand); 57 | } 58 | 59 | @Operation(summary = "주문내역을 조회한다.", 60 | responses = { 61 | @ApiResponse(responseCode = "200", description = "정상처리"), 62 | @ApiResponse(responseCode = "400", description = "미구현"), 63 | @ApiResponse(responseCode = "403", description = "미구현"), 64 | @ApiResponse(responseCode = "404", description = "주문번호와 대응되는 주문내역 검색실패") 65 | }) 66 | @GetMapping("/orders/{orderNo}") 67 | public OrderDto findByOrderNo(@Parameter(description = "주문번호") @PathVariable String orderNo) { 68 | return orderFacade.findByOrderNo(orderNo); 69 | } 70 | 71 | @Operation(summary = "주문내역을 결제처리한다.", 72 | responses = { 73 | @ApiResponse(responseCode = "200", description = "정상처리"), 74 | @ApiResponse(responseCode = "400", description = "회원번호 혹은 상품번호를 누락한 경우 발생", 75 | content = {@Content(schema = @Schema(example = "{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}"))}), 76 | @ApiResponse(responseCode = "403", description = "미구현"), 77 | @ApiResponse(responseCode = "404", description = "주문번호와 대응되는 주문내역 검색실패", 78 | content = {@Content(schema = @Schema(example = "{\"code\":\"C404\",\"message\":\"주문(주문번호: ${주문번호})을 찾을 수 없습니다.\",\"data\":null}"))}) 79 | } 80 | ) 81 | @PutMapping("/orders/{orderNo}/payment") 82 | public OrderDto payment(@Parameter(description = "주문번호") @PathVariable String orderNo, 83 | @Valid @RequestBody OrderPaymentCommand paymentCommand) { 84 | return orderFacade.payment(orderNo, paymentCommand); 85 | } 86 | 87 | @Operation(summary = "주문내역을 출하한다.", 88 | responses = { 89 | @ApiResponse(responseCode = "200", description = "정상처리"), 90 | @ApiResponse(responseCode = "404", description = "주문번호와 대응되는 주문내역 검색실패", 91 | content = {@Content(schema = @Schema(example = "{\"code\":\"C404\",\"message\":\"주문(주문번호: ${주문번호})을 찾을 수 없습니다.\",\"data\":null}"))}) 92 | }) 93 | @PutMapping("/orders/{orderNo}/shipping") 94 | public OrderDto shipping(@Parameter(description = "주문번호") @PathVariable String orderNo) { 95 | return orderFacade.shipping(orderNo); 96 | } 97 | 98 | @Operation(summary = "주문내역을 배송완료처리한다.", 99 | responses = { 100 | @ApiResponse(responseCode = "200", description = "정상처리"), 101 | @ApiResponse(responseCode = "404", description = "주문번호와 대응되는 주문내역 검색실패", 102 | content = {@Content(schema = @Schema(example = "{\"code\":\"C404\",\"message\":\"주문(주문번호: ${주문번호})을 찾을 수 없습니다.\",\"data\":null}"))}) 103 | }) 104 | @PutMapping("/orders/{orderNo}/complete") 105 | public OrderDto complete(@Parameter(description = "주문번호") @PathVariable String orderNo) { 106 | return orderFacade.complete(orderNo); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/order/OrderSearchCondition.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.order; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderDto; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.Getter; 6 | 7 | @Schema(description = "주문검색조건") 8 | @Getter 9 | public class OrderSearchCondition { 10 | @Schema(description = "회원번호") 11 | private String memberNo; 12 | @Schema(description = "주문번호") 13 | private String orderNo; 14 | 15 | private boolean hasMemberNo() { 16 | return memberNo != null && !memberNo.isBlank(); 17 | } 18 | 19 | private boolean hasOrderNo() { 20 | return orderNo != null && !orderNo.isBlank(); 21 | } 22 | 23 | public boolean filter(OrderDto orderDto) { 24 | if (hasMemberNo()) { 25 | return orderDto.getMemberNo().equals(getMemberNo()); 26 | } 27 | if (hasOrderNo()) { 28 | return orderDto.getOrderNo().equals(getOrderNo()); 29 | } 30 | 31 | return true; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/order/command/OrderCreateCommand.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.util.LocalDateTimeUtils; 4 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderDto; 5 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.AccessLevel; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | 11 | import javax.validation.constraints.NotBlank; 12 | import javax.validation.constraints.NotEmpty; 13 | import java.time.LocalDateTime; 14 | import java.util.List; 15 | 16 | @Schema(description = "주문생성명령") 17 | @Getter 18 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 19 | public class OrderCreateCommand { 20 | @Schema(description = "회원번호",required = true) 21 | @NotBlank 22 | private String memberNo; 23 | @Schema(description = "상품식별번호목록", required = true, example = "[1,2,]") 24 | @NotEmpty 25 | private List productIds; 26 | 27 | public OrderCreateCommand(String memberNo, List productIds) { 28 | this.memberNo = memberNo; 29 | this.productIds = productIds; 30 | } 31 | 32 | public OrderDto createOrder(long id, List findProducts) { 33 | return new OrderDto( 34 | id, 35 | this.memberNo, 36 | LocalDateTimeUtils.toString(LocalDateTime.now(), "yyyyMMddHHmmss" + id), 37 | findProducts); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/order/command/OrderPaymentCommand.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderDto; 4 | import io.swagger.v3.oas.annotations.media.Schema; 5 | import lombok.AccessLevel; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | 9 | import javax.validation.constraints.NotNull; 10 | 11 | @Schema(description = "주문결제명령") 12 | @Getter 13 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 14 | public class OrderPaymentCommand { 15 | @Schema(description = "결제금액", required = true, example = "1000") 16 | @NotNull 17 | private Long paymentMoney; 18 | 19 | public OrderPaymentCommand(Long paymentMoney) { 20 | this.paymentMoney = paymentMoney; 21 | } 22 | 23 | public void payment(OrderDto order) { 24 | order.payment(paymentMoney); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/package-info.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web; -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/product/ProductCreateCommand.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.product; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 4 | import lombok.AccessLevel; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | 8 | import javax.validation.constraints.NotBlank; 9 | 10 | @Getter 11 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 12 | public class ProductCreateCommand { 13 | @NotBlank 14 | private String productName; 15 | @NotBlank 16 | private String productNo; 17 | 18 | public ProductCreateCommand(String productName, String productNo) { 19 | this.productName = productName; 20 | this.productNo = productNo; 21 | } 22 | 23 | public ProductDto createDto(Long id) { 24 | return new ProductDto(id, getProductName(), getProductNo()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/product/ProductModifyCommand.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.product; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 4 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductStatus; 5 | import lombok.AccessLevel; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | 9 | import javax.validation.constraints.NotBlank; 10 | import javax.validation.constraints.NotNull; 11 | 12 | @Getter 13 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 14 | public class ProductModifyCommand { 15 | @NotBlank 16 | private String productName; 17 | @NotBlank 18 | private String productNo; 19 | @NotNull 20 | private ProductStatus productStatus; 21 | 22 | public ProductModifyCommand(String productName, String productNo, ProductStatus productStatus) { 23 | this.productName = productName; 24 | this.productNo = productNo; 25 | this.productStatus = productStatus; 26 | } 27 | 28 | public ProductDto modify(ProductDto source) { 29 | source.modify(getProductName(), getProductNo(), getProductStatus()); 30 | return source; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/product/ProductRestController.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.product; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 4 | import com.kurly.tet.guide.springrestdocs.application.product.ProductFacade; 5 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageRequest; 6 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageResponse; 7 | import org.springframework.http.HttpStatus; 8 | import org.springframework.web.bind.annotation.*; 9 | 10 | import javax.validation.Valid; 11 | 12 | @RestController 13 | public class ProductRestController { 14 | private final ProductFacade productFacade; 15 | 16 | public ProductRestController(ProductFacade productFacade) { 17 | this.productFacade = productFacade; 18 | } 19 | 20 | 21 | @GetMapping("/products") 22 | public PageResponse search(ProductSearchCondition searchCondition, @Valid PageRequest pageRequest) { 23 | return productFacade.search(searchCondition, pageRequest); 24 | } 25 | 26 | @ResponseStatus(HttpStatus.CREATED) 27 | @PostMapping("/products") 28 | public ProductDto createProduct(@Valid @RequestBody ProductCreateCommand createCommand) { 29 | return productFacade.create(createCommand); 30 | } 31 | 32 | @GetMapping("/products/{productId}") 33 | public ProductDto getProduct(@PathVariable("productId") Long productId) { 34 | return productFacade.getProduct(productId); 35 | } 36 | 37 | 38 | @PutMapping("/products/{productId}") 39 | public ProductDto modifyProduct(@PathVariable Long productId, @Valid @RequestBody ProductModifyCommand modifyCommand) { 40 | return productFacade.modify(productId, modifyCommand); 41 | } 42 | 43 | @ResponseStatus(HttpStatus.NO_CONTENT) 44 | @DeleteMapping("/products/{productId}") 45 | public void deleteProduct(@PathVariable Long productId) { 46 | productFacade.remove(productId); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/product/ProductSearchCondition.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.product; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 4 | import lombok.Getter; 5 | 6 | @Getter 7 | public class ProductSearchCondition { 8 | private String productName; 9 | private String productNo; 10 | 11 | public boolean hasProductName() { 12 | return getProductName() != null && !getProductName().isEmpty(); 13 | } 14 | 15 | public boolean hasProductNo() { 16 | return getProductNo() != null && !getProductNo().isEmpty(); 17 | } 18 | 19 | public boolean filter(ProductDto productDto) { 20 | if(hasProductName()) { 21 | return productDto.getProductName().equals(getProductNo()); 22 | } 23 | if(hasProductNo()) { 24 | return productDto.getProductNo().equals(getProductNo()); 25 | } 26 | 27 | return true; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/application-springdoc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | spring: 3 | config: 4 | activate: 5 | on-profile: springdoc-local 6 | 7 | springdoc: 8 | api-docs: 9 | enabled: true 10 | 11 | --- 12 | spring: 13 | config: 14 | activate: 15 | on-profile: springdoc-test 16 | 17 | springdoc: 18 | api-docs: 19 | enabled: false 20 | 21 | --- 22 | spring: 23 | config: 24 | activate: 25 | on-profile: springdoc-dev 26 | 27 | springdoc: 28 | api-docs: 29 | enabled: true 30 | 31 | --- 32 | spring: 33 | config: 34 | activate: 35 | on-profile: springdoc-beta 36 | 37 | springdoc: 38 | api-docs: 39 | enabled: true 40 | 41 | --- 42 | spring: 43 | config: 44 | activate: 45 | on-profile: springdoc-prod 46 | 47 | springdoc: 48 | api-docs: 49 | enabled: false 50 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | include: 4 | - springdoc 5 | group: 6 | local: springdoc-local 7 | test: springdoc-test 8 | dev: springdoc-dev 9 | beta: springdoc-beta 10 | prod: springdoc-prod -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/ApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | /** 8 | * 애플리케이션 ApplicationContext 정상적재 확인용 9 | */ 10 | @Disabled("애플리케이션 ApplicationContext 정상적재: 확인됨") 11 | @SpringBootTest 12 | class ApplicationTests { 13 | 14 | @Test 15 | void contextLoads() { 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/annotations/MockTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.annotations; 2 | 3 | import org.junit.jupiter.api.extension.ExtendWith; 4 | import org.mockito.junit.jupiter.MockitoExtension; 5 | import org.springframework.restdocs.RestDocumentationExtension; 6 | 7 | import java.lang.annotation.*; 8 | 9 | @Target(ElementType.TYPE) 10 | @Retention(RetentionPolicy.RUNTIME) 11 | @Documented 12 | @ExtendWith({MockitoExtension.class}) 13 | public @interface MockTest { 14 | } 15 | -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/annotations/RestDocsTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.annotations; 2 | 3 | import org.junit.jupiter.api.Tag; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.mockito.junit.jupiter.MockitoExtension; 6 | import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; 7 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 8 | import org.springframework.restdocs.RestDocumentationExtension; 9 | 10 | import java.lang.annotation.*; 11 | 12 | /** 13 | * @see AutoConfigureMockMvc 14 | * @See AutoConfigureRestDocs 15 | */ 16 | @Target(ElementType.TYPE) 17 | @Retention(RetentionPolicy.RUNTIME) 18 | @Documented 19 | @Tag("restDocs") 20 | @ExtendWith({MockitoExtension.class, RestDocumentationExtension.class}) 21 | @AutoConfigureMockMvc 22 | @AutoConfigureRestDocs 23 | public @interface RestDocsTest { 24 | } 25 | -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/application/order/OrderFacadeTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.application.order; 2 | 3 | import com.kurly.tet.guide.springrestdocs.annotations.MockTest; 4 | import com.kurly.tet.guide.springrestdocs.application.product.ProductFacade; 5 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderStatus; 6 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command.OrderCreateCommand; 7 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.order.command.OrderPaymentCommand; 8 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.product.ProductCreateCommand; 9 | import org.assertj.core.api.SoftAssertions; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.DisplayName; 12 | import org.junit.jupiter.api.Test; 13 | import org.mockito.Spy; 14 | 15 | import java.util.List; 16 | 17 | import static org.assertj.core.api.Assertions.assertThat; 18 | import static org.assertj.core.api.Assertions.catchThrowable; 19 | 20 | @MockTest 21 | class OrderFacadeTest { 22 | @Spy 23 | ProductFacade productFacade; 24 | OrderFacade orderFacade; 25 | 26 | @BeforeEach 27 | void setUp() { 28 | productFacade = new ProductFacade(); 29 | var createCommand = new ProductCreateCommand("TEST", "TEST01"); 30 | productFacade.create(createCommand); 31 | 32 | orderFacade = new OrderFacade(productFacade); 33 | } 34 | 35 | @DisplayName("주문: 주문완료(생성)") 36 | @Test 37 | void testCreate() { 38 | //tiven 39 | long productId = 1L; 40 | var createCommand = new OrderCreateCommand("202209240001", List.of(productId)); 41 | 42 | //when 43 | var createdOrder = orderFacade.create(createCommand); 44 | 45 | //then 46 | SoftAssertions.assertSoftly(softly -> { 47 | softly.assertThat(createdOrder.getMemberNo()).isEqualTo(createCommand.getMemberNo()); 48 | softly.assertThat(createdOrder.getProducts().get(0).getId()).isEqualTo(productId); 49 | }); 50 | 51 | } 52 | 53 | @DisplayName("주문: 결제완료") 54 | @Test 55 | void testPayment() { 56 | //given 57 | long productId = 1L; 58 | var createCommand = new OrderCreateCommand("202209240001", List.of(productId)); 59 | var createdOrder = orderFacade.create(createCommand); 60 | 61 | //when 62 | OrderPaymentCommand paymentCommand = new OrderPaymentCommand(1000L); 63 | var paidOrder = orderFacade.payment(createdOrder.getOrderNo(), paymentCommand); 64 | 65 | //then 66 | SoftAssertions.assertSoftly(softly -> { 67 | softly.assertThat(paidOrder.getOrderStatus()).isEqualTo(OrderStatus.PAID); 68 | softly.assertThat(paidOrder.getPaymentMoney()).isEqualTo(paymentCommand.getPaymentMoney()); 69 | softly.assertThat(paidOrder.getPaymentDateTime()).isNotNull(); 70 | }); 71 | } 72 | 73 | @DisplayName("주문: 출하완료 처리") 74 | @Test 75 | void testShipping() { 76 | //given 77 | long productId = 1L; 78 | var createCommand = new OrderCreateCommand("202209240001", List.of(productId)); 79 | var createdOrder = orderFacade.create(createCommand); 80 | 81 | OrderPaymentCommand paymentCommand = new OrderPaymentCommand(1000L); 82 | var paidOrder = orderFacade.payment(createdOrder.getOrderNo(), paymentCommand); 83 | 84 | var shippingOrder = orderFacade.shipping(paidOrder.getOrderNo()); 85 | 86 | //expect 87 | SoftAssertions.assertSoftly(softly -> { 88 | softly.assertThat(paidOrder.getOrderStatus()).isEqualTo(OrderStatus.SHIPPED); 89 | softly.assertThat(paidOrder.getShipmentDateTime()).isNotNull(); 90 | }); 91 | } 92 | 93 | @Test 94 | @DisplayName("생성상태에서 출하로 변경시 오류 발생") 95 | void testShippingWhenCreatedOccurException() { 96 | //given 97 | long productId = 1L; 98 | var createCommand = new OrderCreateCommand("202209240001", List.of(productId)); 99 | var createdOrder = orderFacade.create(createCommand); 100 | 101 | //expect 102 | Throwable thrown = catchThrowable(() -> orderFacade.shipping(createdOrder.getOrderNo())); 103 | assertThat(thrown) 104 | .isInstanceOf(IllegalArgumentException.class) 105 | .hasMessage("주문상태가 '결제완료' 상태여야 합니다."); 106 | } 107 | 108 | @DisplayName("주문: 배송완료 처리") 109 | @Test 110 | void testComplete() { 111 | //given 112 | long productId = 1L; 113 | var createCommand = new OrderCreateCommand("202209240001", List.of(productId)); 114 | var createdOrder = orderFacade.create(createCommand); 115 | 116 | OrderPaymentCommand paymentCommand = new OrderPaymentCommand(1000L); 117 | var paidOrder = orderFacade.payment(createdOrder.getOrderNo(), paymentCommand); 118 | 119 | var shippingOrder = orderFacade.shipping(paidOrder.getOrderNo()); 120 | 121 | //when 122 | var completedOrder = orderFacade.complete(shippingOrder.getOrderNo()); 123 | 124 | //then 125 | SoftAssertions.assertSoftly(softly -> { 126 | softly.assertThat(completedOrder.getOrderStatus()).isEqualTo(OrderStatus.COMPLETED); 127 | softly.assertThat(completedOrder.getCompletedDateTime()).isNotNull(); 128 | }); 129 | } 130 | } -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/documenation/DocumentFormatGenerator.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.documenation; 2 | 3 | import org.springframework.restdocs.snippet.Attributes; 4 | 5 | import java.util.Arrays; 6 | import java.util.List; 7 | import java.util.function.Function; 8 | import java.util.stream.Collectors; 9 | 10 | import static org.springframework.restdocs.snippet.Attributes.key; 11 | 12 | public class DocumentFormatGenerator { 13 | public DocumentFormatGenerator() { 14 | throw new UnsupportedOperationException("Util class."); 15 | } 16 | 17 | public static Attributes.Attribute required() { 18 | return key("required").value("true"); 19 | } 20 | 21 | public static Attributes.Attribute optional() { 22 | return key("required").value("false"); 23 | } 24 | 25 | public static Attributes.Attribute customFormat(String format) { 26 | return key("format").value(format); 27 | } 28 | 29 | public static Attributes.Attribute emptyFormat() { 30 | return customFormat(""); 31 | } 32 | 33 | public static Attributes.Attribute dateTimeFormat() { 34 | return key("format").value("yyyy-MM-dd HH:mm:ss"); 35 | } 36 | 37 | public static Attributes.Attribute dateFormat() { 38 | return key("format").value("yyyy-MM-dd"); 39 | } 40 | 41 | public static Attributes.Attribute timeFormat() { 42 | return key("format").value("HH:mm:ss"); 43 | } 44 | 45 | /** 46 | * Enum 타입 문자열 출력 47 | * 48 | * @param enumClass enum클래스 49 | * @param 대상타입 50 | * @return "* `A`" 51 | */ 52 | public static > String generatedEnums(Class enumClass) { 53 | return Arrays.stream(enumClass.getEnumConstants()) 54 | .map(el -> "* `" + el.name() + "`") 55 | .collect(Collectors.joining("\n")); 56 | } 57 | 58 | /** 59 | * Enum 타입 문자열 출력 60 | * 61 | * @param enumClass enum클래스 62 | * @param detailFun 문자열 출력 함수 (ex: Enum::getDescription) 63 | * @param 변환하고자 하는 Enum 타입 64 | * @return "A(a 설명),\n B(b 설명)" 65 | */ 66 | public static > Attributes.Attribute generateEnumAttrs(Class enumClass, Function detailFun) { 67 | var value = Arrays.stream(enumClass.getEnumConstants()) 68 | .map(el -> "* `" + el.name() + "`(" + detailFun.apply(el) + ")") 69 | .collect(Collectors.joining("\n")); 70 | return key("format").value(value); 71 | } 72 | 73 | /** 74 | * Enum 타입 중 일부만 목록으로 노출하고 싶을 떄 사용 75 | * 76 | * @param enumClassList 대상 enum클래스 목록 77 | * @param detailFun 문자열 출력함수 78 | * @param 변환하고자 하는 enum 타입 79 | * @return "* `A`(a 설명),\n* `B`(b 설명)" 80 | */ 81 | public static > Attributes.Attribute generateEnumListFormatAttribute(List enumClassList, Function detailFun) { 82 | var value = enumClassList.stream() 83 | .map(el -> "* `" + el.name() + "`(" + detailFun.apply(el) + ")") 84 | .collect(Collectors.joining("\n")); 85 | 86 | return key("format").value(value); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/documenation/DocumentFormatGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.documenation; 2 | 3 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductStatus; 4 | import org.junit.jupiter.api.DisplayName; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.util.List; 8 | 9 | import static com.kurly.tet.guide.springrestdocs.domain.product.ProductStatus.ACTIVATED; 10 | import static com.kurly.tet.guide.springrestdocs.domain.product.ProductStatus.CREATED; 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | 13 | /** 14 | * enum 타입 아스키독 목록 스니펫 사용 문자형식 생성 15 | */ 16 | @DisplayName("지정된 enum 타입을 아스키독문법 목록(
  • ) 형태로 출력") 17 | class DocumentFormatGeneratorTest { 18 | @DisplayName("속성값만 나열함") 19 | @Test 20 | void testGeneratedEnums() { 21 | var result = DocumentFormatGenerator.generatedEnums(ProductStatus.class); 22 | 23 | assertThat(result).isEqualTo(""" 24 | * `CREATED` 25 | * `ACTIVATED` 26 | * `ARCHIVED`"""); 27 | } 28 | 29 | @DisplayName("속성값(설명) 나열함") 30 | @Test 31 | void testGeneratedEnumAttr() { 32 | var attrs = DocumentFormatGenerator.generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription); 33 | 34 | assertThat(attrs.getValue()).isEqualTo(""" 35 | * `CREATED`(생성) 36 | * `ACTIVATED`(활성화) 37 | * `ARCHIVED`(보관처리됨)"""); 38 | } 39 | 40 | @DisplayName("지정된 속성값(설명) 나열함") 41 | @Test 42 | void testGeneratedEnumListFormat() { 43 | var failed = List.of(CREATED, ACTIVATED); 44 | 45 | var formatAttr = DocumentFormatGenerator.generateEnumListFormatAttribute(failed, ProductStatus::getDescription); 46 | 47 | assertThat(formatAttr.getValue()).isEqualTo( 48 | """ 49 | * `CREATED`(생성) 50 | * `ACTIVATED`(활성화)"""); 51 | } 52 | } -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/documenation/DocumentUtils.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.documenation; 2 | 3 | import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; 4 | import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; 5 | 6 | import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; 7 | 8 | public class DocumentUtils { 9 | public static OperationRequestPreprocessor getDocumentRequest() { 10 | return preprocessRequest( 11 | prettyPrint()); 12 | } 13 | 14 | public static OperationResponsePreprocessor getDocumentResponse() { 15 | return preprocessResponse(prettyPrint()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/documenation/MockMvcFactory.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.documenation; 2 | 3 | import com.kurly.tet.guide.springrestdocs.common.util.JsonUtils; 4 | import com.kurly.tet.guide.springrestdocs.common.util.LocalDateTimeUtils; 5 | import com.kurly.tet.guide.springrestdocs.common.util.LocalDateUtils; 6 | import com.kurly.tet.guide.springrestdocs.common.util.LocalTimeUtils; 7 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.advisor.ApiResponseWrappingAdvisor; 8 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.handler.GlobalRestControllerExceptionHandler; 9 | import org.springframework.core.convert.converter.Converter; 10 | import org.springframework.format.support.DefaultFormattingConversionService; 11 | import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; 12 | import org.springframework.restdocs.RestDocumentationContextProvider; 13 | import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; 14 | import org.springframework.test.web.servlet.MockMvc; 15 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 16 | import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; 17 | import org.springframework.web.filter.CharacterEncodingFilter; 18 | 19 | import java.nio.charset.StandardCharsets; 20 | import java.time.LocalDate; 21 | import java.time.LocalDateTime; 22 | import java.time.LocalTime; 23 | import java.time.format.DateTimeFormatter; 24 | 25 | public class MockMvcFactory { 26 | public static MockMvc getMockMvc(Object... controllers) { 27 | return getMockMvcBuilder(controllers).build(); 28 | } 29 | 30 | public static MockMvc getRestDocsMockMvc(RestDocumentationContextProvider restDocumentationContextProvider, String host, Object... controllers) { 31 | var documentationConfigurer = MockMvcRestDocumentation.documentationConfiguration(restDocumentationContextProvider); 32 | documentationConfigurer.uris().withScheme("https").withHost(host).withPort(443); 33 | 34 | return getMockMvcBuilder(controllers).apply(documentationConfigurer).build(); 35 | } 36 | 37 | private static StandaloneMockMvcBuilder getMockMvcBuilder(Object... controllers) { 38 | var conversionService = new DefaultFormattingConversionService(); 39 | conversionService.addConverter(new LocalDateTimeConverter()); 40 | conversionService.addConverter(new LocalDateConverter()); 41 | conversionService.addConverter(new LocalTimeConverter()); 42 | 43 | return MockMvcBuilders.standaloneSetup(controllers) 44 | .setControllerAdvice( 45 | new GlobalRestControllerExceptionHandler(), 46 | new ApiResponseWrappingAdvisor()) 47 | .setConversionService(conversionService) 48 | .setMessageConverters(new MappingJackson2HttpMessageConverter(JsonUtils.getMapper())) 49 | .addFilter(new CharacterEncodingFilter(StandardCharsets.UTF_8.name(), true)); 50 | } 51 | 52 | public static class LocalDateTimeConverter implements Converter { 53 | @Override 54 | public LocalDateTime convert(String source) { 55 | return LocalDateTimeUtils.toLocalDateTime(source); 56 | } 57 | } 58 | 59 | public static class LocalDateConverter implements Converter { 60 | 61 | @Override 62 | public LocalDate convert(String source) { 63 | return LocalDateUtils.toLocalDate(source); 64 | } 65 | } 66 | 67 | public static class LocalTimeConverter implements Converter { 68 | @Override 69 | public LocalTime convert(String source) { 70 | return LocalTimeUtils.toLocalTime(source); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/dto/PageResponseTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.dto; 2 | 3 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageRequest; 4 | import org.junit.jupiter.api.DisplayName; 5 | import org.junit.jupiter.params.ParameterizedTest; 6 | import org.junit.jupiter.params.provider.Arguments; 7 | import org.junit.jupiter.params.provider.MethodSource; 8 | 9 | import java.util.Collections; 10 | import java.util.List; 11 | import java.util.Optional; 12 | import java.util.stream.Stream; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | class PageResponseTest { 17 | @DisplayName("총갯수 계산") 18 | @MethodSource("caseCalculateTotal") 19 | @ParameterizedTest 20 | void test(List content, PageRequest pageRequest, long total, long expectTotalCount) { 21 | var totalCount = Optional.of(pageRequest) 22 | .filter(it -> !content.isEmpty()) 23 | .filter(it -> pageRequest.getOffset() + it.getSize() > total) 24 | .map(it -> pageRequest.getOffset() + content.size()) 25 | .orElse(total); 26 | 27 | assertThat(totalCount).isEqualTo(expectTotalCount); 28 | } 29 | 30 | private static Stream caseCalculateTotal() { 31 | return Stream.of( 32 | Arguments.of(Collections.emptyList(), PageRequest.of(0, 10), 0L, 0L), 33 | Arguments.of(List.of("1"), PageRequest.of(0, 10), 1L, 1L), 34 | Arguments.of(List.of("1", "2", "3", "4", "5", "6", "7", "8", "9"), PageRequest.of(0, 10), 9L, 9L), 35 | Arguments.of(List.of("11"), PageRequest.of(1, 10), 11L, 11L), 36 | Arguments.of(List.of("11", "12"), PageRequest.of(1, 10), 12L, 12L) 37 | ); 38 | } 39 | } -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/order/OrderRestControllerDocsTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.order; 2 | 3 | import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; 4 | import com.epages.restdocs.apispec.ResourceSnippetParameters; 5 | import com.kurly.tet.guide.springrestdocs.annotations.RestDocsTest; 6 | import com.kurly.tet.guide.springrestdocs.application.order.OrderFacade; 7 | import com.kurly.tet.guide.springrestdocs.common.util.JsonUtils; 8 | import com.kurly.tet.guide.springrestdocs.documenation.MockMvcFactory; 9 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderDto; 10 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderStatus; 11 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductStatus; 12 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageResponse; 13 | import org.junit.jupiter.api.DisplayName; 14 | import org.junit.jupiter.api.Test; 15 | import org.mockito.InjectMocks; 16 | import org.mockito.Mock; 17 | import org.mockito.Mockito; 18 | import org.springframework.http.MediaType; 19 | import org.springframework.restdocs.RestDocumentationContextProvider; 20 | import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; 21 | import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; 22 | import org.springframework.restdocs.payload.FieldDescriptor; 23 | import org.springframework.restdocs.request.ParameterDescriptor; 24 | import org.springframework.test.web.servlet.result.MockMvcResultHandlers; 25 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 26 | 27 | import java.util.UUID; 28 | 29 | import static com.epages.restdocs.apispec.ResourceDocumentation.resource; 30 | import static com.kurly.tet.guide.springrestdocs.config.Constant.*; 31 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentFormatGenerator.customFormat; 32 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentFormatGenerator.generateEnumAttrs; 33 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentUtils.getDocumentRequest; 34 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentUtils.getDocumentResponse; 35 | import static org.springframework.restdocs.payload.JsonFieldType.*; 36 | import static org.springframework.restdocs.payload.PayloadDocumentation.*; 37 | import static org.springframework.restdocs.request.RequestDocumentation.*; 38 | 39 | @DisplayName("주문API 문서화") 40 | @RestDocsTest 41 | class OrderRestControllerDocsTest { 42 | @Mock 43 | private OrderFacade orderFacade; 44 | @InjectMocks 45 | private OrderRestController controller; 46 | 47 | @DisplayName("검색: 주문정보 조회") 48 | @Test 49 | void test01(RestDocumentationContextProvider contextProvider) throws Exception { 50 | var responseSource = """ 51 | { 52 | "content":[ 53 | { 54 | "id":1, 55 | "memberNo":"1111", 56 | "orderNo":"202209251659441", 57 | "orderStatus":"COMPLETED", 58 | "orderStatusDescription":"배송완료", 59 | "products":[ 60 | { 61 | "id":1, 62 | "productName":"TEST2", 63 | "productNo":"00003", 64 | "productStatus":"CREATED", 65 | "created":"2022-09-25T16:59:23", 66 | "modified":"2022-09-25T16:59:23", 67 | "productStatusDescription":"생성" 68 | }, 69 | { 70 | "id":4, 71 | "productName":"TEST5", 72 | "productNo":"00006", 73 | "productStatus":"CREATED", 74 | "created":"2022-09-25T16:59:23", 75 | "modified":"2022-09-25T16:59:23", 76 | "productStatusDescription":"생성" 77 | } 78 | ], 79 | "orderDateTime":"2022-09-25T16:59:44", 80 | "paymentMoney":1000, 81 | "paymentDateTime":"2022-09-25T17:00:46", 82 | "shipmentDateTime":"2022-09-25T17:00:59", 83 | "completedDateTime":"2022-09-25T17:01:17" 84 | }, 85 | { 86 | "id":2, 87 | "memberNo":"1111", 88 | "orderNo":"202209251659492", 89 | "orderStatus":"SHIPPED", 90 | "orderStatusDescription":"출하완료 ", 91 | "products":[ 92 | { 93 | "id":4, 94 | "productName":"TEST5", 95 | "productNo":"00006", 96 | "productStatus":"CREATED", 97 | "created":"2022-09-25T16:59:23", 98 | "modified":"2022-09-25T16:59:23", 99 | "productStatusDescription":"생성" 100 | }, 101 | { 102 | "id":7, 103 | "productName":"TEST8", 104 | "productNo":"00009", 105 | "productStatus":"CREATED", 106 | "created":"2022-09-25T16:59:23", 107 | "modified":"2022-09-25T16:59:23", 108 | "productStatusDescription":"생성" 109 | } 110 | ], 111 | "orderDateTime":"2022-09-25T16:59:49", 112 | "paymentMoney":10001, 113 | "paymentDateTime":"2022-09-25T17:01:37", 114 | "shipmentDateTime":"2022-09-25T17:01:44", 115 | "completedDateTime":null 116 | }, 117 | { 118 | "id":3, 119 | "memberNo":"1111", 120 | "orderNo":"202209251659563", 121 | "orderStatus":"PAID", 122 | "orderStatusDescription":"결제완료", 123 | "products":[ 124 | { 125 | "id":7, 126 | "productName":"TEST8", 127 | "productNo":"00009", 128 | "productStatus":"CREATED", 129 | "created":"2022-09-25T16:59:23", 130 | "modified":"2022-09-25T16:59:23", 131 | "productStatusDescription":"생성" 132 | }, 133 | { 134 | "id":10, 135 | "productName":"TEST11", 136 | "productNo":"00012", 137 | "productStatus":"CREATED", 138 | "created":"2022-09-25T16:59:23", 139 | "modified":"2022-09-25T16:59:23", 140 | "productStatusDescription":"생성" 141 | } 142 | ], 143 | "orderDateTime":"2022-09-25T16:59:56", 144 | "paymentMoney":1003, 145 | "paymentDateTime":"2022-09-25T17:02:20", 146 | "shipmentDateTime":null, 147 | "completedDateTime":null 148 | }, 149 | { 150 | "id":4, 151 | "memberNo":"1111", 152 | "orderNo":"202209251700034", 153 | "orderStatus":"ORDERED", 154 | "orderStatusDescription":"주문완료", 155 | "products":[ 156 | { 157 | "id":4, 158 | "productName":"TEST5", 159 | "productNo":"00006", 160 | "productStatus":"CREATED", 161 | "created":"2022-09-25T16:59:23", 162 | "modified":"2022-09-25T16:59:23", 163 | "productStatusDescription":"생성" 164 | }, 165 | { 166 | "id":7, 167 | "productName":"TEST8", 168 | "productNo":"00009", 169 | "productStatus":"CREATED", 170 | "created":"2022-09-25T16:59:23", 171 | "modified":"2022-09-25T16:59:23", 172 | "productStatusDescription":"생성" 173 | }, 174 | { 175 | "id":10, 176 | "productName":"TEST11", 177 | "productNo":"00012", 178 | "productStatus":"CREATED", 179 | "created":"2022-09-25T16:59:23", 180 | "modified":"2022-09-25T16:59:23", 181 | "productStatusDescription":"생성" 182 | } 183 | ], 184 | "orderDateTime":"2022-09-25T17:00:03", 185 | "paymentMoney":null, 186 | "paymentDateTime":null, 187 | "shipmentDateTime":null, 188 | "completedDateTime":null 189 | } 190 | ], 191 | "pageRequest":{ 192 | "page":0, 193 | "size":1000 194 | }, 195 | "total":4, 196 | "totalPages":1, 197 | "hasNext":false, 198 | "hasPrevious":false, 199 | "isFirst":true, 200 | "isLast":true, 201 | "hasContent":true 202 | }"""; 203 | 204 | Mockito.when(orderFacade.search(Mockito.any(), Mockito.any())) 205 | .thenReturn(JsonUtils.fromJson(responseSource, PageResponse.class)); 206 | 207 | var parameterDescriptors = new ParameterDescriptor[]{ 208 | parameterWithName("page").description("페이지번호"), 209 | parameterWithName("size").description("페이지 건수"), 210 | parameterWithName("memberNo").description("회원번호"), 211 | parameterWithName("orderNo").description("주문번호") 212 | }; 213 | 214 | var responseFieldDescriptors = new FieldDescriptor[]{ 215 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 216 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 217 | fieldWithPath("data.content[].id").type(NUMBER).description("주문번호"), 218 | fieldWithPath("data.content[].memberNo").type(STRING).description("회원번호"), 219 | fieldWithPath("data.content[].orderNo").type(STRING).description("주문번호"), 220 | fieldWithPath("data.content[].orderStatus").type(STRING).attributes(generateEnumAttrs(OrderStatus.class, OrderStatus::getDescription)).description("주문상태"), 221 | fieldWithPath("data.content[].orderStatusDescription").type(STRING).description("주문상태설명"), 222 | fieldWithPath("data.content[].products[].id").type(NUMBER).description("상품일련번호"), 223 | fieldWithPath("data.content[].products[].productName").type(STRING).description("상품명"), 224 | fieldWithPath("data.content[].products[].productNo").type(STRING).description("상품번호"), 225 | fieldWithPath("data.content[].products[].productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 226 | fieldWithPath("data.content[].products[].productStatusDescription").type(STRING).description("상품상태설명"), 227 | fieldWithPath("data.content[].products[].created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 228 | fieldWithPath("data.content[].products[].modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시"), 229 | fieldWithPath("data.content[].orderDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("주문접수일시"), 230 | fieldWithPath("data.content[].paymentMoney").type(NUMBER).description("결제금액").optional(), 231 | fieldWithPath("data.content[].paymentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 232 | fieldWithPath("data.content[].shipmentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("출하완료일시").optional(), 233 | fieldWithPath("data.content[].completedDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 234 | fieldWithPath("data.pageRequest.page").type(NUMBER).description("페이지번호"), 235 | fieldWithPath("data.pageRequest.size").type(NUMBER).description("페이지크기"), 236 | fieldWithPath("data.total").type(NUMBER).description("데이터 전체갯수"), 237 | fieldWithPath("data.totalPages").type(NUMBER).description("전체 페이지갯수"), 238 | fieldWithPath("data.isFirst").type(BOOLEAN).description("시작페이지인가?"), 239 | fieldWithPath("data.isLast").type(BOOLEAN).description("마지막페이지인가?"), 240 | fieldWithPath("data.hasPrevious").type(BOOLEAN).description("앞페이지가있는가?"), 241 | fieldWithPath("data.hasNext").type(BOOLEAN).description("다음페이지가있는가?"), 242 | fieldWithPath("data.hasContent").type(BOOLEAN).description("컨텐츠가있는가?"), 243 | }; 244 | 245 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 246 | .perform(RestDocumentationRequestBuilders.get("/orders") 247 | .param("page", "0") 248 | .param("size", "20") 249 | .param("memberNo", "20220925163201") 250 | .param("orderNo", UUID.randomUUID().toString()) 251 | .contentType(MediaType.APPLICATION_JSON)) 252 | .andDo(MockMvcResultHandlers.print()) 253 | .andExpect(MockMvcResultMatchers.status().isOk()) 254 | //REST Docs 용 255 | .andDo(MockMvcRestDocumentation.document("get-v1-get-orders", 256 | getDocumentRequest(), 257 | getDocumentResponse(), 258 | requestParameters( 259 | parameterDescriptors 260 | ), 261 | responseFields( 262 | responseFieldDescriptors 263 | ) 264 | )) 265 | //OAS 3.0 - Swagger 266 | .andDo(MockMvcRestDocumentationWrapper.document("get-v1-get-orders", 267 | getDocumentRequest(), 268 | getDocumentResponse(), 269 | resource(ResourceSnippetParameters.builder() 270 | .requestParameters( 271 | parameterDescriptors 272 | ) 273 | .responseFields( 274 | responseFieldDescriptors 275 | ) 276 | .build()))); 277 | } 278 | 279 | @DisplayName("주문생성") 280 | @Test 281 | void test02(RestDocumentationContextProvider contextProvider) throws Exception { 282 | var createCommandJson = """ 283 | { 284 | "memberNo":"1111", 285 | "productIds":[1,4] 286 | } 287 | """; 288 | 289 | var requestFieldDescription = new FieldDescriptor[]{ 290 | fieldWithPath("memberNo").type(STRING).description("회원번호"), 291 | fieldWithPath("productIds").type(ARRAY).description("상품번호목록") 292 | }; 293 | 294 | var createdOrderJson = """ 295 | { 296 | "id":1, 297 | "memberNo":"1111", 298 | "orderNo":"202209251659441", 299 | "orderStatus":"ORDERED", 300 | "orderStatusDescription":"주문완료", 301 | "products":[ 302 | { 303 | "id":1, 304 | "productName":"TEST2", 305 | "productNo":"00003", 306 | "productStatus":"CREATED", 307 | "created":"2022-09-25T16:59:23", 308 | "modified":"2022-09-25T16:59:23", 309 | "productStatusDescription":"생성" 310 | }, 311 | { 312 | "id":4, 313 | "productName":"TEST5", 314 | "productNo":"00006", 315 | "productStatus":"CREATED", 316 | "created":"2022-09-25T16:59:23", 317 | "modified":"2022-09-25T16:59:23", 318 | "productStatusDescription":"생성" 319 | } 320 | ], 321 | "orderDateTime":"2022-09-25T16:59:44", 322 | "paymentMoney":null, 323 | "paymentDateTime":null, 324 | "shipmentDateTime":null, 325 | "completedDateTime":null 326 | }"""; 327 | 328 | Mockito.when(orderFacade.create(Mockito.any())) 329 | .thenReturn(JsonUtils.fromJson(createdOrderJson, OrderDto.class)); 330 | 331 | var responseFieldDescription = new FieldDescriptor[]{ 332 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 333 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 334 | fieldWithPath("data.id").type(NUMBER).description("주문번호"), 335 | fieldWithPath("data.memberNo").type(STRING).description("회원번호"), 336 | fieldWithPath("data.orderNo").type(STRING).description("주문번호"), 337 | fieldWithPath("data.orderStatus").type(STRING).attributes(generateEnumAttrs(OrderStatus.class, OrderStatus::getDescription)).description("주문상태"), 338 | fieldWithPath("data.orderStatusDescription").type(STRING).description("주문상태설명"), 339 | fieldWithPath("data.products[].id").type(NUMBER).description("상품일련번호"), 340 | fieldWithPath("data.products[].productName").type(STRING).description("상품명"), 341 | fieldWithPath("data.products[].productNo").type(STRING).description("상품번호"), 342 | fieldWithPath("data.products[].productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 343 | fieldWithPath("data.products[].productStatusDescription").type(STRING).description("상품상태설명"), 344 | fieldWithPath("data.products[].created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 345 | fieldWithPath("data.products[].modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시"), 346 | fieldWithPath("data.orderDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("주문접수일시"), 347 | fieldWithPath("data.paymentMoney").type(NUMBER).description("결제금액").optional(), 348 | fieldWithPath("data.paymentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 349 | fieldWithPath("data.shipmentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("출하완료일시").optional(), 350 | fieldWithPath("data.completedDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 351 | }; 352 | 353 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 354 | .perform(RestDocumentationRequestBuilders.post("/orders") 355 | .contentType(MediaType.APPLICATION_JSON) 356 | .content(createCommandJson) 357 | ) 358 | .andDo(MockMvcResultHandlers.print()) 359 | .andExpect(MockMvcResultMatchers.status().isCreated()) 360 | //REST Docs 용 361 | .andDo(MockMvcRestDocumentation.document("post-v1-create-order", 362 | getDocumentRequest(), 363 | getDocumentResponse(), 364 | requestFields( 365 | requestFieldDescription 366 | ), 367 | responseFields( 368 | responseFieldDescription 369 | ) 370 | )) 371 | //OAS 3.0 - Swagger 372 | .andDo(MockMvcRestDocumentationWrapper.document("post-v1-create-order", 373 | getDocumentRequest(), 374 | getDocumentResponse(), 375 | resource(ResourceSnippetParameters.builder() 376 | .requestFields( 377 | requestFieldDescription 378 | ) 379 | .responseFields( 380 | responseFieldDescription 381 | ) 382 | .build()))); 383 | } 384 | 385 | @DisplayName("결제완료") 386 | @Test 387 | void test03(RestDocumentationContextProvider contextProvider) throws Exception { 388 | var orderNo = "797a1eb9-d6b8-4875-9ac6-4cbeac65ba40"; 389 | var commandJson = """ 390 | { 391 | "paymentMoney": 1000 392 | } 393 | """; 394 | var expectedContent = "{\"code\":\"0000\",\"message\":\"정상\",\"data\":{\"id\":1,\"memberNo\":\"1111\",\"orderNo\":\"202209251659441\",\"orderStatus\":\"PAID\",\"products\":[{\"id\":1,\"productName\":\"TEST2\",\"productNo\":\"00003\",\"productStatus\":\"CREATED\",\"created\":\"2022-09-25T16:59:23\",\"modified\":\"2022-09-25T16:59:23\",\"productStatusDescription\":\"생성\"},{\"id\":4,\"productName\":\"TEST5\",\"productNo\":\"00006\",\"productStatus\":\"CREATED\",\"created\":\"2022-09-25T16:59:23\",\"modified\":\"2022-09-25T16:59:23\",\"productStatusDescription\":\"생성\"}],\"orderDateTime\":\"2022-09-25T16:59:44\",\"paymentMoney\":1000,\"paymentDateTime\":\"2022-09-25T17:00:46\",\"shipmentDateTime\":null,\"completedDateTime\":null,\"orderStatusDescription\":\"결제완료\"}}"; 395 | var parameterDescriptor = parameterWithName("orderNo").description("주문번호"); 396 | var requestFieldDescription = new FieldDescriptor[]{ 397 | fieldWithPath("paymentMoney").type(NUMBER).description("결제금액") 398 | }; 399 | 400 | var orderJson = """ 401 | { 402 | "id":1, 403 | "memberNo":"1111", 404 | "orderNo":"202209251659441", 405 | "orderStatus":"PAID", 406 | "orderStatusDescription":"결제완료", 407 | "products":[ 408 | { 409 | "id":1, 410 | "productName":"TEST2", 411 | "productNo":"00003", 412 | "productStatus":"CREATED", 413 | "created":"2022-09-25T16:59:23", 414 | "modified":"2022-09-25T16:59:23", 415 | "productStatusDescription":"생성" 416 | }, 417 | { 418 | "id":4, 419 | "productName":"TEST5", 420 | "productNo":"00006", 421 | "productStatus":"CREATED", 422 | "created":"2022-09-25T16:59:23", 423 | "modified":"2022-09-25T16:59:23", 424 | "productStatusDescription":"생성" 425 | } 426 | ], 427 | "orderDateTime":"2022-09-25T16:59:44", 428 | "paymentMoney":1000, 429 | "paymentDateTime":"2022-09-25T17:00:46", 430 | "shipmentDateTime":null, 431 | "completedDateTime":null 432 | }"""; 433 | 434 | Mockito.when(orderFacade.payment(Mockito.any(), Mockito.any())) 435 | .thenReturn(JsonUtils.fromJson(orderJson, OrderDto.class)); 436 | 437 | var responseFieldDescription = new FieldDescriptor[]{ 438 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 439 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 440 | fieldWithPath("data.id").type(NUMBER).description("주문번호"), 441 | fieldWithPath("data.memberNo").type(STRING).description("회원번호"), 442 | fieldWithPath("data.orderNo").type(STRING).description("주문번호"), 443 | fieldWithPath("data.orderStatus").type(STRING).attributes(generateEnumAttrs(OrderStatus.class, OrderStatus::getDescription)).description("주문상태"), 444 | fieldWithPath("data.orderStatusDescription").type(STRING).description("주문상태설명"), 445 | fieldWithPath("data.products[].id").type(NUMBER).description("상품일련번호"), 446 | fieldWithPath("data.products[].productName").type(STRING).description("상품명"), 447 | fieldWithPath("data.products[].productNo").type(STRING).description("상품번호"), 448 | fieldWithPath("data.products[].productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 449 | fieldWithPath("data.products[].productStatusDescription").type(STRING).description("상품상태설명"), 450 | fieldWithPath("data.products[].created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 451 | fieldWithPath("data.products[].modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시"), 452 | fieldWithPath("data.orderDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("주문접수일시"), 453 | fieldWithPath("data.paymentMoney").type(NUMBER).description("결제금액").optional(), 454 | fieldWithPath("data.paymentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 455 | fieldWithPath("data.shipmentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("출하완료일시").optional(), 456 | fieldWithPath("data.completedDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 457 | }; 458 | 459 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 460 | .perform(RestDocumentationRequestBuilders.put("/orders/{orderNo}/payment", orderNo) 461 | .contentType(MediaType.APPLICATION_JSON) 462 | .content(commandJson) 463 | ) 464 | .andDo(MockMvcResultHandlers.print()) 465 | .andExpect(MockMvcResultMatchers.status().isOk()) 466 | .andExpect(MockMvcResultMatchers.content().string(expectedContent)) 467 | //REST Docs 용 468 | .andDo(MockMvcRestDocumentation.document("put-v1-payment-order", 469 | getDocumentRequest(), 470 | getDocumentResponse(), 471 | pathParameters( 472 | parameterDescriptor 473 | ), 474 | requestFields( 475 | requestFieldDescription 476 | ), 477 | responseFields( 478 | responseFieldDescription 479 | ) 480 | )) 481 | //OAS 3.0 - Swagger 482 | .andDo(MockMvcRestDocumentationWrapper.document("put-v1-payment-order", 483 | getDocumentRequest(), 484 | getDocumentResponse(), 485 | resource(ResourceSnippetParameters.builder() 486 | .pathParameters( 487 | parameterDescriptor 488 | ) 489 | .requestFields( 490 | requestFieldDescription 491 | ) 492 | .responseFields( 493 | responseFieldDescription 494 | ) 495 | .build()))); 496 | } 497 | 498 | @DisplayName("주문상세조회") 499 | @Test 500 | void test06(RestDocumentationContextProvider contextProvider) throws Exception { 501 | var orderNo = "797a1eb9-d6b8-4875-9ac6-4cbeac65ba40"; 502 | var expectedContent = "{\"code\":\"0000\",\"message\":\"정상\",\"data\":{\"id\":1,\"memberNo\":\"1111\",\"orderNo\":\"202209251659441\",\"orderStatus\":\"PAID\",\"products\":[{\"id\":1,\"productName\":\"TEST2\",\"productNo\":\"00003\",\"productStatus\":\"CREATED\",\"created\":\"2022-09-25T16:59:23\",\"modified\":\"2022-09-25T16:59:23\",\"productStatusDescription\":\"생성\"},{\"id\":4,\"productName\":\"TEST5\",\"productNo\":\"00006\",\"productStatus\":\"CREATED\",\"created\":\"2022-09-25T16:59:23\",\"modified\":\"2022-09-25T16:59:23\",\"productStatusDescription\":\"생성\"}],\"orderDateTime\":\"2022-09-25T16:59:44\",\"paymentMoney\":1000,\"paymentDateTime\":\"2022-09-25T17:00:46\",\"shipmentDateTime\":null,\"completedDateTime\":null,\"orderStatusDescription\":\"결제완료\"}}"; 503 | var parameterDescriptor = parameterWithName("orderNo").description("주문번호"); 504 | 505 | var orderJson = """ 506 | { 507 | "id":1, 508 | "memberNo":"1111", 509 | "orderNo":"202209251659441", 510 | "orderStatus":"PAID", 511 | "orderStatusDescription":"결제완료", 512 | "products":[ 513 | { 514 | "id":1, 515 | "productName":"TEST2", 516 | "productNo":"00003", 517 | "productStatus":"CREATED", 518 | "created":"2022-09-25T16:59:23", 519 | "modified":"2022-09-25T16:59:23", 520 | "productStatusDescription":"생성" 521 | }, 522 | { 523 | "id":4, 524 | "productName":"TEST5", 525 | "productNo":"00006", 526 | "productStatus":"CREATED", 527 | "created":"2022-09-25T16:59:23", 528 | "modified":"2022-09-25T16:59:23", 529 | "productStatusDescription":"생성" 530 | } 531 | ], 532 | "orderDateTime":"2022-09-25T16:59:44", 533 | "paymentMoney":1000, 534 | "paymentDateTime":"2022-09-25T17:00:46", 535 | "shipmentDateTime":null, 536 | "completedDateTime":null 537 | }"""; 538 | 539 | Mockito.when(orderFacade.findByOrderNo(Mockito.any())) 540 | .thenReturn(JsonUtils.fromJson(orderJson, OrderDto.class)); 541 | 542 | var responseFieldDescription = new FieldDescriptor[]{ 543 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 544 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 545 | fieldWithPath("data.id").type(NUMBER).description("주문번호"), 546 | fieldWithPath("data.memberNo").type(STRING).description("회원번호"), 547 | fieldWithPath("data.orderNo").type(STRING).description("주문번호"), 548 | fieldWithPath("data.orderStatus").type(STRING).attributes(generateEnumAttrs(OrderStatus.class, OrderStatus::getDescription)).description("주문상태"), 549 | fieldWithPath("data.orderStatusDescription").type(STRING).description("주문상태설명"), 550 | fieldWithPath("data.products[].id").type(NUMBER).description("상품일련번호"), 551 | fieldWithPath("data.products[].productName").type(STRING).description("상품명"), 552 | fieldWithPath("data.products[].productNo").type(STRING).description("상품번호"), 553 | fieldWithPath("data.products[].productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 554 | fieldWithPath("data.products[].productStatusDescription").type(STRING).description("상품상태설명"), 555 | fieldWithPath("data.products[].created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 556 | fieldWithPath("data.products[].modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시"), 557 | fieldWithPath("data.orderDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("주문접수일시"), 558 | fieldWithPath("data.paymentMoney").type(NUMBER).description("결제금액").optional(), 559 | fieldWithPath("data.paymentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 560 | fieldWithPath("data.shipmentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("출하완료일시").optional(), 561 | fieldWithPath("data.completedDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 562 | }; 563 | 564 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 565 | .perform(RestDocumentationRequestBuilders.get("/orders/{orderNo}", orderNo) 566 | .contentType(MediaType.APPLICATION_JSON) 567 | ) 568 | .andDo(MockMvcResultHandlers.print()) 569 | .andExpect(MockMvcResultMatchers.status().isOk()) 570 | .andExpect(MockMvcResultMatchers.content().string(expectedContent)) 571 | //REST Docs 용 572 | .andDo(MockMvcRestDocumentation.document("get-v1-get-order", 573 | getDocumentRequest(), 574 | getDocumentResponse(), 575 | pathParameters( 576 | parameterDescriptor 577 | ), 578 | responseFields( 579 | responseFieldDescription 580 | ) 581 | )) 582 | //OAS 3.0 - Swagger 583 | .andDo(MockMvcRestDocumentationWrapper.document("get-v1-get-order", 584 | getDocumentRequest(), 585 | getDocumentResponse(), 586 | resource(ResourceSnippetParameters.builder() 587 | .pathParameters( 588 | parameterDescriptor 589 | ) 590 | .responseFields( 591 | responseFieldDescription 592 | ) 593 | .build()))); 594 | } 595 | 596 | @DisplayName("출하완료") 597 | @Test 598 | void test04(RestDocumentationContextProvider contextProvider) throws Exception { 599 | var orderNo = "797a1eb9-d6b8-4875-9ac6-4cbeac65ba40"; 600 | 601 | var orderJson = """ 602 | { 603 | "id":1, 604 | "memberNo":"1111", 605 | "orderNo":"202209251659441", 606 | "orderStatus":"SHIPPED", 607 | "orderStatusDescription":"출하완료", 608 | "products":[ 609 | { 610 | "id":1, 611 | "productName":"TEST2", 612 | "productNo":"00003", 613 | "productStatus":"CREATED", 614 | "created":"2022-09-25T16:59:23", 615 | "modified":"2022-09-25T16:59:23", 616 | "productStatusDescription":"생성" 617 | }, 618 | { 619 | "id":4, 620 | "productName":"TEST5", 621 | "productNo":"00006", 622 | "productStatus":"CREATED", 623 | "created":"2022-09-25T16:59:23", 624 | "modified":"2022-09-25T16:59:23", 625 | "productStatusDescription":"생성" 626 | } 627 | ], 628 | "orderDateTime":"2022-09-25T16:59:44", 629 | "paymentMoney":1000, 630 | "paymentDateTime":"2022-09-25T17:00:46", 631 | "shipmentDateTime":"2022-09-25T17:00:50", 632 | "completedDateTime":null 633 | }"""; 634 | 635 | Mockito.when(orderFacade.shipping(Mockito.any())) 636 | .thenReturn(JsonUtils.fromJson(orderJson, OrderDto.class)); 637 | 638 | var parameterDescriptor = parameterWithName("orderNo").description("주문번호"); 639 | 640 | var responseFieldDescription = new FieldDescriptor[]{ 641 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 642 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 643 | fieldWithPath("data.id").type(NUMBER).description("주문번호"), 644 | fieldWithPath("data.memberNo").type(STRING).description("회원번호"), 645 | fieldWithPath("data.orderNo").type(STRING).description("주문번호"), 646 | fieldWithPath("data.orderStatus").type(STRING).attributes(generateEnumAttrs(OrderStatus.class, OrderStatus::getDescription)).description("주문상태"), 647 | fieldWithPath("data.orderStatusDescription").type(STRING).description("주문상태설명"), 648 | fieldWithPath("data.products[].id").type(NUMBER).description("상품일련번호"), 649 | fieldWithPath("data.products[].productName").type(STRING).description("상품명"), 650 | fieldWithPath("data.products[].productNo").type(STRING).description("상품번호"), 651 | fieldWithPath("data.products[].productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 652 | fieldWithPath("data.products[].productStatusDescription").type(STRING).description("상품상태설명"), 653 | fieldWithPath("data.products[].created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 654 | fieldWithPath("data.products[].modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시"), 655 | fieldWithPath("data.orderDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("주문접수일시"), 656 | fieldWithPath("data.paymentMoney").type(NUMBER).description("결제금액").optional(), 657 | fieldWithPath("data.paymentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 658 | fieldWithPath("data.shipmentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("출하완료일시").optional(), 659 | fieldWithPath("data.completedDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 660 | }; 661 | 662 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 663 | .perform(RestDocumentationRequestBuilders.put("/orders/{orderNo}/shipping", orderNo) 664 | .contentType(MediaType.APPLICATION_JSON) 665 | ) 666 | .andDo(MockMvcResultHandlers.print()) 667 | .andExpect(MockMvcResultMatchers.status().isOk()) 668 | //REST Docs 용 669 | .andDo(MockMvcRestDocumentation.document("put-v1-shipping-order", 670 | getDocumentRequest(), 671 | getDocumentResponse(), 672 | pathParameters( 673 | parameterDescriptor 674 | ), 675 | responseFields( 676 | responseFieldDescription 677 | ) 678 | )) 679 | //OAS 3.0 - Swagger 680 | .andDo(MockMvcRestDocumentationWrapper.document("put-v1-shipping-order", 681 | getDocumentRequest(), 682 | getDocumentResponse(), 683 | resource(ResourceSnippetParameters.builder() 684 | .pathParameters( 685 | parameterDescriptor 686 | ) 687 | .responseFields( 688 | responseFieldDescription 689 | ) 690 | .build()))); 691 | } 692 | 693 | @DisplayName("배송완료") 694 | @Test 695 | void test05(RestDocumentationContextProvider contextProvider) throws Exception { 696 | var orderNo = "797a1eb9-d6b8-4875-9ac6-4cbeac65ba40"; 697 | 698 | var orderJson = """ 699 | { 700 | "id":1, 701 | "memberNo":"1111", 702 | "orderNo":"202209251659441", 703 | "orderStatus":"COMPLETED", 704 | "orderStatusDescription":"배송완료", 705 | "products":[ 706 | { 707 | "id":1, 708 | "productName":"TEST2", 709 | "productNo":"00003", 710 | "productStatus":"CREATED", 711 | "created":"2022-09-25T16:59:23", 712 | "modified":"2022-09-25T16:59:23", 713 | "productStatusDescription":"생성" 714 | }, 715 | { 716 | "id":4, 717 | "productName":"TEST5", 718 | "productNo":"00006", 719 | "productStatus":"CREATED", 720 | "created":"2022-09-25T16:59:23", 721 | "modified":"2022-09-25T16:59:23", 722 | "productStatusDescription":"생성" 723 | } 724 | ], 725 | "orderDateTime":"2022-09-25T16:59:44", 726 | "paymentMoney":1000, 727 | "paymentDateTime":"2022-09-25T17:00:46", 728 | "shipmentDateTime":"2022-09-25T17:00:50", 729 | "completedDateTime":"2022-09-26T17:00:50" 730 | }"""; 731 | 732 | Mockito.when(orderFacade.complete(Mockito.any())) 733 | .thenReturn(JsonUtils.fromJson(orderJson, OrderDto.class)); 734 | 735 | var parameterDescriptor = parameterWithName("orderNo").description("주문번호"); 736 | 737 | var responseFieldDescription = new FieldDescriptor[]{ 738 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 739 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 740 | fieldWithPath("data.id").type(NUMBER).description("주문번호"), 741 | fieldWithPath("data.memberNo").type(STRING).description("회원번호"), 742 | fieldWithPath("data.orderNo").type(STRING).description("주문번호"), 743 | fieldWithPath("data.orderStatus").type(STRING).attributes(generateEnumAttrs(OrderStatus.class, OrderStatus::getDescription)).description("주문상태"), 744 | fieldWithPath("data.orderStatusDescription").type(STRING).description("주문상태설명"), 745 | fieldWithPath("data.products[].id").type(NUMBER).description("상품일련번호"), 746 | fieldWithPath("data.products[].productName").type(STRING).description("상품명"), 747 | fieldWithPath("data.products[].productNo").type(STRING).description("상품번호"), 748 | fieldWithPath("data.products[].productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 749 | fieldWithPath("data.products[].productStatusDescription").type(STRING).description("상품상태설명"), 750 | fieldWithPath("data.products[].created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 751 | fieldWithPath("data.products[].modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시"), 752 | fieldWithPath("data.orderDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("주문접수일시"), 753 | fieldWithPath("data.paymentMoney").type(NUMBER).description("결제금액").optional(), 754 | fieldWithPath("data.paymentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 755 | fieldWithPath("data.shipmentDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("출하완료일시").optional(), 756 | fieldWithPath("data.completedDateTime").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("결제완료일시").optional(), 757 | }; 758 | 759 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 760 | .perform(RestDocumentationRequestBuilders.put("/orders/{orderNo}/complete", orderNo) 761 | .contentType(MediaType.APPLICATION_JSON) 762 | ) 763 | .andDo(MockMvcResultHandlers.print()) 764 | .andExpect(MockMvcResultMatchers.status().isOk()) 765 | //REST Docs 용 766 | .andDo(MockMvcRestDocumentation.document("put-v1-complete-order", 767 | getDocumentRequest(), 768 | getDocumentResponse(), 769 | pathParameters( 770 | parameterDescriptor 771 | ), 772 | responseFields( 773 | responseFieldDescription 774 | ) 775 | )) 776 | //OAS 3.0 - Swagger 777 | .andDo(MockMvcRestDocumentationWrapper.document("put-v1-complete-order", 778 | getDocumentRequest(), 779 | getDocumentResponse(), 780 | resource(ResourceSnippetParameters.builder() 781 | .pathParameters( 782 | parameterDescriptor 783 | ) 784 | .responseFields( 785 | responseFieldDescription 786 | ) 787 | .build()))); 788 | } 789 | } 790 | -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/order/OrderRestControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.order; 2 | 3 | import com.kurly.tet.guide.springrestdocs.application.order.OrderFacade; 4 | import com.kurly.tet.guide.springrestdocs.domain.exception.OrderNotFoundException; 5 | import com.kurly.tet.guide.springrestdocs.domain.exception.ProductNotFoundException; 6 | import com.kurly.tet.guide.springrestdocs.domain.order.OrderDto; 7 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 8 | import org.junit.jupiter.api.DisplayName; 9 | import org.junit.jupiter.api.Test; 10 | import org.mockito.Mockito; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 13 | import org.springframework.boot.test.mock.mockito.MockBean; 14 | import org.springframework.http.MediaType; 15 | import org.springframework.test.web.servlet.MockMvc; 16 | import org.springframework.test.web.servlet.result.MockMvcResultHandlers; 17 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 18 | 19 | import java.util.List; 20 | import java.util.UUID; 21 | 22 | import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; 23 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 24 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; 25 | 26 | @DisplayName("주문API") 27 | @WebMvcTest({OrderRestController.class}) 28 | class OrderRestControllerTest { 29 | @Autowired 30 | private MockMvc mockMvc; 31 | @MockBean 32 | private OrderFacade orderFacade; 33 | 34 | @DisplayName("검색: 최대조회크기(size, 1000) 초과하는 경우 400 오류") 35 | @Test 36 | void testSearchWhenOverMax() throws Exception { 37 | this.mockMvc.perform( 38 | get("/orders") 39 | .contentType(MediaType.APPLICATION_JSON) 40 | .param("size", "1001") 41 | ) 42 | .andDo(MockMvcResultHandlers.print()) 43 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 44 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}")); 45 | } 46 | 47 | @DisplayName("생성: 회원번호(memberNo)를 누락한 경우 400 오류") 48 | @Test 49 | void testCreateOrderWhenMissingMemberNo() throws Exception { 50 | var requestContent = """ 51 | { 52 | "memberNo":"", 53 | "ids":[1] 54 | } 55 | """; 56 | this.mockMvc.perform( 57 | post("/orders") 58 | .contentType(MediaType.APPLICATION_JSON) 59 | .content(requestContent) 60 | ) 61 | .andDo(MockMvcResultHandlers.print()) 62 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 63 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}")); 64 | } 65 | 66 | @DisplayName("생성: 회원번호(memberNo)를 누락한 경우 400 오류") 67 | @Test 68 | void testCreateOrderWhenEmptyids() throws Exception { 69 | var requestContent = """ 70 | { 71 | "memberNo":"202209251508", 72 | "ids":[] 73 | } 74 | """; 75 | this.mockMvc.perform( 76 | post("/orders") 77 | .contentType(MediaType.APPLICATION_JSON) 78 | .content(requestContent) 79 | ) 80 | .andDo(MockMvcResultHandlers.print()) 81 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 82 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}")); 83 | } 84 | 85 | @DisplayName("생성: 주문 정상생성") 86 | @Test 87 | void testCreate() throws Exception { 88 | var products = List.of(new ProductDto(1L, "Product1", "P0001")); 89 | var orderDto = new OrderDto(1L, "202209251508", "TX-00001", products); 90 | Mockito.when(orderFacade.create(Mockito.any())) 91 | .thenReturn(orderDto); 92 | 93 | var requestContent = """ 94 | { 95 | "memberNo":"202209251508", 96 | "productIds":[1] 97 | } 98 | """; 99 | this.mockMvc.perform( 100 | post("/orders") 101 | .contentType(MediaType.APPLICATION_JSON) 102 | .content(requestContent) 103 | ) 104 | .andDo(MockMvcResultHandlers.print()) 105 | .andExpect(MockMvcResultMatchers.status().isCreated()); 106 | } 107 | 108 | @DisplayName("조회: 요청정보를 찾지 못하는 경우 404 오류") 109 | @Test 110 | void testFindByOrderNoWhenNotFound() throws Exception { 111 | var orderNo = "797a1eb9-d6b8-4875-9ac6-4cbeac65ba40"; 112 | 113 | Mockito.when(orderFacade.findByOrderNo(orderNo)) 114 | .thenThrow(new OrderNotFoundException(orderNo)); 115 | 116 | this.mockMvc.perform( 117 | get("/orders/{orderNo}", orderNo) 118 | .contentType(MediaType.APPLICATION_JSON) 119 | ) 120 | .andDo(MockMvcResultHandlers.print()) 121 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 122 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C404\",\"message\":\"주문(주문번호: 797a1eb9-d6b8-4875-9ac6-4cbeac65ba40)을 찾을 수 없습니다.\",\"data\":null}")); 123 | } 124 | 125 | @DisplayName("결제: 결제금액이 누락된 경우") 126 | @Test 127 | void testPayment() throws Exception { 128 | var orderNo = "797a1eb9-d6b8-4875-9ac6-4cbeac65ba40"; 129 | 130 | String modifyContent = """ 131 | { 132 | "paymentMoney":null // 상품명 누락 133 | } 134 | """; 135 | 136 | this.mockMvc.perform( 137 | put("/orders/{orderNo}/payment", orderNo) 138 | .contentType(MediaType.APPLICATION_JSON) 139 | .content(modifyContent) 140 | ) 141 | .andDo(MockMvcResultHandlers.print()) 142 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 143 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}")); 144 | } 145 | 146 | @DisplayName("출하: 주문번호 누락된 경우") 147 | @Test 148 | void testShipping() throws Exception { 149 | var orderNo = "797a1eb9-d6b8-4875-9ac6-4cbeac65ba40"; 150 | 151 | Mockito.when(orderFacade.shipping(orderNo)) 152 | .thenThrow(new OrderNotFoundException(orderNo)); 153 | 154 | this.mockMvc.perform( 155 | put("/orders/{orderNo}/shipping", orderNo) 156 | .contentType(MediaType.APPLICATION_JSON) 157 | ) 158 | .andDo(MockMvcResultHandlers.print()) 159 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 160 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C404\",\"message\":\"주문(주문번호: 797a1eb9-d6b8-4875-9ac6-4cbeac65ba40)을 찾을 수 없습니다.\",\"data\":null}")); 161 | } 162 | } -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/product/ProductRestControllerDocsTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.product; 2 | 3 | import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; 4 | import com.epages.restdocs.apispec.ResourceSnippetParameters; 5 | import com.kurly.tet.guide.springrestdocs.annotations.RestDocsTest; 6 | import com.kurly.tet.guide.springrestdocs.application.product.ProductFacade; 7 | import com.kurly.tet.guide.springrestdocs.common.util.JsonUtils; 8 | import com.kurly.tet.guide.springrestdocs.documenation.MockMvcFactory; 9 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductDto; 10 | import com.kurly.tet.guide.springrestdocs.domain.product.ProductStatus; 11 | import com.kurly.tet.guide.springrestdocs.infrastructure.web.common.dto.PageResponse; 12 | import org.junit.jupiter.api.DisplayName; 13 | import org.junit.jupiter.api.Test; 14 | import org.mockito.InjectMocks; 15 | import org.mockito.Mock; 16 | import org.mockito.Mockito; 17 | import org.springframework.http.MediaType; 18 | import org.springframework.restdocs.RestDocumentationContextProvider; 19 | import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; 20 | import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; 21 | import org.springframework.restdocs.payload.FieldDescriptor; 22 | import org.springframework.restdocs.request.ParameterDescriptor; 23 | import org.springframework.test.web.servlet.result.MockMvcResultHandlers; 24 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 25 | 26 | import static com.epages.restdocs.apispec.ResourceDocumentation.resource; 27 | import static com.kurly.tet.guide.springrestdocs.config.Constant.FORMAT_LOCAL_DATE_TIME; 28 | import static com.kurly.tet.guide.springrestdocs.config.Constant.HOST_LOCAL; 29 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentFormatGenerator.customFormat; 30 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentFormatGenerator.generateEnumAttrs; 31 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentUtils.getDocumentRequest; 32 | import static com.kurly.tet.guide.springrestdocs.documenation.DocumentUtils.getDocumentResponse; 33 | import static org.springframework.restdocs.payload.JsonFieldType.*; 34 | import static org.springframework.restdocs.payload.PayloadDocumentation.*; 35 | import static org.springframework.restdocs.request.RequestDocumentation.*; 36 | 37 | @DisplayName("상품API 문서화") 38 | @RestDocsTest 39 | class ProductRestControllerDocsTest { 40 | @Mock 41 | private ProductFacade productFacade; 42 | @InjectMocks 43 | private ProductRestController controller; 44 | 45 | @DisplayName("검색: 상품정보") 46 | @Test 47 | void testSearchProduct(RestDocumentationContextProvider contextProvider) throws Exception { 48 | 49 | var responseSource = """ 50 | { 51 | "content": [ 52 | { 53 | "id": 1, 54 | "productName": "TEST2", 55 | "productNo": "00003", 56 | "productStatus": "CREATED", 57 | "productStatusDescription": "생성", 58 | "created": "2022-09-22T18:41:52", 59 | "modified": "2022-09-22T18:41:52" 60 | }, 61 | { 62 | "id": 4, 63 | "productName": "TEST5", 64 | "productNo": "00006", 65 | "productStatus": "CREATED", 66 | "productStatusDescription": "생성", 67 | "created": "2022-09-22T18:41:52", 68 | "modified": "2022-09-22T18:41:52" 69 | }, 70 | { 71 | "id": 7, 72 | "productName": "TEST8", 73 | "productNo": "00009", 74 | "productStatus": "CREATED", 75 | "productStatusDescription": "생성", 76 | "created": "2022-09-22T18:41:52", 77 | "modified": "2022-09-22T18:41:52" 78 | }, 79 | { 80 | "id": 10, 81 | "productName": "TEST11", 82 | "productNo": "00012", 83 | "productStatus": "ACTIVATED", 84 | "productStatusDescription": "활성화됨", 85 | "created": "2022-09-22T18:41:52", 86 | "modified": "2022-09-22T18:41:52" 87 | }, 88 | { 89 | "id": 13, 90 | "productName": "TEST14", 91 | "productNo": "00015", 92 | "productStatus": "ARCHIVED", 93 | "productStatusDescription": "보관처리됨", 94 | "created": "2022-09-22T18:41:52", 95 | "modified": "2022-09-22T18:41:52" 96 | } 97 | ], 98 | "pageRequest": { 99 | "page": 0, 100 | "size": 1000 101 | }, 102 | "total": 5, 103 | "totalPages": 1, 104 | "hasNext": false, 105 | "hasPrevious": false, 106 | "hasContent": true, 107 | "isFirst": true, 108 | "isLast": true 109 | }"""; 110 | 111 | Mockito.when(productFacade.search(Mockito.any(), Mockito.any())) 112 | .thenReturn(JsonUtils.fromJson(responseSource, PageResponse.class)); 113 | 114 | var parameterDescriptors = new ParameterDescriptor[]{ 115 | parameterWithName("page").description("조회페이지"), 116 | parameterWithName("size").description("페이지내 건수"), 117 | parameterWithName("productName").description("상품명"), 118 | parameterWithName("productNo").description("상품번호") 119 | }; 120 | 121 | var fieldDescriptors = new FieldDescriptor[]{ 122 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 123 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 124 | fieldWithPath("data.content[].id").type(NUMBER).description("상품일련번호"), 125 | fieldWithPath("data.content[].productName").type(STRING).description("상품명"), 126 | fieldWithPath("data.content[].productNo").type(STRING).description("상품번호"), 127 | fieldWithPath("data.content[].productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 128 | fieldWithPath("data.content[].productStatusDescription").type(STRING).description("상품상태설명"), 129 | fieldWithPath("data.content[].created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 130 | fieldWithPath("data.content[].modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시"), 131 | fieldWithPath("data.pageRequest.page").type(NUMBER).description("페이지번호"), 132 | fieldWithPath("data.pageRequest.size").type(NUMBER).description("페이지크기"), 133 | fieldWithPath("data.total").type(NUMBER).description("데이터 전체갯수"), 134 | fieldWithPath("data.totalPages").type(NUMBER).description("전체 페이지갯수"), 135 | fieldWithPath("data.isFirst").type(BOOLEAN).description("시작페이지인가?"), 136 | fieldWithPath("data.isLast").type(BOOLEAN).description("마지막페이지인가?"), 137 | fieldWithPath("data.hasPrevious").type(BOOLEAN).description("앞페이지가있는가?"), 138 | fieldWithPath("data.hasNext").type(BOOLEAN).description("다음페이지가있는가?"), 139 | fieldWithPath("data.hasContent").type(BOOLEAN).description("컨텐츠가있는가?"), 140 | }; 141 | 142 | 143 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 144 | .perform(RestDocumentationRequestBuilders.get("/products") 145 | .param("page", "0") 146 | .param("size", "20") 147 | .param("productName", "테스트상품") 148 | .param("productNo", "PRODUCT01") 149 | .contentType(MediaType.APPLICATION_JSON)) 150 | .andDo(MockMvcResultHandlers.print()) 151 | .andExpect(MockMvcResultMatchers.status().isOk()) 152 | //REST Docs 용 153 | .andDo(MockMvcRestDocumentation.document("get-v1-get-products", 154 | getDocumentRequest(), 155 | getDocumentResponse(), 156 | requestParameters( 157 | parameterDescriptors 158 | ), 159 | responseFields( 160 | fieldDescriptors 161 | ) 162 | )) 163 | //OAS 3.0 - Swagger 164 | .andDo(MockMvcRestDocumentationWrapper.document("get-v1-get-products", 165 | getDocumentRequest(), 166 | getDocumentResponse(), 167 | resource(ResourceSnippetParameters.builder() 168 | .requestParameters( 169 | parameterDescriptors 170 | ) 171 | .responseFields( 172 | fieldDescriptors 173 | ) 174 | .build()))); 175 | } 176 | 177 | @DisplayName("생성: 상품") 178 | @Test 179 | void testCreateProduct(RestDocumentationContextProvider contextProvider) throws Exception { 180 | var createCommandJson = """ 181 | { 182 | "productName": "테스트상품", 183 | "productNo": "TEST-1111" 184 | } 185 | """; 186 | var createdProductJson = """ 187 | { 188 | "id": 1, 189 | "productName": "테스트상품", 190 | "productNo": "TEST01", 191 | "productStatus": "CREATED", 192 | "created": "2022-09-23T05:46:10", 193 | "modified": "2022-09-23T05:46:10" 194 | }"""; 195 | 196 | Mockito.when(productFacade.create(Mockito.any())) 197 | .thenReturn(JsonUtils.fromJson(createdProductJson, ProductDto.class)); 198 | 199 | var requestFieldDescription = new FieldDescriptor[]{ 200 | fieldWithPath("productName").type(STRING).description("상품명"), 201 | fieldWithPath("productNo").type(STRING).description("상품번호") 202 | }; 203 | 204 | var responseFieldDescription = new FieldDescriptor[]{ 205 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 206 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 207 | fieldWithPath("data.id").type(NUMBER).description("상품일련번호"), 208 | fieldWithPath("data.productName").type(STRING).description("상품명"), 209 | fieldWithPath("data.productNo").type(STRING).description("상품번호"), 210 | fieldWithPath("data.productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 211 | fieldWithPath("data.productStatusDescription").type(STRING).description("상품상태설명"), 212 | fieldWithPath("data.created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 213 | fieldWithPath("data.modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시") 214 | }; 215 | 216 | var expectedContent = "{\"code\":\"0000\",\"message\":\"정상\",\"data\":{\"id\":1,\"productName\":\"테스트상품\",\"productNo\":\"TEST01\",\"productStatus\":\"CREATED\",\"created\":\"2022-09-23T05:46:10\",\"modified\":\"2022-09-23T05:46:10\",\"productStatusDescription\":\"생성\"}}"; 217 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 218 | .perform(RestDocumentationRequestBuilders.post("/products") 219 | .contentType(MediaType.APPLICATION_JSON) 220 | .content(createCommandJson) 221 | ) 222 | .andDo(MockMvcResultHandlers.print()) 223 | .andExpect(MockMvcResultMatchers.status().isCreated()) 224 | .andExpect(MockMvcResultMatchers.content().string(expectedContent)) 225 | //REST Docs 용 226 | .andDo(MockMvcRestDocumentation.document("post-v1-create-product", 227 | getDocumentRequest(), 228 | getDocumentResponse(), 229 | requestFields( 230 | requestFieldDescription 231 | ), 232 | responseFields( 233 | responseFieldDescription 234 | ) 235 | )) 236 | //OAS 3.0 - Swagger 237 | .andDo(MockMvcRestDocumentationWrapper.document("post-v1-create-product", 238 | getDocumentRequest(), 239 | getDocumentResponse(), 240 | resource(ResourceSnippetParameters.builder() 241 | .requestFields( 242 | requestFieldDescription 243 | ) 244 | .responseFields( 245 | responseFieldDescription 246 | ) 247 | .build()))); 248 | } 249 | 250 | @DisplayName("조회: 상품상세") 251 | @Test 252 | void testGetProduct(RestDocumentationContextProvider contextProvider) throws Exception { 253 | var sourceJson = """ 254 | { 255 | "id": 1, 256 | "productName": "테스트상품", 257 | "productNo": "TEST01", 258 | "productStatus": "CREATED", 259 | "created": "2022-09-23T05:46:10", 260 | "modified": "2022-09-23T05:46:10" 261 | }"""; 262 | 263 | Mockito.when(productFacade.getProduct(Mockito.anyLong())) 264 | .thenReturn(JsonUtils.fromJson(sourceJson, ProductDto.class)); 265 | 266 | String expectedContent = "{\"code\":\"0000\",\"message\":\"정상\",\"data\":{\"id\":1,\"productName\":\"테스트상품\",\"productNo\":\"TEST01\",\"productStatus\":\"CREATED\",\"created\":\"2022-09-23T05:46:10\",\"modified\":\"2022-09-23T05:46:10\",\"productStatusDescription\":\"생성\"}}"; 267 | 268 | var parameterDescriptor = parameterWithName("id").description("상품번호"); 269 | 270 | var fieldDescriptors = new FieldDescriptor[]{ 271 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 272 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 273 | fieldWithPath("data.id").type(NUMBER).description("상품일련번호"), 274 | fieldWithPath("data.productName").type(STRING).description("상품명"), 275 | fieldWithPath("data.productNo").type(STRING).description("상품번호"), 276 | fieldWithPath("data.productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 277 | fieldWithPath("data.productStatusDescription").type(STRING).description("상품상태설명"), 278 | fieldWithPath("data.created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 279 | fieldWithPath("data.modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시") 280 | }; 281 | 282 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 283 | .perform(RestDocumentationRequestBuilders.get("/products/{id}", 1L) 284 | .contentType(MediaType.APPLICATION_JSON)) 285 | .andDo(MockMvcResultHandlers.print()) 286 | .andExpect(MockMvcResultMatchers.status().isOk()) 287 | .andExpect(MockMvcResultMatchers.content().string(expectedContent)) 288 | //REST Docs 용 289 | .andDo(MockMvcRestDocumentation.document("get-v1-get-product", 290 | getDocumentRequest(), 291 | getDocumentResponse(), 292 | pathParameters( 293 | parameterDescriptor 294 | ), 295 | responseFields( 296 | fieldDescriptors 297 | ) 298 | )) 299 | //OAS 3.0 - Swagger 300 | .andDo(MockMvcRestDocumentationWrapper.document("get-v1-get-product", 301 | getDocumentRequest(), 302 | getDocumentResponse(), 303 | resource(ResourceSnippetParameters.builder() 304 | .pathParameters( 305 | parameterDescriptor 306 | ) 307 | .responseFields( 308 | fieldDescriptors 309 | ) 310 | .build()))); 311 | } 312 | 313 | @DisplayName("변경: 상품") 314 | @Test 315 | void testModifyProduct(RestDocumentationContextProvider contextProvider) throws Exception { 316 | var modifyCommandJson = """ 317 | { 318 | "productName": "테스트상품", 319 | "productNo": "TEST-1111", 320 | "productStatus": "ACTIVATED" 321 | } 322 | """; 323 | 324 | var modifiedProductJson = """ 325 | { 326 | "id": 1, 327 | "productName": "테스트상품", 328 | "productNo": "TEST01", 329 | "productStatus": "ACTIVATED", 330 | "created": "2022-09-23T05:46:10", 331 | "modified": "2022-09-23T05:46:10" 332 | }"""; 333 | 334 | var expectedContent = "{\"code\":\"0000\",\"message\":\"정상\",\"data\":{\"id\":1,\"productName\":\"테스트상품\",\"productNo\":\"TEST01\",\"productStatus\":\"ACTIVATED\",\"created\":\"2022-09-23T05:46:10\",\"modified\":\"2022-09-23T05:46:10\",\"productStatusDescription\":\"활성화\"}}"; 335 | 336 | Mockito.when(productFacade.modify(Mockito.anyLong(), Mockito.any())) 337 | .thenReturn(JsonUtils.fromJson(modifiedProductJson, ProductDto.class)); 338 | 339 | var parameterDescriptor = parameterWithName("id").description("상품번호"); 340 | 341 | var requestFieldDescription = new FieldDescriptor[]{ 342 | fieldWithPath("productName").type(STRING).description("상품명"), 343 | fieldWithPath("productNo").type(STRING).description("상품번호"), 344 | fieldWithPath("productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태") 345 | }; 346 | 347 | var responseFieldDescription = new FieldDescriptor[]{ 348 | fieldWithPath("code").type(STRING).description("응답코드(정상: 0000)"), 349 | fieldWithPath("message").type(STRING).description("응답메시지(정상: OK)"), 350 | fieldWithPath("data.id").type(NUMBER).description("상품일련번호"), 351 | fieldWithPath("data.productName").type(STRING).description("상품명"), 352 | fieldWithPath("data.productNo").type(STRING).description("상품번호"), 353 | fieldWithPath("data.productStatus").type(STRING).attributes(generateEnumAttrs(ProductStatus.class, ProductStatus::getDescription)).description("상품상태"), 354 | fieldWithPath("data.productStatusDescription").type(STRING).description("상품상태설명"), 355 | fieldWithPath("data.created").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("생성일시"), 356 | fieldWithPath("data.modified").type(STRING).attributes(customFormat(FORMAT_LOCAL_DATE_TIME)).description("변경일시") 357 | }; 358 | 359 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 360 | .perform(RestDocumentationRequestBuilders.put("/products/{id}", 1L) 361 | .contentType(MediaType.APPLICATION_JSON) 362 | .content(modifyCommandJson) 363 | ) 364 | .andDo(MockMvcResultHandlers.print()) 365 | .andExpect(MockMvcResultMatchers.status().isOk()) 366 | .andExpect(MockMvcResultMatchers.content().string(expectedContent)) 367 | //REST Docs 용 368 | .andDo(MockMvcRestDocumentation.document("put-v1-modify-product", 369 | getDocumentRequest(), 370 | getDocumentResponse(), 371 | pathParameters( 372 | parameterDescriptor 373 | ), 374 | requestFields( 375 | requestFieldDescription 376 | ), 377 | responseFields( 378 | responseFieldDescription 379 | ) 380 | )) 381 | //OAS 3.0 - Swagger 382 | .andDo(MockMvcRestDocumentationWrapper.document("put-v1-modify-product", 383 | getDocumentRequest(), 384 | getDocumentResponse(), 385 | resource(ResourceSnippetParameters.builder() 386 | .pathParameters( 387 | parameterDescriptor 388 | ) 389 | .requestFields( 390 | requestFieldDescription 391 | ) 392 | .responseFields( 393 | responseFieldDescription 394 | ) 395 | .build()))); 396 | } 397 | 398 | @DisplayName("삭제: 상품") 399 | @Test 400 | void testDeleteProduct(RestDocumentationContextProvider contextProvider) throws Exception { 401 | var expectedContent = ""; 402 | 403 | var parameterDescriptor = parameterWithName("id").description("상품번호"); 404 | 405 | MockMvcFactory.getRestDocsMockMvc(contextProvider, HOST_LOCAL, controller) 406 | .perform(RestDocumentationRequestBuilders.delete("/products/{id}", 1L) 407 | .contentType(MediaType.APPLICATION_JSON) 408 | ) 409 | .andDo(MockMvcResultHandlers.print()) 410 | .andExpect(MockMvcResultMatchers.status().isNoContent()) 411 | .andExpect(MockMvcResultMatchers.content().string(expectedContent)) 412 | //REST Docs 용 413 | .andDo(MockMvcRestDocumentation.document("delete-v1-delete-product", 414 | getDocumentRequest(), 415 | getDocumentResponse(), 416 | pathParameters( 417 | parameterDescriptor 418 | ) 419 | )) 420 | //OAS 3.0 - Swagger 421 | .andDo(MockMvcRestDocumentationWrapper.document("delete-v1-delete-product", 422 | getDocumentRequest(), 423 | getDocumentResponse(), 424 | resource(ResourceSnippetParameters.builder() 425 | .pathParameters( 426 | parameterDescriptor 427 | ) 428 | .build()))); 429 | } 430 | } -------------------------------------------------------------------------------- /src/test/java/com/kurly/tet/guide/springrestdocs/infrastructure/web/product/ProductRestControllerTest.java: -------------------------------------------------------------------------------- 1 | package com.kurly.tet.guide.springrestdocs.infrastructure.web.product; 2 | 3 | import com.kurly.tet.guide.springrestdocs.application.product.ProductFacade; 4 | import com.kurly.tet.guide.springrestdocs.domain.exception.ProductNotFoundException; 5 | import org.junit.jupiter.api.DisplayName; 6 | import org.junit.jupiter.api.Test; 7 | import org.mockito.Mockito; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 10 | import org.springframework.boot.test.mock.mockito.MockBean; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | import org.springframework.test.web.servlet.result.MockMvcResultHandlers; 14 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 15 | 16 | import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; 17 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; 18 | 19 | @DisplayName("상품API") 20 | @WebMvcTest({ProductRestController.class}) 21 | class ProductRestControllerTest { 22 | @Autowired 23 | private MockMvc mockMvc; 24 | @MockBean 25 | private ProductFacade productFacade; 26 | 27 | @DisplayName("검색: 최대조회크기(size, 1000) 초과하는 경우 400 오류") 28 | @Test 29 | void testSearchWhenOverMax() throws Exception { 30 | this.mockMvc.perform( 31 | get("/products") 32 | .contentType(MediaType.APPLICATION_JSON) 33 | .param("size", "1001") 34 | ) 35 | .andDo(MockMvcResultHandlers.print()) 36 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 37 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}")); 38 | } 39 | 40 | @DisplayName("생성: 상품명을 누락한 경우 400 오류") 41 | @Test 42 | void testCreateProduct() throws Exception { 43 | var requestContent = """ 44 | { 45 | "productName":"", 46 | "productNo":"1234" 47 | } 48 | """; 49 | this.mockMvc.perform( 50 | post("/products") 51 | .contentType(MediaType.APPLICATION_JSON) 52 | .content(requestContent) 53 | ) 54 | .andDo(MockMvcResultHandlers.print()) 55 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 56 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}")); 57 | } 58 | 59 | @DisplayName("조회: 요청정보를 찾지 못하는 경우 404 오류") 60 | @Test 61 | void testGetProductWhenNotFound() throws Exception { 62 | Mockito.when(productFacade.getProduct(Long.MAX_VALUE)) 63 | .thenThrow(new ProductNotFoundException(Long.MAX_VALUE)); 64 | 65 | this.mockMvc.perform( 66 | get("/products/{id}", Long.MAX_VALUE) 67 | .contentType(MediaType.APPLICATION_JSON) 68 | ) 69 | .andDo(MockMvcResultHandlers.print()) 70 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 71 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C404\",\"message\":\"상품(ID: 9223372036854775807)을 찾을 수 없습니다.\",\"data\":null}")); 72 | } 73 | 74 | @DisplayName("변경: 요청정보를 찾지 못하는 경우 404 오류") 75 | @Test 76 | void testModifyProductWhenNotFound() throws Exception { 77 | Mockito.when(productFacade.modify(Mockito.anyLong(), Mockito.any())) 78 | .thenThrow(new ProductNotFoundException(Long.MAX_VALUE)); 79 | 80 | String modifyContent = """ 81 | { 82 | "productName":"1234", 83 | "productNo":"1234", 84 | "productStatus":"ACTIVATED" 85 | } 86 | """; 87 | this.mockMvc.perform( 88 | put("/products/{id}", Long.MAX_VALUE) 89 | .contentType(MediaType.APPLICATION_JSON) 90 | .content(modifyContent) 91 | ) 92 | .andDo(MockMvcResultHandlers.print()) 93 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 94 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C404\",\"message\":\"상품(ID: 9223372036854775807)을 찾을 수 없습니다.\",\"data\":null}")); 95 | } 96 | 97 | @DisplayName("변경: 상품명을 누락한 경우 404 오류") 98 | @Test 99 | void testModify02() throws Exception { 100 | String modifyContent = """ 101 | { 102 | "productName":"", // 상품명 누락 103 | "productNo":"1234", 104 | "productStatus":"ACTIVATED" 105 | } 106 | """; 107 | 108 | this.mockMvc.perform( 109 | put("/products/{id}", Long.MAX_VALUE) 110 | .contentType(MediaType.APPLICATION_JSON) 111 | .content(modifyContent) 112 | ) 113 | .andDo(MockMvcResultHandlers.print()) 114 | .andExpect(MockMvcResultMatchers.status().is4xxClientError()) 115 | .andExpect(MockMvcResultMatchers.content().string("{\"code\":\"C400\",\"message\":\"잘못된 요청입니다. 요청내용을 확인하세요.\",\"data\":null}")); 116 | } 117 | 118 | @DisplayName("삭제: 별도 처리 없음. NOT_CONTENT 반환") 119 | @Test 120 | void testDeleteWhenNotFound() throws Exception { 121 | this.mockMvc.perform( 122 | delete("/products/{id}", Long.MAX_VALUE) 123 | .contentType(MediaType.APPLICATION_JSON) 124 | ) 125 | .andDo(MockMvcResultHandlers.print()) 126 | .andExpect(MockMvcResultMatchers.status().isNoContent()); 127 | } 128 | } -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | servlet: 3 | encoding: 4 | force: true -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/README.md: -------------------------------------------------------------------------------- 1 | Spring REST Docs 사용자정의 스니펫 적용시 주의사항 2 | ============================================== 3 | 4 | * Spring REST Docs 스니펫이 적용되지 않은 이유: 스니펫 폴더이름이 잘못 되었음 5 | * AS-IS: `src/test/resources/org.springframework.restdocs.templates.asciidoctor` 6 | * TO-BE: `src/test/resources/org/springframework/restdocs/templates/asciidoctor` 7 | * 인텔리제이에서는 동일하게 보이지만, 실제로 스니펫 폴더 경로는 달랐음 8 | -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/curl-request.snippet: -------------------------------------------------------------------------------- 1 | .link:https://curl.se/docs/manual.html[`curl`] 명령어 2 | [source,bash] 3 | ---- 4 | $ curl {{url}} {{options}} 5 | ---- 6 | -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/http-request.snippet: -------------------------------------------------------------------------------- 1 | .HTTP 요청예제 2 | [source,http,options="nowrap"] 3 | ---- 4 | {{method}} {{path}} HTTP/1.1 5 | {{#headers}} 6 | {{name}}: {{value}} 7 | {{/headers}} 8 | {{requestBody}} 9 | ---- -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/http-response.snippet: -------------------------------------------------------------------------------- 1 | .HTTP 응답예제 2 | [source,http,options="nowrap"] 3 | ---- 4 | HTTP/1.1{{statusCode}}{{statusReason}} 5 | {{#headers}} 6 | {{name}}:{{value}} 7 | {{/headers}} 8 | {{responseBody}} 9 | ---- -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/httpie-request.snippet: -------------------------------------------------------------------------------- 1 | .link:https://httpie.io/docs/cli/usage[`httpie`] 명령어 2 | [source,bash] 3 | ---- 4 | $ {{echoContent}}http {{options}} {{url}}{{requestItems}} 5 | ---- -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/request-fields.snippet: -------------------------------------------------------------------------------- 1 | .요청필드 2 | |=== 3 | |필드|타입|필수값|설명|형식 4 | 5 | {{#fields}} 6 | |{{path}} 7 | |{{type}} 8 | |{{^optional}}true{{/optional}} 9 | a|{{description}} 10 | a|{{#format}}{{format}}{{/format}}{{^format}}{{/format}} 11 | {{/fields}} 12 | |=== 13 | -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/request-parameters.snippet: -------------------------------------------------------------------------------- 1 | .요청파라미터 2 | |=== 3 | |Parameter|Description 4 | 5 | {{#parameters}} 6 | |{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}} 7 | |{{#tableCellContent}}{{description}}{{/tableCellContent}} 8 | 9 | {{/parameters}} 10 | |=== -------------------------------------------------------------------------------- /src/test/resources/org/springframework/restdocs/templates/asciidoctor/response-fields.snippet: -------------------------------------------------------------------------------- 1 | .응답필드 2 | |=== 3 | |필드|타입|필수값|설명|형식 4 | 5 | {{#fields}} 6 | |{{path}} 7 | |{{type}} 8 | |{{^optional}}true{{/optional}} 9 | a|{{description}} 10 | a|{{#format}}{{format}}{{/format}}{{^format}}{{/format}} 11 | {{/fields}} 12 | |=== 13 | -------------------------------------------------------------------------------- /swagger-ui/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thefarmersfront/spring-rest-docs-guide/7fd8b90007b12d1509b55c17bc4e06fd5292daee/swagger-ui/favicon-16x16.png -------------------------------------------------------------------------------- /swagger-ui/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thefarmersfront/spring-rest-docs-guide/7fd8b90007b12d1509b55c17bc4e06fd5292daee/swagger-ui/favicon-32x32.png -------------------------------------------------------------------------------- /swagger-ui/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | overflow: -moz-scrollbars-vertical; 4 | overflow-y: scroll; 5 | } 6 | 7 | *, 8 | *:before, 9 | *:after { 10 | box-sizing: inherit; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | background: #fafafa; 16 | } 17 | -------------------------------------------------------------------------------- /swagger-ui/swagger-initializer.js: -------------------------------------------------------------------------------- 1 | window.onload = function() { 2 | // 3 | 4 | // the following lines will be replaced by docker/configurator, when it runs in a docker-container 5 | window.ui = SwaggerUIBundle({ 6 | url: "openapi3.yaml", 7 | dom_id: '#swagger-ui', 8 | deepLinking: true, 9 | presets: [ 10 | SwaggerUIBundle.presets.apis, 11 | SwaggerUIStandalonePreset 12 | ], 13 | plugins: [ 14 | SwaggerUIBundle.plugins.DownloadUrl 15 | ], 16 | layout: "StandaloneLayout" 17 | }); 18 | 19 | // 20 | }; 21 | -------------------------------------------------------------------------------- /swagger-ui/swagger-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Swagger UI for Spring REST Docs 작성 가이드 8 | 9 | 10 | 11 | 29 | 30 | 31 | 32 |
    33 | 34 | 35 | 36 | 57 | 58 | 59 | --------------------------------------------------------------------------------