Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a4a6b3355 | ||
|
|
59cf934058 | ||
|
|
02553cdec7 | ||
|
|
7d3b445303 | ||
|
|
c7f6e3ca81 | ||
|
|
9ff1c6ecec | ||
|
|
022e344fea | ||
|
|
a2b580e8e7 | ||
|
|
36c34a77c4 | ||
|
|
4e2cc2ea10 | ||
|
|
122766f76e | ||
|
|
6ed8a0e62a | ||
|
|
b399db7604 | ||
|
|
78e8f9b7ec | ||
|
|
c675c10417 | ||
|
|
ec07b2c637 | ||
|
|
d3eb581d94 | ||
|
|
dd999c8f39 | ||
|
|
2a46aff7ed | ||
|
|
8f66697291 | ||
|
|
00f6ed2241 | ||
|
|
e16724ead3 | ||
|
|
8f29f3496b | ||
|
|
5eb705800a | ||
|
|
14a2c2ac79 | ||
|
|
f9e591fc1d | ||
|
|
1ddf129a79 | ||
|
|
1288e23bbf | ||
|
|
396719b8fd | ||
|
|
ddfbf20e66 | ||
|
|
a6684e6dcd | ||
|
|
8f1945f3ad | ||
|
|
9e706ea8a0 | ||
|
|
2d5b1c243e | ||
|
|
079d1ec338 | ||
|
|
7a260d90a9 | ||
|
|
333d346c87 | ||
|
|
fc2e75af6b | ||
|
|
cf80cbd7e9 | ||
|
|
09bf3271f1 | ||
|
|
9c9f1fb1d6 | ||
|
|
d27361cb73 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ bin/
|
|||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
logs/
|
logs/
|
||||||
|
Ttt*
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
117
README.md
117
README.md
@@ -2,38 +2,35 @@
|
|||||||
|
|
||||||
### Hexagonal Architecture 구조로 만든 코프링 웹 애플리케이션<br>
|
### Hexagonal Architecture 구조로 만든 코프링 웹 애플리케이션<br>
|
||||||
|
|
||||||
Kotlin + Spring Boot 2 + Spring MVC + Spring Data JPA
|
|
||||||
|
|
||||||
<p>
|
<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=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=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=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=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=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">
|
<img src="https://img.shields.io/static/v1?label=Swagger&message=3.0.3&color=85EA2D&logo=swagger&logoColor=fff" alt="Swagger 3">
|
||||||
</p>
|
</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)
|
||||||
|
|
||||||
## Tech Stacks
|
|
||||||
|
|
||||||
- JDK 17
|
|
||||||
- Kotlin 1.6.21
|
|
||||||
- Build Tools
|
|
||||||
- Gradle
|
|
||||||
- Kotlin DSL
|
|
||||||
- Spring Boot 2
|
|
||||||
- Spring Data JPA
|
|
||||||
- MariaDB 10.8.3
|
|
||||||
- OpenAPI Specification
|
|
||||||
- Swagger v3
|
|
||||||
- springdoc-openapi ui 1.6.13
|
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
## 프로젝트 구성
|
## <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">
|
<img width="344" alt="architecture-1" src="https://user-images.githubusercontent.com/31076826/204638756-a9a8b9b8-d0e5-4a27-bf14-4c8f12e93448.png">
|
||||||
|
|
||||||
@@ -51,9 +48,27 @@ Kotlin + Spring Boot 2 + Spring MVC + Spring Data JPA
|
|||||||
- util
|
- util
|
||||||
- core, server, infrastructure 모듈에서 공통적으로 사용할 유틸이 들어있습니다.
|
- core, server, infrastructure 모듈에서 공통적으로 사용할 유틸이 들어있습니다.
|
||||||
|
|
||||||
***
|
<br>
|
||||||
|
|
||||||
## demo-app
|
### <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 입니다.
|
demo 웹 애플리케이션의 실행 진입점은 `server - demo-app` 모듈의 DemoAppApplication 입니다.
|
||||||
|
|
||||||
@@ -63,3 +78,67 @@ demo 웹 애플리케이션의 실행 진입점은 `server - demo-app` 모듈의
|
|||||||
demo-app은 아래와 같이 `:util:common-util`, `:core:demo-core`, `:infrastructure:datastore-mariadb` 모듈을 포함합니다.
|
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">
|
<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">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ configurations {
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = "me.jiniworld"
|
group = "me.jiniworld"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -42,6 +42,7 @@ subprojects {
|
|||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||||
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
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("com.google.code.gson:gson:2.9.0")
|
||||||
// implementation("org.jetbrains:annotations:23.0.0")
|
// implementation("org.jetbrains:annotations:23.0.0")
|
||||||
@@ -55,6 +56,21 @@ subprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks {
|
||||||
|
compileKotlin {
|
||||||
|
kotlinOptions {
|
||||||
|
freeCompilerArgs = listOf("-Xjsr305=strict")
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileTestKotlin {
|
||||||
|
kotlinOptions {
|
||||||
|
freeCompilerArgs = listOf("-Xjsr305=strict")
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.withType<Test> {
|
tasks.withType<Test> {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package me.jiniworld.demohx.application.notice.domain
|
package me.jiniworld.demohx.application.notice.domain
|
||||||
|
|
||||||
import me.jiniworld.demohx.DateTimeUtils
|
|
||||||
import me.jiniworld.demohx.application.notice.port.output.NoticeDetail
|
|
||||||
import me.jiniworld.demohx.application.notice.port.output.NoticeSimple
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
data class Notice(
|
data class Notice(
|
||||||
@@ -10,10 +7,4 @@ data class Notice(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val createdAt: LocalDateTime,
|
val createdAt: LocalDateTime,
|
||||||
) {
|
)
|
||||||
fun mapToNoticeSimple() =
|
|
||||||
NoticeSimple(id = id, title = title, createdOn = DateTimeUtils.toDateString(createdAt))
|
|
||||||
|
|
||||||
fun mapToNoticeDetail() =
|
|
||||||
NoticeDetail(id = id, title = title, content = content, createdAt = DateTimeUtils.toString(createdAt))
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package me.jiniworld.demohx.application.notice.port.output
|
package me.jiniworld.demohx.application.notice.domain
|
||||||
|
|
||||||
data class NoticeDetail(
|
data class NoticeDetail(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package me.jiniworld.demohx.application.notice.port.output
|
package me.jiniworld.demohx.application.notice.domain
|
||||||
|
|
||||||
data class NoticeSimple(
|
data class NoticeSimple(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package me.jiniworld.demohx.application.notice.port.input
|
package me.jiniworld.demohx.application.notice.port.input
|
||||||
|
|
||||||
import me.jiniworld.demohx.application.notice.port.output.NoticeDetail
|
import me.jiniworld.demohx.application.notice.domain.NoticeDetail
|
||||||
import me.jiniworld.demohx.application.notice.port.output.NoticeSimple
|
import me.jiniworld.demohx.application.notice.domain.NoticeSimple
|
||||||
import org.springframework.data.domain.Pageable
|
|
||||||
|
|
||||||
interface GetNoticeQuery {
|
interface GetNoticeQuery {
|
||||||
fun getNoticeSimple(command: GetNoticesCommand): List<NoticeSimple>?
|
fun getNoticeSimples(command: GetNoticesCommand): List<NoticeSimple>?
|
||||||
fun getNoticeDetail(noticeId: Long): NoticeDetail?
|
fun getNoticeDetail(id: Long): NoticeDetail?
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package me.jiniworld.demohx.application.notice.port.output
|
package me.jiniworld.demohx.application.notice.port.output
|
||||||
|
|
||||||
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.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
|
|
||||||
interface LoadNoticePort {
|
interface LoadNoticePort {
|
||||||
fun loadNotices(pageable: Pageable): List<Notice>?
|
fun loadNotices(pageable: Pageable): List<NoticeSimple>?
|
||||||
fun loadNotice(id: Long): Notice?
|
fun loadNotice(id: Long): NoticeDetail?
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package me.jiniworld.demohx.application.notice.service
|
package me.jiniworld.demohx.application.notice.service
|
||||||
|
|
||||||
import me.jiniworld.demohx.annotation.UseCase
|
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.GetNoticeQuery
|
||||||
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
|
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
|
||||||
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
|
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
|
||||||
import me.jiniworld.demohx.application.notice.port.output.NoticeDetail
|
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Sort
|
import org.springframework.data.domain.Sort
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -15,10 +15,9 @@ internal class GetNoticeService(
|
|||||||
private val loadNoticePort: LoadNoticePort,
|
private val loadNoticePort: LoadNoticePort,
|
||||||
) : GetNoticeQuery {
|
) : GetNoticeQuery {
|
||||||
|
|
||||||
override fun getNoticeSimple(command: GetNoticesCommand) =
|
override fun getNoticeSimples(command: GetNoticesCommand) =
|
||||||
loadNoticePort.loadNotices(PageRequest.of(command.page, command.size, Sort.by(Sort.Order.desc("id"))))
|
loadNoticePort.loadNotices(PageRequest.of(command.page, command.size, Sort.by(Sort.Order.desc("id"))))
|
||||||
?.map { it.mapToNoticeSimple() }
|
|
||||||
|
|
||||||
override fun getNoticeDetail(id: Long): NoticeDetail? =
|
override fun getNoticeDetail(id: Long): NoticeDetail? =
|
||||||
loadNoticePort.loadNotice(id)?.mapToNoticeDetail()
|
loadNoticePort.loadNotice(id)
|
||||||
}
|
}
|
||||||
9
core/demo-reactive-core/build.gradle.kts
Normal file
9
core/demo-reactive-core/build.gradle.kts
Normal 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"))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package me.jiniworld.demohx.application.item.port.input
|
||||||
|
|
||||||
|
data class GetItemsCommand(
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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")))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.jiniworld.demohx.application.notice.port.input
|
||||||
|
|
||||||
|
interface RegisterNoticeUseCase {
|
||||||
|
suspend fun registerNotice(command: RegisterNoticeCommand)
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
11
infrastructure/datastore-mariadb-reactive/build.gradle.kts
Normal file
11
infrastructure/datastore-mariadb-reactive/build.gradle.kts
Normal 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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package me.jiniworld.demohx.persistence.notice
|
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.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.CreatedDate
|
||||||
import org.springframework.data.annotation.LastModifiedDate
|
import org.springframework.data.annotation.LastModifiedDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
@@ -28,4 +31,11 @@ internal class NoticeEntity {
|
|||||||
|
|
||||||
fun mapToNotice() =
|
fun mapToNotice() =
|
||||||
Notice(id = id, title = title, content = content, createdAt = createdAt)
|
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))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package me.jiniworld.demohx.persistence.notice
|
package me.jiniworld.demohx.persistence.notice
|
||||||
|
|
||||||
import me.jiniworld.demohx.annotation.PersistenceAdapter
|
import me.jiniworld.demohx.annotation.PersistenceAdapter
|
||||||
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 me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
|
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
@@ -10,11 +11,11 @@ import org.springframework.data.repository.findByIdOrNull
|
|||||||
internal class NoticePersistenceAdapter(
|
internal class NoticePersistenceAdapter(
|
||||||
private val noticeRepository: NoticeRepository,
|
private val noticeRepository: NoticeRepository,
|
||||||
) : LoadNoticePort {
|
) : LoadNoticePort {
|
||||||
override fun loadNotices(pageable: Pageable): List<Notice>? {
|
override fun loadNotices(pageable: Pageable): List<NoticeSimple>? {
|
||||||
return noticeRepository.findAllBy(pageable).map { it.mapToNotice() }.toList()
|
return noticeRepository.findAllBy(pageable).map { it.mapToNoticeSimple() }.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loadNotice(id: Long): Notice? {
|
override fun loadNotice(id: Long): NoticeDetail? {
|
||||||
return noticeRepository.findByIdOrNull(id)?.mapToNotice()
|
return noticeRepository.findByIdOrNull(id)?.mapToNoticeDetail()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
10
infrastructure/datastore-mongodb-reactive/build.gradle.kts
Normal file
10
infrastructure/datastore-mongodb-reactive/build.gradle.kts
Normal 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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
14
server/demo-all-in-one-app/build.gradle.kts
Normal file
14
server/demo-all-in-one-app/build.gradle.kts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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.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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package me.jiniworld.demohx.notice.adapter.output.persistence
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
constructor(title: String, content: String) {
|
||||||
|
this.title = title
|
||||||
|
this.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapToNotice() =
|
||||||
|
Notice(id = id, noticeInfo = NoticeInfo(title = title, content = content), createdAt = createdAt)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package me.jiniworld.demohx.notice.application.port.input
|
||||||
|
|
||||||
|
data class GetNoticesCommand(
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package me.jiniworld.demohx.notice.application.port.input
|
||||||
|
|
||||||
|
data class RegisterNoticeCommand(
|
||||||
|
val title: String,
|
||||||
|
val content: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.jiniworld.demohx.notice.application.port.input
|
||||||
|
|
||||||
|
interface RegisterNoticeUseCase {
|
||||||
|
fun registerNotice(command: RegisterNoticeCommand)
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package me.jiniworld.demohx.notice.application.port.output
|
||||||
|
|
||||||
|
data class NoticeSimple(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val createdOn: String,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package me.jiniworld.demohx.notice.domain
|
||||||
|
|
||||||
|
data class NoticeInfo(
|
||||||
|
val title: String,
|
||||||
|
val content: String,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
7
server/demo-all-in-one-app/src/main/resources/banner.txt
Normal file
7
server/demo-all-in-one-app/src/main/resources/banner.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
_ _ _ _
|
||||||
|
_| |___ _____ ___ ___ ___| | |___|_|___ ___ ___ ___ ___
|
||||||
|
| . | -_| | . |___| .'| | |___| | |___| . | | -_|
|
||||||
|
|___|___|_|_|_|___| |__,|_|_| |_|_|_| |___|_|_|___|
|
||||||
|
|
||||||
|
${Ansi.GREEN}:: Spring Boot :: ${Ansi.NORMAL}${spring-boot.formatted-version}${Ansi.NORMAL}
|
||||||
|
${application.title}${application.formatted-version}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
demo-app:
|
||||||
|
version: 1.0.2
|
||||||
|
url: http://localhost:${server.port}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
@@ -2,7 +2,7 @@ dependencies {
|
|||||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
compileOnly("org.springframework.boot:spring-boot-configuration-processor")
|
compileOnly("org.springframework.boot:spring-boot-configuration-processor")
|
||||||
implementation("org.springdoc:springdoc-openapi-ui:1.6.13")
|
implementation("org.springdoc:springdoc-openapi-ui:1.6.14")
|
||||||
|
|
||||||
implementation(project(":util:common-util"))
|
implementation(project(":util:common-util"))
|
||||||
implementation(project(":core:demo-core"))
|
implementation(project(":core:demo-core"))
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
|
|||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = [
|
@SpringBootApplication
|
||||||
"me.jiniworld.demohx.config",
|
|
||||||
"me.jiniworld.demohx.web",
|
|
||||||
"me.jiniworld.demohx.application",
|
|
||||||
"me.jiniworld.demohx.persistence",
|
|
||||||
])
|
|
||||||
class DemoAppApplication
|
class DemoAppApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,11 @@ import io.swagger.v3.oas.annotations.tags.Tag
|
|||||||
import me.jiniworld.demohx.annotation.WebAdapter
|
import me.jiniworld.demohx.annotation.WebAdapter
|
||||||
import me.jiniworld.demohx.application.notice.port.input.GetNoticeQuery
|
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.input.GetNoticesCommand
|
||||||
|
import me.jiniworld.demohx.model.NotFoundException
|
||||||
|
import org.springframework.validation.annotation.Validated
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
@Validated
|
||||||
@WebAdapter
|
@WebAdapter
|
||||||
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
|
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
|
||||||
@RestController
|
@RestController
|
||||||
@@ -17,14 +20,20 @@ internal class GetNoticeController(
|
|||||||
|
|
||||||
@Operation(summary = "공지사항 목록")
|
@Operation(summary = "공지사항 목록")
|
||||||
@GetMapping("")
|
@GetMapping("")
|
||||||
fun notices(
|
fun getNotices(
|
||||||
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
|
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
|
||||||
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
|
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
|
||||||
) = getNoticeQuery.getNoticeSimple(GetNoticesCommand(page = page, size = size))
|
) = getNoticeQuery.getNoticeSimples(GetNoticesCommand(page = page, size = size))
|
||||||
|
?: throw NotFoundException("공지사항이 없습니다.")
|
||||||
|
|
||||||
@Operation(summary = "공지사항 상세조회")
|
@Operation(summary = "공지사항 상세조회")
|
||||||
@GetMapping("/{notice_id}")
|
@GetMapping("/{notice_id}")
|
||||||
fun notice(@PathVariable("notice_id") noticeId: Long) =
|
fun getNotice(@PathVariable("notice_id") noticeId: Long,
|
||||||
getNoticeQuery.getNoticeDetail(noticeId)
|
) = getNoticeQuery.getNoticeDetail(noticeId)
|
||||||
|
?: throw NotFoundException("조회되는 공지사항이 없습니다.")
|
||||||
|
|
||||||
|
@Operation(summary = "test")
|
||||||
|
@GetMapping("/test")
|
||||||
|
fun test() = "test"
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,9 @@ spring:
|
|||||||
active: local
|
active: local
|
||||||
config:
|
config:
|
||||||
import:
|
import:
|
||||||
- chaeking.yml
|
- demo-app.yml
|
||||||
# - vault://secret/chaeking-local
|
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mariadb://localhost:3306/book
|
url: jdbc:mariadb://localhost:3306/demofx
|
||||||
driver-class-name: org.mariadb.jdbc.Driver
|
driver-class-name: org.mariadb.jdbc.Driver
|
||||||
username: test
|
username: test
|
||||||
password: test
|
password: test
|
||||||
@@ -23,7 +22,7 @@ spring:
|
|||||||
database-platform: org.hibernate.dialect.MariaDB103Dialect
|
database-platform: org.hibernate.dialect.MariaDB103Dialect
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
format_sql: false
|
format_sql: true
|
||||||
hbm2ddl.auto: update
|
hbm2ddl.auto: update
|
||||||
implicit_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
|
implicit_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
|
||||||
physical_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
|
physical_naming_strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
chaeking:
|
|
||||||
version: 1.0.1
|
|
||||||
url: http://localhost:${server.port}
|
|
||||||
|
|
||||||
book-search:
|
|
||||||
kakao:
|
|
||||||
api-url: https://dapi.kakao.com
|
|
||||||
3
server/demo-app/src/main/resources/demo-app.yml
Normal file
3
server/demo-app/src/main/resources/demo-app.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
demo-app:
|
||||||
|
version: 1.0.2
|
||||||
|
url: http://localhost:${server.port}
|
||||||
18
server/demo-reactive-app/build.gradle.kts
Normal file
18
server/demo-reactive-app/build.gradle.kts
Normal 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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package me.jiniworld.demohx.config.exception
|
||||||
|
|
||||||
|
data class ErrorResponse(
|
||||||
|
val code: Int,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
@@ -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 ?: ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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("조회되는 공지사항이 없습니다.")
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
71
server/demo-reactive-app/src/main/resources/application.yml
Normal file
71
server/demo-reactive-app/src/main/resources/application.yml
Normal 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
|
||||||
7
server/demo-reactive-app/src/main/resources/banner.txt
Normal file
7
server/demo-reactive-app/src/main/resources/banner.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
___ ____ _ _ ____ ____ ____ ____ ____ ___ _ _ _ ____ ____ ___ ___
|
||||||
|
| \ |___ |\/| | | __ |__/ |___ |__| | | | | | |___ __ |__| |__] |__]
|
||||||
|
|__/ |___ | | |__| | \ |___ | | |___ | | \/ |___ | | | |
|
||||||
|
|
||||||
|
${Ansi.GREEN}:: Spring Boot :: ${Ansi.NORMAL}${spring-boot.formatted-version}${Ansi.NORMAL}
|
||||||
|
${application.title}${application.formatted-version}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
demo-app:
|
||||||
|
version: 1.0.1
|
||||||
|
url: http://localhost:${server.port}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
|
|
||||||
rootProject.name = "demo-hexagonal"
|
rootProject.name = "demo-hexagonal"
|
||||||
include("core:demo-core")
|
|
||||||
findProject(":core:demo-core")?.name = "demo-core"
|
include(
|
||||||
include("infrastructure:datastore-mariadb")
|
"core:demo-core",
|
||||||
findProject(":infrastructure:datastore-mariadb")?.name = "datastore-mariadb"
|
"core:demo-reactive-core",
|
||||||
include("server")
|
"infrastructure:datastore-mariadb",
|
||||||
include("server:demo-app")
|
"infrastructure:datastore-mongodb-reactive",
|
||||||
findProject(":server:demo-app")?.name = "demo-app"
|
"infrastructure:datastore-mariadb-reactive",
|
||||||
include("util:common-util")
|
"server:demo-app",
|
||||||
findProject(":util:common-util")?.name = "common-util"
|
"server:demo-reactive-app",
|
||||||
|
"util:common-util",
|
||||||
|
"server:demo-all-in-one-app"
|
||||||
|
)
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("com.google.code.gson:gson")
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package me.jiniworld.demohx.model
|
||||||
|
|
||||||
|
data class BaseResponse(
|
||||||
|
val result: String = "success",
|
||||||
|
val reason: String = "",
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val SUCCESS = BaseResponse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package me.jiniworld.demohx.model
|
||||||
|
|
||||||
|
data class DataResponse<T>(
|
||||||
|
val result: String = "success",
|
||||||
|
val reason: String = "",
|
||||||
|
val data: T,
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package me.jiniworld.demohx.model
|
||||||
|
|
||||||
|
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 = "access_token 이 유효하지 않습니다.",
|
||||||
|
): ServerException(401, message)
|
||||||
Reference in New Issue
Block a user