Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ out/

### Mac OS ###
.DS_Store

### Env
*.env
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,32 @@
# spring-shopping-precourse
# spring-shopping-precourse

## 기능사항

### 상품

- [x] 상품에는 이름과 가격, 이미지가 있다.
- [x] 상품 이름은 공백을 포함하여 최대 15자까지 입력할 수 있다.
- [x] 상품 이름은 허용된 특수 문자만 입력 가능하다. ( ( ), [ ], +, -, &, /, _ )
- [x] 상품 이름에는 비속어를 포함할 수 없다.
- [x] 상품 가격은 음수일 수 없다.
- [x] 상품 이미지의 경우, 파일을 업로드하지 않고 URL을 직접 입력한다.

### 욕설
- [x] 필터링할 문자열을 입력받아, 욕설이 포함되어있는지 여부를 알려준다.
- [x] 구현체에는 PurgoMalum가 있다.

### 회원
- [x] 회원은 이메일, 비밀번호를 가진다.
- [x] 회원이 올바른 이메일과 비밀번호를 보내면 토큰을 발급한다.
- [x] 이메일은 형식을 지켜야 한다.
- [x] 비밀번호는 8자 이상, 20자 이하이면서 알파벳 대소문자, 숫자, 특수문자 하나씩 포함해야만 한다.
- [x] 회원의 이메일을 저장할 때는 양방향 암호화하여야 한다.
- [x] 암호화된 이메일은 암호화되지 않은 이메일 형식일시 예외가 발생한다.
- [x] 회원의 비밀번호를 저장할 때는 단방향 암호화하여야 한다.
- [x] 암호화된 비밀번호는 암호화되지 않은 비밀번호 형식일시 예외가 발생한다.

### 위시 리스트
- [x] 위시 리스트에는 상품 정보가 있다.
- [x] 위시 리스트에 이미 등록된 상품을 추가하려고 하면 예외가 발생한다.
- [x] 위시 리스트에 등록된 상품 정보를 조회할 수 있다.
- [x] 위시 리스트에 담긴 상품을 삭제할 수 있다.
67 changes: 66 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
import java.util.Properties

// .env 파일을 읽어와 환경 변수로 설정하는 함수
fun loadEnvVariables() {
val envFile = file(".env")
if (envFile.exists()) {
val props = Properties()
envFile.inputStream().use { props.load(it) }
props.forEach { key, value ->
System.setProperty(key as String, value as String)
}
}
}

// 프로젝트가 실행될 때 .env 파일을 로드하도록 설정
loadEnvVariables()

