├── .gitignore ├── .gradle ├── 3.1 │ └── taskArtifacts │ │ ├── cache.properties │ │ ├── cache.properties.lock │ │ ├── fileHashes.bin │ │ ├── fileSnapshots.bin │ │ └── taskArtifacts.bin └── 3.2.1 │ └── taskArtifacts │ ├── fileHashes.bin │ ├── fileSnapshots.bin │ ├── taskArtifacts.bin │ └── taskArtifacts.lock ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src └── main ├── kotlin ├── org │ └── ksug │ │ └── forum │ │ ├── ForumBootApplication.kt │ │ ├── domain │ │ ├── Category.kt │ │ ├── DataNotFoundException.kt │ │ ├── ForumException.kt │ │ ├── Password.kt │ │ ├── Post.kt │ │ ├── Topic.kt │ │ ├── module │ │ │ └── ForumService.kt │ │ └── repository │ │ │ ├── CategoryRepository.kt │ │ │ ├── PostRepository.kt │ │ │ └── TopicRepository.kt │ │ └── web │ │ ├── ForumRestController.kt │ │ ├── ServiceInfoRestController.kt │ │ ├── WebConfig.kt │ │ ├── data │ │ └── DataModels.kt │ │ └── support │ │ ├── DefaultErrorRestController.kt │ │ ├── ErrorResponse.kt │ │ └── ExceptionHandlers.kt └── swagger │ ├── Swagger.kt │ └── SwaggerUIConfig.kt └── resources ├── application.yml ├── data.sql ├── messages.properties ├── messages_en_US.properties ├── messages_ko_KR.properties └── swagger ├── forum-spec.json └── ui.html /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .idea 3 | *.iml 4 | -------------------------------------------------------------------------------- /.gradle/3.1/taskArtifacts/cache.properties: -------------------------------------------------------------------------------- 1 | #Fri May 19 18:02:13 KST 2017 2 | -------------------------------------------------------------------------------- /.gradle/3.1/taskArtifacts/cache.properties.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.1/taskArtifacts/cache.properties.lock -------------------------------------------------------------------------------- /.gradle/3.1/taskArtifacts/fileHashes.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.1/taskArtifacts/fileHashes.bin -------------------------------------------------------------------------------- /.gradle/3.1/taskArtifacts/fileSnapshots.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.1/taskArtifacts/fileSnapshots.bin -------------------------------------------------------------------------------- /.gradle/3.1/taskArtifacts/taskArtifacts.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.1/taskArtifacts/taskArtifacts.bin -------------------------------------------------------------------------------- /.gradle/3.2.1/taskArtifacts/fileHashes.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.2.1/taskArtifacts/fileHashes.bin -------------------------------------------------------------------------------- /.gradle/3.2.1/taskArtifacts/fileSnapshots.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.2.1/taskArtifacts/fileSnapshots.bin -------------------------------------------------------------------------------- /.gradle/3.2.1/taskArtifacts/taskArtifacts.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.2.1/taskArtifacts/taskArtifacts.bin -------------------------------------------------------------------------------- /.gradle/3.2.1/taskArtifacts/taskArtifacts.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/.gradle/3.2.1/taskArtifacts/taskArtifacts.lock -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Building REST services with Kotlin and Spring, JPA 2 | ================================================== 3 | 4 | > 약 1년전 작성했던 예제를 2017년 5월을 기준으로 보완해본다. 5 | 6 | 2017년 5월 17일, 구글이 안드로이드 공식 언어로 코틀린(Kotlin)을 추가했다고 발표했다. 작년 2월에 1.0 정식 발표 후 호기심에 예제를 만들어 보았고, 매력적인 언어라고 평가했었다. 개인적으로 JVM 플랫폼 기반 시장의 한 축으로 자리를 잡을 거라고 생각했었는데, 1년이 조금 지난 시점에 멋진 결과를 만들어냈다고 생각한다. 7 | 8 | 코틀린 팀은 공식 블로그의 [Kotlin on Android. Now official](https://blog.jetbrains.com/kotlin/2017/05/kotlin-on-android-now-official/)를 통해, 코틀린의 비전은 풀스택 웹 애플리케이션, 안드로이드(Android)와 iOS 앱, 임베디드(embedded)/IoT 등 다양한 플랫폼에서 코틀린으로 개발할 수 있도록 하는 것이라고 말했다. 9 | 10 | 작년 2월 스프링 블로그에서 올라왔던 [Developing Spring Boot applications with Kotlin](https://spring.io/blog/2016/02/15/developing-spring-boot-applications-with-kotlin) 이후 한동안 잠잠했던, 스프링 팀도 올해 1월에 [스프링 프레임워크 5.0이 코틀린을 지원](https://spring.io/blog/2017/01/04/introducing-kotlin-support-in-spring-framework-5-0)할 것이라고 소개했다. 11 | 외부로 드러내진 않았지만, 코틀린의 이슈 트래커나 코틀린과 관련된 오픈소스 프로젝트들을 쫓아다니다 보면 스프링 팀의 커미터들에 글을 종종 볼 수 있다. 12 | 13 | 작년에 예제를 만들면서 아쉬웠던 점들이 해결이 되었는지 궁금해, 예제를 보완해봤다. 14 | 15 | 개발환경 변경사항 16 | ------------ 17 | 18 | - 코틀린(Kotlin) 버전 변경: 1.0.0 -> 1.1.2-2 19 | - 스프링 부트(Spring Boot) 버전 변경: 1.3.2 -> 1.5.3 20 | - 스웨거(Swagger) 버전 변경: 2.1.4 -> 3.0.5 21 | - 핸들바(Handlebars) 제거 22 | 23 | 24 | 작년에 겪었던 문제 또는 불편했던 점... 25 | ------------------------------ 26 | 27 | ### | CGLIB 기반 AOP는 올바르게 동작하지 않는다 28 | 29 | 이 현상은 코틀린의 언어 설계 원칙과 연관이 있다. 아래 내용은 코틀린 [공식문서](https://kotlinlang.org/docs/reference/classes.html#inheritance)에 있는 내용이다. 30 | 31 | > By default, all classes in Kotlin are final, which corresponds to Effective Java, Item 17: Design and document for inheritance or else prohibit it. 32 | > 33 | > Effective Java, Item 17: 상속에 대한 설계와 문서화를 제대로 하지 않을 거면 아예 상속을 허용하지 말라. 34 | 35 | 코틀린은 상속에 대해 명확하게 작성하기를 바라기 때문에 필요하다면 open 지시어를 사용하라고 되어 있다. 36 | 그렇기에 AOP를 적용하려는 대상에 open 지시어를 선언해주어야 한다. 37 | 38 | ```kotlin 39 | @Service 40 | @Transactional 41 | open class ForumService constructor(var categoryRepository: CategoryRepository) { 42 | 43 | open fun categories() = categoryRepository.findAll() 44 | 45 | } 46 | ``` 47 | 48 | 대상에는 클래스(class)뿐만이 아니라 메소드(method), 필드(field) 등이 모두 포함된다. 49 | 50 | 스프링 프레임워크를 기반으로 작성하는 애플리케이션에서 AOP는 매우 광범위하게 사용되기 때문에 실무자의 입장에서는 꽤 번거로운 제약사항이다. 51 | 이 제약사항에 대해 개방(open)파와 폐쇄(final)파가 [다양한 의견을 나누는 글](https://discuss.kotlinlang.org/t/classes-final-by-default/166/2)이 있으니, 관심이 있다면 읽어보자. 52 | 53 | 어떤 과정을 거쳐 의사결정이 있었는지 알 수 없지만, 코틀린 팀은 [Compiler Plugins](https://kotlinlang.org/docs/reference/compiler-plugins.html#kotlin-spring-compiler-plugin)을 통해 해결책을 제시한듯 하다. 54 | 애노테이션으로 컴파일 시점에 필요한 작업을 끼워넣는 방식이며, 이런 형태의 유명한 도구로 [롬복](https://projectlombok.org)이 있다. 55 | 56 | 현재 두 개의 플러그인(All-open 플러그인, No-arg 플러그인)이 제공되고 있고, 사용 방법은 빌드 도구(Gradle, Maven)에 몇가지 설정만 추가하면 된다. 57 | 58 | All-open 플러그인 적용 후 open 지시어를 일일히 쓰지 않아도 AOP가 잘 동작하는걸 확인 할 수 있었고, 그리고 No-arg 플러그인 덕분에 하이버네이트 엔티티에 기본 생성자(default constructor)를 작성해주지 않는 편리함도 얻었다. 59 | 60 | 61 | ```kotlin 62 | @Entity 63 | data class Category( var name: String 64 | , val createdAt: Date = Date()) { 65 | 66 | 67 | // No-arg 플러그인 적용 후 기본 생성자 제거 68 | // 69 | // for hibernate 70 | // private constructor() : this("") 71 | 72 | } 73 | ``` 74 | 75 | ### | data class로 빈 검증(JSR-303)을 할 수 있는 방법을 못 찾았다 76 | 77 | 코틀린이 자바와의 호환성을 유지하기 위해 제공하는 기능인 [Annotation Use-site Targets](https://kotlinlang.org/docs/reference/annotations.html#annotation-use-site-targets)만으로 깔끔하게 불편함이 해소되었다. 78 | 79 | ```kotlin 80 | data class TopicForm ( 81 | @field:NotEmpty 82 | var title: String = "", 83 | 84 | @field:NotEmpty 85 | var password: String = "", 86 | 87 | @field:NotEmpty 88 | var author: String = "" 89 | ) 90 | ``` 91 | 92 | 어렴풋한 기억으로 코틀린 컴파일러 초기 시절에는 애노테이션을 처리하는 방식으로 인해 발생하는 이슈가 있었던걸로 알고 있었는데, 지금은 해소가 된 것일까? 93 | 남은 두가지 불편함은 여전하지만, 사소한 수준이기 때문에 무시해도 될 정도라고 인것 같아, 예제는 이쯤에서 마무리한다. 94 | 95 | 마무리 96 | ---- 97 | 98 | 구글이 코틀린에 손을 잡아주면서, 안드로이드 진영에서는 앞으로 더 많은 관심을 받지 않을까? 99 | 100 | JVM 서버 진영에서는 스프링이 내민 손을 커뮤니티가 잡아주기를 기대해 본다. 101 | 102 | 103 | 104 | --- 105 | 106 | 107 | 108 | > 아래는 2016년 2월에 작성했던 내용이다. 109 | 110 | 111 | 지난 2016년 2월 15일, 스프링 블로그에 [Developing Spring Boot applications with Kotlin](https://spring.io/blog/2016/02/15/developing-spring-boot-applications-with-kotlin) 라는 글이 올라왔다. [코틀린(Kotlin)](https://kotlinlang.org)이라는 언어로 스프링 기반 애플리케이션을 개발해보는것에 대한 글인데... 예제가 너무 간단해서 해당 코드만으로는 어떤 장점이 있는지 알기가 쉽지 않아 직접 예제를 만들어봤다. 112 | 113 | 직접 개발을 해보며 느꼈던 점과 겪었던 문제들을 간단히 기록해둔다. 114 | 115 | 116 | 코틀린(Kotlin)이란? 117 | ---------------- 118 | 119 | 코틀린(Kotlin)은 Groovy, Scala등과 같이 JVM 플랫폼에서 동작하는 언어다. 120 | IntelliJ IDE를 개발 및 판매하고 있는 젯브레인즈(Jetbrains)사에서 개발한 언어로 스위프트(Swift)와 무척 비슷한 모습을 가지고 있다. 최근 안드로이드를 개발자를 중심으로 Java를 대체할 수 있는 언어중 하나로 입소문(?)을 타고 있는듯 하다. 121 | 122 | 코틀린이 가진 뚜렷한 장점은 Java와 100% 호환성을 제공함에 따라 Java가 구축해놓은 오픈소스 생태계를 그대로 사용 할 수 있다는 것이다. 또한 Gradle, Maven과 같은 빌드 시스템도 쓸수 있고, 새로 만들어진 언어이니 만큼 Java에서 불편했던 것들이 많이 개선되었다. 무엇보다 IDE를 개발하는 회사가 만든 언어이기 때문에 IDE에 대한 지원 또한 강력하다. 123 | 124 | 코틀린 공식사이트에서 제공하는 [문서](https://kotlinlang.org/docs/reference/)와 Hazealign님이 번역한 [Android 개발을 수주해서 Kotlin을 제대로 써봤더니 최고였다.](https://gist.github.com/Hazealign/1bbc586ded1649a8f08f) 읽어본 후 이 예제를 만드는데 있어 큰 어려움은 없었고, 좋았던 것은 수다스러운 자바에 비해 간결해진 문법과 언어가 제공하는 몇가지 기능들로 인해 코드를 작성함에 있어 꽤 편하고 강력함을 만날 수 있었다는 점이다. 125 | 126 | 예제 설명 및 실행 127 | ------------- 128 | 129 | 사용자들이 주제(Topic)를 작성하고, 해당 주제에 대해 글(Post)을 공유하는 포럼(Forum) 웹 서비스를 예제로 만들었다. 크게 3가지 기능을 가지고 있다. 130 | 131 | * 분류(Category) 조회 132 | * 주제(Topic) 목록, 쓰기, 수정, 삭제 133 | * 글(Post) 목록 134 | 135 | ### 개발환경 136 | 137 | - Kotlin 1.0 138 | - Frameworks: Spring Boot, Spring Web, Spring Data JPA 139 | - Tools: Gradle 2.9, IntelliJ IDE 14 140 | 141 | 142 | ``` 143 | ./gradlew clean bootRun 144 | ``` 145 | 146 | 애플리케이션이 완전히 실행된 후 브라우저에서 `http://localhost:8080/swagger/ui.html` 페이지에 접속해 API를 테스트해보면 된다. 147 | 148 | 좋았던 점 149 | ------- 150 | 151 | - 배우고, 시작하기가 쉬운 언어다. (Java와 호환성이 정말 좋다) 152 | - [Properties support](https://kotlinlang.org/docs/reference/properties.html#declaring-properties)를 비롯해 타입추론, [Single-Expression function](https://kotlinlang.org/docs/reference/functions.html#single-expression-functions), [Smart Casts](https://kotlinlang.org/docs/reference/typecasts.html) 등으로 코드를 간결하게 작성한다. 153 | - [확장 메소드](https://kotlinlang.org/docs/reference/extensions.html)로 코드 표현력 좋아진다. 154 | - null 처리가 매우 안전하다. - [Null Safety](https://kotlinlang.org/docs/reference/null-safety.html) 155 | - 문자열 내부에서 변수에 접근할 수 있어서 유용했다. [String Interpolation](https://kotlinlang.org/docs/reference/idioms.html#string-interpolation) 156 | - 강력한 IDE 지원!!! (Java 코드를 Kotlin 코드로 변환해주는 기능도 있다!) 157 | 158 | 겪었던 문제 또는 불편했던 점... 159 | ------------------------ 160 | 161 | ### | CGLIB 기반 AOP는 올바르게 동작하지 않는다 162 | 163 | 코틀린으로 AOP를 사용하려면 인터페이스 기반(JDK Dynamic Proxy)이나 AspectJ Weaving 기법을 사용해야 한다. 원인을 파악해보기엔 시간적 여유가 없어 해결 방법만 찾아서 적용해두었다. 164 | 165 | ```java 166 | @Service 167 | @Transactional 168 | class ForumService @Autowired constructor(var categoryRepository: CategoryRepository, var topicRepository: TopicRepository, var postRepository: PostRepository) { 169 | 170 | } 171 | ``` 172 | 173 | 위 코드는 인터페이스 없이 `@Transactional`을 사용해 트랜잭션 처리를 하려는 의도로 작성했지만, 그대로 실행하면 `@Autowired`가 무시되며 모든 Repository 빈들이 null이 된다. 위 정상적으로 실행하기 위해 `Load-Time Weaver`를 사용했다. 174 | 175 | ### | data class로 빈 검증(JSR-303)을 할 수 있는 방법을 못 찾았다 176 | 177 | data class는 코틀린이 가진 멋진 언어적 장치이지만... 빈 검증을 할 수 있는 방법을 찾지 못 했다. 178 | 179 | ```java 180 | @RestController 181 | class TopicController { 182 | 183 | @RequestMapping("/write") 184 | fun writeTopic(@Valid form: TopicForm) { ... } 185 | 186 | } 187 | 188 | data class TopicForm(@NotEmpty var title:String? = null) 189 | ``` 190 | 191 | **TopicFrom** 객체의 title 속성은 빈(empty) 값을 가질 수 없다고 선언한 후 동작시켰지만 의도한대로 동작하지 않았다. 아래와 같이 일반 클래스로 변경한 후에는 정상적으로 동작했다. 192 | 193 | ```java 194 | class TopicForm { 195 | @NotEmpty var title:String? = null 196 | } 197 | ``` 198 | 199 | ### | Getter를 가진 Java Interface를 깔끔하게 구현하는 방법을 못 찾았다 200 | 201 | Java와 Kotlin의 타입이 충돌하며 발생하는 문제인듯 한데... 깔끔한 방법을 찾지 못했다. 202 | 203 | ```java 204 | // Java interface 205 | public interface ErrorController { 206 | 207 | String getErrorPath(); 208 | 209 | } 210 | 211 | 212 | // Kotlin implements 213 | class DefaultErrorController : ErrorController { 214 | 215 | private var errorPath: String? = null 216 | override fun getErrorPath(): String? = errorPath 217 | 218 | } 219 | ``` 220 | 221 | ### | 스프링 내부에서 Property placeholders 를 사용하는 경우는... 울고싶다. 222 | 223 | 스프링은 `@Value("${property}")`을 사용해 값을 치환하는 방법을 사용하는데, 코틀린의 문자열 연산에서 `$`가 사용되기 때문에 `@Value("\${property}")`과 같이 이스케이프(\) 처리를 해야한다. 또는 Stack Overflow - [Change property placeholder signifier](http://stackoverflow.com/questions/33821043/spring-boot-change-property-placeholder-signifier/33883230#33883230) 글을 참고해서 문제를 해결할 수 있다. 224 | 225 | 문제는 아래와 같이 스프링 내부에서 사용하는 경우인데... 226 | 227 | ```java 228 | package org.springframework.boot.autoconfigure.web; 229 | 230 | public class ErrorProperties { 231 | 232 | @Value("${error.path:/error}") 233 | private String path = "/error"; 234 | 235 | } 236 | ``` 237 | 238 | 프레임워크 코드를 변경 할 수 없기 때문에 `BeanPostProcessor`를 사용해 우회 처리했다. 239 | 240 | ```java 241 | @Bean 242 | open fun errorPropertiesPostProcessor(@Value("\${error.path:/error}") errorPath: String): BeanPostProcessor { 243 | return object : BeanPostProcessor { 244 | override fun postProcessBeforeInitialization(bean: Any, beanName: String) = bean 245 | override fun postProcessAfterInitialization(bean: Any, beanName: String): Any { 246 | if (bean is ServerProperties) { 247 | bean.error.path = errorPath 248 | } 249 | 250 | return bean 251 | } 252 | } 253 | } 254 | ``` 255 | 256 | 정리 257 | --- 258 | 259 | 몇가지 문제점들이 있기는 했지만 어렵지 않게 슥슥 배워서 꽤 만족스럽게 예제를 만들어냈으니 무척이나 매력적인 언어인것 같다. 260 | CGLIB 기반 AOP 문제만 해결된다면 당장이라도 운영 환경에서 써볼만 하다고 생각된다. 261 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '1.5.3.RELEASE' 4 | kotlinVersion = '1.1.2-2' 5 | } 6 | repositories { 7 | mavenCentral() 8 | } 9 | dependencies { 10 | classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}" 11 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}" 12 | classpath "org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}" 13 | classpath "org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}" 14 | } 15 | } 16 | 17 | apply plugin: 'idea' 18 | apply plugin: 'kotlin' 19 | apply plugin: 'kotlin-spring' 20 | apply plugin: 'kotlin-jpa' 21 | apply plugin: 'org.springframework.boot' 22 | 23 | group 'kotlin-spring' 24 | version '1.0.0' 25 | 26 | sourceCompatibility = 1.8 27 | targetCompatibility = 1.8 28 | 29 | repositories { 30 | mavenCentral() 31 | } 32 | 33 | dependencies { 34 | compile("org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}") 35 | 36 | compile('org.springframework.boot:spring-boot-starter-web') 37 | compile("org.springframework.boot:spring-boot-starter-data-jpa") 38 | compile("com.fasterxml.jackson.module:jackson-module-kotlin:2.8.0") 39 | compile("org.springframework.security:spring-security-crypto") 40 | compile("com.h2database:h2") 41 | 42 | runtime("org.webjars:swagger-ui:3.0.5") 43 | 44 | testCompile('org.springframework.boot:spring-boot-starter-test') 45 | } 46 | 47 | task wrapper(type: Wrapper) { 48 | gradleVersion = '3.1' 49 | } 50 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri May 19 18:02:00 KST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-3.1-bin.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save ( ) { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'kotlin-spring-example' 2 | 3 | -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/ForumBootApplication.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum 2 | 3 | import org.ksug.forum.domain.PasswordHelper 4 | import org.springframework.boot.SpringApplication 5 | import org.springframework.boot.autoconfigure.SpringBootApplication 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Import 8 | import org.springframework.security.crypto.password.NoOpPasswordEncoder 9 | import org.springframework.security.crypto.password.PasswordEncoder 10 | import swagger.Swagger 11 | 12 | fun main(args: Array) { 13 | SpringApplication.run(ForumBootApplication::class.java, *args) 14 | } 15 | 16 | @SpringBootApplication 17 | @Import(Swagger::class) 18 | class ForumBootApplication { 19 | 20 | @Bean 21 | fun passwordEncoder(): PasswordEncoder { 22 | PasswordHelper.passwordEncoder = NoOpPasswordEncoder.getInstance() 23 | return PasswordHelper.passwordEncoder 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/Category.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain 2 | 3 | import java.util.* 4 | import javax.persistence.Entity 5 | import javax.persistence.GeneratedValue 6 | import javax.persistence.GenerationType.IDENTITY 7 | import javax.persistence.Id 8 | 9 | @Entity 10 | data class Category( var name: String 11 | , val createdAt: Date = Date()) { 12 | 13 | var updatedAt: Date = createdAt 14 | 15 | 16 | @Id @GeneratedValue(strategy = IDENTITY) 17 | val id: Long = 0 18 | 19 | 20 | fun rename(name: String) : Category { 21 | this.name = name 22 | this.updatedAt = Date() 23 | 24 | return this 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/DataNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain 2 | 3 | import org.springframework.context.MessageSourceResolvable 4 | 5 | abstract class DataNotFoundException : RuntimeException, MessageSourceResolvable { 6 | 7 | constructor(message: String) : super(message) { } 8 | constructor(message: String, cause: Throwable) : super(message, cause) { } 9 | 10 | override fun getArguments(): Array? = arrayOf() 11 | override fun getDefaultMessage(): String? = message 12 | 13 | } 14 | 15 | class CategoryNotFoundException(val categoryId: Long) : DataNotFoundException("카테고리(id: $categoryId)를 찾을 수 없습니다.") { 16 | 17 | override fun getCodes(): Array = arrayOf("error.categoryNotFound") 18 | override fun getArguments(): Array = arrayOf(categoryId) 19 | 20 | } 21 | 22 | class TopicNotFoundException(val topicId: Long) : DataNotFoundException("$topicId 번 주제를 찾을 수 없습니다.") { 23 | 24 | override fun getCodes(): Array = arrayOf("error.topicNotFound") 25 | override fun getArguments(): Array = arrayOf(topicId) 26 | 27 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/ForumException.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain 2 | 3 | import org.springframework.context.MessageSourceResolvable 4 | 5 | abstract class ForumException : RuntimeException, MessageSourceResolvable { 6 | 7 | constructor(message: String) : super(message) { } 8 | constructor(message: String, cause: Throwable) : super(message, cause) { } 9 | 10 | override fun getArguments(): Array? = arrayOf() 11 | override fun getDefaultMessage(): String? = message 12 | 13 | } 14 | 15 | class TopicCreationException(message: String) : ForumException(message) { 16 | 17 | override fun getCodes(): Array = arrayOf("topicCreationException") 18 | 19 | } 20 | 21 | class BadPasswordException(val target: Target) : ForumException("비밀번호가 일치하지 않습니다.") { 22 | 23 | override fun getCodes(): Array = arrayOf("error.badPassword") 24 | override fun getArguments(): Array = arrayOf(target) 25 | 26 | enum class Target { 27 | TOPIC, POST 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/Password.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain 2 | 3 | import org.springframework.security.crypto.password.PasswordEncoder 4 | import javax.persistence.Column 5 | import javax.persistence.Embeddable 6 | 7 | @Embeddable 8 | data class Password private constructor(@Column(name = "password") val encodedPassword: String) { 9 | 10 | fun matches(rawPassword: String) = PasswordHelper.matches(rawPassword, encodedPassword) 11 | 12 | 13 | companion object { 14 | 15 | fun wrap(rawPassword: CharSequence) = Password(PasswordHelper.encode(rawPassword)) 16 | fun empty() = Password("") 17 | 18 | } 19 | 20 | } 21 | 22 | object PasswordHelper { 23 | 24 | lateinit var passwordEncoder: PasswordEncoder 25 | 26 | fun encode(rawPassword: CharSequence) = passwordEncoder.encode(rawPassword) 27 | fun matches(rawPassword: CharSequence, encodedPassword: String) = passwordEncoder.matches(rawPassword, encodedPassword) 28 | 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/Post.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain 2 | 3 | import java.util.* 4 | import javax.persistence.Entity 5 | import javax.persistence.GeneratedValue 6 | import javax.persistence.GenerationType.IDENTITY; 7 | import javax.persistence.Id 8 | import javax.persistence.ManyToOne 9 | 10 | @Entity 11 | data class Post(var text: String 12 | , var author: String 13 | , val createdAt: Date = Date()) { 14 | 15 | var password: Password = Password.empty() 16 | var updatedAt: Date = createdAt.clone() as Date 17 | 18 | 19 | @Id @GeneratedValue(strategy = IDENTITY) 20 | val id: Long? = null 21 | 22 | @ManyToOne(optional = false) 23 | private var topic: Topic? = null 24 | 25 | 26 | constructor(text: String, author: String, topic: Topic) : this(text, author) { 27 | this.topic = topic 28 | } 29 | 30 | fun checkPassword(rawPassword: String) = 31 | if (!password.matches(rawPassword)) throw BadPasswordException(BadPasswordException.Target.POST) else this 32 | 33 | fun edit(text: String, rawPassword: String): Post { 34 | checkPassword(rawPassword) 35 | 36 | this.text = text 37 | this.updatedAt = Date() 38 | 39 | return this 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/Topic.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain 2 | 3 | import org.ksug.forum.domain.BadPasswordException.Target.TOPIC 4 | import java.util.* 5 | import javax.persistence.* 6 | import javax.persistence.CascadeType.ALL 7 | import javax.persistence.GenerationType.IDENTITY 8 | 9 | @Entity 10 | data class Topic(var title: String 11 | , var author: String 12 | , val createdAt: Date = Date()) { 13 | 14 | var password: Password = Password.empty() 15 | var updatedAt: Date = createdAt.clone() as Date 16 | 17 | 18 | @Id @GeneratedValue(strategy = IDENTITY) 19 | val id: Long? = null 20 | 21 | @ManyToOne(optional = false) 22 | private var category: Category? = null 23 | 24 | @OneToMany(mappedBy = "topic", cascade = arrayOf(ALL), orphanRemoval = true) 25 | private var posts: MutableList = ArrayList() 26 | 27 | 28 | constructor(title: String, author: String, rawPassword: String, category: Category) : this(title, author) { 29 | this.password = Password.wrap(rawPassword) 30 | this.category = category 31 | } 32 | 33 | fun checkPassword(rawPassword: String) = 34 | if (!password.matches(rawPassword)) throw BadPasswordException(TOPIC) else this 35 | 36 | fun edit(title: String, author: String, rawPassword: String) : Topic { 37 | checkPassword(rawPassword) 38 | 39 | this.title = title 40 | this.author = author 41 | this.updatedAt = Date() 42 | 43 | return this 44 | } 45 | 46 | fun edit(title: String, rawPassword: String): Topic = edit(title, this.author, rawPassword) 47 | 48 | fun writePost(text: String, author: String): Post { 49 | val newPost = Post(text, author, this) 50 | this.posts.add(newPost) 51 | this.updatedAt = Date() 52 | 53 | return newPost 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/module/ForumService.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain.module 2 | 3 | import org.hibernate.validator.constraints.NotEmpty 4 | import org.ksug.forum.domain.* 5 | import org.ksug.forum.domain.repository.CategoryRepository 6 | import org.ksug.forum.domain.repository.PostRepository 7 | import org.ksug.forum.domain.repository.TopicRepository 8 | import org.springframework.stereotype.Service 9 | import org.springframework.transaction.annotation.Transactional 10 | import javax.validation.constraints.NotNull 11 | 12 | @Service 13 | @Transactional 14 | class ForumService constructor(var categoryRepository: CategoryRepository, var topicRepository: TopicRepository, var postRepository: PostRepository) { 15 | 16 | fun categories() = categoryRepository.findAll() 17 | fun loadCategory(categoryId: Long) = categoryRepository.findOne(categoryId) ?: throw CategoryNotFoundException(categoryId) 18 | 19 | 20 | fun loadTopics(category: Category) = topicRepository.findByCategory(category) 21 | fun loadTopic(topicId: Long) = topicRepository.findOne(topicId) ?: throw TopicNotFoundException(topicId) 22 | 23 | fun write(form: TopicForm) = topicRepository.save(form.create()) 24 | fun edit(form: TopicForm) = form.update(loadTopic(form.id ?: Long.MIN_VALUE)) 25 | fun delete(topic: Topic, rawPassword: String) = topicRepository.delete(topic.checkPassword(rawPassword)) 26 | 27 | 28 | fun loadPosts(topic: Topic) = postRepository.findByTopic(topic) 29 | 30 | } 31 | 32 | data class TopicForm ( 33 | @field:NotNull(groups = arrayOf(Edit::class)) 34 | var id: Long? = null, 35 | 36 | @field:NotEmpty(groups = arrayOf(Write::class, Edit::class)) 37 | var title: String = "", 38 | 39 | @field:NotEmpty(groups = arrayOf(Write::class, Edit::class)) 40 | var password: String = "", 41 | 42 | @field:NotEmpty(groups = arrayOf(Write::class)) 43 | var author: String = "", 44 | 45 | @field:NotNull(groups = arrayOf(Write::class, Edit::class)) 46 | var category: Category? = null 47 | ) { 48 | 49 | interface Write 50 | interface Edit 51 | 52 | internal fun create() = Topic(title, author, password, category ?: throw TopicCreationException("카테고리가 없습니다.")) 53 | internal fun update(target: Topic): Topic { 54 | if (author.isEmpty()) { 55 | target.edit(title, password) 56 | } else { 57 | target.edit(title, author, password) 58 | } 59 | 60 | return target 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/repository/CategoryRepository.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain.repository 2 | 3 | import org.ksug.forum.domain.Category 4 | import org.springframework.data.jpa.repository.JpaRepository 5 | 6 | interface CategoryRepository : JpaRepository { 7 | 8 | fun findByName(name: String): Category? 9 | 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/repository/PostRepository.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain.repository 2 | 3 | import org.ksug.forum.domain.Post 4 | import org.ksug.forum.domain.Topic 5 | import org.springframework.data.repository.PagingAndSortingRepository 6 | 7 | interface PostRepository : PagingAndSortingRepository { 8 | 9 | fun findByTopic(topic: Topic): List 10 | 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/domain/repository/TopicRepository.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.domain.repository 2 | 3 | import org.ksug.forum.domain.Category 4 | import org.ksug.forum.domain.Topic 5 | import org.springframework.data.jpa.repository.JpaRepository 6 | 7 | interface TopicRepository : JpaRepository { 8 | 9 | fun findByCategory(category: Category): List 10 | 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/web/ForumRestController.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.web 2 | 3 | import org.ksug.forum.domain.module.ForumService 4 | import org.ksug.forum.domain.module.TopicForm 5 | import org.ksug.forum.web.data.PostData 6 | import org.ksug.forum.web.data.TopicData 7 | import org.springframework.http.MediaType 8 | import org.springframework.validation.BindException 9 | import org.springframework.validation.BindingResult 10 | import org.springframework.validation.beanvalidation.SpringValidatorAdapter 11 | import org.springframework.web.bind.annotation.* 12 | 13 | @RestController 14 | @RequestMapping("/forum") 15 | class ForumRestController constructor(var forumService: ForumService, var validator: SpringValidatorAdapter) { 16 | 17 | @GetMapping("/categories") 18 | fun categories() = forumService.categories() 19 | 20 | @GetMapping("/categories/{categoryId}/topics") 21 | fun topics(@PathVariable categoryId: Long) : List { 22 | val category = forumService.loadCategory(categoryId) 23 | 24 | return forumService.loadTopics(category).map { TopicData.of(it) } 25 | } 26 | 27 | @PostMapping(path = arrayOf("/categories/{categoryId}/topics"), consumes = arrayOf(MediaType.APPLICATION_JSON_VALUE)) 28 | fun writeTopic(@PathVariable categoryId: Long, @RequestBody form: TopicForm, bindingResult: BindingResult) : TopicData { 29 | form.category = forumService.loadCategory(categoryId) 30 | 31 | validator.validateAndThrow(form, bindingResult, TopicForm.Write::class.java) 32 | 33 | return TopicData.of(forumService.write(form)) 34 | } 35 | 36 | @PutMapping(path = arrayOf("/categories/{categoryId}/topics/{topicId}"), consumes = arrayOf(MediaType.APPLICATION_JSON_VALUE)) 37 | fun editTopic(@PathVariable categoryId: Long, @PathVariable topicId: Long, @RequestBody form: TopicForm, bindingResult: BindingResult) : TopicData { 38 | form.id = topicId 39 | form.category = forumService.loadCategory(categoryId) 40 | 41 | validator.validateAndThrow(form, bindingResult, TopicForm.Edit::class.java) 42 | 43 | return TopicData.of(forumService.edit(form)) 44 | } 45 | 46 | @DeleteMapping(path = arrayOf("/categories/{categoryId}/topics/{topicId}"), consumes = arrayOf(MediaType.APPLICATION_JSON_VALUE)) 47 | fun deleteTopic(@PathVariable categoryId: Long, @PathVariable topicId: Long, @RequestBody command: DeleteCommand) { 48 | forumService.loadCategory(categoryId) 49 | val topic = forumService.loadTopic(topicId) 50 | 51 | forumService.delete(topic, command.password) 52 | } 53 | 54 | @GetMapping("/categories/{categoryId}/topics/{topicId}/posts") 55 | fun posts(@PathVariable categoryId: Long, @PathVariable topicId: Long) : List { 56 | forumService.loadCategory(categoryId) 57 | val topic = forumService.loadTopic(topicId) 58 | 59 | return forumService.loadPosts(topic).map { PostData.of(it) } 60 | } 61 | 62 | 63 | fun SpringValidatorAdapter.validateAndThrow(target: Any, bindingResult: BindingResult, vararg validationHints: Any) { 64 | validate(target, bindingResult) 65 | validate(target, bindingResult, *validationHints) 66 | 67 | if (bindingResult.hasErrors()) { 68 | throw BindException(bindingResult) 69 | } 70 | } 71 | 72 | } 73 | 74 | data class DeleteCommand(val password: String = "") -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/web/ServiceInfoRestController.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.web 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.web.bind.annotation.RequestMapping 5 | import org.springframework.web.bind.annotation.RestController 6 | 7 | @RestController 8 | @ConfigurationProperties(prefix = "service.info") 9 | class ServiceInfoRestController { 10 | 11 | var name: String = "unknown" 12 | var version: String = "1.0.0" 13 | 14 | @RequestMapping("/info") 15 | fun info() = hashMapOf("name" to name, "version" to version) 16 | 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/web/WebConfig.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.web 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import org.springframework.beans.factory.annotation.Autowired 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.core.MethodParameter 8 | import org.springframework.http.MediaType 9 | import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean 10 | import org.springframework.web.bind.support.WebDataBinderFactory 11 | import org.springframework.web.context.request.NativeWebRequest 12 | import org.springframework.web.context.request.ServletRequestAttributes 13 | import org.springframework.web.method.support.HandlerMethodArgumentResolver 14 | import org.springframework.web.method.support.ModelAndViewContainer 15 | import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer 16 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter 17 | import java.text.SimpleDateFormat 18 | import javax.servlet.http.HttpServletRequest 19 | 20 | @Configuration 21 | class WebConfig : WebMvcConfigurerAdapter() { 22 | 23 | override fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) { 24 | configurer.ignoreAcceptHeader(true) 25 | .favorPathExtension(false) 26 | .favorParameter(false) 27 | .defaultContentType(MediaType.APPLICATION_JSON) 28 | } 29 | 30 | override fun addArgumentResolvers(argumentResolvers: MutableList) { 31 | argumentResolvers.add(object : HandlerMethodArgumentResolver { 32 | override fun supportsParameter(parameter: MethodParameter): Boolean = 33 | parameter.parameterType.isAssignableFrom(ServletRequestAttributes::class.java) 34 | 35 | override fun resolveArgument(parameter: MethodParameter, mavContainer: ModelAndViewContainer, webRequest: NativeWebRequest, dataBindFactory: WebDataBinderFactory): Any = 36 | ServletRequestAttributes(webRequest.getNativeRequest(HttpServletRequest::class.java)) 37 | 38 | }) 39 | } 40 | 41 | @Autowired 42 | fun setUpObjectMapper(mapper: ObjectMapper) { 43 | mapper.dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss") 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/web/data/DataModels.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.web.data 2 | 3 | import org.ksug.forum.domain.Post 4 | import org.ksug.forum.domain.Topic 5 | import java.util.* 6 | 7 | data class TopicData( val id: Long? 8 | , val title: String 9 | , val author: String 10 | , val createdAt: Date 11 | , val updatedAt: Date) { 12 | 13 | companion object { 14 | fun of(source: Topic) = TopicData(source.id, source.title, source.author, source.createdAt, source.updatedAt) 15 | } 16 | 17 | } 18 | 19 | class PostData( val id: Long? 20 | , val text: String 21 | , val author: String 22 | , val createdAt: Date 23 | , val updatedAt: Date) { 24 | 25 | companion object { 26 | fun of(source: Post) = PostData(source.id, source.text, source.author, source.createdAt, source.updatedAt) 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/web/support/DefaultErrorRestController.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.web.support 2 | 3 | import org.springframework.beans.factory.annotation.Autowired 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.beans.factory.config.BeanPostProcessor 6 | import org.springframework.boot.autoconfigure.web.AbstractErrorController 7 | import org.springframework.boot.autoconfigure.web.ErrorAttributes 8 | import org.springframework.boot.autoconfigure.web.ErrorProperties 9 | import org.springframework.boot.autoconfigure.web.ServerProperties 10 | import org.springframework.context.MessageSource 11 | import org.springframework.context.annotation.Bean 12 | import org.springframework.context.annotation.Configuration 13 | import org.springframework.http.HttpStatus 14 | import org.springframework.http.ResponseEntity 15 | import org.springframework.web.bind.annotation.RequestMapping 16 | import org.springframework.web.bind.annotation.RestController 17 | import org.springframework.web.context.request.ServletRequestAttributes 18 | import java.util.* 19 | 20 | @RestController 21 | class DefaultErrorController @Autowired constructor(var serverProperties: ServerProperties, var errorAttributes: ErrorAttributes, var messageSource: MessageSource) : AbstractErrorController(errorAttributes) { 22 | 23 | companion object { 24 | const val ERROR_PATH = "\${error.path:/error}" 25 | const val ERROR_DEFAULT_MESSAGE = "No message available." 26 | } 27 | 28 | @RequestMapping(ERROR_PATH) 29 | fun error(requestAttributes: ServletRequestAttributes, locale: Locale): ResponseEntity> { 30 | val body = errorAttributes.getErrorAttributes(requestAttributes, isIncludeStackTrace(requestAttributes)) 31 | val status = when(body["status"]) { 32 | is Int -> HttpStatus.valueOf(body["status"] as Int) 33 | else -> HttpStatus.INTERNAL_SERVER_ERROR 34 | } 35 | 36 | body["message"] = messageSource.getMessage("error.$status", null, ERROR_DEFAULT_MESSAGE, locale) 37 | 38 | return ResponseEntity.status(status).body(body) 39 | } 40 | 41 | fun isIncludeStackTrace(requestAttributes: ServletRequestAttributes) = 42 | when(serverProperties.error.includeStacktrace) { 43 | ErrorProperties.IncludeStacktrace.ALWAYS -> true 44 | ErrorProperties.IncludeStacktrace.ON_TRACE_PARAM -> requestAttributes.request.getParameter("trace")?.toLowerCase()?.equals("true") ?: false 45 | else -> false 46 | } 47 | 48 | override fun getErrorPath() = serverProperties.error.path 49 | 50 | } 51 | 52 | @Configuration 53 | class ErrorPropertiesSupport { 54 | 55 | @Bean 56 | fun errorPropertiesPostProcessor(@Value(DefaultErrorController.ERROR_PATH) errorPath: String): BeanPostProcessor { 57 | return object : BeanPostProcessor { 58 | override fun postProcessBeforeInitialization(bean: Any, beanName: String) = bean 59 | override fun postProcessAfterInitialization(bean: Any, beanName: String): Any { 60 | if (bean is ServerProperties) { 61 | bean.error.path = errorPath 62 | } 63 | 64 | return bean 65 | } 66 | } 67 | } 68 | 69 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/web/support/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.web.support 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.http.ResponseEntity 5 | import org.springframework.util.MultiValueMap 6 | import java.util.* 7 | 8 | data class ErrorResponse(val status:HttpStatus 9 | , val error:String 10 | , val message:String 11 | , val timestamp: Date 12 | , val bindingErrors: List) { 13 | 14 | constructor(status:HttpStatus, message:String, bindingErrors: List) : this(status, status.reasonPhrase, message, Date(), bindingErrors) 15 | constructor(status:HttpStatus, error:String, message:String) : this(status, error, message, Date(), ArrayList()) 16 | constructor(status:HttpStatus, message:String) : this(status, status.reasonPhrase, message, Date(), ArrayList()) 17 | 18 | } 19 | 20 | class ErrorResponseEntity: ResponseEntity { 21 | 22 | constructor(body:ErrorResponse) : super(body, body.status) 23 | constructor(body:ErrorResponse, headers: MultiValueMap) : super(body, headers, body.status) 24 | 25 | 26 | companion object { 27 | 28 | fun badReqeust(message:String) = ErrorResponseEntity(ErrorResponse(HttpStatus.BAD_REQUEST, message)) 29 | fun badReqeust(message:String, bindingErrors:List) = ErrorResponseEntity(ErrorResponse(HttpStatus.BAD_REQUEST, message, bindingErrors)) 30 | fun notFound(message:String) = ErrorResponseEntity(ErrorResponse(HttpStatus.NOT_FOUND, message)) 31 | fun serverError(message:String) = ErrorResponseEntity(ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, message)) 32 | 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/main/kotlin/org/ksug/forum/web/support/ExceptionHandlers.kt: -------------------------------------------------------------------------------- 1 | package org.ksug.forum.web.support 2 | 3 | import org.ksug.forum.domain.DataNotFoundException 4 | import org.ksug.forum.domain.ForumException 5 | import org.ksug.forum.web.support.ErrorResponseEntity.Companion.badReqeust 6 | import org.ksug.forum.web.support.ErrorResponseEntity.Companion.notFound 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.context.MessageSource 9 | import org.springframework.validation.BindException 10 | import org.springframework.validation.BindingResult 11 | import org.springframework.web.bind.MethodArgumentNotValidException 12 | import org.springframework.web.bind.annotation.ControllerAdvice 13 | import org.springframework.web.bind.annotation.ExceptionHandler 14 | import java.util.* 15 | 16 | @ControllerAdvice 17 | class ExceptionHandlers @Autowired constructor(var messageSource: MessageSource) { 18 | 19 | @ExceptionHandler(ForumException::class) 20 | fun forumException(exception: ForumException, locale: Locale) = 21 | badReqeust(messageSource.getMessage(exception, locale)) 22 | 23 | @ExceptionHandler(DataNotFoundException::class) 24 | fun resourceNotFoundException(exception: DataNotFoundException, locale: Locale) = 25 | notFound(messageSource.getMessage(exception, locale)) 26 | 27 | @ExceptionHandler(MethodArgumentNotValidException::class) 28 | fun methodArgumentNotValidException(exception: MethodArgumentNotValidException, locale: Locale) = 29 | badReqeust("입력값이 올바르지 않습니다.", mapBindingResult(exception.bindingResult, locale)); 30 | 31 | @ExceptionHandler(BindException::class) 32 | fun bindException(exception: BindException, locale: Locale) = 33 | badReqeust("입력값이 올바르지 않습니다.", mapBindingResult(exception.bindingResult, locale)); 34 | 35 | fun mapBindingResult(bindingResult: BindingResult, locale: Locale) = 36 | bindingResult.allErrors.map { messageSource.getMessage(it, locale) } 37 | 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/swagger/Swagger.kt: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import org.springframework.boot.web.servlet.ServletRegistrationBean 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.web.context.ConfigurableWebApplicationContext 7 | import org.springframework.web.context.WebApplicationContext 8 | import org.springframework.web.context.support.AnnotationConfigWebApplicationContext 9 | import org.springframework.web.servlet.DispatcherServlet 10 | 11 | @Configuration 12 | class Swagger { 13 | 14 | @Bean 15 | fun swagger(): ServletRegistrationBean { 16 | val registrationBean = ServletRegistrationBean(SwaggerDispatcherServlet(), "/swagger/*") 17 | registrationBean.setName("swagger") 18 | 19 | return registrationBean 20 | } 21 | 22 | 23 | internal inner class SwaggerDispatcherServlet : DispatcherServlet() { 24 | init { 25 | val webApplicationContext = AnnotationConfigWebApplicationContext() 26 | webApplicationContext.register(SwaggerUIConfig::class.java) 27 | 28 | setApplicationContext(webApplicationContext) 29 | } 30 | 31 | override fun initWebApplicationContext(): WebApplicationContext { 32 | val wac = webApplicationContext 33 | if (wac is ConfigurableWebApplicationContext) { 34 | if (!wac.isActive) { 35 | configureAndRefreshWebApplicationContext(wac) 36 | } 37 | } 38 | 39 | return wac 40 | } 41 | 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/swagger/SwaggerUIConfig.kt: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.web.servlet.config.annotation.EnableWebMvc 5 | import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter 7 | 8 | @Configuration 9 | @EnableWebMvc 10 | class SwaggerUIConfig : WebMvcConfigurerAdapter() { 11 | 12 | override fun addResourceHandlers(registry: ResourceHandlerRegistry) { 13 | registry.addResourceHandler("/webjars/**") 14 | .addResourceLocations("classpath:/META-INF/resources/webjars/") 15 | registry.addResourceHandler("/**") 16 | .addResourceLocations("classpath:/swagger/") 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | service.info: 2 | name: Restful API Service 3 | version: 1.0.0 4 | 5 | error.path: /errorPage 6 | server.error.includeStacktrace: ON_TRACE_PARAM 7 | 8 | spring.jpa.show-sql: true 9 | 10 | logging.level: 11 | org.ksug: WARN 12 | org.springframework: INFO 13 | org.springframework.orm: DEBUG 14 | org.springframework.web: INFO 15 | org.springframework.boot: INFO -------------------------------------------------------------------------------- /src/main/resources/data.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO CATEGORY(name, created_at, updated_at) VALUES('Java', CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); 2 | INSERT INTO CATEGORY(name, created_at, updated_at) VALUES('Kotlin', CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); 3 | 4 | 5 | INSERT INTO TOPIC(title, author, password, category_id, created_at, updated_at) VALUES('Java 8은 많이 사용하시나요?', 'arawn', 'password', 1, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); 6 | INSERT INTO POST(text, author, password, topic_id, created_at, updated_at) VALUES('저는 써요!!!', 'wangeun.lee', 'password', 1, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); 7 | INSERT INTO POST(text, author, password, topic_id, created_at, updated_at) VALUES('Scala 쓰세요~', 'holyeye', 'password', 1, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); 8 | INSERT INTO POST(text, author, password, topic_id, created_at, updated_at) VALUES('엑셀과 파워포인트만 쓰고 있습니다.', 'fupfin', 'password', 1, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); 9 | 10 | 11 | INSERT INTO TOPIC(title, author, password, category_id, created_at, updated_at) VALUES('Kotlin으로 만들어진 서비스가 있나요?', 'arawn', 'password', 2, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); 12 | INSERT INTO TOPIC(title, author, password, category_id, created_at, updated_at) VALUES('Kotlin으로 웹 애플리케이션을 만들 수 있나요?', 'arawn', 'password', 2, CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP()); -------------------------------------------------------------------------------- /src/main/resources/messages.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arawn/kotlin-spring-example/1f4e2a23733089d61d4b06ef17de7273509fa11b/src/main/resources/messages.properties -------------------------------------------------------------------------------- /src/main/resources/messages_en_US.properties: -------------------------------------------------------------------------------- 1 | error.404=unable to find the requested resource. 2 | error.415=unsupported media type explained (you can accept application/json) 3 | error.500=unknown error has occurred in the server. -------------------------------------------------------------------------------- /src/main/resources/messages_ko_KR.properties: -------------------------------------------------------------------------------- 1 | error.404=요청한 자원을 찾을 수 없습니다. 2 | error.415=지원하지 않는 미디어 형식입니다. (Content-Type: application/json 을 사용하세요.) 3 | error.500=요청을 처리하는 중 서버내에서 알 수 없는 오류가 발생했습니다. 4 | 5 | 6 | error.categoryNotFound=카테고리(id: {0})를 찾을 수 없습니다. 7 | error.badPassword={0} 비밀번호가 일치하지 않습니다. 8 | 9 | 10 | error.postNotFound={0}번 게시물을 찾을 수 없습니다. 11 | 12 | NotEmpty={0}는 필수 입력값입니다. 13 | 14 | NotNull.topicForm.category=카테고리(category)는 필수 입력값 입니다. 15 | NotEmpty.topicForm.title=제목(title)은 필수 입력값 입니다. 16 | NotEmpty.topicForm.author=작성자(author)는 필수 입력값 입니다. 17 | NotEmpty.topicForm.password=비밀번호(password)는 필수 입력값 입니다. -------------------------------------------------------------------------------- /src/main/resources/swagger/forum-spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "title": "Building REST services with Kotlin and Spring Boot, Spring Data JPA" 5 | }, 6 | "host": "localhost:8080", 7 | "basePath": "/forum", 8 | "schemes": ["http"], 9 | "consumes": ["application/json"], 10 | "produces": ["application/json"], 11 | "paths": { 12 | "/categories" : { 13 | "get" : { 14 | "summary" : "카테고리(Category)를 조회한다.", 15 | "parameters" : [ ], 16 | "responses" : { 17 | "200" : { 18 | "description" : "successful operation" 19 | } 20 | }, 21 | "tags" : [ "forum" ] 22 | } 23 | }, 24 | "/categories/{categoryId}/topics": { 25 | "get": { 26 | "summary": "카테고리에 등록된 주제(Topic)를 조회한다.", 27 | "parameters": [{ 28 | "name": "categoryId", 29 | "type": "integer", 30 | "in": "path", 31 | "format": "int64", 32 | "required": true, 33 | "description": "카테고리 식별번호" 34 | }], 35 | "responses" : { 36 | "200" : { 37 | "description" : "successful operation" 38 | }, 39 | "404" : { 40 | "description" : "resource not found" 41 | } 42 | }, 43 | "tags": ["forum"] 44 | }, 45 | "post": { 46 | "summary": "카테고리에 주제(Topic)를 등록한다.", 47 | "parameters": [{ 48 | "name": "categoryId", 49 | "type": "integer", 50 | "in": "path", 51 | "format": "int64", 52 | "required": true, 53 | "description": "카테고리 식별번호" 54 | },{ 55 | "name": "data", 56 | "in": "body", 57 | "required": true, 58 | "description": "등록할 주제 데이터", 59 | "schema": { 60 | "type": "object", 61 | "properties": { 62 | "title": { "type": "string" }, 63 | "author": { "type": "string" }, 64 | "password": { "type": "string" } 65 | }, 66 | "required": [ "title", "author", "password" ], 67 | "example": { 68 | "title": "주제를 등록합니다.", 69 | "author": "arawn", 70 | "password": "lol" 71 | } 72 | } 73 | }], 74 | "responses" : { 75 | "200" : { 76 | "description" : "successful operation" 77 | }, 78 | "400" : { 79 | "description" : "invalid input" 80 | }, 81 | "404" : { 82 | "description" : "resource not found" 83 | } 84 | }, 85 | "tags": ["forum"] 86 | } 87 | }, 88 | "/categories/{categoryId}/topics/{topicId}": { 89 | "put": { 90 | "summary": "주제(Topic)를 수정한다.", 91 | "parameters": [{ 92 | "name": "categoryId", 93 | "type": "integer", 94 | "in": "path", 95 | "format": "int64", 96 | "required": true, 97 | "description": "카테고리 식별번호" 98 | },{ 99 | "name": "topicId", 100 | "type": "integer", 101 | "in": "path", 102 | "format": "int64", 103 | "required": true, 104 | "description": "주제 식별번호" 105 | },{ 106 | "name": "data", 107 | "in": "body", 108 | "required": true, 109 | "description": "수정할 주제 데이터", 110 | "schema": { 111 | "type": "object", 112 | "properties": { 113 | "title": { "type": "string" }, 114 | "author": { "type": "string" }, 115 | "password": { "type": "string" } 116 | }, 117 | "required": [ "title", "password" ], 118 | "example": { 119 | "title": "주제를 수정합니다.", 120 | "author": "arawn", 121 | "password": "lol" 122 | } 123 | } 124 | }], 125 | "responses" : { 126 | "200" : { 127 | "description" : "successful operation" 128 | }, 129 | "400" : { 130 | "description" : "invalid input" 131 | }, 132 | "404" : { 133 | "description" : "resource not found" 134 | } 135 | }, 136 | "tags": ["forum"] 137 | }, 138 | "delete": { 139 | "summary": "주제(Topic)를 삭제한다.", 140 | "parameters": [{ 141 | "name": "categoryId", 142 | "type": "integer", 143 | "in": "path", 144 | "format": "int64", 145 | "required": true, 146 | "description": "카테고리 식별번호" 147 | },{ 148 | "name": "topicId", 149 | "type": "integer", 150 | "in": "path", 151 | "format": "int64", 152 | "required": true, 153 | "description": "주제 식별번호" 154 | },{ 155 | "name": "data", 156 | "in": "body", 157 | "required": true, 158 | "description": "삭제할 주제 데이터", 159 | "schema": { 160 | "type": "object", 161 | "properties": { 162 | "password": { "type": "string" } 163 | }, 164 | "required": [ "password" ], 165 | "example": { 166 | "password": "lol" 167 | } 168 | } 169 | }], 170 | "responses" : { 171 | "200" : { 172 | "description" : "successful operation" 173 | }, 174 | "404" : { 175 | "description" : "resource not found" 176 | } 177 | }, 178 | "tags": ["forum"] 179 | } 180 | }, 181 | "/categories/{categoryId}/topics/{topicId}/posts": { 182 | "get": { 183 | "summary": "주제에 등록된 글(Post)을 조회한다.", 184 | "parameters": [{ 185 | "name": "categoryId", 186 | "type": "integer", 187 | "in": "path", 188 | "format": "int64", 189 | "required": true, 190 | "description": "카테고리 식별번호" 191 | },{ 192 | "name": "topicId", 193 | "type": "integer", 194 | "in": "path", 195 | "format": "int64", 196 | "required": true, 197 | "description": "주제 식별번호" 198 | }], 199 | "responses" : { 200 | "200" : { 201 | "description" : "successful operation" 202 | } 203 | }, 204 | "tags": ["forum"] 205 | } 206 | } 207 | }, 208 | "responses": { 209 | "error": { 210 | "description": "API 요청 중 오류가 발생했습니다. 오류 내용을 확인하세요.", 211 | "schema" : { 212 | "$ref" : "#/definitions/Error" 213 | } 214 | } 215 | }, 216 | "definitions": { 217 | "Error": { 218 | "type": "object", 219 | "properties": { 220 | "error": { 221 | "type": "string" 222 | }, 223 | "message": { 224 | "type": "string" 225 | } 226 | } 227 | } 228 | }, 229 | "tags": [{ 230 | "name": "forum", 231 | "description": "Forum APIs" 232 | }] 233 | } -------------------------------------------------------------------------------- /src/main/resources/swagger/ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Swagger UI 7 | 8 | 9 | 10 | 11 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 | 67 | 68 | 69 | 89 | 90 | 91 | --------------------------------------------------------------------------------