Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5기] 4주차 쇼핑몰 과제 제출 - 점프 #4

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a856852
docs(reamde): 요구사항정리
who-is-hu Jul 10, 2024
e61b311
feat(common): api 통신 관련 공용 클래스
who-is-hu Jul 14, 2024
4cdb382
feat(product): 기본 엔티티 클래스
who-is-hu Jul 14, 2024
3c8dab8
feat(product): 상품 등록 API
who-is-hu Jul 14, 2024
5d83893
feat(product): 상품 수정 API
who-is-hu Jul 15, 2024
12a5e3a
feat(product): 상품 조회 API
who-is-hu Jul 15, 2024
c8551bb
feat(product): 상품 삭제 API
who-is-hu Jul 15, 2024
08b11c6
feat(common): 요청에 파라미터 안쓰는 경우 예외처리 추가
who-is-hu Jul 16, 2024
cd6ac86
feat(user): 유저 회원가입 API
who-is-hu Jul 16, 2024
2f443ac
feat(user): 로그인 API
who-is-hu Jul 16, 2024
c74c88d
refactor(common): code -> errorCode 변수명 명확하게 변경
who-is-hu Jul 18, 2024
e6b6ba0
feat(common): 사용자 인증 기능 구현
who-is-hu Jul 18, 2024
5645d36
feat(wishlist): 위시리스트 등록 API
who-is-hu Jul 18, 2024
e46f60a
feat: 위시리스트 상품 조회 API
who-is-hu Jul 19, 2024
ad5726f
feat(wishlist): 위시리스트 삭제 API
who-is-hu Jul 19, 2024
9b4c97c
remove(product): 사용하지 않는 코드 제거
who-is-hu Jul 24, 2024
9a9b8ce
feat(product): 한글도 입력가능하게
who-is-hu Jul 24, 2024
4b6d30d
chore(common): 400대 에러들 로그레벨 warn 으로
who-is-hu Jul 24, 2024
c3b223a
chore(common): 비즈니스 로직에러 400대 응답하게
who-is-hu Jul 24, 2024
f4b6c6d
test(product): 상품이름 검증 테스트
who-is-hu Jul 24, 2024
c165f39
chore(common): 인증 관련된것들 패키지 묶기
who-is-hu Jul 25, 2024
e833117
feat(common): 토큰관련 에러들 더 세부적으로 나누기
who-is-hu Jul 27, 2024
eff7b58
refactor(user): user 엔티티와 테이블이름 member로 변경
who-is-hu Jul 27, 2024
e08c64b
test(member): 멤버 회원가입 로그인 테스트
who-is-hu Jul 27, 2024
3168b67
refactor(ProductTest): 상품 생성 요청 픽스처로 분리
who-is-hu Jul 27, 2024
97cb175
test(wishlist): 위시리스트 테스트
who-is-hu Jul 27, 2024
dbd3bf6
remove(WishlistTest): 안쓰는 클래스 제거
who-is-hu Jul 27, 2024
e95c87a
test(product): 비속어 검사 테스트에는 Mock 으로 사용
who-is-hu Jul 28, 2024
75c2d19
refactor(ProductControllerTest): api 요청 함수 분리
who-is-hu Jul 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,26 @@
# spring-shopping-product
# spring-shopping-product
## 기능요구사항
### 상품
- [x] 상품 등록 api
- [x] 공백 포함 15자 검사
- [x] 특정 특수문자 의외의 특수문자 존재하지 않는지 검사
- [x] 비속어 검사
- [x] 상품 조회 api
- [x] 상품 수정 api
- [x] 상품명 형식 검사
- [x] 상품 삭제 api
### 로그인
- [x] 회원가입
- [x] 이메일 중복 검사
- [x] 이메일 형식 검사
- [x] 비밀번호 암호화
- [x] 비밀번호 형식 검사
- [ ] 로그인
- [x] 로그인시 토큰 발급 api
- [ ] 엑세스 토큰 재발급 api
### 위시리스트
- [x] 위시리스트 상품목록 조회 api
- [x] 위시리스트 상품 추가 api
- [x] 중복 추가 방지
- [x] 상품 존재 여부 검사
- [x] 위시리스트 상품 삭제 api
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@ dependencies {
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-mysql")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("io.rest-assured:rest-assured:5.5.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

Expand Down
11 changes: 0 additions & 11 deletions src/main/java/shopping/Application.java

This file was deleted.

Empty file removed src/main/kotlin/shopping/.gitkeep
Empty file.
11 changes: 11 additions & 0 deletions src/main/kotlin/shopping/Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package shopping

import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication

@SpringBootApplication
class Application

fun main(args: Array<String>) {
SpringApplication.run(Application::class.java, *args)
}
16 changes: 16 additions & 0 deletions src/main/kotlin/shopping/common/api/ApiResponse.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package shopping.common.api

import shopping.common.error.ErrorMessage

class ApiResponse<T> private constructor(
val data: T? = null,
val error: ErrorMessage? = null,
) {
companion object {
fun success(): ApiResponse<Unit> = ApiResponse()

fun <T> success(data: T): ApiResponse<T> = ApiResponse(data = data)

fun error(errorMessage: ErrorMessage?): ApiResponse<Unit> = ApiResponse(error = errorMessage)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package shopping.common.api

import io.jsonwebtoken.ExpiredJwtException
import io.jsonwebtoken.JwtException
import org.springframework.core.MethodParameter
import org.springframework.http.HttpHeaders.AUTHORIZATION
import org.springframework.stereotype.Component
import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer
import shopping.common.auth.InvalidTokenException
import shopping.common.auth.JwtProvider
import shopping.common.auth.TokenExpiredException
import shopping.common.auth.TokenMissingException
import shopping.common.domain.CurrentMember
import shopping.member.application.MemberNotFoundException
import shopping.member.domain.MemberRepository

@Component
class CurrentMemberArgumentResolver(
private val jwtProvider: JwtProvider,
private val memberRepository: MemberRepository,
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.parameterType == CurrentMember::class.java

override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?,
): Any? {
val token = getToken(webRequest)
val email = getEmail(token)

memberRepository.findByEmail(email)?.let {
return CurrentMember(it.id, it.email)
} ?: throw MemberNotFoundException.fromEmail(email)
}

private fun getEmail(token: String) =
try {
jwtProvider.getSubject(token)
} catch (e: Exception) {
when (e) {
is ExpiredJwtException -> throw TokenExpiredException()
is JwtException, is IllegalArgumentException -> throw InvalidTokenException(e)
else -> throw e
}
}

private fun getToken(webRequest: NativeWebRequest): String {
val token = webRequest.getHeader(AUTHORIZATION) ?: throw TokenMissingException()
return token.split(" ").getOrNull(1) ?: throw InvalidTokenException()
}
}
115 changes: 115 additions & 0 deletions src/main/kotlin/shopping/common/api/GlobalErrorHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package shopping.common.api

import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.exc.MismatchedInputException
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
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 shopping.common.auth.LoginFailedException
import shopping.common.error.ApiException
import shopping.common.error.ErrorCode
import shopping.common.error.ErrorMessage

@RestControllerAdvice
class GlobalErrorHandler {
private val logger: Logger = LoggerFactory.getLogger(GlobalErrorHandler::class.java)

@ExceptionHandler(LoginFailedException::class)
fun loginFailedExceptionHandler(e: LoginFailedException): ResponseEntity<ApiResponse<Unit>> {
logger.warn("LoginFailedException: {}", e.message, e)
val errorMessage =
ErrorMessage(
errorCode = ErrorCode.LOGIN_FAILED,
message = e.message,
)

return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(errorMessage))
}

@ExceptionHandler(HttpMessageNotReadableException::class)
fun httpMessageNotReadableExceptionHandler(e: HttpMessageNotReadableException): ResponseEntity<ApiResponse<Unit>> {
logger.warn("HttpMessageNotReadableException: {}", e.message, e)
val data =
when (val causeException = e.cause) {
is MismatchedInputException -> causeException.path[0].toData()
else -> null
}

val errorMessage =
ErrorMessage(
errorCode = ErrorCode.VALIDATION_ERROR,
message = e.message ?: "Missing field error",
data = data,
)

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(errorMessage))
}

@ExceptionHandler(MethodArgumentNotValidException::class)
fun requestValidationExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Unit>> {
logger.warn("MethodArgumentNotValidException: {}", e.message, e)
val errorMessage =
ErrorMessage(
errorCode = ErrorCode.VALIDATION_ERROR,
message = e.message,
data = e.bindingResult.fieldErrors.map { it.toData() },
)

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(errorMessage))
}

