diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..ae45f763 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @ManHyuk @egg528 @binary-ho diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 00000000..3cb6b07f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,14 @@ +--- +name: 티켓 이슈 +about: 티켓 발행을 위한 이슈입니다. +title: '' +labels: enhancement +assignees: '' + +--- + +### ⚠️ Issue +- 이슈 개요를 입력해주세요. + +### ✏️ ToDoList +- [ ] 할 일1 \ No newline at end of file diff --git a/.github/workflows/admin-ci-cd.yml b/.github/workflows/admin-ci-cd.yml new file mode 100644 index 00000000..2bb6398f --- /dev/null +++ b/.github/workflows/admin-ci-cd.yml @@ -0,0 +1,41 @@ +name: Admin CI/CD + +on: + push: + branches: [ "develop" ] + paths-ignore: + - 'application/api/**' # application/api 폴더 내의 변화 무시 + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: setup jdk 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: build with gradle + run: ./gradlew bootJar + + - name: push to dockerhub + run: | + docker login -u ${{ secrets.DOCKER_ID }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build --no-cache -t ${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_ADMIN_REPO }} -f ./application/admin/Dockerfile . + docker push ${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_ADMIN_REPO }} + - name: deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + port: ${{ secrets.SSH_PORT }} + username: ${{ secrets.SSH_USERNAME }} + password: ${{ secrets.SSH_PASSWORD }} + script: | + ${{ secrets.SSH_SCRIPT_FOR_ADMIN }} \ No newline at end of file diff --git a/.github/workflows/api-ci-cd.yml b/.github/workflows/api-ci-cd.yml new file mode 100644 index 00000000..5b89e99c --- /dev/null +++ b/.github/workflows/api-ci-cd.yml @@ -0,0 +1,41 @@ +name: Api CI/CD + +on: + push: + branches: [ "develop" ] + paths-ignore: + - 'application/admin/**' # application/admin 폴더 내의 변화 무시 + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: setup jdk 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: build with gradle + run: ./gradlew bootJar + + - name: push to dockerhub + run: | + docker login -u ${{ secrets.DOCKER_ID }} -p ${{ secrets.DOCKER_PASSWORD }} + docker build --no-cache -t ${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPO }} -f ./application/api/Dockerfile . + docker push ${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPO }} + - name: deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + port: ${{ secrets.SSH_PORT }} + username: ${{ secrets.SSH_USERNAME }} + password: ${{ secrets.SSH_PASSWORD }} + script: | + ${{ secrets.SSH_SCRIPT }} \ No newline at end of file diff --git a/.github/workflows/pull-request-gradle-build-test.yml b/.github/workflows/pull-request-gradle-build-test.yml new file mode 100644 index 00000000..e6082998 --- /dev/null +++ b/.github/workflows/pull-request-gradle-build-test.yml @@ -0,0 +1,41 @@ +name : Pull Request Gradle Build Test + +on: + pull_request: + types: [opened, synchronize, closed] + +permissions: read-all + +jobs: + build-test: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Git Checkout + uses: actions/checkout@v3.0.2 + + - uses: dorny/paths-filter@v2 + id: changes + with: + filters: | + application: + - 'build.gradle.kts' + - '**/src/**' + + - name: JDK 설치 + if: steps.changes.outputs.application == 'true' + uses: actions/setup-java@v3 + with: + distribution: zulu + java-version: 17 + cache: 'gradle' + + - name: gradlew 권한 부여 + run: chmod +x ./gradlew + + - name: Gradle Build + if: steps.changes.outputs.application == 'true' + run: | + ./gradlew build --no-build-cache diff --git a/README.md b/README.md index 3a323585..560ddb3d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# amazing3-be - -## 커멧 컨벤션 +## 커밋 컨벤션 - `태그(#issue): 내용` - feat : 새로운 기능 추가 - fix : 버그 수정 @@ -8,4 +6,4 @@ - style : 코드 포맷팅, 세미콜론 누락, 코드 변경이 없는 경우 - refactor : 코드 리펙토링 - test : 테스트 코드, 리펙토링 테스트 코드 추가 - - chore : 빌드 업무 수정, 패키지 매니저 수정 \ No newline at end of file + - chore : 빌드 업무 수정, 패키지 매니저 수 \ No newline at end of file diff --git a/application/admin/Dockerfile b/application/admin/Dockerfile new file mode 100644 index 00000000..2fea9f54 --- /dev/null +++ b/application/admin/Dockerfile @@ -0,0 +1,4 @@ +FROM --platform=linux/amd64 openjdk:17-jdk-slim +EXPOSE 7000 +COPY application/admin/build/libs/*.jar app.jar +ENTRYPOINT ["java","-jar","/app.jar"] \ No newline at end of file diff --git a/application/admin/build.gradle.kts b/application/admin/build.gradle.kts new file mode 100644 index 00000000..8636108e --- /dev/null +++ b/application/admin/build.gradle.kts @@ -0,0 +1,17 @@ +tasks.getByName("bootJar") { + enabled = true +} + +tasks.getByName("jar") { + enabled = false +} + +dependencies { + implementation(project(":storage:db-core")) + implementation(project(":storage:image")) + + implementation("org.springframework.boot:spring-boot-starter-web") + + /* swagger */ + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") +} diff --git a/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/CoreDbTestApplication.kt b/application/admin/src/main/kotlin/io/raemian/AdminApplication.kt similarity index 71% rename from storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/CoreDbTestApplication.kt rename to application/admin/src/main/kotlin/io/raemian/AdminApplication.kt index 80e4b117..cee85eb5 100644 --- a/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/CoreDbTestApplication.kt +++ b/application/admin/src/main/kotlin/io/raemian/AdminApplication.kt @@ -1,4 +1,4 @@ -package io.dodn.springboot.storage.db +package io.raemian import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan @@ -6,8 +6,8 @@ import org.springframework.boot.runApplication @ConfigurationPropertiesScan @SpringBootApplication -class CoreDbTestApplication +class AdminApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/application/admin/src/main/kotlin/io/raemian/admin/config/GlobalExceptionHandler.kt b/application/admin/src/main/kotlin/io/raemian/admin/config/GlobalExceptionHandler.kt new file mode 100644 index 00000000..268e532c --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/config/GlobalExceptionHandler.kt @@ -0,0 +1,27 @@ +package io.raemian.admin.config + +import io.raemian.admin.support.error.CoreApiException +import io.raemian.admin.support.error.ErrorType +import io.raemian.admin.support.response.ApiResponse +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log: Logger = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(CoreApiException::class) + fun handleCoreApiException(e: CoreApiException): ResponseEntity> { + log.error("Exception : {}", e.message, e) + return ResponseEntity(ApiResponse.error(e.errorType), e.errorType.status) + } + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity> { + log.error("Exception : {}", e.message, e) + return ResponseEntity(ApiResponse.error(ErrorType.DEFAULT_ERROR, e), ErrorType.DEFAULT_ERROR.status) + } +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/config/SpringdocConfig.kt b/application/admin/src/main/kotlin/io/raemian/admin/config/SpringdocConfig.kt new file mode 100644 index 00000000..ea6685e8 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/config/SpringdocConfig.kt @@ -0,0 +1,29 @@ +package io.raemian.admin.config + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.servers.Server +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SpringdocConfig { + + @Value("\${springdoc.server.url}") + private lateinit var url: String + + @Bean + fun openAPI(): OpenAPI { + return OpenAPI() + .info(apiInfo()) + .servers(listOf(apiServer())) + } + + private fun apiInfo() = Info() + .title("BANDIBOODI Admin API 명세") + .description("BANDIBOODI Admin API 명세서") + .version("v1.0.0") + + private fun apiServer() = Server().url(url) +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/sticker/StickerService.kt b/application/admin/src/main/kotlin/io/raemian/admin/sticker/StickerService.kt new file mode 100644 index 00000000..d1b1024f --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/sticker/StickerService.kt @@ -0,0 +1,84 @@ +package io.raemian.admin.sticker + +import io.raemian.admin.sticker.controller.request.CreateStickerRequest +import io.raemian.admin.sticker.controller.request.UpdateStickerRequest +import io.raemian.admin.sticker.controller.response.StickerResponse +import io.raemian.admin.support.error.CoreApiException +import io.raemian.admin.support.error.ErrorType +import io.raemian.image.enums.FileExtensionType +import io.raemian.image.repository.ImageRepository +import io.raemian.storage.db.core.sticker.Sticker +import io.raemian.storage.db.core.sticker.StickerRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class StickerService( + private val stickerRepository: StickerRepository, + private val imageRepository: ImageRepository, +) { + + @Transactional + fun create( + createStickerRequest: CreateStickerRequest, + ): StickerResponse { + val fileName = validateFileName(createStickerRequest.image.originalFilename) + + val url = imageRepository.upload(fileName, createStickerRequest.image.inputStream) + + val stickers = stickerRepository.save(Sticker(createStickerRequest.name, url)) + + return StickerResponse.from(stickers) + } + + @Transactional(readOnly = true) + fun findAll(): List = + stickerRepository.findAll().map(::StickerResponse) + + @Transactional + fun update( + stickerId: Long, + updateStickerRequest: UpdateStickerRequest, + ): StickerResponse { + val newFileName = validateFileName(updateStickerRequest.image.originalFilename) + + val stickers = stickerRepository.getById(stickerId) + + val url = imageRepository.update( + newFileName, + splitFileNameFromUrl(stickers.url), + updateStickerRequest.image.inputStream, + ) + + val updatedStickers = stickerRepository.save(Sticker(updateStickerRequest.name, url)) + + return StickerResponse.from(stickers) + } + + @Transactional + fun delete( + stickerId: Long, + ) { + val stickers = stickerRepository.getById(stickerId) + + imageRepository.delete(splitFileNameFromUrl(stickers.url)) + + stickerRepository.delete(stickers) + } + + private fun splitFileNameFromUrl(url: String): String { + return url.split("/").last() + } + + private fun validateFileName(fileName: String?): String { + if (fileName.isNullOrBlank()) { + throw CoreApiException(ErrorType.NO_IMAGE_NAME_ERROR) + } + + if (!fileName.endsWith(FileExtensionType.PNG.value)) { + throw CoreApiException(ErrorType.NO_PNG_FILE_ERROR) + } + + return fileName + } +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/StickerController.kt b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/StickerController.kt new file mode 100644 index 00000000..2979b260 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/StickerController.kt @@ -0,0 +1,64 @@ +package io.raemian.admin.sticker.controller + +import io.raemian.admin.sticker.StickerService +import io.raemian.admin.sticker.controller.request.CreateStickerRequest +import io.raemian.admin.sticker.controller.request.UpdateStickerRequest +import io.raemian.admin.sticker.controller.response.StickerResponse +import io.raemian.admin.support.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +fun String.toUri(): URI = URI.create(this) + +@RestController +@RequestMapping("/sticker") +class StickerController( + private val stickerService: StickerService, +) { + + @Operation(summary = "스티커 생성 API") + @PostMapping(consumes = arrayOf(MediaType.MULTIPART_FORM_DATA_VALUE), produces = arrayOf(MediaType.APPLICATION_JSON_VALUE)) + fun create( + @ModelAttribute createStickerRequest: CreateStickerRequest, + ): ResponseEntity> { + val response = stickerService.create(createStickerRequest) + + return ResponseEntity + .created("/sticker/${response.id}".toUri()) + .body(ApiResponse.success(response)) + } + + @Operation(summary = "스티커 전체 조회 API") + @GetMapping + fun findAll(): ResponseEntity>> = + ResponseEntity.ok(ApiResponse.success(stickerService.findAll())) + + @Operation(summary = "스티커 수정 API") + @PatchMapping("/{stickerId}", consumes = arrayOf(MediaType.MULTIPART_FORM_DATA_VALUE), produces = arrayOf(MediaType.APPLICATION_JSON_VALUE)) + fun update( + @PathVariable stickerId: Long, + @ModelAttribute updateStickerRequest: UpdateStickerRequest, + ): ResponseEntity { + stickerService.update(stickerId, updateStickerRequest) + return ResponseEntity.ok().build() + } + + @Operation(summary = "스티커 삭제 API") + @DeleteMapping("/{stickerId}") + fun delete( + @PathVariable stickerId: Long, + ): ResponseEntity { + stickerService.delete(stickerId) + return ResponseEntity.noContent().build() + } +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/request/CreateStickerRequest.kt b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/request/CreateStickerRequest.kt new file mode 100644 index 00000000..7be3109d --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/request/CreateStickerRequest.kt @@ -0,0 +1,9 @@ +package io.raemian.admin.sticker.controller.request + +import org.springframework.web.multipart.MultipartFile +import java.io.Serializable + +data class CreateStickerRequest( + val name: String, + val image: MultipartFile, +) : Serializable diff --git a/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/request/UpdateStickerRequest.kt b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/request/UpdateStickerRequest.kt new file mode 100644 index 00000000..91a499a0 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/request/UpdateStickerRequest.kt @@ -0,0 +1,8 @@ +package io.raemian.admin.sticker.controller.request + +import org.springframework.web.multipart.MultipartFile + +data class UpdateStickerRequest( + val name: String, + val image: MultipartFile, +) diff --git a/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/response/StickerResponse.kt b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/response/StickerResponse.kt new file mode 100644 index 00000000..d1730f96 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/sticker/controller/response/StickerResponse.kt @@ -0,0 +1,22 @@ +package io.raemian.admin.sticker.controller.response + +import io.raemian.storage.db.core.sticker.Sticker + +data class StickerResponse( + val id: Long?, + val name: String, + val url: String, +) { + + constructor(sticker: Sticker) : this( + sticker.id, + sticker.name, + sticker.url, + ) + + companion object { + fun from(entity: Sticker): StickerResponse { + return StickerResponse(entity.id, entity.name, entity.url) + } + } +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/support/error/CoreApiException.kt b/application/admin/src/main/kotlin/io/raemian/admin/support/error/CoreApiException.kt new file mode 100644 index 00000000..2d833ee3 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/support/error/CoreApiException.kt @@ -0,0 +1,9 @@ +package io.raemian.admin.support.error + +class CoreApiException( + val errorType: ErrorType, + val data: Any? = null, +) : RuntimeException(errorType.message) { + + override fun fillInStackTrace(): Throwable = this +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorCode.kt b/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorCode.kt new file mode 100644 index 00000000..be5832a2 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorCode.kt @@ -0,0 +1,5 @@ +package io.raemian.admin.support.error + +enum class ErrorCode { + E500, +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorMessage.kt b/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorMessage.kt new file mode 100644 index 00000000..0eeab117 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorMessage.kt @@ -0,0 +1,23 @@ +package io.raemian.admin.support.error + +data class ErrorMessage private constructor( + val code: String, + val message: String, + val data: Any? = null, +) { + companion object { + private val EMPTY_MESSAGE: String = "Empty Message" + } + + constructor(errorType: ErrorType, data: Any? = null) : this( + code = errorType.code.name, + message = errorType.message, + data = data, + ) + + constructor(errorType: ErrorType, e: Exception) : this( + code = errorType.code.name, + message = e.message ?: EMPTY_MESSAGE, + data = null, + ) +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorType.kt b/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorType.kt new file mode 100644 index 00000000..7a85e080 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/support/error/ErrorType.kt @@ -0,0 +1,11 @@ +package io.raemian.admin.support.error + +import org.springframework.boot.logging.LogLevel +import org.springframework.http.HttpStatus + +enum class ErrorType(val status: HttpStatus, val code: ErrorCode, val message: String, val logLevel: LogLevel) { + DEFAULT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.E500, "An unexpected error has occurred.", LogLevel.ERROR), + DUPLICATE_TAG_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.E500, "해당 태그는 이미 존재합니다.", LogLevel.ERROR), + NO_IMAGE_NAME_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.E500, "이미지 이름이 존재하지 않습니다.", LogLevel.ERROR), + NO_PNG_FILE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ErrorCode.E500, "이미지 파일의 확장자가 png가 아닙니다.", LogLevel.ERROR), +} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/response/ApiResponse.kt b/application/admin/src/main/kotlin/io/raemian/admin/support/response/ApiResponse.kt similarity index 53% rename from core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/response/ApiResponse.kt rename to application/admin/src/main/kotlin/io/raemian/admin/support/response/ApiResponse.kt index 821dfd0d..87da4dca 100644 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/response/ApiResponse.kt +++ b/application/admin/src/main/kotlin/io/raemian/admin/support/response/ApiResponse.kt @@ -1,18 +1,14 @@ -package io.dodn.springboot.core.api.support.response +package io.raemian.admin.support.response -import io.dodn.springboot.core.api.support.error.ErrorMessage -import io.dodn.springboot.core.api.support.error.ErrorType +import io.raemian.admin.support.error.ErrorMessage +import io.raemian.admin.support.error.ErrorType -data class ApiResponse private constructor( +class ApiResponse private constructor( val result: ResultType, - val data: T? = null, + val body: T? = null, val error: ErrorMessage? = null, ) { companion object { - fun success(): ApiResponse { - return ApiResponse(ResultType.SUCCESS, null, null) - } - fun success(data: S): ApiResponse { return ApiResponse(ResultType.SUCCESS, data, null) } @@ -20,5 +16,9 @@ data class ApiResponse private constructor( fun error(error: ErrorType, errorData: Any? = null): ApiResponse { return ApiResponse(ResultType.ERROR, null, ErrorMessage(error, errorData)) } + + fun error(error: ErrorType, e: Exception): ApiResponse { + return ApiResponse(ResultType.ERROR, null, ErrorMessage(error, e)) + } } } diff --git a/application/admin/src/main/kotlin/io/raemian/admin/support/response/ResultType.kt b/application/admin/src/main/kotlin/io/raemian/admin/support/response/ResultType.kt new file mode 100644 index 00000000..e965bb09 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/support/response/ResultType.kt @@ -0,0 +1,5 @@ +package io.raemian.admin.support.response + +enum class ResultType { + SUCCESS, ERROR +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/tag/TagService.kt b/application/admin/src/main/kotlin/io/raemian/admin/tag/TagService.kt new file mode 100644 index 00000000..8a223fc9 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/tag/TagService.kt @@ -0,0 +1,42 @@ +package io.raemian.admin.tag + +import io.raemian.admin.support.error.CoreApiException +import io.raemian.admin.support.error.ErrorType +import io.raemian.admin.tag.controller.request.CreateTagRequest +import io.raemian.admin.tag.controller.request.UpdateTagRequest +import io.raemian.admin.tag.controller.response.TagResponse +import io.raemian.storage.db.core.tag.TagRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class TagService( + private val tagRepository: TagRepository, +) { + + @Transactional + fun create(createTagRequest: CreateTagRequest): TagResponse { + if (tagRepository.existsTagsByContent(createTagRequest.content)) { + throw CoreApiException(ErrorType.DUPLICATE_TAG_ERROR) + } + + return TagResponse.from(tagRepository.save(createTagRequest.toEntity())) + } + + @Transactional(readOnly = true) + fun findAll(): List = + tagRepository.findAll().map(::TagResponse) + + @Transactional + fun update(tagId: Long, updateTagRequest: UpdateTagRequest): TagResponse { + val tags = tagRepository.getById(tagId) + tags.updateContent(updateTagRequest.content) + return TagResponse.from(tagRepository.save(tags)) + } + + @Transactional + fun delete(tagId: Long) { + val tags = tagRepository.getById(tagId) + tagRepository.delete(tags) + } +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/TagController.kt b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/TagController.kt new file mode 100644 index 00000000..28399401 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/TagController.kt @@ -0,0 +1,57 @@ +package io.raemian.admin.tag.controller + +import io.raemian.admin.support.response.ApiResponse +import io.raemian.admin.tag.TagService +import io.raemian.admin.tag.controller.request.CreateTagRequest +import io.raemian.admin.tag.controller.request.UpdateTagRequest +import io.raemian.admin.tag.controller.response.TagResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +fun String.toUri(): URI = URI.create(this) + +@RestController +@RequestMapping("/tag") +class TagController( + private val tagService: TagService, +) { + + @PostMapping + @Operation(summary = "태그 생성 API") + fun create( + @RequestBody createTagRequest: CreateTagRequest, + ): ResponseEntity> { + val response = tagService.create(createTagRequest) + return ResponseEntity.created("/tag/${response.id}".toUri()) + .body(ApiResponse.success(response)) + } + + @GetMapping + @Operation(summary = "태그 전체 조회 API") + fun findAll(): ResponseEntity>> = + ResponseEntity.ok(ApiResponse.success(tagService.findAll())) + + @PatchMapping("/{tagId}") + @Operation(summary = "태그 수정 API") + fun update( + @PathVariable tagId: Long, + @RequestBody updateTagRequest: UpdateTagRequest, + ): ResponseEntity> = + ResponseEntity.ok().body(ApiResponse.success(tagService.update(tagId, updateTagRequest))) + + @DeleteMapping("/{tagId}") + @Operation(summary = "태그 삭제 API") + fun delete(@PathVariable tagId: Long): ResponseEntity { + tagService.delete(tagId) + return ResponseEntity.ok().build() + } +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/request/CreateTagRequest.kt b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/request/CreateTagRequest.kt new file mode 100644 index 00000000..d1cd583d --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/request/CreateTagRequest.kt @@ -0,0 +1,9 @@ +package io.raemian.admin.tag.controller.request + +import io.raemian.storage.db.core.tag.Tag + +data class CreateTagRequest( + val content: String, +) { + fun toEntity() = Tag(content) +} diff --git a/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/request/UpdateTagRequest.kt b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/request/UpdateTagRequest.kt new file mode 100644 index 00000000..b44c5738 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/request/UpdateTagRequest.kt @@ -0,0 +1,5 @@ +package io.raemian.admin.tag.controller.request + +data class UpdateTagRequest( + val content: String, +) diff --git a/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/response/TagResponse.kt b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/response/TagResponse.kt new file mode 100644 index 00000000..778d9c33 --- /dev/null +++ b/application/admin/src/main/kotlin/io/raemian/admin/tag/controller/response/TagResponse.kt @@ -0,0 +1,22 @@ +package io.raemian.admin.tag.controller.response + +import io.raemian.storage.db.core.tag.Tag + +fun from(entity: Tag): TagResponse = + TagResponse(entity.id, entity.content) + +data class TagResponse( + val id: Long?, + val content: String, +) { + + constructor(tag: Tag) : this( + tag.id, + tag.content, + ) + + companion object { + fun from(entity: Tag): TagResponse = + TagResponse(entity.id, entity.content) + } +} diff --git a/application/admin/src/main/resources/application.yml b/application/admin/src/main/resources/application.yml new file mode 100644 index 00000000..841a3b77 --- /dev/null +++ b/application/admin/src/main/resources/application.yml @@ -0,0 +1,44 @@ +# default +spring: + profiles: + default: local + application: + name: admin + mvc.throw-exception-if-no-handler-found: true + web.resources.add-mappings: false + +server: + servlet: + context-path: /admin + port: 7000 + +springdoc: + server: + url: ${SPRINGDOC-SERVER-URL:http://localhost:8080/admin} + +--- +# local +spring: + profiles: + group: + local: + - db-core + - image + +--- +# dev +spring: + profiles: + group: + dev: + - db-core + - image + +--- +# live +spring: + profiles: + group: + live: + - db-core + - image \ No newline at end of file diff --git a/application/admin/src/test/kotlin/io/raemian/AdminApiApplicationTests.kt b/application/admin/src/test/kotlin/io/raemian/AdminApiApplicationTests.kt new file mode 100644 index 00000000..41a40116 --- /dev/null +++ b/application/admin/src/test/kotlin/io/raemian/AdminApiApplicationTests.kt @@ -0,0 +1,12 @@ +package io.raemian + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class AdminApiApplicationTests { + + @Test + fun contextLoads() { + } +} diff --git a/application/api/Dockerfile b/application/api/Dockerfile new file mode 100644 index 00000000..6692c053 --- /dev/null +++ b/application/api/Dockerfile @@ -0,0 +1,4 @@ +FROM --platform=linux/amd64 openjdk:17-jdk-slim +EXPOSE 8080 7463 +COPY application/api/build/libs/*.jar app.jar +ENTRYPOINT ["java","-jar","/app.jar"] \ No newline at end of file diff --git a/application/api/build.gradle.kts b/application/api/build.gradle.kts new file mode 100644 index 00000000..0fa0263b --- /dev/null +++ b/application/api/build.gradle.kts @@ -0,0 +1,35 @@ +tasks.getByName("bootJar") { + enabled = true +} + +tasks.getByName("jar") { + enabled = false +} + +dependencies { + implementation(project(":infra:metrics")) + implementation(project(":infra:logging")) + implementation(project(":storage:db-core")) + + implementation("org.springframework.boot:spring-boot-starter-web") + + /* jwt */ + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") + + /* security */ + implementation("org.springframework.boot:spring-boot-starter-security") + + /* oauth-client */ + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + + /* test */ + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc") + testImplementation("org.springframework.restdocs:spring-restdocs-restassured") + testImplementation("io.rest-assured:spring-mock-mvc") + + /* swagger */ + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") +} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/CoreApiApplication.kt b/application/api/src/main/kotlin/io/raemian/CoreApiApplication.kt similarity index 92% rename from core/core-api/src/main/kotlin/io/dodn/springboot/CoreApiApplication.kt rename to application/api/src/main/kotlin/io/raemian/CoreApiApplication.kt index 5e1ecf20..28f36d78 100644 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/CoreApiApplication.kt +++ b/application/api/src/main/kotlin/io/raemian/CoreApiApplication.kt @@ -1,4 +1,5 @@ -package io.dodn.springboot + +package io.raemian import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/controller/AuthController.kt b/application/api/src/main/kotlin/io/raemian/api/auth/controller/AuthController.kt new file mode 100644 index 00000000..3dd22f8e --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/controller/AuthController.kt @@ -0,0 +1,43 @@ +package io.raemian.api.auth.controller + +import io.raemian.api.auth.controller.request.UpdateUserRequest +import io.raemian.api.auth.controller.response.UserResponse +import io.raemian.api.auth.domain.CurrentUser +import io.raemian.api.auth.service.AuthService +import io.raemian.api.support.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +class AuthController( + private val authService: AuthService, +) { + + @Operation(summary = "토큰 유저 정보 조회 API") + @GetMapping("/my") + fun my(@AuthenticationPrincipal currentUser: CurrentUser): ResponseEntity> { + val user = authService.getUserById(currentUser.id) + val response = UserResponse.of(user) + + return ResponseEntity.ok(ApiResponse.success(response)) + } + + @Operation(summary = "유저 온보딩 이후 정보 업데이트 API") + @PutMapping("/my") + fun update( + @AuthenticationPrincipal currentUser: CurrentUser, + @RequestBody updateUserRequest: UpdateUserRequest, + ): ResponseEntity { + authService.update( + id = currentUser.id, + nickname = updateUserRequest.nickname, + birth = updateUserRequest.birth, + ) + return ResponseEntity.ok().build() + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/SignInRequest.kt b/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/SignInRequest.kt new file mode 100644 index 00000000..45b6676e --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/SignInRequest.kt @@ -0,0 +1,6 @@ +package io.raemian.api.auth.controller.request + +data class SignInRequest( + val email: String, + val password: String, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/SignUpRequest.kt b/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/SignUpRequest.kt new file mode 100644 index 00000000..98a13f63 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/SignUpRequest.kt @@ -0,0 +1,6 @@ +package io.raemian.api.auth.controller.request + +data class SignUpRequest( + val email: String, + val password: String, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/UpdateUserRequest.kt b/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/UpdateUserRequest.kt new file mode 100644 index 00000000..ce1e76e8 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/controller/request/UpdateUserRequest.kt @@ -0,0 +1,8 @@ +package io.raemian.api.auth.controller.request + +import java.time.LocalDate + +data class UpdateUserRequest( + val nickname: String, + val birth: LocalDate, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/controller/response/UserResponse.kt b/application/api/src/main/kotlin/io/raemian/api/auth/controller/response/UserResponse.kt new file mode 100644 index 00000000..6b6cac15 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/controller/response/UserResponse.kt @@ -0,0 +1,26 @@ +package io.raemian.api.auth.controller.response + +import io.raemian.storage.db.core.user.User +import java.time.LocalDate + +data class UserResponse( + val id: Long, + val email: String, + val username: String?, + val nickname: String?, + val birth: LocalDate?, + val image: String, +) { + companion object { + fun of(user: User): UserResponse { + return UserResponse( + id = user.id!!, + email = user.email, + username = user.userName, + nickname = user.nickname, + birth = user.birth, + image = user.image, + ) + } + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/domain/CurrentUser.kt b/application/api/src/main/kotlin/io/raemian/api/auth/domain/CurrentUser.kt new file mode 100644 index 00000000..59ca77e9 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/domain/CurrentUser.kt @@ -0,0 +1,38 @@ +package io.raemian.api.auth.domain + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.user.OAuth2User + +data class CurrentUser( + val id: Long, + val email: String, + + private val authorities: List = listOf("ROLE_USER"), + + // not use field + private val password: String? = null, +) : UserDetails, OAuth2User { + + override fun getName(): String = email + + override fun getAttributes(): MutableMap = mutableMapOf() + + override fun getAuthorities(): MutableCollection = + authorities + .map { SimpleGrantedAuthority(it) } + .toMutableList() + + override fun getPassword(): String? = password + + override fun getUsername(): String = email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true +} diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/domain/TokenDTO.kt b/application/api/src/main/kotlin/io/raemian/api/auth/domain/TokenDTO.kt new file mode 100644 index 00000000..b08e4b2d --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/domain/TokenDTO.kt @@ -0,0 +1,8 @@ +package io.raemian.api.auth.domain + +data class TokenDTO( + val grantType: String, + val accessToken: String, + val refreshToken: String, + val accessTokenExpiresIn: Long, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/service/AuthService.kt b/application/api/src/main/kotlin/io/raemian/api/auth/service/AuthService.kt new file mode 100644 index 00000000..328c6d53 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/service/AuthService.kt @@ -0,0 +1,43 @@ +package io.raemian.api.auth.service + +import io.raemian.api.auth.domain.CurrentUser +import io.raemian.storage.db.core.user.User +import io.raemian.storage.db.core.user.UserRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.stereotype.Service +import java.time.LocalDate +import kotlin.jvm.optionals.getOrNull + +@Service +class AuthService( + private val userRepository: UserRepository, +) : UserDetailsService { + + fun getUserById(id: Long): User { + val user = userRepository.findById(id).getOrNull() ?: throw RuntimeException("") + return user + } + + fun update(id: Long, nickname: String, birth: LocalDate): User { + val user = userRepository.findById(id) + .getOrNull() ?: throw RuntimeException("") + + val updated = user.updateInfo( + nickname = nickname, + birth = birth, + ) + + return userRepository.save(updated) + } + + override fun loadUserByUsername(username: String): UserDetails { + val user = userRepository.findByEmail(username) ?: throw UsernameNotFoundException("not found $username") + return CurrentUser( + id = user.id!!, + email = user.email, + authorities = listOf(), + ) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/auth/service/OAuth2UserService.kt b/application/api/src/main/kotlin/io/raemian/api/auth/service/OAuth2UserService.kt new file mode 100644 index 00000000..d94d974e --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/auth/service/OAuth2UserService.kt @@ -0,0 +1,61 @@ +package io.raemian.api.auth.service + +import io.raemian.api.auth.domain.CurrentUser +import io.raemian.storage.db.core.user.Authority +import io.raemian.storage.db.core.user.User +import io.raemian.storage.db.core.user.UserRepository +import io.raemian.storage.db.core.user.enums.OAuthProvider +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Service + +@Service +class OAuth2UserService( + private val userRepository: UserRepository, +) : DefaultOAuth2UserService() { + override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { + val oAuth2User = super.loadUser(userRequest) + val usernameAttributeName = userRequest.clientRegistration + .providerDetails.userInfoEndpoint + .userNameAttributeName + + return when (val provider = OAuthProvider.valueOf(userRequest.clientRegistration.registrationId.uppercase())) { + OAuthProvider.GOOGLE -> { + val email = oAuth2User.attributes["email"]?.toString() ?: throw RuntimeException("이메일이없음") + val name = oAuth2User.attributes["name"] + val image = oAuth2User.attributes["picture"]?.toString() ?: "" + val user = upsert(email, image, OAuthProvider.GOOGLE) + CurrentUser( + id = user.id!!, + email = email, + authorities = listOf(), + ) + } + + OAuthProvider.NAVER -> { + val userInfo = oAuth2User.attributes[usernameAttributeName] as Map + val email = userInfo["email"] ?: throw RuntimeException("이메일없음") + val image = "" + val user = upsert(email, image, OAuthProvider.NAVER) + CurrentUser( + id = user.id!!, + email = email, + authorities = listOf(), + ) + } + } + } + + private fun upsert(email: String, image: String, oAuthProvider: OAuthProvider): User { + return userRepository.findByEmail(email) + ?: return userRepository.save( + User( + email = email, + image = image, + provider = oAuthProvider, + authority = Authority.ROLE_USER, + ), + ) + } +} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/config/AsyncConfig.kt b/application/api/src/main/kotlin/io/raemian/api/config/AsyncConfig.kt similarity index 68% rename from core/core-api/src/main/kotlin/io/dodn/springboot/core/api/config/AsyncConfig.kt rename to application/api/src/main/kotlin/io/raemian/api/config/AsyncConfig.kt index 81e08015..0a68ffe2 100644 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/config/AsyncConfig.kt +++ b/application/api/src/main/kotlin/io/raemian/api/config/AsyncConfig.kt @@ -1,5 +1,7 @@ -package io.dodn.springboot.core.api.config +package io.raemian.api.config +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler import org.springframework.context.annotation.Configuration import org.springframework.scheduling.annotation.AsyncConfigurer @@ -7,14 +9,16 @@ import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor import java.util.concurrent.Executor -@Configuration @EnableAsync +@Configuration class AsyncConfig : AsyncConfigurer { + private val log: Logger = LoggerFactory.getLogger(javaClass) + override fun getAsyncExecutor(): Executor { val executor = ThreadPoolTaskExecutor() - executor.corePoolSize = 10 - executor.maxPoolSize = 10 - executor.queueCapacity = 10000 + executor.corePoolSize = 8 + executor.maxPoolSize = 8 + executor.queueCapacity = 200 executor.setWaitForTasksToCompleteOnShutdown(true) executor.setAwaitTerminationSeconds(10) executor.initialize() @@ -22,6 +26,6 @@ class AsyncConfig : AsyncConfigurer { } override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler { - return AsyncExceptionHandler() + return AsyncUncaughtExceptionHandler { e, method, param -> log.error("Exception : {}", e.message, e) } } } diff --git a/application/api/src/main/kotlin/io/raemian/api/config/CorsConfig.kt b/application/api/src/main/kotlin/io/raemian/api/config/CorsConfig.kt new file mode 100644 index 00000000..57bdf97b --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/config/CorsConfig.kt @@ -0,0 +1,22 @@ +package io.raemian.api.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.web.filter.CorsFilter + +@Configuration +class CorsConfig { + @Bean + fun corsFilter(): CorsFilter { + val source = UrlBasedCorsConfigurationSource() + val config = CorsConfiguration() + config.allowCredentials = true + config.allowedOriginPatterns = listOf("*") + config.allowedHeaders = listOf("*") + config.allowedMethods = listOf("*") + source.registerCorsConfiguration("/**", config) + return CorsFilter(source) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/config/GlobalExceptionHandler.kt b/application/api/src/main/kotlin/io/raemian/api/config/GlobalExceptionHandler.kt new file mode 100644 index 00000000..8df2f48a --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/config/GlobalExceptionHandler.kt @@ -0,0 +1,24 @@ +package io.raemian.api.config + +import io.raemian.api.log.LogService +import io.raemian.api.support.error.ErrorType +import io.raemian.api.support.response.ApiResponse +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler( + val logService: LogService, +) { + private val log: Logger = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity> { + log.error("Exception : {}", e.message, e) + logService.createSlackErrorLog(e) + return ResponseEntity(ApiResponse.error(ErrorType.DEFAULT_ERROR), ErrorType.DEFAULT_ERROR.status) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/config/JwtSecurityConfig.kt b/application/api/src/main/kotlin/io/raemian/api/config/JwtSecurityConfig.kt new file mode 100644 index 00000000..a0d08fbd --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/config/JwtSecurityConfig.kt @@ -0,0 +1,19 @@ +package io.raemian.api.config + +import io.raemian.api.support.JwtFilter +import io.raemian.api.support.TokenProvider +import org.springframework.security.config.annotation.SecurityConfigurerAdapter +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +class JwtSecurityConfig( + private val tokenProvider: TokenProvider, +) : SecurityConfigurerAdapter() { + + // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록 + override fun configure(http: HttpSecurity) { + val jwtFilter = JwtFilter(tokenProvider) + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/config/SpringdocConfig.kt b/application/api/src/main/kotlin/io/raemian/api/config/SpringdocConfig.kt new file mode 100644 index 00000000..7b8ecb94 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/config/SpringdocConfig.kt @@ -0,0 +1,40 @@ +package io.raemian.api.config + +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 org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SpringdocConfig { + + @Value("\${springdoc.server.url}") + private lateinit var url: String + + @Bean + fun openAPI(): OpenAPI { + return OpenAPI() + .components(Components().addSecuritySchemes("bearerAuth", apiSecurityScheme())) + .security(apiSecurityRequirementList()) + .info(apiInfo()) + .servers(listOf(apiServer())) + } + + private fun apiInfo() = Info() + .title("BANDIBOODI API 명세") + .description("BANDIBOODI API 명세서") + .version("v1.0.0") + + private fun apiServer() = Server().url(url) + + private fun apiSecurityScheme() = SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER).name("Authorization") + + private fun apiSecurityRequirementList() = listOf(SecurityRequirement().addList("bearerAuth")) +} diff --git a/application/api/src/main/kotlin/io/raemian/api/config/WebSecurityConfig.kt b/application/api/src/main/kotlin/io/raemian/api/config/WebSecurityConfig.kt new file mode 100644 index 00000000..e0ab4279 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/config/WebSecurityConfig.kt @@ -0,0 +1,106 @@ +package io.raemian.api.config + +import io.raemian.api.auth.domain.CurrentUser +import io.raemian.api.auth.service.OAuth2UserService +import io.raemian.api.support.TokenProvider +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.security.servlet.PathRequest +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.MediaType +import org.springframework.security.config.annotation.SecurityConfigurerAdapter +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.web.filter.CorsFilter +import java.nio.charset.StandardCharsets + +@Configuration +@EnableWebSecurity +class WebSecurityConfig( + private val corsFilter: CorsFilter, + private val tokenProvider: TokenProvider, + private val oAuth2UserService: OAuth2UserService, +) : SecurityConfigurerAdapter() { + + private val log = LoggerFactory.getLogger(javaClass) + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .formLogin { it.disable() } + .httpBasic { it.disable() } + .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter::class.java) + .exceptionHandling { + it + .authenticationEntryPoint { request, response, authException -> + // 유효한 자격증명을 제공하지 않고 접근하려 할때 401 + response.sendError(HttpServletResponse.SC_UNAUTHORIZED) + } + .accessDeniedHandler { request, response, accessDeniedException -> + // 필요한 권한이 없이 접근하려 할때 403 + response.sendError(HttpServletResponse.SC_FORBIDDEN) + } + } + .authorizeHttpRequests { + it.requestMatchers(AntPathRequestMatcher("/auth/**")).permitAll() + .requestMatchers(AntPathRequestMatcher("/oauth2/**")).permitAll() + .requestMatchers(AntPathRequestMatcher("/login/**")).permitAll() + .requestMatchers(AntPathRequestMatcher("/one-baily-actuator/**")).permitAll() + .requestMatchers(AntPathRequestMatcher("/log/**")).permitAll() + .requestMatchers( + AntPathRequestMatcher("/swagger*/**"), + AntPathRequestMatcher("/v3/api-docs/**"), + AntPathRequestMatcher("/swagger-resources/**"), + AntPathRequestMatcher("/webjars/**"), + ).permitAll() + .anyRequest().authenticated() + } + .oauth2Login { + it.userInfoEndpoint { endpoint -> endpoint.userService(oAuth2UserService) } + it.successHandler { request, response, authentication -> + val user = authentication.principal as CurrentUser + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = StandardCharsets.UTF_8.name() + + val tokenDTO = tokenProvider.generateTokenDto(user) + response.setHeader("x-token", tokenDTO.accessToken) + // TODO edit redirect url + response.sendRedirect("https://www.bandiboodi.com/login/oauth2/code/google?token=${tokenDTO.accessToken}&refresh=${tokenDTO.refreshToken}") + } + it.failureHandler { request, response, exception -> + response.addHeader("x-token", exception.message) + } + } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .apply(JwtSecurityConfig(tokenProvider)) + + return http.build() + } + + @Bean + @ConditionalOnProperty(name = ["spring.h2.console.enabled"], havingValue = "true") + fun configureH2ConsoleEnable(): WebSecurityCustomizer { + return WebSecurityCustomizer { + it + .ignoring() + .requestMatchers(PathRequest.toH2Console()) + .requestMatchers(AntPathRequestMatcher("/favicon.ico", "**/favicon.ico")) + } + } + + @Bean + fun getPasswordEncoder(): PasswordEncoder { + return BCryptPasswordEncoder() + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/GoalReadService.kt b/application/api/src/main/kotlin/io/raemian/api/goal/GoalReadService.kt new file mode 100644 index 00000000..4cd7b75f --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/GoalReadService.kt @@ -0,0 +1,33 @@ +package io.raemian.api.goal + +import io.raemian.api.goal.controller.response.GoalResponse +import io.raemian.api.goal.controller.response.GoalsResponse +import io.raemian.storage.db.core.goal.Goal +import io.raemian.storage.db.core.goal.GoalRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class GoalReadService( + private val goalRepository: GoalRepository, +) { + @Transactional(readOnly = true) + fun findAllByUserId(userId: Long): GoalsResponse { + val goals = goalRepository.findAllByUserId(userId) + val sortedGoals = sortByDeadlineAscendingAndCreatedAtDescending(goals) + return GoalsResponse.from(sortedGoals) + } + + @Transactional(readOnly = true) + fun getById(id: Long): GoalResponse { + val goal = goalRepository.getById(id) + return GoalResponse(goal) + } + + private fun sortByDeadlineAscendingAndCreatedAtDescending(goals: List): List { + return goals.sortedWith( + compareBy { it.deadline } + .thenByDescending { it.createdAt }, + ) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/GoalService.kt b/application/api/src/main/kotlin/io/raemian/api/goal/GoalService.kt new file mode 100644 index 00000000..42259efb --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/GoalService.kt @@ -0,0 +1,47 @@ +package io.raemian.api.goal + +import io.raemian.api.goal.controller.request.CreateGoalRequest +import io.raemian.api.goal.controller.response.CreateGoalResponse +import io.raemian.api.sticker.StickerService +import io.raemian.api.support.RaemianLocalDate +import io.raemian.api.tag.TagService +import io.raemian.api.user.UserService +import io.raemian.storage.db.core.goal.Goal +import io.raemian.storage.db.core.goal.GoalRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class GoalService( + private val userService: UserService, + private val stickerService: StickerService, + private val tagService: TagService, + private val goalRepository: GoalRepository, +) { + @Transactional + fun create(userId: Long, createGoalRequest: CreateGoalRequest): CreateGoalResponse { + val (title, yearOfDeadline, monthOfDeadLine, stickerId, tagId, description) = createGoalRequest + + val deadline = RaemianLocalDate.of(yearOfDeadline, monthOfDeadLine) + val sticker = stickerService.getById(stickerId) + val tag = tagService.getById(tagId) + val user = userService.getById(userId) + + val goal = Goal(user, title, deadline, sticker, tag, description!!, emptyList()) + goalRepository.save(goal) + return CreateGoalResponse(goal) + } + + @Transactional + fun delete(userId: Long, goalId: Long) { + val goal = goalRepository.getById(goalId) + validateGoalIsUsers(userId, goal) + goalRepository.delete(goal) + } + + private fun validateGoalIsUsers(userId: Long, goal: Goal) { + if (userId != goal.user.id) { + throw SecurityException() + } + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/controller/GoalController.kt b/application/api/src/main/kotlin/io/raemian/api/goal/controller/GoalController.kt new file mode 100644 index 00000000..b5688fad --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/controller/GoalController.kt @@ -0,0 +1,72 @@ +package io.raemian.api.goal.controller + +import io.raemian.api.auth.domain.CurrentUser +import io.raemian.api.goal.GoalReadService +import io.raemian.api.goal.GoalService +import io.raemian.api.goal.controller.request.CreateGoalRequest +import io.raemian.api.goal.controller.response.CreateGoalResponse +import io.raemian.api.goal.controller.response.GoalResponse +import io.raemian.api.goal.controller.response.GoalsResponse +import io.raemian.api.support.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +fun String.toUri(): URI = URI.create(this) + +@RestController +@RequestMapping("/goal") +class GoalController( + private val goalService: GoalService, + private val goalReadService: GoalReadService, +) { + + @Operation(summary = "유저 목표 전체 조회 API") + @GetMapping + fun findAllByUserId( + @AuthenticationPrincipal currentUser: CurrentUser, + ): ResponseEntity> { + val response = goalReadService.findAllByUserId(currentUser.id) + return ResponseEntity + .ok(ApiResponse.success(response)) + } + + @Operation(summary = "목표 단건 조회 API") + @GetMapping("/{goalId}") + fun getByUserId( + @PathVariable("goalId") goalId: Long, + ): ResponseEntity> = + ResponseEntity.ok( + ApiResponse.success(goalReadService.getById(goalId)), + ) + + @Operation(summary = "목표 생성 API") + @PostMapping + fun create( + @AuthenticationPrincipal currentUser: CurrentUser, + @RequestBody createGoalRequest: CreateGoalRequest, + ): ResponseEntity> { + val response = goalService.create(currentUser.id, createGoalRequest) + return ResponseEntity + .created("/goal/${response.id}".toUri()) + .body(ApiResponse.success(response)) + } + + @Operation(summary = "목표 삭제 API") + @DeleteMapping("/{goalId}") + fun delete( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable goalId: Long, + ): ResponseEntity { + goalService.delete(currentUser.id, goalId) + return ResponseEntity.noContent().build() + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/controller/request/CreateGoalRequest.kt b/application/api/src/main/kotlin/io/raemian/api/goal/controller/request/CreateGoalRequest.kt new file mode 100644 index 00000000..20b28183 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/controller/request/CreateGoalRequest.kt @@ -0,0 +1,10 @@ +package io.raemian.api.goal.controller.request + +data class CreateGoalRequest( + val title: String, + val yearOfDeadline: String, + val monthOfDeadline: String, + val stickerId: Long, + val tagId: Long, + val description: String? = "", +) diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/controller/request/DeleteGoalRequest.kt b/application/api/src/main/kotlin/io/raemian/api/goal/controller/request/DeleteGoalRequest.kt new file mode 100644 index 00000000..2cb324f8 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/controller/request/DeleteGoalRequest.kt @@ -0,0 +1,5 @@ +package io.raemian.api.goal.controller.request + +data class DeleteGoalRequest( + val goalId: Long, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/CreateGoalResponse.kt b/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/CreateGoalResponse.kt new file mode 100644 index 00000000..73476cfe --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/CreateGoalResponse.kt @@ -0,0 +1,23 @@ +package io.raemian.api.goal.controller.response + +import io.raemian.api.support.format +import io.raemian.storage.db.core.goal.Goal + +data class CreateGoalResponse( + val id: Long, + val title: String, + val description: String, + val deadline: String, + val stickerUrl: String, + val tag: String, +) { + + constructor(goal: Goal) : this( + id = goal.id!!, + title = goal.title, + description = goal.description, + deadline = goal.deadline.format(), + stickerUrl = goal.sticker.url, + tag = goal.tag.content, + ) +} diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/GoalResponse.kt b/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/GoalResponse.kt new file mode 100644 index 00000000..80372a96 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/GoalResponse.kt @@ -0,0 +1,42 @@ +package io.raemian.api.goal.controller.response + +import io.raemian.api.support.format +import io.raemian.storage.db.core.goal.Goal +import io.raemian.storage.db.core.tag.Tag +import io.raemian.storage.db.core.task.Task + +data class GoalResponse( + val title: String, + val description: String, + val deadline: String, + val stickerUrl: String, + val tagInfo: TagInfo, + val tasks: List, +) { + + constructor(goal: Goal) : this( + goal.title, + goal.description, + goal.deadline.format(), + goal.sticker.url, + TagInfo(goal.tag), + goal.tasks.map(::TaskInfo), + ) + + data class TagInfo( + val tagId: Long?, + val tagContent: String, + ) { + + constructor(tag: Tag) : this(tag.id, tag.content) + } + + data class TaskInfo( + val taskId: Long?, + val isTaskDone: Boolean, + val taskDescription: String, + ) { + + constructor(task: Task) : this(task.id, task.isDone, task.description) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/GoalsResponse.kt b/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/GoalsResponse.kt new file mode 100644 index 00000000..c0850e92 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/goal/controller/response/GoalsResponse.kt @@ -0,0 +1,33 @@ +package io.raemian.api.goal.controller.response + +import io.raemian.api.support.format +import io.raemian.storage.db.core.goal.Goal + +class GoalsResponse private constructor( + val goals: List, + val goalsCount: Int, +) { + + companion object { + fun from(goals: List): GoalsResponse = + GoalsResponse( + goals.map(::GoalInfo), + goals.size, + ) + } + + data class GoalInfo( + val id: Long?, + val deadline: String, + val stickerUrl: String, + val tagContent: String, + ) { + + constructor(goal: Goal) : this( + id = goal.id, + deadline = goal.deadline.format(), + stickerUrl = goal.sticker.url, + tagContent = goal.tag.content, + ) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/log/LogService.kt b/application/api/src/main/kotlin/io/raemian/api/log/LogService.kt new file mode 100644 index 00000000..8640e9d0 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/log/LogService.kt @@ -0,0 +1,32 @@ +package io.raemian.api.log + +import io.raemian.api.log.controller.request.CreateSlackErrorLogRequest +import io.raemian.infra.logging.enums.ErrorLocationEnum +import io.raemian.infra.logging.logger.SlackLogger +import org.springframework.stereotype.Service + +@Service +class LogService( + val slackLogger: SlackLogger, +) { + + private final val EMPTY_VALUE: String = "EMPTY VALUE" + + fun createSlackErrorLog(createSlackErrorLogRequest: CreateSlackErrorLogRequest) { + slackLogger.error( + createSlackErrorLogRequest.errorLocation, + createSlackErrorLogRequest.appName, + createSlackErrorLogRequest.errormessage, + createSlackErrorLogRequest.userAgent, + createSlackErrorLogRequest.referer, + ) + } + + fun createSlackErrorLog(exception: Exception) { + slackLogger.error( + ErrorLocationEnum.BACKEND_SERVER, + "one-bailey api", + exception.message ?: EMPTY_VALUE, + ) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/log/controller/LogController.kt b/application/api/src/main/kotlin/io/raemian/api/log/controller/LogController.kt new file mode 100644 index 00000000..af5dc130 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/log/controller/LogController.kt @@ -0,0 +1,24 @@ +package io.raemian.api.log.controller + +import io.raemian.api.log.LogService +import io.raemian.api.log.controller.request.CreateSlackErrorLogRequest +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/log") +class LogController( + val logService: LogService, +) { + + @Operation(summary = "Slack Log 생성 API") + @PostMapping("/slack/error") + fun createSlackErrorLog(@RequestBody createSlackErrorLogRequest: CreateSlackErrorLogRequest): ResponseEntity { + logService.createSlackErrorLog(createSlackErrorLogRequest) + return ResponseEntity.noContent().build() + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/log/controller/request/CreateSlackErrorLogRequest.kt b/application/api/src/main/kotlin/io/raemian/api/log/controller/request/CreateSlackErrorLogRequest.kt new file mode 100644 index 00000000..13118721 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/log/controller/request/CreateSlackErrorLogRequest.kt @@ -0,0 +1,11 @@ +package io.raemian.api.log.controller.request + +import io.raemian.infra.logging.enums.ErrorLocationEnum + +data class CreateSlackErrorLogRequest( + val errorLocation: ErrorLocationEnum, + val appName: String, + val errormessage: String, + val referer: String?, + val userAgent: String?, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/sticker/StickerService.kt b/application/api/src/main/kotlin/io/raemian/api/sticker/StickerService.kt new file mode 100644 index 00000000..92885aca --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/sticker/StickerService.kt @@ -0,0 +1,24 @@ +package io.raemian.api.sticker + +import io.raemian.api.sticker.controller.response.StickerResponse +import io.raemian.storage.db.core.sticker.Sticker +import io.raemian.storage.db.core.sticker.StickerRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class StickerService( + private val stickerRepository: StickerRepository, +) { + + @Transactional(readOnly = true) + fun findAll(): List { + return stickerRepository.findAll() + .map(::StickerResponse) + } + + @Transactional(readOnly = true) + fun getById(id: Long): Sticker { + return stickerRepository.getById(id) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/sticker/controller/StickerController.kt b/application/api/src/main/kotlin/io/raemian/api/sticker/controller/StickerController.kt new file mode 100644 index 00000000..e6915a3f --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/sticker/controller/StickerController.kt @@ -0,0 +1,24 @@ +package io.raemian.api.sticker.controller + +import io.raemian.api.sticker.StickerService +import io.raemian.api.sticker.controller.response.StickerResponse +import io.raemian.api.support.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/sticker") +class StickerController( + private val stickerService: StickerService, +) { + + @Operation(summary = "스티커 전체 조회 API") + @GetMapping + fun findAll(): ResponseEntity>> = + ResponseEntity.ok( + ApiResponse.success(stickerService.findAll()), + ) +} diff --git a/application/api/src/main/kotlin/io/raemian/api/sticker/controller/response/StickerResponse.kt b/application/api/src/main/kotlin/io/raemian/api/sticker/controller/response/StickerResponse.kt new file mode 100644 index 00000000..36505c9d --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/sticker/controller/response/StickerResponse.kt @@ -0,0 +1,16 @@ +package io.raemian.api.sticker.controller.response + +import io.raemian.storage.db.core.sticker.Sticker + +data class StickerResponse( + val id: Long?, + val name: String, + val url: String, +) { + + constructor(sticker: Sticker) : this( + sticker.id, + sticker.name, + sticker.url, + ) +} diff --git a/application/api/src/main/kotlin/io/raemian/api/support/JwtFilter.kt b/application/api/src/main/kotlin/io/raemian/api/support/JwtFilter.kt new file mode 100644 index 00000000..868fbe8c --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/support/JwtFilter.kt @@ -0,0 +1,43 @@ +package io.raemian.api.support + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.util.StringUtils +import org.springframework.web.filter.OncePerRequestFilter + +class JwtFilter( + private val tokenProvider: TokenProvider, +) : OncePerRequestFilter() { + + private val AUTHORIZATION_HEADER = "Authorization" + private val BEARER_PREFIX = "Bearer " + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + // 1. Request Header 에서 토큰을 꺼냄 + val jwt: String = resolveToken(request) + + // 2. validateToken 으로 토큰 유효성 검사 + // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장 + if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + val authentication: Authentication = tokenProvider.getAuthentication(jwt) + SecurityContextHolder.getContext().authentication = authentication + } + filterChain.doFilter(request, response) + } + + // Request Header 에서 토큰 정보를 꺼내오기 + private fun resolveToken(request: HttpServletRequest): String { + val bearerToken = request.getHeader(AUTHORIZATION_HEADER) + return if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + bearerToken.split(" ".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1].trim { it <= ' ' } + } else { + "" + } + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/support/RaemianLocalDate.kt b/application/api/src/main/kotlin/io/raemian/api/support/RaemianLocalDate.kt new file mode 100644 index 00000000..d20b0983 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/support/RaemianLocalDate.kt @@ -0,0 +1,47 @@ +package io.raemian.api.support + +import java.time.LocalDate +import java.time.Month +import java.time.Year + +fun LocalDate.format(): String { + var month = (this.monthValue).toString() + if (month.length == 1) { + month = "0$month" + } + + return "${this.year}.$month" +} + +object RaemianLocalDate { + + private const val DAY_OF_MONTH = 1 + + fun of(year: String, month: String): LocalDate { + val parsedYear = parseYear(year) + val parsedMonth = parseMonth(month) + return LocalDate.of(parsedYear, parsedMonth, DAY_OF_MONTH) + } + + private fun parseYear(year: String): Int { + validateYearFormat(year) + return year.toInt() + } + + private fun validateYearFormat(year: String) { + runCatching { + Year.parse(year) + }.onFailure { + throw IllegalArgumentException() + } + } + + private fun parseMonth(month: String): Month { + val result = runCatching { + Month.of(month.toInt()) + } + + return result.getOrNull() + ?: throw IllegalArgumentException() + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/support/SecurityUtil.kt b/application/api/src/main/kotlin/io/raemian/api/support/SecurityUtil.kt new file mode 100644 index 00000000..b045f6f1 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/support/SecurityUtil.kt @@ -0,0 +1,15 @@ +package io.raemian.api.support + +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder + +object SecurityUtil { + // SecurityContext 에 유저 정보가 저장되는 시점 + fun currentUserId(): Long { + val authentication: Authentication? = SecurityContextHolder.getContext().authentication + if (authentication == null || authentication.name == null) { + throw RuntimeException("Security Context 에 인증 정보가 없습니다.") + } + return authentication.name.toLong() + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/support/TokenProvider.kt b/application/api/src/main/kotlin/io/raemian/api/support/TokenProvider.kt new file mode 100644 index 00000000..b35b3a8e --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/support/TokenProvider.kt @@ -0,0 +1,119 @@ +package io.raemian.api.support + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.MalformedJwtException +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.UnsupportedJwtException +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import io.jsonwebtoken.security.SecurityException +import io.raemian.api.auth.domain.CurrentUser +import io.raemian.api.auth.domain.TokenDTO +import org.slf4j.LoggerFactory +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.stereotype.Component +import java.security.Key +import java.time.Duration +import java.util.Date + +@Component +class TokenProvider { + + private val log = LoggerFactory.getLogger(javaClass) + + private val secretKey: String = + "c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK" + private val key: Key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)) + + private val AUTHORITIES_KEY = "auth" + private val EMAIL_KEY = "email" + private val ID_KEY = "id" + private val BEARER_TYPE = "Bearer" + + private val ACCESS_TOKEN_EXPIRE_TIME = Duration.ofMinutes(300).toMillis() // 300분 + private val REFRESH_TOKEN_EXPIRE_TIME = Duration.ofDays(70).toMillis() // 70일 + + fun generateTokenDto(currentUser: CurrentUser): TokenDTO { + val authorities: String = currentUser.authorities + .map { obj: GrantedAuthority -> obj.authority } + .joinToString(",") + val now: Long = Date().time + + // Access Token 생성 + val accessTokenExpiresIn = Date(now + ACCESS_TOKEN_EXPIRE_TIME) + val accessToken: String = Jwts.builder() + .setSubject(currentUser.email) // payload "sub": "name" + .claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER" + .claim(EMAIL_KEY, currentUser.email) + .claim(ID_KEY, currentUser.id) + .setExpiration(accessTokenExpiresIn) // payload "exp": 151621022 (ex) + .signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512" + .compact() + + // Refresh Token 생성 + val refreshToken: String = Jwts.builder() + .setExpiration(Date(now + REFRESH_TOKEN_EXPIRE_TIME)) + .signWith(key, SignatureAlgorithm.HS512) + .compact() + return TokenDTO( + grantType = BEARER_TYPE, + accessToken = accessToken, + refreshToken = refreshToken, + accessTokenExpiresIn = accessTokenExpiresIn.time, + ) + } + + fun getAuthentication(accessToken: String): Authentication { + // 토큰 복호화 + val claims = parseClaims(accessToken) + if (claims[AUTHORITIES_KEY] == null) { + throw RuntimeException("권한 정보가 없는 토큰입니다.") + } + + claims[AUTHORITIES_KEY].toString().split(",".toRegex()) + // 클레임에서 권한 정보 가져오기 + val authorities = claims[AUTHORITIES_KEY] + .toString() + .split(",".toRegex()) + .dropLastWhile { it.isEmpty() } + + val id = claims[ID_KEY] + .toString() + .toLong() + + // UserDetails 객체를 만들어서 Authentication 리턴 + val principal = + CurrentUser(id = id, email = claims.subject, password = "", authorities = authorities) + return UsernamePasswordAuthenticationToken(principal, "", principal.authorities) + } + + fun validateToken(token: String?): Boolean { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token) + return true + } catch (e: SecurityException) { + log.info("잘못된 JWT 서명입니다.") + } catch (e: MalformedJwtException) { + log.info("잘못된 JWT 서명입니다.") + } catch (e: ExpiredJwtException) { + log.info("만료된 JWT 토큰입니다.") + } catch (e: UnsupportedJwtException) { + log.info("지원되지 않는 JWT 토큰입니다.") + } catch (e: IllegalArgumentException) { + log.info("JWT 토큰이 잘못되었습니다.") + } + return false + } + + private fun parseClaims(accessToken: String): Claims { + return try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody() + } catch (e: ExpiredJwtException) { + e.claims + } + } +} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/CoreApiException.kt b/application/api/src/main/kotlin/io/raemian/api/support/error/CoreApiException.kt similarity index 70% rename from core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/CoreApiException.kt rename to application/api/src/main/kotlin/io/raemian/api/support/error/CoreApiException.kt index 6815b19a..569f4251 100644 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/CoreApiException.kt +++ b/application/api/src/main/kotlin/io/raemian/api/support/error/CoreApiException.kt @@ -1,4 +1,4 @@ -package io.dodn.springboot.core.api.support.error +package io.raemian.api.support.error class CoreApiException( val errorType: ErrorType, diff --git a/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorCode.kt b/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorCode.kt new file mode 100644 index 00000000..df31d44f --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorCode.kt @@ -0,0 +1,5 @@ +package io.raemian.api.support.error + +enum class ErrorCode { + E500, +} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorMessage.kt b/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorMessage.kt similarity index 85% rename from core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorMessage.kt rename to application/api/src/main/kotlin/io/raemian/api/support/error/ErrorMessage.kt index 656b5de4..0859c4d2 100644 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorMessage.kt +++ b/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorMessage.kt @@ -1,4 +1,4 @@ -package io.dodn.springboot.core.api.support.error +package io.raemian.api.support.error data class ErrorMessage private constructor( val code: String, diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorType.kt b/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorType.kt similarity index 86% rename from core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorType.kt rename to application/api/src/main/kotlin/io/raemian/api/support/error/ErrorType.kt index 2fc96e27..e92fdcc6 100644 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorType.kt +++ b/application/api/src/main/kotlin/io/raemian/api/support/error/ErrorType.kt @@ -1,4 +1,4 @@ -package io.dodn.springboot.core.api.support.error +package io.raemian.api.support.error import org.springframework.boot.logging.LogLevel import org.springframework.http.HttpStatus diff --git a/application/api/src/main/kotlin/io/raemian/api/support/response/ApiResponse.kt b/application/api/src/main/kotlin/io/raemian/api/support/response/ApiResponse.kt new file mode 100644 index 00000000..41eccbb8 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/support/response/ApiResponse.kt @@ -0,0 +1,20 @@ +package io.raemian.api.support.response + +import io.raemian.api.support.error.ErrorMessage +import io.raemian.api.support.error.ErrorType + +class ApiResponse private constructor( + val result: ResultType, + val body: T? = null, + val error: ErrorMessage? = null, +) { + companion object { + fun success(data: S): ApiResponse { + return ApiResponse(ResultType.SUCCESS, data, null) + } + + fun error(error: ErrorType, errorData: Any? = null): ApiResponse { + return ApiResponse(ResultType.ERROR, null, ErrorMessage(error, errorData)) + } + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/support/response/ResultType.kt b/application/api/src/main/kotlin/io/raemian/api/support/response/ResultType.kt new file mode 100644 index 00000000..b8860a45 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/support/response/ResultType.kt @@ -0,0 +1,5 @@ +package io.raemian.api.support.response + +enum class ResultType { + SUCCESS, ERROR +} diff --git a/application/api/src/main/kotlin/io/raemian/api/tag/TagService.kt b/application/api/src/main/kotlin/io/raemian/api/tag/TagService.kt new file mode 100644 index 00000000..1d220c90 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/tag/TagService.kt @@ -0,0 +1,24 @@ +package io.raemian.api.tag + +import io.raemian.api.tag.controller.response.TagResponse +import io.raemian.storage.db.core.tag.Tag +import io.raemian.storage.db.core.tag.TagRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class TagService( + private val tagRepository: TagRepository, +) { + + @Transactional(readOnly = true) + fun findAll(): List { + return tagRepository.findAll() + .map(::TagResponse) + } + + @Transactional(readOnly = true) + fun getById(id: Long): Tag { + return tagRepository.getById(id) + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/tag/controller/TagController.kt b/application/api/src/main/kotlin/io/raemian/api/tag/controller/TagController.kt new file mode 100644 index 00000000..f278478e --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/tag/controller/TagController.kt @@ -0,0 +1,24 @@ +package io.raemian.api.tag.controller + +import io.raemian.api.support.response.ApiResponse +import io.raemian.api.tag.TagService +import io.raemian.api.tag.controller.response.TagResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/tag") +class TagController( + private val tagService: TagService, +) { + + @Operation(summary = "태그 전체 조회 API") + @GetMapping + fun findAll(): ResponseEntity>> = + ResponseEntity.ok( + ApiResponse.success(tagService.findAll()), + ) +} diff --git a/application/api/src/main/kotlin/io/raemian/api/tag/controller/response/TagResponse.kt b/application/api/src/main/kotlin/io/raemian/api/tag/controller/response/TagResponse.kt new file mode 100644 index 00000000..e135bf6d --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/tag/controller/response/TagResponse.kt @@ -0,0 +1,14 @@ +package io.raemian.api.tag.controller.response + +import io.raemian.storage.db.core.tag.Tag + +data class TagResponse( + val id: Long?, + val content: String, +) { + + constructor(tag: Tag) : this( + tag.id, + tag.content, + ) +} diff --git a/application/api/src/main/kotlin/io/raemian/api/task/TaskService.kt b/application/api/src/main/kotlin/io/raemian/api/task/TaskService.kt new file mode 100644 index 00000000..6cdeca42 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/task/TaskService.kt @@ -0,0 +1,65 @@ +package io.raemian.api.task + +import io.raemian.api.task.controller.request.CreateTaskRequest +import io.raemian.api.task.controller.request.RewriteTaskRequest +import io.raemian.api.task.controller.request.UpdateTaskCompletionRequest +import io.raemian.api.task.controller.response.CreateTaskResponse +import io.raemian.storage.db.core.goal.Goal +import io.raemian.storage.db.core.goal.GoalRepository +import io.raemian.storage.db.core.task.Task +import io.raemian.storage.db.core.task.TaskRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class TaskService( + val taskRepository: TaskRepository, + val goalRepository: GoalRepository, +) { + + @Transactional + fun create(currentUserId: Long, createTaskRequest: CreateTaskRequest): CreateTaskResponse { + val goal = goalRepository.getById(createTaskRequest.goalId) + validateCurrentUserIsGoalOwner(currentUserId, goal) + + val task = Task.createTask(goal, createTaskRequest.description) + taskRepository.save(task) + return CreateTaskResponse(task.id!!, task.description) + } + + @Transactional + fun rewrite(currentUserId: Long, taskId: Long, rewriteTaskRequest: RewriteTaskRequest) { + val task = taskRepository.getById(taskId) + validateCurrentUserIsGoalOwner(currentUserId, task.goal) + + task.rewrite(rewriteTaskRequest.newDescription) + taskRepository.save(task) + } + + @Transactional + fun updateTaskCompletion( + currentUserId: Long, + taskId: Long, + updateTaskCompletionRequest: UpdateTaskCompletionRequest, + ) { + val task = taskRepository.getById(taskId) + validateCurrentUserIsGoalOwner(currentUserId, task.goal) + + task.updateTaskCompletion(updateTaskCompletionRequest.isDone) + taskRepository.save(task) + } + + @Transactional + fun delete(currentUserId: Long, taskId: Long) { + val task = taskRepository.getById(taskId) + validateCurrentUserIsGoalOwner(currentUserId, task.goal) + + taskRepository.delete(task) + } + + private fun validateCurrentUserIsGoalOwner(currentUserId: Long, goal: Goal) { + if (currentUserId != goal.user.id) { + throw SecurityException() + } + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/task/controller/TaskController.kt b/application/api/src/main/kotlin/io/raemian/api/task/controller/TaskController.kt new file mode 100644 index 00000000..e59d6460 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/task/controller/TaskController.kt @@ -0,0 +1,70 @@ +package io.raemian.api.task.controller + +import io.raemian.api.auth.domain.CurrentUser +import io.raemian.api.goal.controller.toUri +import io.raemian.api.support.response.ApiResponse +import io.raemian.api.task.TaskService +import io.raemian.api.task.controller.request.CreateTaskRequest +import io.raemian.api.task.controller.request.RewriteTaskRequest +import io.raemian.api.task.controller.request.UpdateTaskCompletionRequest +import io.raemian.api.task.controller.response.CreateTaskResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/task") +class TaskController( + private val taskService: TaskService, +) { + + @Operation(summary = "Task 생성 API입니다.") + @PostMapping + fun create( + @AuthenticationPrincipal currentUser: CurrentUser, + @RequestBody createTaskRequest: CreateTaskRequest, + ): ResponseEntity> { + val response = taskService.create(currentUser.id, createTaskRequest) + return ResponseEntity.created("/task/${response.id}".toUri()) + .body(ApiResponse.success(response)) + } + + @Operation(summary = "Task의 description을 수정하는 API입니다.") + @PatchMapping("/{taskId}/description") + fun rewrite( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable("taskId") taskId: Long, + @RequestBody rewriteTaskRequest: RewriteTaskRequest, + ): ResponseEntity { + taskService.rewrite(currentUser.id, taskId, rewriteTaskRequest) + return ResponseEntity.ok().build() + } + + @Operation(summary = "Task의 완료 여부를 수정하는 API입니다.") + @PatchMapping("/{taskId}/isDone") + fun updateTaskCompletion( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable("taskId") taskId: Long, + @RequestBody updateTaskCompletionRequest: UpdateTaskCompletionRequest, + ): ResponseEntity { + taskService.updateTaskCompletion(currentUser.id, taskId, updateTaskCompletionRequest) + return ResponseEntity.ok().build() + } + + @Operation(summary = "Task를 삭제하는 API입니다.") + @DeleteMapping("/{taskId}") + fun updateTaskCompletion( + @AuthenticationPrincipal currentUser: CurrentUser, + @PathVariable("taskId") taskId: Long, + ): ResponseEntity { + taskService.delete(currentUser.id, taskId) + return ResponseEntity.noContent().build() + } +} diff --git a/application/api/src/main/kotlin/io/raemian/api/task/controller/request/CreateTaskRequest.kt b/application/api/src/main/kotlin/io/raemian/api/task/controller/request/CreateTaskRequest.kt new file mode 100644 index 00000000..78a92682 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/task/controller/request/CreateTaskRequest.kt @@ -0,0 +1,6 @@ +package io.raemian.api.task.controller.request + +data class CreateTaskRequest( + val goalId: Long, + val description: String, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/task/controller/request/RewriteTaskRequest.kt b/application/api/src/main/kotlin/io/raemian/api/task/controller/request/RewriteTaskRequest.kt new file mode 100644 index 00000000..40d58457 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/task/controller/request/RewriteTaskRequest.kt @@ -0,0 +1,5 @@ +package io.raemian.api.task.controller.request + +data class RewriteTaskRequest( + val newDescription: String, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/task/controller/request/UpdateTaskCompletionRequest.kt b/application/api/src/main/kotlin/io/raemian/api/task/controller/request/UpdateTaskCompletionRequest.kt new file mode 100644 index 00000000..6bc42adc --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/task/controller/request/UpdateTaskCompletionRequest.kt @@ -0,0 +1,5 @@ +package io.raemian.api.task.controller.request + +data class UpdateTaskCompletionRequest( + val isDone: Boolean, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/task/controller/response/CreateTaskResponse.kt b/application/api/src/main/kotlin/io/raemian/api/task/controller/response/CreateTaskResponse.kt new file mode 100644 index 00000000..8355d9a5 --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/task/controller/response/CreateTaskResponse.kt @@ -0,0 +1,6 @@ +package io.raemian.api.task.controller.response + +class CreateTaskResponse( + val id: Long, + val description: String, +) diff --git a/application/api/src/main/kotlin/io/raemian/api/user/UserService.kt b/application/api/src/main/kotlin/io/raemian/api/user/UserService.kt new file mode 100644 index 00000000..3930a02d --- /dev/null +++ b/application/api/src/main/kotlin/io/raemian/api/user/UserService.kt @@ -0,0 +1,17 @@ +package io.raemian.api.user + +import io.raemian.storage.db.core.user.User +import io.raemian.storage.db.core.user.UserRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserService( + private val userRepository: UserRepository, +) { + + @Transactional(readOnly = true) + fun getById(userId: Long): User { + return userRepository.getById(userId) + } +} diff --git a/application/api/src/main/resources/application-security.yml b/application/api/src/main/resources/application-security.yml new file mode 100644 index 00000000..032cc2fa --- /dev/null +++ b/application/api/src/main/resources/application-security.yml @@ -0,0 +1,98 @@ +# local +spring.config.activate.on-profile: local + +spring: + security: + oauth2: + client: + provider: + naver: + authorization_uri: https://nid.naver.com/oauth2.0/authorize + token_uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user_name_attribute: response + registration: + naver: + client-id: f0Y2iXBYxDsBPH699BkC + client-secret: xxOOW3Nr6X + redirect-uri: http://localhost:8080/login/oauth2/code/naver + authorization-grant-type: authorization_code + scope: + - email + - name + client-name: naver + google: + redirect-uri: ${GOOGLE-REDIRECT-URI} + client-id: ${GOOGLE-CLIENT-ID} + client-secret: ${GOOGLE-CLIENT-SECRET} + scope: + - email + - profile + + +--- +# dev +spring.config.activate.on-profile: dev + +spring: + security: + oauth2: + client: + provider: + naver: + authorization_uri: https://nid.naver.com/oauth2.0/authorize + token_uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user_name_attribute: response + registration: + naver: + client-id: f0Y2iXBYxDsBPH699BkC + client-secret: xxOOW3Nr6X + redirect-uri: http://localhost:8080/login/oauth2/code/naver + authorization-grant-type: authorization_code + scope: + - email + - name + client-name: naver + google: + redirect-uri: ${GOOGLE-REDIRECT-URI} + client-id: ${GOOGLE-CLIENT-ID} + client-secret: ${GOOGLE-CLIENT-SECRET} + scope: + - email + - profile + + + +--- +# live +spring.config.activate.on-profile: live + +spring: + security: + oauth2: + client: + provider: + naver: + authorization_uri: https://nid.naver.com/oauth2.0/authorize + token_uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user_name_attribute: response + registration: + naver: + client-id: f0Y2iXBYxDsBPH699BkC + client-secret: xxOOW3Nr6X + redirect-uri: http://localhost:8080/login/oauth2/code/naver + authorization-grant-type: authorization_code + scope: + - email + - name + client-name: naver + google: + redirect-uri: ${GOOGLE-REDIRECT-URI} + client-id: ${GOOGLE-CLIENT-ID} + client-secret: ${GOOGLE-CLIENT-SECRET} + scope: + - email + - profile + diff --git a/application/api/src/main/resources/application.yml b/application/api/src/main/resources/application.yml new file mode 100644 index 00000000..22326240 --- /dev/null +++ b/application/api/src/main/resources/application.yml @@ -0,0 +1,50 @@ +# default +spring: + profiles: + default: local + application: + name: api + mvc.throw-exception-if-no-handler-found: true + web.resources.add-mappings: false + +server: + servlet: + context-path: /api + + +springdoc: + server: + url: ${SPRINGDOC-SERVER-URL:http://localhost:8080/api} + +--- +# local +spring: + profiles: + group: + local: + - security + - db-core + - logging + - metrics + +--- +# dev +spring: + profiles: + group: + dev: + - security + - db-core + - logging + - metrics + +--- +# live +spring: + profiles: + group: + live: + - security + - db-core + - logging + - metrics \ No newline at end of file diff --git a/application/api/src/test/kotlin/io/raemian/api/CoreApiApplicationTest.kt b/application/api/src/test/kotlin/io/raemian/api/CoreApiApplicationTest.kt new file mode 100644 index 00000000..175c0b4d --- /dev/null +++ b/application/api/src/test/kotlin/io/raemian/api/CoreApiApplicationTest.kt @@ -0,0 +1,12 @@ +package io.raemian.api + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class CoreApiApplicationTest { + + @Test + fun contextLoads() { + } +} diff --git a/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalReadServiceTest.kt b/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalReadServiceTest.kt new file mode 100644 index 00000000..5615d3cc --- /dev/null +++ b/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalReadServiceTest.kt @@ -0,0 +1,226 @@ +package io.raemian.api.integration.goal + +import io.raemian.api.goal.GoalReadService +import io.raemian.storage.db.core.goal.Goal +import io.raemian.storage.db.core.goal.GoalRepository +import io.raemian.storage.db.core.sticker.Sticker +import io.raemian.storage.db.core.tag.Tag +import io.raemian.storage.db.core.user.Authority +import io.raemian.storage.db.core.user.User +import io.raemian.storage.db.core.user.enums.OAuthProvider +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.function.Executable +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@SpringBootTest +class GoalReadServiceTest { + + companion object { + val USER_FIXTURE = User( + email = "dfghcvb111@naver.com", + userName = "binaryHoHo", + nickname = "binaryHoHoHo", + birth = LocalDate.MIN, + image = "", + provider = OAuthProvider.NAVER, + authority = Authority.ROLE_USER, + ) + + val STICKER_FIXTURE = Sticker("sticker", "image yeah") + val TAG_FIXTURE = Tag("꿈") + } + + @Autowired + private lateinit var goalReadService: GoalReadService + + @Autowired + private lateinit var goalRepository: GoalRepository + + @Autowired + private lateinit var entityManager: EntityManager + + @BeforeEach + fun saveEntities() { + entityManager.merge(USER_FIXTURE) + entityManager.merge(STICKER_FIXTURE) + entityManager.merge(TAG_FIXTURE) + } + + @Test + @DisplayName("Goal ID를 통해 Goal을 조회 할 수 있다.") + @Transactional + fun getByIdTest() { + // given + val goal = Goal( + user = USER_FIXTURE, + title = "짱이 될거야", + deadline = LocalDate.MAX, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "열심히, 잘, 최선을 다해 꼭 짱이 된다.", + tasks = emptyList(), + ) + + val savedGoal = goalRepository.save(goal) + + // when + // then + assertThatCode { + goalReadService.getById(savedGoal.id!!) + }.doesNotThrowAnyException() + } + + @Test + @DisplayName("User ID를 통해 유저가 가진 전체 Goal을 조회 할 수 있다.") + @Transactional + fun findAllByUserIdTest() { + // given + val goal1 = Goal( + user = USER_FIXTURE, + title = "제목1", + deadline = LocalDate.MAX, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "", + tasks = emptyList(), + ) + + val goal2 = Goal( + user = USER_FIXTURE, + title = "제목2", + deadline = LocalDate.MAX, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "", + tasks = emptyList(), + ) + + goalRepository.save(goal1) + goalRepository.save(goal2) + + // when + val savedGoals = goalReadService.findAllByUserId(USER_FIXTURE.id!!) + + // then + assertAll( + Executable { + assertThat(savedGoals.goalsCount).isEqualTo(2) + assertThat(savedGoals.goals[0].tagContent).isEqualTo(goal1.tag.content) + assertThat(savedGoals.goals[1].tagContent).isEqualTo(goal2.tag.content) + }, + ) + } + + @Test + @DisplayName("GoalsResponse와 GoalResponse의 deadline 포멧은 'YYYY.MM'이다.") + @Transactional + fun responseFormattingTest() { + // given + val now = LocalDate.now() + val goal1 = Goal( + user = USER_FIXTURE, + title = "제목1", + deadline = now, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "", + tasks = emptyList(), + ) + + val goal2 = Goal( + user = USER_FIXTURE, + title = "제목2", + deadline = now, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "", + tasks = emptyList(), + ) + goalRepository.save(goal1) + goalRepository.save(goal2) + + // when + val savedGoal = goalReadService.getById(goal1.id!!) + val savedGoals = goalReadService.findAllByUserId(USER_FIXTURE.id!!) + + // then + var month = (now.monthValue).toString() + if (month.length == 1) { + month = "0$month" + } + val deadline = "${now.year}.$month" + + assertAll( + Executable { + assertThat(savedGoal.deadline).isEqualTo(deadline) + assertThat(savedGoals.goals[0].deadline).isEqualTo(deadline) + assertThat(savedGoals.goals[1].deadline).isEqualTo(deadline) + }, + ) + } + + // 정렬 테스트 + @Test + @DisplayName("Goals 전체 조회시 Deadline 기준 오름차순, CreatedAt 기준 내림차순으로 정렬된다.") + @Transactional + fun sortGoalsTest() { + // given + val deadline이_내일이고_가장_처음_만들어진_객체 = Goal( + user = USER_FIXTURE, + title = "제목1", + deadline = LocalDate.now() + .plusDays(1), + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "", + tasks = emptyList(), + ) + + val deadline이_내일이고_가장_나중에_만들어진_객체 = Goal( + user = USER_FIXTURE, + title = "제목2", + deadline = LocalDate.now() + .plusDays(1), + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "", + tasks = emptyList(), + ) + + val deadline이_오늘인_객체 = Goal( + user = USER_FIXTURE, + title = "제목2", + deadline = LocalDate.now(), + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "", + tasks = emptyList(), + ) + + // 역순으로 저장한다. + goalRepository.save(deadline이_내일이고_가장_처음_만들어진_객체) + goalRepository.save(deadline이_내일이고_가장_나중에_만들어진_객체) + goalRepository.save(deadline이_오늘인_객체) + + // when + val savedGoals = goalReadService.findAllByUserId(USER_FIXTURE.id!!) + + // then + assertAll( + Executable { + assertThat(savedGoals.goals[0].id).isEqualTo(deadline이_오늘인_객체.id) + assertThat(savedGoals.goals[1].id).isEqualTo(deadline이_내일이고_가장_나중에_만들어진_객체.id) + assertThat(savedGoals.goals[2].id).isEqualTo(deadline이_내일이고_가장_처음_만들어진_객체.id) + }, + ) + } +} diff --git a/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalServiceTest.kt b/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalServiceTest.kt new file mode 100644 index 00000000..100d146a --- /dev/null +++ b/application/api/src/test/kotlin/io/raemian/api/integration/goal/GoalServiceTest.kt @@ -0,0 +1,115 @@ +package io.raemian.api.integration.goal + +import io.raemian.api.goal.GoalService +import io.raemian.api.goal.controller.request.CreateGoalRequest +import io.raemian.storage.db.core.goal.Goal +import io.raemian.storage.db.core.goal.GoalRepository +import io.raemian.storage.db.core.sticker.Sticker +import io.raemian.storage.db.core.tag.Tag +import io.raemian.storage.db.core.user.Authority +import io.raemian.storage.db.core.user.User +import io.raemian.storage.db.core.user.enums.OAuthProvider +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.function.Executable +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@SpringBootTest +@Transactional +class GoalServiceTest { + + companion object { + val USER_FIXTURE = User( + email = "dfghcvb111@naver.com", + userName = "binaryHoHo", + nickname = "binaryHoHoHo", + birth = LocalDate.MIN, + image = "", + provider = OAuthProvider.NAVER, + authority = Authority.ROLE_USER, + ) + + val STICKER_FIXTURE = Sticker("sticker", "image yeah") + val TAG_FIXTURE = Tag("꿈") + } + + @Autowired + private lateinit var goalService: GoalService + + @Autowired + private lateinit var goalRepository: GoalRepository + + @Autowired + private lateinit var entityManager: EntityManager + + @BeforeEach + fun saveEntities() { + entityManager.merge(USER_FIXTURE) + entityManager.merge(STICKER_FIXTURE) + entityManager.merge(TAG_FIXTURE) + } + + @Test + @DisplayName("Goal을 생성할 수 있다.") + fun createGoalTest() { + val createGoalRequest = CreateGoalRequest( + title = "title", + description = "description", + stickerId = STICKER_FIXTURE.id!!, + tagId = TAG_FIXTURE.id!!, + yearOfDeadline = "2023", + monthOfDeadline = "12", + ) + + val createResponse = goalService.create( + USER_FIXTURE.id!!, + createGoalRequest, + ) + + val goal = goalRepository.getById(createResponse.id) + assertAll( + Executable { + Assertions.assertThat(goal.title).isEqualTo(createGoalRequest.title) + Assertions.assertThat(goal.description).isEqualTo(createGoalRequest.description) + Assertions.assertThat(goal.sticker.id).isEqualTo(createGoalRequest.stickerId) + Assertions.assertThat(goal.tag.id).isEqualTo(createGoalRequest.tagId) + Assertions.assertThat(goal.deadline.year.toString()) + .isEqualTo(createGoalRequest.yearOfDeadline) + Assertions.assertThat(goal.deadline.monthValue.toString()) + .isEqualTo(createGoalRequest.monthOfDeadline) + }, + ) + } + + @Test + @DisplayName("Goal을 삭제할 수 있다.") + @Transactional + fun deleteGoalTest() { + // given + val goal = Goal( + user = USER_FIXTURE, + title = "title", + description = "", + deadline = LocalDate.now(), + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + tasks = emptyList(), + ) + + goalRepository.save(goal) + + // when + goalService.delete(USER_FIXTURE.id!!, goal.id!!) + + // then + assertThat(goalRepository.findById(goal.id!!).isEmpty).isTrue() + } +} diff --git a/application/api/src/test/kotlin/io/raemian/api/integration/sticker/StickerServiceTest.kt b/application/api/src/test/kotlin/io/raemian/api/integration/sticker/StickerServiceTest.kt new file mode 100644 index 00000000..8d6fd869 --- /dev/null +++ b/application/api/src/test/kotlin/io/raemian/api/integration/sticker/StickerServiceTest.kt @@ -0,0 +1,47 @@ +package io.raemian.api.integration.sticker + +import io.raemian.api.sticker.StickerService +import io.raemian.storage.db.core.sticker.Sticker +import io.raemian.storage.db.core.sticker.StickerRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.function.Executable +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class StickerServiceTest { + + @Autowired + private lateinit var stickerService: StickerService + + @Autowired + private lateinit var stickerRepository: StickerRepository + + @Test + @DisplayName("저장된 전체 Tag를 조회할 수 있다.") + fun findAllByUserIdTest() { + // given + val sticker1 = Sticker("sticker", "image1") + val sticker2 = Sticker("sticker2", "image2") + + stickerRepository.save(sticker1) + stickerRepository.save(sticker2) + + // when + val stickers = stickerService.findAll() + + // then + assertAll( + Executable { + assertThat(stickers.size).isEqualTo(2) + assertThat(stickers[0].name).isEqualTo(sticker1.name) + assertThat(stickers[0].url).isEqualTo(sticker1.url) + assertThat(stickers[1].name).isEqualTo(sticker2.name) + assertThat(stickers[1].url).isEqualTo(sticker2.url) + }, + ) + } +} diff --git a/application/api/src/test/kotlin/io/raemian/api/integration/tag/TagServiceTest.kt b/application/api/src/test/kotlin/io/raemian/api/integration/tag/TagServiceTest.kt new file mode 100644 index 00000000..b8536a80 --- /dev/null +++ b/application/api/src/test/kotlin/io/raemian/api/integration/tag/TagServiceTest.kt @@ -0,0 +1,45 @@ +package io.raemian.api.integration.tag + +import io.raemian.api.tag.TagService +import io.raemian.storage.db.core.tag.Tag +import io.raemian.storage.db.core.tag.TagRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.function.Executable +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class TagServiceTest { + + @Autowired + private lateinit var tagService: TagService + + @Autowired + private lateinit var tagRepository: TagRepository + + @Test + @DisplayName("저장된 전체 Sticker를 조회할 수 있다.") + fun findAllByUserIdTest() { + // given + val tag1 = Tag("tag1") + val tag2 = Tag("tag2") + + tagRepository.save(tag1) + tagRepository.save(tag2) + + // when + val tags = tagService.findAll() + + // then + assertAll( + Executable { + assertThat(tags.size).isEqualTo(2) + assertThat(tags[0].content).isEqualTo(tag1.content) + assertThat(tags[1].content).isEqualTo(tag2.content) + }, + ) + } +} diff --git a/application/api/src/test/kotlin/io/raemian/api/integration/task/TaskServiceTest.kt b/application/api/src/test/kotlin/io/raemian/api/integration/task/TaskServiceTest.kt new file mode 100644 index 00000000..26f7c4d1 --- /dev/null +++ b/application/api/src/test/kotlin/io/raemian/api/integration/task/TaskServiceTest.kt @@ -0,0 +1,160 @@ +package io.raemian.api.integration.task + +import io.raemian.api.task.TaskService +import io.raemian.api.task.controller.request.CreateTaskRequest +import io.raemian.api.task.controller.request.RewriteTaskRequest +import io.raemian.api.task.controller.request.UpdateTaskCompletionRequest +import io.raemian.storage.db.core.goal.Goal +import io.raemian.storage.db.core.goal.GoalRepository +import io.raemian.storage.db.core.sticker.Sticker +import io.raemian.storage.db.core.tag.Tag +import io.raemian.storage.db.core.task.Task +import io.raemian.storage.db.core.task.TaskRepository +import io.raemian.storage.db.core.user.Authority +import io.raemian.storage.db.core.user.User +import io.raemian.storage.db.core.user.enums.OAuthProvider +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +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.transaction.annotation.Transactional +import java.time.LocalDate + +@SpringBootTest +@Transactional +class TaskServiceTest { + + companion object { + val USER_FIXTURE = User( + email = "dfghcvb111@naver.com", + userName = "binaryHoHo", + nickname = "binaryHoHoHo", + image = "", + birth = LocalDate.MIN, + provider = OAuthProvider.NAVER, + authority = Authority.ROLE_USER, + ) + + val STICKER_FIXTURE = Sticker("sticker", "image yeah") + val TAG_FIXTURE = Tag("꿈") + val GOAL_FIXTURE = Goal( + user = USER_FIXTURE, + title = "title", + deadline = LocalDate.MAX, + sticker = STICKER_FIXTURE, + tag = TAG_FIXTURE, + description = "description", + tasks = emptyList(), + ) + } + + @Autowired + private lateinit var taskService: TaskService + + @Autowired + private lateinit var taskRepository: TaskRepository + + @Autowired + private lateinit var goalRepository: GoalRepository + + @Autowired + private lateinit var entityManager: EntityManager + + @BeforeEach + fun saveEntities() { + entityManager.merge(USER_FIXTURE) + entityManager.merge(STICKER_FIXTURE) + entityManager.merge(TAG_FIXTURE) + goalRepository.save(GOAL_FIXTURE) + } + + @Test + @DisplayName("Goal ID와 description으로 Task를 생성할 수 있다.") + fun createTest() { + // given + // when + val response = taskService.create( + currentUserId = USER_FIXTURE.id!!, + CreateTaskRequest(GOAL_FIXTURE.id!!, "description"), + ) + + // then + val task = taskRepository.getById(response.id) + assertThat(task.id).isEqualTo(response.id) + assertThat(task.description).isEqualTo("description") + assertThat(task.goal.description).isEqualTo(GOAL_FIXTURE.description) + } + + @Test + @DisplayName("Task 생성시 isDone 값은 false로 설정된다.") + fun createTaskIsDoneFalseTest() { + // given + // when + val response = taskService.create( + currentUserId = USER_FIXTURE.id!!, + CreateTaskRequest(GOAL_FIXTURE.id!!, "description"), + ) + + // then + val task = taskRepository.getById(response.id) + assertThat(task.isDone).isEqualTo(false) + } + + @Test + @DisplayName("Task의 Description을 수정할 수 있다.") + fun rewriteTest() { + // given + val description = "description" + val newTask = Task.createTask(GOAL_FIXTURE, description) + taskRepository.save(newTask) + + // when + val newDescription = "new description" + taskService.rewrite( + currentUserId = USER_FIXTURE.id!!, + taskId = newTask.id!!, + RewriteTaskRequest(newDescription), + ) + + // then + val task = taskRepository.getById(newTask.id!!) + assertThat(task.description).isEqualTo(newDescription) + } + + @Test + @DisplayName("Task의 수행 여부를 변경할 수 있다.") + fun updateTaskCompletionTest() { + // given + val newTask = Task.createTask(GOAL_FIXTURE, "description") + taskRepository.save(newTask) + + // when + taskService.updateTaskCompletion( + currentUserId = USER_FIXTURE.id!!, + taskId = newTask.id!!, + UpdateTaskCompletionRequest(true), + ) + + // then + val task = taskRepository.getById(newTask.id!!) + assertThat(task.isDone).isEqualTo(true) + } + + @Test + @DisplayName("Task를 삭제할 수 있다.") + fun deleteTest() { + // given + val newTask = Task.createTask(GOAL_FIXTURE, "description") + taskRepository.save(newTask) + + // when + taskService.delete(USER_FIXTURE.id!!, newTask.id!!) + + // then + val task = taskRepository.findById(newTask.id!!) + assertThat(task.isEmpty).isTrue() + } +} diff --git a/application/api/src/test/kotlin/io/raemian/api/support/RaemianLocalDateTest.kt b/application/api/src/test/kotlin/io/raemian/api/support/RaemianLocalDateTest.kt new file mode 100644 index 00000000..1dbcbf8d --- /dev/null +++ b/application/api/src/test/kotlin/io/raemian/api/support/RaemianLocalDateTest.kt @@ -0,0 +1,78 @@ +package io.raemian.api.support + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.time.LocalDate + +class RaemianLocalDateTest { + + @Test + @DisplayName("RaemianLocalDate를 통해 연도와 날짜 string으로 LocalDate를 만들 수 있다.") + fun createRaemianLocalDateTest() { + // given + val year = "2023" + val month = "11" + + // when + val localDate = RaemianLocalDate.of(year, month) + + // then + assertThat(localDate.year.toString()).isEqualTo(year) + assertThat(localDate.monthValue.toString()).isEqualTo(month) + } + + @Test + @DisplayName("RaemianLocalDate를 통해 생성된 LocalDate의 날짜가 모두 같다.") + fun createRaemianLocalDateFixedDayTest() { + // given + val year1 = (1..2024).random().toString() + val month1 = (1..12).random().toString() + + val year2 = (1..2024).random().toString() + val month2 = (1..12).random().toString() + + // when + val localDate1 = RaemianLocalDate.of(year1, month1) + val localDate2 = RaemianLocalDate.of(year2, month2) + + // then + assertThat(localDate1.dayOfMonth).isEqualTo(localDate2.dayOfMonth) + } + + @DisplayName("RaemianLocalDate를 통해 생성된 LocalDate생성시 연도나 월의 형식이 잘못 되면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = ["13", "-1", "abcdabcd", "331er"]) + fun createRaemianLocalDateOutOfBoundTest(yearAndMonth: String) { + // given + // when + // then + assertThatThrownBy { + RaemianLocalDate.of(yearAndMonth, yearAndMonth) + }.isInstanceOf(RuntimeException::class.java) + } + + @Test + @DisplayName("LocalDate Format을 'YYYY.MM'로 변환할 수 있다.") + fun localDateFormattingTest() { + // given + val localDate = LocalDate.now() + + // when + val formattedLocalDate = localDate.format() + + // then + val year = localDate.year.toString() + var month = localDate.monthValue.toString() + if (month.length == 1) { + month = "0$month" + } + + val expectedLocalDate = "$year.$month" + + assertThat(formattedLocalDate).isEqualTo(expectedLocalDate) + } +} diff --git a/application/api/src/test/resources/application-security.yml b/application/api/src/test/resources/application-security.yml new file mode 100644 index 00000000..967fa6e6 --- /dev/null +++ b/application/api/src/test/resources/application-security.yml @@ -0,0 +1,94 @@ +# local +spring.config.activate.on-profile: local + +spring: + security: + oauth2: + client: + provider: + naver: + authorization_uri: https://nid.naver.com/oauth2.0/authorize + token_uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user_name_attribute: response + registration: + naver: + client-id: f0Y2iXBYxDsBPH699BkC + client-secret: xxOOW3Nr6X + redirect-uri: http://localhost:8080/login/oauth2/code/naver + authorization-grant-type: authorization_code + scope: + - email + - name + client-name: naver + google: + client-id: ${GOOGLE-CLIENT-ID} + client-secret: ${GOOGLE-CLIENT-SECRET} + scope: + - email + - profile + + +--- +# dev +spring.config.activate.on-profile: dev + +spring: + security: + oauth2: + client: + provider: + naver: + authorization_uri: https://nid.naver.com/oauth2.0/authorize + token_uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user_name_attribute: response + registration: + naver: + client-id: f0Y2iXBYxDsBPH699BkC + client-secret: xxOOW3Nr6X + redirect-uri: http://localhost:8080/login/oauth2/code/naver + authorization-grant-type: authorization_code + scope: + - email + - name + client-name: naver + google: + client-id: ${GOOGLE-CLIENT-ID} + client-secret: ${GOOGLE-CLIENT-SECRET} + scope: + - email + - profile + + + +--- +# live +spring.config.activate.on-profile: live + +spring: + security: + oauth2: + client: + provider: + naver: + authorization_uri: https://nid.naver.com/oauth2.0/authorize + token_uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user_name_attribute: response + registration: + naver: + client-id: f0Y2iXBYxDsBPH699BkC + client-secret: xxOOW3Nr6X + redirect-uri: http://localhost:8080/login/oauth2/code/naver + authorization-grant-type: authorization_code + scope: + - email + - name + client-name: naver + google: + client-id: ${GOOGLE-CLIENT-ID} + client-secret: ${GOOGLE-CLIENT-SECRET} + scope: + - email + - profile diff --git a/application/api/src/test/resources/application.yml b/application/api/src/test/resources/application.yml new file mode 100644 index 00000000..22326240 --- /dev/null +++ b/application/api/src/test/resources/application.yml @@ -0,0 +1,50 @@ +# default +spring: + profiles: + default: local + application: + name: api + mvc.throw-exception-if-no-handler-found: true + web.resources.add-mappings: false + +server: + servlet: + context-path: /api + + +springdoc: + server: + url: ${SPRINGDOC-SERVER-URL:http://localhost:8080/api} + +--- +# local +spring: + profiles: + group: + local: + - security + - db-core + - logging + - metrics + +--- +# dev +spring: + profiles: + group: + dev: + - security + - db-core + - logging + - metrics + +--- +# live +spring: + profiles: + group: + live: + - security + - db-core + - logging + - metrics \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 36d91fef..27f5f759 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { id("org.springframework.boot") apply false id("io.spring.dependency-management") id("org.asciidoctor.jvm.convert") apply false - id("org.jlleitschuh.gradle.ktlint") apply false + id("org.jlleitschuh.gradle.ktlint") version "12.0.2" } java.sourceCompatibility = JavaVersion.valueOf("VERSION_${property("javaVersion")}") @@ -32,19 +32,16 @@ subprojects { apply(plugin = "org.asciidoctor.jvm.convert") apply(plugin = "org.jlleitschuh.gradle.ktlint") - dependencyManagement { - imports { - mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudDependenciesVersion")}") - } - } - dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.boot:spring-boot-gradle-plugin:3.1.5") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("com.ninja-squad:springmockk:${property("springMockkVersion")}") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + implementation("org.springframework:spring-context") + kapt("org.springframework.boot:spring-boot-configuration-processor") } @@ -66,14 +63,14 @@ subprojects { tasks.test { useJUnitPlatform { - excludeTags("develop", "restdocs") + excludeTags("develop") } } tasks.register("unitTest") { group = "verification" useJUnitPlatform { - excludeTags("develop", "context", "restdocs") + excludeTags("develop", "context") } } @@ -84,13 +81,6 @@ subprojects { } } - tasks.register("restDocsTest") { - group = "verification" - useJUnitPlatform { - includeTags("restdocs") - } - } - tasks.register("developTest") { group = "verification" useJUnitPlatform { @@ -98,7 +88,7 @@ subprojects { } } - tasks.getByName("asciidoctor") { - dependsOn("restDocsTest") + configure { + debug.set(true) } } diff --git a/clients/client-example/build.gradle.kts b/clients/client-example/build.gradle.kts deleted file mode 100644 index a69d1638..00000000 --- a/clients/client-example/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -dependencies { - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - implementation("io.github.openfeign:feign-hc5") - implementation("io.github.openfeign:feign-micrometer") -} diff --git a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleApi.kt b/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleApi.kt deleted file mode 100644 index 1bbfe399..00000000 --- a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleApi.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.dodn.springboot.client.example - -import org.springframework.cloud.openfeign.FeignClient -import org.springframework.http.MediaType -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestMethod - -@FeignClient(value = "example-api", url = "\${example.api.url}") -internal interface ExampleApi { - @RequestMapping( - method = [RequestMethod.POST], - value = ["/example/example-api"], - consumes = [MediaType.APPLICATION_JSON_VALUE], - ) - fun example(@RequestBody request: ExampleRequestDto): ExampleResponseDto -} diff --git a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleClient.kt b/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleClient.kt deleted file mode 100644 index cffa9e0f..00000000 --- a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleClient.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.dodn.springboot.client.example - -import io.dodn.springboot.client.example.model.ExampleClientResult -import org.springframework.stereotype.Component - -@Component -class ExampleClient internal constructor( - private val exampleApi: ExampleApi, -) { - fun example( - exampleParameter: String, - ): ExampleClientResult { - val request = ExampleRequestDto(exampleParameter) - return exampleApi.example(request).toResult() - } -} diff --git a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleConfig.kt b/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleConfig.kt deleted file mode 100644 index 9da21878..00000000 --- a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleConfig.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.dodn.springboot.client.example - -import org.springframework.cloud.openfeign.EnableFeignClients -import org.springframework.context.annotation.Configuration - -@EnableFeignClients -@Configuration -internal class ExampleConfig diff --git a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleRequestDto.kt b/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleRequestDto.kt deleted file mode 100644 index 24d12d92..00000000 --- a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleRequestDto.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.dodn.springboot.client.example - -internal data class ExampleRequestDto( - val exampleRequestValue: String, -) diff --git a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleResponseDto.kt b/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleResponseDto.kt deleted file mode 100644 index eb510559..00000000 --- a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/ExampleResponseDto.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.dodn.springboot.client.example - -import io.dodn.springboot.client.example.model.ExampleClientResult - -internal data class ExampleResponseDto( - val exampleResponseValue: String, -) { - fun toResult(): ExampleClientResult { - return ExampleClientResult(exampleResponseValue) - } -} diff --git a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/model/ExampleClientResult.kt b/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/model/ExampleClientResult.kt deleted file mode 100644 index b5e425bc..00000000 --- a/clients/client-example/src/main/kotlin/io/dodn/springboot/client/example/model/ExampleClientResult.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.dodn.springboot.client.example.model - -data class ExampleClientResult( - val exampleResult: String, -) diff --git a/clients/client-example/src/main/resources/client-example.yml b/clients/client-example/src/main/resources/client-example.yml deleted file mode 100644 index 169ee0ed..00000000 --- a/clients/client-example/src/main/resources/client-example.yml +++ /dev/null @@ -1,35 +0,0 @@ -example: - api: - url: https://default.example.example - -spring.cloud.openfeign: - client: - config: - example-api: - connectTimeout: 2100 - readTimeout: 5000 - loggerLevel: full - compression: - response: - enabled: false - httpclient: - max-connections: 2000 - max-connections-per-route: 500 - ---- -spring.config.activate.on-profile: local - ---- -spring.config.activate.on-profile: - - local-dev - - dev - ---- -spring.config.activate.on-profile: - - staging - - live - -example: - api: - url: https://live.example.example - diff --git a/clients/client-example/src/test/kotlin/io/dodn/springboot/client/ClientExampleContextTest.kt b/clients/client-example/src/test/kotlin/io/dodn/springboot/client/ClientExampleContextTest.kt deleted file mode 100644 index 8a35630b..00000000 --- a/clients/client-example/src/test/kotlin/io/dodn/springboot/client/ClientExampleContextTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.dodn.springboot.client - -import org.junit.jupiter.api.Tag -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.ActiveProfiles -import org.springframework.test.context.TestConstructor - -@ActiveProfiles("local") -@Tag("context") -@SpringBootTest -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) -abstract class ClientExampleContextTest diff --git a/clients/client-example/src/test/kotlin/io/dodn/springboot/client/ClientExampleTestApplication.kt b/clients/client-example/src/test/kotlin/io/dodn/springboot/client/ClientExampleTestApplication.kt deleted file mode 100644 index ae7142e0..00000000 --- a/clients/client-example/src/test/kotlin/io/dodn/springboot/client/ClientExampleTestApplication.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.dodn.springboot.client - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.context.properties.ConfigurationPropertiesScan -import org.springframework.boot.runApplication - -@ConfigurationPropertiesScan -@SpringBootApplication -class ClientExampleTestApplication - -fun main(args: Array) { - runApplication(*args) -} diff --git a/clients/client-example/src/test/kotlin/io/dodn/springboot/client/example/ExampleClientTest.kt b/clients/client-example/src/test/kotlin/io/dodn/springboot/client/example/ExampleClientTest.kt deleted file mode 100644 index 1039fde1..00000000 --- a/clients/client-example/src/test/kotlin/io/dodn/springboot/client/example/ExampleClientTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.dodn.springboot.client.example - -import feign.RetryableException -import io.dodn.springboot.client.ClientExampleContextTest -import org.assertj.core.api.Assertions -import org.junit.jupiter.api.Test - -class ExampleClientTest( - val exampleClient: ExampleClient, -) : ClientExampleContextTest() { - @Test - fun shouldBeThrownExceptionWhenExample() { - try { - exampleClient.example("HELLO!") - } catch (e: Exception) { - Assertions.assertThat(e).isExactlyInstanceOf(RetryableException::class.java) - } - } -} diff --git a/clients/client-example/src/test/resources/application.yml b/clients/client-example/src/test/resources/application.yml deleted file mode 100644 index 73319a40..00000000 --- a/clients/client-example/src/test/resources/application.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring.application.name: client-example-test - -spring: - config: - import: - - client-example.yml diff --git a/core/core-api/build.gradle.kts b/core/core-api/build.gradle.kts deleted file mode 100644 index aa85c0c1..00000000 --- a/core/core-api/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -tasks.getByName("bootJar") { - enabled = true -} - -tasks.getByName("jar") { - enabled = false -} - -dependencies { - implementation(project(":core:core-enum")) - implementation(project(":support:monitoring")) - implementation(project(":support:logging")) - implementation(project(":storage:db-core")) - implementation(project(":clients:client-example")) - - testImplementation(project(":tests:api-docs")) - - implementation("org.springframework.boot:spring-boot-starter-web") -} diff --git a/core/core-api/src/docs/asciidoc/index.adoc b/core/core-api/src/docs/asciidoc/index.adoc deleted file mode 100644 index fd2c461d..00000000 --- a/core/core-api/src/docs/asciidoc/index.adoc +++ /dev/null @@ -1,38 +0,0 @@ -= API Docs -:doctype: book -:icons: font -:source-highlighter: highlightjs -:toc: left -:toclevels: 3 -:sectlinks: -:snippets: build/generated-snippets - -== Introduce - -This is the Core API documentation. - -== Example API - -=== Example GET API -==== Curl Request -include::{snippets}/exampleGet/curl-request.adoc[] -==== Path Parameters -include::{snippets}/exampleGet/path-parameters.adoc[] -==== Query Parameters -include::{snippets}/exampleGet/query-parameters.adoc[] -==== Http Response -include::{snippets}/exampleGet/http-response.adoc[] -==== Response Fields -include::{snippets}/exampleGet/response-fields.adoc[] - -''' - -=== Example POST API -==== Curl Request -include::{snippets}/examplePost/curl-request.adoc[] -==== Request Fields -include::{snippets}/examplePost/request-fields.adoc[] -==== Http Response -include::{snippets}/examplePost/http-response.adoc[] -==== Response Fields -include::{snippets}/examplePost/response-fields.adoc[] diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/config/AsyncExceptionHandler.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/config/AsyncExceptionHandler.kt deleted file mode 100644 index 0fe1905c..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/config/AsyncExceptionHandler.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.dodn.springboot.core.api.config - -import io.dodn.springboot.core.api.support.error.CoreApiException -import org.slf4j.LoggerFactory -import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler -import org.springframework.boot.logging.LogLevel -import java.lang.reflect.Method - -class AsyncExceptionHandler : AsyncUncaughtExceptionHandler { - private val log = LoggerFactory.getLogger(javaClass) - - override fun handleUncaughtException(e: Throwable, method: Method, vararg params: Any?) { - if (e is CoreApiException) { - when (e.errorType.logLevel) { - LogLevel.ERROR -> log.error("CoreApiException : {}", e.message, e) - LogLevel.WARN -> log.warn("CoreApiException : {}", e.message, e) - else -> log.info("CoreApiException : {}", e.message, e) - } - } else { - log.error("Exception : {}", e.message, e) - } - } -} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/ApiControllerAdvice.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/ApiControllerAdvice.kt deleted file mode 100644 index 946f4974..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/ApiControllerAdvice.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.dodn.springboot.core.api.controller - -import io.dodn.springboot.core.api.support.error.CoreApiException -import io.dodn.springboot.core.api.support.error.ErrorType -import io.dodn.springboot.core.api.support.response.ApiResponse -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.boot.logging.LogLevel -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.ExceptionHandler -import org.springframework.web.bind.annotation.RestControllerAdvice - -@RestControllerAdvice -class ApiControllerAdvice { - private val log: Logger = LoggerFactory.getLogger(javaClass) - - @ExceptionHandler(CoreApiException::class) - fun handleCoreApiException(e: CoreApiException): ResponseEntity> { - when (e.errorType.logLevel) { - LogLevel.ERROR -> log.error("CoreApiException : {}", e.message, e) - LogLevel.WARN -> log.warn("CoreApiException : {}", e.message, e) - else -> log.info("CoreApiException : {}", e.message, e) - } - return ResponseEntity(ApiResponse.error(e.errorType, e.data), e.errorType.status) - } - - @ExceptionHandler(Exception::class) - fun handleException(e: Exception): ResponseEntity> { - log.error("Exception : {}", e.message, e) - return ResponseEntity(ApiResponse.error(ErrorType.DEFAULT_ERROR), ErrorType.DEFAULT_ERROR.status) - } -} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/HealthController.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/HealthController.kt deleted file mode 100644 index 9eaf8de3..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/HealthController.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.dodn.springboot.core.api.controller - -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -class HealthController { - @GetMapping("/health") - fun health(): ResponseEntity<*> { - return ResponseEntity.status(HttpStatus.OK).build() - } -} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/ExampleController.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/ExampleController.kt deleted file mode 100644 index 51e9c7c3..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/ExampleController.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.dodn.springboot.core.api.controller.v1 - -import io.dodn.springboot.core.api.controller.v1.request.ExampleRequestDto -import io.dodn.springboot.core.api.controller.v1.response.ExampleResponseDto -import io.dodn.springboot.core.api.domain.ExampleData -import io.dodn.springboot.core.api.domain.ExampleService -import io.dodn.springboot.core.api.support.response.ApiResponse -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -@RestController -class ExampleController( - val exampleExampleService: ExampleService, -) { - @GetMapping("/get/{exampleValue}") - fun exampleGet( - @PathVariable exampleValue: String, - @RequestParam exampleParam: String, - ): ApiResponse { - val result = exampleExampleService.processExample(ExampleData(exampleValue, exampleParam)) - return ApiResponse.success(ExampleResponseDto(result.data)) - } - - @PostMapping("/post") - fun examplePost( - @RequestBody request: ExampleRequestDto, - ): ApiResponse { - val result = exampleExampleService.processExample(request.toExampleData()) - return ApiResponse.success(ExampleResponseDto(result.data)) - } -} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/request/ExampleRequestDto.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/request/ExampleRequestDto.kt deleted file mode 100644 index 27da470a..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/request/ExampleRequestDto.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.dodn.springboot.core.api.controller.v1.request - -import io.dodn.springboot.core.api.domain.ExampleData - -data class ExampleRequestDto( - val data: String, -) { - fun toExampleData(): ExampleData { - return ExampleData(data, data) - } -} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/response/ExampleResponseDto.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/response/ExampleResponseDto.kt deleted file mode 100644 index e9a75381..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/controller/v1/response/ExampleResponseDto.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.dodn.springboot.core.api.controller.v1.response - -data class ExampleResponseDto( - val result: String, -) diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleData.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleData.kt deleted file mode 100644 index 8a5ff284..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.dodn.springboot.core.api.domain - -data class ExampleData( - val value: String, - val param: String, -) diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleResult.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleResult.kt deleted file mode 100644 index a8be81ae..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleResult.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.dodn.springboot.core.api.domain - -data class ExampleResult( - val data: String, -) diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleService.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleService.kt deleted file mode 100644 index d900f7ff..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/domain/ExampleService.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.dodn.springboot.core.api.domain - -import org.springframework.stereotype.Service - -@Service -class ExampleService() { - fun processExample(exampleData: ExampleData): ExampleResult { - return ExampleResult(exampleData.value) - } -} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorCode.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorCode.kt deleted file mode 100644 index 90b51664..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/error/ErrorCode.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.dodn.springboot.core.api.support.error - -enum class ErrorCode { - E500, -} diff --git a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/response/ResultType.kt b/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/response/ResultType.kt deleted file mode 100644 index 3f4574dc..00000000 --- a/core/core-api/src/main/kotlin/io/dodn/springboot/core/api/support/response/ResultType.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.dodn.springboot.core.api.support.response - -enum class ResultType { - SUCCESS, ERROR -} diff --git a/core/core-api/src/main/resources/application.yml b/core/core-api/src/main/resources/application.yml deleted file mode 100644 index 4cede0ff..00000000 --- a/core/core-api/src/main/resources/application.yml +++ /dev/null @@ -1,32 +0,0 @@ -spring.application.name: core-api -spring.profiles.active: local - -spring: - config: - import: - - monitoring.yml - - logging.yml - - db-core.yml - - client-example.yml - mvc.throw-exception-if-no-handler-found: true - web.resources.add-mappings: false - ---- -spring.config.activate.on-profile: local - - ---- -spring.config.activate.on-profile: local-dev - - ---- -spring.config.activate.on-profile: dev - - ---- -spring.config.activate.on-profile: staging - - ---- -spring.config.activate.on-profile: live - diff --git a/core/core-api/src/test/kotlin/io/dodn/springboot/ContextTest.kt b/core/core-api/src/test/kotlin/io/dodn/springboot/ContextTest.kt deleted file mode 100644 index 59f92274..00000000 --- a/core/core-api/src/test/kotlin/io/dodn/springboot/ContextTest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.dodn.springboot - -import org.junit.jupiter.api.Tag -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.TestConstructor - -@Tag("context") -@SpringBootTest -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) -abstract class ContextTest diff --git a/core/core-api/src/test/kotlin/io/dodn/springboot/CoreApiApplicationTest.kt b/core/core-api/src/test/kotlin/io/dodn/springboot/CoreApiApplicationTest.kt deleted file mode 100644 index 6c32dc03..00000000 --- a/core/core-api/src/test/kotlin/io/dodn/springboot/CoreApiApplicationTest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.dodn.springboot - -import org.junit.jupiter.api.Test - -internal class CoreApiApplicationTest : ContextTest() { - @Test - fun shouldBeLoadedContext() { - // it should be passed - } -} diff --git a/core/core-api/src/test/kotlin/io/dodn/springboot/DevelopTest.kt b/core/core-api/src/test/kotlin/io/dodn/springboot/DevelopTest.kt deleted file mode 100644 index 9da6b45f..00000000 --- a/core/core-api/src/test/kotlin/io/dodn/springboot/DevelopTest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.dodn.springboot - -import org.junit.jupiter.api.Tag -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.TestConstructor - -@Tag("develop") -@SpringBootTest -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) -abstract class DevelopTest diff --git a/core/core-api/src/test/kotlin/io/dodn/springboot/core/api/controller/v1/ExampleControllerTest.kt b/core/core-api/src/test/kotlin/io/dodn/springboot/core/api/controller/v1/ExampleControllerTest.kt deleted file mode 100644 index 07ab7a08..00000000 --- a/core/core-api/src/test/kotlin/io/dodn/springboot/core/api/controller/v1/ExampleControllerTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package io.dodn.springboot.core.api.controller.v1 - -import io.dodn.springboot.core.api.controller.v1.request.ExampleRequestDto -import io.dodn.springboot.core.api.domain.ExampleResult -import io.dodn.springboot.core.api.domain.ExampleService -import io.dodn.springboot.test.api.RestDocsTest -import io.dodn.springboot.test.api.RestDocsUtils.requestPreprocessor -import io.dodn.springboot.test.api.RestDocsUtils.responsePreprocessor -import io.mockk.every -import io.mockk.mockk -import io.restassured.http.ContentType -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.HttpStatus -import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document -import org.springframework.restdocs.payload.JsonFieldType -import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath -import org.springframework.restdocs.payload.PayloadDocumentation.requestFields -import org.springframework.restdocs.payload.PayloadDocumentation.responseFields -import org.springframework.restdocs.request.RequestDocumentation -import org.springframework.restdocs.request.RequestDocumentation.parameterWithName -import org.springframework.restdocs.request.RequestDocumentation.queryParameters - -class ExampleControllerTest : RestDocsTest() { - private lateinit var exampleService: ExampleService - private lateinit var controller: ExampleController - - @BeforeEach - fun setUp() { - exampleService = mockk() - controller = ExampleController(exampleService) - mockMvc = mockController(controller) - } - - @Test - fun exampleGet() { - every { exampleService.processExample(any()) } returns ExampleResult("BYE") - - given() - .contentType(ContentType.JSON) - .queryParam("exampleParam", "HELLO_PARAM") - .get("/get/{exampleValue}", "HELLO_PATH") - .then() - .status(HttpStatus.OK) - .apply( - document( - "exampleGet", - requestPreprocessor(), - responsePreprocessor(), - RequestDocumentation.pathParameters( - parameterWithName("exampleValue").description("ExampleValue"), - ), - queryParameters( - parameterWithName("exampleParam").description("ExampleParam"), - ), - responseFields( - fieldWithPath("result").type(JsonFieldType.STRING).description("ResultType"), - fieldWithPath("data.result").type(JsonFieldType.STRING).description("Result Date"), - fieldWithPath("error").type(JsonFieldType.NULL).ignored(), - ), - ), - ) - } - - @Test - fun examplePost() { - every { exampleService.processExample(any()) } returns ExampleResult("BYE") - - given() - .contentType(ContentType.JSON) - .body(ExampleRequestDto("HELLO_BODY")) - .post("/post") - .then() - .status(HttpStatus.OK) - .apply( - document( - "examplePost", - requestPreprocessor(), - responsePreprocessor(), - requestFields( - fieldWithPath("data").type(JsonFieldType.STRING).description("ExampleBody Data Field"), - ), - responseFields( - fieldWithPath("result").type(JsonFieldType.STRING).description("ResultType"), - fieldWithPath("data.result").type(JsonFieldType.STRING).description("Result Date"), - fieldWithPath("error").type(JsonFieldType.STRING).ignored(), - ), - ), - ) - } -} diff --git a/core/core-enum/build.gradle.kts b/core/core-enum/build.gradle.kts deleted file mode 100644 index e69de29b..00000000 diff --git a/core/core-enum/src/main/kotlin/io/dodn/springboot/core/enums/ExampleEnum.kt b/core/core-enum/src/main/kotlin/io/dodn/springboot/core/enums/ExampleEnum.kt deleted file mode 100644 index 2bb23b1c..00000000 --- a/core/core-enum/src/main/kotlin/io/dodn/springboot/core/enums/ExampleEnum.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.dodn.springboot.core.enums - -enum class ExampleEnum diff --git a/gradle.properties b/gradle.properties index eda29bd8..87912b23 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,11 +2,11 @@ applicationVersion=0.0.1-SNAPSHOT ### Project configs ### -projectGroup=io.dodn.springboot +projectGroup=io.raemian ### Project depdency versions ### kotlinVersion=1.9.20 -javaVersion=21 +javaVersion=17 ### Plugin depdency versions ### asciidoctorConvertVersion=3.3.2 diff --git a/infra/logging/build.gradle.kts b/infra/logging/build.gradle.kts new file mode 100644 index 00000000..ba819303 --- /dev/null +++ b/infra/logging/build.gradle.kts @@ -0,0 +1,8 @@ +dependencies { + implementation("io.micrometer:micrometer-tracing-bridge-brave") + implementation("io.sentry:sentry-logback:${property("sentryVersion")}") + + implementation("com.slack.api:slack-api-model-kotlin-extension:1.36.1") + implementation("com.slack.api:slack-api-client-kotlin-extension:1.36.1") + implementation("com.slack.api:slack-api-client:1.36.1") +} diff --git a/infra/logging/src/main/kotlin/io/raemian/infra/logging/enums/ErrorLocationEnum.kt b/infra/logging/src/main/kotlin/io/raemian/infra/logging/enums/ErrorLocationEnum.kt new file mode 100644 index 00000000..d5adde50 --- /dev/null +++ b/infra/logging/src/main/kotlin/io/raemian/infra/logging/enums/ErrorLocationEnum.kt @@ -0,0 +1,18 @@ +package io.raemian.infra.logging.enums + +import com.fasterxml.jackson.annotation.JsonProperty + +enum class ErrorLocationEnum(val value: String) { + @JsonProperty("CL") + CLIENT("CLIENT"), + + @JsonProperty("FS") + FRONTEND_SERVER("FRONTEND SERVER"), + + @JsonProperty("FM") + FRONTEND_MIDDLEWARE("FRONTEND MIDDLEWARE"), + + @JsonProperty("BS") + BACKEND_SERVER("BACKEND SERVER"), + ; +} diff --git a/infra/logging/src/main/kotlin/io/raemian/infra/logging/enums/LogTemplate.kt b/infra/logging/src/main/kotlin/io/raemian/infra/logging/enums/LogTemplate.kt new file mode 100644 index 00000000..378656a9 --- /dev/null +++ b/infra/logging/src/main/kotlin/io/raemian/infra/logging/enums/LogTemplate.kt @@ -0,0 +1,14 @@ +package io.raemian.infra.logging.enums + +enum class LogTemplate(val message: String) { + ERROR_HEADER(":red_circle: [%s] 오류 발생"), + ERROR_MESSAGE("Error Message"), + APP_NAME("*App Name*: %s"), + USERAGENT("*User Agent*: %s"), + REFERER("*Referer*: %s"), + ; + + fun of(vararg value: String?): String { + return this.message.format(*value) + } +} diff --git a/infra/logging/src/main/kotlin/io/raemian/infra/logging/logger/SlackLogger.kt b/infra/logging/src/main/kotlin/io/raemian/infra/logging/logger/SlackLogger.kt new file mode 100644 index 00000000..8a793594 --- /dev/null +++ b/infra/logging/src/main/kotlin/io/raemian/infra/logging/logger/SlackLogger.kt @@ -0,0 +1,155 @@ +package io.raemian.infra.logging.logger + +import com.slack.api.Slack +import com.slack.api.model.block.Blocks.asBlocks +import com.slack.api.model.block.Blocks.divider +import com.slack.api.model.block.Blocks.header +import com.slack.api.model.block.Blocks.richText +import com.slack.api.model.block.Blocks.section +import com.slack.api.model.block.HeaderBlock +import com.slack.api.model.block.RichTextBlock +import com.slack.api.model.block.SectionBlock +import com.slack.api.model.block.composition.BlockCompositions.markdownText +import com.slack.api.model.block.composition.BlockCompositions.plainText +import com.slack.api.model.block.element.BlockElements.asElements +import com.slack.api.model.block.element.BlockElements.asRichTextElements +import com.slack.api.model.block.element.BlockElements.richTextPreformatted +import com.slack.api.model.block.element.BlockElements.richTextSection +import com.slack.api.model.block.element.RichTextSectionElement.Text +import com.slack.api.model.block.element.RichTextSectionElement.TextStyle +import com.slack.api.webhook.Payload.PayloadBuilder +import com.slack.api.webhook.WebhookPayloads.payload +import io.raemian.infra.logging.enums.ErrorLocationEnum +import io.raemian.infra.logging.enums.LogTemplate +import io.raemian.infra.logging.enums.LogTemplate.APP_NAME +import io.raemian.infra.logging.enums.LogTemplate.ERROR_HEADER +import io.raemian.infra.logging.enums.LogTemplate.ERROR_MESSAGE +import io.raemian.infra.logging.enums.LogTemplate.REFERER +import io.raemian.infra.logging.enums.LogTemplate.USERAGENT +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component + +@Component +class SlackLogger( + private val slack: Slack = Slack.getInstance(), +) { + @Value("\${slack.webhook.url}") + private lateinit var url: String + + @Async + fun error( + errorLocation: ErrorLocationEnum, + appName: String, + errorMessage: String = "EMPTY VALUE", + userAgent: String? = "", + referer: String? = "", + ) { + when (errorLocation) { + ErrorLocationEnum.CLIENT -> clientError(errorLocation, appName, errorMessage, userAgent, referer) + else -> defaultError(errorLocation, appName, errorMessage) + } + } + + private fun clientError( + errorLocation: ErrorLocationEnum, + appName: String, + errorMessage: String, + userAgent: String?, + referer: String?, + ) { + slack.send( + url, + payload { p: PayloadBuilder -> + p.text(LogTemplate.ERROR_HEADER.of(errorLocation.value)) + .blocks( + asBlocks( + createHeaderBlock(errorLocation), + divider(), + createAppNameBlock(appName), + createUserAgentBlock(userAgent), + createRefererBlock(referer), + createErrorMessageBlock(errorMessage), + divider(), + + ), + ) + }, + ) + } + + private fun defaultError( + errorLocation: ErrorLocationEnum, + appName: String, + errorMessage: String, + ) { + slack.send( + url, + payload { p: PayloadBuilder -> + p.text(ERROR_HEADER.of(errorLocation.value)) + .blocks( + asBlocks( + createHeaderBlock(errorLocation), + divider(), + createAppNameBlock(appName), + createErrorMessageBlock(errorMessage), + divider(), + ), + ) + }, + ) + } + + private fun createHeaderBlock(errorLocation: ErrorLocationEnum): HeaderBlock { + return header { header -> + header.text(plainText(ERROR_HEADER.of(errorLocation.value))) + } + } + + private fun createAppNameBlock(appName: String): SectionBlock { + return section { section: SectionBlock.SectionBlockBuilder -> + section.text( + markdownText(APP_NAME.of(appName)), + ) + } + } + + private fun createUserAgentBlock(userAgent: String?): SectionBlock { + return section { section: SectionBlock.SectionBlockBuilder -> + section.text( + markdownText(USERAGENT.of(userAgent)), + ) + } + } + + private fun createRefererBlock(referer: String?): SectionBlock { + return section { section: SectionBlock.SectionBlockBuilder -> + section.text( + markdownText(REFERER.of(referer)), + ) + } + } + + private fun createErrorMessageBlock(errorMessage: String): RichTextBlock { + return richText { richText -> + richText.elements( + asElements( + richTextSection { section -> + section.elements( + asRichTextElements( + Text.builder().text(ERROR_MESSAGE.message).style(TextStyle.builder().bold(true).build()).build(), + ), + ) + }, + richTextPreformatted { section -> + section.elements( + asRichTextElements( + Text.builder().text(errorMessage).build(), + ), + ) + }, + ), + ) + } + } +} diff --git a/infra/logging/src/main/resources/application-logging.yml b/infra/logging/src/main/resources/application-logging.yml new file mode 100644 index 00000000..b46c9836 --- /dev/null +++ b/infra/logging/src/main/resources/application-logging.yml @@ -0,0 +1,23 @@ +# local +spring.config.activate.on-profile: local +logging.config: classpath:logback/logback-local.xml + +slack: + webhook: + url: ${SLACK-WEBHOOK-URL:EMPTY} +--- +# dev +spring.config.activate.on-profile: dev +logging.config: classpath:logback/logback-dev.xml + +slack: + webhook: + url: ${SLACK-WEBHOOK-URL:EMPTY} +--- +# live +spring.config.activate.on-profile: live +logging.config: classpath:logback/logback-live.xml + +slack: + webhook: + url: ${SLACK-WEBHOOK-URL:EMPTY} \ No newline at end of file diff --git a/support/logging/src/main/resources/logback/logback-dev.xml b/infra/logging/src/main/resources/logback/logback-dev.xml similarity index 94% rename from support/logging/src/main/resources/logback/logback-dev.xml rename to infra/logging/src/main/resources/logback/logback-dev.xml index 3003dac1..2f11d40e 100644 --- a/support/logging/src/main/resources/logback/logback-dev.xml +++ b/infra/logging/src/main/resources/logback/logback-dev.xml @@ -18,7 +18,7 @@ - + diff --git a/support/logging/src/main/resources/logback/logback-live.xml b/infra/logging/src/main/resources/logback/logback-live.xml similarity index 94% rename from support/logging/src/main/resources/logback/logback-live.xml rename to infra/logging/src/main/resources/logback/logback-live.xml index 3003dac1..2f11d40e 100644 --- a/support/logging/src/main/resources/logback/logback-live.xml +++ b/infra/logging/src/main/resources/logback/logback-live.xml @@ -18,7 +18,7 @@ - + diff --git a/support/logging/src/main/resources/logback/logback-local.xml b/infra/logging/src/main/resources/logback/logback-local.xml similarity index 92% rename from support/logging/src/main/resources/logback/logback-local.xml rename to infra/logging/src/main/resources/logback/logback-local.xml index b5e358fd..378b1a33 100644 --- a/support/logging/src/main/resources/logback/logback-local.xml +++ b/infra/logging/src/main/resources/logback/logback-local.xml @@ -10,7 +10,7 @@ - + diff --git a/support/monitoring/build.gradle.kts b/infra/metrics/build.gradle.kts similarity index 100% rename from support/monitoring/build.gradle.kts rename to infra/metrics/build.gradle.kts diff --git a/infra/metrics/src/main/resources/application-metrics.yml b/infra/metrics/src/main/resources/application-metrics.yml new file mode 100644 index 00000000..7d18155c --- /dev/null +++ b/infra/metrics/src/main/resources/application-metrics.yml @@ -0,0 +1,63 @@ +# local +spring.config.activate.on-profile: local + +management: # Actuator + endpoints: + enabled-by-default: false # default 사용하지 않을 것 + jmx: # JMX 형태 사용 불가 처리 + exposure: + exclude: '*' + web: + base-path: /one-baily-actuator # Actuator 경로 변경 + exposure: + include: metrics, prometheus + endpoint: + metrics: + enabled: true + prometheus: + enabled: true + # actuator 포트 변경 + server: + port: ${MANAGEMENT_PORT:7463} +--- +# dev +spring.config.activate.on-profile: dev + +management: # Actuator + endpoints: + enabled-by-default: false + jmx: + exposure: + exclude: '*' + web: + base-path: /one-baily-actuator + exposure: + include: metrics, prometheus + endpoint: + metrics: + enabled: true + prometheus: + enabled: true + server: + port: ${MANAGEMENT_PORT:7463} +--- +# live +spring.config.activate.on-profile: live + +management: # Actuator + endpoints: + enabled-by-default: false + jmx: + exposure: + exclude: '*' + web: + base-path: /one-baily-actuator + exposure: + include: metrics, prometheus + endpoint: + metrics: + enabled: true + prometheus: + enabled: true + server: + port: ${MANAGEMENT_PORT:7463} diff --git a/settings.gradle.kts b/settings.gradle.kts index 97ecbf5a..40a6227d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,13 +1,12 @@ rootProject.name = "one-bailey" include( - "core:core-enum", - "core:core-api", + "application:api", + "application:admin", "storage:db-core", - "tests:api-docs", - "support:logging", - "support:monitoring", - "clients:client-example" + "storage:image", + "infra:logging", + "infra:metrics", ) pluginManagement { diff --git a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/ExampleEntity.kt b/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/ExampleEntity.kt deleted file mode 100644 index 44495a4c..00000000 --- a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/ExampleEntity.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.dodn.springboot.storage.db.core - -import jakarta.persistence.Column -import jakarta.persistence.Entity - -@Entity -class ExampleEntity( - @Column - val exampleColumn: String, -) : BaseEntity() diff --git a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/ExampleRepository.kt b/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/ExampleRepository.kt deleted file mode 100644 index 2157634b..00000000 --- a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/ExampleRepository.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.dodn.springboot.storage.db.core - -import org.springframework.data.jpa.repository.JpaRepository - -interface ExampleRepository : JpaRepository diff --git a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/config/CoreDataSourceConfig.kt b/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/config/CoreDataSourceConfig.kt deleted file mode 100644 index fc7ff80e..00000000 --- a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/config/CoreDataSourceConfig.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.dodn.springboot.storage.db.core.config - -import com.zaxxer.hikari.HikariConfig -import com.zaxxer.hikari.HikariDataSource -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -@Configuration -internal class CoreDataSourceConfig { - @Bean - @ConfigurationProperties(prefix = "storage.datasource.core") - fun coreHikariConfig(): HikariConfig { - return HikariConfig() - } - - @Bean - fun coreDataSource(@Qualifier("coreHikariConfig") config: HikariConfig): HikariDataSource { - return HikariDataSource(config) - } -} diff --git a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/config/CoreJpaConfig.kt b/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/config/CoreJpaConfig.kt deleted file mode 100644 index e5e19c92..00000000 --- a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/config/CoreJpaConfig.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.dodn.springboot.storage.db.core.config - -import org.springframework.boot.autoconfigure.domain.EntityScan -import org.springframework.context.annotation.Configuration -import org.springframework.data.jpa.repository.config.EnableJpaRepositories -import org.springframework.transaction.annotation.EnableTransactionManagement - -@Configuration -@EnableTransactionManagement -@EntityScan(basePackages = ["io.dodn.springboot.storage.db.core"]) -@EnableJpaRepositories(basePackages = ["io.dodn.springboot.storage.db.core"]) -internal class CoreJpaConfig diff --git a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/BaseEntity.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/BaseEntity.kt similarity index 64% rename from storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/BaseEntity.kt rename to storage/db-core/src/main/kotlin/io/raemian/storage/db/core/BaseEntity.kt index ce4219f5..a15e0f07 100644 --- a/storage/db-core/src/main/kotlin/io/dodn/springboot/storage/db/core/BaseEntity.kt +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/BaseEntity.kt @@ -1,9 +1,6 @@ -package io.dodn.springboot.storage.db.core +package io.raemian.storage.db.core import jakarta.persistence.Column -import jakarta.persistence.GeneratedValue -import jakarta.persistence.GenerationType -import jakarta.persistence.Id import jakarta.persistence.MappedSuperclass import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.UpdateTimestamp @@ -11,10 +8,6 @@ import java.time.LocalDateTime @MappedSuperclass abstract class BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long? = null - @CreationTimestamp @Column(updatable = false) val createdAt: LocalDateTime? = null diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/Goal.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/Goal.kt new file mode 100644 index 00000000..edd25d35 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/Goal.kt @@ -0,0 +1,53 @@ +package io.raemian.storage.db.core.goal + +import io.raemian.storage.db.core.BaseEntity +import io.raemian.storage.db.core.sticker.Sticker +import io.raemian.storage.db.core.tag.Tag +import io.raemian.storage.db.core.task.Task +import io.raemian.storage.db.core.user.User +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import org.hibernate.annotations.Nationalized +import java.time.LocalDate + +@Entity +@Table(name = "GOALS") +class Goal( + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + val user: User, + + @Column(nullable = false) + @Nationalized + val title: String, + + @Column(nullable = false) + val deadline: LocalDate, + + @ManyToOne + @JoinColumn(name = "sticker_id", nullable = false) + val sticker: Sticker, + + @ManyToOne + @JoinColumn(name = "tag_id", nullable = false) + val tag: Tag, + + @Nationalized + val description: String = "", + + @OneToMany(mappedBy = "goal", cascade = [CascadeType.REMOVE], fetch = FetchType.LAZY) + val tasks: List, + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) : BaseEntity() diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/GoalRepository.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/GoalRepository.kt new file mode 100644 index 00000000..3271b5bd --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/goal/GoalRepository.kt @@ -0,0 +1,10 @@ +package io.raemian.storage.db.core.goal + +import org.springframework.data.jpa.repository.JpaRepository + +interface GoalRepository : JpaRepository { + fun findAllByUserId(userId: Long): List + + override fun getById(id: Long): Goal = + findById(id).orElseThrow() { NoSuchElementException("목표가 없습니다 $id") } +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/sticker/Sticker.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/sticker/Sticker.kt new file mode 100644 index 00000000..eb4352cc --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/sticker/Sticker.kt @@ -0,0 +1,33 @@ +package io.raemian.storage.db.core.sticker + +import io.raemian.storage.db.core.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.Nationalized + +@Entity +@Table(name = "STICKERS") +class Sticker( + @Column(nullable = false) + @Nationalized + var name: String, + + @Column(nullable = false) + var url: String, + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) : BaseEntity() { + fun updateNameAndUrl( + name: String, + url: String, + ) { + this.name = name + this.url = url + } +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/sticker/StickerRepository.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/sticker/StickerRepository.kt new file mode 100644 index 00000000..87cb60a2 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/sticker/StickerRepository.kt @@ -0,0 +1,9 @@ +package io.raemian.storage.db.core.sticker + +import org.springframework.data.jpa.repository.JpaRepository + +interface StickerRepository : JpaRepository { + + override fun getById(id: Long): Sticker = + findById(id).orElseThrow { NoSuchElementException("존재하지 않는 스티커입니다. $id") } +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/tag/Tag.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/tag/Tag.kt new file mode 100644 index 00000000..c6cc33fb --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/tag/Tag.kt @@ -0,0 +1,26 @@ +package io.raemian.storage.db.core.tag + +import io.raemian.storage.db.core.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.Nationalized + +@Entity +@Table(name = "TAGS") +class Tag( + @Column(nullable = false) + @Nationalized + var content: String, + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) : BaseEntity() { + fun updateContent(content: String) { + this.content = content + } +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/tag/TagRepository.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/tag/TagRepository.kt new file mode 100644 index 00000000..83add923 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/tag/TagRepository.kt @@ -0,0 +1,11 @@ +package io.raemian.storage.db.core.tag + +import org.springframework.data.jpa.repository.JpaRepository + +interface TagRepository : JpaRepository { + + override fun getById(id: Long): Tag = + findById(id).orElseThrow { NoSuchElementException("존재하지 않는 태그입니다. $id") } + + fun existsTagsByContent(content: String): Boolean +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/task/Task.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/task/Task.kt new file mode 100644 index 00000000..cce75144 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/task/Task.kt @@ -0,0 +1,51 @@ +package io.raemian.storage.db.core.task + +import io.raemian.storage.db.core.BaseEntity +import io.raemian.storage.db.core.goal.Goal +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.hibernate.annotations.Nationalized + +@Entity +@Table(name = "TASKS") +class Task private constructor( + @ManyToOne + @JoinColumn(name = "goal_id") + val goal: Goal, + + @Column(nullable = false) + var isDone: Boolean, + + @Column(nullable = false) + @Nationalized + var description: String, + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) : BaseEntity() { + + companion object { + fun createTask(goal: Goal, description: String): Task { + return Task( + goal = goal, + isDone = false, + description = description, + ) + } + } + + fun rewrite(newDescription: String) { + this.description = newDescription + } + + fun updateTaskCompletion(isDone: Boolean) { + this.isDone = isDone + } +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/task/TaskRepository.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/task/TaskRepository.kt new file mode 100644 index 00000000..6f603a22 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/task/TaskRepository.kt @@ -0,0 +1,9 @@ +package io.raemian.storage.db.core.task + +import org.springframework.data.jpa.repository.JpaRepository + +interface TaskRepository : JpaRepository { + + override fun getById(id: Long): Task = + findById(id).orElseThrow() { NoSuchElementException("Task가 없습니다 $id") } +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/token/RefreshToken.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/token/RefreshToken.kt new file mode 100644 index 00000000..d7dd63d6 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/token/RefreshToken.kt @@ -0,0 +1,12 @@ +// package io.raemian.storage.db.core.token +// +// import jakarta.persistence.Entity +// import jakarta.persistence.Id +// +// @Entity +// class RefreshToken( +// @Id +// val id: Long, +// val key: String, +// val value: String, +// ) diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/User.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/User.kt new file mode 100644 index 00000000..22baed61 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/User.kt @@ -0,0 +1,61 @@ +package io.raemian.storage.db.core.user + +import io.raemian.storage.db.core.BaseEntity +import io.raemian.storage.db.core.user.enums.OAuthProvider +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import org.hibernate.annotations.Nationalized +import java.time.LocalDate + +@Entity(name = "USERS") +class User( + @Column(unique = true, nullable = false) + val email: String, + + @Column(unique = true) + @Nationalized + val userName: String? = null, + + @Column + @Nationalized + val nickname: String? = null, + + @Column + val birth: LocalDate? = null, + + @Column + val image: String, + + @Column + @Enumerated(EnumType.STRING) + val provider: OAuthProvider, + + @Column + @Enumerated(EnumType.STRING) + val authority: Authority, + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, +) : BaseEntity() { + fun updateInfo(nickname: String, birth: LocalDate): User { + return User( + email = email, + nickname = nickname, + birth = birth, + image = image, + provider = provider, + authority = authority, + id = id, + ) + } +} + +enum class Authority { + ROLE_USER, ROLE_ADMIN +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/UserRepository.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/UserRepository.kt new file mode 100644 index 00000000..cd44b629 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/UserRepository.kt @@ -0,0 +1,12 @@ +package io.raemian.storage.db.core.user + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository { + fun findByEmail(email: String): User? + + fun existsByEmail(email: String): Boolean + + override fun getById(id: Long): User = + findById(id).orElseThrow { NoSuchElementException("존재하지 않는 유저입니다. $id") } +} diff --git a/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/enums/OAuthProvider.kt b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/enums/OAuthProvider.kt new file mode 100644 index 00000000..d4ad3945 --- /dev/null +++ b/storage/db-core/src/main/kotlin/io/raemian/storage/db/core/user/enums/OAuthProvider.kt @@ -0,0 +1,5 @@ +package io.raemian.storage.db.core.user.enums + +enum class OAuthProvider { + GOOGLE, NAVER +} diff --git a/storage/db-core/src/main/resources/application-db-core.yml b/storage/db-core/src/main/resources/application-db-core.yml new file mode 100644 index 00000000..4d48b21c --- /dev/null +++ b/storage/db-core/src/main/resources/application-db-core.yml @@ -0,0 +1,55 @@ +# default +spring: + jpa: + open-in-view: false + hibernate: + ddl-auto: none + +--- +# local +spring.config.activate.on-profile: local + +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:core;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + hikari: + pool-name: db-core-pool + h2: + console: + enabled: true + path: /h2-console + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true +--- +# dev +spring.config.activate.on-profile: dev + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB-URL} + username: ${DB-USERNAME} + password: ${DB-PASSWORD} + hikari: + pool-name: db-core-pool + + +--- +# live +spring.config.activate.on-profile: live + +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DB-URL} + username: ${DB-USERNAME} + password: ${DB-PASSWORD} + hikari: + pool-name: db-core-pool \ No newline at end of file diff --git a/storage/db-core/src/main/resources/db-core.yml b/storage/db-core/src/main/resources/db-core.yml deleted file mode 100644 index bad45f5c..00000000 --- a/storage/db-core/src/main/resources/db-core.yml +++ /dev/null @@ -1,156 +0,0 @@ -spring: - jpa: - open-in-view: false - hibernate: - ddl-auto: none - properties: - hibernate.default_batch_fetch_size: 100 - ---- -spring.config.activate.on-profile: local - -spring: - jpa: - hibernate: - ddl-auto: create - properties: - hibernate: - format_sql: true - show_sql: true - h2: - console: - enabled: true - -storage: - datasource: - core: - driver-class-name: org.h2.Driver - jdbc-url: jdbc:h2:mem:core;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - username: sa - pool-name: core-db-pool - data-source-properties: - rewriteBatchedStatements: true - ---- -spring.config.activate.on-profile: local-dev - -spring: - jpa: - properties: - hibernate: - show_log: true - format_sql: true - show-sql: true - -storage: - datasource: - core: - driver-class-name: com.mysql.cj.jdbc.Driver - jdbc-url: jdbc:mysql://${storage.database.core-db.url} - username: ${storage.database.core-db.username} - password: ${storage.database.core-db.password} - maximum-pool-size: 5 - connection-timeout: 1100 - keepalive-time: 30000 - validation-timeout: 1000 - max-lifetime: 600000 - pool-name: core-db-pool - data-source-properties: - socketTimeout: 3000 - cachePrepStmts: true - prepStmtCacheSize: 250 - prepStmtCacheSqlLimit: 2048 - useServerPrepStmts: true - useLocalSessionState: true - rewriteBatchedStatements: true - cacheResultSetMetadata: true - cacheServerConfiguration: true - elideSetAutoCommits: true - maintainTimeStats: false - ---- -spring.config.activate.on-profile: dev - -storage: - datasource: - core: - driver-class-name: com.mysql.cj.jdbc.Driver - jdbc-url: jdbc:mysql://${storage.database.core-db.url} - username: ${storage.database.core-db.username} - password: ${storage.database.core-db.password} - maximum-pool-size: 5 - connection-timeout: 1100 - keepalive-time: 30000 - validation-timeout: 1000 - max-lifetime: 600000 - pool-name: core-db-pool - data-source-properties: - socketTimeout: 3000 - cachePrepStmts: true - prepStmtCacheSize: 250 - prepStmtCacheSqlLimit: 2048 - useServerPrepStmts: true - useLocalSessionState: true - rewriteBatchedStatements: true - cacheResultSetMetadata: true - cacheServerConfiguration: true - elideSetAutoCommits: true - maintainTimeStats: false - ---- -spring.config.activate.on-profile: staging - -storage: - datasource: - core: - driver-class-name: com.mysql.cj.jdbc.Driver - jdbc-url: jdbc:mysql://${storage.database.core-db.url} - username: ${storage.database.core-db.username} - password: ${storage.database.core-db.password} - maximum-pool-size: 5 - connection-timeout: 1100 - keepalive-time: 30000 - validation-timeout: 1000 - max-lifetime: 600000 - pool-name: core-db-pool - data-source-properties: - socketTimeout: 3000 - cachePrepStmts: true - prepStmtCacheSize: 250 - prepStmtCacheSqlLimit: 2048 - useServerPrepStmts: true - useLocalSessionState: true - rewriteBatchedStatements: true - cacheResultSetMetadata: true - cacheServerConfiguration: true - elideSetAutoCommits: true - maintainTimeStats: false - ---- -spring.config.activate.on-profile: live - -storage: - datasource: - core: - driver-class-name: com.mysql.cj.jdbc.Driver - jdbc-url: jdbc:mysql://${storage.database.core-db.url} - username: ${storage.database.core-db.username} - password: ${storage.database.core-db.password} - maximum-pool-size: 25 - connection-timeout: 1100 - keepalive-time: 30000 - validation-timeout: 1000 - max-lifetime: 600000 - pool-name: core-db-pool - data-source-properties: - socketTimeout: 3000 - cachePrepStmts: true - prepStmtCacheSize: 250 - prepStmtCacheSqlLimit: 2048 - useServerPrepStmts: true - useLocalSessionState: true - rewriteBatchedStatements: true - cacheResultSetMetadata: true - cacheServerConfiguration: true - elideSetAutoCommits: true - maintainTimeStats: false \ No newline at end of file diff --git a/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/CoreDbContextTest.kt b/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/CoreDbContextTest.kt deleted file mode 100644 index d4065fa1..00000000 --- a/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/CoreDbContextTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.dodn.springboot.storage.db - -import org.junit.jupiter.api.Tag -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.test.context.ActiveProfiles -import org.springframework.test.context.TestConstructor - -@ActiveProfiles("local") -@Tag("context") -@SpringBootTest -@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) -abstract class CoreDbContextTest diff --git a/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/core/ExampleRepositoryIT.kt b/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/core/ExampleRepositoryIT.kt deleted file mode 100644 index e3c564d3..00000000 --- a/storage/db-core/src/test/kotlin/io/dodn/springboot/storage/db/core/ExampleRepositoryIT.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.dodn.springboot.storage.db.core - -import io.dodn.springboot.storage.db.CoreDbContextTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test - -class ExampleRepositoryIT( - val exampleRepository: ExampleRepository, -) : CoreDbContextTest() { - @Test - fun testShouldBeSavedAndFound() { - val saved = exampleRepository.save(ExampleEntity("SPRING_BOOT")) - assertThat(saved.exampleColumn).isEqualTo("SPRING_BOOT") - - val found = exampleRepository.findById(saved.id!!).get() - assertThat(found.exampleColumn).isEqualTo("SPRING_BOOT") - } -} diff --git a/storage/db-core/src/test/resources/application.yml b/storage/db-core/src/test/resources/application.yml index db132cfe..e69de29b 100644 --- a/storage/db-core/src/test/resources/application.yml +++ b/storage/db-core/src/test/resources/application.yml @@ -1,6 +0,0 @@ -spring.application.name: db-core-test - -spring: - config: - import: - - db-core.yml diff --git a/storage/image/build.gradle.kts b/storage/image/build.gradle.kts new file mode 100644 index 00000000..7d82dc72 --- /dev/null +++ b/storage/image/build.gradle.kts @@ -0,0 +1,2 @@ +dependencies { +} diff --git a/storage/image/src/main/kotlin/io/raemian/image/enums/FileExtensionType.kt b/storage/image/src/main/kotlin/io/raemian/image/enums/FileExtensionType.kt new file mode 100644 index 00000000..72975622 --- /dev/null +++ b/storage/image/src/main/kotlin/io/raemian/image/enums/FileExtensionType.kt @@ -0,0 +1,7 @@ +package io.raemian.image.enums + +enum class FileExtensionType( + val value: String, +) { + PNG(".png"), +} diff --git a/storage/image/src/main/kotlin/io/raemian/image/repository/ImageRepository.kt b/storage/image/src/main/kotlin/io/raemian/image/repository/ImageRepository.kt new file mode 100644 index 00000000..9b5a0514 --- /dev/null +++ b/storage/image/src/main/kotlin/io/raemian/image/repository/ImageRepository.kt @@ -0,0 +1,76 @@ +package io.raemian.image.repository + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Repository +import java.io.File +import java.io.InputStream +import java.nio.file.Files + +@Repository +class ImageRepository { + + @Value("\${server.image.url}") + private lateinit var url: String + + @Value("\${server.image.file-path}") + private lateinit var path: String + + fun upload( + fileName: String, + inputStream: InputStream, + ): String { + if (isExist(fileName)) { + throw IllegalStateException("이미 동일한 이름의 이미지가 존재합니다.") + } + + Files.copy(inputStream, File(createPath(fileName)).toPath()) + + return createUrl(fileName) + } + + fun update( + newFileName: String, + oldFileName: String, + inputStream: InputStream, + ): String { + // 파일이 존재하면 삭제 + if (isExist(oldFileName)) { + File(createPath(oldFileName)).delete() + } + + Files.copy(inputStream, File(createPath(newFileName)).toPath()) + + return createUrl(newFileName) + } + + fun delete( + fileName: String, + ) { + // 파일이 존재하지 않는다면 생략 + if (!isExist(fileName)) { + return + } + + val file: File = File(createPath(fileName)) + + file.delete() + } + + fun isExist( + fileName: String, + ): Boolean { + val file: File = File(createPath(fileName)) + + if (file.exists()) { + return true + } + + return false + } + + private fun createPath(fileName: String): String = + "%s/$fileName".format(path) + + private fun createUrl(fileName: String): String = + "%s/$fileName".format(url) +} diff --git a/storage/image/src/main/resources/application-image.yml b/storage/image/src/main/resources/application-image.yml new file mode 100644 index 00000000..28371e30 --- /dev/null +++ b/storage/image/src/main/resources/application-image.yml @@ -0,0 +1,16 @@ +server: + image: + url: ${IMAGE-SERVER-URL:http://localhost:8080} + file-path: ${IMAGE-FILE-PATH:/Users/{usename}/Desktop} + +--- +# local +spring.config.activate.on-profile: local + +--- +# dev +spring.config.activate.on-profile: dev + +--- +# live +spring.config.activate.on-profile: live diff --git a/support/logging/build.gradle.kts b/support/logging/build.gradle.kts deleted file mode 100644 index 9118a20c..00000000 --- a/support/logging/build.gradle.kts +++ /dev/null @@ -1,4 +0,0 @@ -dependencies { - implementation("io.micrometer:micrometer-tracing-bridge-brave") - implementation("io.sentry:sentry-logback:${property("sentryVersion")}") -} diff --git a/support/logging/src/main/resources/logback/logback-local-dev.xml b/support/logging/src/main/resources/logback/logback-local-dev.xml deleted file mode 100644 index b5e358fd..00000000 --- a/support/logging/src/main/resources/logback/logback-local-dev.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - %clr(%d{HH:mm:ss.SSS}){faint}|%clr(${level:-%5p})|%32X{traceId:-},%16X{spanId:-}|%clr(%-40.40logger{39}){cyan}%clr(|){faint}%m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} - utf8 - - - - - - - - - - diff --git a/support/logging/src/main/resources/logback/logback-staging.xml b/support/logging/src/main/resources/logback/logback-staging.xml deleted file mode 100644 index 3003dac1..00000000 --- a/support/logging/src/main/resources/logback/logback-staging.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - %clr(%d{HH:mm:ss.SSS}){faint}|%clr(${level:-%5p})|%32X{traceId:-},%16X{spanId:-}|%clr(%-40.40logger{39}){cyan}%clr(|){faint}%m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} - utf8 - - - - - - YOUR_DSN - - WARN - INFO - - - - - - - - - - diff --git a/support/logging/src/main/resources/logging.yml b/support/logging/src/main/resources/logging.yml deleted file mode 100644 index 8f932ad6..00000000 --- a/support/logging/src/main/resources/logging.yml +++ /dev/null @@ -1 +0,0 @@ -logging.config: classpath:logback/logback-${spring.profiles.active}.xml \ No newline at end of file diff --git a/support/monitoring/src/main/resources/monitoring.yml b/support/monitoring/src/main/resources/monitoring.yml deleted file mode 100644 index 4f84fa61..00000000 --- a/support/monitoring/src/main/resources/monitoring.yml +++ /dev/null @@ -1,5 +0,0 @@ -management: - endpoints: - web: - exposure: - include: prometheus diff --git a/tests/api-docs/build.gradle.kts b/tests/api-docs/build.gradle.kts deleted file mode 100644 index d8a3f863..00000000 --- a/tests/api-docs/build.gradle.kts +++ /dev/null @@ -1,7 +0,0 @@ -dependencies { - compileOnly("jakarta.servlet:jakarta.servlet-api") - compileOnly("org.springframework.boot:spring-boot-starter-test") - api("org.springframework.restdocs:spring-restdocs-mockmvc") - api("org.springframework.restdocs:spring-restdocs-restassured") - api("io.rest-assured:spring-mock-mvc") -} diff --git a/tests/api-docs/src/main/kotlin/io/dodn/springboot/test/api/RestDocsTest.kt b/tests/api-docs/src/main/kotlin/io/dodn/springboot/test/api/RestDocsTest.kt deleted file mode 100644 index 3fe9510f..00000000 --- a/tests/api-docs/src/main/kotlin/io/dodn/springboot/test/api/RestDocsTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package io.dodn.springboot.test.api - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.restassured.module.mockmvc.RestAssuredMockMvc -import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Tag -import org.junit.jupiter.api.extension.ExtendWith -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter -import org.springframework.restdocs.RestDocumentationContextProvider -import org.springframework.restdocs.RestDocumentationExtension -import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder - -@Tag("restdocs") -@ExtendWith(RestDocumentationExtension::class) -abstract class RestDocsTest { - - lateinit var mockMvc: MockMvcRequestSpecification - private lateinit var restDocumentation: RestDocumentationContextProvider - - @BeforeEach - fun setUp(restDocumentation: RestDocumentationContextProvider) { - this.restDocumentation = restDocumentation - } - - protected fun given(): MockMvcRequestSpecification { - return mockMvc - } - - protected fun mockController(controller: Any): MockMvcRequestSpecification { - val mockMvc = createMockMvc(controller) - return RestAssuredMockMvc.given() - .mockMvc(mockMvc) - } - - private fun createMockMvc(controller: Any): MockMvc { - val converter = MappingJackson2HttpMessageConverter(objectMapper()) - - return MockMvcBuilders.standaloneSetup(controller) - .apply(MockMvcRestDocumentation.documentationConfiguration(restDocumentation)) - .setMessageConverters(converter) - .build() - } - - private fun objectMapper(): ObjectMapper { - return jacksonObjectMapper() - .findAndRegisterModules() - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) - } -} diff --git a/tests/api-docs/src/main/kotlin/io/dodn/springboot/test/api/RestDocsUtils.kt b/tests/api-docs/src/main/kotlin/io/dodn/springboot/test/api/RestDocsUtils.kt deleted file mode 100644 index 9eb0aaed..00000000 --- a/tests/api-docs/src/main/kotlin/io/dodn/springboot/test/api/RestDocsUtils.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.dodn.springboot.test.api - -import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor -import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor -import org.springframework.restdocs.operation.preprocess.Preprocessors - -object RestDocsUtils { - fun requestPreprocessor(): OperationRequestPreprocessor { - return Preprocessors.preprocessRequest( - Preprocessors.modifyUris().scheme("http").host("dev.dodn.io").removePort(), - Preprocessors.prettyPrint(), - ) - } - - fun responsePreprocessor(): OperationResponsePreprocessor { - return Preprocessors.preprocessResponse(Preprocessors.prettyPrint()) - } -}