54 Commits

Author SHA1 Message Date
jini
5a4a6b3355 - 2023-03-31 03:28:19 +09:00
jini
59cf934058 🎨 GetNoticesCommand validation 설정 추가 2023-03-31 02:51:50 +09:00
jini
02553cdec7 🔧 web logging level 설정: debug 2023-03-31 02:51:01 +09:00
jini
7d3b445303 hibernate-validator 의존성 추가 2023-03-31 02:50:28 +09:00
jini
c7f6e3ca81 🎨 global exception handler 추가 2023-03-31 02:49:05 +09:00
jini
9ff1c6ecec 공지사항 등록 2023-03-31 01:36:23 +09:00
jini
022e344fea 🐛 공지사항 등록시 id가 빈문자열로 등록되는 bug fix 2023-03-31 01:36:10 +09:00
jini
a2b580e8e7 🚚 2023-03-30 19:57:52 +09:00
jini
36c34a77c4 🎨 [reactive-app] domain mapper 클래스 추가 2023-03-30 19:56:46 +09:00
jini
4e2cc2ea10 [reactive-app] 상품 조회 2023-03-28 04:14:07 +09:00
jini
122766f76e 🔥 2023-03-28 04:13:34 +09:00
jini
6ed8a0e62a 🔥 2023-03-28 03:06:45 +09:00
jini
b399db7604 🎨 2023-03-21 01:20:15 +09:00
jini
78e8f9b7ec 속도 개선을 위해 domain 수정 2023-03-07 23:20:36 +09:00
jini
c675c10417 🔧 2023-03-07 23:20:11 +09:00
jini
ec07b2c637 🔧 demo-reactive-app 의 datastore 의존성 변경: datastore-mongo-reactive -> datastore-mariadb-reactive 2023-03-07 21:18:49 +09:00
jini
d3eb581d94 datastore-mariadb-reactive 추가 2023-03-07 21:18:07 +09:00
jini
dd999c8f39 🔧 version 1.0.2 로 변경 2023-03-01 17:48:37 +09:00
jini
2a46aff7ed 📝 demo-reactive-app 관련 정보 추가 2023-03-01 17:46:47 +09:00
jini
8f66697291 ⬆️ [demo-app] springdoc-openapi-ui version upgrade 1.6.14 2023-03-01 17:38:26 +09:00
jini
00f6ed2241 mongo-reactive app 추가 2023-03-01 17:21:14 +09:00
jini
e16724ead3 🔧 2023-03-01 17:13:00 +09:00
jini
8f29f3496b springmockk 2022-12-04 02:55:04 +09:00
jini
5eb705800a [demo-all-in-one-app] RegisterNoticeControllerTest 테스트 코드 추가 2022-12-03 22:06:07 +09:00
jini
14a2c2ac79 [common-util] Gson 의존성 및 GsonUtils 추가 2022-12-03 22:05:00 +09:00
jini
f9e591fc1d 🚚 NoticeContent -> NoticeInfo 2022-12-02 15:11:02 +09:00
jini
1ddf129a79 notice 저장 test 코드 추가 2022-12-02 15:09:49 +09:00
jini
1288e23bbf notice 저장 2022-12-02 14:20:13 +09:00
jini
396719b8fd 🚚 package명 변경 (in -> input, out -> output) 2022-12-02 14:05:48 +09:00
jini
ddfbf20e66 🚧 Notice 저장 (진행중...) 2022-12-02 14:04:22 +09:00
jini
a6684e6dcd 🎨 Notice domain에서 NoticeContent 분리 2022-12-02 13:50:52 +09:00
jini
8f1945f3ad NoticePersistenceAdapterTest 추가 2022-12-02 13:35:48 +09:00
jini
9e706ea8a0 h2 database dependency 추가 2022-12-02 13:35:12 +09:00
jini
2d5b1c243e 🔥 2022-12-02 05:25:35 +09:00
jini
079d1ec338 🎉 adapter + core 합친 앱 추가 2022-12-02 05:19:05 +09:00
jini
7a260d90a9 🚨 2022-12-01 20:26:25 +09:00
jini
333d346c87 🔧 component scan 하는 부분을 별도의 config 파일로 분리 2022-11-30 18:12:08 +09:00
jini
fc2e75af6b 🔧 Swagger Config 추가 2022-11-30 16:50:31 +09:00
jini
cf80cbd7e9 📄 LICENSE 추가 2022-11-30 16:47:32 +09:00
jini
09bf3271f1 🎨 [demo-app, common-util] Global Exception Handler 설정 추가 2022-11-30 14:43:43 +09:00
jini
9c9f1fb1d6 [common-util] output model 껍데기 추가 2022-11-30 14:26:47 +09:00
jini
d27361cb73 🔧 datasource schema 변경: book -> demofx 2022-11-30 14:12:45 +09:00
jini
eb87c250b6 🔖 v1.0.1 2022-11-30 05:38:50 +09:00
jini
81a6f9d86b 📝 README 추가 2022-11-30 05:34:06 +09:00
jini
ad70bb1f2e 🔥 2022-11-30 04:57:31 +09:00
jini
2f2adebbf2 ♻️ code 정리 2022-11-30 04:55:12 +09:00
jini
31b6c85b77 🚧 common-util 모듈 추가 2022-11-30 04:31:38 +09:00
jini
23cff9d75f 📝 banner 추가 2022-11-30 03:41:53 +09:00
jini
15e952893b 불필요한 의존성 제거, notices 조회 input model 별도 생성 2022-11-30 03:32:54 +09:00
jini
1c855f60b0 불필요한 의존성 제거 2022-11-30 03:08:23 +09:00
jini
4983029a8f 🔥 불필요한 코드 제거 2022-11-30 03:08:03 +09:00
jini
b2561d6608 🏗️ 프로젝트 구조 web, core, infrastructure 모듈로 분리 2022-11-30 02:59:47 +09:00
jini
c72f7be010 🚚 2022-11-30 01:08:06 +09:00
jini
273e3cbf62 persistence adapter JavaConfig 방식으로 조립 2022-11-30 01:00:59 +09:00
110 changed files with 1958 additions and 175 deletions

3
.gitignore vendored
View File

@@ -40,4 +40,5 @@ bin/
### VS Code ###
.vscode/
logs/
logs/
Ttt*

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 jini
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

144
README.md Normal file
View File