plugins {
id("org.springframework.boot") version "3.3.1"
id("io.spring.dependency-management") version "1.1.5"
Expand All @@ -8,6 +25,9 @@ plugins {

group = "camp.nextstep.edu"
version = "0.0.1-SNAPSHOT"
val mapstructVersion = "1.4.2.Final"
val lombokVersion = "1.18.34"
val slf4jVersion = "1.7.36"

java {
toolchain {
Expand All @@ -24,15 +44,47 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")

implementation("org.springframework.cloud:spring-cloud-starter-openfeign")

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-mysql")
implementation("org.jetbrains.kotlin:kotlin-reflect")

// mapstruct
implementation("org.mapstruct:mapstruct:$mapstructVersion")
annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion")

// lombok
compileOnly("org.projectlombok:lombok:$lombokVersion")
annotationProcessor("org.projectlombok:lombok:$lombokVersion")

// api docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2")

// database
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")

// jwt
implementation("com.auth0:java-jwt:3.18.2")

developmentOnly("org.springframework.boot:spring-boot-devtools")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testCompileOnly("org.projectlombok:lombok:$lombokVersion")
}

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2023.0.3")
}
}

kotlin {
Expand All @@ -41,6 +93,19 @@ kotlin {
}
}

tasks.withType<org.springframework.boot.gradle.tasks.run.BootRun> {
doFirst {
val envFile = file(".env")
if (envFile.exists()) {
val props = Properties()
envFile.inputStream().use { props.load(it) }
props.forEach { key, value ->
environment(key.toString(), value.toString()) // 환경 변수를 Gradle의 환경에 설정
}
}
}
}

tasks.withType<Test> {
useJUnitPlatform()
}
}
4 changes: 4 additions & 0 deletions src/main/java/shopping/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@EnableFeignClients
@SpringBootApplication
public class Application {
public static void main(String[] args) {
Expand Down
91 changes: 91 additions & 0 deletions src/main/java/shopping/apps/shopping/api/common/ApiUrls.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package shopping.apps.shopping.api.common;

import lombok.NonNull;
import org.yaml.snakeyaml.Yaml;

import java.io.InputStream;
import java.util.Map;

public class ApiUrls {
public static String API_PREFIX;

public static final class ApiDocs {
public static final String API_DOCS = "/v3/api-docs/**";

public static final String UI = "/swagger-ui/**";

public static final String RESOURCES = "/swagger-resources/**";
}

public static final class User {
public static final String PREFIX = "/members";

public static final String REGISTER = "/register";

public static final String LOGIN = "/login";
}


public static final class Product {
public static final String PREFIX = "/products";

public static final String GET_ALL_INFO = "";

public static final String CREATE = "";

public static final class GetInfo {
public static final String FULL_URI = "/{productId}";

public static final String PRODUCT_ID_PATH_VAR = "productId";
}


public static final class Update {
public static final String FULL_URI = "/{productId}";

public static final String PRODUCT_ID_PATH_VAR = "productId";
}

public static final class Delete {
public static final String FULL_URI = "/{productId}";

public static final String PRODUCT_ID_PATH_VAR = "productId";
}
}

public static final class Wishlist {
public static final String PREFIX = "/wishes";

public static final String CREATE = "";

public static final String GET_ALL_INFO = "";


public static final class Delete {
public static final String FULL_URI = "/{wishId}";

public static final String WISHLIST_ID_PATH_VAR = "wishId";
}
}


static {
final Yaml yaml = new Yaml();
try (final InputStream in = ApiUrls.class.getClassLoader().getResourceAsStream("application.yaml")) {
if (in == null) {
throw new RuntimeException("application.yaml not found");
}

handleProperty(yaml.load(in));
} catch (Exception e) {
throw new RuntimeException("Failed to load application.yaml", e);
}
}

private static void handleProperty(@NonNull final Map<String, Object> property) {
final Map<String, Object> server = (Map<String, Object>) property.get("server");
final Map<String, Object> servlet = (Map<String, Object>) server.get("servlet");

API_PREFIX = (String) servlet.get("context-path");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package shopping.apps.shopping.api.common;

import com.auth0.jwt.exceptions.TokenExpiredException;

import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import shopping.domains.common.core.domain.entity.*;
import shopping.domains.common.core.domain.enums.CommonErrorCode;
import shopping.domains.common.core.domain.enums.ErrorCode;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ClientIllegalArgumentException.class)
public ResponseEntity<Object> handleClientIllegalArgument(@NonNull final ClientIllegalArgumentException e) {
log.warn("handleResourceAlreadyExist", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(makeErrorResponse(e.getErrorCode()));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgument(@NonNull final IllegalArgumentException e) {
log.warn("handleResourceAlreadyExist", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(makeErrorResponse(CommonErrorCode.BAD_REQUEST));
}

@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<Object> handleClientException(@NonNull final UnauthorizedException e) {
log.warn("ClientException", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(makeErrorResponse(e.getErrorCode()));
}

@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Object> handleResourceNotFound(@NonNull final ResourceNotFoundException e) {
log.warn("handleResourceNotFound", e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(makeErrorResponse(e.getErrorCode()));
}

@ExceptionHandler(AlreadyExistResourceException.class)
public ResponseEntity<Object> handleAlreadyExistResource(@NonNull final AlreadyExistResourceException e) {
log.warn("handleAlreadyExistResourceException", e);
return ResponseEntity.status(HttpStatus.CONFLICT).body(makeErrorResponse(e.getErrorCode()));
}


@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ResponseEntity<Object> handleOptimisticLocking(ObjectOptimisticLockingFailureException e) {
log.warn("ObjectOptimisticLockingFailureException", e);
return ResponseEntity.status(HttpStatus.CONFLICT).body(makeErrorResponse(CommonErrorCode.DUPLICATE));
}

@ExceptionHandler(ThirdPartyUnavailableException.class)
public ResponseEntity<Object> handleThirdPartyUnavailable(@NonNull final ThirdPartyUnavailableException e) {
log.warn("ThirdPartyUnavailableException", e);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(makeErrorResponse(CommonErrorCode.SERVICE_UNAVAILABLE));
}

@ExceptionHandler(TokenExpiredException.class)
public ResponseEntity<Object> handleTokenExpired(@NonNull final TokenExpiredException e) {
log.warn("TokenExpiredException", e);
return ResponseEntity.status(401).body(makeErrorResponse(CommonErrorCode.UNAUTHORIZED));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleException(Exception e) {
log.warn("handleException", e);
return ResponseEntity.status(500).body(makeErrorResponse(CommonErrorCode.INTERNAL_SERVER_ERROR));
}

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
@NonNull final MethodArgumentNotValidException ex,
@NonNull final HttpHeaders headers,
@NonNull final HttpStatusCode status,
@NonNull final WebRequest request
) {

return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(makeErrorResponse(CommonErrorCode.BAD_REQUEST));
}

private ErrorResponse makeErrorResponse(@NonNull final ErrorCode errorCode) {
return new ErrorResponse(errorCode.getCode(), errorCode.getMessage());
}
}

record ErrorResponse(String code, String message) {
}
46 changes: 46 additions & 0 deletions src/main/java/shopping/apps/shopping/api/common/SwaggerConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package shopping.apps.shopping.api.common;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpHeaders;

@Configuration
public class SwaggerConfig {
private final Environment env;

@Autowired
public SwaggerConfig(@NonNull final Environment env) {
this.env = env;
}

@Bean
public OpenAPI openAPI(@Value("springdoc.version") String version) {
Info info = new Info().title("Spring Shopping API").version(version).description("Spring Shopping API 명세서입니다.");

// Security 스키마 설정
SecurityScheme bearerAuth = new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
.name(HttpHeaders.AUTHORIZATION);

// Security 요청 설정 => JWT 활성화
SecurityRequirement addSecurityItem = new SecurityRequirement();
addSecurityItem.addList("JWT");

return new OpenAPI().addServersItem(new Server().url(env.getProperty("server.servlet.context-path")))
.components(new Components().addSecuritySchemes("JWT", bearerAuth))
.addSecurityItem(addSecurityItem).info(info);
}
}
Loading