@ExceptionHandler(ApiException::class)
fun apiExceptionHandler(e: ApiException): ResponseEntity<ApiResponse<Unit>> {
logger.warn("ApiException: {}", e.message, e)
val errorMessage =
ErrorMessage(
errorCode = e.errorCode,
message = e.message,
data = e.data,
)

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(errorMessage))
}

@ExceptionHandler(Exception::class)
fun exceptionHandler(e: Exception): ResponseEntity<ApiResponse<Unit>> {
logger.error("Exception: {}", e.message, e)
val errorMessage =
ErrorMessage(
errorCode = ErrorCode.UNKNOWN_ERROR,
message = e.message ?: "Unknown Error",
)

return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(errorMessage))
}
}

private fun FieldError.toData() =
mapOf(
"field" to field,
"rejectedValue" to rejectedValue,
"message" to defaultMessage,
)

private fun JsonMappingException.Reference.toData() =
mapOf(
"field" to fieldName,
"rejectedValue" to null,
"message" to "null 이면 안됩니다.",
)
12 changes: 12 additions & 0 deletions src/main/kotlin/shopping/common/auth/AuthUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package shopping.common.auth

import java.security.MessageDigest

object AuthUtil {
private val sha256Digest = MessageDigest.getInstance("SHA-256")

fun encryptSha256(text: String): String =
sha256Digest.digest(text.toByteArray()).joinToString("") {
"%02x".format(it)
}
}
11 changes: 11 additions & 0 deletions src/main/kotlin/shopping/common/auth/InvalidTokenException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package shopping.common.auth