@@ -0,0 +1,144 @@
# demo-hexagonal
### Hexagonal Architecture 구조로 만든 코프링 웹 애플리케이션<br>
<p>
<img src="https://img.shields.io/static/v1?label=OpenJDK&message=17.0.2&color=007396&logo=openjdk" alt="OpenJDK">
<img src="https://img.shields.io/static/v1?label=Kotlin&message=1.6.21&color=7F52FF&logo=kotlin&logoColor=fff" alt="Kotlin">
<img src="https://img.shields.io/static/v1?label=Spring%20Boot&message=2.7.3&color=6DB33F&logo=springboot&logoColor=fff" alt="Spring Boot">
<img src="https://img.shields.io/static/v1?label=Gradle&message=7.5.1&color=02303A&logo=Gradle&logoColor=fff" alt="Gradle">
<img src="https://img.shields.io/static/v1?label=MariaDB&message=10.8.3&color=003545&logo=MariaDB" alt="MariaDB">
<img src="https://img.shields.io/static/v1?label=MongoDB&message=6.0.4&color=47A248&logo=MongoDB&logoColor=fff">
<img src="https://img.shields.io/static/v1?label=Swagger&message=3.0.3&color=85EA2D&logo=swagger&logoColor=fff" alt="Swagger 3">
</p>
1. demo-app (JDK 17 + Kotlin + Spring Boot 2 + Spring MVC + Spring Data JPA)
1. [프로젝트 구성](#a01-1)
2. [Tech Stacks](#a01-2)
3. [demo-app](#a01-3)
4. [demo-all-in-one-app](#a01-4)
2. demo-reactive (JDK 17 + Spring Boot 2 + Spring WebFlux + Spring Data MongoDB Reactive)
1. [프로젝트 구성](#a02-1)
2. [Tech Stacks](#a02-2)
3. [demo-reactive-app](#a02-3)
***
## <div id="a01-1">1. demo-app</div>
### 1.1. 프로젝트 구성
demo-app 프로젝트는 멀티 모듈로 구성되어있습니다.
<img width="344" alt="architecture-1" src="https://user-images.githubusercontent.com/31076826/204638756-a9a8b9b8-d0e5-4a27-bf14-4c8f12e93448.png">
- core
- 헥사고날 아키텍처의 application core 영역에 해당
- 프로젝트의 핵심적인 비즈니스 로직이 들어있습니다
- domain, port를 포함하고 있으며, 모든 클래스는 public 접근제한자로 설정되어 있습니다.
- infrastructure
- 헥사고날 아키텍처의 `adapter - out` 영역에 해당
- datasource에 관련된 모듈이 들어갑니다.
- persistence adapter, JpaRepository, ORM Entity 가 포함되어있습니다.
- server
- 헥사고날 아키텍처의 `adapter - in` 영역에 해당
- controller를 포함하고 있으며, 모든 클래스는 internal 접근제한자로 설정되어 있습니다.
- util
- core, server, infrastructure 모듈에서 공통적으로 사용할 유틸이 들어있습니다.
<br>
### <div id="a01-2">1.2. Tech Stacks</div>
- JDK 17
- Kotlin 1.6.21
- Build Tools
- Gradle
- Kotlin DSL
- Spring MVC
- Tomcat
- Spring Boot 2
- Spring Data JPA
- MariaDB 10.8.3
- OpenAPI Specification
- Swagger v3
- springdoc-openapi ui 1.6.13
<br>
### <div id="a01-3">1.3. demo-app</div>
demo 웹 애플리케이션의 실행 진입점은 `server - demo-app` 모듈의 DemoAppApplication 입니다.
<img width="779" alt="DemoAppApplication" src="https://user-images.githubusercontent.com/31076826/204640445-cfcfb9db-a35b-492c-b6d2-4cf7c05030fb.png">
<br><br>
demo-app은 아래와 같이 `:util:common-util`, `:core:demo-core`, `:infrastructure:datastore-mariadb` 모듈을 포함합니다.
<img width="724" alt="build.gradle.kts" src="https://user-images.githubusercontent.com/31076826/204640772-f1846649-a21d-459a-9883-3dae61b44536.png">
<br>
### <div id="a01-4">1.4. demo-all-in-one-app</div>
demo-app을 하나의 모듈로 만든 웹 애플리케이션
adapter + core + domain 을 하나의 프로젝트 내에 구성했습니다.
<img width="382" alt="스크린샷 2022-12-02 오후 2 08 10" src="https://user-images.githubusercontent.com/31076826/205219195-fd9fef03-5a0d-4673-8020-8c7f353c7a05.png">
***
## <div id="a02-1">2. demo-reactive-app</div>
### 2.1. 프로젝트 구성
demo-reactive-app 프로젝트도는 멀티 모듈로 구성되어있습니다.
<img width="277" alt="2-1" src="https://user-images.githubusercontent.com/31076826/222085102-abbe7a55-f66e-430e-a1ed-3658b72d2600.png">
- core
- 헥사고날 아키텍처의 application core 영역에 해당
- 프로젝트의 핵심적인 비즈니스 로직이 들어있습니다
- domain, port를 포함하고 있으며, 모든 클래스는 public 접근제한자로 설정되어 있습니다.
- infrastructure
- 헥사고날 아키텍처의 `adapter - out` 영역에 해당
- datasource에 관련된 모듈이 들어갑니다.
- persistence adapter, JpaRepository, ORM Entity 가 포함되어있습니다.
- server
- 헥사고날 아키텍처의 `adapter - in` 영역에 해당
- controller를 포함하고 있으며, 모든 클래스는 internal 접근제한자로 설정되어 있습니다.
- util
- core, server, infrastructure 모듈에서 공통적으로 사용할 유틸이 들어있습니다.
<br>
### <div id="a02-2">2.2. Tech Stacks</div>
- JDK 17
- Kotlin 1.6.21
- Build Tools
- Gradle
- Kotlin DSL
- Spring WebFlux
- Netty
- Coroutines
- Spring Boot 2
- Spring Data MongoDB Reactive
- MongoDB 6.0.4
- OpenAPI Specification
- Swagger v3
- springdoc-openapi-kotlin 1.6.14
- springdoc-openapi-webflux-ui 1.6.14
<br>
### <div id="a02-3">2.3. demo-reactive-app</div>
demo 웹 애플리케이션의 실행 진입점은 `server - demo-reactive-app` 모듈의 DemoReactiveAppApplication 입니다.
demo-reactive-app은 아래와 같이 `:util:common-util`, `:core:demo-reactivecore`, `:infrastructure:datastore-mongodb-reactive` 모듈을 포함합니다.
<img width="687" alt="demo-reactive-app" src="https://user-images.githubusercontent.com/31076826/222084603-a63918ae-0105-45d4-b4da-0f1b0dd42600.png">

View File

@@ -3,6 +3,7 @@ plugins {
id("io.spring.dependency-management") version "1.0.13.RELEASE"
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
kotlin("plugin.jpa") version "1.6.21"
kotlin("kapt") version "1.6.21"
}
@@ -18,7 +19,7 @@ configurations {
allprojects {
group = "me.jiniworld"
version = "1.0.0"
version = "1.0.2"
repositories {
mavenCentral()
@@ -41,6 +42,7 @@ subprojects {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
testImplementation("org.springframework.boot:spring-boot-starter-test")
implementation("com.ninja-squad:springmockk:3.1.1")
// implementation("com.google.code.gson:gson:2.9.0")
// implementation("org.jetbrains:annotations:23.0.0")
@@ -54,6 +56,21 @@ subprojects {
}
}
tasks {
compileKotlin {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
compileTestKotlin {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@@ -0,0 +1,6 @@
dependencies {
implementation("org.springframework.data:spring-data-commons")
implementation("org.springframework:spring-tx")
implementation(project(":util:common-util"))
}

View File

@@ -0,0 +1,10 @@
package me.jiniworld.demohx.application.notice.domain
import java.time.LocalDateTime
data class Notice(
val id: Long,
val title: String,
val content: String,
val createdAt: LocalDateTime,
)

View File

@@ -1,4 +1,4 @@
package me.jiniworld.demohx.application.port.output
package me.jiniworld.demohx.application.notice.domain
data class NoticeDetail(
val id: Long,

View File

@@ -1,4 +1,4 @@
package me.jiniworld.demohx.application.port.output
package me.jiniworld.demohx.application.notice.domain
data class NoticeSimple(
val id: Long,

View File

@@ -0,0 +1,9 @@
package me.jiniworld.demohx.application.notice.port.input
import me.jiniworld.demohx.application.notice.domain.NoticeDetail
import me.jiniworld.demohx.application.notice.domain.NoticeSimple
interface GetNoticeQuery {
fun getNoticeSimples(command: GetNoticesCommand): List<NoticeSimple>?
fun getNoticeDetail(id: Long): NoticeDetail?
}

View File

@@ -0,0 +1,6 @@
package me.jiniworld.demohx.application.notice.port.input
data class GetNoticesCommand(
val page: Int,
val size: Int,
)

View File

@@ -0,0 +1,10 @@
package me.jiniworld.demohx.application.notice.port.output
import me.jiniworld.demohx.application.notice.domain.NoticeDetail
import me.jiniworld.demohx.application.notice.domain.NoticeSimple
import org.springframework.data.domain.Pageable
interface LoadNoticePort {
fun loadNotices(pageable: Pageable): List<NoticeSimple>?
fun loadNotice(id: Long): NoticeDetail?
}

View File

@@ -0,0 +1,23 @@
package me.jiniworld.demohx.application.notice.service
import me.jiniworld.demohx.annotation.UseCase
import me.jiniworld.demohx.application.notice.domain.NoticeDetail
import me.jiniworld.demohx.application.notice.port.input.GetNoticeQuery
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
@UseCase
internal class GetNoticeService(
private val loadNoticePort: LoadNoticePort,
) : GetNoticeQuery {
override fun getNoticeSimples(command: GetNoticesCommand) =
loadNoticePort.loadNotices(PageRequest.of(command.page, command.size, Sort.by(Sort.Order.desc("id"))))
override fun getNoticeDetail(id: Long): NoticeDetail? =
loadNoticePort.loadNotice(id)
}

View File

@@ -0,0 +1,9 @@
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.springframework.data:spring-data-commons")
implementation("org.springframework:spring-tx")
implementation("org.hibernate.validator:hibernate-validator:8.0.0.Final")
implementation(project(":util:common-util"))
}

View File

@@ -0,0 +1,8 @@
package me.jiniworld.demohx.application.item.domain
data class Item(
val id: String,
val name: String,
val price: Int,
val stock: Int,
)

View File

@@ -0,0 +1,9 @@
package me.jiniworld.demohx.application.item.port.input
import kotlinx.coroutines.flow.Flow
import me.jiniworld.demohx.application.item.domain.Item
interface GetItemQuery {
fun getItems(command: GetItemsCommand): Flow<Item>
}

View File

@@ -0,0 +1,6 @@
package me.jiniworld.demohx.application.item.port.input
data class GetItemsCommand(
val page: Int,
val size: Int,
)

View File

@@ -0,0 +1,11 @@
package me.jiniworld.demohx.application.item.port.output
import kotlinx.coroutines.flow.Flow
import me.jiniworld.demohx.application.item.domain.Item
import org.springframework.data.domain.Pageable
interface LoadItemPort {
suspend fun loadItem(id: String): Item?
fun loadItems(pageable: Pageable): Flow<Item>
fun loadItems(ids: Collection<String>): Flow<Item>
}

View File

@@ -0,0 +1,25 @@
package me.jiniworld.demohx.application.item.service
import kotlinx.coroutines.flow.Flow
import me.jiniworld.demohx.annotation.UseCase
import me.jiniworld.demohx.application.item.domain.Item
import me.jiniworld.demohx.application.item.port.input.GetItemQuery
import me.jiniworld.demohx.application.item.port.input.GetItemsCommand
import me.jiniworld.demohx.application.item.port.output.LoadItemPort
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
@UseCase
internal class GetItemService(
private val loadItemPort: LoadItemPort,
): GetItemQuery {
override fun getItems(command: GetItemsCommand): Flow<Item> {
return loadItemPort.loadItems(
PageRequest.of(command.page, command.size, Sort.by(
Sort.Order.desc("id")))
)
}
}

View File

@@ -0,0 +1,23 @@
package me.jiniworld.demohx.application.notice.domain
data class Notice(
val summary: Summary,
val content: String,
) {
data class Summary(
val id: String? = null,
val title: String,
val createdAt: String? = null,
)
data class Detail(
val id: String? = null,
val title: String,
val content: String,
val createdAt: String? = null,
)
fun detail(): Detail {
return Detail(id = summary.id, title = summary.title, content = content, createdAt = summary.createdAt)
}
}

View File

@@ -0,0 +1,9 @@
package me.jiniworld.demohx.application.notice.port.input
import kotlinx.coroutines.flow.Flow
import me.jiniworld.demohx.application.notice.domain.Notice
interface GetNoticeQuery {
fun getNoticeSummaries(command: GetNoticesCommand): Flow<Notice.Summary>
suspend fun getNoticeDetail(id: String): Notice.Detail?
}

View File

@@ -0,0 +1,8 @@
package me.jiniworld.demohx.application.notice.port.input
import javax.validation.constraints.Min
data class GetNoticesCommand(
@Min(0) val page: Int,
@Min(5) val size: Int,
)

View File

@@ -0,0 +1,8 @@
package me.jiniworld.demohx.application.notice.port.input
import javax.validation.constraints.NotBlank
data class RegisterNoticeCommand(
@NotBlank val title: String,
@NotBlank val content: String,
)

View File

@@ -0,0 +1,5 @@
package me.jiniworld.demohx.application.notice.port.input
interface RegisterNoticeUseCase {
suspend fun registerNotice(command: RegisterNoticeCommand)
}

View File

@@ -0,0 +1,10 @@
package me.jiniworld.demohx.application.notice.port.output
import kotlinx.coroutines.flow.Flow
import me.jiniworld.demohx.application.notice.domain.Notice
import org.springframework.data.domain.Pageable
interface LoadNoticePort {
fun loadNotices(pageable: Pageable): Flow<Notice>
suspend fun loadNotice(id: String): Notice?
}

View File

@@ -0,0 +1,7 @@
package me.jiniworld.demohx.application.notice.port.output
import me.jiniworld.demohx.application.notice.domain.Notice
interface SaveNoticePort {
suspend fun saveNotice(notice: Notice)
}

View File

@@ -0,0 +1,25 @@
package me.jiniworld.demohx.application.notice.service
import kotlinx.coroutines.flow.map
import me.jiniworld.demohx.annotation.UseCase
import me.jiniworld.demohx.application.notice.domain.Notice
import me.jiniworld.demohx.application.notice.port.input.GetNoticeQuery
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
@UseCase
internal class GetNoticeService(
private val loadNoticePort: LoadNoticePort,
) : GetNoticeQuery {
override fun getNoticeSummaries(command: GetNoticesCommand) =
loadNoticePort.loadNotices(PageRequest.of(command.page, command.size, Sort.by(
Sort.Order.desc("id")))).map { it.summary }
override suspend fun getNoticeDetail(id: String): Notice.Detail? =
loadNoticePort.loadNotice(id)?.detail()
}

View File

@@ -0,0 +1,21 @@
package me.jiniworld.demohx.application.notice.service
import me.jiniworld.demohx.annotation.UseCase
import me.jiniworld.demohx.application.notice.domain.Notice
import me.jiniworld.demohx.application.notice.port.input.RegisterNoticeCommand
import me.jiniworld.demohx.application.notice.port.input.RegisterNoticeUseCase
import me.jiniworld.demohx.application.notice.port.output.SaveNoticePort
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
@UseCase
internal class RegisterNoticeService(
private val saveNoticePort: SaveNoticePort,
): RegisterNoticeUseCase {
@Transactional
override suspend fun registerNotice(command: RegisterNoticeCommand) {
saveNoticePort.saveNotice(Notice(Notice.Summary(title = command.title), content = command.content))
}
}

View File

@@ -0,0 +1,11 @@
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
runtimeOnly("org.mariadb:r2dbc-mariadb:1.1.3")
implementation(project(":util:common-util"))
implementation(project(":core:demo-reactive-core"))
}

View File

@@ -0,0 +1,24 @@
package me.jiniworld.demohx.persistence.notice
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.relational.core.mapping.Table
import java.time.LocalDateTime
@Table("notice")
internal class NoticeEntity {
@Id
var id: String = ""
var title: String = ""
var content: String = ""
@CreatedDate
var createdAt: LocalDateTime = LocalDateTime.now()
@LastModifiedDate
var updatedAt: LocalDateTime? = null
}

View File

@@ -0,0 +1,12 @@
package me.jiniworld.demohx.persistence.notice
import me.jiniworld.demohx.DateTimeUtils
import me.jiniworld.demohx.application.notice.domain.Notice
internal object NoticeMapper {
fun mapToNotice(entity: NoticeEntity) =
Notice(summary = Notice.Summary(id = entity.id, title = entity.title,
createdAt = DateTimeUtils.toString(entity.createdAt)), content = entity.content)
}

View File

@@ -0,0 +1,21 @@
package me.jiniworld.demohx.persistence.notice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.jiniworld.demohx.annotation.PersistenceAdapter
import me.jiniworld.demohx.application.notice.domain.Notice
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
import org.springframework.data.domain.Pageable
@PersistenceAdapter
internal class NoticePersistenceAdapter(
private val noticeRepository: NoticeRepository,
) : LoadNoticePort {
override fun loadNotices(pageable: Pageable): Flow<Notice> {
return noticeRepository.findAllBy(pageable).map { NoticeMapper.mapToNotice(it) }
}
override suspend fun loadNotice(id: String): Notice? {
return noticeRepository.findById(id)?.let { NoticeMapper.mapToNotice(it) }
}
}

View File

@@ -0,0 +1,11 @@
package me.jiniworld.demohx.persistence.notice
import kotlinx.coroutines.flow.Flow
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository
@Repository
internal interface NoticeRepository: CoroutineCrudRepository<NoticeEntity, String> {
fun findAllBy(pageable: Pageable): Flow<NoticeEntity>
}

View File

@@ -0,0 +1,9 @@
apply(plugin = "kotlin-jpa")
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
implementation(project(":util:common-util"))
implementation(project(":core:demo-core"))
}

View File

@@ -0,0 +1,41 @@
package me.jiniworld.demohx.persistence.notice
import me.jiniworld.demohx.DateTimeUtils
import me.jiniworld.demohx.application.notice.domain.Notice
import me.jiniworld.demohx.application.notice.domain.NoticeDetail
import me.jiniworld.demohx.application.notice.domain.NoticeSimple
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import java.time.LocalDateTime
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Table
@Table(name = "notice")
@Entity
internal class NoticeEntity {
@javax.persistence.Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0
var title: String = ""
var content: String = ""
@CreatedDate
var createdAt: LocalDateTime = LocalDateTime.now()
@LastModifiedDate
var updatedAt: LocalDateTime? = null
fun mapToNotice() =
Notice(id = id, title = title, content = content, createdAt = createdAt)
fun mapToNoticeSimple() =
NoticeSimple(id = id, title = title, createdOn = DateTimeUtils.toDateString(createdAt))
fun mapToNoticeDetail() =
NoticeDetail(id = id, title = title, content = content, createdAt = DateTimeUtils.toString(createdAt))
}

View File

@@ -0,0 +1,21 @@
package me.jiniworld.demohx.persistence.notice
import me.jiniworld.demohx.annotation.PersistenceAdapter
import me.jiniworld.demohx.application.notice.domain.NoticeDetail
import me.jiniworld.demohx.application.notice.domain.NoticeSimple
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
@PersistenceAdapter
internal class NoticePersistenceAdapter(
private val noticeRepository: NoticeRepository,
) : LoadNoticePort {
override fun loadNotices(pageable: Pageable): List<NoticeSimple>? {
return noticeRepository.findAllBy(pageable).map { it.mapToNoticeSimple() }.toList()
}
override fun loadNotice(id: Long): NoticeDetail? {
return noticeRepository.findByIdOrNull(id)?.mapToNoticeDetail()
}
}

View File

@@ -1,8 +1,10 @@
package me.jiniworld.demohx.adapter.output.persistence
package me.jiniworld.demohx.persistence.notice
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
internal interface NoticeRepository: JpaRepository<NoticeEntity, Long> {
fun findAllBy(pageable: Pageable): List<NoticeEntity>
}

View File

@@ -0,0 +1,10 @@
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")
implementation(project(":util:common-util"))
implementation(project(":core:demo-reactive-core"))
}

View File

@@ -0,0 +1,20 @@
package me.jiniworld.demohx.persistence.item
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
@Document("item")
internal class ItemDocument(val name: String, val price: Int, var stock: Int) {
@Id
var id: String = ""
@CreatedDate
var createdAt: LocalDateTime = LocalDateTime.now()
@LastModifiedDate
var updatedAt: LocalDateTime? = null
}

View File

@@ -0,0 +1,11 @@
package me.jiniworld.demohx.persistence.item
import me.jiniworld.demohx.application.item.domain.Item
internal object ItemMapper {
fun mapToDomainEntity(doc: ItemDocument): Item {
return Item(doc.id, doc.name, doc.price, doc.stock)
}
}

View File

@@ -0,0 +1,30 @@
package me.jiniworld.demohx.persistence.item
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.jiniworld.demohx.annotation.PersistenceAdapter
import me.jiniworld.demohx.application.item.domain.Item
import me.jiniworld.demohx.application.item.port.output.LoadItemPort
import org.springframework.data.domain.Pageable
@PersistenceAdapter
internal class ItemPersistenceAdapter(
private val itemRepository: ItemRepository,
): LoadItemPort {
override suspend fun loadItem(id: String): Item? {
return itemRepository.findById(id)
?.let { ItemMapper.mapToDomainEntity(it) }
}
override fun loadItems(pageable: Pageable): Flow<Item> {
return itemRepository.findAllBy(pageable)
.map { ItemMapper.mapToDomainEntity(it) }
}
override fun loadItems(ids: Collection<String>): Flow<Item> {
return itemRepository.findAllById(ids)
.map { ItemMapper.mapToDomainEntity(it) }
}
}

View File

@@ -0,0 +1,11 @@
package me.jiniworld.demohx.persistence.item
import kotlinx.coroutines.flow.Flow
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository
@Repository
internal interface ItemRepository: CoroutineCrudRepository<ItemDocument, String> {
fun findAllBy(pageable: Pageable): Flow<ItemDocument>
}

View File

@@ -0,0 +1,20 @@
package me.jiniworld.demohx.persistence.notice
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
@Document(value = "notice")
internal class NoticeDocument(var title: String, var content: String) {
@Id
var id: String? = null
@CreatedDate
var createdAt: LocalDateTime = LocalDateTime.now()
@LastModifiedDate
var updatedAt: LocalDateTime? = null
}

View File

@@ -0,0 +1,15 @@
package me.jiniworld.demohx.persistence.notice
import me.jiniworld.demohx.DateTimeUtils
import me.jiniworld.demohx.application.notice.domain.Notice
internal object NoticeMapper {
fun mapToNotice(doc: NoticeDocument) =
Notice(summary = Notice.Summary(id = doc.id, title = doc.title,
createdAt = DateTimeUtils.toString(doc.createdAt)), content = doc.content)
fun mapToDocument(notice: Notice) =
NoticeDocument(title = notice.summary.title, content = notice.content)
}

View File

@@ -0,0 +1,26 @@
package me.jiniworld.demohx.persistence.notice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import me.jiniworld.demohx.annotation.PersistenceAdapter
import me.jiniworld.demohx.application.notice.domain.Notice
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
import me.jiniworld.demohx.application.notice.port.output.SaveNoticePort
import org.springframework.data.domain.Pageable
@PersistenceAdapter
internal class NoticePersistenceAdapter(
private val noticeRepository: NoticeRepository,
) : LoadNoticePort, SaveNoticePort {
override fun loadNotices(pageable: Pageable): Flow<Notice> {
return noticeRepository.findAllBy(pageable).map { NoticeMapper.mapToNotice(it) }
}
override suspend fun loadNotice(id: String): Notice? {
return noticeRepository.findById(id)?.let { NoticeMapper.mapToNotice(it) }
}
override suspend fun saveNotice(notice: Notice) {
noticeRepository.save(NoticeMapper.mapToDocument(notice))
}
}

View File

@@ -0,0 +1,11 @@
package me.jiniworld.demohx.persistence.notice
import kotlinx.coroutines.flow.Flow
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.stereotype.Repository
@Repository
internal interface NoticeRepository: CoroutineCrudRepository<NoticeDocument, String> {
fun findAllBy(pageable: Pageable): Flow<NoticeDocument>
}

View File

@@ -1,8 +1,14 @@
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
compileOnly("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springdoc:springdoc-openapi-ui:1.6.13")
implementation("com.epages:restdocs-api-spec-model:0.16.2")
testImplementation("org.mockito:mockito-inline")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
runtimeOnly("com.h2database:h2")
implementation(project(":util:common-util"))
}

View File

@@ -0,0 +1,13 @@
package me.jiniworld.demohx
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import java.util.*
@SpringBootApplication
class DemoAllInOneAppApplication
fun main(args: Array<String>) {
Locale.setDefault(Locale.KOREA)
runApplication<DemoAllInOneAppApplication>(*args)
}

View File

@@ -0,0 +1,40 @@
package me.jiniworld.demohx.notice.adapter.input.web
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.annotation.WebAdapter
import me.jiniworld.demohx.model.DataResponse
import me.jiniworld.demohx.model.NotFoundException
import me.jiniworld.demohx.notice.application.port.input.GetNoticeQuery
import me.jiniworld.demohx.notice.application.port.input.GetNoticesCommand
import org.springframework.web.bind.annotation.*
@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class GetNoticeController(
private val getNoticeQuery: GetNoticeQuery,
) {
@Operation(summary = "공지사항 목록")
@GetMapping("")
fun getNotices(
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
) = getNoticeQuery.getNoticeSimples(GetNoticesCommand(page = page, size = size))
?.let { DataResponse(data = it) }
?: throw NotFoundException("공지사항이 없습니다.")
@Operation(summary = "공지사항 상세조회")
@GetMapping("/{notice_id}")
fun getNotice(@PathVariable("notice_id") noticeId: Long,
) = getNoticeQuery.getNoticeDetail(noticeId)
?.let { DataResponse(data = it) }
?: throw NotFoundException("조회되는 공지사항이 없습니다.")
@Operation(summary = "test")
@GetMapping("/test")
fun test() = DataResponse(data = "test")
}

View File

@@ -0,0 +1,30 @@
package me.jiniworld.demohx.notice.adapter.input.web
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.annotation.WebAdapter
import me.jiniworld.demohx.model.BaseResponse
import me.jiniworld.demohx.notice.application.port.input.RegisterNoticeCommand
import me.jiniworld.demohx.notice.application.port.input.RegisterNoticeUseCase
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
@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class RegisterNoticeController(
private val registerNoticeUseCase: RegisterNoticeUseCase,
) {
@Operation(summary = "공지사항 추가")
@PostMapping("")
fun registerNotice(@RequestBody command: RegisterNoticeCommand): BaseResponse {
registerNoticeUseCase.registerNotice(command)
return BaseResponse.SUCCESS
}
}

View File

@@ -1,6 +1,7 @@
package me.jiniworld.demohx.adapter.output.persistence
package me.jiniworld.demohx.notice.adapter.output.persistence
import me.jiniworld.demohx.domain.Notice
import me.jiniworld.demohx.notice.domain.Notice
import me.jiniworld.demohx.notice.domain.NoticeInfo
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import java.time.LocalDateTime
@@ -26,6 +27,13 @@ internal class NoticeEntity {
@LastModifiedDate
var updatedAt: LocalDateTime? = null
constructor() {}
constructor(title: String, content: String) {
this.title = title
this.content = content
}
fun mapToNotice() =
Notice(id = id, title = title, content = content, createdAt = createdAt)
Notice(id = id, noticeInfo = NoticeInfo(title = title, content = content), createdAt = createdAt)
}

View File

@@ -0,0 +1,26 @@
package me.jiniworld.demohx.notice.adapter.output.persistence
import me.jiniworld.demohx.annotation.PersistenceAdapter
import me.jiniworld.demohx.notice.application.port.output.LoadNoticePort
import me.jiniworld.demohx.notice.application.port.output.SaveNoticePort
import me.jiniworld.demohx.notice.domain.Notice
import me.jiniworld.demohx.notice.domain.NoticeInfo
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
@PersistenceAdapter
internal class NoticePersistenceAdapter(
private val noticeRepository: NoticeRepository,
) : LoadNoticePort, SaveNoticePort {
override fun loadNotices(pageable: Pageable): List<Notice>? {
return noticeRepository.findAllBy(pageable).map { it.mapToNotice() }.toList()
}
override fun loadNotice(id: Long): Notice? {
return noticeRepository.findByIdOrNull(id)?.mapToNotice()
}
override fun saveNotice(noticeInfo: NoticeInfo): Notice =
noticeRepository.save(NoticeEntity(title = noticeInfo.title, content = noticeInfo.content))
.mapToNotice()
}

View File

@@ -0,0 +1,10 @@
package me.jiniworld.demohx.notice.adapter.output.persistence
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
internal interface NoticeRepository: JpaRepository<NoticeEntity, Long> {
fun findAllBy(pageable: Pageable): List<NoticeEntity>
}

View File

@@ -0,0 +1,9 @@
package me.jiniworld.demohx.notice.application.port.input
import me.jiniworld.demohx.notice.application.port.output.NoticeDetail
import me.jiniworld.demohx.notice.application.port.output.NoticeSimple
interface GetNoticeQuery {
fun getNoticeSimples(command: GetNoticesCommand): List<NoticeSimple>?
fun getNoticeDetail(id: Long): NoticeDetail?
}

View File

@@ -0,0 +1,6 @@
package me.jiniworld.demohx.notice.application.port.input
data class GetNoticesCommand(
val page: Int,
val size: Int,
)

View File

@@ -0,0 +1,6 @@
package me.jiniworld.demohx.notice.application.port.input
data class RegisterNoticeCommand(
val title: String,
val content: String,
)

View File

@@ -0,0 +1,5 @@
package me.jiniworld.demohx.notice.application.port.input
interface RegisterNoticeUseCase {
fun registerNotice(command: RegisterNoticeCommand)
}

View File

@@ -0,0 +1,9 @@
package me.jiniworld.demohx.notice.application.port.output
import me.jiniworld.demohx.notice.domain.Notice
import org.springframework.data.domain.Pageable
interface LoadNoticePort {
fun loadNotices(pageable: Pageable): List<Notice>?
fun loadNotice(id: Long): Notice?
}

View File

@@ -0,0 +1,8 @@
package me.jiniworld.demohx.notice.application.port.output
data class NoticeDetail(
val id: Long,
val title: String,
val content: String,
val createdAt: String,
)

View File

@@ -0,0 +1,7 @@
package me.jiniworld.demohx.notice.application.port.output
data class NoticeSimple(
val id: Long,
val title: String,
val createdOn: String,
)

View File

@@ -0,0 +1,8 @@
package me.jiniworld.demohx.notice.application.port.output
import me.jiniworld.demohx.notice.domain.Notice
import me.jiniworld.demohx.notice.domain.NoticeInfo
interface SaveNoticePort {
fun saveNotice(noticeInfo: NoticeInfo): Notice
}

View File

@@ -0,0 +1,24 @@
package me.jiniworld.demohx.notice.application.service
import me.jiniworld.demohx.annotation.UseCase
import me.jiniworld.demohx.notice.application.port.input.GetNoticeQuery
import me.jiniworld.demohx.notice.application.port.input.GetNoticesCommand
import me.jiniworld.demohx.notice.application.port.output.LoadNoticePort
import me.jiniworld.demohx.notice.application.port.output.NoticeDetail
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
@UseCase
internal class GetNoticeService(
private val loadNoticePort: LoadNoticePort,
) : GetNoticeQuery {
override fun getNoticeSimples(command: GetNoticesCommand) =
loadNoticePort.loadNotices(PageRequest.of(command.page, command.size, Sort.by(Sort.Order.desc("id"))))
?.map { it.mapToNoticeSimple() }
override fun getNoticeDetail(id: Long): NoticeDetail? =
loadNoticePort.loadNotice(id)?.mapToNoticeDetail()
}

View File

@@ -0,0 +1,19 @@
package me.jiniworld.demohx.notice.application.service
import me.jiniworld.demohx.annotation.UseCase
import me.jiniworld.demohx.notice.application.port.input.RegisterNoticeCommand
import me.jiniworld.demohx.notice.application.port.input.RegisterNoticeUseCase
import me.jiniworld.demohx.notice.application.port.output.SaveNoticePort
import me.jiniworld.demohx.notice.domain.NoticeInfo
import org.springframework.transaction.annotation.Transactional
@Transactional
@UseCase
internal class RegisterNoticeService(
private val saveNoticePort: SaveNoticePort,
) : RegisterNoticeUseCase {
override fun registerNotice(command: RegisterNoticeCommand) {
saveNoticePort.saveNotice(NoticeInfo(title = command.title, content = command.content))
}
}

View File

@@ -0,0 +1,18 @@
package me.jiniworld.demohx.notice.domain
import me.jiniworld.demohx.DateTimeUtils
import me.jiniworld.demohx.notice.application.port.output.NoticeDetail
import me.jiniworld.demohx.notice.application.port.output.NoticeSimple
import java.time.LocalDateTime
data class Notice(
val id: Long,
val noticeInfo: NoticeInfo,
val createdAt: LocalDateTime,
) {
fun mapToNoticeSimple() =
NoticeSimple(id = id, title = noticeInfo.title, createdOn = DateTimeUtils.toDateString(createdAt))
fun mapToNoticeDetail() =
NoticeDetail(id = id, title = noticeInfo.title, content = noticeInfo.content, createdAt = DateTimeUtils.toString(createdAt))
}

View File

@@ -0,0 +1,6 @@
package me.jiniworld.demohx.notice.domain
data class NoticeInfo(
val title: String,
val content: String,
)

View File

@@ -0,0 +1,80 @@
spring:
application:
name: demo-all-in-one-app
profiles:
active: local
config:
import:
- demo-all-in-one-app.yml
datasource:
url: jdbc:mariadb://localhost:3306/demofx
driver-class-name: org.mariadb.jdbc.Driver
username: test
password: test
hikari:
auto-commit: false
connection-test-query: SELECT 1
minimum-idle: 10
maximum-pool-size: 50
# transaction-isolation: TRANSACTION_READ_UNCOMMITTED
pool-name: pool-demofx
jpa:
properties:
hibernate:
format_sql: true
hbm2ddl.auto: update
implicit_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
physical_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
default_batch_fetch_size: 500
open-in-view: false
show-sql: true
h2:
console:
enabled: true
path: /h2-console
# devtools:
# add-properties: false
server:
port: 8080
tomcat:
basedir: .
# accesslog:
# enabled: true
# directory: logs
# pattern: "%{yyyy-MM-dd HH:mm:ss}t %{X-Forwarded-For}i(%h) %l %u \"%r\" %s %b"
remoteip:
protocol-header: X-Forwarded-Proto
remote-ip-header: X-Forwarded-For
springdoc:
api-docs:
path: /api-docs
default-consumes-media-type: application/json
default-produces-media-type: application/json
swagger-ui:
operations-sorter: alpha
tags-sorter: alpha
path: /
disable-swagger-default-url: true
doc-expansion: none
syntax-highlight:
theme: nord
paths-to-match:
- /v1/**
- /temp/**
- /data4library/**
logging:
# file:
# name: logs/demo-all-in-one.log
exception-conversion-word: '%wEx'
pattern:
console: '%d{yyyy-MM-dd HH:mm:ss.SSS} %clr(${LOG_LEVEL_PATTERN:%-5p}){green} %clr([%22thread]){magenta} %clr(%-40.40logger{39}){cyan} %clr(: %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}){faint}'
level:
web: debug
org:
springframework:
web:
servlet: debug

View File

@@ -0,0 +1,7 @@
_ _ _ _
_| |___ _____ ___ ___ ___| | |___|_|___ ___ ___ ___ ___
| . | -_| | . |___| .'| | |___| | |___| . | | -_|
|___|___|_|_|_|___| |__,|_|_| |_|_|_| |___|_|_|___|
${Ansi.GREEN}:: Spring Boot :: ${Ansi.NORMAL}${spring-boot.formatted-version}${Ansi.NORMAL}
${application.title}${application.formatted-version}

View File

@@ -0,0 +1,3 @@
demo-app:
version: 1.0.2
url: http://localhost:${server.port}

View File

@@ -0,0 +1,105 @@
package me.jiniworld.demohx.notice.adapter.input.web
import io.mockk.mockk
import me.jiniworld.demohx.notice.application.port.input.GetNoticeQuery
import org.hamcrest.Matchers
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultHandlers
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.test.web.servlet.setup.MockMvcBuilders
@AutoConfigureRestDocs
@AutoConfigureMockMvc
internal class GetNoticeControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
private lateinit var getNoticeController: GetNoticeController
private lateinit var getNoticeQuery: GetNoticeQuery
@BeforeEach
fun setUp() {
val registerNoticeUseCase = mockk<GetNoticeQuery>(relaxed = true)
this.getNoticeQuery = registerNoticeUseCase
val cont = GetNoticeController(registerNoticeUseCase)
this.getNoticeController = cont
this.mockMvc = MockMvcBuilders.standaloneSetup(cont).build()
}
@Test
fun getNotices() {
val resultActions = mockMvc.perform(
MockMvcRequestBuilders.get("/v1/notices")
.header("Content-Type", "application/json")
)
// then
resultActions
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.jsonPath("$.result", Matchers.`is`("success")))
.andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andDo(MockMvcResultHandlers.print())
}
@Test
fun getNotice() {
val resultActions = mockMvc.perform(
MockMvcRequestBuilders.get("/v1/notices/{id}", 1)
.accept(MediaType.APPLICATION_JSON)
)
// then
resultActions
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.jsonPath("$.result", Matchers.`is`("success")))
.andExpect(MockMvcResultMatchers.jsonPath("$.data").exists())
.andExpect(MockMvcResultMatchers.jsonPath("$.data.id").isNotEmpty)
.andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andDo(MockMvcResultHandlers.print())
//
// resultActions.andDo(document("notice-search/success",
// requestFields(
// fieldWithPath("id").description("notice id")
// )
// ));
// prepa
// resource(
// ResourceSnippetParameters.builder()
// .description("v1 test")
// .pathParameters(
// parameterWithName("name").description("이름"))
// .responseFields(
// fieldWithPath("data").description("입력한 이름")
//// fieldWithPath("products").description("The product line item of the cart."),
//// subsectionWithPath("products[]._links.product").description("Link to the product."),
//// fieldWithPath("products[].quantity").description("The quantity of the line item."),
//// subsectionWithPath("products[].product").description("The product the line item relates to."),
//// subsectionWithPath("_links").description("Links section.")
// )
}
@Test
fun test1() {
val resultActions = mockMvc.perform(
MockMvcRequestBuilders.get("/v1/notices/test")
.header("Content-Type", "application/json")
)
// then
resultActions
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.jsonPath("$.result", Matchers.`is`("success")))
.andExpect(MockMvcResultMatchers.jsonPath("$.data", Matchers.`is`("test")))
.andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andDo(MockMvcResultHandlers.print())
}
}

View File

@@ -0,0 +1,67 @@
package me.jiniworld.demohx.notice.adapter.input.web
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import me.jiniworld.demohx.GsonUtils
import me.jiniworld.demohx.model.BaseResponse
import me.jiniworld.demohx.notice.application.port.input.RegisterNoticeCommand
import me.jiniworld.demohx.notice.application.port.input.RegisterNoticeUseCase
import org.assertj.core.api.Assertions.assertThat
import org.hamcrest.Matchers
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultHandlers
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.setup.MockMvcBuilders
internal class RegisterNoticeControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
private lateinit var registerNoticeController: RegisterNoticeController
private lateinit var registerNoticeUseCase: RegisterNoticeUseCase
@BeforeEach
fun setUp() {
val registerNoticeUseCase = mockk<RegisterNoticeUseCase>(relaxed = true)
this.registerNoticeUseCase = registerNoticeUseCase
val cont = RegisterNoticeController(registerNoticeUseCase)
this.registerNoticeController = cont
this.mockMvc = MockMvcBuilders.standaloneSetup(cont).build()
}
@Test
fun registerNoticeTest_mockMvc() {
val command = RegisterNoticeCommand(title = "title1", content = "content1")
// when
val resultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/v1/notices")
.content(GsonUtils.toJson(command))
.header("Content-Type", "application/json")
)
// then
resultActions
.andExpect(status().isOk)
.andExpect(jsonPath("$.result", Matchers.`is`("success")))
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andDo(MockMvcResultHandlers.print())
}
@Test
fun registerNoticeTest_mockK() {
val command = RegisterNoticeCommand(title = "title1", content = "content1")
every { registerNoticeUseCase.registerNotice(command) } returns Unit
assertThat(registerNoticeController.registerNotice(command)).isEqualTo(BaseResponse.SUCCESS)
verify { registerNoticeUseCase.registerNotice(command) }
}
}

View File

@@ -0,0 +1,49 @@
package me.jiniworld.demohx.notice.adapter.output.persistence
import me.jiniworld.demohx.notice.domain.NoticeInfo
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.test.context.jdbc.Sql
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
@DataJpaTest
@Import(NoticePersistenceAdapter::class)
internal class NoticePersistenceAdapterTest @Autowired constructor(
val noticePersistenceAdapter: NoticePersistenceAdapter,
) {
@Test
@Sql("notice.sql")
fun loadNotices() {
val notices = noticePersistenceAdapter.loadNotices(
PageRequest.of(1, 3, Sort.by(Sort.Order.desc("id")))
)
notices?.forEach(System.out::println)
}
@Test
@Sql("notice.sql")
fun loadNotice() {
val notice = noticePersistenceAdapter.loadNotice(1L)
checkNotNull(notice)
Assertions.assertEquals(notice.id, 1L)
println(notice)
}
@Transactional
@Test
fun saveNotice() {
val content = NoticeInfo(title = "공지사항", content = "공지사항입니다")
val notice = noticePersistenceAdapter.saveNotice(content)
Assertions.assertTrue(notice.id > 0)
Assertions.assertEquals(notice.noticeInfo.title, content.title)
Assertions.assertEquals(notice.noticeInfo.content, content.content)
println(notice)
}
}

View File

@@ -0,0 +1,14 @@
insert into notice(`title`, `content`, `created_at`) values ('notice1', 'content1', now());
insert into notice(`title`, `content`, `created_at`) values ('notice2', 'content2', now());
insert into notice(`title`, `content`, `created_at`) values ('notice3', 'content3', now());
insert into notice(`title`, `content`, `created_at`) values ('notice4', 'content4', now());
insert into notice(`title`, `content`, `created_at`) values ('notice5', 'content5', now());
insert into notice(`title`, `content`, `created_at`) values ('notice6', 'content6', now());
insert into notice(`title`, `content`, `created_at`) values ('notice7', 'content7', now());
insert into notice(`title`, `content`, `created_at`) values ('notice8', 'content8', now());
insert into notice(`title`, `content`, `created_at`) values ('notice9', 'content9', now());
insert into notice(`title`, `content`, `created_at`) values ('notice10', 'content10', now());
insert into notice(`title`, `content`, `created_at`) values ('notice11', 'content11', now());
insert into notice(`title`, `content`, `created_at`) values ('notice12', 'content12', now());
insert into notice(`title`, `content`, `created_at`) values ('notice13', 'content13', now());
insert into notice(`title`, `content`, `created_at`) values ('notice14', 'content14', now());

View File

@@ -0,0 +1,10 @@
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
compileOnly("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springdoc:springdoc-openapi-ui:1.6.14")
implementation(project(":util:common-util"))
implementation(project(":core:demo-core"))
implementation(project(":infrastructure:datastore-mariadb"))
}

View File

@@ -5,9 +5,9 @@ import org.springframework.boot.runApplication
import java.util.*
@SpringBootApplication
class DemoHxApplication
class DemoAppApplication
fun main(args: Array<String>) {
Locale.setDefault(Locale.KOREA)
runApplication<DemoHxApplication>(*args)
runApplication<DemoAppApplication>(*args)
}

View File

@@ -0,0 +1,13 @@
package me.jiniworld.demohx.config
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
@ComponentScan(basePackages = [
"me.jiniworld.demohx.config",
"me.jiniworld.demohx.web",
"me.jiniworld.demohx.application",
"me.jiniworld.demohx.persistence",
])
@Configuration
internal class BeanConfig

View File

@@ -0,0 +1,41 @@
package me.jiniworld.demohx.config
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Contact
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.info.License
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 SwaggerConfig(
@Value("\${demo-app.version}") val version: String,
@Value("\${spring.profiles.active}") val profile: String,
@Value("\${demo-app.url}") val url: String,
) {
@Bean
fun openAPI(): OpenAPI =
OpenAPI()
// .components(
// Components()
// .addSecuritySchemes("access_token",
// SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT")
// .`in`(SecurityScheme.In.HEADER).name("Authorization")))
// .addSecurityItem(SecurityRequirement().addList("access_token"))
.info(
Info().title("demo-app API - $profile")
.description("Hexagonal Architecture 구조로 만든 코프링 웹 애플리케이션")
.contact(Contact().name("jini").url("https://blog.jiniworld.me/").email("jini@jiniworld.me"))
.license(License().name("MIT License").url("https://github.com/jiniya22/demo-hexagonal/blob/master/LICENSE")))
.servers(
listOf(
Server().url(url).description(
"demo-app api ($profile)"
)
)
)
}

View File

@@ -0,0 +1,28 @@
package me.jiniworld.demohx.config.exception
import me.jiniworld.demohx.model.BaseResponse
import me.jiniworld.demohx.model.ServerException
import mu.KotlinLogging
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler
@RestControllerAdvice
internal class GlobalExceptionHandler: ResponseEntityExceptionHandler() {
private val logging = KotlinLogging.logger {}
@ExceptionHandler(ServerException::class)
fun handleServerException(ex: ServerException): ResponseEntity<BaseResponse> {
logging.error { ex.message }
return ResponseEntity.status(ex.code)
.body(BaseResponse(result = "fail", reason = ex.message))
}
@ExceptionHandler(Exception::class)
fun handleException(ex: Exception): ResponseEntity<BaseResponse> {
logging.error { ex.message }
return ResponseEntity.internalServerError()
.body(BaseResponse(result = "fail", reason = "Internal Server Error"))
}
}

View File

@@ -0,0 +1,39 @@
package me.jiniworld.demohx.web.notice
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.annotation.WebAdapter
import me.jiniworld.demohx.application.notice.port.input.GetNoticeQuery
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
import me.jiniworld.demohx.model.NotFoundException
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
@Validated
@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class GetNoticeController(
private val getNoticeQuery: GetNoticeQuery,
) {
@Operation(summary = "공지사항 목록")
@GetMapping("")
fun getNotices(
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
) = getNoticeQuery.getNoticeSimples(GetNoticesCommand(page = page, size = size))
?: throw NotFoundException("공지사항이 없습니다.")
@Operation(summary = "공지사항 상세조회")
@GetMapping("/{notice_id}")
fun getNotice(@PathVariable("notice_id") noticeId: Long,
) = getNoticeQuery.getNoticeDetail(noticeId)
?: throw NotFoundException("조회되는 공지사항이 없습니다.")
@Operation(summary = "test")
@GetMapping("/test")
fun test() = "test"
}

View File

@@ -1,14 +1,13 @@
spring:
application:
name: chaeking
name: demo-app
profiles:
active: local
config:
import:
- chaeking.yml
# - vault://secret/chaeking-local
- demo-app.yml
datasource:
url: jdbc:mariadb://localhost:3306/book
url: jdbc:mariadb://localhost:3306/demofx
driver-class-name: org.mariadb.jdbc.Driver
username: test
password: test
@@ -23,7 +22,7 @@ spring:
database-platform: org.hibernate.dialect.MariaDB103Dialect
properties:
hibernate:
format_sql: false
format_sql: true
hbm2ddl.auto: update
implicit_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
physical_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
@@ -31,8 +30,8 @@ spring:
open-in-view: false
show-sql: true
devtools:
add-properties: false
# devtools:
# add-properties: false
server:
port: 8080

View File

@@ -0,0 +1,7 @@
___ ____ _ _ ____ ____ ___ ___
| \ |___ |\/| | | __ |__| |__] |__]
|__/ |___ | | |__| | | | |
${Ansi.GREEN}:: Spring Boot :: ${Ansi.NORMAL}${spring-boot.formatted-version}${Ansi.NORMAL}
${application.title}${application.formatted-version}

View File

@@ -0,0 +1,3 @@
demo-app:
version: 1.0.2
url: http://localhost:${server.port}

View File

@@ -0,0 +1,18 @@
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.springframework.boot:spring-boot-starter-validation")
compileOnly("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springdoc:springdoc-openapi-kotlin:1.6.14")
implementation("org.springdoc:springdoc-openapi-webflux-ui:1.6.14")
implementation(project(":util:common-util"))
implementation(project(":core:demo-reactive-core"))
implementation(project(":infrastructure:datastore-mongodb-reactive"))
// implementation(project(":infrastructure:datastore-mariadb-reactive"))
// implementation("io.netty:netty-resolver-dns-native-macos:4.1.86.Final:osx-aarch_64")
}

View File

@@ -0,0 +1,13 @@
package me.jiniworld.demohx
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import java.util.*
@SpringBootApplication
class DemoReactiveAppApplication
fun main(args: Array<String>) {
Locale.setDefault(Locale.KOREA)
runApplication<DemoReactiveAppApplication>(*args)
}

View File

@@ -0,0 +1,13 @@
package me.jiniworld.demohx.config
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
@ComponentScan(basePackages = [
"me.jiniworld.demohx.config",
"me.jiniworld.demohx.web",
"me.jiniworld.demohx.application",
"me.jiniworld.demohx.persistence",
])
@Configuration
internal class BeanConfig

View File

@@ -0,0 +1,41 @@
package me.jiniworld.demohx.config
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Contact
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.info.License
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 SwaggerConfig(
@Value("\${demo-app.version}") val version: String,
@Value("\${spring.profiles.active}") val profile: String,
@Value("\${demo-app.url}") val url: String,
) {
@Bean
fun openAPI(): OpenAPI =
OpenAPI()
// .components(
// Components()
// .addSecuritySchemes("access_token",
// SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT")
// .`in`(SecurityScheme.In.HEADER).name("Authorization")))
// .addSecurityItem(SecurityRequirement().addList("access_token"))
.info(
Info().title("demo-reactive-app API - $profile")
.description("Hexagonal Architecture 구조로 만든 코프링 웹 애플리케이션")
.contact(Contact().name("jini").url("https://blog.jiniworld.me/").email("jini@jiniworld.me"))
.license(License().name("MIT License").url("https://github.com/jiniya22/demo-hexagonal/blob/master/LICENSE")))
.servers(
listOf(
Server().url(url).description(
"demo-reactive-app api ($profile)"
)
)
)
}

View File

@@ -0,0 +1,6 @@
package me.jiniworld.demohx.config.exception
data class ErrorResponse(
val code: Int,
val message: String,
)

View File

@@ -0,0 +1,48 @@
package me.jiniworld.demohx.config.exception
import mu.KotlinLogging
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageConversionException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import javax.validation.ValidationException
@RestControllerAdvice
class GlobalExceptionHandler {
private val logger = KotlinLogging.logger {}
@ExceptionHandler(ServerException::class)
fun handleServerException(ex: ServerException) : ResponseEntity<ErrorResponse> {
logger.error { ex.message }
return ResponseEntity.status(ex.code).body(ErrorResponse(code = ex.code, message = ex.message))
}
@ExceptionHandler(Exception::class)
fun handleException(ex: Exception): ResponseEntity<ErrorResponse> {
logger.error { ex.message }
var message: String?
var code: Int
when(ex) {
is MethodArgumentTypeMismatchException -> {
message = String.format("지원하지 않는 %s 입니다. 입력 가능한 값: %s",
ex.name, ex.requiredType?.enumConstants?.joinToString { it.toString() })
code = 400
}
is ValidationException, is HttpMessageConversionException, is IllegalArgumentException, is ArrayIndexOutOfBoundsException -> {
message = ex.message
code = 400
}
else -> {
message = "Internal Server Error";
code = 500
}
}
return ResponseEntity.status(code).body(ErrorResponse(code = code, message = message ?: ""))
}
}

View File

@@ -0,0 +1,14 @@
package me.jiniworld.demohx.config.exception
sealed class ServerException(
val code: Int,
override val message: String,
) : RuntimeException(message)
data class NotFoundException(
override val message: String,
) : ServerException(404, message)
data class UnauthorizedException(
override val message: String = "인증 정보가 잘못되었습니다",
) : ServerException(401, message)

View File

@@ -0,0 +1,27 @@
package me.jiniworld.demohx.web.item
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.annotation.WebAdapter
import me.jiniworld.demohx.application.item.port.input.GetItemQuery
import me.jiniworld.demohx.application.item.port.input.GetItemsCommand
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@WebAdapter
@Tag(name = "item", description = "상품")
@RestController
@RequestMapping("/v1/items")
internal class GetItemController(
private val getItemQuery: GetItemQuery,
) {
@Operation(summary = "상품 목록")
@GetMapping("")
fun getItems(
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
) = getItemQuery.getItems(GetItemsCommand(page = page, size = size))
}

View File

@@ -0,0 +1,34 @@
package me.jiniworld.demohx.web.notice
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.annotation.WebAdapter
import me.jiniworld.demohx.application.notice.port.input.GetNoticeQuery
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
import me.jiniworld.demohx.model.NotFoundException
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
@Validated
@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class GetNoticeController(
private val getNoticeQuery: GetNoticeQuery,
) {
@Operation(summary = "공지사항 목록")
@GetMapping("")
fun getNotices(
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
) = getNoticeQuery.getNoticeSummaries(GetNoticesCommand(page = page, size = size))
@Operation(summary = "공지사항 상세조회")
@GetMapping("/{notice_id}")
suspend fun getNotice(@PathVariable("notice_id") noticeId: String,
) = getNoticeQuery.getNoticeDetail(noticeId)
?: throw NotFoundException("조회되는 공지사항이 없습니다.")
}

View File

@@ -0,0 +1,28 @@
package me.jiniworld.demohx.web.notice
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.annotation.WebAdapter
import me.jiniworld.demohx.application.notice.port.input.RegisterNoticeCommand
import me.jiniworld.demohx.application.notice.port.input.RegisterNoticeUseCase
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 javax.validation.Valid
@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class RegisterNoticeController(
private val registerNoticeUseCase: RegisterNoticeUseCase,
) {
@Operation(summary = "공지사항 등록")
@PostMapping("")
suspend fun getNotices(
@Valid @RequestBody command: RegisterNoticeCommand,
) = registerNoticeUseCase.registerNotice(command)
}

View File

@@ -0,0 +1,71 @@
spring:
application:
name: demo-reactive-app
profiles:
active: local
config:
import:
- demo-reactive-app.yml
# r2dbc:
# url: r2dbc:mariadb://localhost:3306/demofx
# username: test
# password: test
# pool:
# max-size: 50
# validation-query: SELECT 1
# initial-size: 10
# enabled: true
# h2:
# console:
# enabled: true
# path: /h2-console
data:
mongodb:
uri: mongodb://localhost:27017/demofx
server:
port: 8081
tomcat:
basedir: .
# accesslog:
# enabled: true
# directory: logs
# pattern: "%{yyyy-MM-dd HH:mm:ss}t %{X-Forwarded-For}i(%h) %l %u \"%r\" %s %b"
remoteip:
protocol-header: X-Forwarded-Proto
remote-ip-header: X-Forwarded-For
springdoc:
api-docs:
path: /api-docs
default-consumes-media-type: application/json
default-produces-media-type: application/json
swagger-ui:
operations-sorter: alpha
tags-sorter: alpha
path: /
disable-swagger-default-url: true
doc-expansion: none
syntax-highlight:
theme: nord
paths-to-match:
- /v1/**
- /temp/**
- /data4library/**
logging:
# file:
# name: logs/demo-reactive.log
exception-conversion-word: '%wEx'
pattern:
console: '%d{yyyy-MM-dd HH:mm:ss.SSS} %clr(${LOG_LEVEL_PATTERN:%-5p}){green} %clr([%22thread]){magenta} %clr(%-40.40logger{39}){cyan} %clr(: %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}){faint}'
level:
web: DEBUG
me.jiniworld.demohx: DEBUG
org:
springframework:
data:
mongodb.core.ReactiveMongoTemplate: DEBUG

View File

@@ -0,0 +1,7 @@
___ ____ _ _ ____ ____ ____ ____ ____ ___ _ _ _ ____ ____ ___ ___
| \ |___ |\/| | | __ |__/ |___ |__| | | | | | |___ __ |__| |__] |__]
|__/ |___ | | |__| | \ |___ | | |___ | | \/ |___ | | | |
${Ansi.GREEN}:: Spring Boot :: ${Ansi.NORMAL}${spring-boot.formatted-version}${Ansi.NORMAL}
${application.title}${application.formatted-version}

View File

@@ -0,0 +1,3 @@
demo-app:
version: 1.0.1
url: http://localhost:${server.port}

View File

@@ -1,3 +1,14 @@
rootProject.name = "demo-hexagonal"
include("web")
include(
"core:demo-core",
"core:demo-reactive-core",
"infrastructure:datastore-mariadb",
"infrastructure:datastore-mongodb-reactive",
"infrastructure:datastore-mariadb-reactive",
"server:demo-app",
"server:demo-reactive-app",
"util:common-util",
"server:demo-all-in-one-app"
)

View File

@@ -0,0 +1,3 @@
dependencies {
implementation("com.google.code.gson:gson")
}

View File

@@ -1,14 +1,12 @@
package me.jiniworld.demohx.util
package me.jiniworld.demohx
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoField
import java.time.temporal.TemporalAdjusters
object DateTimeUtils {
private const val SIMPLE_PATTERN_MONTH = "yyyyMM"
private const val SIMPLE_PATTERN_DATE = "yyyyMMdd"
private const val DEFAULT_PATTERN_DATE = "yyyy-MM-dd"
@@ -36,27 +34,9 @@ object DateTimeUtils {
fun getLastDateTime(date: LocalDate): LocalDateTime =
LocalDateTime.of(date.with(TemporalAdjusters.lastDayOfMonth()), LOCALTIME_END)
fun getFirstDateTime(date: LocalDate, type: AnalysisType): LocalDateTime =
when (type) {
AnalysisType.weekly -> LocalDateTime.of(date.minusDays((date[ChronoField.DAY_OF_WEEK] - 1).toLong()).minusWeeks(6), LOCALTIME_START)
AnalysisType.monthly -> date.minusDays((date[ChronoField.DAY_OF_MONTH] - 1).toLong()).minusMonths(6).atStartOfDay()
else -> LocalDateTime.of(date.minusDays((date[ChronoField.DAY_OF_WEEK] - 1).toLong()), LOCALTIME_START)
}
fun toString(date: LocalDate): String = date.format(FORMATTER_DATE)
fun getLastDateTime(date: LocalDate, type: AnalysisType): LocalDateTime =
when (type) {
AnalysisType.monthly -> LocalDateTime.of(date.minusDays(date[ChronoField.DAY_OF_MONTH].toLong()).plusMonths(1), LOCALTIME_END)
else -> LocalDateTime.of(date.plusDays((7 - date[ChronoField.DAY_OF_WEEK]).toLong()), LOCALTIME_END)
}
fun toString(dateTime: LocalDateTime): String = dateTime.format(FORMATTER_DATETIME)
fun toString(date: LocalDate) = date.format(FORMATTER_DATE)
fun toString(dateTime: LocalDateTime) = dateTime.format(FORMATTER_DATETIME)
fun toDateString(dateTime: LocalDateTime) = dateTime.format(FORMATTER_DATE)
}
enum class AnalysisType {
daily, weekly, monthly
fun toDateString(dateTime: LocalDateTime) : String = dateTime.format(FORMATTER_DATE)
}

View File

@@ -0,0 +1,70 @@
package me.jiniworld.demohx
import com.google.gson.FieldNamingPolicy
import com.google.gson.GsonBuilder
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
object GsonUtils {
private const val patternDate = "yyyy-MM-dd"
private const val patternTime = "HH:mm:ss"
private const val patternDateTime = "yyyy-MM-dd HH:mm:ss"
private val gson = GsonBuilder()
.disableHtmlEscaping()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.setDateFormat(patternDateTime)
.registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter().nullSafe())
.registerTypeAdapter(LocalDate::class.java, LocalDateAdapter().nullSafe())
.registerTypeAdapter(LocalTime::class.java, LocalTimeAdapter().nullSafe())
.create()
fun toJson(obj: Any): String {
return gson.toJson(obj)
}
fun <T> fromJson(str: String, clazz: Class<T>): T {
return gson.fromJson(str, clazz)
}
class LocalDateAdapter : TypeAdapter<LocalDate>() {
private val format: DateTimeFormatter = DateTimeFormatter.ofPattern(patternDate)
override fun write(writer: JsonWriter, value: LocalDate?) {
value?.let { writer.value(value.format(format)) }
}
override fun read(reader: JsonReader): LocalDate {
return LocalDate.parse(reader.nextString(), format)
}
}
class LocalDateTimeAdapter : TypeAdapter<LocalDateTime>() {
private val format: DateTimeFormatter = DateTimeFormatter.ofPattern(patternDateTime)
override fun write(writer: JsonWriter, value: LocalDateTime?) {
value?.let { writer.value(value.format(format)) }
}
override fun read(reader: JsonReader): LocalDateTime {
return LocalDateTime.parse(reader.nextString(), format)
}
}
class LocalTimeAdapter : TypeAdapter<LocalTime>() {
private val format: DateTimeFormatter = DateTimeFormatter.ofPattern(patternTime)
override fun write(writer: JsonWriter, value: LocalTime?) {
value?.let { writer.value(value.format(format)) }
}
override fun read(reader: JsonReader): LocalTime {
return LocalTime.parse(reader.nextString(), format)
}
}
}

View File

@@ -0,0 +1,12 @@
package me.jiniworld.demohx.annotation
import org.springframework.stereotype.Component
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Component
annotation class PersistenceAdapter(
val value: String = ""
)

View File

@@ -0,0 +1,13 @@
package me.jiniworld.demohx.annotation
import org.springframework.stereotype.Component
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Component
annotation class UseCase(
val value: String = ""
)

View File

@@ -0,0 +1,12 @@
package me.jiniworld.demohx.annotation
import org.springframework.stereotype.Component
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Component
annotation class WebAdapter(
val value: String = ""
)

Some files were not shown because too many files have changed in this diff Show More