diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..014b7d4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy to EC2 with GHCR + +on: + push: + branches: + - feature + +env: + IMAGE_NAME: ghcr.io/${{ github.repository }}/ktb-yuni-api + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GHCR + run: echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Build and push Docker image + run: | + IMAGE_NAME_LOWER=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]') + docker build -t $IMAGE_NAME_LOWER:latest . + docker push $IMAGE_NAME_LOWER:latest + + - name: Set up SSH key + run: | + echo "${{ secrets.EC2_KEY }}" | base64 -d > yuni-key.pem + chmod 600 yuni-key.pem + + - name: Deploy to EC2 + env: + EC2_HOST: ${{ secrets.EC2_HOST }} + EC2_USER: ${{ secrets.EC2_USER }} + GHCR_USERNAME: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GHCR_PAT }} + run: | + ssh -o StrictHostKeyChecking=no -i yuni-key.pem $EC2_USER@$EC2_HOST < '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || 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=SC2039,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=SC2039,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, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +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 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +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 0000000..2849482 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'yuni' diff --git a/src/main/java/com/ktb/yuni/YuniApplication.java b/src/main/java/com/ktb/yuni/YuniApplication.java new file mode 100644 index 0000000..46a29ac --- /dev/null +++ b/src/main/java/com/ktb/yuni/YuniApplication.java @@ -0,0 +1,16 @@ +package com.ktb.yuni; + +import com.ktb.yuni.config.Constants; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(Constants.class) +public class YuniApplication { + + public static void main(String[] args) { + SpringApplication.run(YuniApplication.class, args); + } + +} diff --git a/src/main/java/com/ktb/yuni/config/Constants.java b/src/main/java/com/ktb/yuni/config/Constants.java new file mode 100644 index 0000000..7dfdbf8 --- /dev/null +++ b/src/main/java/com/ktb/yuni/config/Constants.java @@ -0,0 +1,18 @@ +package com.ktb.yuni.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import jakarta.validation.constraints.NotBlank; +import org.springframework.validation.annotation.Validated; + +@Validated +@Configuration +@ConfigurationProperties(prefix = "api") +@Getter +@Setter +public class Constants { + @NotBlank(message = "API key가 필요합니다.") + private String key; +} diff --git a/src/main/java/com/ktb/yuni/controller/StockController.java b/src/main/java/com/ktb/yuni/controller/StockController.java new file mode 100644 index 0000000..ad66976 --- /dev/null +++ b/src/main/java/com/ktb/yuni/controller/StockController.java @@ -0,0 +1,57 @@ +package com.ktb.yuni.controller; + +import com.ktb.yuni.config.Constants; +import com.ktb.yuni.dto.ApiResponse; +import com.ktb.yuni.dto.StockResponseDto; +import com.ktb.yuni.exception.InvalidApiKeyException; +import com.ktb.yuni.service.StockService; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@Validated +@RestController +@RequestMapping("/api/v1/stocks") +public class StockController { + private final StockService stockService; + private final Constants constants; + + + public StockController(StockService stockService, Constants constants) { + this.stockService = stockService; + this.constants = constants; + } + + @GetMapping + public ResponseEntity> getStockPrices( + @RequestParam String companyCode, + @RequestParam String startDate, + @RequestParam String endDate, + @RequestHeader(value = "x-api-key", required = false) String apiKeyHeader, + @RequestParam(value = "apiKey", required = false) String apiKeyParam + ) { + String apiKey = (apiKeyHeader != null) ? apiKeyHeader : apiKeyParam; + + validateApiKey(apiKey); + + StockResponseDto response = stockService.getStockPrices( + companyCode, LocalDate.parse(startDate), LocalDate.parse(endDate) + ); + return ResponseEntity.ok(ApiResponse.success("주식 데이터 조회 성공", response)); + } + + /** + * 요청 API 키의 유효성을 검증 + * - 키가 없거나 잘못된 경우 InvalidApiKeyException 발생 + */ + private void validateApiKey(String apiKey) { + if (apiKey == null) { + throw new InvalidApiKeyException("API key가 누락되었습니다."); + } + if (!constants.getKey().equals(apiKey)) { + throw new InvalidApiKeyException("올바르지 않은 API key 입니다."); + } + } +} diff --git a/src/main/java/com/ktb/yuni/domain/Company.java b/src/main/java/com/ktb/yuni/domain/Company.java new file mode 100644 index 0000000..33712d7 --- /dev/null +++ b/src/main/java/com/ktb/yuni/domain/Company.java @@ -0,0 +1,22 @@ +package com.ktb.yuni.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "company") +public class Company { + @Id + @Column(name = "company_code", nullable = false, unique = true) + private String companyCode; + + @Column(name = "company_name", nullable = false) + private String companyName; +} diff --git a/src/main/java/com/ktb/yuni/domain/StocksHistory.java b/src/main/java/com/ktb/yuni/domain/StocksHistory.java new file mode 100644 index 0000000..a5a3dd7 --- /dev/null +++ b/src/main/java/com/ktb/yuni/domain/StocksHistory.java @@ -0,0 +1,27 @@ +package com.ktb.yuni.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@IdClass(StocksHistoryId.class) +@Table(name = "stocks_history") +public class StocksHistory { + @Id + @Column(name = "company_code", nullable = false) + private String companyCode; + + @Id + @Column(name = "trade_date", nullable = false) + private LocalDate tradeDate; + + @Column(name = "close_price", nullable = false) + private Float closePrice; +} diff --git a/src/main/java/com/ktb/yuni/domain/StocksHistoryId.java b/src/main/java/com/ktb/yuni/domain/StocksHistoryId.java new file mode 100644 index 0000000..bc91420 --- /dev/null +++ b/src/main/java/com/ktb/yuni/domain/StocksHistoryId.java @@ -0,0 +1,18 @@ +package com.ktb.yuni.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDate; + +@AllArgsConstructor +@NoArgsConstructor +@EqualsAndHashCode +@Data +public class StocksHistoryId implements Serializable { + private String companyCode; + private LocalDate tradeDate; +} diff --git a/src/main/java/com/ktb/yuni/dto/ApiResponse.java b/src/main/java/com/ktb/yuni/dto/ApiResponse.java new file mode 100644 index 0000000..c7b2d3e --- /dev/null +++ b/src/main/java/com/ktb/yuni/dto/ApiResponse.java @@ -0,0 +1,23 @@ +package com.ktb.yuni.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApiResponse { + private boolean success; // API 요청 성공 여부 + private String message; // 응답 메시지 + private T data; // 응답 데이터 + private int status; + + // 성공 응답 + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(true, message, data, 200); + } + + // 실패 응답 + public static ApiResponse failure(String message, int status) { + return new ApiResponse<>(false, message, null, status); + } +} diff --git a/src/main/java/com/ktb/yuni/dto/StockResponseDto.java b/src/main/java/com/ktb/yuni/dto/StockResponseDto.java new file mode 100644 index 0000000..2901e09 --- /dev/null +++ b/src/main/java/com/ktb/yuni/dto/StockResponseDto.java @@ -0,0 +1,21 @@ +package com.ktb.yuni.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.cglib.core.Local; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class StockResponseDto { + private String companyName; + private List stocks; + + @Getter + @AllArgsConstructor + public static class StockDetail { + private String tradeDate; + private Float closingPrice; + } +} diff --git a/src/main/java/com/ktb/yuni/exception/CompanyNotFoundException.java b/src/main/java/com/ktb/yuni/exception/CompanyNotFoundException.java new file mode 100644 index 0000000..c51a2a1 --- /dev/null +++ b/src/main/java/com/ktb/yuni/exception/CompanyNotFoundException.java @@ -0,0 +1,7 @@ +package com.ktb.yuni.exception; + +public class CompanyNotFoundException extends RuntimeException { + public CompanyNotFoundException(String companyCode) { + super("회사 코드 '" + companyCode + "'를 찾을 수 없습니다."); + } +} diff --git a/src/main/java/com/ktb/yuni/exception/GlobalExceptionHandler.java b/src/main/java/com/ktb/yuni/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..166d9af --- /dev/null +++ b/src/main/java/com/ktb/yuni/exception/GlobalExceptionHandler.java @@ -0,0 +1,73 @@ +package com.ktb.yuni.exception; + +import com.ktb.yuni.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.format.DateTimeParseException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // 필수 파라미터 누락 예외 처리 + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParams(MissingServletRequestParameterException e) { + String missingParam = e.getParameterName(); + String message = "필수 파라미터 '" + missingParam + "'이(가) 누락되었습니다."; + log.warn("[400 BAD REQUEST] {}", message, e); + return ResponseEntity.badRequest().body(ApiResponse.failure(message, HttpStatus.BAD_REQUEST.value())); + } + + // API 키 관련 예외 처리 + @ExceptionHandler(InvalidApiKeyException.class) + public ResponseEntity> handleInvalidApiKeyException(InvalidApiKeyException e) { + log.warn("[403 FORBIDDEN] API 키 오류: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.failure(e.getMessage(), HttpStatus.FORBIDDEN.value())); + } + + // 날짜 형식 오류 처리 + @ExceptionHandler(DateTimeParseException.class) + public ResponseEntity> handleDateTimeParseException(DateTimeParseException e) { + log.warn("[400 BAD REQUEST] 잘못된 날짜 형식 입력", e); + return ResponseEntity.badRequest() + .body(ApiResponse.failure("잘못된 날짜 형식입니다. yyyy-MM-dd 형식을 사용하세요.", HttpStatus.BAD_REQUEST.value())); + } + + // startDate가 endDate 보다 이후일 때 예외 처리 + @ExceptionHandler(StartDateAfterEndDateException.class) + public ResponseEntity> handleStartDateAfterEndDateException(StartDateAfterEndDateException e) { + log.warn("[400 BAD REQUEST] 잘못된 날짜 범위: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.failure(e.getMessage(), HttpStatus.BAD_REQUEST.value())); + } + + // 찾을 수 없는 회사 코드 + @ExceptionHandler(CompanyNotFoundException.class) + public ResponseEntity> handleCompanyNotFound(CompanyNotFoundException e) { + log.warn("[404 NOT FOUND] 회사 코드 오류: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.failure(e.getMessage(), HttpStatus.NOT_FOUND.value())); + } + + // IllegalArgumentException 처리 + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException e) { + log.error("[500 INTERNAL SERVER ERROR] 예상치 못한 오류 발생", e); + return ResponseEntity.internalServerError() + .body(ApiResponse.failure(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR.value())); + } + + // 예상치 못한 RuntimeException 처리 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("[400 BAD REQUEST] 잘못된 요청 파라미터", e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.failure(e.getMessage(), HttpStatus.BAD_REQUEST.value())); + } +} diff --git a/src/main/java/com/ktb/yuni/exception/InvalidApiKeyException.java b/src/main/java/com/ktb/yuni/exception/InvalidApiKeyException.java new file mode 100644 index 0000000..76bbd28 --- /dev/null +++ b/src/main/java/com/ktb/yuni/exception/InvalidApiKeyException.java @@ -0,0 +1,11 @@ +package com.ktb.yuni.exception; + +public class InvalidApiKeyException extends IllegalArgumentException { + public InvalidApiKeyException() { + super("API key가 누락되었거나 올바르지 않습니다."); + } + + public InvalidApiKeyException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ktb/yuni/exception/StartDateAfterEndDateException.java b/src/main/java/com/ktb/yuni/exception/StartDateAfterEndDateException.java new file mode 100644 index 0000000..419890c --- /dev/null +++ b/src/main/java/com/ktb/yuni/exception/StartDateAfterEndDateException.java @@ -0,0 +1,7 @@ +package com.ktb.yuni.exception; + +public class StartDateAfterEndDateException extends RuntimeException { + public StartDateAfterEndDateException() { + super(String.format("startDate 는 endDate 보다 이후일 수 없습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/ktb/yuni/repository/CompanyRepository.java b/src/main/java/com/ktb/yuni/repository/CompanyRepository.java new file mode 100644 index 0000000..0addd03 --- /dev/null +++ b/src/main/java/com/ktb/yuni/repository/CompanyRepository.java @@ -0,0 +1,12 @@ +package com.ktb.yuni.repository; + +import com.ktb.yuni.domain.Company; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CompanyRepository extends JpaRepository { + Optional findByCompanyCode(String companyCode); +} diff --git a/src/main/java/com/ktb/yuni/repository/StocksHistoryRepository.java b/src/main/java/com/ktb/yuni/repository/StocksHistoryRepository.java new file mode 100644 index 0000000..b98a4d8 --- /dev/null +++ b/src/main/java/com/ktb/yuni/repository/StocksHistoryRepository.java @@ -0,0 +1,18 @@ +package com.ktb.yuni.repository; + +import com.ktb.yuni.domain.StocksHistory; +import com.ktb.yuni.domain.StocksHistoryId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface StocksHistoryRepository extends JpaRepository { + List findByCompanyCodeAndTradeDateBetween( + String companyCode, + LocalDate startDate, + LocalDate endDate + ); +} \ No newline at end of file diff --git a/src/main/java/com/ktb/yuni/service/StockService.java b/src/main/java/com/ktb/yuni/service/StockService.java new file mode 100644 index 0000000..5fee717 --- /dev/null +++ b/src/main/java/com/ktb/yuni/service/StockService.java @@ -0,0 +1,46 @@ +package com.ktb.yuni.service; + +import com.ktb.yuni.domain.Company; +import com.ktb.yuni.domain.StocksHistory; +import com.ktb.yuni.dto.StockResponseDto; +import com.ktb.yuni.exception.CompanyNotFoundException; +import com.ktb.yuni.exception.StartDateAfterEndDateException; +import com.ktb.yuni.repository.CompanyRepository; +import com.ktb.yuni.repository.StocksHistoryRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; + +@Service +public class StockService { + private final CompanyRepository companyRepository; + private final StocksHistoryRepository stocksHistoryRepository; + + public StockService(CompanyRepository companyRepository, StocksHistoryRepository stocksHistoryRepository) { + this.companyRepository = companyRepository; + this.stocksHistoryRepository = stocksHistoryRepository; + } + + public StockResponseDto getStockPrices(String companyCode, LocalDate startDate, LocalDate endDate) { + // startDate가 endDate보다 이전이어야 함 + if (startDate.isAfter(endDate)) { + throw new StartDateAfterEndDateException(); + } + + // 회사 조회 + Company company = companyRepository.findByCompanyCode(companyCode) + .orElseThrow(()->new CompanyNotFoundException(companyCode)); + + // 주식 데이터 조회 + List stocks = stocksHistoryRepository + .findByCompanyCodeAndTradeDateBetween(companyCode, startDate, endDate); + + // DTO로 변환 + List stockDetails = stocks.stream() + .map(stock -> new StockResponseDto.StockDetail(stock.getTradeDate().toString(), stock.getClosePrice())) + .toList(); + + return new StockResponseDto(company.getCompanyName(), stockDetails); + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3e6b8a1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,28 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + logs/app.log + + logs/app-%d{yyyy-MM-dd}.%i.log + 100MB + + 30 + + 5GB + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/ktb/yuni/YuniApplicationTests.java b/src/test/java/com/ktb/yuni/YuniApplicationTests.java new file mode 100644 index 0000000..c8c35fa --- /dev/null +++ b/src/test/java/com/ktb/yuni/YuniApplicationTests.java @@ -0,0 +1,13 @@ +package com.ktb.yuni; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class YuniApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/ktb/yuni/exception/StockExceptionTest.java b/src/test/java/com/ktb/yuni/exception/StockExceptionTest.java new file mode 100644 index 0000000..a415e2c --- /dev/null +++ b/src/test/java/com/ktb/yuni/exception/StockExceptionTest.java @@ -0,0 +1,69 @@ +package com.ktb.yuni.exception; + +import com.ktb.yuni.dto.ApiResponse; +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.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class StockExceptionTest { + + @Autowired + private TestRestTemplate restTemplate; + + private static final String BASE_URL = "/api/v1/stocks"; + + + @Test + @DisplayName("필수 파라미터 누락 시 400 Bad Request 테스트") + void testMissingParameter() { + // given - startDate 파라미터 누락 + String url = BASE_URL + "?companyCode=AAPL&endDate=2020-01-01&apiKey=c18aa07f-f005-4c2f-b6db-dff8294e6b5e"; + + // when + ResponseEntity response = restTemplate.getForEntity(url, ApiResponse.class); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(Objects.requireNonNull(response.getBody()).getMessage()).isEqualTo("필수 파라미터 'startDate'이(가) 누락되었습니다."); + } + + @Test + @DisplayName("잘못된 날짜 형식 입력 시 400 Bad Request 테스트") + void testInvalidDateFormat () { + // given - 잘못된 날짜 형식 + String url = BASE_URL + "?companyCode=AAPL&startDate=2020-12-dd&endDate=2020-01-01&apiKey=c18aa07f-f005-4c2f-b6db-dff8294e6b5e"; + + // when + ResponseEntity response = restTemplate.getForEntity(url, ApiResponse.class); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(Objects.requireNonNull(response.getBody()).getMessage()).isEqualTo("잘못된 날짜 형식입니다. yyyy-MM-dd 형식을 사용하세요."); + } + + @Test + @DisplayName("API 키 누락 시 403 Forbidden 반환 테스트") + void testMissingApiKey() { + // given - API key 미입력 + String url = "/api/v1/stocks?companyCode=AAPL&startDate=2019-12-01&endDate=2019-01-10"; + + // when + ResponseEntity response = restTemplate.getForEntity(url, ApiResponse.class); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).contains("API key가 누락되었습니다."); + } +} diff --git a/src/test/java/com/ktb/yuni/service/StockServiceTest.java b/src/test/java/com/ktb/yuni/service/StockServiceTest.java new file mode 100644 index 0000000..b681073 --- /dev/null +++ b/src/test/java/com/ktb/yuni/service/StockServiceTest.java @@ -0,0 +1,70 @@ +package com.ktb.yuni.service; + +import com.ktb.yuni.domain.Company; +import com.ktb.yuni.domain.StocksHistory; +import com.ktb.yuni.dto.StockResponseDto; +import com.ktb.yuni.repository.CompanyRepository; +import com.ktb.yuni.repository.StocksHistoryRepository; +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.test.context.ActiveProfiles; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@ActiveProfiles("test") +public class StockServiceTest { + @Autowired + private StockService stockService; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private StocksHistoryRepository stocksHistoryRepository; + + @Test + @DisplayName("주식 데이터 조회 정상 응답 테스트") + void getStockPrices_success() { + // given + Company company = new Company("YUNI", "유니회사"); + companyRepository.save(company); + stocksHistoryRepository.save(new StocksHistory("YUNI", LocalDate.of(2025, 2, 1), 100f)); + + // when + StockResponseDto result = stockService.getStockPrices("YUNI", LocalDate.of(2025, 2, 1), LocalDate.of(2025, 2, 3)); + + // then + assertThat(result.getCompanyName()).isEqualTo("유니회사"); + assertThat(result.getStocks()).hasSize(1); + assertThat(result.getStocks().get(0).getClosingPrice()).isEqualTo(100f); + } + + @Test + @DisplayName("존재하지 않는 회사 코드로 조회 시 예외 발생 테스트") + void getStockPrices_companyNotFound() { + // when + Exception exception = assertThrows(RuntimeException.class, () -> + stockService.getStockPrices("INVALID", LocalDate.of(2020, 1, 1), LocalDate.of(2020, 1, 3))); + + // then + assertThat(exception.getMessage()).contains("회사 코드 'INVALID'를 찾을 수 없습니다."); + } + + @Test + @DisplayName("startDate가 endDate보다 이후일 때 예외 발생 테스트") + void getStockPrices_invalidDateRange() { + // when + Exception exception = assertThrows(RuntimeException.class, () -> + stockService.getStockPrices("AAPL", LocalDate.of(2020, 1, 5), LocalDate.of(2020, 1, 3))); + + // then + assertThat(exception.getMessage()).contains("startDate 는 endDate 보다 이후일 수 없습니다."); + } + +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..8ab1bbe --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,11 @@ +spring: + datasource : + url: jdbc:h2:mem:test + driverClassName: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect \ No newline at end of file