import shopping.common.error.ErrorCode

class InvalidTokenException(
cause: Throwable? = null,
) : LoginFailedException(
errorCode = ErrorCode.INVALID_TOKEN,
message = "토큰이 유효하지 않습니다.",
cause = cause,
)
41 changes: 41 additions & 0 deletions src/main/kotlin/shopping/common/auth/JwtProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package shopping.common.auth

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.Date

const val ONE_WEEK_MILLISECOND: Long = 1000 * 60 * 60 * 24 * 7

@Component
class JwtProvider(
@Value("\${jwt.secret-key}")
private val secretKey: String,
@Value("\${jwt.expiration-millis-second}")
private val expirationMillisSecond: Long = ONE_WEEK_MILLISECOND,
) {
private val signingKey = Keys.hmacShaKeyFor(secretKey.toByteArray())

fun createToken(subject: String): String {
val now = Date()
val expireAt = Date(now.time + expirationMillisSecond)

return Jwts
.builder()
.subject(subject)
.issuedAt(now)
.expiration(expireAt)
.signWith(signingKey)
.compact()
}

fun getSubject(token: String): String =
Jwts
.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.payload
.subject
}
10 changes: 10 additions & 0 deletions src/main/kotlin/shopping/common/auth/LoginFailedException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package shopping.common.auth

import shopping.common.error.ApiException
import shopping.common.error.ErrorCode

abstract class LoginFailedException(
errorCode: ErrorCode,
message: String,
cause: Throwable? = null,
) : ApiException(errorCode, message, cause)
9 changes: 9 additions & 0 deletions src/main/kotlin/shopping/common/auth/TokenExpiredException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package shopping.common.auth

import shopping.common.error.ErrorCode

class TokenExpiredException :
LoginFailedException(
errorCode = ErrorCode.TOKEN_EXPIRED,
message = "토큰이 만료되었습니다.",
)
9 changes: 9 additions & 0 deletions src/main/kotlin/shopping/common/auth/TokenMissingException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package shopping.common.auth

import shopping.common.error.ErrorCode

class TokenMissingException :
LoginFailedException(
errorCode = ErrorCode.TOKEN_NOT_FOUND,
message = "인증정보가 없습니다.",
)
22 changes: 22 additions & 0 deletions src/main/kotlin/shopping/common/domain/BaseEntity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package shopping.common.domain

import jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.LocalDateTime

@MappedSuperclass
abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0

@CreationTimestamp
@Column
val createdAt: LocalDateTime? = null

@UpdateTimestamp
@Column
var updatedAt: LocalDateTime? = null
protected set
}
6 changes: 6 additions & 0 deletions src/main/kotlin/shopping/common/domain/CurrentMember.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package shopping.common.domain

data class CurrentMember(
val id: Long,
val email: String,
)
8 changes: 8 additions & 0 deletions src/main/kotlin/shopping/common/error/ApiException.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package shopping.common.error

open class ApiException(
val errorCode: ErrorCode,
override val message: String,
val data: Any? = null,
cause: Throwable? = null,
) : RuntimeException(cause)
Loading