├── .gitignore ├── Makefile ├── README.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── gradle.properties ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── rest-api ├── Makefile ├── build.gradle.kts ├── docker │ ├── app │ │ ├── Dockerfile │ │ └── check-health.sh │ ├── docker-compose-ci.yml │ ├── docker-compose-local.yml │ ├── docker-compose-playground.yml │ └── postgres │ │ ├── Dockerfile │ │ ├── db-dumps │ │ ├── dump.sql │ │ └── extensions.sql │ │ └── docker-entrypoint-initdb.d │ │ ├── 001-init.sh │ │ ├── 002-import_dump.sh │ │ ├── 003-install_extensions.sh │ │ └── config.sh └── src │ ├── main │ ├── kotlin │ │ └── com │ │ │ └── example │ │ │ ├── RestApiApplication.kt │ │ │ ├── api │ │ │ ├── ApiConfig.kt │ │ │ ├── bookstore │ │ │ │ ├── ApiController.kt │ │ │ │ ├── ApiModel.kt │ │ │ │ └── db │ │ │ │ │ ├── AuthorRepo.kt │ │ │ │ │ ├── AuthorTable.kt │ │ │ │ │ ├── BookRepo.kt │ │ │ │ │ ├── BookTable.kt │ │ │ │ │ └── Common.kt │ │ │ ├── bookz │ │ │ │ ├── ApiModel.kt │ │ │ │ ├── BookzApiController.kt │ │ │ │ ├── db │ │ │ │ │ ├── BookzRepo.kt │ │ │ │ │ └── BookzTable.kt │ │ │ │ └── handler │ │ │ │ │ ├── bulkSave │ │ │ │ │ ├── Handler.kt │ │ │ │ │ └── Request.kt │ │ │ │ │ ├── createOne │ │ │ │ │ ├── Handler.kt │ │ │ │ │ └── Request.kt │ │ │ │ │ ├── findAll │ │ │ │ │ └── Handler.kt │ │ │ │ │ ├── getOneById │ │ │ │ │ ├── Handler.kt │ │ │ │ │ └── Request.kt │ │ │ │ │ └── updateOneById │ │ │ │ │ ├── Handler.kt │ │ │ │ │ └── Request.kt │ │ │ ├── common │ │ │ │ └── rest │ │ │ │ │ ├── error │ │ │ │ │ ├── exception │ │ │ │ │ │ └── ApiExceptions.kt │ │ │ │ │ └── handler │ │ │ │ │ │ └── ApiExceptionHandler.kt │ │ │ │ │ └── serialization │ │ │ │ │ └── PatchableModule.kt │ │ │ ├── places │ │ │ │ ├── ApiController.kt │ │ │ │ ├── common │ │ │ │ │ ├── db │ │ │ │ │ │ ├── Record.kt │ │ │ │ │ │ ├── Repo.kt │ │ │ │ │ │ └── Table.kt │ │ │ │ │ └── rest │ │ │ │ │ │ ├── mutation │ │ │ │ │ │ └── Mutations.kt │ │ │ │ │ │ └── response │ │ │ │ │ │ ├── PlaceDto.kt │ │ │ │ │ │ └── ResponseDto.kt │ │ │ │ └── geosearch │ │ │ │ │ ├── Request.kt │ │ │ │ │ ├── Response.kt │ │ │ │ │ ├── dsl │ │ │ │ │ ├── Handler.kt │ │ │ │ │ └── service │ │ │ │ │ │ └── GeoSearchQueryBuilder.kt │ │ │ │ │ └── native │ │ │ │ │ ├── Handler.kt │ │ │ │ │ └── service │ │ │ │ │ ├── Request.kt │ │ │ │ │ ├── Result.kt │ │ │ │ │ └── Service.kt │ │ │ └── tweeter │ │ │ │ ├── ApiController.kt │ │ │ │ ├── ApiModel.kt │ │ │ │ ├── db │ │ │ │ ├── TweetsRepo.kt │ │ │ │ └── TweetsTable.kt │ │ │ │ └── search │ │ │ │ ├── Handler.kt │ │ │ │ ├── Request.kt │ │ │ │ └── Response.kt │ │ │ ├── config │ │ │ ├── AppEnvName.kt │ │ │ ├── Jackson.kt │ │ │ ├── Swagger.kt │ │ │ ├── WebMvc.kt │ │ │ └── db │ │ │ │ ├── Exposed.kt │ │ │ │ └── Flyway.kt │ │ │ ├── main.kt │ │ │ └── util │ │ │ ├── exposed │ │ │ ├── columnTypes │ │ │ │ ├── enumBySqlTypeColumnType.kt │ │ │ │ ├── instantColumnType.kt │ │ │ │ └── jsonbColumnType.kt │ │ │ ├── crud │ │ │ │ ├── CrudRecordRepo.kt │ │ │ │ └── CrudRecordTable.kt │ │ │ ├── expr │ │ │ │ └── postgres │ │ │ │ │ └── ILike.kt │ │ │ ├── functions │ │ │ │ ├── common │ │ │ │ │ └── CustomBooleanFunction.kt │ │ │ │ └── postgres │ │ │ │ │ └── DistinctOn.kt │ │ │ ├── nativesql │ │ │ │ └── NativeSql.kt │ │ │ ├── postgres │ │ │ │ └── extensions │ │ │ │ │ └── earthdistance │ │ │ │ │ ├── EarthBoxFunctions.kt │ │ │ │ │ ├── EarthDistance.kt │ │ │ │ │ ├── LatLonFunctions.kt │ │ │ │ │ ├── LatLonToEarthFunctions.kt │ │ │ │ │ ├── NullExpr.kt │ │ │ │ │ ├── PGEarthBoxType.kt │ │ │ │ │ ├── PGEarthPointLocationTypes.kt │ │ │ │ │ └── PgRangeOperators.kt │ │ │ ├── query │ │ │ │ └── QueryExt.kt │ │ │ └── spring │ │ │ │ └── transaction │ │ │ │ └── SpringTransactionTemplate.kt │ │ │ ├── resources │ │ │ └── resources.kt │ │ │ └── time │ │ │ └── Durations.kt │ └── resources │ │ ├── application-flyway-migrate.yml │ │ ├── application-flyway-validate.yml │ │ ├── application-local.yml │ │ ├── application-playground.yml │ │ ├── application-test.yml │ │ ├── application.yml │ │ ├── db │ │ └── migration │ │ │ ├── V1.0__tweets_api.sql │ │ │ ├── V2.0__bookstore_api.sql │ │ │ ├── V3.0__bookz_api.sql │ │ │ ├── V4.0__documents_api.sql │ │ │ └── V5.0__gis_places_api.sql │ │ ├── default-detekt-config.yml │ │ └── logback.xml │ └── test │ ├── kotlin │ └── com │ │ └── example │ │ ├── api │ │ ├── bookstore │ │ │ ├── db │ │ │ │ ├── AuthorRepoTest.kt │ │ │ │ └── BookRepoTest.kt │ │ │ └── fixtures │ │ │ │ └── ApiFixtures.kt │ │ ├── bookz │ │ │ ├── db │ │ │ │ └── RepoTest.kt │ │ │ └── fixtures │ │ │ │ └── ApiFixtures.kt │ │ ├── places │ │ │ ├── db │ │ │ │ └── RepoTest.kt │ │ │ └── fixtures │ │ │ │ └── ApiFixtures.kt │ │ └── tweeter │ │ │ ├── db │ │ │ └── RepoTest.kt │ │ │ ├── fixtures │ │ │ └── ApiFixtures.kt │ │ │ └── search │ │ │ └── HandlerTest.kt │ │ ├── bootstrap │ │ └── BootstrapIT.kt │ │ ├── testconfig │ │ └── Configurations.kt │ │ └── testutils │ │ ├── assertions │ │ └── assertions.kt │ │ ├── collections │ │ └── cartesian.kt │ │ ├── json │ │ └── jsonAssertions.kt │ │ ├── junit5 │ │ └── TestFactory.kt │ │ ├── minutest │ │ └── TestFactory.kt │ │ ├── random │ │ └── Randomize.kt │ │ ├── resources │ │ └── CodeSourceResources.kt │ │ └── spring │ │ ├── SpringProfiles.kt │ │ └── SpringTestContexts.kt │ └── resources │ ├── golden-test-data │ └── tests │ │ └── api │ │ └── tweeter │ │ └── search │ │ ├── golden-test-data.json │ │ ├── testcase-001.json │ │ ├── testcase-002.json │ │ ├── testcase-003.json │ │ ├── testcase-004.json │ │ ├── testcase-005.json │ │ ├── testcase-006.json │ │ ├── testcase-007.json │ │ ├── testcase-008.json │ │ ├── testcase-009.json │ │ └── testcase-010.json │ └── junit-platform.properties └── settings.gradle.kts /.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 | 16 | 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GRADLE_VERSION=6.7 2 | 3 | print-%: ; @echo $*=$($*) 4 | guard-%: 5 | @test ${${*}} || (echo "FAILED! Environment variable $* not set " && exit 1) 6 | @echo "-> use env var $* = ${${*}}"; 7 | 8 | .PHONY : help 9 | help : Makefile 10 | @sed -n 's/^##//p' $< 11 | 12 | ## idea-start: : start intellij 13 | idea-start: 14 | open -a /Applications/IntelliJ\ IDEA.app 15 | 16 | ## gradle-wrapper: : install gradle wrapper 17 | gradle-wrapper: 18 | ./gradlew --version 19 | ./gradlew wrapper --gradle-version=$(GRADLE_VERSION) 20 | ./gradlew --version 21 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("tanvd.kosogor") apply false 3 | } 4 | 5 | subprojects { 6 | repositories { 7 | mavenLocal() 8 | mavenCentral() 9 | jcenter() 10 | // maven { setUrl("https://dl.bintray.com/kotlin/exposed") } 11 | } 12 | apply(plugin = "tanvd.kosogor") 13 | } 14 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | gradlePluginPortal() 4 | jcenter() 5 | } 6 | 7 | dependencies { 8 | gradleApi() 9 | } 10 | 11 | plugins { 12 | `kotlin-dsl` apply true 13 | } 14 | -------------------------------------------------------------------------------- /buildSrc/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.caching=true 2 | org.gradle.parallel=true 3 | org.gradle.jvmargs=-Dfile.encoding=UTF-8 4 | 5 | group=com.example 6 | version=0.0.1 7 | dialect=none 8 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlinVersion=1.4.10 2 | springBootVersion=2.1.4.RELEASE 3 | file.encoding=UTF-8 4 | 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bastman/spring-kotlin-exposed/e2859065328668fd294c4a360e0a0ad5ce9e2db1/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /rest-api/docker/app/Dockerfile: -------------------------------------------------------------------------------- 1 | #FROM openjdk:8-jre-alpine 2 | FROM adoptopenjdk/openjdk8-openj9:alpine-slim 3 | 4 | EXPOSE 8080 8081 5 | 6 | COPY build/libs/rest-api-0.0.1.jar /opt/app/app.jar 7 | COPY docker/app/check-health.sh /usr/local/bin/ 8 | 9 | WORKDIR /opt/app/ 10 | CMD java -jar app.jar 11 | -------------------------------------------------------------------------------- /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/actuator/health" || exit 1 6 | -------------------------------------------------------------------------------- /rest-api/docker/docker-compose-ci.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | spring-kotlin-exposed-db-ci: 4 | image: local/spring-kotlin-exposed-db:latest 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | - "POSTGRES_PASSWORD=password" 9 | networks: 10 | - spring-kotlin-exposed-ci-network 11 | tmpfs: 12 | - /tmp 13 | - /var/run/postgresql 14 | - /var/lib/postgresql/data 15 | networks: 16 | spring-kotlin-exposed-ci-network: {} 17 | -------------------------------------------------------------------------------- /rest-api/docker/docker-compose-local.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | spring-kotlin-exposed-local-db: 4 | image: local/spring-kotlin-exposed-db:latest 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | - "POSTGRES_PASSWORD=password" 9 | networks: 10 | - spring-kotlin-exposed-local-network 11 | volumes: 12 | - spring-kotlin-exposed-local-db-volume:/var/lib/postgresql/data 13 | 14 | networks: 15 | spring-kotlin-exposed-local-network: {} 16 | volumes: 17 | spring-kotlin-exposed-local-db-volume: {} 18 | -------------------------------------------------------------------------------- /rest-api/docker/docker-compose-playground.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | services: 3 | spring-kotlin-exposed-playground-db: 4 | image: local/spring-kotlin-exposed-db:latest 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | - "POSTGRES_PASSWORD=password" 9 | networks: 10 | - spring-kotlin-exposed-playground-network 11 | volumes: 12 | - spring-kotlin-exposed-playground-db-volume:/var/lib/postgresql/data 13 | 14 | spring-kotlin-exposed-playground-web: 15 | image: local/spring-kotlin-exposed-rest-api:${SERVICE_VERSION} 16 | ports: 17 | - "8080:8080" 18 | networks: 19 | - spring-kotlin-exposed-playground-network 20 | environment: 21 | - "DB_URL=spring-kotlin-exposed-playground-db:5432/app" 22 | command: [ 23 | "java", 24 | "-jar", 25 | "-Dspring.profiles.active=playground,flyway-migrate", 26 | "-Xms32m", 27 | "-Xmx256m", 28 | "/opt/app/app.jar" 29 | ] 30 | depends_on: 31 | - spring-kotlin-exposed-playground-db 32 | healthcheck: 33 | test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] 34 | interval: 1m30s 35 | timeout: 10s 36 | retries: 3 37 | start_period: 30s 38 | 39 | networks: 40 | spring-kotlin-exposed-playground-network: {} 41 | volumes: 42 | spring-kotlin-exposed-playground-db-volume: {} 43 | -------------------------------------------------------------------------------- /rest-api/docker/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | # FROM library/postgres:9.6.3-alpine 2 | FROM postgis/postgis:9.6-3.1-alpine 3 | 4 | EXPOSE 5432 5 | 6 | COPY docker/postgres/docker-entrypoint-initdb.d/** /docker-entrypoint-initdb.d/ 7 | COPY docker/postgres/db-dumps/** /db-dumps/ 8 | #ADD docker/postgres/config.sh /docker-entrypoint-initdb.d/ 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /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 | 39 | 40 | -- 41 | -- PostgreSQL database dump complete 42 | -- 43 | 44 | -------------------------------------------------------------------------------- /rest-api/docker/postgres/db-dumps/extensions.sql: -------------------------------------------------------------------------------- 1 | -- custom extensions --- 2 | CREATE EXTENSION IF NOT EXISTS cube; 3 | CREATE EXTENSION IF NOT EXISTS earthdistance; 4 | CREATE EXTENSION IF NOT EXISTS postgis; 5 | -------------------------------------------------------------------------------- /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/docker/postgres/docker-entrypoint-initdb.d/003-install_extensions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | echo "======= install sql extensions =======" 4 | 5 | psql app -f /db-dumps/extensions.sql 6 | psql app_test -f /db-dumps/extensions.sql 7 | 8 | -------------------------------------------------------------------------------- /rest-api/docker/postgres/docker-entrypoint-initdb.d/config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # https://github.com/Roconda/docker-postgres-logging/blame/master/config.sh 3 | 4 | echo "patch config - enable statment logs: log_statement=all in /var/lib/postgresql/data/postgresql.conf..." 5 | set -e 6 | 7 | sed -ri "s/#log_statement = 'none'/log_statement = 'all'/g" /var/lib/postgresql/data/postgresql.conf 8 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/RestApiApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import com.example.api.ApiConfig 4 | import mu.KLogging 5 | import org.springframework.boot.autoconfigure.SpringBootApplication 6 | import org.springframework.boot.context.event.ApplicationReadyEvent 7 | import org.springframework.context.ApplicationListener 8 | import org.springframework.transaction.annotation.EnableTransactionManagement 9 | import java.util.* 10 | import javax.annotation.PostConstruct 11 | 12 | @SpringBootApplication 13 | @EnableTransactionManagement 14 | class RestApiApplication(private val apiConfig: ApiConfig) : ApplicationListener { 15 | @PostConstruct 16 | fun starting() { 17 | TimeZone.setDefault(TimeZone.getTimeZone("UTC")) 18 | logger.info { "===== SPRING BOOT APP STARTING: ${apiConfig.title} ====" } 19 | } 20 | 21 | override fun onApplicationEvent(contextRefreshedEvent: ApplicationReadyEvent) { 22 | logger.info("===== SPRING BOOT APP STARTED: ${apiConfig.title} ======") 23 | System.gc() 24 | } 25 | 26 | companion object : KLogging() 27 | } 28 | -------------------------------------------------------------------------------- /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 | @Value(value = "\${app.envName}") val envName: String 10 | ) { 11 | val title: String 12 | get() = "API $appName ($envName)" 13 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/ApiController.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore 2 | 3 | import com.example.api.bookstore.db.AuthorRepository 4 | import com.example.api.bookstore.db.BookRepository 5 | import mu.KLogging 6 | import org.springframework.web.bind.annotation.* 7 | import java.time.Instant 8 | import java.util.* 9 | 10 | @RestController 11 | class BookStoreApiController(private val authorRepo: AuthorRepository, private val bookRepo: BookRepository) { 12 | 13 | @GetMapping("/$API_AUTHORS") 14 | fun authorsFindAll(): List = authorRepo 15 | .findAll() 16 | .map { it.toAuthorDto() } 17 | 18 | @GetMapping("/$API_AUTHORS/{id}") 19 | fun authorsGetOne(@PathVariable id: UUID): AuthorDto = authorRepo[id].toAuthorDto() 20 | 21 | @PutMapping("/$API_AUTHORS") 22 | fun authorsCreateOne(@RequestBody req: AuthorCreateRequest): AuthorDto = req 23 | .toAuthorRecord(id = UUID.randomUUID(), now = Instant.now()) 24 | .let { authorRepo.insert(it) } 25 | .also { logger.info { "INSERT DB ENTITY: $it" } } 26 | .toAuthorDto() 27 | 28 | @GetMapping("/$API_AUTHORS/{id}/books") 29 | fun authorsGetOneWithBooks(@PathVariable id: UUID): AuthorWithBooksDto = 30 | authorRepo[id].let { authorRecord -> 31 | authorRecord.toAuthorWithBooksDto( 32 | books = bookRepo.findAllByAuthorIdList(listOf(authorRecord.id)) 33 | ) 34 | } 35 | 36 | @PostMapping("/$API_AUTHORS/{id}") 37 | fun authorsUpdateOne(@PathVariable id: UUID, @RequestBody req: AuthorUpdateRequest): AuthorDto = 38 | authorRepo[id] 39 | .copy(modifiedAt = Instant.now(), name = req.name) 40 | .let { authorRepo.update(it) } 41 | .also { logger.info { "UPDATE DB ENTITY: $it" } } 42 | .toAuthorDto() 43 | 44 | @GetMapping("/$API_BOOKS") 45 | fun booksFindAll(): List = 46 | bookRepo.findAllBooksJoinAuthor() 47 | .map { it.toBookWithAuthorDto() } 48 | 49 | @GetMapping("/$API_BOOKS/{id}") 50 | fun booksGetOne(@PathVariable id: UUID): BookWithAuthorDto = 51 | bookRepo.requireOneJoinAuthor(id).toBookWithAuthorDto() 52 | 53 | @PutMapping("/$API_BOOKS") 54 | fun booksCreateOne(@RequestBody req: BookCreateRequest): BookWithAuthorDto = 55 | req.toBookRecord(id = UUID.randomUUID(), now = Instant.now()) 56 | .also { authorRepo.requireIdExists(req.authorId) } 57 | .let { bookRepo.insert(it) } 58 | .also { logger.info { "INSERT DB ENTITY: $it" } } 59 | .let { bookRepo.requireOneJoinAuthor(it.id) } 60 | .toBookWithAuthorDto() 61 | 62 | @PostMapping("/$API_BOOKS/{id}") 63 | fun booksUpdateOne(@PathVariable id: UUID, @RequestBody req: BookUpdateRequest): BookWithAuthorDto = 64 | bookRepo[id] 65 | .copy(modifiedAt = Instant.now(), status = req.status, title = req.title, price = req.price) 66 | .let { bookRepo.update(it) } 67 | .also { logger.info { "UPDATE DB ENTITY: $it" } } 68 | .let { bookRepo.requireOneJoinAuthor(it.id) } 69 | .toBookWithAuthorDto() 70 | 71 | companion object : KLogging() { 72 | private const val API_AUTHORS = "api/bookstore/authors" 73 | private const val API_BOOKS = "api/bookstore/books" 74 | } 75 | } 76 | 77 | 78 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/ApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore 2 | 3 | import com.example.api.bookstore.db.AuthorRecord 4 | import com.example.api.bookstore.db.BookRecord 5 | import com.example.api.bookstore.db.BookRecordJoinAuthorRecord 6 | import com.example.api.bookstore.db.BookStatus 7 | import java.math.BigDecimal 8 | import java.time.Instant 9 | import java.util.* 10 | 11 | data class AuthorCreateRequest(val name: String) 12 | data class AuthorUpdateRequest(val name: String) 13 | data class BookCreateRequest(val authorId: UUID, val title: String, val status: BookStatus, val price: BigDecimal) 14 | data class BookUpdateRequest(val title: String, val status: BookStatus, val price: BigDecimal) 15 | 16 | data class AuthorDto(val id: UUID, val createdAt: Instant, val modifiedAt: Instant, val name: String) 17 | data class AuthorWithBooksDto(val author: AuthorDto, val books: List) 18 | 19 | data class BookDto( 20 | val id: UUID, val createdAt: Instant, val modifiedAt: Instant, 21 | val title: String, val status: BookStatus, val price: BigDecimal, 22 | val authorId: UUID 23 | ) 24 | 25 | data class BookWithAuthorDto( 26 | val id: UUID, val createdAt: Instant, val modifiedAt: Instant, 27 | val title: String, val status: BookStatus, val price: BigDecimal, 28 | val author: AuthorDto 29 | ) 30 | 31 | fun AuthorCreateRequest.toAuthorRecord(id: UUID, now: Instant): AuthorRecord = 32 | AuthorRecord(id = id, version = 0, createdAt = now, modifiedAt = now, name = name) 33 | 34 | fun BookCreateRequest.toBookRecord(id: UUID, now: Instant): BookRecord = 35 | BookRecord( 36 | id = id, version = 0, createdAt = now, modifiedAt = now, 37 | authorId = authorId, 38 | title = title, status = status, price = price 39 | ) 40 | 41 | fun AuthorRecord.toAuthorDto() = 42 | AuthorDto(id = id, createdAt = createdAt, modifiedAt = modifiedAt, name = name) 43 | 44 | fun BookRecord.toBookDto() = 45 | BookDto( 46 | id = id, createdAt = createdAt, modifiedAt = modifiedAt, 47 | authorId = authorId, 48 | title = title, status = status, price = price 49 | ) 50 | 51 | fun AuthorRecord.toAuthorWithBooksDto(books: List) = 52 | AuthorWithBooksDto(author = this.toAuthorDto(), books = books.map { it.toBookDto() }) 53 | 54 | fun BookRecordJoinAuthorRecord.toBookWithAuthorDto() = 55 | BookWithAuthorDto( 56 | id = bookRecord.id, 57 | createdAt = bookRecord.createdAt, 58 | modifiedAt = bookRecord.modifiedAt, 59 | title = bookRecord.title, 60 | status = bookRecord.status, 61 | price = bookRecord.price, 62 | author = authorRecord.toAuthorDto() 63 | ) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/db/AuthorRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.db 2 | 3 | import com.example.api.common.rest.error.exception.EntityNotFoundException 4 | import org.jetbrains.exposed.sql.* 5 | import org.springframework.stereotype.Repository 6 | import org.springframework.transaction.annotation.Transactional 7 | import java.util.* 8 | 9 | @Repository 10 | @Transactional // Should be at @Service level in real applications 11 | class AuthorRepository { 12 | private val crudTable = AuthorTable 13 | 14 | fun insert(authorRecord: AuthorRecord): AuthorRecord = crudTable 15 | .insert { 16 | it[id] = authorRecord.id 17 | it[createdAt] = authorRecord.createdAt 18 | it[modifiedAt] = authorRecord.modifiedAt 19 | it[version] = authorRecord.version 20 | it[name] = authorRecord.name 21 | }.let { this[authorRecord.id] } 22 | 23 | fun update(authorRecord: AuthorRecord): AuthorRecord = updatePartial(authorRecord = authorRecord) { cols: ColumnList -> 24 | cols - listOf(crudTable.id) 25 | } 26 | 27 | fun updatePartial(authorRecord: AuthorRecord, columnsToUpdate: (ColumnList) -> ColumnList): AuthorRecord { 28 | val recordId = authorRecord.id 29 | val fieldsToUpdate: ColumnList = columnsToUpdate(crudTable.columns) 30 | if (fieldsToUpdate.isEmpty()) { 31 | return this[recordId] 32 | } 33 | 34 | return crudTable.update({ crudTable.id eq recordId }) { stmt -> 35 | listOf( 36 | Pair(id, { stmt[id] = authorRecord.id }), 37 | Pair(createdAt, { stmt[createdAt] = authorRecord.createdAt }), 38 | Pair(modifiedAt, { stmt[modifiedAt] = authorRecord.modifiedAt }), 39 | Pair(version, { stmt[version] = authorRecord.version }), 40 | Pair(name, { stmt[name] = authorRecord.name }) 41 | ) 42 | .filter { p -> p.first in fieldsToUpdate } 43 | .forEach { p -> p.second() } 44 | }.let { this[recordId] } 45 | } 46 | 47 | fun findAll(): List = crudTable.selectAll().map { it.toAuthorRecord() } 48 | fun requireIdExists(id: UUID): UUID = this[id].id 49 | 50 | operator fun get(id: UUID): AuthorRecord = findOneById(id) 51 | ?: throw EntityNotFoundException("AuthorRecord NOT FOUND ! (id=$id)") 52 | 53 | fun findOneById(id: UUID): AuthorRecord? = crudTable 54 | .select { AuthorTable.id eq id } 55 | .limit(1) 56 | .map { it.toAuthorRecord() } 57 | .firstOrNull() 58 | 59 | } 60 | 61 | private fun ResultRow.toAuthorRecord() = AuthorTable.rowToAuthorRecord(this) 62 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/db/AuthorTable.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.db 2 | 3 | import com.example.util.exposed.columnTypes.instant 4 | import org.jetbrains.exposed.sql.ResultRow 5 | import org.jetbrains.exposed.sql.Table 6 | import java.time.Instant 7 | import java.util.* 8 | 9 | object AuthorTable : Table("author") { 10 | val id = uuid("id") 11 | override val primaryKey: PrimaryKey = PrimaryKey(id, name = "author_pkey") 12 | val createdAt = instant("created_at") 13 | val modifiedAt = instant("updated_at") 14 | val version = integer("version") 15 | val name = text("name") 16 | } 17 | 18 | data class AuthorRecord( 19 | val id: UUID, val createdAt: Instant, val modifiedAt: Instant, val version: Int, 20 | val name: String 21 | ) 22 | 23 | fun AuthorTable.rowToAuthorRecord(row: ResultRow): AuthorRecord = 24 | AuthorRecord( 25 | id = row[id], 26 | createdAt = row[createdAt], 27 | modifiedAt = row[modifiedAt], 28 | version = row[version], 29 | name = row[name] 30 | ) 31 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/db/BookRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.db 2 | 3 | import com.example.api.common.rest.error.exception.EntityNotFoundException 4 | import org.jetbrains.exposed.sql.* 5 | import org.springframework.stereotype.Repository 6 | import org.springframework.transaction.annotation.Transactional 7 | import java.util.* 8 | 9 | @Repository 10 | @Transactional // Should be at @Service level in real applications 11 | class BookRepository { 12 | val crudTable = BookTable 13 | 14 | fun insert(bookRecord: BookRecord): BookRecord = crudTable 15 | .insert { 16 | it[id] = bookRecord.id 17 | it[authorId] = bookRecord.authorId 18 | it[createdAt] = bookRecord.createdAt 19 | it[modifiedAt] = bookRecord.modifiedAt 20 | it[version] = bookRecord.version 21 | it[title] = bookRecord.title 22 | it[status] = bookRecord.status 23 | it[price] = bookRecord.price 24 | }.let { this[bookRecord.id] } 25 | 26 | fun update(bookRecord: BookRecord): BookRecord = updatePartial(bookRecord = bookRecord) { cols: ColumnList -> 27 | cols.filter { it != crudTable.id } 28 | } 29 | 30 | fun updatePartial(bookRecord: BookRecord, columnsToUpdate: (ColumnList) -> ColumnList): BookRecord { 31 | val recordId = bookRecord.id 32 | val fieldsToUpdate: ColumnList = columnsToUpdate(crudTable.columns) 33 | if (fieldsToUpdate.isEmpty()) { 34 | return this[recordId] 35 | } 36 | 37 | return crudTable.update({ crudTable.id eq recordId }) { stmt -> 38 | listOf( 39 | Pair(id, { stmt[id] = bookRecord.id }), 40 | Pair(createdAt, { stmt[createdAt] = bookRecord.createdAt }), 41 | Pair(modifiedAt, { stmt[modifiedAt] = bookRecord.modifiedAt }), 42 | Pair(version, { stmt[version] = bookRecord.version }), 43 | Pair(title, { stmt[title] = bookRecord.title }), 44 | Pair(status, { stmt[status] = bookRecord.status }), 45 | Pair(price, { stmt[price] = bookRecord.price }) 46 | ) 47 | .filter { p -> p.first in fieldsToUpdate } 48 | .forEach { p -> p.second() } 49 | }.let { this[recordId] } 50 | } 51 | 52 | 53 | operator fun get(id: UUID): BookRecord = findOneById(id) 54 | ?: throw EntityNotFoundException("BookRecord NOT FOUND ! (id=$id)") 55 | 56 | fun findOneById(id: UUID): BookRecord? = crudTable 57 | .select { crudTable.id eq id } 58 | .limit(1) 59 | .map { it.toBookRecord() } 60 | .firstOrNull() 61 | 62 | fun findByIdList(ids: List): List = crudTable 63 | .select { BookTable.id inList ids.distinct() } 64 | .map { it.toBookRecord() } 65 | 66 | fun findAll(): List = crudTable.selectAll().map { it.toBookRecord() } 67 | 68 | fun findAllByAuthorIdList(ids: List): List = crudTable 69 | .select { crudTable.authorId inList ids.distinct() } 70 | .map { it.toBookRecord() } 71 | 72 | fun findAllBooksJoinAuthor() = 73 | (AuthorTable innerJoin crudTable) 74 | .selectAll() 75 | .map { 76 | BookRecordJoinAuthorRecord( 77 | bookRecord = it.toBookRecord(), 78 | authorRecord = it.toAuthorRecord() 79 | ) 80 | } 81 | 82 | fun findOneJoinAuthor(id: UUID) = 83 | (AuthorTable innerJoin crudTable) 84 | .select { crudTable.id eq id } 85 | .limit(1) 86 | .map { 87 | BookRecordJoinAuthorRecord( 88 | bookRecord = it.toBookRecord(), 89 | authorRecord = it.toAuthorRecord() 90 | ) 91 | } 92 | .firstOrNull() 93 | 94 | fun requireOneJoinAuthor(id: UUID): BookRecordJoinAuthorRecord = 95 | findOneJoinAuthor(id) ?: throw EntityNotFoundException("BookRecord NOT FOUND ! (id=$id)") 96 | 97 | 98 | } 99 | 100 | data class BookRecordJoinAuthorRecord(val bookRecord: BookRecord, val authorRecord: AuthorRecord) 101 | 102 | private fun ResultRow.toBookRecord() = BookTable.rowToBookRecord(this) 103 | private fun ResultRow.toAuthorRecord() = AuthorTable.rowToAuthorRecord(this) 104 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/db/BookTable.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.db 2 | 3 | import com.example.util.exposed.columnTypes.instant 4 | import org.jetbrains.exposed.sql.ResultRow 5 | import org.jetbrains.exposed.sql.Table 6 | import java.math.BigDecimal 7 | import java.time.Instant 8 | import java.util.* 9 | 10 | object BookTable : Table("book") { 11 | val id = uuid("id") 12 | override val primaryKey: PrimaryKey = PrimaryKey(id, name = "book_pkey") 13 | val createdAt = instant("created_at") 14 | val modifiedAt = instant("updated_at") 15 | val version = integer("version") 16 | val authorId = (uuid("author_id") references AuthorTable.id) 17 | val title = varchar("title", 255) 18 | val status = enumerationByName("status", 255, BookStatus::class) 19 | val price = decimal("price", 15, 2) 20 | } 21 | 22 | data class BookRecord( 23 | val id: UUID, val createdAt: Instant, val modifiedAt: Instant, val version: Int, 24 | val authorId: UUID, 25 | val title: String, val status: BookStatus, val price: BigDecimal 26 | ) 27 | 28 | enum class BookStatus { NEW, PUBLISHED; } 29 | 30 | fun BookTable.rowToBookRecord(row: ResultRow): BookRecord = 31 | BookRecord( 32 | id = row[id], createdAt = row[createdAt], modifiedAt = row[modifiedAt], version = row[version], 33 | authorId = row[authorId], 34 | title = row[title], status = row[status], price = row[price] 35 | ) 36 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookstore/db/Common.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.db 2 | 3 | import org.jetbrains.exposed.sql.Column 4 | 5 | typealias ColumnList = List> 6 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/ApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz 2 | 3 | import com.example.api.bookz.db.BookzData 4 | import com.example.api.bookz.db.BookzRecord 5 | import java.time.Instant 6 | import java.util.* 7 | 8 | data class BookzDto(val id: UUID, val createdAt: Instant, val modifiedAt: Instant, val isActive:Boolean, val data: BookzData) 9 | 10 | fun BookzRecord.toBookzDto() = 11 | BookzDto(id = id, createdAt = createdAt, modifiedAt = modifiedAt, data = data, isActive = isActive) 12 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/BookzApiController.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz 2 | 3 | import com.example.api.bookz.db.BookzRecord 4 | import com.example.api.bookz.db.BookzTable 5 | import com.example.api.bookz.db.crudRecordId 6 | import com.example.api.bookz.db.toBookzRecord 7 | import com.example.api.bookz.handler.bulkSave.BookzBulkSaveRequest 8 | import com.example.api.bookz.handler.bulkSave.BulkSaveHandler 9 | import com.example.api.bookz.handler.createOne.BookzCreateHandler 10 | import com.example.api.bookz.handler.createOne.BookzCreateRequest 11 | import com.example.api.bookz.handler.findAll.BookzFindAllHandler 12 | import com.example.api.bookz.handler.getOneById.BookzGetOneByIdHandler 13 | import com.example.api.bookz.handler.getOneById.BookzGetOneByIdRequest 14 | import com.example.api.bookz.handler.updateOneById.BookzUpdateOneByIdHandler 15 | import com.example.api.bookz.handler.updateOneById.BookzUpdateOneByIdRequest 16 | import com.example.api.bookz.handler.updateOneById.BookzUpdateOnePayload 17 | import com.example.util.exposed.spring.transaction.SpringTransactionTemplate 18 | import com.example.util.exposed.spring.transaction.invoke 19 | import mu.KLogging 20 | import org.jetbrains.exposed.spring.SpringTransactionManager 21 | import org.jetbrains.exposed.sql.insert 22 | import org.jetbrains.exposed.sql.selectAll 23 | import org.springframework.web.bind.annotation.* 24 | import java.util.* 25 | 26 | @RestController 27 | class BookzApiController( 28 | private val createOne: BookzCreateHandler, 29 | private val updateOne: BookzUpdateOneByIdHandler, 30 | private val getOneById: BookzGetOneByIdHandler, 31 | private val findAll: BookzFindAllHandler, 32 | private val bulkSaveHandler: BulkSaveHandler, 33 | private val pf: SpringTransactionManager 34 | ) { 35 | 36 | // jsonb examples: see: https://www.compose.com/articles/faster-operations-with-the-jsonb-data-type-in-postgresql/ 37 | 38 | @PutMapping("/api/$API_NAME/books") 39 | fun booksCreateOne(@RequestBody req: BookzCreateRequest): BookzDto = 40 | BookzCreateRequest(data = req.data) 41 | .let { createOne.handle(it) } 42 | 43 | @GetMapping("/api/$API_NAME/books") 44 | fun booksFindAll(): List = findAll.handle() 45 | 46 | @GetMapping("/api/$API_NAME/books/{id}") 47 | fun booksGetOne(@PathVariable id: UUID): BookzDto = BookzGetOneByIdRequest(id = id) 48 | .let { getOneById.handle(it) } 49 | 50 | @PostMapping("/api/$API_NAME/books/{id}") 51 | fun booksUpdateOne(@PathVariable id: UUID, @RequestBody payload: BookzUpdateOnePayload): BookzDto = 52 | BookzUpdateOneByIdRequest(id = id, data = payload.data) 53 | .let { updateOne.handle(it) } 54 | 55 | @PostMapping("/api/$API_NAME/books/bulk-save") 56 | fun bulkSave(): List = 57 | BookzBulkSaveRequest(limit = 2) 58 | .let { bulkSaveHandler.handle(it) } 59 | 60 | @PostMapping("/api/$API_NAME/experimental/transaction-demos/001") 61 | fun transactionDemo001(): Any? { 62 | val outer = SpringTransactionTemplate(pf) 63 | .execute { 64 | BookzTable.findAll() 65 | 66 | SpringTransactionTemplate(pf) { 67 | propagationNested() 68 | }.tryExecute { BookzTable.findAll() } 69 | 70 | SpringTransactionTemplate(pf) { 71 | propagationNested() 72 | }.tryExecute { BookzTable.findAll() } 73 | 74 | SpringTransactionTemplate(pf) { 75 | propagationNested() 76 | }.tryExecute { 77 | BookzTable.findAll() 78 | error("foo") 79 | }.onFailure { 80 | println("FAILURE") 81 | } 82 | 83 | SpringTransactionTemplate(pf) { 84 | propagationNested() 85 | }.tryExecute { BookzTable.findAll() } 86 | 87 | SpringTransactionTemplate(pf) { 88 | propagationNested() 89 | }.tryExecute { BookzTable.findAll() } 90 | 91 | SpringTransactionTemplate(pf) { 92 | propagationRequiresNew() 93 | }.tryExecute { BookzTable.findAll() } 94 | SpringTransactionTemplate(pf) { 95 | propagationNested() 96 | }.tryExecute { BookzTable.findAll() } 97 | SpringTransactionTemplate(pf) { 98 | propagationNested() 99 | }.tryExecute { BookzTable.findAll() } 100 | } 101 | return null 102 | } 103 | 104 | fun BookzTable.insert(record: BookzRecord): BookzRecord { 105 | val r = insert { 106 | it[id] = record.crudRecordId() 107 | it[createdAt] = record.createdAt 108 | it[modifiedAt] = record.modifiedAt 109 | it[isActive] = record.isActive 110 | it[data] = record.data 111 | }.let { getRowById(record.id) } 112 | .toBookzRecord() 113 | return r 114 | } 115 | 116 | fun BookzTable.findAll() = selectAll().map { it.toBookzRecord() } 117 | 118 | companion object : KLogging() { 119 | const val API_NAME = "bookz-jsonb" 120 | } 121 | } 122 | 123 | 124 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/db/BookzRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.db 2 | 3 | import com.example.util.exposed.crud.UUIDCrudRepo 4 | import com.example.util.exposed.crud.UUIDCrudTable 5 | import com.example.util.exposed.crud.updateRowById 6 | import mu.KLogging 7 | import org.jetbrains.exposed.sql.* 8 | import org.jetbrains.exposed.sql.statements.UpdateStatement 9 | import org.springframework.stereotype.Repository 10 | import org.springframework.transaction.annotation.Transactional 11 | import java.time.Instant 12 | import java.util.* 13 | 14 | 15 | private typealias CrudTable = BookzTable 16 | private typealias CrudRecord = BookzRecord 17 | 18 | @Repository 19 | @Transactional // Should be at @Service level in real applications 20 | class BookzRepo : UUIDCrudRepo() { 21 | companion object : KLogging() 22 | 23 | override val table = CrudTable 24 | override val mapr: (ResultRow) -> CrudRecord = ResultRow::toBookzRecord 25 | 26 | fun newRecord(id: UUID, data: BookzData, now: Instant): CrudRecord = 27 | CrudRecord( 28 | id = id, 29 | createdAt = now, 30 | modifiedAt = now, 31 | isActive = true, 32 | data = data 33 | ) 34 | 35 | fun insert(record: BookzRecord): BookzRecord = table 36 | .insert { 37 | it[id] = record.crudRecordId() 38 | it[createdAt] = record.createdAt 39 | it[modifiedAt] = record.modifiedAt 40 | it[isActive] = record.isActive 41 | it[data] = record.data 42 | } 43 | .let { this[record.crudRecordId()] } 44 | .also { logger.info { "INSERT: table: ${table.tableName} id: ${record.crudRecordId()} record: $it" } } 45 | 46 | fun update(record: BookzRecord): BookzRecord = table 47 | .update({ table.id eq record.id }) { 48 | it[id] = record.crudRecordId() 49 | it[createdAt] = record.createdAt 50 | it[modifiedAt] = record.modifiedAt 51 | it[isActive] = record.isActive 52 | it[data] = record.data 53 | } 54 | .let { this[record.crudRecordId()] } 55 | .also { logger.info { "UPDATE: table: ${table.tableName} id: ${record.crudRecordId()} record: $it" } } 56 | 57 | fun updateOne(id: UUID, body: CrudTable.(UpdateStatement) -> Unit) = 58 | table.updateRowById(id, body = body) 59 | .let { this[id] } 60 | 61 | fun insertOrUpdate(insertRecord: CrudRecord, updateStatement: CrudTable.(UpdateStatement) -> Unit): CrudRecord { 62 | return when (val oldRecordId = findOne(insertRecord.crudRecordId())?.crudRecordId()) { 63 | null -> insert(record = insertRecord) 64 | else -> updateOne(oldRecordId, updateStatement) 65 | } 66 | } 67 | 68 | fun findAll() = 69 | table.selectAll().map { mapr(it) } 70 | 71 | fun findAllActive() = 72 | table.select { table.isActive eq true }.map { mapr(it) } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/db/BookzTable.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.db 2 | 3 | import com.example.util.exposed.columnTypes.instant 4 | import com.example.util.exposed.columnTypes.jsonb 5 | import com.example.util.exposed.crud.UUIDCrudTable 6 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 7 | import org.jetbrains.exposed.sql.Column 8 | import org.jetbrains.exposed.sql.ResultRow 9 | import java.time.Instant 10 | import java.util.* 11 | 12 | 13 | object BookzTable : UUIDCrudTable("bookz") { 14 | val id = uuid("id") 15 | override val primaryKey: PrimaryKey = PrimaryKey(id, name = "bookz_pkey") 16 | val createdAt = instant("created_at") 17 | val modifiedAt = instant("updated_at") 18 | val isActive = bool("is_active") 19 | val data = jsonb("data", BookzData::class.java, jacksonObjectMapper()) 20 | 21 | override val crudIdColumn: () -> Column = { id } 22 | } 23 | 24 | data class BookzRecord( 25 | val id: UUID, val createdAt: Instant, val modifiedAt: Instant, 26 | val isActive: Boolean, 27 | val data: BookzData 28 | ) 29 | 30 | data class BookzData(val title: String, val genres: List, val published: Boolean) 31 | 32 | fun BookzRecord.crudRecordId(): UUID = id 33 | 34 | fun BookzTable.rowToBookzRecord(row: ResultRow): BookzRecord = BookzRecord( 35 | id = row[id], 36 | createdAt = row[createdAt], 37 | modifiedAt = row[modifiedAt], 38 | isActive = row[isActive], 39 | data = row[data] 40 | ) 41 | 42 | fun ResultRow.toBookzRecord(): BookzRecord = BookzTable.rowToBookzRecord(this) 43 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/bulkSave/Handler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.bulkSave 2 | 3 | import com.example.api.bookz.BookzDto 4 | import com.example.api.bookz.db.BookzData 5 | import com.example.api.bookz.db.BookzRecord 6 | import com.example.api.bookz.db.BookzRepo 7 | import com.example.api.bookz.toBookzDto 8 | import mu.KLogging 9 | import org.springframework.stereotype.Component 10 | import org.springframework.transaction.annotation.Transactional 11 | import java.time.Instant 12 | import java.util.* 13 | 14 | private typealias Request = BookzBulkSaveRequest 15 | private typealias Response = List 16 | 17 | @Component 18 | class BulkSaveHandler(private val repo: BookzRepo) { 19 | private val ids = listOf( 20 | "c0c0d4aa-0de8-406a-9afa-8e72fe2e4739", 21 | "47c9bace-0066-48ac-80bf-0f3d57e99d33", 22 | "9c7dacc1-815f-48d7-89df-ae2f3a3006f4" 23 | ).map { UUID.fromString(it) } 24 | 25 | @Transactional 26 | fun handle(req: Request): Response = req 27 | .let { insertOrUpdateDb(it) } 28 | .let { mapToResponse(it) } 29 | 30 | private fun mapToResponse(records: List): Response = 31 | records.map { it.toBookzDto() } 32 | 33 | private fun insertOrUpdateDb(req: Request): List { 34 | val newRecords = (0..req.limit) 35 | .map { 36 | val id = UUID.randomUUID() 37 | repo.newRecord( 38 | id = id, now = Instant.now(), 39 | data = BookzData( 40 | published = true, title = "Some Title $id", 41 | genres = listOf("genreA", "genreB") 42 | ) 43 | ) 44 | } 45 | .also { logger.info { "INSERT DB ENTITY ...: $it" } } 46 | .map { repo.insert(it) } 47 | .also { logger.info { "INSERTED DB ENTITY: $it" } } 48 | 49 | val oldRecords = ids.map { 50 | when (val record = repo.findOne(it)) { 51 | null -> { 52 | val id = UUID.randomUUID() 53 | repo.newRecord( 54 | id = id, now = Instant.now(), 55 | data = BookzData( 56 | published = true, title = "Some Title $id", 57 | genres = listOf("genreA", "genreB") 58 | ) 59 | ) 60 | .also { logger.info { "INSERT DB ENTITY ...: $it" } } 61 | .let { repo.insert(it) } 62 | .also { logger.info { "INSERTED DB ENTITY: $it" } } 63 | } 64 | else -> { 65 | val now = Instant.now() 66 | val id = record.id 67 | repo.updateOne(id) { 68 | it[modifiedAt] = now 69 | it[data] = record.data.copy(title = "Some Title $id - updatedAt: $now") 70 | } 71 | .also { logger.info { "UPDATED DB ENTITY: $it" } } 72 | } 73 | } 74 | } 75 | 76 | val allRecords = newRecords + oldRecords 77 | 78 | return allRecords 79 | } 80 | 81 | companion object : KLogging() 82 | } 83 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/bulkSave/Request.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.bulkSave 2 | 3 | data class BookzBulkSaveRequest(val limit: Int) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/createOne/Handler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.createOne 2 | 3 | import com.example.api.bookz.BookzDto 4 | import com.example.api.bookz.db.BookzRecord 5 | import com.example.api.bookz.db.BookzRepo 6 | import com.example.api.bookz.toBookzDto 7 | import mu.KLogging 8 | import org.springframework.stereotype.Component 9 | import org.springframework.transaction.annotation.Transactional 10 | import java.time.Instant 11 | import java.util.* 12 | 13 | private typealias Request = BookzCreateRequest 14 | private typealias Response = BookzDto 15 | 16 | @Component 17 | class BookzCreateHandler(private val repo: BookzRepo) { 18 | 19 | @Transactional 20 | fun handle(req: Request): Response = req 21 | .let { insertIntoDb(it) } 22 | .let { mapToResponse(it) } 23 | 24 | private fun mapToResponse(it: BookzRecord) = it.toBookzDto() 25 | 26 | private fun insertIntoDb(req: Request) = 27 | repo.newRecord(id = UUID.randomUUID(), now = Instant.now(), data = req.data) 28 | .let { repo.insert(record = it) } 29 | .also { logger.info { "INSERT DB ENTITY: $it" } } 30 | 31 | companion object : KLogging() 32 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/createOne/Request.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.createOne 2 | 3 | import com.example.api.bookz.db.BookzData 4 | 5 | data class BookzCreateRequest(val data: BookzData) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/findAll/Handler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.findAll 2 | 3 | import com.example.api.bookz.BookzDto 4 | import com.example.api.bookz.db.BookzRecord 5 | import com.example.api.bookz.db.BookzRepo 6 | import com.example.api.bookz.toBookzDto 7 | import org.springframework.stereotype.Component 8 | import org.springframework.transaction.annotation.Transactional 9 | 10 | private typealias Response = List 11 | 12 | @Component 13 | class BookzFindAllHandler( 14 | private val repo: BookzRepo 15 | ) { 16 | 17 | @Transactional(readOnly = true) 18 | fun handle(): Response = loadFromDb() 19 | .let { mapToResponse(it) } 20 | 21 | private fun mapToResponse(items: List) = items 22 | .map { it.toBookzDto() } 23 | 24 | private fun loadFromDb(): List = repo.findAllActive() 25 | 26 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/getOneById/Handler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.getOneById 2 | 3 | import com.example.api.bookz.BookzDto 4 | import com.example.api.bookz.db.BookzRecord 5 | import com.example.api.bookz.db.BookzRepo 6 | import com.example.api.bookz.toBookzDto 7 | import org.springframework.stereotype.Component 8 | import org.springframework.transaction.annotation.Transactional 9 | 10 | private typealias Request = BookzGetOneByIdRequest 11 | private typealias Response = BookzDto 12 | 13 | @Component 14 | class BookzGetOneByIdHandler(private val repo: BookzRepo) { 15 | 16 | @Transactional(readOnly = true) 17 | fun handle(req: Request): Response = req 18 | .let { loadFromDb(it) } 19 | .let { mapToResponse(it) } 20 | 21 | private fun mapToResponse(it: BookzRecord) = it.toBookzDto() 22 | private fun loadFromDb(req: Request): BookzRecord = repo[req.id] 23 | } 24 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/getOneById/Request.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.getOneById 2 | 3 | import java.util.* 4 | 5 | data class BookzGetOneByIdRequest(val id: UUID) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/updateOneById/Handler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.updateOneById 2 | 3 | import com.example.api.bookz.BookzDto 4 | import com.example.api.bookz.db.BookzRecord 5 | import com.example.api.bookz.db.BookzRepo 6 | import com.example.api.bookz.toBookzDto 7 | import mu.KLogging 8 | import org.springframework.stereotype.Component 9 | import org.springframework.transaction.annotation.Transactional 10 | import java.time.Instant 11 | 12 | private typealias Request = BookzUpdateOneByIdRequest 13 | private typealias Response = BookzDto 14 | 15 | @Component 16 | class BookzUpdateOneByIdHandler(private val repo: BookzRepo) { 17 | 18 | @Transactional 19 | fun handle(req: Request): Response = req 20 | .let { saveToDb(it) } 21 | .let { mapToResponse(it) } 22 | 23 | private fun mapToResponse(it: BookzRecord) = it.toBookzDto() 24 | 25 | private fun saveToDb(req: Request) = 26 | repo.updateOne(req.id) { 27 | it[modifiedAt] = Instant.now() 28 | it[data] = req.data 29 | } 30 | .also { logger.info { "UPDATE DB ENTITY: $it" } } 31 | 32 | companion object : KLogging() 33 | 34 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/bookz/handler/updateOneById/Request.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.handler.updateOneById 2 | 3 | import com.example.api.bookz.db.BookzData 4 | import java.util.* 5 | 6 | data class BookzUpdateOneByIdRequest(val id: UUID, val data: BookzData) 7 | 8 | data class BookzUpdateOnePayload(val data: BookzData) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/common/rest/error/exception/ApiExceptions.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.common.rest.error.exception 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) 8 | 9 | @ResponseStatus(value = HttpStatus.BAD_REQUEST) 10 | class BadRequestException(message: String) : RuntimeException(message) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/common/rest/error/handler/ApiExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.common.rest.error.handler 2 | 3 | 4 | import com.example.api.common.rest.error.exception.BadRequestException 5 | import com.example.api.common.rest.error.exception.EntityNotFoundException 6 | import mu.KLogging 7 | import org.springframework.http.HttpStatus 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.http.converter.HttpMessageNotReadableException 10 | import org.springframework.web.bind.annotation.ExceptionHandler 11 | import org.springframework.web.bind.annotation.RestControllerAdvice 12 | import java.time.Instant 13 | import java.util.* 14 | 15 | private typealias ApiResponseEntity = ResponseEntity 16 | 17 | @RestControllerAdvice 18 | class ApiExceptionHandler { 19 | companion object : KLogging() 20 | 21 | @ExceptionHandler(HttpMessageNotReadableException::class) 22 | fun handle(ex: HttpMessageNotReadableException): ApiResponseEntity = 23 | handleApiException( 24 | ex = ex, 25 | httpStatus = HttpStatus.BAD_REQUEST, 26 | apiErrorType = ApiError.ApiErrorType.BAD_REQUEST 27 | ) 28 | 29 | @ExceptionHandler(BadRequestException::class) 30 | fun handle(ex: BadRequestException): ApiResponseEntity = 31 | handleApiException( 32 | ex = ex, 33 | httpStatus = HttpStatus.BAD_REQUEST, 34 | apiErrorType = ApiError.ApiErrorType.BAD_REQUEST 35 | ) 36 | 37 | @ExceptionHandler(EntityNotFoundException::class) 38 | fun handle(ex: EntityNotFoundException): ApiResponseEntity = 39 | handleApiException( 40 | ex = ex, 41 | httpStatus = HttpStatus.NOT_FOUND, 42 | apiErrorType = ApiError.ApiErrorType.ENTITY_NOT_FOUND 43 | ) 44 | 45 | private fun handleApiException( 46 | ex: Exception, httpStatus: HttpStatus, apiErrorType: ApiError.ApiErrorType 47 | ): ApiResponseEntity { 48 | val logId = UUID.randomUUID() 49 | val responseBody: ApiErrorResponseBody = ex.toApiErrorResponseBody( 50 | logId = logId, httpStatus = httpStatus, type = apiErrorType 51 | ) 52 | val responseEntity: ApiResponseEntity = ResponseEntity(responseBody, httpStatus) 53 | logApiException(ex, logId, responseEntity) 54 | return responseEntity 55 | } 56 | 57 | private fun logApiException( 58 | ex: Exception, 59 | logId: UUID, 60 | responseEntity: ApiResponseEntity 61 | ) { 62 | logger.error { 63 | "Catch Exception! (logId: $logId)" + 64 | " message: ${ex.message}" + 65 | " clazz: ${ex::class.qualifiedName}" + 66 | " response.statusCode: ${responseEntity.statusCode}" + 67 | " response.body: ${responseEntity.body}" 68 | } 69 | logger.error("Error Log! (logId: $logId) ", ex) 70 | } 71 | } 72 | 73 | 74 | private fun Exception.toApiErrorResponseBody( 75 | logId: UUID, httpStatus: HttpStatus, type: ApiError.ApiErrorType 76 | ): ApiErrorResponseBody = 77 | ApiErrorResponseBody( 78 | status = httpStatus.value(), 79 | message = "$message", 80 | timestamp = Instant.now(), 81 | error = "${this::class.qualifiedName}", 82 | apiError = ApiError(logId = logId, type = type) 83 | ) 84 | 85 | data class ApiErrorResponseBody( 86 | val status: Int, 87 | val message: String, 88 | val timestamp: Instant, 89 | val error: String, 90 | val apiError: ApiError 91 | ) 92 | 93 | data class ApiError( 94 | val type: ApiErrorType, 95 | val logId: UUID 96 | ) { 97 | enum class ApiErrorType { 98 | BAD_REQUEST, 99 | ENTITY_NOT_FOUND 100 | ; 101 | } 102 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/common/rest/serialization/PatchableModule.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.common.rest.serialization 2 | 3 | import com.fasterxml.jackson.annotation.JsonValue 4 | import com.fasterxml.jackson.core.JsonParser 5 | import com.fasterxml.jackson.core.JsonToken 6 | import com.fasterxml.jackson.databind.BeanProperty 7 | import com.fasterxml.jackson.databind.DeserializationContext 8 | import com.fasterxml.jackson.databind.JsonDeserializer 9 | import com.fasterxml.jackson.databind.JsonSerializer 10 | import com.fasterxml.jackson.databind.deser.ContextualDeserializer 11 | import com.fasterxml.jackson.databind.jsontype.TypeSerializer 12 | import com.fasterxml.jackson.databind.module.SimpleModule 13 | import com.fasterxml.jackson.databind.ser.std.ReferenceTypeSerializer 14 | import com.fasterxml.jackson.databind.type.ReferenceType 15 | import com.fasterxml.jackson.databind.util.NameTransformer 16 | import org.funktionale.option.Option 17 | 18 | /** 19 | * see: https://stackoverflow.com/questions/55166379/deserialize-generic-type-using-referencetypedeserializer-with-jackson-spring 20 | */ 21 | 22 | class PatchableModule : SimpleModule() { 23 | override fun setupModule(context: SetupContext?) { 24 | super.setupModule(context) 25 | 26 | addDeserializer(Patchable::class.java, PatchableDeserializer()) 27 | // addSerializer(Patchable::class.java, PatchableSerializer::class) 28 | } 29 | } 30 | 31 | sealed class Patchable { 32 | class Undefined : Patchable() 33 | class Null : Patchable() 34 | data class Present(val content: T) : Patchable() 35 | 36 | @JsonValue 37 | fun value(): T? = 38 | when (this) { 39 | is Undefined -> null 40 | is Null -> null 41 | is Present -> content 42 | } 43 | 44 | companion object { 45 | // fun undefined() = Undefined() 46 | } 47 | } 48 | 49 | class PatchableDeserializer() : JsonDeserializer>(), ContextualDeserializer { 50 | 51 | private var valueType: Class<*>? = null 52 | 53 | constructor(valueType: Class<*>? = null) : this() { 54 | this.valueType = valueType 55 | } 56 | 57 | override fun createContextual(ctxt: DeserializationContext?, property: BeanProperty?): JsonDeserializer<*> { 58 | val wrapperType = property?.type 59 | 60 | val rawClass = wrapperType?.containedType(0)?.rawClass 61 | return PatchableDeserializer(rawClass) 62 | } 63 | 64 | override fun deserialize(p: JsonParser?, ctxt: DeserializationContext?): Patchable<*> { 65 | val f = p!!.readValueAs(valueType) 66 | return Patchable.Present(f) 67 | } 68 | 69 | 70 | override fun getNullValue(ctxt: DeserializationContext?): Patchable = 71 | if (ctxt?.parser?.currentToken == JsonToken.VALUE_NULL) 72 | Patchable.Null() 73 | //Patchable.ofNull() 74 | else Patchable.Undefined() 75 | //Patchable.undefined() 76 | } 77 | 78 | 79 | class PatchableSerializer : ReferenceTypeSerializer> // since 2.9 80 | { 81 | 82 | 83 | protected constructor(fullType: ReferenceType, staticTyping: Boolean, 84 | vts: TypeSerializer, ser: JsonSerializer) : super(fullType, staticTyping, vts, ser) { 85 | } 86 | 87 | protected constructor(base: PatchableSerializer, property: BeanProperty, 88 | vts: TypeSerializer, valueSer: JsonSerializer<*>, unwrapper: NameTransformer, 89 | suppressableValue: Any, suppressNulls: Boolean) : super(base, property, vts, valueSer, unwrapper, 90 | suppressableValue, suppressNulls) { 91 | } 92 | 93 | override fun withResolved(prop: BeanProperty, 94 | vts: TypeSerializer, valueSer: JsonSerializer<*>, 95 | unwrapper: NameTransformer): ReferenceTypeSerializer> { 96 | return PatchableSerializer(this, prop, vts, valueSer, unwrapper, 97 | _suppressableValue, _suppressNulls) 98 | } 99 | 100 | override fun withContentInclusion(suppressableValue: Any, 101 | suppressNulls: Boolean): ReferenceTypeSerializer> { 102 | return PatchableSerializer(this, _property, _valueTypeSerializer, 103 | _valueSerializer, _unwrapper, 104 | suppressableValue, suppressNulls) 105 | } 106 | 107 | 108 | override fun _isValuePresent(value: Patchable<*>): Boolean { 109 | return value is Patchable.Present 110 | } 111 | 112 | override fun _getReferenced(value: Patchable<*>): Any { 113 | return when (value) { 114 | is Patchable.Present -> value.content!! 115 | else -> error("foo") 116 | } 117 | } 118 | 119 | override fun _getReferencedIfPresent(value: Patchable<*>): Any? { 120 | return when (value) { 121 | is Patchable.Present -> value.content 122 | is Patchable.Null -> null 123 | else -> null 124 | } 125 | } 126 | 127 | companion object { 128 | private val serialVersionUID = 1L 129 | } 130 | } 131 | 132 | fun Patchable.toOption(): Option = 133 | when (this) { 134 | is Patchable.Present -> Option.Some(content) 135 | is Patchable.Null -> Option.Some(null) 136 | is Patchable.Undefined -> Option.None 137 | } 138 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/common/db/Record.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.common.db 2 | 3 | import java.math.BigDecimal 4 | import java.time.Instant 5 | import java.util.* 6 | 7 | data class PlaceRecord( 8 | // pk 9 | val place_id: UUID, 10 | // record meta 11 | val createdAt: Instant, 12 | val modified_at: Instant, 13 | val deleted_at: Instant?, 14 | val active: Boolean, 15 | // custom 16 | val placeName: String, 17 | val countryName: String, 18 | val cityName: String, 19 | val postalCode: String, 20 | val streetAddress: String, 21 | val formattedAddress: String, 22 | val latitude: BigDecimal, 23 | val longitude: BigDecimal 24 | ) 25 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/common/db/Repo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.common.db 2 | 3 | import com.example.api.common.rest.error.exception.EntityNotFoundException 4 | import org.jetbrains.exposed.sql.* 5 | import org.springframework.stereotype.Repository 6 | import org.springframework.transaction.annotation.Propagation 7 | import org.springframework.transaction.annotation.Transactional 8 | import java.time.Instant 9 | import java.util.* 10 | 11 | @Repository 12 | @Transactional(propagation = Propagation.MANDATORY) 13 | class PlaceRepo { 14 | private val table = PlaceTable 15 | 16 | fun findAll(isActive: Boolean?) = table 17 | .select { 18 | when (isActive) { 19 | null -> Op.TRUE 20 | else -> (table.active eq isActive) 21 | } 22 | } 23 | .map(table::mapRowToRecord) 24 | 25 | fun findByIdList(placeIds: Set, isActive: Boolean?): List = table 26 | .select { 27 | val op: Op = (table.place_id inList placeIds) 28 | when (isActive) { 29 | null -> op 30 | else -> op.and(table.active eq isActive) 31 | } 32 | } 33 | .map(table::mapRowToRecord) 34 | 35 | fun findById(placeId: UUID, isActive: Boolean?): PlaceRecord? = findByIdList( 36 | placeIds = setOf(placeId), 37 | isActive = isActive 38 | ).firstOrNull() 39 | 40 | fun getById(placeId: UUID, isActive: Boolean?): PlaceRecord = findById(placeId = placeId, isActive = isActive) 41 | ?: throw EntityNotFoundException("PlaceRecord not found ! (table: ${table.tableName} id: $placeId)") 42 | 43 | fun insert(record: PlaceRecord): PlaceRecord = table 44 | .insert { 45 | // pk 46 | it[place_id] = record.place_id 47 | // record meta 48 | it[createdAt] = record.createdAt 49 | it[modified_at] = record.modified_at 50 | it[deleted_at] = record.deleted_at 51 | it[active] = record.active 52 | // custom 53 | it[placeName] = record.placeName 54 | it[countryName] = record.countryName 55 | it[cityName] = record.cityName 56 | it[postalCode] = record.postalCode 57 | it[streetAddress] = record.streetAddress 58 | it[formattedAddress] = record.formattedAddress 59 | it[latitude] = record.latitude 60 | it[longitude] = record.longitude 61 | }.let { getById(placeId = record.place_id, isActive = null) } 62 | 63 | fun update(record: PlaceRecord): PlaceRecord = table 64 | .update({ table.place_id eq record.place_id }) { 65 | // pk 66 | // it[place_id] = record.place_id 67 | // record meta 68 | it[createdAt] = record.createdAt 69 | it[modified_at] = record.modified_at 70 | it[deleted_at] = record.deleted_at 71 | it[active] = record.active 72 | // custom 73 | it[placeName] = record.placeName 74 | it[countryName] = record.countryName 75 | it[cityName] = record.cityName 76 | it[postalCode] = record.postalCode 77 | it[streetAddress] = record.streetAddress 78 | it[formattedAddress] = record.formattedAddress 79 | it[latitude] = record.latitude 80 | it[longitude] = record.longitude 81 | }.let { getById(placeId = record.place_id, isActive = null) } 82 | 83 | fun softDeleteById(placeId: UUID, deletedAt: Instant): PlaceRecord = table 84 | .update({ table.place_id eq placeId }) { 85 | it[active] = false 86 | it[deleted_at] = deletedAt 87 | }.let { getById(placeId = placeId, isActive = null) } 88 | 89 | fun softRestoreById(placeId: UUID): PlaceRecord = table 90 | .update({ table.place_id eq placeId }) { 91 | it[active] = true 92 | it[deleted_at] = null 93 | }.let { getById(placeId = placeId, isActive = true) } 94 | } 95 | 96 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/common/db/Table.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.common.db 2 | 3 | import com.example.util.exposed.columnTypes.instant 4 | import org.jetbrains.exposed.sql.ResultRow 5 | import org.jetbrains.exposed.sql.Table 6 | 7 | object PlaceTable : Table("place") { 8 | // pk 9 | val place_id = uuid("place_id") 10 | override val primaryKey: PrimaryKey = PrimaryKey(place_id, name = "place_pkey") 11 | // record meta 12 | val createdAt = instant("created_at") 13 | val modified_at = instant("modified_at") 14 | val deleted_at = instant("deleted_at").nullable() 15 | val active = bool(name = "active") 16 | // custom 17 | val placeName = varchar(name = "place_name", length = 255) 18 | val countryName = varchar(name = "country_name", length = 255) 19 | val cityName = varchar(name = "city_name", length = 2048) 20 | val postalCode = varchar(name = "postal_code", length = 255) 21 | val streetAddress = varchar(name = "street_address", length = 2048) 22 | val formattedAddress = varchar(name = "formatted_address", length = 2048) 23 | val latitude = decimal(name = "latitude", precision = 10, scale = 6) 24 | val longitude = decimal(name = "longitude", precision = 10, scale = 6) 25 | 26 | fun mapRowToRecord(row: ResultRow): PlaceRecord = PlaceRecord( 27 | // pk 28 | place_id = row[place_id], 29 | // record meta 30 | createdAt = row[createdAt], 31 | modified_at = row[modified_at], 32 | deleted_at = row[deleted_at], 33 | active = row[active], 34 | // custom 35 | placeName = row[placeName], 36 | countryName = row[countryName], 37 | cityName = row[cityName], 38 | postalCode = row[postalCode], 39 | streetAddress = row[streetAddress], 40 | formattedAddress = row[formattedAddress], 41 | latitude = row[latitude], 42 | longitude = row[longitude] 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/common/rest/mutation/Mutations.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.common.rest.mutation 2 | 3 | import com.example.api.places.common.db.PlaceRecord 4 | import io.swagger.annotations.ApiModel 5 | import java.math.BigDecimal 6 | import java.time.Instant 7 | import java.util.* 8 | 9 | private const val SWAGGER_PREFIX = "PlacesApiMutation" 10 | 11 | sealed class Mutations { 12 | 13 | @ApiModel("${SWAGGER_PREFIX}_CreatePlace") 14 | data class CreatePlace( 15 | // custom 16 | val placeName: String, 17 | val countryName: String, 18 | val cityName: String, 19 | val postalCode: String, 20 | val streetAddress: String, 21 | val formattedAddress: String, 22 | val latitude: BigDecimal, 23 | val longitude: BigDecimal 24 | ) 25 | } 26 | 27 | fun Mutations.CreatePlace.toRecord(placeId: UUID, now: Instant): PlaceRecord = 28 | PlaceRecord( 29 | place_id = placeId, 30 | createdAt = now, 31 | modified_at = now, 32 | deleted_at = null, 33 | active = true, 34 | placeName = placeName, 35 | countryName = countryName, 36 | cityName = cityName, 37 | postalCode = postalCode, 38 | streetAddress = streetAddress, 39 | formattedAddress = formattedAddress, 40 | latitude = latitude, 41 | longitude = longitude 42 | ) 43 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/common/rest/response/PlaceDto.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.common.rest.response 2 | 3 | import com.example.api.places.common.db.PlaceRecord 4 | import java.math.BigDecimal 5 | import java.time.Instant 6 | import java.util.* 7 | 8 | data class PlaceDto( 9 | // pk 10 | val placeId: UUID, 11 | // record meta 12 | val createdAt: Instant, 13 | val modified_at: Instant, 14 | val deletedAt: Instant?, 15 | val active: Boolean, 16 | // custom 17 | val placeName: String, 18 | val countryName: String, 19 | val cityName: String, 20 | val postalCode: String, 21 | val streetAddress: String, 22 | val formattedAddress: String, 23 | val latitude: BigDecimal, 24 | val longitude: BigDecimal 25 | ) 26 | 27 | fun PlaceRecord.toPlaceDto(): PlaceDto = 28 | PlaceDto( 29 | placeId = place_id, 30 | createdAt = createdAt, 31 | modified_at = modified_at, 32 | deletedAt = deleted_at, 33 | active = active, 34 | placeName = placeName, 35 | countryName = countryName, 36 | cityName = cityName, 37 | postalCode = postalCode, 38 | streetAddress = streetAddress, 39 | formattedAddress = formattedAddress, 40 | latitude = latitude, 41 | longitude = longitude 42 | ) 43 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/common/rest/response/ResponseDto.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.common.rest.response 2 | 3 | data class ListResponseDto(val items: List) 4 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/Request.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch 2 | 3 | import io.swagger.annotations.ApiModel 4 | import java.util.* 5 | 6 | private const val SWAGGER_PREFIX = "PlacesGeoSearchRequest" 7 | 8 | data class PlacesGeoSearchRequest( 9 | val logId: UUID = UUID.randomUUID(), 10 | val payload: Payload 11 | ) { 12 | @ApiModel("${SWAGGER_PREFIX}_Payload") 13 | data class Payload( 14 | val latitude: Double, 15 | val longitude: Double, 16 | val radiusInMeter: Int, 17 | val limit: Int, 18 | val offset: Int 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/Response.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch 2 | 3 | import com.example.api.places.common.rest.response.ListResponseDto 4 | import com.example.api.places.common.rest.response.PlaceDto 5 | 6 | typealias PlacesGeoSearchResponse = ListResponseDto 7 | 8 | data class PlacesGeoSearchResponseItem( 9 | val distance: Double, 10 | val place: PlaceDto 11 | ) 12 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/dsl/Handler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch.dsl 2 | 3 | import com.example.api.places.common.db.PlaceRecord 4 | import com.example.api.places.common.db.PlaceTable 5 | import com.example.api.places.common.rest.response.toPlaceDto 6 | import com.example.api.places.geosearch.PlacesGeoSearchRequest 7 | import com.example.api.places.geosearch.PlacesGeoSearchResponse 8 | import com.example.api.places.geosearch.PlacesGeoSearchResponseItem 9 | import com.example.api.places.geosearch.dsl.service.GeoSearchQuery 10 | import com.example.api.places.geosearch.dsl.service.buildGeoSearchQuery 11 | import com.example.util.exposed.query.toSQL 12 | import com.example.util.time.durationToNowInMillis 13 | import mu.KLogging 14 | import org.funktionale.tries.Try 15 | import org.jetbrains.exposed.sql.SortOrder 16 | import org.jetbrains.exposed.sql.and 17 | import org.jetbrains.exposed.sql.select 18 | import org.springframework.stereotype.Component 19 | import org.springframework.transaction.annotation.Transactional 20 | import java.time.Instant 21 | 22 | private typealias Request = PlacesGeoSearchRequest 23 | private typealias Response = PlacesGeoSearchResponse 24 | private typealias ResponseItem = PlacesGeoSearchResponseItem 25 | 26 | @Component 27 | class GeoSearchDslHandler { 28 | companion object : KLogging() 29 | 30 | @Transactional(readOnly = true) 31 | fun handle(req: Request): Response { 32 | val startedAt = Instant.now() 33 | return Try { handleInternal(req) } 34 | .log(req, startedAt) 35 | .get() 36 | } 37 | 38 | private fun handleInternal(req: Request): Response = req 39 | .let(::search) 40 | .let(::mapToResponse) 41 | 42 | private fun mapToResponse(source: SearchResult): Response = source.items 43 | .map { ResponseItem(distance = it.distance, place = it.placeRecord.toPlaceDto()) } 44 | .let { Response(items = it) } 45 | 46 | private fun search(req: Request): SearchResult { 47 | val logCtxInfo: String = req.logCtxInfo 48 | val startedAt = Instant.now() 49 | val geoSearchQuery: GeoSearchQuery = buildGeoSearchQuery( 50 | fromLatitude = req.payload.latitude, 51 | fromLongitude = req.payload.longitude, 52 | searchRadiusInMeter = req.payload.radiusInMeter, 53 | toLatitudeColumn = PLACE.latitude, 54 | toLongitudeColumn = PLACE.longitude, 55 | returnDistanceAsAlias = "distance_from_current_location" 56 | ) 57 | 58 | return PLACE 59 | .slice( 60 | geoSearchQuery.sliceDistanceAlias, 61 | *PLACE.columns.toTypedArray() 62 | ) 63 | .select { 64 | (PLACE.active eq true) 65 | .and(geoSearchQuery.whereDistanceLessEqRadius) 66 | .and(geoSearchQuery.whereEarthBoxContainsLocation) 67 | } 68 | .orderBy( 69 | Pair(geoSearchQuery.orderByDistance, SortOrder.ASC), 70 | Pair(PLACE.createdAt, SortOrder.ASC), 71 | Pair(PLACE.place_id, SortOrder.ASC) 72 | ) 73 | .limit(n = req.payload.limit, offset = req.payload.offset.toLong()) 74 | .also { 75 | logger.info("SEARCH (dsl): $logCtxInfo - prepare sql: ${it.toSQL()}") 76 | } 77 | .map { 78 | val placeRecord: PlaceRecord = PLACE.mapRowToRecord(it) 79 | val distance: Double = it[geoSearchQuery.sliceDistanceAlias] 80 | SearchResult.Item(distance = distance, placeRecord = placeRecord) 81 | } 82 | .let { SearchResult(items = it) } 83 | .also { 84 | logger.info { 85 | "SEARCH (dsl): COMPLETE. $logCtxInfo" + 86 | " - duration: ${startedAt.durationToNowInMillis()} ms " + 87 | " - result.items.count: ${it.items.size}" 88 | } 89 | } 90 | } 91 | 92 | private fun Try.log(req: Request, startedAt: Instant): Try { 93 | val logCtxInfo: String = req.logCtxInfo 94 | this.onFailure { 95 | logger.error { 96 | "Handler FAILED! $logCtxInfo" + 97 | " - duration: ${startedAt.durationToNowInMillis()} ms" + 98 | " - reason: ${it.message}" + 99 | " - req: $req" 100 | } 101 | } 102 | this.onSuccess { 103 | logger.info { 104 | "Handler Success. $logCtxInfo" + 105 | " - duration: ${startedAt.durationToNowInMillis()} ms" + 106 | " - response.items.count: ${it.items.size}" 107 | } 108 | } 109 | 110 | return this 111 | } 112 | 113 | } 114 | 115 | private val PLACE = PlaceTable 116 | 117 | private data class SearchResult( 118 | val items: List 119 | ) { 120 | data class Item(val distance: Double, val placeRecord: PlaceRecord) 121 | } 122 | 123 | private val Request.logCtxInfo: String 124 | get() = "(logId: $logId)" 125 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/dsl/service/GeoSearchQueryBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch.dsl.service 2 | 3 | 4 | import com.example.util.exposed.postgres.extensions.earthdistance.* 5 | import org.jetbrains.exposed.sql.* 6 | import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq 7 | 8 | data class GeoSearchQuery( 9 | val sliceDistanceAlias: ExpressionAlias, 10 | val whereDistanceLessEqRadius: Op, 11 | val whereEarthBoxContainsLocation: Op, 12 | val orderByDistance: CustomFunction 13 | ) 14 | 15 | fun buildGeoSearchQuery( 16 | fromLatitude: Number, 17 | fromLongitude: Number, 18 | searchRadiusInMeter: Number, 19 | toLatitudeColumn: Column, 20 | toLongitudeColumn: Column, 21 | returnDistanceAsAlias: String 22 | ): GeoSearchQuery { 23 | val reqEarthExpr: CustomFunction = ll_to_earth( 24 | latitude = fromLatitude, longitude = fromLongitude 25 | ) 26 | val dbEarthExpr: CustomFunction = ll_to_earth( 27 | latitude = toLatitudeColumn, longitude = toLongitudeColumn 28 | ) 29 | val earthDistanceExpr: CustomFunction = earth_distance( 30 | fromEarth = reqEarthExpr, toEarth = dbEarthExpr 31 | ) 32 | val earthDistanceExprAlias: ExpressionAlias = ExpressionAlias( 33 | earthDistanceExpr, returnDistanceAsAlias 34 | ) 35 | val reqEarthBoxExpr: CustomFunction = earth_box( 36 | fromLocation = reqEarthExpr, 37 | greatCircleRadiusInMeter = intParam(searchRadiusInMeter.toInt()) 38 | ) 39 | 40 | return GeoSearchQuery( 41 | sliceDistanceAlias = earthDistanceExprAlias, 42 | whereDistanceLessEqRadius = (earthDistanceExpr lessEq searchRadiusInMeter.toDouble()), 43 | whereEarthBoxContainsLocation = (reqEarthBoxExpr rangeContains dbEarthExpr), 44 | orderByDistance = earthDistanceExpr 45 | ) 46 | } 47 | 48 | 49 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/native/Handler.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch.native 2 | 3 | import com.example.api.places.common.db.PlaceRecord 4 | import com.example.api.places.common.db.PlaceRepo 5 | import com.example.api.places.common.rest.response.toPlaceDto 6 | import com.example.api.places.geosearch.PlacesGeoSearchRequest 7 | import com.example.api.places.geosearch.PlacesGeoSearchResponse 8 | import com.example.api.places.geosearch.PlacesGeoSearchResponseItem 9 | import com.example.api.places.geosearch.native.service.GeoSearchServiceRequest 10 | import com.example.api.places.geosearch.native.service.GeoSearchServiceResult 11 | import com.example.api.places.geosearch.native.service.PlacesGeoSearchService 12 | import com.example.util.time.durationToNowInMillis 13 | import mu.KLogging 14 | import org.funktionale.tries.Try 15 | import org.springframework.stereotype.Component 16 | import org.springframework.transaction.annotation.Transactional 17 | import java.time.Instant 18 | import java.util.* 19 | 20 | private typealias Request = PlacesGeoSearchRequest 21 | private typealias Response = PlacesGeoSearchResponse 22 | private typealias ResponseItem = PlacesGeoSearchResponseItem 23 | 24 | @Component 25 | class GeoSearchNativeHandler( 26 | private val placeRepo: PlaceRepo, 27 | private val geoSearchService: PlacesGeoSearchService 28 | ) { 29 | companion object : KLogging() 30 | 31 | @Transactional(readOnly = true) 32 | fun handle(req: Request): Response { 33 | val startedAt = Instant.now() 34 | return Try { handleInternal(req) } 35 | .log(req, startedAt) 36 | .get() 37 | } 38 | 39 | private fun handleInternal(req: Request): Response = req 40 | .let(::search) 41 | .let(::joinPropertyRecords) 42 | .let(::mapToResponse) 43 | 44 | private fun mapToResponse(source: JoinResult): Response = Response(items = source.items) 45 | 46 | private fun search(req: Request): ServiceResult { 47 | val logCtxInfo: String = req.logCtxInfo 48 | val startedAt = Instant.now() 49 | val searchReq = ServiceRequest( 50 | latitude = req.payload.latitude, 51 | longitude = req.payload.longitude, 52 | radiusInMeter = req.payload.radiusInMeter, 53 | limit = req.payload.limit, 54 | offset = req.payload.offset 55 | ) 56 | 57 | return geoSearchService 58 | .find(searchReq) 59 | .also { 60 | logger.info { 61 | "SEARCH (native): COMPLETE. $logCtxInfo" + 62 | " - duration: ${startedAt.durationToNowInMillis()} ms " + 63 | " - result.items.count: ${it.items.size}" 64 | } 65 | } 66 | } 67 | 68 | private fun joinPropertyRecords(source: ServiceResult): JoinResult { 69 | val sourceItems: List = source.items 70 | val placeIds: Set = sourceItems.map { it.placeId }.distinct().toSet() 71 | if (placeIds.isEmpty()) { 72 | return JoinResult.EMPTY 73 | } 74 | val placeRecordsById: Map = placeRepo 75 | .findByIdList(placeIds = placeIds, isActive = null) 76 | .associateBy { it.place_id } 77 | val sinkItems: List = sourceItems 78 | .mapNotNull { geoSearchItem: ServiceResultItem -> 79 | when (val placeRecord: PlaceRecord? = placeRecordsById[geoSearchItem.placeId]) { 80 | null -> null 81 | else -> ResponseItem( 82 | place = placeRecord.toPlaceDto(), 83 | distance = geoSearchItem.distance 84 | ) 85 | } 86 | } 87 | return JoinResult(items = sinkItems) 88 | } 89 | 90 | private fun Try.log(req: Request, startedAt: Instant): Try { 91 | val logCtxInfo: String = req.logCtxInfo 92 | this.onFailure { 93 | logger.error { 94 | "Handler FAILED! $logCtxInfo" + 95 | " - duration: ${startedAt.durationToNowInMillis()} ms" + 96 | " - reason: ${it.message}" + 97 | " - req: $req" 98 | } 99 | } 100 | this.onSuccess { 101 | logger.info { 102 | "Handler Success. $logCtxInfo" + 103 | " - duration: ${startedAt.durationToNowInMillis()} ms" + 104 | " - response.items.count: ${it.items.size}" 105 | } 106 | } 107 | 108 | return this 109 | } 110 | 111 | } 112 | 113 | private typealias ServiceRequest = GeoSearchServiceRequest 114 | private typealias ServiceResult = GeoSearchServiceResult 115 | private typealias ServiceResultItem = GeoSearchServiceResult.Item 116 | 117 | private val Request.logCtxInfo: String 118 | get() = "(logId: $logId)" 119 | 120 | private data class JoinResult(val items: List) { 121 | companion object { 122 | val EMPTY = JoinResult(items = emptyList()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/native/service/Request.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch.native.service 2 | 3 | data class GeoSearchServiceRequest( 4 | val latitude: Double, 5 | val longitude: Double, 6 | val radiusInMeter: Int, 7 | val limit: Int, 8 | val offset: Int 9 | ) 10 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/native/service/Result.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch.native.service 2 | 3 | import java.util.* 4 | 5 | data class GeoSearchServiceResult( 6 | val items: List 7 | ) { 8 | companion object { 9 | val EMPTY = GeoSearchServiceResult(items = emptyList()) 10 | } 11 | 12 | data class Item( 13 | val placeId: UUID, 14 | val distance: Double 15 | ) 16 | } 17 | 18 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/places/geosearch/native/service/Service.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.geosearch.native.service 2 | 3 | import com.example.api.places.common.db.PlaceTable 4 | import com.example.util.exposed.nativesql.INativeSql 5 | import mu.KLogging 6 | import org.funktionale.tries.Try 7 | import org.jetbrains.exposed.sql.transactions.TransactionManager 8 | import org.springframework.stereotype.Component 9 | import org.springframework.transaction.annotation.Propagation 10 | import org.springframework.transaction.annotation.Transactional 11 | import java.util.* 12 | 13 | private typealias Request = GeoSearchServiceRequest 14 | private typealias Result = GeoSearchServiceResult 15 | private typealias ResultItem = GeoSearchServiceResult.Item 16 | 17 | @Component 18 | class PlacesGeoSearchService { 19 | companion object : KLogging(), INativeSql 20 | 21 | // NOTE: uses index ... CREATE INDEX place_geosearch_index ON property USING gist (ll_to_earth(latitude, longitude)); 22 | @Transactional(readOnly = true, propagation = Propagation.MANDATORY) 23 | fun find(req: Request): Result { 24 | val selectFields = listOf( 25 | PLACE.place_id.qName 26 | ) 27 | 28 | val sql: String = """ 29 | SELECT 30 | ${selectFields.joinToString(" , ")}, 31 | 32 | earth_distance( 33 | ll_to_earth( ${req.latitude} , ${req.longitude} ), 34 | ll_to_earth( ${PLACE.latitude.qName}, ${PLACE.longitude.qName} ) 35 | ) as $FIELD_DISTANCE 36 | 37 | FROM 38 | ${PLACE.qTableName} 39 | 40 | WHERE 41 | earth_box( 42 | ll_to_earth( ${req.latitude} , ${req.longitude} ), ${req.radiusInMeter} 43 | ) @> ll_to_earth( ${PLACE.latitude.qName} , ${PLACE.longitude.qName} ) 44 | 45 | AND 46 | earth_distance( 47 | ll_to_earth( ${req.latitude} , ${req.longitude} ), 48 | ll_to_earth( ${PLACE.latitude.qName}, ${PLACE.longitude.qName} ) 49 | ) <= ${req.radiusInMeter} 50 | 51 | ORDER BY 52 | $FIELD_DISTANCE ASC, 53 | ${PLACE.createdAt.qName} ASC, 54 | ${PLACE.place_id.qName} ASC 55 | 56 | LIMIT ${req.limit} 57 | OFFSET ${req.offset} 58 | 59 | ; 60 | """.trimIndent() 61 | 62 | val items: List = Try { execSql(sql = sql) } 63 | .onFailure { 64 | logger.error { "SQL QUERY FAILED ! error.message: ${it.message} - req: $req - SQL: $sql" } 65 | } 66 | .get() 67 | 68 | return Result(items = items) 69 | .also { 70 | logger.info { "==== result.items.count: ${it.items.size} - req: $req - SQL: $sql" } 71 | } 72 | } 73 | 74 | private fun execSql(sql: String): List = 75 | sqlExecAndMap(sql = sql, transaction = TransactionManager.current()) { 76 | val meta: Map = it.metaData.toQualifiedColumnIndexMap() 77 | ResultItem( 78 | placeId = UUID.fromString(it.getString(meta.getValue(PLACE.place_id.qName))), 79 | distance = it.getDouble(meta.getValue(FIELD_DISTANCE)) 80 | ) 81 | } 82 | 83 | } 84 | 85 | private fun sqlExprInIdList(fieldName: String, ids: Set): String { 86 | // select * from place where place.place_id in ('b682f087-5b6d-4d09-bda0-7846a287cb22') 87 | val valuesExpr: String = ids.map { 88 | "'$it'" 89 | }.joinToString(separator = " , ") 90 | 91 | return "$fieldName IN ( $valuesExpr )" 92 | 93 | } 94 | 95 | private val PLACE = PlaceTable 96 | private val FIELD_DISTANCE = "distance_from_current_location" 97 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/tweeter/ApiModel.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter 2 | 3 | import am.ik.yavi.builder.ValidatorBuilder 4 | import am.ik.yavi.builder.konstraint 5 | import am.ik.yavi.core.ConstraintViolations 6 | import am.ik.yavi.core.Validator 7 | import com.example.api.common.rest.serialization.Patchable 8 | import com.example.api.tweeter.db.TweetStatus 9 | import com.example.api.tweeter.db.TweetsRecord 10 | import java.time.Instant 11 | import java.util.* 12 | 13 | data class CreateTweetRequest( 14 | val message: String, 15 | val comment: String? 16 | ) { 17 | companion object 18 | } 19 | 20 | data class UpdateTweetRequest( 21 | val message: String, 22 | val comment: String? 23 | ) 24 | 25 | data class PatchTweetRequest( 26 | val message: Patchable, 27 | val comment: Patchable, 28 | val status: Patchable 29 | ) 30 | 31 | data class TweetDto( 32 | val id: UUID, val createdAt: Instant, val modifiedAt: Instant, val deletedAt: Instant, val version: Int, 33 | val message: String, val comment: String?, val status: TweetStatus 34 | ) 35 | 36 | fun TweetsRecord.toTweetsDto() = TweetDto( 37 | id = id, createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt, version = version, 38 | message = message, comment = comment, status = status 39 | ) 40 | 41 | fun CreateTweetRequest.toRecord(id: UUID, now: Instant): TweetsRecord = 42 | TweetsRecord( 43 | id = id, version = 0, createdAt = now, modifiedAt = now, deletedAt = Instant.EPOCH, 44 | message = message, comment = comment, status = TweetStatus.DRAFT 45 | ) 46 | 47 | 48 | fun CreateTweetRequest.Companion.validatorBuilder(): ValidatorBuilder = ValidatorBuilder.of() 49 | fun CreateTweetRequest.Companion.validator(): Validator = validatorBuilder() 50 | .konstraint(CreateTweetRequest::message) { 51 | notNull() 52 | .lessThanOrEqual(20) 53 | } 54 | .konstraint(CreateTweetRequest::comment) { 55 | lessThanOrEqual(30) 56 | } 57 | .build() 58 | 59 | fun CreateTweetRequest.validate(): ConstraintViolations = CreateTweetRequest 60 | .validator() 61 | .validate(this) 62 | 63 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/tweeter/db/TweetsRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter.db 2 | 3 | import com.example.api.common.rest.error.exception.EntityNotFoundException 4 | import com.example.api.tweeter.db.TweetsTable.id 5 | import mu.KLogging 6 | import org.jetbrains.exposed.sql.insert 7 | import org.jetbrains.exposed.sql.select 8 | import org.jetbrains.exposed.sql.selectAll 9 | import org.jetbrains.exposed.sql.update 10 | import org.springframework.stereotype.Repository 11 | import org.springframework.transaction.annotation.Transactional 12 | import java.util.* 13 | 14 | @Repository 15 | @Transactional // Should be at @Service level in real applications 16 | class TweetsRepo { 17 | companion object : KLogging() 18 | 19 | private val crudTable = TweetsTable 20 | 21 | fun insert(record: TweetsRecord): TweetsRecord = crudTable 22 | .insert { 23 | it[id] = record.id 24 | it[createdAt] = record.createdAt 25 | it[modifiedAt] = record.modifiedAt 26 | it[deletedAt] = record.deletedAt 27 | it[version] = record.version 28 | it[message] = record.message 29 | it[comment] = record.comment 30 | it[status] = record.status 31 | } 32 | .let { this[record.id] } 33 | .also { logger.info { "INSERT: table: ${crudTable.tableName} id: ${record.id} record: $it" } } 34 | 35 | 36 | fun update(record: TweetsRecord): TweetsRecord = crudTable 37 | .update({ id eq record.id }) { 38 | it[createdAt] = record.createdAt 39 | it[modifiedAt] = record.modifiedAt 40 | it[deletedAt] = record.deletedAt 41 | it[version] = record.version 42 | it[message] = record.message 43 | it[comment] = record.comment 44 | it[status] = record.status 45 | } 46 | .let { this[record.id] } 47 | .also { logger.info { "UPDATE: table: ${crudTable.tableName} id: ${record.id} record: $it" } } 48 | 49 | fun findAll() = TweetsTable 50 | .selectAll() 51 | .map { with(TweetsTable) { it.toTweetsRecord() } } 52 | 53 | fun findOneById(id: UUID): TweetsRecord? = 54 | TweetsTable.select { TweetsTable.id eq id } 55 | .limit(1) 56 | .map { with(TweetsTable) { it.toTweetsRecord() } } 57 | .firstOrNull() 58 | 59 | operator fun get(id: UUID): TweetsRecord = findOneById(id) 60 | ?: throw EntityNotFoundException("TweetRecord NOT FOUND ! (id=$id)") 61 | 62 | } 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/tweeter/db/TweetsTable.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter.db 2 | 3 | import com.example.util.exposed.columnTypes.enumerationBySqlType 4 | import com.example.util.exposed.columnTypes.instant 5 | import org.jetbrains.exposed.sql.ResultRow 6 | import org.jetbrains.exposed.sql.Table 7 | import java.time.Instant 8 | import java.util.* 9 | 10 | object TweetsTable : Table("tweet") { 11 | val id = uuid("id") 12 | override val primaryKey: PrimaryKey = PrimaryKey(id, name = "tweet_pkey") 13 | 14 | val createdAt = instant("created_at") 15 | val modifiedAt = instant("updated_at") 16 | val deletedAt = instant("deleted_at").default(Instant.EPOCH) 17 | val version = integer("version") 18 | val message = varchar("message", 255) 19 | val comment = text("comment").nullable() 20 | val status = enumerationBySqlType( 21 | name = "status", sqlType = "TweetStatusType", klass = TweetStatus::class.java, 22 | serialize = { it.dbValue }, 23 | unserialize = { fromDb, toKlass -> 24 | toKlass.enumConstants.first { it.dbValue == fromDb } 25 | } 26 | ).default(TweetStatus.DRAFT) 27 | 28 | fun ResultRow.toTweetsRecord() = TweetsTable.rowToTweetsRecord(this) 29 | } 30 | 31 | enum class TweetStatus(val dbValue: String) { DRAFT("DRAFT"), PENDING("PENDING"), PUBLISHED("PUBLISHED"); } 32 | 33 | data class TweetsRecord( 34 | val id: UUID, 35 | val createdAt: Instant, 36 | val modifiedAt: Instant, 37 | val deletedAt: Instant, 38 | val version: Int, 39 | val message: String, 40 | val comment: String?, 41 | val status: TweetStatus 42 | ) 43 | 44 | fun TweetsTable.rowToTweetsRecord(row: ResultRow): TweetsRecord = 45 | TweetsRecord( 46 | id = row[id], 47 | createdAt = row[createdAt], 48 | modifiedAt = row[modifiedAt], 49 | deletedAt = row[deletedAt], 50 | version = row[version], 51 | message = row[message], 52 | comment = row[comment], 53 | status = row[status] 54 | ) 55 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/tweeter/search/Request.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter.search 2 | 3 | import com.example.api.tweeter.db.TweetStatus 4 | import com.example.api.tweeter.db.TweetsTable 5 | import com.fasterxml.jackson.annotation.JsonProperty 6 | import com.fasterxml.jackson.annotation.JsonValue 7 | import io.swagger.annotations.ApiModel 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.SortOrder 10 | import java.time.Instant 11 | import java.util.* 12 | 13 | private const val SWAGGER_API_MODEL_PREFIX = "TweeterSearchRequest" 14 | 15 | @ApiModel(SWAGGER_API_MODEL_PREFIX) 16 | data class TweeterSearchRequest( 17 | val limit: Int, 18 | val offset: Int, 19 | val match: Match?, 20 | val filter: Filter?, 21 | val orderBy: List?, // Set may lead to inconsistent order within the set 22 | val jmesPath: String? 23 | ) { 24 | @ApiModel("${SWAGGER_API_MODEL_PREFIX}_Payload_OrderBy") 25 | enum class OrderBy(@get:JsonValue val jsonValue: String, val field: Column<*>, val sortOrder: SortOrder) { 26 | CREATED_AT_DESC("createdAt-DESC", TweetsTable.createdAt, SortOrder.DESC), 27 | CREATED_AT_ASC("createdAt-ASC", TweetsTable.createdAt, SortOrder.ASC), 28 | MODIFIED_AT_DESC("modifiedAt-DESC", TweetsTable.modifiedAt, SortOrder.DESC), 29 | MODIFIED_AT_ASC("modifiedAt-ASC", TweetsTable.modifiedAt, SortOrder.ASC), 30 | VERSION_DESC("version-DESC", TweetsTable.version, SortOrder.DESC), 31 | VERSION_ASC("version-ASC", TweetsTable.version, SortOrder.ASC), 32 | ID_DESC("id-DESC", TweetsTable.id, SortOrder.DESC), 33 | ID_ASC("id-ASC", TweetsTable.id, SortOrder.ASC), 34 | ; 35 | } 36 | 37 | @ApiModel("${SWAGGER_API_MODEL_PREFIX}_Payload_Filter") 38 | data class Filter( 39 | // SQL: AND, e.g: ( id IN ("123","456") AND status IN(DRAFT, PENDING) ) 40 | @JsonProperty("id-IN") val idIN: Set?, 41 | @JsonProperty("status-IN") val statusIN: Set?, 42 | @JsonProperty("createdAt-GTE") val createdAtGTE: Instant?, 43 | @JsonProperty("createdAt-LOE") val createdAtLOE: Instant?, 44 | @JsonProperty("modifiedAt-GTE") val modifiedAtGTE: Instant?, 45 | @JsonProperty("modifiedAt-LOE") val modifiedAtLOE: Instant?, 46 | @JsonProperty("version-GTE") val versionGTE: Int?, 47 | @JsonProperty("version-LOE") val versionLOE: Int?, 48 | @JsonProperty("version-EQ") val versionEQ: Int? 49 | ) 50 | 51 | @ApiModel("${SWAGGER_API_MODEL_PREFIX}_Payload_Match") 52 | data class Match( 53 | // SQL: OR, e.g: ( (message LIKE "%foo%) OR (comment LIKE "%bar%) ) 54 | @JsonProperty("message-LIKE") val messageLIKE: String?, 55 | @JsonProperty("comment-LIKE") val commentLIKE: String? 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/api/tweeter/search/Response.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter.search 2 | 3 | import com.example.api.tweeter.TweetDto 4 | 5 | data class TweeterSearchResponse(val items: List) -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/AppEnvName.kt: -------------------------------------------------------------------------------- 1 | package com.example.config 2 | 3 | enum class AppEnvName { test, local, playground; } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/Jackson.kt: -------------------------------------------------------------------------------- 1 | package com.example.config 2 | 3 | import com.example.api.common.rest.serialization.Patchable 4 | import com.example.api.common.rest.serialization.PatchableDeserializer 5 | import com.fasterxml.jackson.databind.DeserializationFeature 6 | import com.fasterxml.jackson.databind.ObjectMapper 7 | import com.fasterxml.jackson.databind.SerializationFeature 8 | import com.fasterxml.jackson.databind.module.SimpleModule 9 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 10 | import mu.KLogging 11 | import org.springframework.context.annotation.Bean 12 | import org.springframework.context.annotation.Configuration 13 | 14 | 15 | @Configuration 16 | class Jackson { 17 | 18 | @Bean 19 | fun objectMapper(): ObjectMapper = defaultMapper() 20 | .also { 21 | logger.info { "==> configure spring objectmapper. JACKSON MODULES: ${it.registeredModuleIds}" } 22 | } 23 | 24 | companion object : KLogging() { 25 | fun defaultMapper(): ObjectMapper = jacksonObjectMapper() 26 | .registerModule( 27 | SimpleModule() 28 | .addDeserializer(Patchable::class.java, PatchableDeserializer()) 29 | // .addSerializer(Patchable::class.java, PatchableSerializer::class) 30 | ) 31 | //.registerModule(Jdk8Module()) 32 | //.registerModule(JavaTimeModule()) 33 | .findAndRegisterModules() 34 | 35 | // toJson() 36 | .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) 37 | .disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) 38 | 39 | // fromJson() 40 | .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) 41 | .disable(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT) 42 | .disable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT) 43 | .disable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) 44 | .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) 45 | .enable(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /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.example.api.common.rest.serialization.Patchable 5 | import com.fasterxml.classmate.TypeResolver 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import springfox.documentation.builders.RequestHandlerSelectors 9 | import springfox.documentation.spring.web.plugins.Docket 10 | //import springfox.documentation.swagger2.annotations.EnableSwagger2 11 | import java.util.* 12 | 13 | @Configuration 14 | //@EnableSwagger2 15 | class Swagger(private val apiConfig: ApiConfig, private val typeResolver: TypeResolver) { 16 | // see: https://medium.com/@hala3k/setting-up-swagger-3-with-spring-boot-2-a7c1c3151545 17 | 18 | // http://localhost:8080/v2/api-docs 19 | // http://localhost:8080/swagger-ui/index.html 20 | 21 | @Bean 22 | fun mainApi(): Docket = apiConfig.toDocket() 23 | //.groupName("Main") 24 | .select() 25 | .apis(RequestHandlerSelectors.basePackage(apiConfig.getBasePackageName())) 26 | .build() 27 | //.additionalModels(typeResolver.resolve(Patchable::class.java, WildcardType::class.java)) 28 | // see: https://github.com/swagger-api/swagger-codegen/issues/7601 29 | .genericModelSubstitutes(Optional::class.java) 30 | .genericModelSubstitutes(Patchable::class.java) 31 | 32 | } 33 | 34 | private fun ApiConfig.getBasePackageName() = this::class.java.`package`.name 35 | private fun ApiConfig.toApiInfo() = springfox.documentation.builders.ApiInfoBuilder().title(this.title).build() 36 | private fun ApiConfig.toDocket() = Docket(springfox.documentation.spi.DocumentationType.SWAGGER_2).apiInfo(this.toApiInfo()) 37 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/WebMvc.kt: -------------------------------------------------------------------------------- 1 | package com.example.config 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.web.servlet.config.annotation.ViewControllerRegistry 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 7 | 8 | @Configuration 9 | class WebMvc { 10 | 11 | @Bean 12 | fun webMvcConfigurer(): WebMvcConfigurer = object : WebMvcConfigurer { 13 | override fun addViewControllers(registry: ViewControllerRegistry) { 14 | registry.addViewController("/health").setViewName("forward:/actuator/health") 15 | 16 | //registry.addRedirectViewController("/", "/swagger-ui.html") 17 | val swaggerUIUrl="/swagger-ui/index.html" 18 | registry.addRedirectViewController("/swagger-ui.html", swaggerUIUrl) 19 | registry.addRedirectViewController("/", swaggerUIUrl) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/db/Exposed.kt: -------------------------------------------------------------------------------- 1 | package com.example.config.db 2 | 3 | import com.zaxxer.hikari.HikariDataSource 4 | import mu.KLogging 5 | import org.jetbrains.exposed.spring.SpringTransactionManager 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor 9 | import org.springframework.transaction.annotation.EnableTransactionManagement 10 | 11 | private typealias PlatformDataSource = HikariDataSource 12 | 13 | @Configuration 14 | @EnableTransactionManagement 15 | class Exposed { 16 | 17 | @Bean 18 | fun transactionManager(dataSource: PlatformDataSource): SpringTransactionManager = 19 | SpringTransactionManager(dataSource) 20 | .also { 21 | logger.info { 22 | "=== USE SQL datasource ${dataSource.toDetailsText()}" 23 | } 24 | } 25 | 26 | @Bean // PersistenceExceptionTranslationPostProcessor with proxyTargetClass=false, see https://github.com/spring-projects/spring-boot/issues/1844 27 | fun persistenceExceptionTranslationPostProcessor() = PersistenceExceptionTranslationPostProcessor() 28 | 29 | companion object : KLogging() 30 | } 31 | 32 | private fun PlatformDataSource.toDetailsText(): String = 33 | "user: $username url: $jdbcUrl pool: $poolName maxPoolSize: $maximumPoolSize minIdle: $minimumIdle" 34 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/config/db/Flyway.kt: -------------------------------------------------------------------------------- 1 | package com.example.config.db 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.isActive 5 | import kotlinx.coroutines.launch 6 | import kotlinx.coroutines.runBlocking 7 | import mu.KLogging 8 | import org.flywaydb.core.Flyway 9 | import org.springframework.beans.factory.annotation.Value 10 | import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy 11 | import org.springframework.context.annotation.Bean 12 | import org.springframework.context.annotation.Configuration 13 | import org.springframework.stereotype.Component 14 | import java.time.Duration 15 | import java.time.Instant 16 | 17 | enum class FlywayStrategyName { SKIP, VALIDATE, MIGRATE, REPAIR, BASELINE; } 18 | 19 | @Component 20 | data class FlywayConfig( 21 | @Value(value = "\${app.flyway.info}") val info: Boolean, 22 | @Value(value = "\${app.flyway.strategy}") val strategyName: FlywayStrategyName 23 | ) { 24 | val monitorMaxDuration: Duration = Duration.ofSeconds(60) 25 | val monitorTickDuration: Duration = Duration.ofSeconds(10) 26 | } 27 | 28 | 29 | @Configuration 30 | class FlywayConfiguration( 31 | private val config: FlywayConfig 32 | ) { 33 | companion object : KLogging() 34 | 35 | @Bean 36 | fun flywayMigrationStrategy(): FlywayMigrationStrategy = 37 | FlywayMigrationStrategy { flyway -> executeFlywayMonitored(flyway) } 38 | 39 | 40 | private fun executeFlyway(flyway: Flyway) { 41 | logger.info { "==== flyway (START): config=$config ... ====" } 42 | try { 43 | if (config.info) { 44 | logger.info("flyway.info()") 45 | flyway.info() 46 | } 47 | executeStrategy(flyway = flyway) 48 | } catch (all: Exception) { 49 | logger.error { " Flyway Failed! strategy: ${config.strategyName} reason: ${all.message}" } 50 | throw all 51 | } 52 | logger.info { "==== flyway (DONE): config: $config ====" } 53 | } 54 | 55 | private fun executeStrategy(flyway: Flyway) { 56 | logger.info { "=> execute flyway strategy: ${config.strategyName} ${Instant.now()} ..." } 57 | return when (config.strategyName) { 58 | FlywayStrategyName.SKIP -> { 59 | logger.info("flyway: ${config.strategyName}: Do nothing with flyway.") 60 | } 61 | FlywayStrategyName.VALIDATE -> { 62 | logger.info("=== VALIDATE flyway: ${config.strategyName} ${Instant.now()} ...") 63 | try { 64 | flyway.validate() 65 | } catch (all: Throwable) { 66 | logger.error("flyway FAILED! ${all.message}", all) 67 | throw all 68 | } 69 | } 70 | FlywayStrategyName.MIGRATE -> { 71 | logger.info("==== MIGRATE !!!!! flyway: ${config.strategyName} ${Instant.now()} ...") 72 | try { 73 | flyway.migrate() 74 | Unit 75 | } catch (all: Throwable) { 76 | logger.error("flyway FAILED! ${all.message}", all) 77 | throw all 78 | } 79 | } 80 | FlywayStrategyName.REPAIR -> { 81 | logger.warn("flyway: ${config.strategyName} - Hope, you know what your doing ;) ...") 82 | flyway.repair() 83 | Unit 84 | } 85 | FlywayStrategyName.BASELINE -> { 86 | logger.warn("flyway: ${config.strategyName} - Hope, you know what your doing ;) ...") 87 | flyway.baseline() 88 | Unit 89 | } 90 | } 91 | } 92 | 93 | /** 94 | * execute flyway strategy and detect slow executions 95 | * in very rare cases flyway just hangs - without saying anything 96 | */ 97 | private fun executeFlywayMonitored(flyway: Flyway) = runBlocking { 98 | val startedAt = Instant.now() 99 | val shouldFinishAt = startedAt + config.monitorMaxDuration 100 | var finishedAt: Instant? = null 101 | 102 | launch { 103 | while (isActive && finishedAt == null) { 104 | val now = Instant.now() 105 | val isOverdue = now > shouldFinishAt 106 | logger.info { 107 | "=== flyway monitor (TICK): startedAt: $startedAt shouldFinishAt: $shouldFinishAt now: $now" 108 | } 109 | if (isOverdue) { 110 | logger.warn { 111 | "=== flyway monitor: WARN! FLYWAY IS SLOW !!!" + 112 | " - startedAt: $startedAt shouldFinishAt: $shouldFinishAt now: $now" 113 | } 114 | } 115 | delay(config.monitorTickDuration.toMillis()) 116 | } 117 | } 118 | 119 | try { 120 | executeFlyway(flyway) 121 | finishedAt = Instant.now() 122 | } catch (all: Exception) { 123 | finishedAt = Instant.now() 124 | 125 | throw all 126 | } 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/main.kt: -------------------------------------------------------------------------------- 1 | package com.example 2 | 3 | import org.springframework.boot.Banner 4 | import org.springframework.boot.runApplication 5 | 6 | fun main(args: Array) { 7 | runApplication(*args) { 8 | setBannerMode(Banner.Mode.OFF) 9 | } 10 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/columnTypes/enumBySqlTypeColumnType.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.columnTypes 2 | 3 | import org.jetbrains.exposed.sql.Column 4 | import org.jetbrains.exposed.sql.ColumnType 5 | import org.jetbrains.exposed.sql.Table 6 | import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi 7 | import org.postgresql.util.PGobject 8 | 9 | fun > Table.enumerationBySqlType( 10 | name: String, 11 | sqlType: String, 12 | klass: Class, 13 | serialize: (toDb: T) -> String = { it.name }, 14 | unserialize: (fromDb: String, toKlass: Class) -> T = { fromDb, toKlass -> 15 | toKlass.enumConstants.first { it.name == fromDb } 16 | } 17 | ): Column = 18 | registerColumn( 19 | name = name, 20 | type = EnumBySqlType(sqlType, klass, serialize, unserialize) 21 | ) 22 | 23 | private class EnumBySqlType>( 24 | private val sqlType: String, 25 | private val klass: Class, 26 | private val serialize: (toDb: T) -> String, 27 | private val unserialize: (fromDb: String, toKlass: Class) -> T 28 | ) : ColumnType() { 29 | override fun sqlType() = sqlType 30 | 31 | @Suppress("UNCHECKED_CAST") 32 | override fun notNullValueToDB(value: Any): Any = when (value) { 33 | // is String -> value 34 | is Enum<*> -> try { 35 | serialize(value as T) 36 | } catch (all: Exception) { 37 | error( 38 | "SERIALIZE FAILED! $value of ${value::class.qualifiedName} is not valid for enum ${klass.name} ." 39 | + " details: ${all.message}" 40 | ) 41 | } 42 | else -> error("SERIALIZE FAILED! $value of ${value::class.qualifiedName} is not valid for enum ${klass.name}") 43 | } 44 | 45 | override fun valueFromDB(value: Any): Any = when (value) { 46 | is String -> try { 47 | unserialize(value, klass) 48 | } catch (all: Exception) { 49 | error( 50 | "UNSERIALIZE FAILED! $value of ${value::class.qualifiedName} is not valid for enum ${klass.name} ." 51 | + " details: ${all.message}" 52 | ) 53 | } 54 | //is Enum<*> -> value 55 | else -> error("UNSERIALIZE FAILED! $value of ${value::class.qualifiedName} is not valid for enum ${klass.name}") 56 | } 57 | 58 | private fun valueToPGobject(value: Any?, index: Int): PGobject { 59 | val obj = PGobject() 60 | obj.type = sqlType() 61 | obj.value = when(value) { 62 | null->null 63 | else->value as String 64 | } 65 | 66 | return obj 67 | } 68 | 69 | override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { 70 | val obj: PGobject = valueToPGobject(value = value, index = index) 71 | super.setParameter(stmt, index, obj) 72 | } 73 | 74 | /* 75 | override fun setParameter(stmt: PreparedStatement, index: Int, value: Any?) { 76 | val obj = PGobject() 77 | obj.type = sqlType() 78 | obj.value = when(value) { 79 | null -> null 80 | else -> value as String 81 | } 82 | stmt.setObject(index, obj) 83 | } 84 | 85 | */ 86 | } 87 | 88 | /* 89 | private inline fun > EnumBySqlType.fromDb(value: Any, unserialize: (fromDb:String)->T):T = when (value) { 90 | is String -> try { 91 | unserialize(value) 92 | }catch (all:Exception) { 93 | error("$value of ${value::class.qualifiedName} is not valid for enum ${T::class.java.name} . details: ${all.message}") 94 | } 95 | //is Enum<*> -> value 96 | else -> error("$value of ${value::class.qualifiedName} is not valid for enum ${T::class.java.name}") 97 | } 98 | */ 99 | 100 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/columnTypes/instantColumnType.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.columnTypes 2 | 3 | import org.jetbrains.exposed.sql.Column 4 | import org.jetbrains.exposed.sql.ColumnType 5 | //import org.jetbrains.exposed.sql.DateColumnType 6 | import org.jetbrains.exposed.sql.Table 7 | import org.jetbrains.exposed.sql.jodatime.DateColumnType as JodaDateColumnType 8 | import org.joda.time.DateTime as JodaDateTime 9 | import java.time.Instant as JavaInstant 10 | 11 | fun Table.instant(name: String): Column = registerColumn(name, InstantColumnType(true)) 12 | 13 | private fun JodaDateTime.toInstantJava() = JavaInstant.ofEpochMilli(this.millis) 14 | private fun JavaInstant.toJodaDateTime() = JodaDateTime(this.toEpochMilli()) 15 | 16 | 17 | class InstantColumnType(time: Boolean) : ColumnType() { 18 | private val delegate = JodaDateColumnType(time) 19 | 20 | override fun sqlType(): String = delegate.sqlType() 21 | 22 | override fun nonNullValueToString(value: Any): String = when (value) { 23 | is JavaInstant -> delegate.nonNullValueToString(value.toJodaDateTime()) 24 | else -> delegate.nonNullValueToString(value) 25 | } 26 | 27 | override fun valueFromDB(value: Any): Any { 28 | val fromDb = when (value) { 29 | is JavaInstant -> delegate.valueFromDB(value.toJodaDateTime()) 30 | else -> delegate.valueFromDB(value) 31 | } 32 | return when (fromDb) { 33 | is JodaDateTime -> fromDb.toInstantJava() 34 | else -> error("failed to convert value to Instant") 35 | } 36 | } 37 | 38 | override fun notNullValueToDB(value: Any): Any = when (value) { 39 | is JavaInstant -> delegate.notNullValueToDB(value.toJodaDateTime()) 40 | else -> delegate.notNullValueToDB(value) 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/crud/CrudRecordRepo.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.crud 2 | 3 | import org.jetbrains.exposed.sql.ResultRow 4 | import java.util.* 5 | 6 | abstract class AbstractRepo() { 7 | protected abstract val table: CrudRecordTable // 8 | fun findOne(id: ID) = table.findOneById(id) 9 | operator fun get(id: ID) = table.getOneById(id) 10 | 11 | } 12 | 13 | abstract class UUIDCrudRepo { 14 | protected abstract val table: TABLE 15 | protected abstract val mapr: (ResultRow) -> RECORD 16 | 17 | fun findOne(id: UUID): RECORD? = table.findRowById(id)?.let(mapr) 18 | operator fun get(id: UUID): RECORD = table.getRowById(id).let(mapr) 19 | fun exists(id: UUID): Boolean = table.rowExistsById(id) 20 | fun mapRow(row: ResultRow): RECORD = mapr(row) 21 | fun mapRows(rows: List): List = rows.map(mapr) 22 | } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/crud/CrudRecordTable.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.crud 2 | 3 | 4 | import com.example.api.common.rest.error.exception.EntityNotFoundException 5 | import org.jetbrains.exposed.sql.* 6 | import org.jetbrains.exposed.sql.statements.UpdateStatement 7 | import java.util.* 8 | 9 | 10 | /* 11 | open class UUIDTable(name: String = "", columnName: String = "id") : IdTable(name) { 12 | override val id: Column> = uuid(columnName).primaryKey() 13 | .clientDefault { UUID.randomUUID() } 14 | .entityId() 15 | } 16 | 17 | abstract class IdTable>(name: String=""): Table(name) { 18 | abstract val id : Column> 19 | 20 | } 21 | */ 22 | 23 | abstract class IdCrudTable>( 24 | name: String = "" 25 | ) : Table(name) { 26 | abstract val crudIdColumn: () -> Column 27 | // abstract fun crudIdColumn():Column 28 | fun findRowById(id: ID) = 29 | select { crudIdColumn() eq id } 30 | .limit(1) 31 | .firstOrNull() 32 | 33 | fun getRowById(id: ID) = findRowById(id) 34 | ?: throw EntityNotFoundException("DB RECORD NOT FOUND ! (${crudIdColumn().name}=$id)") 35 | 36 | fun rowExistsById(id: ID): Boolean = 37 | select { crudIdColumn() eq id } 38 | .limit(1) 39 | .count() > 0 40 | } 41 | 42 | abstract class UUIDCrudTable(name: String = "") : IdCrudTable(name) 43 | 44 | fun TABLE.updateRowById(id: UUID, body: TABLE.(UpdateStatement) -> Unit) = 45 | update({ crudIdColumn() eq id }, body = body) 46 | 47 | fun
TABLE.updateRowByIdAndGet(id: UUID, body: TABLE.(UpdateStatement) -> Unit) = 48 | update({ crudIdColumn() eq id }, body = body) 49 | .let { getRowById(id) } 50 | 51 | interface ICrudRecordTable { 52 | fun crudIdColumn(): Column 53 | val resultRowMapper: (row: ResultRow) -> RECORD 54 | 55 | fun findOneById(id: ID): RECORD? 56 | fun getOneById(id: ID): RECORD 57 | fun existsById(id: ID): Boolean 58 | } 59 | 60 | 61 | abstract class CrudRecordTable( 62 | name: String = "" 63 | ) : Table(name = name), ICrudRecordTable { 64 | override fun findOneById(id: ID) = 65 | select { crudIdColumn().eq(id) } 66 | .limit(1) 67 | .map { resultRowMapper(it) } 68 | .firstOrNull() 69 | 70 | override fun getOneById(id: ID) = findOneById(id) 71 | ?: throw EntityNotFoundException("DB RECORD NOT FOUND ! (${crudIdColumn().name}=$id)") 72 | 73 | override fun existsById(id: ID): Boolean = 74 | select { crudIdColumn() eq id } 75 | .limit(1) 76 | .count() > 0 77 | } 78 | 79 | fun
, ID : Any, RECORD : Any> TABLE.updateOneById(id: ID, body: TABLE.(UpdateStatement) -> Unit) = 80 | update({ crudIdColumn() eq id }, body = body) 81 | .let { getOneById(id) } -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/expr/postgres/ILike.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.expr.postgres 2 | 3 | import org.jetbrains.exposed.sql.* 4 | 5 | // see: https://github.com/JetBrains/Exposed/issues/622 6 | 7 | 8 | class InsensitiveLikeOp(expr1: Expression<*>, expr2: Expression<*>) : ComparisonOp(expr1, expr2, "ILIKE") 9 | 10 | infix fun ExpressionWithColumnType.ilike(pattern: String): Op = InsensitiveLikeOp(this, QueryParameter(pattern, columnType)) 11 | 12 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/functions/common/CustomBooleanFunction.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.functions.common 2 | 3 | import org.jetbrains.exposed.sql.BooleanColumnType 4 | import org.jetbrains.exposed.sql.CustomFunction 5 | import org.jetbrains.exposed.sql.Expression 6 | import org.jetbrains.exposed.sql.QueryBuilder 7 | 8 | /** 9 | * Boolean Function (with optional postfix) 10 | */ 11 | fun CustomBooleanFunction( 12 | functionName: String, postfix: String = "", vararg params: Expression<*> 13 | ): CustomFunction = 14 | object : CustomFunction(functionName, BooleanColumnType(), *params) { 15 | override fun toQueryBuilder(queryBuilder: QueryBuilder) { 16 | super.toQueryBuilder(queryBuilder) 17 | if (postfix.isNotEmpty()) { 18 | queryBuilder.append(postfix) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/functions/postgres/DistinctOn.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.functions.postgres 2 | 3 | import com.example.util.exposed.functions.common.CustomBooleanFunction 4 | import org.jetbrains.exposed.sql.CustomFunction 5 | import org.jetbrains.exposed.sql.Expression 6 | 7 | /** 8 | * SELECT DISTINCT ON (a,b,c) TRUE, a,b,x,y,z FROM table WHERE ... 9 | * see: https://github.com/JetBrains/Exposed/issues/500 10 | */ 11 | fun distinctOn(vararg expressions: Expression<*>): CustomFunction = CustomBooleanFunction( 12 | functionName = "DISTINCT ON", 13 | postfix = " TRUE", 14 | params = *expressions 15 | ) 16 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/nativesql/NativeSql.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.nativesql 2 | 3 | import org.jetbrains.exposed.sql.Column 4 | import org.jetbrains.exposed.sql.Table 5 | import org.jetbrains.exposed.sql.Transaction 6 | import java.sql.ResultSet 7 | import java.sql.ResultSetMetaData 8 | 9 | object NativeSql: INativeSql 10 | 11 | interface INativeSql { 12 | fun sqlExecAndMap(sql:String, transaction: Transaction, transform: (ResultSet) -> T): List { 13 | val result = arrayListOf() 14 | transaction.exec(sql) { rs -> 15 | try { 16 | while (rs.next()) { 17 | result += transform(rs) 18 | } 19 | } finally { 20 | rs.closeSilently() 21 | } 22 | 23 | } 24 | return result 25 | } 26 | 27 | private fun ResultSet.closeSilently() = 28 | try { 29 | when(isClosed) { 30 | true-> Unit 31 | false-> close() 32 | } 33 | } catch (all:Throwable) { 34 | // ignore 35 | } 36 | 37 | 38 | fun ResultSetMetaData.toQualifiedColumnIndexMap():Map { 39 | val meta:Map =(1..columnCount).map { colPos-> 40 | val colLabel:String = getColumnLabel(colPos) 41 | val colTableName:String = getTableName(colPos) 42 | val key:String= listOf(colTableName, colLabel) 43 | .filter { it.isNotEmpty()} 44 | .joinToString(".") 45 | Pair(key, colPos) 46 | }.toMap() 47 | 48 | return meta 49 | } 50 | 51 | val Column<*>.qName:String 52 | get() = "${table.qTableName}.${name.toLowerCase()}" 53 | val Table.qTableName:String 54 | get() = tableName.toLowerCase() 55 | 56 | } 57 | 58 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/EarthBoxFunctions.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | import org.jetbrains.exposed.sql.CustomFunction 4 | import org.jetbrains.exposed.sql.Expression 5 | 6 | /** 7 | * earth_box(earth, float8):cube - Returns a box suitable for an indexed search using the cube @> operator for points within a given great circle distance of a location. Some points in this box are further than the specified great circle distance from the location, so a second check using earth_distance should be included in the query. 8 | * https://www.postgresql.org/docs/10/earthdistance.html 9 | * select earth_box(ll_to_earth(1.0,2.0), 10); -> returns Box defined by Points: (6373301.75827338, 222550.95080971, 111304.380261276),(6373321.75827338, 222570.95080971, 111324.380261276) 10 | * select earth_box(ll_to_earth(1.0,2.0), null); -> returns NULL 11 | * select earth_box(null, 10); -> returns NULL 12 | */ 13 | 14 | 15 | fun earth_box( 16 | fromLocation: CustomFunction, 17 | greatCircleRadiusInMeter: Expression 18 | ): CustomFunction = _earth_box( 19 | fromLocation = fromLocation, 20 | greatCircleRadiusInMeter = greatCircleRadiusInMeter, 21 | returnsNullable = true 22 | ) 23 | 24 | @JvmName("earth_box_not_nullable") 25 | @Suppress("UNCHECKED_CAST") 26 | fun earth_box( 27 | fromLocation: CustomFunction, 28 | greatCircleRadiusInMeter: Expression 29 | ): CustomFunction = _earth_box( 30 | fromLocation = fromLocation, 31 | greatCircleRadiusInMeter = greatCircleRadiusInMeter, 32 | returnsNullable = false 33 | ) as CustomFunction 34 | 35 | private fun _earth_box( 36 | fromLocation: Expression, 37 | greatCircleRadiusInMeter: Expression, 38 | returnsNullable: Boolean 39 | ): CustomFunction { 40 | val params = listOf( 41 | fromLocation, greatCircleRadiusInMeter 42 | ) 43 | val fn = CustomFunction( 44 | "earth_box", 45 | PGEarthBoxColumnType().apply { nullable = returnsNullable }, 46 | *(params).toTypedArray() 47 | ) 48 | return fn 49 | } 50 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/EarthDistance.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | import org.jetbrains.exposed.sql.CustomFunction 4 | import org.jetbrains.exposed.sql.DoubleColumnType 5 | 6 | /** 7 | * PostGIS or Cube + EarthDistance 8 | * 9 | * see: 10 | * https://hashrocket.com/blog/posts/juxtaposing-earthdistance-and-postgis 11 | * https://gist.github.com/norman/1535879 12 | * https://developpaper.com/using-postgresql-database-to-app-geographical-location/ 13 | */ 14 | 15 | // select earth() -> returns the assumed radius of te earth as float8 ( SELECT '6378168'::float8 ) 16 | fun earth(): CustomFunction { 17 | val fn = CustomFunction("earth", DoubleColumnType()) 18 | return fn 19 | } 20 | 21 | 22 | /** 23 | * earth_distance(earth, earth):float8 24 | * - Returns the great circle distance between two points on the surface of the Earth. 25 | * - returns a value in meters 26 | * see: https://www.postgresql.org/docs/8.3/earthdistance.html 27 | */ 28 | 29 | @JvmName("earth_distance_not_nullable") 30 | @Suppress("UNCHECKED_CAST") 31 | fun earth_distance( 32 | fromEarth: CustomFunction, toEarth: CustomFunction 33 | ): CustomFunction = _earth_distance( 34 | fromEarth = fromEarth, 35 | toEarth = toEarth, 36 | returnsNullable = false 37 | ) as CustomFunction 38 | 39 | fun earth_distance( 40 | fromEarth: CustomFunction, toEarth: CustomFunction 41 | ): CustomFunction = _earth_distance( 42 | fromEarth = fromEarth, 43 | toEarth = toEarth, 44 | returnsNullable = true 45 | ) 46 | 47 | private fun _earth_distance( 48 | fromEarth: CustomFunction, 49 | toEarth: CustomFunction, 50 | returnsNullable: Boolean 51 | ): CustomFunction { 52 | val params = listOf( 53 | fromEarth, toEarth 54 | ) 55 | val fn = CustomFunction( 56 | "earth_distance", 57 | DoubleColumnType().apply { nullable = returnsNullable }, 58 | *(params).toTypedArray() 59 | ) 60 | return fn 61 | } 62 | 63 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/LatLonFunctions.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | import org.jetbrains.exposed.sql.CustomFunction 4 | import org.jetbrains.exposed.sql.DoubleColumnType 5 | import org.jetbrains.exposed.sql.QueryParameter 6 | 7 | /** 8 | 9 | select ll_to_earth( 11.1 , 20.0 ); -> returns (5881394.65979286, 2140652.5921368, 1227937.44619261) 10 | select latitude('(5881394.65979286, 2140652.5921368, 1227937.44619261)'::earth); -> returns 11.1 11 | fun ll_to_earth(latitude:Double?, longitude:Double?):PGEarthPointLocation? 12 | 13 | */ 14 | 15 | fun latitude(earth: T): CustomFunction = _latitude(earth) 16 | 17 | @JvmName("latitude_not_nullable") 18 | @Suppress("UNCHECKED_CAST") 19 | fun latitude(earth: PGEarthPointLocation): CustomFunction = _latitude(earth) as CustomFunction 20 | 21 | private fun _latitude(earth: T): CustomFunction { 22 | return CustomFunction( 23 | "latitude", 24 | DoubleColumnType().apply { nullable = earth == null }, 25 | QueryParameter(earth, PGEarthPointLocationColumnType().apply { nullable = earth == null }) 26 | ) 27 | } 28 | 29 | fun longitude(earth: T): CustomFunction = _longitude(earth) 30 | @JvmName("longitude_not_nullable") 31 | @Suppress("UNCHECKED_CAST") 32 | fun longitude(earth: PGEarthPointLocation): CustomFunction = _longitude(earth) as CustomFunction 33 | 34 | private fun _longitude(earth: T): CustomFunction { 35 | return CustomFunction( 36 | "longitude", 37 | DoubleColumnType().apply { nullable = earth == null }, 38 | QueryParameter(earth, PGEarthPointLocationColumnType().apply { nullable = earth == null }) 39 | ) 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/LatLonToEarthFunctions.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | import org.jetbrains.exposed.sql.* 4 | 5 | /** 6 | * select ll_to_earth( 11.1 , 20.0 ); -> returns (5881394.65979286, 2140652.5921368, 1227937.44619261) 7 | * fun ll_to_earth(latitude:Double?, longitude:Double?):PGEarthPointLocation? 8 | */ 9 | 10 | fun ll_to_earth(latitude: T, longitude: T): CustomFunction = 11 | _ll_to_earth(latitude = latitude, longitude = longitude, returnsNullable = true) 12 | 13 | @JvmName("ll_to_earth_not_nullable") 14 | @Suppress("UNCHECKED_CAST") 15 | fun ll_to_earth(latitude: T, longitude: T): CustomFunction = 16 | _ll_to_earth( 17 | latitude = latitude, 18 | longitude = longitude, 19 | returnsNullable = false 20 | ) as CustomFunction 21 | 22 | private fun _ll_to_earth( 23 | latitude: T?, 24 | longitude: T?, 25 | returnsNullable: Boolean 26 | ): CustomFunction = 27 | CustomFunction( 28 | "ll_to_earth", 29 | PGEarthPointLocationColumnType().apply { nullable = returnsNullable }, 30 | when (latitude) { 31 | null -> NullExpr() 32 | else -> doubleParam(latitude.toDouble()) 33 | }, 34 | when (longitude) { 35 | null -> NullExpr() 36 | else -> doubleParam(longitude.toDouble()) 37 | } 38 | ) 39 | 40 | 41 | fun ll_to_earth(latitude: Column, longitude: Column): CustomFunction = 42 | _ll_to_earth(latitude = latitude, longitude = longitude) 43 | 44 | @JvmName("ll_to_earth_not_nullable") 45 | @Suppress("UNCHECKED_CAST") 46 | fun ll_to_earth(latitude: Column, longitude: Column): CustomFunction = 47 | _ll_to_earth(latitude = latitude, longitude = longitude) as CustomFunction 48 | 49 | fun _ll_to_earth( 50 | latitude: Column, longitude: Column 51 | ): CustomFunction = CustomFunction( 52 | "ll_to_earth", 53 | PGEarthPointLocationColumnType().apply { nullable = true }, 54 | *(listOf(latitude, longitude).toTypedArray()) 55 | ) 56 | 57 | private fun doubleParam(value: Double): Expression = QueryParameter(value, DoubleColumnType()) 58 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/NullExpr.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | import org.jetbrains.exposed.sql.Expression 4 | import org.jetbrains.exposed.sql.QueryBuilder 5 | 6 | class NullExpr : Expression() { 7 | override fun toQueryBuilder(queryBuilder: QueryBuilder) = queryBuilder { 8 | append(" NULL ") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/PGEarthBoxType.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | 4 | import org.jetbrains.exposed.sql.ColumnType 5 | import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi 6 | import org.postgresql.util.* 7 | 8 | data class PGEarthBox(val c1: PGEarthPointLocation, val c2: PGEarthPointLocation) 9 | 10 | fun PGEarthBox.toPgValue(): String = "${c1.toPgValue()},${c2.toPgValue()}" 11 | 12 | class PGEarthBoxColumnType : ColumnType() { 13 | companion object { 14 | private val patternTokenizeIntoPoints: Regex = "\\((.*?)\\)".toRegex() 15 | } 16 | 17 | private val pgObjectType: String = "cube" 18 | override fun sqlType(): String = pgObjectType 19 | 20 | override fun valueFromDB(value: Any): PGEarthBox { 21 | var pgTypeGiven: String? = null 22 | var pgValueGiven: String? = null 23 | return try { 24 | value as PGobject 25 | pgTypeGiven = value.type 26 | pgValueGiven = value.value 27 | 28 | val pointsText: List = patternTokenizeIntoPoints.findAll(pgValueGiven?:"") 29 | .map { it.value.trim() } 30 | .filter { it.isNotBlank() } 31 | .toList() 32 | .take(2) 33 | 34 | val points: List = pointsText.map { 35 | PGtokenizer.removePara(it) 36 | val t = PGtokenizer(PGtokenizer.removePara(it), ',') 37 | PGEarthPointLocation( 38 | x = java.lang.Double.parseDouble(t.getToken(0)), 39 | y = java.lang.Double.parseDouble(t.getToken(1)), 40 | z = java.lang.Double.parseDouble(t.getToken(2)) 41 | ) 42 | } 43 | 44 | PGEarthBox(c1 = points[0], c2 = points[1]) 45 | } catch (e: NumberFormatException) { 46 | throw PSQLException( 47 | GT.tr("Conversion to type $pgTypeGiven -> ${this::class.qualifiedName} failed: $pgValueGiven."), 48 | PSQLState.DATA_TYPE_MISMATCH, e) 49 | } 50 | } 51 | 52 | private fun valueToPGobject(value: Any?, index: Int): PGobject { 53 | val obj = PGobject() 54 | obj.type = sqlType() 55 | obj.value = when (value) { 56 | null -> null 57 | else -> try { 58 | (value as PGEarthBox).toPgValue() 59 | } catch (all: Exception) { 60 | throw PSQLException( 61 | "Failed to setParameter at index: $index - value: $value ! reason: ${all.message}", 62 | PSQLState.DATA_TYPE_MISMATCH, 63 | all 64 | ) 65 | } 66 | } 67 | return obj 68 | } 69 | 70 | override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { 71 | val obj: PGobject = valueToPGobject(value = value, index = index) 72 | super.setParameter(stmt, index, obj) 73 | } 74 | 75 | /* 76 | override fun setParameter(stmt: PreparedStatement, index: Int, value: Any?) { 77 | val obj = PGobject() 78 | obj.type = sqlType() 79 | obj.value = when (value) { 80 | null -> null 81 | else -> try { 82 | (value as PGEarthBox).toPgValue() 83 | } catch (all: Exception) { 84 | throw PSQLException( 85 | "Failed to setParameter at index: $index - value: $value ! reason: ${all.message}", 86 | PSQLState.DATA_TYPE_MISMATCH, 87 | all 88 | ) 89 | } 90 | } 91 | stmt.setObject(index, obj) 92 | } 93 | 94 | */ 95 | 96 | override fun notNullValueToDB(value: Any): PGEarthBox { 97 | return value as PGEarthBox 98 | } 99 | 100 | override fun nonNullValueToString(value: Any): String { 101 | val sinkValue: PGEarthBox = notNullValueToDB(value) 102 | return "'${sinkValue.toPgValue()}'" 103 | } 104 | } 105 | 106 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/PGEarthPointLocationTypes.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | import org.jetbrains.exposed.sql.ColumnType 4 | import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi 5 | import org.postgresql.util.* 6 | 7 | data class PGEarthPointLocation(val x: Double, val y: Double, val z: Double) 8 | 9 | fun PGEarthPointLocation.toPgValue(): String = "($x, $y, $z)" 10 | 11 | class PGEarthPointLocationColumnType : ColumnType() { 12 | private val pgObjectType: String = "cube" 13 | override fun sqlType(): String = pgObjectType 14 | 15 | override fun valueFromDB(value: Any): PGEarthPointLocation { 16 | var pgTypeGiven: String? = null 17 | var pgValueGiven: String? = null 18 | return try { 19 | value as PGobject 20 | pgTypeGiven = value.type 21 | pgValueGiven = value.value 22 | val t = PGtokenizer(PGtokenizer.removePara(pgValueGiven), ',') 23 | PGEarthPointLocation( 24 | x = java.lang.Double.parseDouble(t.getToken(0)), 25 | y = java.lang.Double.parseDouble(t.getToken(1)), 26 | z = java.lang.Double.parseDouble(t.getToken(2)) 27 | ) 28 | } catch (e: NumberFormatException) { 29 | throw PSQLException( 30 | GT.tr("Conversion to type $pgTypeGiven -> ${this::class.qualifiedName} failed: $pgValueGiven."), 31 | PSQLState.DATA_TYPE_MISMATCH, e) 32 | } 33 | } 34 | 35 | private fun valueToPGobject(value: Any?, index: Int): PGobject { 36 | val obj = PGobject() 37 | obj.type = sqlType() 38 | obj.value = when (value) { 39 | null -> null 40 | else -> try { 41 | (value as PGEarthPointLocation).toPgValue() 42 | } catch (all: Exception) { 43 | throw PSQLException( 44 | "Failed to setParameter at index: $index - value: $value ! reason: ${all.message}", 45 | PSQLState.DATA_TYPE_MISMATCH, 46 | all 47 | ) 48 | } 49 | } 50 | return obj 51 | } 52 | 53 | override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { 54 | val obj: PGobject = valueToPGobject(value = value, index = index) 55 | super.setParameter(stmt, index, obj) 56 | } 57 | /* 58 | override fun setParameter(stmt: PreparedStatement, index: Int, value: Any?) { 59 | val obj = PGobject() 60 | obj.type = sqlType() 61 | obj.value = when (value) { 62 | null -> null 63 | else -> try { 64 | (value as PGEarthPointLocation).toPgValue() 65 | } catch (all: Exception) { 66 | throw PSQLException( 67 | "Failed to setParameter at index: $index - value: $value ! reason: ${all.message}", 68 | PSQLState.DATA_TYPE_MISMATCH, 69 | all 70 | ) 71 | } 72 | } 73 | stmt.setObject(index, obj) 74 | } 75 | 76 | */ 77 | 78 | override fun notNullValueToDB(value: Any): PGEarthPointLocation { 79 | return value as PGEarthPointLocation 80 | } 81 | 82 | override fun nonNullValueToString(value: Any): String { 83 | val sinkValue: PGEarthPointLocation = notNullValueToDB(value) 84 | return "'${sinkValue.toPgValue()}'" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/postgres/extensions/earthdistance/PgRangeOperators.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.postgres.extensions.earthdistance 2 | 3 | import org.jetbrains.exposed.sql.ComparisonOp 4 | import org.jetbrains.exposed.sql.Expression 5 | import org.jetbrains.exposed.sql.Op 6 | 7 | /** 8 | * https://www.postgresql.org/docs/9.4/functions-range.html 9 | */ 10 | 11 | infix fun Expression<*>.rangeContains(other: Expression<*>): Op = 12 | PgRangeContainsOp(this, containsOtherExpr = other) 13 | 14 | private class PgRangeContainsOp(val sourceExpr: Expression<*>, val containsOtherExpr: Expression<*>) : 15 | ComparisonOp(sourceExpr, containsOtherExpr, "@>") 16 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/query/QueryExt.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.query 2 | 3 | import org.jetbrains.exposed.sql.Query 4 | import org.jetbrains.exposed.sql.QueryBuilder 5 | 6 | fun Query.toSQL():String = prepareSQL(QueryBuilder(false)) 7 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/exposed/spring/transaction/SpringTransactionTemplate.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.exposed.spring.transaction 2 | 3 | import org.funktionale.tries.Try 4 | import org.springframework.transaction.PlatformTransactionManager 5 | import org.springframework.transaction.TransactionDefinition 6 | import org.springframework.transaction.TransactionDefinition.TIMEOUT_DEFAULT 7 | import org.springframework.transaction.TransactionStatus 8 | import org.springframework.transaction.support.TransactionTemplate 9 | 10 | class SpringTransactionTemplate( 11 | private val delegate: TransactionTemplate 12 | ) { 13 | companion object 14 | 15 | /** 16 | * execute transactional code: 17 | * -> on error -> rollback -> throw 18 | * -> on success -> commit 19 | */ 20 | fun execute(block: (status: TransactionStatus) -> T): T = delegate.execute(block) as T 21 | 22 | /** 23 | * execute transactional code: 24 | * -> on error -> rollback 25 | * -> on success -> commit 26 | * entire result (success/error) is wrapped into Try 27 | */ 28 | fun tryExecute(block: (status: TransactionStatus) -> T): Try = Try { delegate.execute(block) as T } 29 | } 30 | 31 | 32 | class SpringTransactionTemplateBuilder( 33 | private val transactionManager: PlatformTransactionManager, 34 | var timeout: Int = TIMEOUT_DEFAULT, 35 | var propagation: Int = TransactionDefinition.PROPAGATION_REQUIRED, 36 | var isReadOnly: Boolean = false, 37 | var isolationLevel: Int = TransactionDefinition.ISOLATION_DEFAULT 38 | ) { 39 | companion object 40 | 41 | fun readOnly(value: Boolean): SpringTransactionTemplateBuilder { 42 | isReadOnly = value 43 | return this 44 | } 45 | 46 | fun propagation(value: Int): SpringTransactionTemplateBuilder { 47 | propagation = value 48 | return this 49 | } 50 | 51 | fun propagationRequired(): SpringTransactionTemplateBuilder = propagation(TransactionDefinition.PROPAGATION_REQUIRED) 52 | fun propagationRequiresNew(): SpringTransactionTemplateBuilder = propagation(TransactionDefinition.PROPAGATION_REQUIRES_NEW) 53 | fun propagationMandatory(): SpringTransactionTemplateBuilder = propagation(TransactionDefinition.PROPAGATION_MANDATORY) 54 | fun propagationNested(): SpringTransactionTemplateBuilder = propagation(TransactionDefinition.PROPAGATION_NESTED) 55 | fun isolationLevel(value: Int): SpringTransactionTemplateBuilder { 56 | isolationLevel = value 57 | return this 58 | } 59 | 60 | fun isolationLevelDefault(): SpringTransactionTemplateBuilder = isolationLevel(TransactionDefinition.ISOLATION_DEFAULT) 61 | fun isolationLevelRepeatableRead(): SpringTransactionTemplateBuilder = isolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ) 62 | fun isolationLevelSerializable(): SpringTransactionTemplateBuilder = isolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE) 63 | 64 | fun build(): SpringTransactionTemplate { 65 | val delegate = TransactionTemplate(transactionManager) 66 | delegate.timeout = timeout 67 | delegate.propagationBehavior = propagation 68 | delegate.isReadOnly = isReadOnly 69 | delegate.isolationLevel = isolationLevel 70 | return SpringTransactionTemplate(delegate) 71 | } 72 | } 73 | 74 | operator fun SpringTransactionTemplate.Companion.invoke( 75 | transactionManager: PlatformTransactionManager, init: SpringTransactionTemplateBuilder.() -> Unit = {} 76 | ): SpringTransactionTemplate = springTransactionTemplate(transactionManager, init) 77 | 78 | fun springTransactionTemplate( 79 | transactionManager: PlatformTransactionManager, init: SpringTransactionTemplateBuilder.() -> Unit = {} 80 | ): SpringTransactionTemplate = SpringTransactionTemplateBuilder(transactionManager) 81 | .apply(init) 82 | .build() 83 | 84 | 85 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/resources/resources.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.resources 2 | 3 | fun loadResource(resource: String): String = 4 | try { 5 | object {}.javaClass.getResource(resource) 6 | .readText(Charsets.UTF_8) 7 | } catch (all: Exception) { 8 | throw RuntimeException("Failed to load resource=$resource!", all) 9 | } 10 | 11 | -------------------------------------------------------------------------------- /rest-api/src/main/kotlin/com/example/util/time/Durations.kt: -------------------------------------------------------------------------------- 1 | package com.example.util.time 2 | 3 | import java.time.Duration 4 | import java.time.Instant 5 | 6 | fun Instant.durationToNow(now: Instant = Instant.now()): Duration = Duration.between(this, now) 7 | fun Instant.durationToNowInMillis(now: Instant = Instant.now()):Long = durationToNow(now).toMillis() -------------------------------------------------------------------------------- /rest-api/src/main/resources/application-flyway-migrate.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: flyway-migrate 3 | 4 | app.flyway: 5 | info: true 6 | strategy: MIGRATE 7 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/application-flyway-validate.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: flyway-validate 3 | 4 | app.flyway: 5 | info: true 6 | strategy: VALIDATE 7 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/application-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: local 3 | datasource: 4 | url: jdbc:postgresql://localhost:5432/app 5 | username: app_rw 6 | password: app_rw 7 | 8 | app: 9 | envName: local 10 | flyway: 11 | strategy: MIGRATE 12 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/application-playground.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: playground 3 | datasource: 4 | url: jdbc:postgresql://${DB_URL} 5 | username: app_rw 6 | password: app_rw 7 | 8 | app: 9 | envName: playground 10 | # flyway.strategy -> externalized in docker-compose-playground.yml 11 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: test 3 | datasource: 4 | #url: jdbc:postgresql://localhost:5432/app 5 | url: jdbc:postgresql://${SPRING_KOTLIN_EXPOSED_DB_CI_HOST:localhost}:5432/app_test 6 | username: app_rw 7 | password: app_rw 8 | 9 | app: 10 | envName: test 11 | flyway: 12 | strategy: MIGRATE 13 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | --- 2 | server.http2.enabled: true 3 | spring: 4 | main.banner-mode: "off" 5 | profiles: 6 | active: local 7 | 8 | datasource: 9 | driver-class-name: org.postgresql.Driver 10 | 11 | #flyway.enabled: true 12 | 13 | logging: 14 | level: 15 | org.springframework.web.servlet: INFO 16 | Exposed: DEBUG 17 | 18 | 19 | 20 | app: 21 | appName: "spring-kotlin-exposed" 22 | flyway: 23 | info: true 24 | strategy: VALIDATE 25 | 26 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/db/migration/V1.0__tweets_api.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE TweetStatusType AS ENUM ('DRAFT', 'PENDING', 'PUBLISHED'); 2 | 3 | CREATE TABLE Tweet ( 4 | id UUID NOT NULL, 5 | version INTEGER NOT NULL, 6 | created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 7 | updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 8 | deleted_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT TIMESTAMP 'epoch', 9 | 10 | status TweetStatusType NOT NULL DEFAULT 'DRAFT', 11 | message CHARACTER VARYING(255) NOT NULL, 12 | comment TEXT NULL 13 | ); 14 | ALTER TABLE Tweet 15 | OWNER TO app_rw; 16 | ALTER TABLE ONLY Tweet 17 | ADD CONSTRAINT tweet_pkey PRIMARY KEY (id); 18 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/db/migration/V2.0__bookstore_api.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE author ( 2 | id UUID NOT NULL, 3 | version INTEGER NOT NULL, 4 | created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 5 | updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 6 | name TEXT NOT NULL 7 | ); 8 | ALTER TABLE author 9 | OWNER TO app_rw; 10 | ALTER TABLE ONLY author 11 | ADD CONSTRAINT author_pkey PRIMARY KEY (id); 12 | 13 | CREATE TABLE book ( 14 | id UUID NOT NULL, 15 | author_id UUID NOT NULL, 16 | version INTEGER NOT NULL, 17 | created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 18 | updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 19 | title CHARACTER VARYING(255) NOT NULL, 20 | status CHARACTER VARYING(255) NOT NULL, 21 | price NUMERIC(15, 2) NOT NULL 22 | ); 23 | ALTER TABLE book 24 | OWNER TO app_rw; 25 | ALTER TABLE ONLY book 26 | ADD CONSTRAINT book_pkey PRIMARY KEY (id); 27 | ALTER TABLE ONLY book 28 | ADD CONSTRAINT book_author_id_fkey FOREIGN KEY (author_id) REFERENCES author (id); 29 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/db/migration/V3.0__bookz_api.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE bookz ( 2 | id UUID NOT NULL, 3 | created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 4 | updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 5 | is_active BOOLEAN NOT NULL, 6 | data JSONB NOT NULL 7 | ); 8 | ALTER TABLE bookz 9 | OWNER TO app_rw; 10 | ALTER TABLE ONLY bookz 11 | ADD CONSTRAINT bookz_pkey PRIMARY KEY (id); 12 | 13 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/db/migration/V4.0__documents_api.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE documents ( 2 | id CHARACTER VARYING(255) NOT NULL, 3 | created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 4 | updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, 5 | data JSONB NOT NULL 6 | ); 7 | ALTER TABLE documents 8 | OWNER TO app_rw; 9 | ALTER TABLE ONLY documents 10 | ADD CONSTRAINT documents_pkey PRIMARY KEY (id); 11 | 12 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/db/migration/V5.0__gis_places_api.sql: -------------------------------------------------------------------------------- 1 | 2 | -- table: place (gis example) 3 | CREATE TABLE place 4 | ( 5 | place_id uuid NOT NULL, 6 | created_at timestamp NOT NULL, 7 | modified_at timestamp NOT NULL, 8 | deleted_at timestamp NULL, 9 | active bool NOT NULL, 10 | place_name varchar(2048) NOT NULL, 11 | country_name varchar(2048) NOT NULL, 12 | city_name varchar(2048) NOT NULL, 13 | postal_code varchar(2048) NOT NULL, 14 | street_address varchar(2048) NOT NULL, 15 | formatted_address varchar(2048) NOT NULL, 16 | latitude numeric(10, 6) NOT NULL, 17 | longitude numeric(10, 6) NOT NULL, 18 | 19 | CONSTRAINT place_pkey PRIMARY KEY (place_id) 20 | ); 21 | 22 | -- table: place_geosearch_index -> create gist-index: 23 | CREATE INDEX place_geosearch_index ON place USING gist (ll_to_earth(latitude, longitude)); 24 | -------------------------------------------------------------------------------- /rest-api/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{yyyy-MM-dd HH:mm:ss.SSS Z} %5p --- [%t] %X{request_id} %logger{40} : %m%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/api/bookstore/db/AuthorRepoTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.db 2 | 3 | import com.example.api.bookstore.fixtures.BookstoreApiFixtures 4 | import com.example.api.bookstore.fixtures.randomized 5 | import com.example.api.common.rest.error.exception.EntityNotFoundException 6 | import com.example.testutils.assertions.shouldEqualRecursively 7 | import com.example.testutils.minutest.minuTestFactory 8 | import com.example.testutils.spring.BootWebMockMvcTest 9 | import org.amshove.kluent.shouldBe 10 | import org.amshove.kluent.shouldBeInstanceOf 11 | import org.amshove.kluent.shouldEqual 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.TestFactory 14 | import org.junit.jupiter.api.assertThrows 15 | import org.springframework.beans.factory.annotation.Autowired 16 | import java.time.Instant 17 | import java.util.* 18 | 19 | class AuthorRepoTest( 20 | @Autowired private val repo: AuthorRepository 21 | ) : BootWebMockMvcTest() { 22 | 23 | @Test 24 | fun `unhappy - handle unknown id's `() { 25 | val unknownId: UUID = UUID.randomUUID() 26 | 27 | repo.findOneById(id = unknownId) shouldBe null 28 | 29 | assertThrows { repo.get(id = unknownId) } 30 | assertThrows { repo[unknownId] } 31 | } 32 | 33 | @Test 34 | fun `basic crud ops should work`() { 35 | val id: UUID = UUID.randomUUID() 36 | val now: Instant = Instant.now() 37 | val recordNew = BookstoreApiFixtures.newAuthorRecord(authorId = id, now = now) 38 | val recordInserted = repo.insert(recordNew) 39 | recordInserted shouldEqualRecursively recordNew 40 | 41 | run { 42 | val recordSource: AuthorRecord = repo[id] 43 | val recordToBeModified: AuthorRecord = recordSource 44 | .copy(version = 1, name = "other-name", modifiedAt = Instant.now()) 45 | val recordUpdated: AuthorRecord = repo.update(recordToBeModified) 46 | 47 | recordUpdated shouldEqualRecursively recordToBeModified 48 | } 49 | 50 | val recordFromDb: AuthorRecord? = repo 51 | .findAll() 52 | .firstOrNull { it.id == id } 53 | 54 | recordFromDb shouldBeInstanceOf AuthorRecord::class 55 | recordFromDb!! 56 | recordFromDb.id shouldEqual recordNew.id 57 | } 58 | 59 | @TestFactory 60 | fun `some random crud ops should work`() = minuTestFactory { 61 | val testCases: List = (0..100).map { 62 | val recordId = UUID.randomUUID() 63 | val recordNew = BookstoreApiFixtures.newAuthorRecord(authorId = recordId, now = Instant.now()) 64 | .randomized(preserveIds = true) 65 | TestCase( 66 | recordNew = recordNew, 67 | recordsUpdate = (0..10).map { 68 | recordNew.randomized(preserveIds = true) 69 | } 70 | ) 71 | } 72 | 73 | testCases.forEach { testCase -> 74 | context("test: : ${testCase.recordNew}") { 75 | test("INSERT: ${testCase.recordNew}") { 76 | val inserted: AuthorRecord = repo.insert(testCase.recordNew) 77 | inserted shouldEqualRecursively testCase.recordNew 78 | } 79 | test("GET: ${testCase.recordNew}") { 80 | val loaded: AuthorRecord = repo.get(id = testCase.recordNew.id) 81 | loaded shouldEqualRecursively testCase.recordNew 82 | } 83 | testCase.recordsUpdate.forEachIndexed { index, recordToUpdate -> 84 | test("UPDATE ($index): $recordToUpdate") { 85 | val updated: AuthorRecord = repo.update(recordToUpdate) 86 | updated shouldEqualRecursively recordToUpdate 87 | } 88 | } 89 | } 90 | } 91 | } 92 | 93 | private data class TestCase( 94 | val recordNew: AuthorRecord, 95 | val recordsUpdate: List 96 | ) 97 | 98 | } 99 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/api/bookstore/fixtures/ApiFixtures.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookstore.fixtures 2 | 3 | import com.example.api.bookstore.db.AuthorRecord 4 | import com.example.api.bookstore.db.BookRecord 5 | import com.example.api.bookstore.db.BookStatus 6 | import com.example.testutils.random.random 7 | import com.example.testutils.random.randomBigDecimal 8 | import com.example.testutils.random.randomEnumValue 9 | import com.example.testutils.random.randomString 10 | import java.math.BigDecimal 11 | import java.time.Duration 12 | import java.time.Instant 13 | import java.util.* 14 | 15 | data class BookEntity(val bookRecord: BookRecord, val authorRecord: AuthorRecord) 16 | 17 | object BookstoreApiFixtures { 18 | fun newAuthorRecord(authorId: UUID = UUID.randomUUID(), now: Instant = Instant.now()): AuthorRecord = 19 | AuthorRecord( 20 | id = authorId, createdAt = now, modifiedAt = now, version = 0, name = "name" 21 | ) 22 | 23 | fun newBookRecord( 24 | bookId: UUID = UUID.randomUUID(), authorId: UUID = UUID.randomUUID(), now: Instant = Instant.now() 25 | ): BookRecord = 26 | BookRecord( 27 | id = bookId, createdAt = now, modifiedAt = now, version = 0, 28 | authorId = authorId, title = "title", 29 | status = BookStatus.NEW, price = BigDecimal("100.01") 30 | ) 31 | 32 | fun newBookEntity( 33 | bookId: UUID = UUID.randomUUID(), authorId: UUID = UUID.randomUUID(), now: Instant = Instant.now() 34 | ): BookEntity = 35 | BookEntity( 36 | authorRecord = newAuthorRecord(authorId = authorId, now = now), 37 | bookRecord = newBookRecord( 38 | bookId = bookId, authorId = authorId, now = now 39 | ) 40 | ) 41 | } 42 | 43 | fun AuthorRecord.randomized(preserveIds: Boolean): AuthorRecord { 44 | val instantMin: Instant = Instant.EPOCH 45 | val instantMax: Instant = (Instant.now() + Duration.ofDays(50 * 365)) 46 | return AuthorRecord( 47 | id = when (preserveIds) { 48 | true -> id 49 | false -> UUID.randomUUID() 50 | }, 51 | createdAt = (instantMin..instantMax).random(), 52 | modifiedAt = (instantMin..instantMax).random(), 53 | version = (0..1000).random(), 54 | name = randomString(prefix = "name-") 55 | ) 56 | } 57 | 58 | fun BookRecord.randomized(preserveIds: Boolean): BookRecord { 59 | val instantMin: Instant = Instant.EPOCH 60 | val instantMax: Instant = (Instant.now() + Duration.ofDays(50 * 365)) 61 | return BookRecord( 62 | id = when (preserveIds) { 63 | true -> id 64 | false -> UUID.randomUUID() 65 | }, 66 | authorId = when (preserveIds) { 67 | true -> authorId 68 | false -> UUID.randomUUID() 69 | }, 70 | createdAt = (instantMin..instantMax).random(), 71 | modifiedAt = (instantMin..instantMax).random(), 72 | version = (0..1000).random(), 73 | title = randomString(prefix = "title-"), 74 | status = randomEnumValue(), 75 | price = (10_000.01..20_000.01).randomBigDecimal() 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/api/bookz/db/RepoTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.db 2 | 3 | import com.example.api.bookz.fixtures.BookzApiFixtures 4 | import com.example.api.bookz.fixtures.randomized 5 | import com.example.api.common.rest.error.exception.EntityNotFoundException 6 | import com.example.testutils.assertions.shouldEqualRecursively 7 | import com.example.testutils.minutest.minuTestFactory 8 | import com.example.testutils.spring.BootWebMockMvcTest 9 | import org.amshove.kluent.shouldBe 10 | import org.junit.jupiter.api.Test 11 | import org.junit.jupiter.api.TestFactory 12 | import org.junit.jupiter.api.assertThrows 13 | import org.springframework.beans.factory.annotation.Autowired 14 | import java.time.Instant 15 | import java.util.* 16 | 17 | class BookzRepoTest( 18 | @Autowired private val repo: BookzRepo 19 | ) : BootWebMockMvcTest() { 20 | 21 | @Test 22 | fun `unhappy - handle unknown id's `() { 23 | val unknownId: UUID = UUID.randomUUID() 24 | 25 | repo.findOne(id = unknownId) shouldBe null 26 | 27 | assertThrows { repo.get(id = unknownId) } 28 | assertThrows { repo[unknownId] } 29 | } 30 | 31 | @Test 32 | fun `basic crud ops should work`() { 33 | val recordNew: BookzRecord = BookzApiFixtures.newBookzRecord() 34 | val recordId: UUID = recordNew.id 35 | 36 | repo.insert(recordNew) 37 | .also { it shouldEqualRecursively recordNew } 38 | repo.get(id = recordId) 39 | .also { it shouldEqualRecursively recordNew } 40 | 41 | repo.findAll() 42 | .firstOrNull { it.id == recordId } 43 | .also { it shouldEqualRecursively recordNew } 44 | 45 | repo.findAllActive() 46 | .firstOrNull { it.id == recordId } 47 | .also { it shouldEqualRecursively recordNew } 48 | 49 | val toUpdate: BookzRecord = recordNew.copy(isActive = false, modifiedAt = Instant.now()) 50 | val recordUpdated: BookzRecord = repo.updateOne(id = recordId) { 51 | it[modifiedAt] = toUpdate.modifiedAt 52 | it[isActive] = toUpdate.isActive 53 | } 54 | .also { it shouldEqualRecursively toUpdate } 55 | .also { repo[recordId] shouldEqualRecursively it } 56 | 57 | repo.get(id = recordId) 58 | .also { it shouldEqualRecursively recordUpdated } 59 | repo.findAll() 60 | .firstOrNull { it.id == recordId } 61 | .also { it shouldEqualRecursively recordUpdated } 62 | repo.findAllActive() 63 | .firstOrNull { it.id == recordId } 64 | .also { it shouldBe null } 65 | } 66 | 67 | 68 | @TestFactory 69 | fun `some random crud ops should work`() = minuTestFactory { 70 | val testCases: List = (0..100).map { 71 | val recordNew: BookzRecord = BookzApiFixtures 72 | .newBookzRecord() 73 | .randomized(preserveIds = true, preserveIsActive = true) 74 | 75 | TestCase( 76 | recordNew = recordNew, 77 | recordsUpdate = (0..10).map { 78 | recordNew.randomized(preserveIds = true, preserveIsActive = true) 79 | } 80 | ) 81 | } 82 | context("prepare ...") { 83 | testCases.onEach { testCase -> 84 | context("prepare: ${testCase.recordNew}") { 85 | testCase.recordNew shouldEqualRecursively testCase.recordNew 86 | testCase shouldEqualRecursively testCase 87 | } 88 | } 89 | } 90 | 91 | testCases.forEachIndexed { testCaseIndex, testCase -> 92 | context("test ($testCaseIndex): ${testCase.recordNew}") { 93 | 94 | context("DB INSERT") { 95 | test("INSERT: ${testCase.recordNew}") { 96 | repo.insert(testCase.recordNew) 97 | .also { it shouldEqualRecursively testCase.recordNew } 98 | } 99 | test("GET INSERTED: ${testCase.recordNew}") { 100 | repo.get(id = testCase.recordNew.id) 101 | .also { it shouldEqualRecursively testCase.recordNew } 102 | } 103 | } 104 | context("DB UPDATE") { 105 | testCase.recordsUpdate.forEachIndexed { index, recordToUpdate -> 106 | context("DB UPDATE ($index): $recordToUpdate") { 107 | test("UPDATE ($index): $recordToUpdate") { 108 | repo.update(recordToUpdate) 109 | .also { it shouldEqualRecursively recordToUpdate } 110 | } 111 | test("GET UPDATED ($index): $recordToUpdate") { 112 | repo.get(id = recordToUpdate.id) 113 | .also { it shouldEqualRecursively recordToUpdate } 114 | } 115 | } 116 | } 117 | } 118 | 119 | } 120 | 121 | } 122 | } 123 | 124 | private data class TestCase( 125 | val recordNew: BookzRecord, 126 | val recordsUpdate: List 127 | ) 128 | 129 | } 130 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/api/bookz/fixtures/ApiFixtures.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.bookz.fixtures 2 | 3 | import com.example.api.bookz.db.BookzData 4 | import com.example.api.bookz.db.BookzRecord 5 | import com.example.testutils.random.random 6 | import com.example.testutils.random.randomBoolean 7 | import com.example.testutils.random.randomString 8 | import java.time.Duration 9 | import java.time.Instant 10 | import java.util.* 11 | 12 | object BookzApiFixtures { 13 | fun newBookzRecord(bookzId: UUID = UUID.randomUUID(), now: Instant = Instant.now()): BookzRecord = 14 | BookzRecord( 15 | id = bookzId, 16 | createdAt = now, 17 | modifiedAt = now, 18 | isActive = true, 19 | data = BookzData( 20 | title = "title", 21 | genres = listOf("genre-001", "genre-002"), 22 | published = true 23 | ) 24 | ) 25 | } 26 | 27 | 28 | fun BookzRecord.randomized(preserveIds: Boolean, preserveIsActive: Boolean): BookzRecord { 29 | val instantMin: Instant = Instant.EPOCH 30 | val instantMax: Instant = (Instant.now() + Duration.ofDays(50 * 365)) 31 | return BookzRecord( 32 | id = when (preserveIds) { 33 | true -> id 34 | false -> UUID.randomUUID() 35 | }, 36 | createdAt = (instantMin..instantMax).random(), 37 | modifiedAt = (instantMin..instantMax).random(), 38 | isActive = when (preserveIsActive) { 39 | true -> isActive 40 | false -> randomBoolean() 41 | }, 42 | data = BookzData( 43 | title = randomString(prefix = "title-"), 44 | genres = (0..100).map { "genre-$it" }.shuffled().take((0..50).random()), 45 | published = randomBoolean() 46 | ) 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/api/places/fixtures/ApiFixtures.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.places.fixtures 2 | 3 | import com.example.api.places.common.db.PlaceRecord 4 | import com.example.testutils.random.random 5 | import com.example.testutils.random.randomBoolean 6 | import com.example.testutils.random.randomString 7 | import java.math.BigDecimal 8 | import java.math.RoundingMode 9 | import java.time.Duration 10 | import java.time.Instant 11 | import java.util.* 12 | 13 | object PlacesApiFixtures { 14 | fun newPlaceRecord(place_id: UUID = UUID.randomUUID(), now: Instant = Instant.now()): PlaceRecord = 15 | PlaceRecord( 16 | place_id = place_id, 17 | createdAt = now, 18 | modified_at = now, 19 | deleted_at = null, 20 | active = true, 21 | placeName = "placeName", 22 | countryName = "countryName", 23 | cityName = "cityName", 24 | postalCode = "postalCode", 25 | streetAddress = "streetAddress", 26 | formattedAddress = "formattedAddress", 27 | latitude = BigDecimal("1.01"), 28 | longitude = BigDecimal("2.02") 29 | 30 | ) 31 | } 32 | 33 | 34 | fun PlaceRecord.randomized(preserveIds: Boolean, preserveIsActive: Boolean): PlaceRecord { 35 | val instantMin: Instant = Instant.EPOCH 36 | val instantMax: Instant = (Instant.now() + Duration.ofDays(50 * 365)) 37 | return PlaceRecord( 38 | place_id = when (preserveIds) { 39 | true -> place_id 40 | false -> UUID.randomUUID() 41 | }, 42 | createdAt = (instantMin..instantMax).random(), 43 | modified_at = (instantMin..instantMax).random(), 44 | deleted_at = listOf(null, (instantMin..instantMax).random()).shuffled().first(), 45 | active = when (preserveIsActive) { 46 | true -> active 47 | false -> randomBoolean() 48 | }, 49 | placeName = randomString(prefix = "placeName"), 50 | countryName = randomString(prefix = "countryName"), 51 | cityName = randomString(prefix = "cityName"), 52 | postalCode = randomString(prefix = "postalCode"), 53 | streetAddress = randomString(prefix = "streetAddress"), 54 | formattedAddress = randomString(prefix = "formattedAddress"), 55 | latitude = (-180.0000..180.0000).random().toBigDecimal().setScale(4, RoundingMode.HALF_EVEN), 56 | longitude = (-180.0000..180.0000).random().toBigDecimal().setScale(4, RoundingMode.HALF_EVEN) 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/api/tweeter/db/RepoTest.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter.db 2 | 3 | import com.example.api.common.rest.error.exception.EntityNotFoundException 4 | import com.example.api.tweeter.fixtures.TweeterApiFixtures 5 | import com.example.api.tweeter.fixtures.randomized 6 | import com.example.testutils.assertions.shouldEqualRecursively 7 | import com.example.testutils.minutest.minuTestFactory 8 | import com.example.testutils.spring.BootWebMockMvcTest 9 | import org.amshove.kluent.shouldBe 10 | import org.amshove.kluent.shouldBeInstanceOf 11 | import org.amshove.kluent.shouldEqual 12 | import org.junit.jupiter.api.Test 13 | import org.junit.jupiter.api.TestFactory 14 | import org.junit.jupiter.api.assertThrows 15 | import org.springframework.beans.factory.annotation.Autowired 16 | import java.time.Instant 17 | import java.util.* 18 | 19 | class TweetsRepoTest( 20 | @Autowired private val repo: TweetsRepo 21 | ) : BootWebMockMvcTest() { 22 | 23 | @Test 24 | fun `unhappy - handle unknown id's `() { 25 | val unknownId: UUID = UUID.randomUUID() 26 | 27 | repo.findOneById(id = unknownId) shouldBe null 28 | 29 | assertThrows { repo.get(id = unknownId) } 30 | assertThrows { repo[unknownId] } 31 | } 32 | 33 | @Test 34 | fun `basic crud ops should work`() { 35 | val recordNew: TweetsRecord = TweeterApiFixtures.newTweetsRecord() 36 | val recordId: UUID = recordNew.id 37 | 38 | repo.insert(recordNew) 39 | .also { it shouldEqualRecursively recordNew } 40 | 41 | TweetStatus.values().forEach { statusToBeApplied: TweetStatus -> 42 | val recordSource: TweetsRecord = repo[recordId] 43 | val recordToBeModified: TweetsRecord = recordSource 44 | .copy(status = statusToBeApplied, modifiedAt = Instant.now()) 45 | repo.update(recordToBeModified) 46 | .also { it shouldEqualRecursively recordToBeModified } 47 | repo[recordId] 48 | .also { it shouldEqualRecursively recordToBeModified } 49 | } 50 | 51 | val recordFromDb: TweetsRecord? = repo 52 | .findAll() 53 | .firstOrNull { it.id == recordId } 54 | 55 | recordFromDb shouldBeInstanceOf TweetsRecord::class 56 | recordFromDb!! 57 | recordFromDb.id shouldEqual recordNew.id 58 | recordFromDb.message shouldEqual recordNew.message 59 | recordFromDb.comment shouldEqual recordNew.comment 60 | } 61 | 62 | @TestFactory 63 | fun `some random crud ops should work`() = minuTestFactory { 64 | val testCases: List = (0..100).map { 65 | val recordNew: TweetsRecord = TweeterApiFixtures 66 | .newTweetsRecord() 67 | .randomized(preserveIds = true) 68 | 69 | TestCase( 70 | recordNew = recordNew, 71 | recordsUpdate = (0..10).map { 72 | recordNew.randomized(preserveIds = true) 73 | } 74 | ) 75 | } 76 | context("prepare ...") { 77 | testCases.onEach { testCase -> 78 | context("prepare: ${testCase.recordNew}") { 79 | testCase.recordNew shouldEqualRecursively testCase.recordNew 80 | testCase shouldEqualRecursively testCase 81 | } 82 | } 83 | } 84 | 85 | testCases.forEachIndexed { testCaseIndex, testCase -> 86 | context("test ($testCaseIndex): ${testCase.recordNew}") { 87 | 88 | context("DB INSERT") { 89 | test("INSERT: ${testCase.recordNew}") { 90 | repo.insert(testCase.recordNew) 91 | .also { it shouldEqualRecursively testCase.recordNew } 92 | } 93 | test("GET INSERTED: ${testCase.recordNew}") { 94 | repo.get(id = testCase.recordNew.id) 95 | .also { it shouldEqualRecursively testCase.recordNew } 96 | } 97 | } 98 | context("DB UPDATE") { 99 | testCase.recordsUpdate.forEachIndexed { index, recordToUpdate -> 100 | context("DB UPDATE ($index): $recordToUpdate") { 101 | test("UPDATE ($index): $recordToUpdate") { 102 | repo.update(recordToUpdate) 103 | .also { it shouldEqualRecursively recordToUpdate } 104 | } 105 | test("GET UPDATED ($index): $recordToUpdate") { 106 | repo.get(id = recordToUpdate.id) 107 | .also { it shouldEqualRecursively recordToUpdate } 108 | } 109 | } 110 | } 111 | } 112 | 113 | } 114 | 115 | } 116 | } 117 | 118 | private data class TestCase( 119 | val recordNew: TweetsRecord, 120 | val recordsUpdate: List 121 | ) 122 | 123 | } 124 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/api/tweeter/fixtures/ApiFixtures.kt: -------------------------------------------------------------------------------- 1 | package com.example.api.tweeter.fixtures 2 | 3 | import com.example.api.tweeter.db.TweetStatus 4 | import com.example.api.tweeter.db.TweetsRecord 5 | import com.example.testutils.random.random 6 | import com.example.testutils.random.randomEnumValue 7 | import com.example.testutils.random.randomString 8 | import java.time.Duration 9 | import java.time.Instant 10 | import java.util.* 11 | 12 | object TweeterApiFixtures { 13 | fun newTweetsRecord(tweetsId:UUID= UUID.randomUUID(),now:Instant=Instant.now()):TweetsRecord= 14 | TweetsRecord( 15 | id = tweetsId, 16 | createdAt = now, 17 | modifiedAt = now, 18 | deletedAt = Instant.EPOCH, 19 | version = 0, 20 | message = "message", 21 | comment = "comment", 22 | status = TweetStatus.DRAFT 23 | ) 24 | } 25 | 26 | 27 | fun TweetsRecord.randomized(preserveIds: Boolean): TweetsRecord { 28 | val instantMin: Instant = Instant.EPOCH 29 | val instantMax: Instant = (Instant.now() + Duration.ofDays(50 * 365)) 30 | return TweetsRecord( 31 | id = when (preserveIds) { 32 | true -> id 33 | false -> UUID.randomUUID() 34 | }, 35 | createdAt = (instantMin..instantMax).random(), 36 | modifiedAt = (instantMin..instantMax).random(), 37 | deletedAt = (instantMin..instantMax).random(), 38 | version = (0..1000).random(), 39 | message = randomString(prefix = "msg-"), 40 | comment = randomString(prefix = "comment-"), 41 | status = randomEnumValue() 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/bootstrap/BootstrapIT.kt: -------------------------------------------------------------------------------- 1 | package com.example.bootstrap 2 | 3 | import com.example.testutils.spring.BootWebRandomPortTest 4 | import org.amshove.kluent.`should be greater than` 5 | import org.junit.jupiter.api.Test 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.boot.web.server.LocalServerPort 8 | import org.springframework.test.web.reactive.server.WebTestClient 9 | import java.time.Duration 10 | 11 | class BootstrapIT( 12 | @LocalServerPort private val port: Int, 13 | @Autowired webTestClient: WebTestClient 14 | ) : BootWebRandomPortTest() { 15 | private val webClient: WebTestClient = webTestClient 16 | .mutate() 17 | .responseTimeout(Duration.ofSeconds(60)) 18 | .build() 19 | 20 | @Test 21 | fun `context loads`() { 22 | port `should be greater than` 0 23 | } 24 | 25 | @Test 26 | fun `GET api health - should return 200`() { 27 | webClient 28 | 29 | .get().uri("/health") 30 | .header("Origin", "http://example.com") 31 | .exchange().expectStatus().isOk 32 | } 33 | 34 | @Test 35 | fun `OPTIONS api health - should return 200`() { 36 | webClient.options().uri("/health") 37 | .header("Origin", "http://example.com") 38 | .exchange().expectStatus().isOk 39 | } 40 | 41 | 42 | } -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testconfig/Configurations.kt: -------------------------------------------------------------------------------- 1 | package com.example.testconfig 2 | 3 | import com.example.testutils.resources.CodeSourceResourceBucket 4 | import com.example.testutils.resources.CodeSourceResources 5 | 6 | object TestConfigurations { 7 | val codeSourceResourcesLocation: String = CodeSourceResources 8 | .fileLocationAsString() 9 | .let { 10 | CodeSourceResources.replaceLocationSuffixes( 11 | location = it, 12 | oldSuffixes = listOf("/out/test/classes/","/classes/kotlin/test/"), 13 | newSuffix = "/src/test/resources", 14 | oldSuffixRequired = true 15 | ) 16 | } 17 | } 18 | 19 | 20 | private fun foo() = TestConfigurations.codeSourceResourcesLocation 21 | enum class CodeSourceResourceBuckets(val bucket: CodeSourceResourceBucket) { 22 | ROOT( 23 | bucket = CodeSourceResourceBucket( 24 | qualifiedName = "", codeSourceLocation = TestConfigurations.codeSourceResourcesLocation 25 | ) 26 | ), 27 | GOLDEN_TEST_DATA( 28 | bucket = CodeSourceResourceBucket( 29 | qualifiedName = "/golden-test-data", codeSourceLocation = TestConfigurations.codeSourceResourcesLocation 30 | ) 31 | ) 32 | 33 | ; 34 | 35 | } 36 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/assertions/assertions.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.assertions 2 | 3 | import org.amshove.kluent.shouldBeEqualTo 4 | import org.amshove.kluent.shouldBeInRange 5 | import org.assertj.core.api.Assertions 6 | import org.assertj.core.util.BigDecimalComparator 7 | import org.assertj.core.util.DoubleComparator 8 | import org.assertj.core.util.FloatComparator 9 | import org.junit.jupiter.api.assertAll 10 | import java.math.BigDecimal 11 | import java.math.RoundingMode 12 | import java.time.Instant 13 | import java.time.temporal.ChronoUnit 14 | import kotlin.math.min 15 | import org.junit.jupiter.api.Assertions as JunitAssertions 16 | 17 | fun assertTrue(message: String, condition: Boolean): Unit = JunitAssertions.assertTrue(condition, message) 18 | 19 | infix fun Instant.shouldBeGreaterThan(expected: Instant) = this.apply { assertTrue("Expected $this to be greater than $expected", this > expected) } 20 | infix fun Instant.`should be greater than`(expected: Instant) = this.shouldBeGreaterThan(expected) 21 | 22 | infix fun Instant.shouldBeBetween(expected: Pair) = this.apply { assertTrue("Expected $this to be between $expected", this >= expected.first && this <= expected.second) } 23 | infix fun Instant.shouldEqualInstant(other: Instant?) { 24 | this.truncatedTo(ChronoUnit.MILLIS) shouldBeEqualTo other?.truncatedTo(ChronoUnit.MILLIS) 25 | } 26 | 27 | /** 28 | * https://joel-costigliola.github.io/assertj/assertj-core-features-highlight.html#field-by-field-comparison 29 | * https://assertj.github.io/doc/#assertj-core-recursive-comparison 30 | */ 31 | 32 | infix fun Any?.shouldEqualRecursively(other: Any?) = Assertions 33 | .assertThat(this) 34 | .usingRecursiveComparison() 35 | .ignoringAllOverriddenEquals() 36 | .withComparatorForType(DoubleComparator(0.01), Double::class.java) 37 | .withComparatorForType(FloatComparator(0.01f), Float::class.java) 38 | .withComparatorForType({ o1, o2 -> 39 | o1.truncatedTo(ChronoUnit.MILLIS).compareTo(o2.truncatedTo(ChronoUnit.MILLIS)) 40 | }, Instant::class.java) 41 | .withComparatorForType(object : BigDecimalComparator() { 42 | private val roundingMode = RoundingMode.HALF_EVEN // banker's rounding," - the rounding policy used for {@code float} and {@code double} 43 | override fun compareNonNull(number1: BigDecimal, number2: BigDecimal): Int { 44 | val scale = min(number1.scale(), number2.scale()) 45 | 46 | val n1 = number1.rounded(scale = scale, roundingMode = roundingMode) 47 | val n2 = number2.rounded(scale = scale, roundingMode = roundingMode) 48 | val r2 = n1.compareTo(n2) 49 | return r2 50 | } 51 | }, BigDecimal::class.java) 52 | .isEqualTo(other) 53 | 54 | infix fun Any?.shouldEqualRecursivelyOld(other: Any?) = Assertions 55 | .assertThat(this) 56 | .usingComparatorForType(DoubleComparator(0.01), Double::class.java) 57 | .usingComparatorForType({ o1, o2 -> 58 | o1.truncatedTo(ChronoUnit.MILLIS).compareTo(o2.truncatedTo(ChronoUnit.MILLIS)) 59 | }, Instant::class.java) 60 | .usingComparatorForType(object : BigDecimalComparator() { 61 | private val roundingMode = RoundingMode.HALF_EVEN // banker's rounding," - the rounding policy used for {@code float} and {@code double} 62 | override fun compareNonNull(number1: BigDecimal, number2: BigDecimal): Int { 63 | val scale = min(number1.scale(), number2.scale()) 64 | 65 | val n1 = number1.rounded(scale = scale, roundingMode = roundingMode) 66 | val n2 = number2.rounded(scale = scale, roundingMode = roundingMode) 67 | val r2 = n1.compareTo(n2) 68 | return r2 69 | } 70 | }, BigDecimal::class.java) 71 | .isEqualToComparingFieldByFieldRecursively(other) 72 | 73 | 74 | inline fun T?.shouldNotNull(): T { 75 | assertTrue("Expected ${T::class.java.canonicalName} to be not null", this != null) 76 | return this!! 77 | } 78 | 79 | private fun BigDecimal.rounded(scale: Int, roundingMode: RoundingMode = RoundingMode.HALF_EVEN): BigDecimal { 80 | return setScale(scale, roundingMode) 81 | } 82 | 83 | fun softAssertAll(heading: String?, assertions: List<() -> Any?>) { 84 | val executables: List<() -> Unit> = assertions.map { 85 | val exe: () -> Unit = { it() } 86 | exe 87 | } 88 | assertAll(heading, executables.stream()) 89 | } 90 | 91 | infix fun Double.shouldBeInRange(r: ClosedFloatingPointRange): Double = shouldBeInRange(r.start, r.endInclusive) 92 | 93 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/collections/cartesian.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.collections 2 | 3 | infix fun List.cartesianProduct(others: List): List> = 4 | flatMap { t: T -> 5 | others.map { o -> Pair(t, o) } 6 | } 7 | 8 | inline fun List.mapCartesianProduct(others: List, transform: (Pair) -> R): List = 9 | flatMap { t: T -> 10 | others.map { o -> transform(Pair(t, o)) } 11 | } 12 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/json/jsonAssertions.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.json 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import com.fasterxml.jackson.databind.SerializationFeature 5 | import com.fasterxml.jackson.datatype.jdk8.Jdk8Module 6 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 7 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 8 | import com.fasterxml.jackson.module.kotlin.readValue 9 | import org.amshove.kluent.shouldBeEqualTo 10 | 11 | fun simpleJsonSerializer(): ObjectMapper { 12 | return jacksonObjectMapper() 13 | .registerModules( 14 | JavaTimeModule(), 15 | Jdk8Module() 16 | ) 17 | .disable( 18 | SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, 19 | SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, 20 | SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, 21 | SerializationFeature.WRITE_ENUMS_USING_INDEX 22 | ) 23 | } 24 | 25 | fun String.toNormalizedJson(): String { 26 | val mapper:ObjectMapper = jacksonObjectMapper() 27 | mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); 28 | val decoded: Any? = mapper.readValue(this)// mapper.readTree(this) 29 | 30 | return mapper 31 | .writeValueAsString(decoded) 32 | .trim() 33 | } 34 | 35 | infix fun String.shouldEqualJson(theOther: String) = 36 | this.toNormalizedJson() shouldBeEqualTo theOther.toNormalizedJson() 37 | 38 | fun Any?.toJson(mapper: ObjectMapper = simpleJsonSerializer()): String { 39 | return mapper.writeValueAsString(this) 40 | } 41 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/junit5/TestFactory.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.junit5 2 | 3 | import org.junit.jupiter.api.DynamicContainer 4 | import org.junit.jupiter.api.DynamicNode 5 | import org.junit.jupiter.api.DynamicTest 6 | import org.junit.jupiter.api.function.Executable 7 | import java.util.stream.Stream 8 | 9 | class TestContainerBuilder(private var name: String) : TestProvider, ContainerProvider { 10 | private val nodes: MutableList = mutableListOf() 11 | fun name(value: String) { 12 | name = value 13 | } 14 | 15 | fun name(): String = name 16 | 17 | override fun test(name: String, test: () -> Any?) { 18 | val node = dynamicTest(name, test) 19 | nodes.add(node) 20 | } 21 | 22 | override fun container(name: String, init: TestContainerBuilder.() -> Unit) { 23 | val node = containerBuilder(name = name, init = init) 24 | nodes.add(node) 25 | } 26 | 27 | operator fun invoke(): DynamicContainer = build() 28 | private fun build(): DynamicContainer = dynamicContainer(name, nodes.toList()) 29 | } 30 | 31 | private fun containerBuilder(name: String, init: TestContainerBuilder.() -> Unit): DynamicContainer { 32 | return TestContainerBuilder(name = name) 33 | .apply(init)() 34 | } 35 | 36 | class TestFactoryBuilder : TestProvider, ContainerProvider { 37 | private val nodes: MutableList = mutableListOf() 38 | 39 | override fun test(name: String, test: () -> Any?) { 40 | val node = dynamicTest(name, test) 41 | nodes.add(node) 42 | } 43 | 44 | override fun container(name: String, init: TestContainerBuilder.() -> Unit) { 45 | val node = containerBuilder(name = name, init = init) 46 | nodes.add(node) 47 | } 48 | 49 | operator fun invoke(): Stream = nodes.stream() 50 | } 51 | 52 | fun testFactory(init: TestFactoryBuilder.() -> Unit): Stream = TestFactoryBuilder() 53 | .apply(init)() 54 | 55 | private interface TestProvider { 56 | fun test(name: String, test: () -> Any?) 57 | } 58 | 59 | private interface ContainerProvider { 60 | fun container(name: String, init: TestContainerBuilder.() -> Unit) 61 | } 62 | 63 | private fun dynamicContainer(name: String, nodes: List): DynamicContainer = 64 | DynamicContainer.dynamicContainer(name, nodes) 65 | 66 | private fun dynamicTest(name: String, test: () -> Any?): DynamicTest = 67 | DynamicTest.dynamicTest(name, executable(test)) 68 | 69 | private fun executable(test: () -> Any?): Executable = 70 | Executable { 71 | test.invoke() 72 | Unit 73 | } 74 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/minutest/TestFactory.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.minutest 2 | 3 | import dev.minutest.TestContextBuilder 4 | import dev.minutest.junit.toTestFactory 5 | import dev.minutest.rootContext 6 | import org.junit.jupiter.api.DynamicNode 7 | import java.util.stream.Stream 8 | 9 | @JvmName("minuTestFactoryForClass") 10 | inline fun minuTestFactory( 11 | name:String= "root", 12 | noinline builder: TestContextBuilder.() -> Unit 13 | ): Stream = rootContext(name,builder).toTestFactory() 14 | 15 | 16 | fun minuTestFactory(name:String= "root", builder: TestContextBuilder.() -> Unit): Stream = 17 | minuTestFactory(name, builder) 18 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/random/Randomize.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.random 2 | 3 | import java.math.BigDecimal 4 | import java.time.Duration 5 | import java.time.Instant 6 | import java.time.temporal.ChronoUnit 7 | import java.util.* 8 | 9 | fun randomBoolean(): Boolean = listOf(true, false).shuffled().first() 10 | fun ClosedRange.random() = 11 | Random().nextInt((endInclusive + 1) - start) + start 12 | 13 | fun ClosedRange.randomLong(): Long = random().toLong() 14 | fun ClosedRange.randomDurationOfSeconds(): Duration = Duration.ofSeconds(randomLong()) 15 | 16 | // see: http://www.baeldung.com/java-generate-random-long-float-integer-double 17 | fun ClosedRange.random(): Double { 18 | val leftLimit = start 19 | val rightLimit = endInclusive 20 | val generatedDouble = leftLimit + Random().nextDouble() * (rightLimit - leftLimit) 21 | return generatedDouble 22 | } 23 | 24 | fun ClosedRange.randomBigDecimal(): BigDecimal = random().toBigDecimal() 25 | 26 | inline fun > randomEnumValue(): T = (enumValues()).toList().shuffled().first() 27 | 28 | 29 | fun randomString(prefix: String = "", postfix: String = ""): String = "$prefix${UUID.randomUUID()}$postfix" 30 | 31 | fun ClosedRange.random(): Instant { 32 | val leftLimit = start.toEpochMilli() 33 | val rightLimit = endInclusive.toEpochMilli() 34 | val randomMillis = (leftLimit..rightLimit).random() 35 | return Instant.ofEpochMilli(randomMillis).truncatedTo(ChronoUnit.MILLIS) 36 | } 37 | 38 | inline fun > randomEnumSet(itemsCount: IntRange): Set { 39 | val out = mutableSetOf() 40 | val theCount: Int = itemsCount.random() 41 | if (theCount < 1) { 42 | return out.toSet() 43 | } 44 | (0..theCount).forEach { 45 | out += (enumValues()).toList().shuffled().first() 46 | } 47 | return out.toSet() 48 | } 49 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/resources/CodeSourceResources.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.resources 2 | 3 | import com.example.util.resources.loadResource 4 | import java.io.File 5 | import java.net.URI 6 | import java.net.URL 7 | import java.nio.charset.Charset 8 | 9 | object CodeSourceResources { 10 | /** 11 | * URI / URL ... 12 | * returns e.g.: file:/out/test/classes/ 13 | * NOTE: ends with "/" 14 | */ 15 | private fun locationURL(): URL = object {}.javaClass.protectionDomain.codeSource.location 16 | 17 | fun locationURI(): URI = locationURL().toURI() 18 | /** 19 | * File 20 | * returns e.g.: /out/test/classes/ 21 | * NOTE: ends with "/" 22 | */ 23 | fun fileLocationAsString(): String = locationURL().file 24 | 25 | fun replaceLocationSuffixes(location: String, oldSuffixes: List, newSuffix: String, oldSuffixRequired: Boolean): String { 26 | if ((oldSuffixRequired)) { 27 | val hasAnyKnownSuffix=oldSuffixes.any { location.endsWith(it) } 28 | if(!hasAnyKnownSuffix) { 29 | error( 30 | "Can not replace oldSuffice with newSuffix in location string!" + 31 | " reason: location must end with oneOf oldSuffixes !" + 32 | " oldSuffixes (expected): $oldSuffixes newSuffix: $newSuffix location: $location" 33 | ) 34 | } 35 | 36 | } 37 | oldSuffixes.forEach { oldSuffix-> 38 | if(location.endsWith(oldSuffix)) { 39 | return location 40 | .removeSuffix(oldSuffix) 41 | .let { "$it$newSuffix" } 42 | } 43 | } 44 | return location 45 | .let { "$it$newSuffix" } 46 | } 47 | 48 | fun writeTextFile(location: String, content: String, charset: Charset = Charsets.UTF_8): File = try { 49 | val file = File(location) 50 | file.writeText(content, charset) 51 | file 52 | } catch (all: Exception) { 53 | throw RuntimeException( 54 | "Failed to save text file! sink location: $location reason: ${all.message}", all 55 | ) 56 | } 57 | 58 | } 59 | 60 | data class CodeSourceResourceBucket( 61 | val qualifiedName: String, 62 | val codeSourceLocation: String 63 | ) { 64 | init { 65 | if (qualifiedName.isNotEmpty()) { 66 | if (!qualifiedName.startsWith("/")) { 67 | error("qualifiedName must start with '/' ! given: $qualifiedName") 68 | } 69 | if (qualifiedName.endsWith("/")) { 70 | error("qualifiedName must not end with '/' ! given: $qualifiedName") 71 | } 72 | } 73 | if (codeSourceLocation.endsWith("/")) { 74 | error("codeSourceLocation must not end with '/' ! given: $codeSourceLocation") 75 | } 76 | } 77 | 78 | val location: String = "$codeSourceLocation$qualifiedName" 79 | 80 | fun withQualifiedName(qualifiedName: (CodeSourceResourceBucket) -> String): CodeSourceResourceBucket = 81 | copy(qualifiedName = qualifiedName(this)) 82 | } 83 | 84 | fun CodeSourceResourceBucket.loadResourceText():String = loadResource(qualifiedName) 85 | 86 | -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/spring/SpringProfiles.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.spring 2 | 3 | object SpringProfiles { 4 | const val TEST: String = "test" 5 | } -------------------------------------------------------------------------------- /rest-api/src/test/kotlin/com/example/testutils/spring/SpringTestContexts.kt: -------------------------------------------------------------------------------- 1 | package com.example.testutils.spring 2 | 3 | import org.junit.jupiter.api.extension.ExtendWith 4 | import org.springframework.boot.autoconfigure.ImportAutoConfiguration 5 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureJdbc 6 | import org.springframework.boot.test.context.SpringBootTest 7 | import org.springframework.test.context.ActiveProfiles 8 | import org.springframework.test.context.TestPropertySource 9 | import org.springframework.test.context.junit.jupiter.SpringExtension 10 | import org.springframework.transaction.annotation.Transactional 11 | 12 | @ExtendWith(SpringExtension::class) 13 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 14 | @ActiveProfiles(SpringProfiles.TEST) 15 | @Transactional 16 | @TestPropertySource(properties = ["foo=foo1"]) 17 | //@AutoConfigureMockMvc // NEW !!! lets see 18 | @AutoConfigureJdbc // lets see 19 | @ImportAutoConfiguration 20 | //@EnableAspectJAutoProxy 21 | abstract class BootWebMockMvcTest 22 | 23 | 24 | @ExtendWith(SpringExtension::class) 25 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 26 | @ActiveProfiles(SpringProfiles.TEST) 27 | @Transactional 28 | @TestPropertySource(properties = ["foo=foo3"]) 29 | @ImportAutoConfiguration 30 | @AutoConfigureJdbc 31 | //@EnableAspectJAutoProxy 32 | abstract class BootWebRandomPortTest -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-001.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":10,"offset":0,"match":{"message-LIKE":"fox","comment-LIKE":"brown"},"filter":{"id-IN":null,"status-IN":["PUBLISHED","DRAFT"],"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":null,"version-LOE":null,"version-EQ":null},"orderBy":["createdAt-DESC","id-DESC"],"jq":null},"response":{"items":[{"id":"1e30242c-d0b8-4899-9434-ac10190792ad","createdAt":"2019-03-05T08:00:48.601Z","modifiedAt":"2019-03-05T08:00:48.601Z","deletedAt":"1970-01-01T00:00:00Z","version":6,"message":"message: lazy brown quick The fox","comment":"comment: quick fox the","status":"DRAFT"},{"id":"843e25d6-c2d3-4a74-8ad9-f621572f7f5f","createdAt":"2019-03-05T08:00:45.601Z","modifiedAt":"2019-03-05T08:00:45.601Z","deletedAt":"1970-01-01T00:00:00Z","version":5,"message":"message: The over fox the dog","comment":"comment: lazy over fox","status":"PUBLISHED"},{"id":"4383d6ff-a2c4-42ab-b4ec-125ffe384f75","createdAt":"2019-03-05T08:00:44.601Z","modifiedAt":"2019-03-05T08:00:44.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: The quick dog brown fox","comment":"comment: The lazy jumps","status":"DRAFT"},{"id":"e056df85-d556-4d13-b25a-59059492235e","createdAt":"2019-03-05T08:00:42.601Z","modifiedAt":"2019-03-05T08:00:42.601Z","deletedAt":"1970-01-01T00:00:00Z","version":6,"message":"message: jumps fox the quick dog","comment":"comment: quick fox over","status":"DRAFT"},{"id":"92624cf2-dfaa-45e4-aa48-a6691a6cf5f8","createdAt":"2019-03-05T08:00:40.601Z","modifiedAt":"2019-03-05T08:00:40.601Z","deletedAt":"1970-01-01T00:00:00Z","version":10,"message":"message: the over fox quick The","comment":"comment: lazy The brown","status":"PUBLISHED"},{"id":"da1721ba-f822-4348-b8a5-f8da2a5094f2","createdAt":"2019-03-05T08:00:39.601Z","modifiedAt":"2019-03-05T08:00:39.601Z","deletedAt":"1970-01-01T00:00:00Z","version":7,"message":"message: jumps lazy brown fox over","comment":"comment: the lazy brown","status":"DRAFT"},{"id":"3dce8dd1-11e0-4c53-bc3e-836da04d1c59","createdAt":"2019-03-05T08:00:37.601Z","modifiedAt":"2019-03-05T08:00:37.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: quick lazy fox jumps over","comment":"comment: dog jumps quick","status":"DRAFT"},{"id":"392acc9c-483e-4a0d-9af8-0c1bc3bad59a","createdAt":"2019-03-05T08:00:34.601Z","modifiedAt":"2019-03-05T08:00:34.601Z","deletedAt":"1970-01-01T00:00:00Z","version":8,"message":"message: jumps fox quick dog lazy","comment":"comment: lazy jumps brown","status":"DRAFT"},{"id":"4b5ae8c2-085b-4df2-953c-aecd6fadf79c","createdAt":"2019-03-05T08:00:30.601Z","modifiedAt":"2019-03-05T08:00:30.601Z","deletedAt":"1970-01-01T00:00:00Z","version":9,"message":"message: fox over brown jumps dog","comment":"comment: fox dog over","status":"PUBLISHED"},{"id":"f1ce075f-ca15-4e1b-95ef-85896ddfa202","createdAt":"2019-03-05T08:00:28.601Z","modifiedAt":"2019-03-05T08:00:28.601Z","deletedAt":"1970-01-01T00:00:00Z","version":2,"message":"message: jumps the brown fox dog","comment":"comment: the lazy jumps","status":"PUBLISHED"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-002.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":3,"offset":1,"match":{"message-LIKE":"fox","comment-LIKE":"brown"},"filter":{"id-IN":null,"status-IN":["PUBLISHED","DRAFT"],"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":null,"version-LOE":null,"version-EQ":null},"orderBy":["createdAt-DESC"],"jq":null},"response":{"items":[{"id":"843e25d6-c2d3-4a74-8ad9-f621572f7f5f","createdAt":"2019-03-05T08:00:45.601Z","modifiedAt":"2019-03-05T08:00:45.601Z","deletedAt":"1970-01-01T00:00:00Z","version":5,"message":"message: The over fox the dog","comment":"comment: lazy over fox","status":"PUBLISHED"},{"id":"4383d6ff-a2c4-42ab-b4ec-125ffe384f75","createdAt":"2019-03-05T08:00:44.601Z","modifiedAt":"2019-03-05T08:00:44.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: The quick dog brown fox","comment":"comment: The lazy jumps","status":"DRAFT"},{"id":"e056df85-d556-4d13-b25a-59059492235e","createdAt":"2019-03-05T08:00:42.601Z","modifiedAt":"2019-03-05T08:00:42.601Z","deletedAt":"1970-01-01T00:00:00Z","version":6,"message":"message: jumps fox the quick dog","comment":"comment: quick fox over","status":"DRAFT"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-003.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":3,"offset":0,"match":{"message-LIKE":"fox","comment-LIKE":"brown"},"filter":{"id-IN":[],"status-IN":["PENDING","DRAFT","PUBLISHED"],"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":3,"version-LOE":5,"version-EQ":null},"orderBy":["createdAt-DESC"],"jq":null},"response":{"items":[{"id":"843e25d6-c2d3-4a74-8ad9-f621572f7f5f","createdAt":"2019-03-05T08:00:45.601Z","modifiedAt":"2019-03-05T08:00:45.601Z","deletedAt":"1970-01-01T00:00:00Z","version":5,"message":"message: The over fox the dog","comment":"comment: lazy over fox","status":"PUBLISHED"},{"id":"4383d6ff-a2c4-42ab-b4ec-125ffe384f75","createdAt":"2019-03-05T08:00:44.601Z","modifiedAt":"2019-03-05T08:00:44.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: The quick dog brown fox","comment":"comment: The lazy jumps","status":"DRAFT"},{"id":"3dce8dd1-11e0-4c53-bc3e-836da04d1c59","createdAt":"2019-03-05T08:00:37.601Z","modifiedAt":"2019-03-05T08:00:37.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: quick lazy fox jumps over","comment":"comment: dog jumps quick","status":"DRAFT"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-004.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":3,"offset":0,"match":{"message-LIKE":"fox","comment-LIKE":"brown"},"filter":{"id-IN":["843e25d6-c2d3-4a74-8ad9-f621572f7f5f","3dce8dd1-11e0-4c53-bc3e-836da04d1c59"],"status-IN":["DRAFT","PENDING","PUBLISHED"],"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":3,"version-LOE":5,"version-EQ":null},"orderBy":["createdAt-DESC"],"jq":null},"response":{"items":[{"id":"843e25d6-c2d3-4a74-8ad9-f621572f7f5f","createdAt":"2019-03-05T08:00:45.601Z","modifiedAt":"2019-03-05T08:00:45.601Z","deletedAt":"1970-01-01T00:00:00Z","version":5,"message":"message: The over fox the dog","comment":"comment: lazy over fox","status":"PUBLISHED"},{"id":"3dce8dd1-11e0-4c53-bc3e-836da04d1c59","createdAt":"2019-03-05T08:00:37.601Z","modifiedAt":"2019-03-05T08:00:37.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: quick lazy fox jumps over","comment":"comment: dog jumps quick","status":"DRAFT"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-005.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":5,"offset":2,"match":{"message-LIKE":null,"comment-LIKE":null},"filter":{"id-IN":null,"status-IN":null,"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":null,"version-LOE":null,"version-EQ":7},"orderBy":["version-ASC","createdAt-DESC"],"jq":null},"response":{"items":[{"id":"278d78e8-a8f2-49e7-8005-a161e6ca1f76","createdAt":"2019-03-05T08:00:33.601Z","modifiedAt":"2019-03-05T08:00:33.601Z","deletedAt":"1970-01-01T00:00:00Z","version":7,"message":"message: dog The brown lazy jumps","comment":"comment: jumps over dog","status":"DRAFT"},{"id":"5d4352e8-a28f-469e-af6d-5ffa3281288b","createdAt":"2019-03-05T08:00:06.601Z","modifiedAt":"2019-03-05T08:00:06.601Z","deletedAt":"1970-01-01T00:00:00Z","version":7,"message":"message: fox jumps quick brown The","comment":"comment: fox dog The","status":"DRAFT"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-006.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":5,"offset":0,"match":{"message-LIKE":null,"comment-LIKE":null},"filter":{"id-IN":null,"status-IN":null,"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":"2019-03-05T08:23:10.735Z","modifiedAt-LOE":"2019-03-05T08:23:10.783Z","version-GTE":null,"version-LOE":null,"version-EQ":null},"orderBy":["version-ASC","modifiedAt-DESC"],"jq":null},"response":{"items":[]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-007.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":10,"offset":0,"match":{"message-LIKE":null,"comment-LIKE":null},"filter":{"id-IN":null,"status-IN":null,"createdAt-GTE":"2019-03-05T08:23:10.771Z","createdAt-LOE":"2019-03-05T08:23:10.778Z","modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":null,"version-LOE":null,"version-EQ":null},"orderBy":["version-ASC","modifiedAt-DESC"],"jq":null},"response":{"items":[]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-008.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":10,"offset":0,"match":{"message-LIKE":null,"comment-LIKE":null},"filter":{"id-IN":null,"status-IN":null,"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":null,"version-LOE":null,"version-EQ":null},"orderBy":[],"jq":null},"response":{"items":[{"id":"0bb6e557-a273-44af-b13f-916796e1eb62","createdAt":"2019-03-05T08:00:10.601Z","modifiedAt":"2019-03-05T08:00:10.601Z","deletedAt":"1970-01-01T00:00:00Z","version":10,"message":"message: the over brown dog fox","comment":"comment: lazy quick The","status":"PUBLISHED"},{"id":"135c1d19-d91d-46de-8ffa-b2ea16061125","createdAt":"2019-03-05T08:00:32.601Z","modifiedAt":"2019-03-05T08:00:32.601Z","deletedAt":"1970-01-01T00:00:00Z","version":10,"message":"message: dog over quick jumps The","comment":"comment: dog quick over","status":"PENDING"},{"id":"14ab245a-1e7b-4ed0-b1b0-403c472cbf62","createdAt":"2019-03-05T08:00:15.601Z","modifiedAt":"2019-03-05T08:00:15.601Z","deletedAt":"1970-01-01T00:00:00Z","version":6,"message":"message: jumps lazy quick brown fox","comment":"comment: fox over dog","status":"DRAFT"},{"id":"1dd91055-d373-438e-bd2a-dcaea0fdc38c","createdAt":"2019-03-05T08:00:19.601Z","modifiedAt":"2019-03-05T08:00:19.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: The quick over dog lazy","comment":"comment: jumps lazy The","status":"PUBLISHED"},{"id":"1e30242c-d0b8-4899-9434-ac10190792ad","createdAt":"2019-03-05T08:00:48.601Z","modifiedAt":"2019-03-05T08:00:48.601Z","deletedAt":"1970-01-01T00:00:00Z","version":6,"message":"message: lazy brown quick The fox","comment":"comment: quick fox the","status":"DRAFT"},{"id":"207503df-121f-440d-887f-cdee38a16ba7","createdAt":"2019-03-05T08:00:14.601Z","modifiedAt":"2019-03-05T08:00:14.601Z","deletedAt":"1970-01-01T00:00:00Z","version":6,"message":"message: the quick dog brown fox","comment":"comment: jumps lazy the","status":"PUBLISHED"},{"id":"20ec55f3-9a6a-4720-936a-56f9118b41a4","createdAt":"2019-03-05T08:00:03.601Z","modifiedAt":"2019-03-05T08:00:03.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: dog jumps quick the brown","comment":"comment: dog brown jumps","status":"PUBLISHED"},{"id":"22339b63-0d9c-4145-bffe-816a116f4ec7","createdAt":"2019-03-05T08:00:38.601Z","modifiedAt":"2019-03-05T08:00:38.601Z","deletedAt":"1970-01-01T00:00:00Z","version":1,"message":"message: dog brown the The jumps","comment":"comment: quick dog fox","status":"DRAFT"},{"id":"278d78e8-a8f2-49e7-8005-a161e6ca1f76","createdAt":"2019-03-05T08:00:33.601Z","modifiedAt":"2019-03-05T08:00:33.601Z","deletedAt":"1970-01-01T00:00:00Z","version":7,"message":"message: dog The brown lazy jumps","comment":"comment: jumps over dog","status":"DRAFT"},{"id":"30b0ad10-1084-4bc2-810c-3be5c101bd78","createdAt":"2019-03-05T08:00:12.601Z","modifiedAt":"2019-03-05T08:00:12.601Z","deletedAt":"1970-01-01T00:00:00Z","version":0,"message":"message: fox brown dog The the","comment":"comment: quick fox lazy","status":"PUBLISHED"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-009.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":10,"offset":0,"match":{"message-LIKE":null,"comment-LIKE":null},"filter":{"id-IN":["135c1d19-d91d-46de-8ffa-b2ea16061125","035c1d19-d91d-46de-8ffa-b2ea16061125","0bb6e557-a273-44af-b13f-916796e1eb62"],"status-IN":null,"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":null,"version-LOE":null,"version-EQ":null},"orderBy":[],"jq":null},"response":{"items":[{"id":"0bb6e557-a273-44af-b13f-916796e1eb62","createdAt":"2019-03-05T08:00:10.601Z","modifiedAt":"2019-03-05T08:00:10.601Z","deletedAt":"1970-01-01T00:00:00Z","version":10,"message":"message: the over brown dog fox","comment":"comment: lazy quick The","status":"PUBLISHED"},{"id":"135c1d19-d91d-46de-8ffa-b2ea16061125","createdAt":"2019-03-05T08:00:32.601Z","modifiedAt":"2019-03-05T08:00:32.601Z","deletedAt":"1970-01-01T00:00:00Z","version":10,"message":"message: dog over quick jumps The","comment":"comment: dog quick over","status":"PENDING"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/golden-test-data/tests/api/tweeter/search/testcase-010.json: -------------------------------------------------------------------------------- 1 | {"request":{"limit":10,"offset":0,"match":{"message-LIKE":null,"comment-LIKE":null},"filter":{"id-IN":null,"status-IN":["PENDING","PUBLISHED"],"createdAt-GTE":null,"createdAt-LOE":null,"modifiedAt-GTE":null,"modifiedAt-LOE":null,"version-GTE":null,"version-LOE":null,"version-EQ":null},"orderBy":[],"jq":null},"response":{"items":[{"id":"0bb6e557-a273-44af-b13f-916796e1eb62","createdAt":"2019-03-05T08:00:10.601Z","modifiedAt":"2019-03-05T08:00:10.601Z","deletedAt":"1970-01-01T00:00:00Z","version":10,"message":"message: the over brown dog fox","comment":"comment: lazy quick The","status":"PUBLISHED"},{"id":"135c1d19-d91d-46de-8ffa-b2ea16061125","createdAt":"2019-03-05T08:00:32.601Z","modifiedAt":"2019-03-05T08:00:32.601Z","deletedAt":"1970-01-01T00:00:00Z","version":10,"message":"message: dog over quick jumps The","comment":"comment: dog quick over","status":"PENDING"},{"id":"1dd91055-d373-438e-bd2a-dcaea0fdc38c","createdAt":"2019-03-05T08:00:19.601Z","modifiedAt":"2019-03-05T08:00:19.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: The quick over dog lazy","comment":"comment: jumps lazy The","status":"PUBLISHED"},{"id":"207503df-121f-440d-887f-cdee38a16ba7","createdAt":"2019-03-05T08:00:14.601Z","modifiedAt":"2019-03-05T08:00:14.601Z","deletedAt":"1970-01-01T00:00:00Z","version":6,"message":"message: the quick dog brown fox","comment":"comment: jumps lazy the","status":"PUBLISHED"},{"id":"20ec55f3-9a6a-4720-936a-56f9118b41a4","createdAt":"2019-03-05T08:00:03.601Z","modifiedAt":"2019-03-05T08:00:03.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: dog jumps quick the brown","comment":"comment: dog brown jumps","status":"PUBLISHED"},{"id":"30b0ad10-1084-4bc2-810c-3be5c101bd78","createdAt":"2019-03-05T08:00:12.601Z","modifiedAt":"2019-03-05T08:00:12.601Z","deletedAt":"1970-01-01T00:00:00Z","version":0,"message":"message: fox brown dog The the","comment":"comment: quick fox lazy","status":"PUBLISHED"},{"id":"3248df64-681f-464e-be14-dcee46210206","createdAt":"2019-03-05T08:00:00.601Z","modifiedAt":"2019-03-05T08:00:00.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: over quick brown dog lazy","comment":"comment: dog fox over","status":"PENDING"},{"id":"33bd1fc3-15cf-43b9-bb9f-05b0f521fe84","createdAt":"2019-03-05T08:00:31.601Z","modifiedAt":"2019-03-05T08:00:31.601Z","deletedAt":"1970-01-01T00:00:00Z","version":3,"message":"message: over brown The lazy fox","comment":"comment: quick The jumps","status":"PENDING"},{"id":"34fe73e2-98e0-43e2-b6c5-45fd95547219","createdAt":"2019-03-05T08:00:23.601Z","modifiedAt":"2019-03-05T08:00:23.601Z","deletedAt":"1970-01-01T00:00:00Z","version":4,"message":"message: dog brown The over quick","comment":"comment: quick over brown","status":"PENDING"},{"id":"49822b48-dd71-47b0-b4c4-54106c1b7221","createdAt":"2019-03-05T08:00:17.601Z","modifiedAt":"2019-03-05T08:00:17.601Z","deletedAt":"1970-01-01T00:00:00Z","version":2,"message":"message: brown dog over The lazy","comment":"comment: fox The the","status":"PENDING"}]}} 2 | -------------------------------------------------------------------------------- /rest-api/src/test/resources/junit-platform.properties: -------------------------------------------------------------------------------- 1 | # see: https://spring.io/guides/tutorials/spring-boot-kotlin/ 2 | # why? @BeforeAll and @AfterAll 3 | junit.jupiter.testinstance.lifecycle.default = per_class -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "spring-kotlin-exposed" 2 | include("rest-api") 3 | 4 | pluginManagement { 5 | // see: https://github.com/ilya40umov/KotLink/blob/master/settings.gradle.kts 6 | 7 | val kotlinVersion = "1.4.10" 8 | val springBootVersion = "2.3.5.RELEASE" 9 | 10 | plugins { 11 | kotlin("jvm") version kotlinVersion 12 | id("tanvd.kosogor") version "1.0.7" 13 | id("io.gitlab.arturbosch.detekt") version "1.14.2" 14 | id("org.owasp.dependencycheck") version "6.0.3" 15 | id("com.avast.gradle.docker-compose") version "0.13.4" 16 | id("com.github.ben-manes.versions") version "0.34.0" 17 | id("org.jetbrains.dokka") version "1.4.10.2" 18 | // spring 19 | id("io.spring.dependency-management") version "1.0.10.RELEASE" 20 | id("org.springframework.boot") version springBootVersion 21 | 22 | // spring-kotlin 23 | // kotlin: spring (proxy) related plugins see: https://kotlinlang.org/docs/reference/compiler-plugins.html 24 | id("org.jetbrains.kotlin.plugin.spring") version kotlinVersion 25 | id("org.jetbrains.kotlin.plugin.noarg") version kotlinVersion 26 | id("org.jetbrains.kotlin.plugin.allopen") version kotlinVersion 27 | 28 | // ak-artifactory 29 | //id("com.jfrog.artifactory") version "4.9.6" 30 | } 31 | 32 | 33 | resolutionStrategy { 34 | eachPlugin { 35 | if(requested.id.id.startsWith("org.jetbrains.kotlin")) { 36 | useVersion(kotlinVersion) 37 | } 38 | if (requested.id.id == "org.springframework.boot") { 39 | useVersion(springBootVersion) 40 | } 41 | } 42 | } 43 | } 44 | --------------------------------------------------------------------------------