diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..649136dfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +*.yml diff --git a/BOOT-INF/classes/static/docs/index.html b/BOOT-INF/classes/static/docs/index.html new file mode 100644 index 000000000..0110dcbe4 --- /dev/null +++ b/BOOT-INF/classes/static/docs/index.html @@ -0,0 +1,864 @@ + + + + + + + +API Docs + + + + + + +
+
+

게시판

+
+ +
+

/posts

+
+
Request
+
+
$ curl 'http://localhost:8080/posts?title=Your+Title&content=Your+Content' -i -X GET
+
+
+
+
+
GET /posts?title=Your+Title&content=Your+Content HTTP/1.1
+Host: localhost:8080
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1124
+
+{"result":{"content":[{"postId":1,"title":"흑구팀 화이팅팅","content":"언제나 응원해 흑구팀팀","memberName":"\"김별\""},{"postId":2,"title":"흑구영수팀 화이팅","content":"흑구영수팀 언제나 응원해","memberName":"\"김별\""},{"postId":3,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":4,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":5,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":6,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":7,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""}],"pageable":{"sort":{"empty":true,"unsorted":true,"sorted":false},"offset":0,"pageNumber":0,"pageSize":20,"paged":true,"unpaged":false},"size":20,"number":0,"sort":{"empty":true,"unsorted":true,"sorted":false},"first":true,"last":true,"numberOfElements":7,"empty":false},"resultCode":200,"resultMsg":"SELECT SUCCESS"}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

result

Object

Result details

result.content[]

Array

The list of content items

result.pageable

Object

Pagination information

result.pageable.sort.empty

Boolean

Indicates if the sort is empty

result.pageable.sort.sorted

Boolean

Indicates if the sort is sorted

result.pageable.sort.unsorted

Boolean

Indicates if the sort is unsorted

result.pageable.offset

Number

Offset value for pagination

result.pageable.pageSize

Number

Page size for pagination

result.pageable.pageNumber

Number

Page number

result.pageable.unpaged

Boolean

Indicates if the page is unpaged

result.pageable.paged

Boolean

Indicates if the page is paged

result.size

Number

The page size

result.number

Number

The current page number

result.sort.empty

Boolean

Indicates if the sort is empty

result.sort.sorted

Boolean

Indicates if the sort is sorted

result.sort.unsorted

Boolean

Indicates if the sort is unsorted

result.first

Boolean

Indicates if this is the first page

result.last

Boolean

Indicates if this is the last page

result.numberOfElements

Number

Number of elements in the current page

result.empty

Boolean

Indicates if the content is empty

resultCode

Number

The result code of the response

resultMsg

String

The result message of the response

+
+ +
+

/posts/{id}

+
+
Request
+
+
$ curl 'http://localhost:8080/posts/1' -i -X PATCH \
+    -H 'Content-Type: application/json;charset=UTF-8' \
+    -d '{"title": "흑구팀 화이팅팅", "content": "언제나 응원해 흑구팀팀", "memberId": 1}'
+
+
+
+
+
PATCH /posts/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 97
+Host: localhost:8080
+
+{"title": "흑구팀 화이팅팅", "content": "언제나 응원해 흑구팀팀", "memberId": 1}
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 173
+
+{"result":{"postId":1,"title":"흑구팀 화이팅팅","content":"언제나 응원해 흑구팀팀","memberName":"\"김별\""},"resultCode":204,"resultMsg":"UPDATE SUCCESS"}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

result.postId

Number

The id of the updated post

result.title

String

The title of the updated post

result.content

String

The content of the updated post

result.memberName

String

The name of the member who made the update

resultCode

Number

The result code of the response

resultMsg

String

The result message of the response

+
+ +
+

/posts/{id}

+
+
Request
+
+
$ curl 'http://localhost:8080/posts/1' -i -X GET
+
+
+
+
+
GET /posts/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 173
+
+{"result":{"postId":1,"title":"흑구팀 화이팅팅","content":"언제나 응원해 흑구팀팀","memberName":"\"김별\""},"resultCode":200,"resultMsg":"SELECT SUCCESS"}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

result.postId

Number

The id of the detailed post

result.title

String

The title of the detailed post

result.content

String

The content of the detailed post

result.memberName

String

The name of writer

resultCode

Number

The result code of the response

resultMsg

String

The result message of the response

+
+ +
+

/posts

