├── .gitignore ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── rest-api ├── Makefile ├── bin │ └── version.sh ├── build.gradle ├── docker │ ├── app │ │ ├── Dockerfile │ │ └── check-health.sh │ ├── docker-compose.yml │ └── postgres │ │ ├── Dockerfile │ │ ├── db-dumps │ │ └── dump.sql │ │ └── docker-entrypoint-initdb.d │ │ ├── 001-init.sh │ │ └── 002-import_dump.sh └── src │ └── main │ ├── kotlin │ └── com │ │ └── example │ │ ├── DatabaseInitializer.kt │ │ ├── RestApiApplication.kt │ │ ├── api │ │ ├── ApiConfig.kt │ │ ├── bookstore │ │ │ ├── BookStoreApiController.kt │ │ │ ├── apiModel.kt │ │ │ └── domain │ │ │ │ └── repo │ │ │ │ ├── authorRepo.kt │ │ │ │ └── bookRepo.kt │ │ ├── common │ │ │ ├── EntityAlreadyExistException.kt │ │ │ └── EntityNotFoundException.kt │ │ └── tweeter │ │ │ ├── TweeterApiController.kt │ │ │ └── domain │ │ │ └── TweeterRepo.kt │ │ ├── config │ │ ├── Jackson.kt │ │ ├── Swagger.kt │ │ └── jooq │ │ │ ├── JOOQToSpringExceptionTransformer.kt │ │ │ └── JooqPersistentContextConfig.kt │ │ └── util │ │ └── sql │ │ └── sqlExtensions.kt │ └── resources │ ├── application-local.yml │ ├── application.yml │ └── logback.xml └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | ### osx 2 | .DS_STORE 3 | 4 | ### idea, gradle 5 | .idea/ 6 | *.iml 7 | 8 | .gradle/ 9 | **/build/** 10 | **/out/** 11 | 12 | 13 | ### custom 14 | **/version.txt 15 | **/src/main/generated/** 16 | 17 | ## jooq codegen 18 | rest-api/src/main/generated 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spring-kotlin-jooq 2 | playground for spring-boot, kotlin, jooq 3 | 4 | ## build 5 | 6 | ``` 7 | $ make -C rest-api app.build 8 | 9 | ``` 10 | 11 | ## run local db (docker) 12 | 13 | ``` 14 | $ make -C rest-api db.local.up 15 | 16 | ``` 17 | 18 | 19 | ## the build pipeline - what is it doing? 20 | 21 | - it spawns a postgres (docker) 22 | - it connects to postgres and generates code based on the actual db schema 23 | - compile source code into jar 24 | - put jar into a docker image -> thats your artefact 25 | 26 | 27 | ## findings 28 | 29 | - jooq is great, but no silver bullet 30 | - the jooq codegen generates java 31 | - the generated java is not null-safe 32 | - nullable db columns will be treated the same way as non-nullable columns by the code-gen 33 | --> We need either jsr305 annotations provided by the code-gen or kotlin data classes (with nullable types) 34 | 35 | - generated record-classes are mutable (ActiveRecord) 36 | --> Its hard to write idiomatic kotlin at the moment. Hope future versions of jooq may support idiomatic kotlin nicer. 37 | 38 | - And intellij ... 39 | Working in Intellij gets really slow (might be related to all this method overloading and varargs being used by jooq generated classes) 40 | Solution ? : mark directory "src/main/generated" as "excluded" seems to solve it 41 | 42 | see: 43 | - https://blog.jooq.org/tag/null/ 44 | - https://github.com/jOOQ/jOOQ/issues/6345 45 | 46 | 47 | 48 | ## This example project is based on ... 49 | - https://github.com/making/spring-boot-db-samples 50 | 51 | ## Whats wrong with orm, jpa, hibernate and in-memory h2-db these days ? 52 | 53 | There is no silver bullet. 54 | It's born in a world of single-instance big fat application servers. 55 | It hardly fits into a modern world of: 56 | 57 | - functional programming: e.g. immutable threadsafe pojos / data classes 58 | - CQRS and eventsourcing 59 | - horizontal scaling of polyglot microservices 60 | 61 | Make up your mind ... 62 | 63 | - How hibernate ruined Monica's career: https://www.toptal.com/java/how-hibernate-ruined-my-career 64 | - Why do I hate hibernate: https://de.slideshare.net/alimenkou/why-do-i-hate-hibernate-12998784 65 | - ORM is an antipattern: http://seldo.com/weblog/2011/08/11/orm_is_an_antipattern 66 | - Opinionated JPA: https://leanpub.com/opinionatedjpa/read 67 | - Lightweight ORM, do it yourself: https://blog.philipphauer.de/do-it-yourself-orm-alternative-hibernate-drawbacks/ 68 | - Don't use H2 db for testing, use docker: https://blog.philipphauer.de/dont-use-in-memory-databases-tests-h2/ 69 | 70 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | task wrapper(type: Wrapper) { 2 | gradleVersion = '4.2' 3 | } 4 | 5 | 6 | subprojects { 7 | buildscript { 8 | repositories { 9 | mavenCentral() 10 | jcenter() 11 | maven { 12 | url "https://plugins.gradle.org/m2/" 13 | } 14 | } 15 | } 16 | 17 | repositories { 18 | mavenCentral() 19 | jcenter() 20 | } 21 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bastman/spring-kotlin-jooq/14411d39e00c248bec549b3faeedbfe9a56d0686/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.2-bin.zip 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /rest-api/Makefile: -------------------------------------------------------------------------------- 1 | SERVICE_NAME=spring-kotlin-jooq-rest-api 2 | SERVICE_VERSION ?= $(shell test -n "$$(cat version.txt)" && cat version.txt || ./bin/version.sh | tee version.txt) 3 | 4 | 5 | DOCKER_FILE_DB=docker/postgres/Dockerfile 6 | DOCKER_FILE_APP=docker/app/Dockerfile 7 | 8 | DOCKER_TAG_LOCAL_DB=local/spring-kotlin-jooq-db:latest 9 | DOCKER_TAG_LOCAL_APP=local/$(SERVICE_NAME):$(SERVICE_VERSION) 10 | 11 | DOCKER_COMPOSE_FILE="docker/docker-compose.yml" 12 | DOCKER_COMPOSE_SVC_DB_LOCAL="spring-kotlin-jooq-db-local" 13 | DOCKER_COMPOSE_SVC_DB_CI="spring-kotlin-jooq-db-ci" 14 | 15 | 16 | GRADLE_CMD=../gradlew 17 | 18 | print-%: ; @echo $*=$($*) 19 | guard-%: 20 | @test ${${*}} || (echo "FAILED! Environment variable $* not set " && exit 1) 21 | @echo "-> use env var $* = ${${*}}"; 22 | 23 | .PHONY : help 24 | help : Makefile 25 | @sed -n 's/^##//p' $< 26 | 27 | version: 28 | @echo $(SERVICE_VERSION) 29 | 30 | ## db.build : build db docker image 31 | db.build: 32 | docker build -t $(DOCKER_TAG_LOCAL_DB) -f $(DOCKER_FILE_DB) . 33 | ## db.local.up : build and start db-local 34 | db.local.up: db.build 35 | docker-compose -f $(DOCKER_COMPOSE_FILE) up $(DOCKER_COMPOSE_SVC_DB_LOCAL) 36 | ## db.ci.up : build and start db-ci 37 | db.ci.up: db.build 38 | docker-compose -f $(DOCKER_COMPOSE_FILE) up $(DOCKER_COMPOSE_SVC_DB_CI) 39 | ## db.down : stop db's 40 | db.down: 41 | docker-compose -f $(DOCKER_COMPOSE_FILE) down 42 | ## db.down : stop db's and delete volumes 43 | db.down.v: 44 | docker-compose -f $(DOCKER_COMPOSE_FILE) down -v 45 | ## app.clean : clean 46 | app.clean: 47 | $(GRADLE_CMD) clean 48 | rm version.txt || true 49 | 50 | ## app.version : show version 51 | app.version: 52 | @echo "$(SERVICE_VERSION)" 53 | 54 | ## app.build : clean and build (jar, docker) 55 | app.build: app.clean app.version guard-SERVICE_VERSION db.build 56 | 57 | @echo "building service $(SERVICE_NAME) version $(SERVICE_VERSION)" 58 | mkdir -p src/main/resources/public/ && cp -rf version.txt src/main/resources/public/version.txt 59 | $(GRADLE_CMD) clean generateAppJooqSchemaSource build test 60 | docker build -t $(DOCKER_TAG_LOCAL_APP) -f $(DOCKER_FILE_APP) . 61 | export SERVICE_VERSION=$(SERVICE_VERSION) && docker-compose -f $(DOCKER_COMPOSE_FILE) down -v 62 | @echo "build complete: service $(SERVICE_NAME) version $(SERVICE_VERSION)" 63 | 64 | 65 | app.compose.up: guard-SERVICE_VERSION 66 | docker ps 67 | export SERVICE_VERSION=$(SERVICE_VERSION) && export SERVICE_NAME=$(SERVICE_NAME) && docker-compose -f $(DOCKER_COMPOSE_FILE) up 68 | 69 | app.compose.down: guard-SERVICE_VERSION 70 | docker ps 71 | export SERVICE_VERSION=$(SERVICE_VERSION) && export SERVICE_NAME=$(SERVICE_NAME) && docker-compose -f $(DOCKER_COMPOSE_FILE) down -v 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /rest-api/bin/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | date -u +"%Y-%m-%dT%H.%M.%SZ" > version.txt -------------------------------------------------------------------------------- /rest-api/build.gradle: -------------------------------------------------------------------------------- 1 | version '0.0.1' 2 | 3 | 4 | 5 | buildscript { 6 | ext { 7 | kotlin_version = '1.2.21' 8 | kotlinLoggingVersion = '1.4.6' 9 | logstashLogbackEncoderVersion='4.8' 10 | springBootVersion ='1.5.9.RELEASE' 11 | jacksonVersion = '2.9.2' 12 | swaggerVersion = '2.8.0' 13 | mockitoVersion = '2.8.47' 14 | postgresDriverVersion = '42.1.4' 15 | jooqVersion = '3.9.5' // '3.10.1' not supported yet by plugin: nu.studer.jooq 16 | jooqCodeGenVersion = '2.0.9' 17 | avastDockerComposeVersion = '0.6.9' 18 | } 19 | 20 | dependencies { 21 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 22 | classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlin_version}") 23 | classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlin_version}") 24 | classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlin_version}") 25 | 26 | classpath "org.postgresql:postgresql:${postgresDriverVersion}" 27 | classpath "nu.studer:gradle-jooq-plugin:${jooqCodeGenVersion}" 28 | classpath "com.avast.gradle:gradle-docker-compose-plugin:$avastDockerComposeVersion" 29 | } 30 | } 31 | 32 | 33 | 34 | apply plugin: "kotlin" 35 | apply plugin: "kotlin-spring" 36 | apply plugin: "kotlin-noarg" 37 | apply plugin: "org.springframework.boot" 38 | apply plugin: 'nu.studer.jooq' 39 | apply plugin: 'docker-compose' 40 | 41 | compileKotlin { 42 | kotlinOptions.jvmTarget = "1.8" 43 | } 44 | compileTestKotlin { 45 | kotlinOptions.jvmTarget = "1.8" 46 | } 47 | 48 | dependencies { 49 | // kotlin 50 | compile("org.jetbrains.kotlin:kotlin-stdlib-jre8") 51 | compile("org.jetbrains.kotlin:kotlin-reflect") 52 | // logging 53 | compile "net.logstash.logback:logstash-logback-encoder:$logstashLogbackEncoderVersion" 54 | compile "io.github.microutils:kotlin-logging:$kotlinLoggingVersion" 55 | // spring 56 | compile 'org.springframework.boot:spring-boot-starter-jooq', { 57 | exclude group: "org.jooq", module: "jooq" 58 | } 59 | compile("org.springframework.boot:spring-boot-starter-web") 60 | compile("org.springframework.boot:spring-boot-starter-actuator") 61 | // jackson json 62 | compile "com.fasterxml.jackson.module:jackson-modules-java8:$jacksonVersion" 63 | compile "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion" 64 | compile "com.fasterxml.jackson.module:jackson-module-parameter-names:$jacksonVersion" 65 | compile "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jacksonVersion" 66 | compile "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion" 67 | // swagger 68 | compile "io.springfox:springfox-swagger2:$swaggerVersion" 69 | compile "io.springfox:springfox-swagger-ui:$swaggerVersion" 70 | 71 | // jsr305 - does it help jooq code gen ???? NO :( 72 | compile group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.2' 73 | 74 | 75 | // jooq & postgres 76 | // https://mvnrepository.com/artifact/org.jooq/jooq 77 | compile "org.postgresql:postgresql:${postgresDriverVersion}" 78 | compile "org.jooq:jooq:$jooqVersion" 79 | jooqRuntime "org.postgresql:postgresql:${postgresDriverVersion}" 80 | 81 | } 82 | 83 | test { 84 | testLogging { 85 | events "failed" 86 | exceptionFormat "full" 87 | } 88 | } 89 | 90 | jooq { 91 | // see: https://www.jooq.org/xsd/jooq-codegen-3.9.2.xsd 92 | version = "${jooqVersion}" 93 | app(sourceSets.main) { 94 | jdbc { 95 | driver = 'org.postgresql.Driver' 96 | url = 'jdbc:postgresql://localhost:5435/app' 97 | user = 'app_rw' 98 | password = 'app_rw' 99 | schema = 'public' 100 | } 101 | generator { 102 | name = 'org.jooq.util.DefaultGenerator' 103 | strategy { 104 | name = 'org.jooq.util.DefaultGeneratorStrategy' 105 | } 106 | database { 107 | name = 'org.jooq.util.postgres.PostgresDatabase' 108 | inputSchema = 'public' 109 | } 110 | generate { 111 | relations = true 112 | deprecated = false 113 | records = true 114 | daos = false 115 | immutablePojos = true 116 | fluentSetters = true 117 | javaTimeTypes = false // java8 time api 118 | 119 | // requires jpa 120 | jpaAnnotations = false 121 | validationAnnotations = false 122 | springAnnotations = false 123 | } 124 | target { 125 | packageName = 'com.example.db.gen' 126 | directory = 'src/main/generated/java' 127 | } 128 | } 129 | } 130 | } 131 | 132 | //compileJava.dependsOn generateJooqSchemaSource 133 | dockerCompose.isRequiredBy(generateAppJooqSchemaSource) // hooks 'dependsOn composeUp' and 'finalizedBy composeDown', and exposes environment variables and system properties (if possible) 134 | 135 | dockerCompose.isRequiredBy(build) // hooks 'dependsOn composeUp' and 'finalizedBy composeDown', and exposes environment variables and system properties (if possible) 136 | //dockerCompose.isRequiredBy(anyTask) // hooks 'dependsOn composeUp' and 'finalizedBy composeDown', and exposes environment variables and system properties (if possible) 137 | 138 | dockerCompose { 139 | useComposeFiles = ['docker/docker-compose.yml'] // like 'docker-compose -f ' 140 | startedServices = ['spring-kotlin-jooq-db-ci'] // list of services to execute when calling 'docker-compose up' (when not specified, all services are executed) 141 | // captureContainersOutput = true // prints output of all containers to Gradle output - very useful for debugging 142 | // captureContainersOutputToFile = '/path/to/logFile' // sends output of all containers to a log file 143 | // stopContainers = false // doesn't call `docker-compose down` - useful for debugging 144 | // removeContainers = false 145 | // removeImages = "None" // Other accepted values are: "All" and "Local" 146 | // removeOrphans = false // Removes containers for services not defined in the Compose file 147 | // forceRecreate = true // pass '--force-recreate' when calling 'docker-compose up' 148 | // removeVolumes = false 149 | // waitForTcpPorts = false // turns off the waiting for exposed TCP ports opening 150 | // projectName = 'my-project' // allow to set custom docker-compose project name (defaults to directory name) 151 | // executable = '/path/to/docker-compose' // allow to set the path of the docker-compose executable (useful if not present in PATH) 152 | // dockerExecutable = '/path/to/docker' // allow to set the path of the docker executable (useful if not present in PATH) 153 | // dockerComposeWorkingDirectory = '/path/where/docker-compose/is/invoked/from' 154 | // dockerComposeStopTimeout = java.time.Duration.ofSeconds(20) // time before docker-compose sends SIGTERM to the running containers after the composeDown task has been started 155 | // environment.put 'BACKEND_ADDRESS', '192.168.1.100' // Pass environment variable to 'docker-compose' for substitution in compose file 156 | // scale = [${serviceName1}: 5, ${serviceName2}: 2] // Pass docker compose --scale option like 'docker-compose up --scale serviceName1=5 --scale serviceName2=2' 157 | } 158 | 159 | -------------------------------------------------------------------------------- /rest-api/docker/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:8-jre-alpine 2 | 3 | EXPOSE 8080 8081 4 | 5 | COPY build/libs/rest-api-0.0.1.jar /opt/app/app.jar 6 | COPY docker/app/check-health.sh /usr/local/bin/ 7 | 8 | WORKDIR /opt/app/ 9 | CMD java -jar app.jar 10 | -------------------------------------------------------------------------------- /rest-api/docker/app/check-health.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eo pipefail 3 | 4 | host="$(hostname -i || echo '127.0.0.1')" 5 | curl --fail "http://$host/health" || exit 1 6 | -------------------------------------------------------------------------------- /rest-api/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | spring-kotlin-jooq-db-local: 4 | image: local/spring-kotlin-jooq-db:latest 5 | ports: 6 | - "5432:5432" 7 | tmpfs: 8 | - /tmp 9 | - /var/run/postgresql 10 | - /var/lib/postgresql/data 11 | spring-kotlin-jooq-db-ci: 12 | image: local/spring-kotlin-jooq-db:latest 13 | ports: 14 | - "5435:5432" 15 | restart: always 16 | tmpfs: 17 | - /tmp 18 | - /var/run/postgresql 19 | - /var/lib/postgresql/data -------------------------------------------------------------------------------- /rest-api/docker/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM library/postgres:9.6.3-alpine 2 | 3 | EXPOSE 5432 4 | 5 | COPY docker/postgres/docker-entrypoint-initdb.d/** /docker-entrypoint-initdb.d/ 6 | COPY docker/postgres/db-dumps/** /db-dumps/ 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /rest-api/docker/postgres/db-dumps/dump.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | -- Dumped from database version 9.6.3 6 | -- Dumped by pg_dump version 9.6.5 7 | 8 | SET statement_timeout = 0; 9 | SET lock_timeout = 0; 10 | SET idle_in_transaction_session_timeout = 0; 11 | SET client_encoding = 'UTF8'; 12 | SET standard_conforming_strings = on; 13 | SET check_function_bodies = false; 14 | SET client_min_messages = warning; 15 | SET row_security = off; 16 | 17 | -- 18 | -- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: 19 | -- 20 | 21 | CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; 22 | 23 | 24 | -- 25 | -- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: 26 | -- 27 | 28 | COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; 29 | 30 | 31 | SET search_path = public, pg_catalog; 32 | 33 | SET default_tablespace = ''; 34 | 35 | SET default_with_oids = false; 36 | 37 | 38 | CREATE TABLE tweet ( 39 | id uuid NOT NULL, 40 | version integer NOT NULL, 41 | created_at timestamp without time zone NOT NULL, 42 | updated_at timestamp without time zone NOT NULL, 43 | message text NOT NULL, 44 | comment text NULL 45 | ); 46 | ALTER TABLE tweet OWNER TO app_rw; 47 | ALTER TABLE ONLY tweet 48 | ADD CONSTRAINT tweet_pkey PRIMARY KEY (id); 49 | 50 | 51 | CREATE TABLE author ( 52 | id uuid NOT NULL, 53 | version integer NOT NULL, 54 | created_at timestamp without time zone NOT NULL, 55 | updated_at timestamp without time zone NOT NULL, 56 | name text NOT NULL 57 | ); 58 | ALTER TABLE author OWNER TO app_rw; 59 | ALTER TABLE ONLY author 60 | ADD CONSTRAINT author_pkey PRIMARY KEY (id); 61 | 62 | CREATE TABLE book ( 63 | id uuid NOT NULL, 64 | author_id uuid NOT NULL, 65 | version integer NOT NULL, 66 | created_at timestamp without time zone NOT NULL, 67 | updated_at timestamp without time zone NOT NULL, 68 | title character varying(255) NOT NULL, 69 | status character varying(255) NOT NULL, 70 | price numeric(15,2) NOT NULL 71 | ); 72 | ALTER TABLE book OWNER TO app_rw; 73 | ALTER TABLE ONLY book 74 | ADD CONSTRAINT book_pkey PRIMARY KEY (id); 75 | ALTER TABLE ONLY book 76 | ADD CONSTRAINT book_author_id_fkey FOREIGN KEY (author_id) REFERENCES author(id); 77 | -- 78 | -- PostgreSQL database dump complete 79 | -- 80 | 81 | -------------------------------------------------------------------------------- /rest-api/docker/postgres/docker-entrypoint-initdb.d/001-init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | echo "======= create db and (user) roles =======" 4 | 5 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 6 | CREATE ROLE app_rw WITH LOGIN PASSWORD 'app_rw'; 7 | 8 | CREATE DATABASE app OWNER app_rw; 9 | CREATE DATABASE app_test OWNER app_rw; 10 | 11 | GRANT ALL ON DATABASE app TO app_rw; 12 | GRANT ALL ON DATABASE app_test TO app_rw; 13 | 14 | EOSQL 15 | 16 | 17 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL 18 | 19 | CREATE ROLE app_ro WITH LOGIN ENCRYPTED PASSWORD 'app_ro' NOSUPERUSER NOCREATEROLE NOCREATEDB ; 20 | GRANT CONNECT ON DATABASE app TO app_ro; 21 | GRANT CONNECT ON DATABASE app_test TO app_ro; 22 | 23 | \c app; 24 | ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO app_ro; --- this grants privileges on new tables generated in new database "foo" 25 | GRANT USAGE ON SCHEMA public TO app_ro; 26 | GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro; 27 | GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO app_ro; 28 | 29 | \c app_test; 30 | ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO app_ro; --- this grants privileges on new tables generated in new database "foo" 31 | GRANT USAGE ON SCHEMA public TO app_ro; 32 | GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro; 33 | GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO app_ro; 34 | 35 | EOSQL 36 | 37 | -------------------------------------------------------------------------------- /rest-api/docker/postgres/docker-entrypoint-initdb.d/002-import_dump.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | echo "======= import sql dump =======" 4 | 5 | psql app -f /db-dumps/dump.sql 6 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/DatabaseInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import com.example.api.tweeter.domain.TweeterRepo 4 | import com.example.db.gen.tables.records.TweetRecord 5 | import com.example.util.sql.toSqlTimestamp 6 | import mu.KLogging 7 | import org.springframework.boot.CommandLineRunner 8 | import org.springframework.stereotype.Component 9 | import java.time.Instant 10 | import java.util.* 11 | 12 | @Component 13 | class DatabaseInitializer(val tweeterRepo: TweeterRepo) : CommandLineRunner { 14 | 15 | override fun run(vararg args: String) { 16 | logger.info { "START DB INITIALIZER" } 17 | 18 | val now = Instant.now() 19 | val r: TweetRecord = TweetRecord( 20 | UUID.randomUUID(), 0, now.toSqlTimestamp(), now.toSqlTimestamp(), "msg $now", "comment $now") 21 | 22 | val x = tweeterRepo.insert(r) 23 | logger.info { "x=$x" } 24 | 25 | 26 | } 27 | 28 | companion object : KLogging() 29 | } 30 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/RestApiApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import org.springframework.boot.SpringApplication 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | 6 | @SpringBootApplication 7 | class RestApiApplication 8 | 9 | fun main(args: Array) { 10 | SpringApplication.run(RestApiApplication::class.java, *args) 11 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/ApiConfig.kt: -------------------------------------------------------------------------------- 1 | package com.example.api 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.stereotype.Component 5 | 6 | @Component 7 | data class ApiConfig( 8 | @Value(value = "\${app.appName}") val appName: String 9 | ) { 10 | val title: String 11 | get() = "API $appName" 12 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/BookStoreApiController.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore 2 | 3 | 4 | import com.example.api.bookstore.domain.repo.AuthorRepo 5 | import com.example.api.bookstore.domain.repo.BookRepo 6 | import com.example.util.sql.toSqlTimestamp 7 | import mu.KLogging 8 | import org.springframework.web.bind.annotation.* 9 | import java.time.Instant 10 | import java.util.* 11 | 12 | @RestController 13 | class BookStoreApiController( 14 | private val authorRepo: AuthorRepo, 15 | private val bookRepo: BookRepo 16 | ) { 17 | 18 | @GetMapping("/api/bookstore/authors") 19 | fun authorsFindAll() = 20 | authorRepo.getAllRecords() 21 | .toList() 22 | .map { it.toAuthorDto() } 23 | 24 | @GetMapping("/api/bookstore/authors/{id}") 25 | fun authorsGetOne(@PathVariable id: UUID) = 26 | authorRepo.requireOneById(id) 27 | .toAuthorDto() 28 | 29 | @PutMapping("/api/bookstore/authors") 30 | fun authorsCreateOne(@RequestBody req: AuthorCreateRequest) = 31 | req.toAuthorRecord() 32 | .let { authorRepo.insert(it) } 33 | .also { logger.info { "Updated Record: $it" } } 34 | .toAuthorDto() 35 | 36 | @PostMapping("/api/bookstore/authors/{id}") 37 | fun authorsUpdateOne(@PathVariable id: UUID, @RequestBody req: AuthorUpdateRequest): AuthorDto = authorRepo.requireOneById(id) 38 | .apply { 39 | updatedAt = Instant.now().toSqlTimestamp() 40 | name = req.name 41 | } 42 | .let { authorRepo.update(it) } 43 | .also { logger.info { "Updated Record: $it" } } 44 | .toAuthorDto() 45 | 46 | @GetMapping("/api/bookstore/books/{id}") 47 | fun booksGetOne(@PathVariable id: UUID) = 48 | bookRepo.requireOneById(id).toBookDto() 49 | 50 | @PutMapping("/api/bookstore/books") 51 | fun booksCreateOne(@RequestBody req: BookCreateRequest) = 52 | req.toBookRecord() 53 | .let { bookRepo.insert(it) } 54 | .also { logger.info { "Updated Record: $it" } } 55 | .toBookDto() 56 | 57 | @PostMapping("/api/bookstore/books/{id}") 58 | fun booksUpdateOne(@PathVariable id: UUID, @RequestBody req: BookUpdateRequest) = bookRepo.requireOneById(id) 59 | .apply { 60 | updatedAt = Instant.now().toSqlTimestamp() 61 | title = req.title 62 | status = req.status.name 63 | price = req.price 64 | } 65 | .let { bookRepo.update(it) } 66 | .also { logger.info { "Updated Record: $it" } } 67 | .toBookDto() 68 | 69 | @GetMapping("/api/bookstore/books") 70 | fun booksFindAll() = bookRepo.findAllBooksJoinAuthor() 71 | .map { it.toBookDto() } 72 | .also { logger.info { it } } 73 | 74 | @GetMapping("/api/bookstore/books/summary") 75 | fun booksFindAllAsSummary() = bookRepo.findAllBooksJoinAuthorAsSummary() 76 | 77 | companion object : KLogging() 78 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/apiModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore 2 | 3 | /* 4 | import com.example.api.bookstore.domain.db.Author 5 | import com.example.api.bookstore.domain.db.Book 6 | import com.example.api.bookstore.domain.db.BookStatus 7 | import com.example.api.bookstore.domain.repo.BookRecordJoinAuthorRecord 8 | */ 9 | import com.example.api.bookstore.domain.repo.BookRecordJoinAuthorRecord 10 | import com.example.db.gen.tables.records.AuthorRecord 11 | import com.example.db.gen.tables.records.BookRecord 12 | import com.example.util.sql.toSqlTimestamp 13 | import java.math.BigDecimal 14 | import java.time.Instant 15 | import java.util.* 16 | 17 | enum class BookStatus { NEW, PUBLISHED; } 18 | data class AuthorCreateRequest(val name: String) 19 | data class AuthorUpdateRequest(val name: String) 20 | data class BookCreateRequest(val authorId: UUID, val title: String, val status: BookStatus, val price: BigDecimal) 21 | data class BookUpdateRequest(val title: String, val status: BookStatus, val price: BigDecimal) 22 | 23 | data class AuthorDto(val id: UUID, val createdAt: Instant, val modifiedAt: Instant, val name: String) 24 | data class BookDto( 25 | val id: UUID, 26 | val createdAt: Instant, 27 | val modifiedAt: Instant, 28 | val title: String, 29 | val status: BookStatus, 30 | val price: BigDecimal 31 | ) 32 | 33 | data class BookWithAuthorDto( 34 | val id: UUID, 35 | val createdAt: Instant, 36 | val modifiedAt: Instant, 37 | val title: String, 38 | val status: BookStatus, 39 | val price: BigDecimal, 40 | val author: AuthorDto 41 | ) 42 | 43 | 44 | fun AuthorRecord.toAuthorDto() = AuthorDto( 45 | id = id, createdAt = createdAt.toInstant(), modifiedAt = updatedAt.toInstant(), name = name 46 | ) 47 | 48 | 49 | fun AuthorCreateRequest.toAuthorRecord(): AuthorRecord { 50 | val now = Instant.now() 51 | return AuthorRecord( 52 | UUID.randomUUID(), 53 | 0, 54 | now.toSqlTimestamp(), 55 | now.toSqlTimestamp(), 56 | name 57 | ) 58 | } 59 | 60 | fun BookCreateRequest.toBookRecord(): BookRecord { 61 | val now = Instant.now() 62 | return BookRecord( 63 | UUID.randomUUID(), 64 | authorId, 65 | 0, 66 | now.toSqlTimestamp(), 67 | now.toSqlTimestamp(), 68 | title, 69 | status.name, 70 | price 71 | ) 72 | } 73 | 74 | fun BookRecord.toBookDto() = 75 | BookDto( 76 | id = id, 77 | createdAt = createdAt.toInstant(), 78 | modifiedAt = updatedAt.toInstant(), 79 | title = title, 80 | status = BookStatus.valueOf(status), 81 | price = price 82 | ) 83 | 84 | fun BookRecordJoinAuthorRecord.toBookDto() = 85 | BookWithAuthorDto( 86 | id = bookRecord.id, 87 | createdAt = bookRecord.createdAt.toInstant(), 88 | modifiedAt = bookRecord.updatedAt.toInstant(), 89 | title = bookRecord.title, 90 | status = BookStatus.valueOf(bookRecord.status), 91 | price = bookRecord.price, 92 | author = authorRecord.toAuthorDto() 93 | ) 94 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/domain/repo/authorRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.domain.repo 2 | 3 | import com.example.api.common.EntityNotFoundException 4 | import com.example.db.gen.tables.Author 5 | import com.example.db.gen.tables.records.AuthorRecord 6 | import org.jooq.DSLContext 7 | import org.springframework.stereotype.Repository 8 | import org.springframework.transaction.annotation.Transactional 9 | import java.util.* 10 | 11 | @Repository 12 | @Transactional 13 | class AuthorRepo(private val dsl: DSLContext) { 14 | fun getAllRecords(): List = dsl.select() 15 | .from(AUTHOR) 16 | .fetch() 17 | .into(AuthorRecord::class.java) 18 | 19 | // see: https://stackoverflow.com/questions/45342644/jooq-throws-npe-when-fetchone-is-used 20 | fun getOneById(id: UUID): AuthorRecord? = dsl.select() 21 | .from(AUTHOR) 22 | .where(AUTHOR.ID.eq(id)) 23 | .limit(1) 24 | .fetch() 25 | .into(AuthorRecord::class.java) 26 | .firstOrNull() 27 | 28 | fun requireOneById(id: UUID): AuthorRecord = getOneById(id) 29 | ?: throw EntityNotFoundException("AuthorRecord NOT FOUND ! (id=$id)") 30 | 31 | fun insert(authorRecord: AuthorRecord) = 32 | dsl.insertInto(AUTHOR) 33 | .set(authorRecord) 34 | .execute() 35 | .let { requireOneById(authorRecord.id) } 36 | 37 | fun update(authorRecord: AuthorRecord) = 38 | dsl.update(AUTHOR) 39 | .set(authorRecord) 40 | .execute() 41 | .let { requireOneById(authorRecord.id) } 42 | 43 | 44 | companion object { 45 | private val AUTHOR = Author.AUTHOR 46 | } 47 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/domain/repo/bookRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.domain.repo 2 | 3 | import com.example.api.common.EntityNotFoundException 4 | import com.example.db.gen.tables.Author 5 | import com.example.db.gen.tables.Book 6 | import com.example.db.gen.tables.records.AuthorRecord 7 | import com.example.db.gen.tables.records.BookRecord 8 | import org.jooq.DSLContext 9 | import org.springframework.stereotype.Repository 10 | import org.springframework.transaction.annotation.Transactional 11 | import java.util.* 12 | 13 | @Repository 14 | @Transactional 15 | class BookRepo(private val dsl: DSLContext) { 16 | fun getAllRecords(): List = dsl.select() 17 | .from(BOOK) 18 | .fetch() 19 | .into(BookRecord::class.java) 20 | 21 | // see: https://stackoverflow.com/questions/45342644/jooq-throws-npe-when-fetchone-is-used 22 | fun getOneById(id: UUID): BookRecord? = dsl.select() 23 | .from(BOOK) 24 | .where(BOOK.ID.eq(id)) 25 | .limit(1) 26 | .fetch() 27 | .into(BookRecord::class.java) 28 | .firstOrNull() 29 | 30 | fun requireOneById(id: UUID): BookRecord = getOneById(id) 31 | ?: throw EntityNotFoundException("BookRecord NOT FOUND ! (id=$id)") 32 | 33 | fun insert(bookRecord: BookRecord) = 34 | dsl.insertInto(BOOK) 35 | .set(bookRecord) 36 | .execute() 37 | .let { requireOneById(bookRecord.id) } 38 | 39 | fun update(bookRecord: BookRecord) = 40 | dsl.update(BOOK) 41 | .set(bookRecord) 42 | .execute() 43 | .let { requireOneById(bookRecord.id) } 44 | 45 | fun findAllBooksJoinAuthor(): List = dsl.select(BOOK.fields().toList()) 46 | .select(AUTHOR.fields().toList()) 47 | .from(AUTHOR) 48 | .innerJoin(BOOK) 49 | .on(BOOK.AUTHOR_ID.eq(AUTHOR.ID)) 50 | .fetchGroups({ it.into(BOOK) }, { it.into(AUTHOR) }) 51 | .map { 52 | val authorRecord = it.value.firstOrNull() 53 | when (authorRecord) { 54 | null -> null 55 | else -> BookRecordJoinAuthorRecord(bookRecord = it.key, authorRecord = authorRecord) 56 | } 57 | }.filterNotNull() 58 | 59 | fun findAllBooksJoinAuthorAsSummary(): List = dsl.select(BOOK.fields().toList()).select(AUTHOR.fields().toList()) 60 | .from(AUTHOR) 61 | .innerJoin(BOOK) 62 | .on(BOOK.AUTHOR_ID.eq(AUTHOR.ID)) 63 | .fetchGroups({ it.into(BOOK) }, { it.into(AUTHOR) }) 64 | .map { 65 | val authorRecord = it.value.firstOrNull() 66 | val bookRecord = it.key 67 | when (authorRecord) { 68 | null -> null 69 | else -> BookWithAuthorSummary( 70 | authorId = bookRecord.authorId, 71 | authorName = authorRecord.name, 72 | bookId = bookRecord.id, 73 | bookTitle = bookRecord.title 74 | ) 75 | } 76 | }.filterNotNull() 77 | 78 | 79 | companion object { 80 | private val BOOK = Book.BOOK 81 | private val AUTHOR = Author.AUTHOR 82 | } 83 | } 84 | 85 | data class BookWithAuthorSummary(val authorId: UUID, val authorName: String, val bookId: UUID, val bookTitle: String) 86 | data class BookRecordJoinAuthorRecord(val bookRecord: BookRecord, val authorRecord: AuthorRecord) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/common/EntityAlreadyExistException.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.common 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(value = HttpStatus.UNPROCESSABLE_ENTITY) 7 | class EntityAlreadyExistException(message: String) : RuntimeException(message) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/common/EntityNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.common 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(value = HttpStatus.NOT_FOUND) 7 | class EntityNotFoundException(message: String) : RuntimeException(message) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/tweeter/TweeterApiController.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter 2 | 3 | import com.example.api.tweeter.domain.TweeterRepo 4 | import com.example.db.gen.tables.records.TweetRecord 5 | import com.example.util.sql.toSqlTimestamp 6 | import org.springframework.web.bind.annotation.* 7 | import java.time.Instant 8 | import java.util.* 9 | 10 | @RestController 11 | class TweeterApiController(private val tweeterRepo: TweeterRepo) { 12 | 13 | @GetMapping("/api/tweeter") 14 | fun findAll() = tweeterRepo.getAllRecords().map { it.toDto() } 15 | 16 | @GetMapping("/api/tweeter/{id}") 17 | fun getOne(@PathVariable id: UUID) = 18 | tweeterRepo.requireOneById(id).toDto() 19 | 20 | @PutMapping("/api/tweeter") 21 | fun createOne(@RequestBody req: CreateTweetRequest): TweetDto { 22 | val record = req.toRecord() 23 | tweeterRepo.insert(record) 24 | 25 | return tweeterRepo.requireOneById(record.id).toDto() 26 | } 27 | 28 | @PostMapping("/api/tweeter/{id}") 29 | fun updateOne(@PathVariable id: UUID, @RequestBody req: CreateTweetRequest): TweetDto { 30 | val record = tweeterRepo.requireOneById(id) 31 | record.apply { 32 | updatedAt = Instant.now().toSqlTimestamp() 33 | message = req.message 34 | comment = req.comment 35 | } 36 | tweeterRepo.update(record) 37 | 38 | return tweeterRepo.requireOneById(record.id).toDto() 39 | } 40 | 41 | } 42 | 43 | data class CreateTweetRequest(val message: String, val comment: String?) 44 | 45 | data class TweetDto( 46 | val id: UUID, 47 | val version: Int, 48 | val createdAt: Instant, 49 | val modifiedAt: Instant, 50 | val message: String, 51 | val comment: String? 52 | ) 53 | 54 | private fun TweetRecord.toDto() = TweetDto( 55 | id = id, 56 | version = version, 57 | createdAt = createdAt.toInstant(), 58 | modifiedAt = updatedAt.toInstant(), 59 | message = message, 60 | comment = comment 61 | ) 62 | 63 | private fun CreateTweetRequest.toRecord(): TweetRecord { 64 | val now = Instant.now() 65 | return TweetRecord(UUID.randomUUID(), 0, now.toSqlTimestamp(), now.toSqlTimestamp(), message, comment) 66 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/tweeter/domain/TweeterRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter.domain 2 | 3 | import com.example.api.common.EntityNotFoundException 4 | import com.example.db.gen.tables.Tweet 5 | import com.example.db.gen.tables.records.TweetRecord 6 | import org.jooq.DSLContext 7 | import org.springframework.stereotype.Repository 8 | import org.springframework.transaction.annotation.Transactional 9 | import java.util.* 10 | 11 | @Repository 12 | @Transactional 13 | class TweeterRepo(private val dsl: DSLContext) { 14 | 15 | fun getAllRecords(): List 16 | = dsl.select() 17 | .from(TWEET) 18 | .fetch() 19 | .into(TweetRecord::class.java) 20 | 21 | // see: https://stackoverflow.com/questions/45342644/jooq-throws-npe-when-fetchone-is-used 22 | fun getOneById(id: UUID): TweetRecord? 23 | = dsl.select() 24 | .from(TWEET) 25 | .where(TWEET.ID.eq(id)) 26 | .limit(1) 27 | .fetch() 28 | .into(TweetRecord::class.java) 29 | .firstOrNull() 30 | 31 | fun requireOneById(id: UUID): TweetRecord 32 | = getOneById(id) ?: throw EntityNotFoundException("TweetRecord NOT FOUND ! (id=$id)") 33 | 34 | fun insert(tweetRecord: TweetRecord) = 35 | dsl.insertInto(TWEET) 36 | .set(tweetRecord) 37 | .execute() 38 | 39 | fun update(tweetRecord: TweetRecord) = 40 | dsl.update(TWEET) 41 | .set(tweetRecord) 42 | .execute() 43 | 44 | 45 | companion object { 46 | private val TWEET = Tweet.TWEET 47 | } 48 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/Jackson.kt: -------------------------------------------------------------------------------- 1 | package com.example.config 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.databind.SerializationFeature 6 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | 10 | @Configuration 11 | class Jackson { 12 | 13 | @Bean 14 | fun objectMapper(): ObjectMapper = defaultMapper() 15 | 16 | companion object { 17 | fun defaultMapper(): ObjectMapper 18 | = jacksonObjectMapper() 19 | .findAndRegisterModules() 20 | 21 | // toJson() 22 | .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) 23 | .disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) 24 | 25 | // fromJson() 26 | .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) 27 | .disable(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT) 28 | .disable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT) 29 | .disable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) 30 | .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) 31 | .enable(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS) 32 | } 33 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/Swagger.kt: -------------------------------------------------------------------------------- 1 | package com.example.config 2 | 3 | import com.example.api.ApiConfig 4 | import com.google.common.base.Predicates 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import springfox.documentation.builders.RequestHandlerSelectors 8 | import springfox.documentation.spring.web.plugins.Docket 9 | import springfox.documentation.swagger2.annotations.EnableSwagger2 10 | 11 | @Configuration 12 | @EnableSwagger2 13 | class Swagger(private val apiConfig: ApiConfig) { 14 | 15 | @Bean 16 | fun mainApi(): Docket 17 | = apiConfig.toDocket() 18 | .groupName("Main") 19 | .select() 20 | .apis(RequestHandlerSelectors.basePackage(apiConfig.getBasePackageName())) 21 | .build() 22 | 23 | @Bean 24 | fun monitoringApi(): Docket 25 | = apiConfig.toDocket() 26 | .groupName("Monitoring") 27 | .useDefaultResponseMessages(true) 28 | .select() 29 | .apis(Predicates.not(RequestHandlerSelectors.basePackage(apiConfig.getBasePackageName()))) 30 | .build() 31 | } 32 | 33 | private fun ApiConfig.getBasePackageName() = this::class.java.`package`.name 34 | private fun ApiConfig.toApiInfo() = springfox.documentation.builders.ApiInfoBuilder().title(this.title).build() 35 | private fun ApiConfig.toDocket() = springfox.documentation.spring.web.plugins.Docket(springfox.documentation.spi.DocumentationType.SWAGGER_2).apiInfo(this.toApiInfo()) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/jooq/JOOQToSpringExceptionTransformer.kt: -------------------------------------------------------------------------------- 1 | package com.example.config.jooq 2 | 3 | import org.jooq.ExecuteContext 4 | import org.jooq.impl.DefaultExecuteListener 5 | import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator 6 | import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator 7 | 8 | /** 9 | *
10 |  * 1. Create a JOOQToSpringExceptionTransformer class which extends the DefaultExecuteListener class.
11 |  * The DefaultExecuteListener class is the public default implementation of the ExecuteListener
12 |  * interface which provides listener methods for different life cycle events of a single query execution.
13 |  * 2. Override the exception(ExecuteContext ctx) method of the DefaultExecuteListener class.
14 |  * This method is called if an exception is thrown at any moment of the execution life cycle.
15 |  * Implement this method by following these steps:
16 |  * a. Get a SQLDialect object from the jOOQ configuration.
17 |  * b. Create an object which implements the SQLExceptionTranslator interface by following these rules:
18 |  * i. If the configured SQL dialect is found, create a new SQLErrorCodeSQLExceptionTranslator
19 |  * object and pass the name of the SQL dialect as a constructor argument.
20 |  * This class “selects” the right DataAccessException by analyzing vendor specific error codes.
21 |  *
22 |  * ii. If the SQL dialect isn’t found, create a new SQLStateSQLExceptionTranslator object.
23 |  * This class “selects” the right DataAccessException by analyzing the SQL state stored to the SQLException.
24 |  * 3. Create the DataAccessException object by using the created SQLExceptionTranslator object.
25 |  * 4. Pass the thrown DataAccessException forward to the ExecuteContext object given as a method argument.
26 | 
* 27 | */ 28 | 29 | class JOOQToSpringExceptionTransformer : DefaultExecuteListener() { 30 | 31 | override fun exception(ctx: ExecuteContext) { 32 | if (ctx.sqlException() == null) return 33 | 34 | val dialect = ctx.configuration().dialect() 35 | val translator = if (dialect != null) { 36 | SQLErrorCodeSQLExceptionTranslator(dialect.name) 37 | } else { 38 | SQLStateSQLExceptionTranslator() 39 | } 40 | 41 | ctx.exception(translator.translate("jOOQ", ctx.sql(), ctx.sqlException())) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/jooq/JooqPersistentContextConfig.kt: -------------------------------------------------------------------------------- 1 | package com.example.config.jooq 2 | 3 | import org.jooq.SQLDialect 4 | import org.jooq.impl.DataSourceConnectionProvider 5 | import org.jooq.impl.DefaultConfiguration 6 | import org.jooq.impl.DefaultDSLContext 7 | import org.jooq.impl.DefaultExecuteListenerProvider 8 | import org.springframework.beans.factory.annotation.Value 9 | import org.springframework.boot.autoconfigure.ImportAutoConfiguration 10 | import org.springframework.boot.autoconfigure.jooq.JooqAutoConfiguration 11 | import org.springframework.context.ApplicationContextException 12 | import org.springframework.context.annotation.Bean 13 | import org.springframework.context.annotation.Configuration 14 | import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy 15 | import org.springframework.transaction.annotation.EnableTransactionManagement 16 | import javax.sql.DataSource 17 | 18 | @Configuration 19 | @EnableTransactionManagement 20 | @ImportAutoConfiguration(JooqAutoConfiguration::class) 21 | class JooqPersistentContextConfig( 22 | private val dataSource: DataSource, 23 | @Value(value = "\${spring.jooq.sql-dialect}") 24 | private val dialect: String 25 | ) { 26 | 27 | @Bean 28 | fun connectionProvider()= DataSourceConnectionProvider(TransactionAwareDataSourceProxy(dataSource)) 29 | 30 | @Bean 31 | fun dsl() = DefaultDSLContext(configuration()) 32 | 33 | 34 | fun configuration(): DefaultConfiguration { 35 | val jooqConfiguration = DefaultConfiguration() 36 | 37 | val settings=jooqConfiguration.settings() 38 | // https://www.jooq.org/doc/3.7/manual/sql-execution/crud-with-updatablerecords/optimistic-locking/ 39 | settings.withExecuteWithOptimisticLocking(true) 40 | .withExecuteLogging(true) 41 | 42 | jooqConfiguration.set(settings) 43 | jooqConfiguration.set(connectionProvider()) 44 | jooqConfiguration.set(DefaultExecuteListenerProvider(jooqToSpringExceptionTransformer())) 45 | 46 | if (dialect.isEmpty()) { 47 | throw RuntimeException("App Config Error! spring.jooq.sql-dialect MUST NOT BE EMPTY!") 48 | } 49 | val sqlDialect: SQLDialect = when (dialect.trim().toUpperCase()) { 50 | "POSTGRES" -> SQLDialect.POSTGRES_9_5 51 | else -> { 52 | try { 53 | SQLDialect.valueOf(dialect) 54 | } catch (e: Exception) { 55 | throw ApplicationContextException( 56 | "App Config Error! No Handler defined for spring.jooq.sql-dialect = '$dialect' !" 57 | + " reason: ${e.message}" 58 | ) 59 | } 60 | } 61 | } 62 | jooqConfiguration.setSQLDialect(sqlDialect) 63 | 64 | return jooqConfiguration 65 | } 66 | 67 | @Bean 68 | fun jooqToSpringExceptionTransformer() = JOOQToSpringExceptionTransformer() 69 | 70 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/sql/sqlExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.sql 2 | 3 | import java.sql.Timestamp 4 | import java.time.Instant 5 | 6 | 7 | fun Instant.toSqlTimestamp() = Timestamp(this.toEpochMilli()) 8 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/application-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: local 3 | datasource: 4 | #url: jdbc:postgresql://localhost:5432/app 5 | url: jdbc:postgresql://localhost:5432/app 6 | username: app_rw 7 | password: app_rw 8 | driver-class-name: org.postgresql.Driver 9 | 10 | 11 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | --- 2 | spring: 3 | profiles: 4 | active: local 5 | datasource: 6 | driver-class-name: org.postgresql.Driver 7 | jooq: 8 | sql-dialect: Postgres 9 | app: 10 | appName: "spring-kotlin-jooq" 11 | --- 12 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include 'rest-api' 2 | 3 | --------------------------------------------------------------------------------