+
+
Request
+
+
$ curl 'http://localhost:8080/posts' -i -X POST \
+    -H 'Content-Type: application/json;charset=UTF-8' \
+    -d '{"memberId":1,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀"}'
+
+
+
+
+
POST /posts HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 84
+Host: localhost:8080
+
+{"memberId":1,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀"}
+
+
+
+
Response
+
+
HTTP/1.1 201 Created
+Location: http://localhost:8080/posts/7
+Content-Type: application/json;charset=UTF-8
+Content-Length: 108
+
+{"postId":7,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

postId

Number

The id of the saved post

title

String

The title of the saved post

content

String

The content of the saved post

memberName

String

The name of writer

+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..f2806b7ff --- /dev/null +++ b/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.2' + id 'io.spring.dependency-management' version '1.1.2' + id "org.asciidoctor.jvm.convert" version "3.3.2" +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + asciidoctorExt +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +bootJar { + dependsOn asciidoctor + copy { + from "${asciidoctor.outputDir}" + into 'BOOT-INF/classes/static/docs' + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + runtimeOnly 'com.h2database:h2' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + implementation 'mysql:mysql-connector-java:8.0.28' +} + +tasks.named('test') { + useJUnitPlatform() +} + +test { + useJUnitPlatform() + outputs.dir snippetsDir +} + +asciidoctor { + dependsOn test + inputs.dir snippetsDir + attributes 'snippets': snippetsDir +} + +asciidoctor.doFirst { + delete file('src/main/resources/static/docs') +} + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..033e24c4c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..9f4197d5f --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..fcb6fca14 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..93e3f59f1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..1ec9f6ed0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'jpaboard' diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..aabec328b --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,66 @@ += API Docs +:icons: font +:source-highlighter: highlightjs +:toc: left +:toc-title: API +:toclevels: 2 +:sectlinks: + +== 게시판 + +=== 검색 필드 및 무한 스크롤 + +=== /posts + +.Request + +include::{snippets}/post-findAllBy/curl-request.adoc[] +include::{snippets}/post-findAllBy/http-request.adoc[] + +.Response + +include::{snippets}/post-findAllBy/http-response.adoc[] +include::{snippets}/post-findAllBy/response-fields.adoc[] + + +=== 게시판 업데이트 + +=== /posts/{id} + +.Request + +include::{snippets}/post-update/curl-request.adoc[] +include::{snippets}/post-update/http-request.adoc[] + +.Response + +include::{snippets}/post-update/http-response.adoc[] +include::{snippets}/post-update/response-fields.adoc[] + +=== 게시판 조회 + +=== /posts/{id} + +.Request + +include::{snippets}/post-findById/curl-request.adoc[] +include::{snippets}/post-findById/http-request.adoc[] + +.Response + +include::{snippets}/post-findById/http-response.adoc[] +include::{snippets}/post-findById/response-fields.adoc[] + +=== 게시글 등록 + +=== /posts + +.Request + +include::{snippets}/post-save/curl-request.adoc[] +include::{snippets}/post-save/http-request.adoc[] + +.Response + +include::{snippets}/post-save/http-response.adoc[] +include::{snippets}/post-save/response-fields.adoc[] diff --git a/src/main/java/com/example/jpaboard/JpaboardApplication.java b/src/main/java/com/example/jpaboard/JpaboardApplication.java new file mode 100644 index 000000000..1bef38ef3 --- /dev/null +++ b/src/main/java/com/example/jpaboard/JpaboardApplication.java @@ -0,0 +1,18 @@ +package com.example.jpaboard; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing( + auditorAwareRef = "auditorAwareConfig", + dateTimeProviderRef = "auditorAwareConfig" +) +public class JpaboardApplication { + + public static void main(String[] args) { + SpringApplication.run(JpaboardApplication.class, args); + } + +} diff --git a/src/main/java/com/example/jpaboard/config/AuditorAwareConfig.java b/src/main/java/com/example/jpaboard/config/AuditorAwareConfig.java new file mode 100644 index 000000000..0aa6dca73 --- /dev/null +++ b/src/main/java/com/example/jpaboard/config/AuditorAwareConfig.java @@ -0,0 +1,27 @@ +package com.example.jpaboard.config; + +import java.time.LocalDateTime; +import java.time.temporal.TemporalAccessor; +import java.util.Optional; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@Configuration +public class AuditorAwareConfig implements AuditorAware, DateTimeProvider { + + @Override + public Optional getCurrentAuditor() { + return Optional.of("별앤영"); + } + + @Override + public Optional getNow() { + return Optional.of(LocalDateTime.now()); + } + +} + diff --git a/src/main/java/com/example/jpaboard/global/ApiResponse.java b/src/main/java/com/example/jpaboard/global/ApiResponse.java new file mode 100644 index 000000000..aeb5ae5c7 --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/ApiResponse.java @@ -0,0 +1,27 @@ +package com.example.jpaboard.global; + +public class ApiResponse { + + private T result; + private int resultCode; + private String resultMsg; + + public ApiResponse(final T result, SuccessCode successCode) { + this.result = result; + this.resultCode = successCode.getStatus(); + this.resultMsg = successCode.getMessage(); + } + + public T getResult() { + return result; + } + + public int getResultCode() { + return resultCode; + } + + public String getResultMsg() { + return resultMsg; + } + +} diff --git a/src/main/java/com/example/jpaboard/global/BaseEntity.java b/src/main/java/com/example/jpaboard/global/BaseEntity.java new file mode 100644 index 000000000..c275dbe89 --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/BaseEntity.java @@ -0,0 +1,34 @@ +package com.example.jpaboard.global; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(value = {AuditingEntityListener.class}) +public class BaseEntity { + + protected BaseEntity() { } + + @CreatedDate + @Column(updatable = false, columnDefinition = "DATE") + private LocalDateTime createdAt; + + @CreatedBy + private String createdBy; + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public String getCreatedBy() { + return createdBy; + } + +} diff --git a/src/main/java/com/example/jpaboard/global/ErrorResponse.java b/src/main/java/com/example/jpaboard/global/ErrorResponse.java new file mode 100644 index 000000000..929cd3055 --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/ErrorResponse.java @@ -0,0 +1,14 @@ +package com.example.jpaboard.global; + +import java.time.LocalDateTime; + +public record ErrorResponse(int statusCode, + String detail, + String instance, + String time) { + + public ErrorResponse(int statusCode, String detail, String instance) { + this(statusCode, detail, instance, LocalDateTime.now().toString()); + } + +} diff --git a/src/main/java/com/example/jpaboard/global/GlobalRestExceptionHandler.java b/src/main/java/com/example/jpaboard/global/GlobalRestExceptionHandler.java new file mode 100644 index 000000000..1b334fcb0 --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/GlobalRestExceptionHandler.java @@ -0,0 +1,89 @@ +package com.example.jpaboard.global; + +import com.example.jpaboard.global.exception.EntityNotFoundException; +import com.example.jpaboard.global.exception.UnauthorizedEditException; + +import jakarta.servlet.http.HttpServletRequest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import static org.springframework.http.HttpStatus.*; + +@RestControllerAdvice +public class GlobalRestExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalRestExceptionHandler.class); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) { + StringBuilder resultStringBuilder = getResultStringBuilder(e); + + ErrorResponse errorResponse = getErrorResponse(BAD_REQUEST.value(), + resultStringBuilder.toString(), + request.getRequestURI()); + return ResponseEntity + .status(BAD_REQUEST) + .body(errorResponse); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException e) { + ErrorResponse errorResponse = getErrorResponse(BAD_REQUEST.value(), + e.getMessage(), + request.getRequestURI()); + + return ResponseEntity.status(BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(UnauthorizedEditException.class) + public ResponseEntity handleUnauthorizedEditException(HttpServletRequest request, UnauthorizedEditException e) { + ErrorResponse errorResponse = getErrorResponse(FORBIDDEN.value(), + e.getMessage(), + request.getRequestURI()); + + return ResponseEntity.status(FORBIDDEN).body(errorResponse); + } + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleNotFoundException(HttpServletRequest request, EntityNotFoundException e) { + ErrorResponse errorResponse = getErrorResponse(NOT_FOUND.value(), + e.getMessage(), + request.getRequestURI()); + + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(HttpServletRequest request, Exception e) { + logger.error("Exception URI {}", request.getRequestURI()); + logger.error("Sever Exception", e); + + return ResponseEntity.status(INTERNAL_SERVER_ERROR).build(); + } + + private static StringBuilder getResultStringBuilder(MethodArgumentNotValidException e) { + BindingResult bindingResult = e.getBindingResult(); + StringBuilder stringBuilder = new StringBuilder(); + + for (FieldError fieldError : bindingResult.getFieldErrors()) { + stringBuilder.append(fieldError.getField()).append(":"); + stringBuilder.append(fieldError.getDefaultMessage()); + stringBuilder.append(", "); + } + return stringBuilder; + } + + private static ErrorResponse getErrorResponse(int status, String masesage, String requestURI) { + return new ErrorResponse(status, masesage, requestURI); + } + +} diff --git a/src/main/java/com/example/jpaboard/global/SliceResponse.java b/src/main/java/com/example/jpaboard/global/SliceResponse.java new file mode 100644 index 000000000..469014c78 --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/SliceResponse.java @@ -0,0 +1,13 @@ +package com.example.jpaboard.global; + +import org.springframework.data.domain.Slice; + +public class SliceResponse extends ApiResponse { + + private Slice data; + + public SliceResponse(Slice data, SuccessCode successCode) { + super(data, successCode); + } + +} diff --git a/src/main/java/com/example/jpaboard/global/SuccessCode.java b/src/main/java/com/example/jpaboard/global/SuccessCode.java new file mode 100644 index 000000000..11132b4f2 --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/SuccessCode.java @@ -0,0 +1,30 @@ +package com.example.jpaboard.global; + +public enum SuccessCode { + + SUCCESS(200, "200", "REQUEST SUCCESS"), + SELECT_SUCCESS(200, "200", "SELECT SUCCESS"), + DELETE_SUCCESS(200, "200", "DELETE SUCCESS"), + INSERT_SUCCESS(201, "201", "INSERT SUCCESS"), + UPDATE_SUCCESS(204, "204", "UPDATE SUCCESS"), + ; + + private final int status; + private final String code; + private final String message; + + SuccessCode(final int status, final String code, final String message) { + this.status = status; + this.code = code; + this.message = message; + } + + public int getStatus() { + return status; + } + + public String getMessage() { + return message; + } + +} diff --git a/src/main/java/com/example/jpaboard/global/exception/EntityNotFoundException.java b/src/main/java/com/example/jpaboard/global/exception/EntityNotFoundException.java new file mode 100644 index 000000000..b2b213a8e --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/exception/EntityNotFoundException.java @@ -0,0 +1,9 @@ +package com.example.jpaboard.global.exception; + +public class EntityNotFoundException extends RuntimeException { + + public EntityNotFoundException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/example/jpaboard/global/exception/UnauthorizedEditException.java b/src/main/java/com/example/jpaboard/global/exception/UnauthorizedEditException.java new file mode 100644 index 000000000..70f1f8a4e --- /dev/null +++ b/src/main/java/com/example/jpaboard/global/exception/UnauthorizedEditException.java @@ -0,0 +1,9 @@ +package com.example.jpaboard.global.exception; + +public class UnauthorizedEditException extends RuntimeException { + + public UnauthorizedEditException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/example/jpaboard/member/domain/Age.java b/src/main/java/com/example/jpaboard/member/domain/Age.java new file mode 100644 index 000000000..21601d22f --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/domain/Age.java @@ -0,0 +1,27 @@ +package com.example.jpaboard.member.domain; + +import jakarta.persistence.Embeddable; + +@Embeddable +public class Age { + + private static final int AGE_MIN = 0; + private static final int AGE_MAX = 150; + + private int age; + + protected Age() { + } + + public Age(int age) { + validateAge(age); + this.age = age; + } + + private void validateAge(int age) { + if (age < AGE_MIN || age >= AGE_MAX) { + throw new IllegalArgumentException(); + } + } + +} diff --git a/src/main/java/com/example/jpaboard/member/domain/Member.java b/src/main/java/com/example/jpaboard/member/domain/Member.java new file mode 100644 index 000000000..c0375d82f --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/domain/Member.java @@ -0,0 +1,60 @@ +package com.example.jpaboard.member.domain; + +import com.example.jpaboard.global.BaseEntity; + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "members") +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private Name name; + + @Embedded + private Age age; + + private String hobby; + + protected Member() { + } + + public Member(Name name, Age age, String hobby) { + this.name = name; + this.age = age; + this.hobby = hobby; + } + + public Member(Long id, Name name, Age age, String hobby) { + this.id = id; + this.name = name; + this.age = age; + this.hobby = hobby; + } + + public Long getId() { + return id; + } + + public Name getName() { + return name; + } + + public Age getAge() { + return age; + } + + public String getHobby() { + return hobby; + } + +} diff --git a/src/main/java/com/example/jpaboard/member/domain/Name.java b/src/main/java/com/example/jpaboard/member/domain/Name.java new file mode 100644 index 000000000..d525a2313 --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/domain/Name.java @@ -0,0 +1,33 @@ +package com.example.jpaboard.member.domain; + +import jakarta.persistence.Embeddable; + +@Embeddable +public class Name { + + private String value; + + protected Name() { + } + + public Name(String value) { + validateName(value); + this.value = value; + } + + public void changeName(String value) { + validateName(value); + this.value = value; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException(); + } + } + + public String getValue() { + return value; + } + +} diff --git a/src/main/java/com/example/jpaboard/member/service/MemberRepository.java b/src/main/java/com/example/jpaboard/member/service/MemberRepository.java new file mode 100644 index 000000000..1fdb84409 --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/service/MemberRepository.java @@ -0,0 +1,8 @@ +package com.example.jpaboard.member.service; + +import com.example.jpaboard.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberRepository extends JpaRepository { + +} diff --git a/src/main/java/com/example/jpaboard/member/service/MemberService.java b/src/main/java/com/example/jpaboard/member/service/MemberService.java new file mode 100644 index 000000000..f5ed54e05 --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/service/MemberService.java @@ -0,0 +1,27 @@ +package com.example.jpaboard.member.service; + +import com.example.jpaboard.global.exception.EntityNotFoundException; +import com.example.jpaboard.member.domain.Member; +import com.example.jpaboard.member.service.dto.MemberFindResponse; +import com.example.jpaboard.member.service.mapper.MemberMapper; + +import org.springframework.stereotype.Service; + +@Service +public class MemberService { + + private final MemberRepository memberRepository; + private final MemberMapper memberMapper; + + public MemberService(MemberRepository memberRepository, MemberMapper memberMapper) { + this.memberRepository = memberRepository; + this.memberMapper = memberMapper; + } + + public MemberFindResponse findById(Long id) { + Member member = memberRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("존재하지 않은 고객입니다.")); + + return new MemberFindResponse(member); + } + +} diff --git a/src/main/java/com/example/jpaboard/member/service/dto/MemberCreateRequest.java b/src/main/java/com/example/jpaboard/member/service/dto/MemberCreateRequest.java new file mode 100644 index 000000000..fb8212f3e --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/service/dto/MemberCreateRequest.java @@ -0,0 +1,8 @@ +package com.example.jpaboard.member.service.dto; + +import com.example.jpaboard.member.domain.Age; +import com.example.jpaboard.member.domain.Name; + +public record MemberCreateRequest(Name name, + Age age, + String hobby) { } diff --git a/src/main/java/com/example/jpaboard/member/service/dto/MemberFindResponse.java b/src/main/java/com/example/jpaboard/member/service/dto/MemberFindResponse.java new file mode 100644 index 000000000..fbf2043fa --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/service/dto/MemberFindResponse.java @@ -0,0 +1,41 @@ +package com.example.jpaboard.member.service.dto; + +import com.example.jpaboard.member.domain.Age; +import com.example.jpaboard.member.domain.Member; +import com.example.jpaboard.member.domain.Name; + +public class MemberFindResponse { + + private final Long id; + private final Name name; + private final Age age; + private final String hobby; + + public MemberFindResponse(Long id, Name name, Age age, String hobby) { + this.id = id; + this.name = name; + this.age = age; + this.hobby = hobby; + } + + public MemberFindResponse(Member member) { + this(member.getId(), member.getName(), member.getAge(), member.getHobby()); + } + + public Long getId() { + return id; + } + + public Name getName() { + return name; + } + + public Age getAge() { + return age; + } + + public String getHobby() { + return hobby; + } + +} diff --git a/src/main/java/com/example/jpaboard/member/service/mapper/MemberMapper.java b/src/main/java/com/example/jpaboard/member/service/mapper/MemberMapper.java new file mode 100644 index 000000000..d12a6de20 --- /dev/null +++ b/src/main/java/com/example/jpaboard/member/service/mapper/MemberMapper.java @@ -0,0 +1,23 @@ +package com.example.jpaboard.member.service.mapper; + +import com.example.jpaboard.member.domain.Age; +import com.example.jpaboard.member.domain.Member; +import com.example.jpaboard.member.domain.Name; +import com.example.jpaboard.member.service.dto.MemberCreateRequest; + +import org.springframework.stereotype.Component; + +@Component +public class MemberMapper { + + private MemberMapper() { } + + public Member to(MemberCreateRequest memberCreateRequest) { + Name name = memberCreateRequest.name(); + Age age = memberCreateRequest.age(); + String hobby = memberCreateRequest.hobby(); + + return new Member(name, age, hobby); + } + +} diff --git a/src/main/java/com/example/jpaboard/post/controller/PostController.java b/src/main/java/com/example/jpaboard/post/controller/PostController.java new file mode 100644 index 000000000..15b070760 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/controller/PostController.java @@ -0,0 +1,69 @@ +package com.example.jpaboard.post.controller; + +import com.example.jpaboard.global.ApiResponse; +import com.example.jpaboard.global.SliceResponse; +import com.example.jpaboard.global.SuccessCode; +import com.example.jpaboard.post.controller.dto.PostFindApiRequest; +import com.example.jpaboard.post.controller.dto.PostSaveApiRequest; +import com.example.jpaboard.post.controller.dto.PostUpdateApiRequest; +import com.example.jpaboard.post.controller.mapper.PostApiMapper; +import com.example.jpaboard.post.service.PostFacade; +import com.example.jpaboard.post.service.dto.*; + +import jakarta.validation.Valid; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; + +@RestController +@RequestMapping("/posts") +public class PostController { + + private final PostApiMapper postApiMapper; + private final PostFacade postFacade; + + public PostController( PostApiMapper postApiMapper, PostFacade postFacade) { + this.postApiMapper = postApiMapper; + this.postFacade = postFacade; + } + + @GetMapping + public SliceResponse findAllBy(@ModelAttribute PostFindApiRequest postRetrieveApiRequest, Pageable pageable) { + PostFindRequest postFindRequest = postApiMapper.toFindAllRequest(postRetrieveApiRequest); + + Slice postAllByFilter = postFacade.findAllByFilter(postFindRequest, pageable); + SliceResponse postResponseSliceResponse = new SliceResponse<>(postAllByFilter, SuccessCode.SELECT_SUCCESS); + return postResponseSliceResponse; + } + + @PatchMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse updatePost(@PathVariable Long id, @RequestBody @Valid PostUpdateApiRequest postUpdateApiRequest) { + PostUpdateRequest postUpdateRequest = postApiMapper.toUpdateRequest(postUpdateApiRequest); + return new ApiResponse<>(postFacade.updatePost(id, postUpdateRequest), SuccessCode.UPDATE_SUCCESS); + } + + @GetMapping("/{id}") + public ApiResponse findById(@PathVariable Long id) { + return new ApiResponse<>(postFacade.findById(id), SuccessCode.SELECT_SUCCESS); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity savePost(@RequestBody @Valid PostSaveApiRequest postSaveApiRequest) { + PostSaveRequest postSaveRequest = postApiMapper.toSaveRequest(postSaveApiRequest); + PostResponse saveResponse = postFacade.createPost(postSaveRequest); + + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(saveResponse.postId()) + .toUri(); + + return ResponseEntity.created(location).body(saveResponse); + } + +} diff --git a/src/main/java/com/example/jpaboard/post/controller/dto/PostFindApiRequest.java b/src/main/java/com/example/jpaboard/post/controller/dto/PostFindApiRequest.java new file mode 100644 index 000000000..3a1c38e0e --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/controller/dto/PostFindApiRequest.java @@ -0,0 +1,26 @@ +package com.example.jpaboard.post.controller.dto; + +public class PostFindApiRequest { + + private String title; + private String content; + + public PostFindApiRequest() { + title = ""; + content = ""; + } + + public PostFindApiRequest(String title, String content) { + this.title = title; + this.content = content; + } + + public String title() { + return title; + } + + public String content() { + return content; + } + +} diff --git a/src/main/java/com/example/jpaboard/post/controller/dto/PostSaveApiRequest.java b/src/main/java/com/example/jpaboard/post/controller/dto/PostSaveApiRequest.java new file mode 100644 index 000000000..5c77b6ced --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/controller/dto/PostSaveApiRequest.java @@ -0,0 +1,8 @@ +package com.example.jpaboard.post.controller.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record PostSaveApiRequest(@NotNull(message = "memberId 값이 입력되지 않았습니다.") Long memberId, + @NotBlank(message = "title 값이 입력되지 않았습니다.") String title, + @NotBlank(message = "content 값이 입력되지 않았습니다.") String content) { } diff --git a/src/main/java/com/example/jpaboard/post/controller/dto/PostUpdateApiRequest.java b/src/main/java/com/example/jpaboard/post/controller/dto/PostUpdateApiRequest.java new file mode 100644 index 000000000..06e394efa --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/controller/dto/PostUpdateApiRequest.java @@ -0,0 +1,9 @@ +package com.example.jpaboard.post.controller.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record PostUpdateApiRequest(@NotBlank(message = "title 값이 입력되지 않았습니다.") String title, + @NotBlank(message = "content 값이 입력되지 않았습니다.") String content, + @NotNull(message = "memberId 값이 입력되지 않았습니다.") Long memberId) { } + diff --git a/src/main/java/com/example/jpaboard/post/controller/mapper/PostApiMapper.java b/src/main/java/com/example/jpaboard/post/controller/mapper/PostApiMapper.java new file mode 100644 index 000000000..c1becfe8c --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/controller/mapper/PostApiMapper.java @@ -0,0 +1,33 @@ +package com.example.jpaboard.post.controller.mapper; + +import com.example.jpaboard.post.controller.dto.PostFindApiRequest; +import com.example.jpaboard.post.controller.dto.PostSaveApiRequest; +import com.example.jpaboard.post.controller.dto.PostUpdateApiRequest; +import com.example.jpaboard.post.service.dto.PostFindRequest; +import com.example.jpaboard.post.service.dto.PostSaveRequest; +import com.example.jpaboard.post.service.dto.PostUpdateRequest; + +import org.springframework.stereotype.Component; + +@Component +public class PostApiMapper { + + public PostFindRequest toFindAllRequest(PostFindApiRequest postRetrieveApiRequest) { + + return new PostFindRequest(postRetrieveApiRequest.title(), postRetrieveApiRequest.content()); + } + + public PostUpdateRequest toUpdateRequest(PostUpdateApiRequest postUpdateApiRequest) { + return new PostUpdateRequest(postUpdateApiRequest.title(), + postUpdateApiRequest.content(), + postUpdateApiRequest.memberId()); + } + + public PostSaveRequest toSaveRequest(PostSaveApiRequest postSaveApiRequest) { + return new PostSaveRequest(postSaveApiRequest.memberId(), + postSaveApiRequest.title(), + postSaveApiRequest.content()); + + } + +} diff --git a/src/main/java/com/example/jpaboard/post/domain/Post.java b/src/main/java/com/example/jpaboard/post/domain/Post.java new file mode 100644 index 000000000..b62b6deb3 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/domain/Post.java @@ -0,0 +1,76 @@ +package com.example.jpaboard.post.domain; + +import com.example.jpaboard.global.BaseEntity; +import com.example.jpaboard.member.domain.Member; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import org.springframework.util.Assert; + +import java.util.Objects; + +@Entity +@Table(name = "posts") +public class Post extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private String title; + + @Lob + @NotNull // 3 + @Column(nullable = false) // 2 + private String content; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) // LAZY 반영 + @JoinColumn(name = "member_id") // 흑구멘토님 의견 반영 관계 조건 확실하게 + private Member member; + + protected Post() { } + + Post(String title, String content, Member member) { // + this.title = title; + this.content = content; + this.member = member; + } + + public static Post create(String title, String content, Member member) { + Assert.notNull(title, "not null"); + Assert.notNull(content, "not null"); + Assert.notNull(member, "not null"); + + return new Post(title, content, member); + } + + public boolean isNotOwner(Long memberId) { + return !Objects.equals(member.getId(), memberId); + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public Member getMember() { + return member; + } + + public void changTitle(String title) { + this.title = title; + } + + public void changeContent(String content) { + this.content = content; + } + +} diff --git a/src/main/java/com/example/jpaboard/post/domain/PostRepository.java b/src/main/java/com/example/jpaboard/post/domain/PostRepository.java new file mode 100644 index 000000000..d303f5b73 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/domain/PostRepository.java @@ -0,0 +1,19 @@ +package com.example.jpaboard.post.domain; + +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Pageable; + +public interface PostRepository extends JpaRepository { + + @Query("SELECT p FROM Post p " + + "JOIN FETCH p.member " + + "WHERE (p.title LIKE %:title%) " + + "AND (p.content LIKE %:content%)") + Slice findPostAllByFilter(@Param("title") String title, + @Param("content") String content, + Pageable pageable); + +} diff --git a/src/main/java/com/example/jpaboard/post/service/PostFacade.java b/src/main/java/com/example/jpaboard/post/service/PostFacade.java new file mode 100644 index 000000000..061130685 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/service/PostFacade.java @@ -0,0 +1,41 @@ +package com.example.jpaboard.post.service; + +import com.example.jpaboard.member.service.MemberService; +import com.example.jpaboard.member.service.dto.MemberFindResponse; +import com.example.jpaboard.post.service.dto.PostFindRequest; +import com.example.jpaboard.post.service.dto.PostResponse; +import com.example.jpaboard.post.service.dto.PostSaveRequest; +import com.example.jpaboard.post.service.dto.PostUpdateRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; + +@Service +public class PostFacade { + private final MemberService memberService; + private final PostService postService; + + public PostFacade(MemberService memberService, PostService postService){ + this.memberService = memberService; + this.postService = postService; + } + + public PostResponse createPost(PostSaveRequest postSaveRequest) { + MemberFindResponse member = memberService.findById(postSaveRequest.memberId()); + + return postService.savePost(member,postSaveRequest); + } + + public PostResponse findById(Long postId) { + return postService.findById(postId); + } + + public Slice findAllByFilter(PostFindRequest postFindRequest, Pageable pageable) { + return postService.findAllByFilter(postFindRequest, pageable); + } + + public PostResponse updatePost(Long id, PostUpdateRequest request) { + return postService.updatePost(id, request); + } + +} diff --git a/src/main/java/com/example/jpaboard/post/service/PostService.java b/src/main/java/com/example/jpaboard/post/service/PostService.java new file mode 100644 index 000000000..382687519 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/service/PostService.java @@ -0,0 +1,64 @@ +package com.example.jpaboard.post.service; + +import com.example.jpaboard.member.service.dto.MemberFindResponse; +import com.example.jpaboard.post.domain.Post; +import com.example.jpaboard.post.domain.PostRepository; +import com.example.jpaboard.post.service.dto.PostFindRequest; +import com.example.jpaboard.post.service.dto.PostResponse; +import com.example.jpaboard.post.service.dto.PostSaveRequest; +import com.example.jpaboard.post.service.dto.PostUpdateRequest; +import com.example.jpaboard.post.service.mapper.PostMapper; +import com.example.jpaboard.global.exception.EntityNotFoundException; +import com.example.jpaboard.global.exception.UnauthorizedEditException; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@Service +public class PostService { + + private final PostRepository postRepository; + private final PostMapper mapper; + + public PostService(PostRepository postRepository, PostMapper mapper) { + this.postRepository = postRepository; + this.mapper = mapper; + } + + public Slice findAllByFilter(PostFindRequest postFindRequest, Pageable pageable) { + Slice results = postRepository.findPostAllByFilter(postFindRequest.title(), postFindRequest.content(), pageable); + return results.map(PostResponse::new); + } + + public PostResponse findById(Long postId) { + Post findPost = postRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException("해당 post가 존재하지 않습니다.")); + return new PostResponse(findPost); + } + + @Transactional + public PostResponse savePost(MemberFindResponse member ,PostSaveRequest request) { + Post post = mapper.to(request, member); + + return new PostResponse(postRepository.save(post)); + } + + @Transactional + public PostResponse updatePost(Long id, PostUpdateRequest request) { + Post findPost = postRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("해당 post가 존재하지 않습니다.")); + + if (findPost.isNotOwner(request.memberId())){ + throw new UnauthorizedEditException("해당 게시글을 수정할 권한이 없습니다."); + } + + findPost.changTitle(request.title()); + findPost.changeContent(request.content()); + + return new PostResponse(findPost); + } + +} diff --git a/src/main/java/com/example/jpaboard/post/service/dto/PostFindRequest.java b/src/main/java/com/example/jpaboard/post/service/dto/PostFindRequest.java new file mode 100644 index 000000000..322da2536 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/service/dto/PostFindRequest.java @@ -0,0 +1,4 @@ +package com.example.jpaboard.post.service.dto; + +public record PostFindRequest(String title , + String content) { } diff --git a/src/main/java/com/example/jpaboard/post/service/dto/PostResponse.java b/src/main/java/com/example/jpaboard/post/service/dto/PostResponse.java new file mode 100644 index 000000000..293682ed8 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/service/dto/PostResponse.java @@ -0,0 +1,15 @@ +package com.example.jpaboard.post.service.dto; + +import com.example.jpaboard.member.domain.Name; +import com.example.jpaboard.post.domain.Post; + +public record PostResponse(Long postId, + String title, + String content, + Name memberName) { + + public PostResponse(Post post) { + this(post.getId(), post.getTitle(), post.getContent(), post.getMember().getName()); + } + +} diff --git a/src/main/java/com/example/jpaboard/post/service/dto/PostSaveRequest.java b/src/main/java/com/example/jpaboard/post/service/dto/PostSaveRequest.java new file mode 100644 index 000000000..fbd082306 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/service/dto/PostSaveRequest.java @@ -0,0 +1,5 @@ +package com.example.jpaboard.post.service.dto; + +public record PostSaveRequest(Long memberId, + String title, + String content) { } diff --git a/src/main/java/com/example/jpaboard/post/service/dto/PostUpdateRequest.java b/src/main/java/com/example/jpaboard/post/service/dto/PostUpdateRequest.java new file mode 100644 index 000000000..b1263a765 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/service/dto/PostUpdateRequest.java @@ -0,0 +1,5 @@ +package com.example.jpaboard.post.service.dto; + +public record PostUpdateRequest(String title, + String content, + Long memberId) { } diff --git a/src/main/java/com/example/jpaboard/post/service/mapper/PostMapper.java b/src/main/java/com/example/jpaboard/post/service/mapper/PostMapper.java new file mode 100644 index 000000000..34d2714c2 --- /dev/null +++ b/src/main/java/com/example/jpaboard/post/service/mapper/PostMapper.java @@ -0,0 +1,23 @@ +package com.example.jpaboard.post.service.mapper; + +import com.example.jpaboard.member.domain.Member; +import com.example.jpaboard.member.service.dto.MemberFindResponse; +import com.example.jpaboard.post.domain.Post; +import com.example.jpaboard.post.service.dto.PostSaveRequest; + +import org.springframework.stereotype.Component; + +@Component +public class PostMapper { + + private PostMapper() { } + + public Post to(PostSaveRequest request, MemberFindResponse memberResponse) { + Member member = new Member(memberResponse.getId(), memberResponse.getName(), + memberResponse.getAge(), memberResponse.getHobby()); + return Post.create(request.title(), + request.content(), + member); + } + +} diff --git a/src/main/java/com/example/jpaboard/rpc/RpcInput.java b/src/main/java/com/example/jpaboard/rpc/RpcInput.java new file mode 100644 index 000000000..d6b63361a --- /dev/null +++ b/src/main/java/com/example/jpaboard/rpc/RpcInput.java @@ -0,0 +1,23 @@ +package com.example.jpaboard.rpc; + +import com.example.jpaboard.post.domain.Post; +import com.example.jpaboard.post.service.PostService; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@RequiredArgsConstructor +public class RpcInput { + + private final PostService postService; + + public void create(Map request) { + + var title = request.get("title"); + + var content = request.get("content"); + + Post.create(title, content, null); + } + +} diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html new file mode 100644 index 000000000..0110dcbe4 --- /dev/null +++ b/src/main/resources/static/docs/index.html @@ -0,0 +1,864 @@ + + + + + + + +API Docs + + + + + + +
+
+

게시판

+
+ +
+

/posts

+
+
Request
+
+
$ curl 'http://localhost:8080/posts?title=Your+Title&content=Your+Content' -i -X GET
+
+
+
+
+
GET /posts?title=Your+Title&content=Your+Content HTTP/1.1
+Host: localhost:8080
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1124
+
+{"result":{"content":[{"postId":1,"title":"흑구팀 화이팅팅","content":"언제나 응원해 흑구팀팀","memberName":"\"김별\""},{"postId":2,"title":"흑구영수팀 화이팅","content":"흑구영수팀 언제나 응원해","memberName":"\"김별\""},{"postId":3,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":4,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":5,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":6,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""},{"postId":7,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""}],"pageable":{"sort":{"empty":true,"unsorted":true,"sorted":false},"offset":0,"pageNumber":0,"pageSize":20,"paged":true,"unpaged":false},"size":20,"number":0,"sort":{"empty":true,"unsorted":true,"sorted":false},"first":true,"last":true,"numberOfElements":7,"empty":false},"resultCode":200,"resultMsg":"SELECT SUCCESS"}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

result

Object

Result details

result.content[]

Array

The list of content items

result.pageable

Object

Pagination information

result.pageable.sort.empty

Boolean

Indicates if the sort is empty

result.pageable.sort.sorted

Boolean

Indicates if the sort is sorted

result.pageable.sort.unsorted

Boolean

Indicates if the sort is unsorted

result.pageable.offset

Number

Offset value for pagination

result.pageable.pageSize

Number

Page size for pagination

result.pageable.pageNumber

Number

Page number

result.pageable.unpaged

Boolean

Indicates if the page is unpaged

result.pageable.paged

Boolean

Indicates if the page is paged

result.size

Number

The page size

result.number

Number

The current page number

result.sort.empty

Boolean

Indicates if the sort is empty

result.sort.sorted

Boolean

Indicates if the sort is sorted

result.sort.unsorted

Boolean

Indicates if the sort is unsorted

result.first

Boolean

Indicates if this is the first page

result.last

Boolean

Indicates if this is the last page

result.numberOfElements

Number

Number of elements in the current page

result.empty

Boolean

Indicates if the content is empty

resultCode

Number

The result code of the response

resultMsg

String

The result message of the response

+
+ +
+

/posts/{id}

+
+
Request
+
+
$ curl 'http://localhost:8080/posts/1' -i -X PATCH \
+    -H 'Content-Type: application/json;charset=UTF-8' \
+    -d '{"title": "흑구팀 화이팅팅", "content": "언제나 응원해 흑구팀팀", "memberId": 1}'
+
+
+
+
+
PATCH /posts/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 97
+Host: localhost:8080
+
+{"title": "흑구팀 화이팅팅", "content": "언제나 응원해 흑구팀팀", "memberId": 1}
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 173
+
+{"result":{"postId":1,"title":"흑구팀 화이팅팅","content":"언제나 응원해 흑구팀팀","memberName":"\"김별\""},"resultCode":204,"resultMsg":"UPDATE SUCCESS"}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

result.postId

Number

The id of the updated post

result.title

String

The title of the updated post

result.content

String

The content of the updated post

result.memberName

String

The name of the member who made the update

resultCode

Number

The result code of the response

resultMsg

String

The result message of the response

+
+ +
+

/posts/{id}

+
+
Request
+
+
$ curl 'http://localhost:8080/posts/1' -i -X GET
+
+
+
+
+
GET /posts/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
Response
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 173
+
+{"result":{"postId":1,"title":"흑구팀 화이팅팅","content":"언제나 응원해 흑구팀팀","memberName":"\"김별\""},"resultCode":200,"resultMsg":"SELECT SUCCESS"}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

result.postId

Number

The id of the detailed post

result.title

String

The title of the detailed post

result.content

String

The content of the detailed post

result.memberName

String

The name of writer

resultCode

Number

The result code of the response

resultMsg

String

The result message of the response

+
+ +
+

/posts

+
+
Request
+
+
$ curl 'http://localhost:8080/posts' -i -X POST \
+    -H 'Content-Type: application/json;charset=UTF-8' \
+    -d '{"memberId":1,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀"}'
+
+
+
+
+
POST /posts HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 84
+Host: localhost:8080
+
+{"memberId":1,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀"}
+
+
+
+
Response
+
+
HTTP/1.1 201 Created
+Location: http://localhost:8080/posts/7
+Content-Type: application/json;charset=UTF-8
+Content-Length: 108
+
+{"postId":7,"title":"흑구영수팀 화이팅","content":"흑구영수팀팀팀","memberName":"\"김별\""}
+
+
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

postId

Number

The id of the saved post

title

String

The title of the saved post

content

String

The content of the saved post

memberName

String

The name of writer

+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/test/java/com/example/jpaboard/member/domain/AgeTest.java b/src/test/java/com/example/jpaboard/member/domain/AgeTest.java new file mode 100644 index 000000000..3a50ca0be --- /dev/null +++ b/src/test/java/com/example/jpaboard/member/domain/AgeTest.java @@ -0,0 +1,20 @@ +package com.example.jpaboard.member.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class AgeTest { + + static final int MINUS_AGE = -10; + + @Test + @DisplayName("나이에 대해서 음수를 입력한 경우 예외를 던진다.") + void member_MinusAge_throwsException() { + //when_then + assertThatThrownBy(() -> new Age(MINUS_AGE)) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/src/test/java/com/example/jpaboard/member/service/MemberServiceTest.java b/src/test/java/com/example/jpaboard/member/service/MemberServiceTest.java new file mode 100644 index 000000000..e82a7f9e8 --- /dev/null +++ b/src/test/java/com/example/jpaboard/member/service/MemberServiceTest.java @@ -0,0 +1,48 @@ +package com.example.jpaboard.member.service; + +import com.example.jpaboard.member.domain.Age; +import com.example.jpaboard.member.domain.Member; +import com.example.jpaboard.member.domain.Name; +import com.example.jpaboard.member.service.dto.MemberFindResponse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +@SpringBootTest +@Transactional +class MemberServiceTest { + + @Autowired + MemberRepository memberRepository; + + @Autowired + MemberService memberService; + Long id; + + @BeforeEach + void setUp() { + Member member = new Member(new Name("김별"), new Age(26), "산책"); + memberRepository.save(member); + id = member.getId(); + } + + @Test + void findById_Member_Equals() { + //when + MemberFindResponse response = memberService.findById(id); + + //then + assertThat(response.getName().getValue()).isEqualTo("김별"); + assertThat(response.getHobby()).isEqualTo("산책"); + assertThat(response.getAge()).usingRecursiveComparison().isEqualTo(new Age(26)); + } + +} diff --git a/src/test/java/com/example/jpaboard/post/controller/PostControllerTest.java b/src/test/java/com/example/jpaboard/post/controller/PostControllerTest.java new file mode 100644 index 000000000..f3e598e97 --- /dev/null +++ b/src/test/java/com/example/jpaboard/post/controller/PostControllerTest.java @@ -0,0 +1,151 @@ +package com.example.jpaboard.post.controller; + +import com.example.jpaboard.post.controller.dto.PostSaveApiRequest; + +import com.example.jpaboard.post.controller.dto.PostUpdateApiRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +class PostControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void findAllByFilter() throws Exception { + FieldDescriptor[] responseFields = new FieldDescriptor[] { + subsectionWithPath("result").description("Result details"), + fieldWithPath("result.content[]").description("The list of content items"), + subsectionWithPath("result.pageable").description("Pagination information"), + fieldWithPath("result.pageable.sort.empty").description("Indicates if the sort is empty"), + fieldWithPath("result.pageable.sort.sorted").description("Indicates if the sort is sorted"), + fieldWithPath("result.pageable.sort.unsorted").description("Indicates if the sort is unsorted"), + fieldWithPath("result.pageable.offset").description("Offset value for pagination"), + fieldWithPath("result.pageable.pageSize").description("Page size for pagination"), + fieldWithPath("result.pageable.pageNumber").description("Page number"), + fieldWithPath("result.pageable.unpaged").description("Indicates if the page is unpaged"), + fieldWithPath("result.pageable.paged").description("Indicates if the page is paged"), + fieldWithPath("result.size").description("The page size"), + fieldWithPath("result.number").description("The current page number"), + fieldWithPath("result.sort.empty").description("Indicates if the sort is empty"), + fieldWithPath("result.sort.sorted").description("Indicates if the sort is sorted"), + fieldWithPath("result.sort.unsorted").description("Indicates if the sort is unsorted"), + fieldWithPath("result.first").description("Indicates if this is the first page"), + fieldWithPath("result.last").description("Indicates if this is the last page"), + fieldWithPath("result.numberOfElements").description("Number of elements in the current page"), + fieldWithPath("result.empty").description("Indicates if the content is empty"), + fieldWithPath("resultCode").description("The result code of the response"), + fieldWithPath("resultMsg").description("The result message of the response") + }; + + this.mockMvc.perform(get("/posts") + .param("title", "Your Title") + .param("content", "Your Content")) + .andExpect(status().isOk()) + .andDo(print()) + .andDo(document("post-findAllBy", + responseFields(responseFields) + )); + } + + @Test + public void updatePost() throws Exception { + FieldDescriptor[] requestFields = new FieldDescriptor[] { + fieldWithPath("title").description("Updated title of the post"), + fieldWithPath("content").description("Updated content of the post"), + fieldWithPath("memberId").description("ID of the member making the update") + }; + + FieldDescriptor[] responseFields = new FieldDescriptor[] { + fieldWithPath("result.postId").description("The id of the updated post"), + fieldWithPath("result.title").description("The title of the updated post"), + fieldWithPath("result.content").description("The content of the updated post"), + fieldWithPath("result.memberName").description("The name of the member who made the update"), + fieldWithPath("resultCode").description("The result code of the response"), + fieldWithPath("resultMsg").description("The result message of the response") + }; + + + String requestBody = objectMapper.writeValueAsString(new PostUpdateApiRequest("흑구팀 화이팅팅", "언제나 응원해 흑구팀팀", 1L)); + + this.mockMvc.perform( + patch("/posts/{id}", 1L) + .content(requestBody) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(document("post-update", + requestFields(requestFields), + responseFields(responseFields) + )); + } + + @Test + void findById() throws Exception { + FieldDescriptor[] responseFields = new FieldDescriptor[] { + fieldWithPath("result.postId").description("The id of the detailed post"), + fieldWithPath("result.title").description("The title of the detailed post"), + fieldWithPath("result.content").description("The content of the detailed post"), + fieldWithPath("result.memberName").description("The name of writer"), + fieldWithPath("resultCode").description("The result code of the response"), + fieldWithPath("resultMsg").description("The result message of the response") + }; + + this.mockMvc.perform( + get("/posts/{id}", 1L) + ) + .andExpect(status().isOk()) + .andDo(document("post-findById", + responseFields(responseFields) + )); + } + + @Test + void savePost() throws Exception { + PostSaveApiRequest postSaveApiRequest = new PostSaveApiRequest(1L,"흑구영수팀 화이팅","흑구영수팀팀팀"); + + FieldDescriptor[] requestFields = new FieldDescriptor[] { + fieldWithPath("title").description("Saved title of the post"), + fieldWithPath("content").description("Saved content of the post"), + fieldWithPath("memberId").description("ID of the member making the post") + }; + + FieldDescriptor[] responseFields = new FieldDescriptor[] { + fieldWithPath("postId").description("The id of the saved post"), + fieldWithPath("title").description("The title of the saved post"), + fieldWithPath("content").description("The content of the saved post"), + fieldWithPath("memberName").description("The name of writer"), + }; + + this.mockMvc.perform( + post("/posts") + .content(objectMapper.writeValueAsString(postSaveApiRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andDo(document("post-save", + requestFields(requestFields), + responseFields(responseFields) + )); + } + +} diff --git a/src/test/java/com/example/jpaboard/post/service/PostServiceTest.java b/src/test/java/com/example/jpaboard/post/service/PostServiceTest.java new file mode 100644 index 000000000..efa018c46 --- /dev/null +++ b/src/test/java/com/example/jpaboard/post/service/PostServiceTest.java @@ -0,0 +1,176 @@ +package com.example.jpaboard.post.service; + +import com.example.jpaboard.member.domain.Age; +import com.example.jpaboard.member.domain.Member; +import com.example.jpaboard.member.domain.Name; +import com.example.jpaboard.member.service.MemberRepository; +import com.example.jpaboard.member.service.dto.MemberFindResponse; +import com.example.jpaboard.post.domain.Post; +import com.example.jpaboard.post.domain.PostRepository; +import com.example.jpaboard.global.exception.EntityNotFoundException; +import com.example.jpaboard.global.exception.UnauthorizedEditException; +import com.example.jpaboard.post.service.dto.PostFindRequest; +import com.example.jpaboard.post.service.dto.PostResponse; +import com.example.jpaboard.post.service.dto.PostSaveRequest; +import com.example.jpaboard.post.service.dto.PostUpdateRequest; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchException; + + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@Transactional +class PostServiceTest { + + @Autowired + PostService postService; + @Autowired + PostRepository postRepository; + @Autowired + MemberRepository memberRepository; + + + Long setupPostId1; + + Long setupPostId2; + Long setupMemberId2; + + Long setupMemberId3; + + @BeforeEach + void setup() { + setupData(); + } + + @Test + @DisplayName("setup에서 저장한 post를 필터 없이 10개 조회하여 개수를 확인한다.") + void findAllBy_pageable_PostResponses() { + //given + PostFindRequest postFindRequest = new PostFindRequest("", ""); + + //when + Slice findPosts = postService.findAllByFilter(postFindRequest, PageRequest.of(0, 10)); + + //then + assertThat(findPosts.getContent().size()).isEqualTo(2); + } + + @Test + @DisplayName("setup에서 저장한 post를 조회하여 title과 memberName을 확인한다.") + void findById_correctPostId_PostResponse() { + //when + PostResponse response = postService.findById(setupPostId1); + + //then + String findTitle = response.title(); + Name findMemberName = response.memberName(); + assertThat(findTitle).isEqualTo("별의 포스트 제목"); + assertThat(findMemberName.getValue()).isEqualTo("김별"); + } + + @Test + @DisplayName("존재하지 않는 postId를 통해 post를 조회할 때 EntityNotFoundException이 발생하는지 확인한다.") + void findById_inCorrectPostId_EntityNotFoundException() { + //when + Exception exception = catchException(() -> postService.findById(setupMemberId3)); + + //then + assertThat(exception).isInstanceOf(EntityNotFoundException.class); + } + + @Test + @DisplayName("post 저장 후 title과 memberName을 확인한다.") + void savePost_correctSaveRequest_postResponse() { + //given + PostSaveRequest postSaveRequest = new PostSaveRequest(setupMemberId3, "산책의 정석", "산책의 정석 내용"); + MemberFindResponse member = new MemberFindResponse(setupMemberId3,new Name("박세영"), new Age(27), "산책"); + + //when + PostResponse postResponse = postService.savePost(member, postSaveRequest); + + //then + String savedTitle = postResponse.title(); + Name savedMemberName = postResponse.memberName(); + assertThat(savedTitle).isEqualTo("산책의 정석"); + assertThat(savedMemberName.getValue()).isEqualTo("박세영"); + } + + @Test + @DisplayName("setUp에서 저장한 post를 update후 title과 content를 확인한다.") + void updatePost_IdAndUpdateRequest_PostResponse() { + //given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("영운의 변경된 postTitle", "영운의 변경된 content", setupMemberId2); + + //when + PostResponse postResponse = postService.updatePost(setupPostId2, postUpdateRequest); + + //then + String updatedTitle = postResponse.title(); + String updatedContent = postResponse.content(); + assertThat(updatedTitle).isEqualTo("영운의 변경된 postTitle"); + assertThat(updatedContent).isEqualTo("영운의 변경된 content"); + } + + @Test + @DisplayName("setUp에서 저장한 post를 올바르지 않은 memberId로 수정할 때 PermissionDeniedEditException이 발생하는지 확인한다.") + void updatePost_IdAndIncorrectUpdateRequest_PermissionDeniedEditException() { + //given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("영운의 변경된 postTitle", "영운의 변경된 content", setupMemberId3); + + //when + Exception exception = catchException(() -> postService.updatePost(setupPostId2, postUpdateRequest)); + + //then + assertThat(exception).isInstanceOf(UnauthorizedEditException.class); + } + + @Test + @DisplayName("존재하지 않는 postId로 post를 수정할 때 EntityNotFoundException이 발생하는지 확인한다.") + void updatePost_incorrectIdAndUpdateRequest_EntityNotFoundException() { + //given + PostUpdateRequest postUpdateRequest = new PostUpdateRequest("영운의 변경된 postTitle", "영운의 변경된 content", setupMemberId2); + Random random = new Random(); + + //when + Exception exception = catchException(() -> postService.updatePost(random.nextLong(), postUpdateRequest)); + + //then + assertThat(exception).isInstanceOf(EntityNotFoundException.class); + } + + private void setupData() { + Member member1 = new Member(new Name("김별"), new Age(27), "락 부르기"); + Member member2 = new Member(new Name("윤영운"), new Age(27), "저글링 돌리기"); + Member member3 = new Member(new Name("박세영"), new Age(27), "산책"); + + memberRepository.save(member1); + memberRepository.save(member2); + memberRepository.save(member3); + + Post post1 = Post.create("별의 포스트 제목", "별의 포스트 내용", member1); + Post post2 = Post.create("영운의 포스트 제목", "영운의 포스트 내용", member2); + + postRepository.save(post1); + postRepository.save(post2); + + setupPostId1 = post1.getId(); + setupPostId2 = post2.getId(); + setupMemberId2 = member2.getId(); + setupMemberId3 = member3.getId(); + } + +}