First commit

This commit is contained in:
Jaroslav Držík
2025-01-05 07:50:23 +01:00
commit 12e0eb37a8
52 changed files with 1544 additions and 0 deletions

15
.fleet/run.json Normal file
View File

@@ -0,0 +1,15 @@
{
"configurations": [
{
"name": "test [run] (1)",
"type": "gradle",
"workingDir": "$PROJECT_DIR$",
"tasks": ["run"],
"args": [""],
"initScripts": {
"flmapper": "ext.mapPath = { path -> path }"
}
}
]
}

9
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,9 @@
version: 2
updates:
# Enable version updates for Gradle
- package-ecosystem: "gradle"
# Look for `build.gradle` in the `root` directory
directory: "/"
# Check for updates once daily
schedule:
interval: "daily"

35
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
# This workflow will build a Java project with Gradle
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle
name: Build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '21' ]
steps:
- uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1
- name: Set up JDK ${{ matrix.java }}
uses: actions/setup-java@v3
with:
java-version: ${{ matrix.java }}
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew build
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
files: build/reports/kover/report.xml

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
.DS_Store
.idea/shelf
/android.tests.dependencies
/confluence/target
/dependencies
/dist
/local
/gh-pages
/ideaSDK
/clionSDK
/android-studio/sdk
out/
/tmp
workspace.xml
*.versionsBackup
/idea/testData/debugger/tinyApp/classes*
/jps-plugin/testData/kannotator
/ultimate/dependencies
/ultimate/ideaSDK
/ultimate/out
/ultimate/tmp
/js/js.translator/testData/out/
/js/js.translator/testData/out-min/
.gradle/
build/
!**/src/**/build
!**/test/**/build
*.iml
!**/testData/**/*.iml
*.iml
/local.properties
/.idea
.DS_Store
/build
/captures

View File

@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

View File

@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM gradle:8-jdk21 AS build
USER gradle
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY src/ ./src
RUN gradle installDist --no-daemon
FROM eclipse-temurin:21-jre-jammy
EXPOSE 8080
WORKDIR /app
COPY --from=build /app/build/install/kotlin-ktor-exposed-starter .
ENTRYPOINT ["./bin/kotlin-ktor-exposed-starter"]

101
README.md Normal file
View File

@@ -0,0 +1,101 @@
[![Kotlin](https://img.shields.io/badge/kotlin-2.0.21-blue.svg?logo=kotlin)](http://kotlinlang.org)
[![Ktor](https://img.shields.io/badge/ktor-3.0.0-blue.svg)](https://github.com/ktorio/ktor)
[![Build](https://github.com/raharrison/kotlin-ktor-exposed-starter/workflows/Build/badge.svg)](https://github.com/raharrison/kotlin-ktor-exposed-starter/actions/workflows/build.yml)
[![codecov](https://codecov.io/gh/raharrison/kotlin-ktor-exposed-starter/branch/master/graph/badge.svg?token=v2k9oObm0C)](https://codecov.io/gh/raharrison/kotlin-ktor-exposed-starter)
## Starter project to create a simple RESTful web service in Kotlin
**Updated for Kotlin 2.0.21 and Ktor 3.0.0**
Companion article: <https://ryanharrison.co.uk/2018/04/14/kotlin-ktor-exposed-starter.html>
## Getting Started
1. Clone the repo.
2. In the root directory execute `./gradlew run`
3. By default, the server will start on port `8080`. See below [Routes](#routes) section for more information.
### Libraries used:
- [Ktor](https://github.com/ktorio/ktor) - Kotlin async web framework
- [Netty](https://github.com/netty/netty) - Async web server
- [Kotlin Serialization](https://github.com/Kotlin/kotlinx.serialization) - JSON serialization/deserialization
- [Exposed](https://github.com/JetBrains/Exposed) - Kotlin SQL framework
- [H2](https://github.com/h2database/h2database) - Embeddable database
- [HikariCP](https://github.com/brettwooldridge/HikariCP) - High performance JDBC connection pooling
- [Flyway](https://flywaydb.org/) - Database migrations
- [JUnit 5](https://junit.org/junit5/), [AssertJ](http://joel-costigliola.github.io/assertj/)
and [Rest Assured](http://rest-assured.io/) for testing
- [Kover](https://github.com/Kotlin/kotlinx-kover) for code coverage, publishing
to [Codecov](https://about.codecov.io/) through GitHub Actions
The starter project creates a new in-memory H2 database with one table for `Widget` instances.
As ktor is async and based on coroutines, standard blocking JDBC may cause performance issues when used
directly on the main thread pool (as threads must be reused for other requests). Therefore, another dedicated thread
pool is created for all database queries, alongside connection pooling with HikariCP.
### Routes:
`GET /widgets` --> get all widgets in the database
`GET /widgets/{id}` --> get one widget instance by id (integer)
`POST /widgets` --> add a new widget to the database by providing a JSON object (converted to a NewWidget instance). e.g -
```json
{
"name": "new widget",
"quantity": 64
}
```
returns
```json
{
"id": 3,
"name": "new widget",
"quantity": 64,
"dateUpdated": 1519926898
}
```
`PUT /widgets` --> update an existing widgets name or quantity. Pass in the id in the JSON request to determine which record to update
`DELETE /widgets/{id}` --> delete the widget with the specified id
### Notifications (WebSocket)
All updates (creates, updates and deletes) to `Widget` instances are served as notifications through a WebSocket endpoint:
`WS /updates` --> returns `Notification` instances containing the change type, id and entity (if applicable) e.g:
```json
{
"type": "CREATE",
"id": 12,
"entity": {
"id": 12,
"name": "widget1",
"quantity": 5,
"dateUpdated": 1533583858169
}
}
```
The websocket listener will also log out any text messages send by the client. Refer to [this blog post](https://ryanharrison.co.uk/2018/08/19/testing-websockets.html) for some useful tools to test the websocket behaviour.
### Testing
The sample Widget service and corresponding endpoints are also tested with 100% coverage. Upon startup of the main JUnit suite (via the `test` source folder), the server is started ready for testing and is torn down after all tests are run.
- Unit testing of services with AssertJ - DAO and business logic is tested by initialising an in-memory H2 database with
Exposed, using the same schema as the main app. With this approach database queries are fully tested without any
mocking.
- Integration testing of endpoints using a fully running server with Rest Assured - routing tests/status codes/response
structure. This utilises the fact that Ktor is a small microframework that can be easily spun up and down as part of
the test suite. You could also use the special test engine that [Ktor provides](https://ktor.io/docs/testing.html),
however my preference is to always start a full version of the server so that HTTP behaviour can be tested without
relying on special internal mechanisms.
- Code coverage and reporting performed automatically by Kover as part of the Gradle build

59
build.gradle.kts Normal file
View File

@@ -0,0 +1,59 @@
val ktorVersion = "3.0.0"
val exposedVersion = "0.55.0"
val h2Version = "2.3.232"
val hikariCpVersion = "5.1.0"
val flywayVersion = "10.20.1"
val logbackVersion = "1.5.12"
val assertjVersion = "3.26.3"
val restAssuredVersion = "5.5.0"
val junitVersion = "5.11.3"
val psqlVersion = "42.7.4"
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.serialization") version "2.0.21"
id("org.jetbrains.kotlinx.kover") version "0.8.2"
application
}
repositories {
mavenCentral()
}
dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-serialization:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("io.ktor:ktor-server-call-logging:$ktorVersion")
implementation("io.ktor:ktor-server-default-headers:$ktorVersion")
implementation("io.ktor:ktor-server-websockets:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.7.20")
implementation("com.github.seratch:kotliquery:1.9.0")
implementation("com.h2database:h2:$h2Version")
implementation("org.postgresql:postgresql:$psqlVersion")
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-json:$exposedVersion")
implementation("com.zaxxer:HikariCP:$hikariCpVersion")
implementation("org.flywaydb:flyway-core:$flywayVersion")
implementation("ch.qos.logback:logback-classic:$logbackVersion")
implementation("de.m3y.kformat:kformat:0.11")
testImplementation("org.assertj:assertj-core:$assertjVersion")
testImplementation("io.rest-assured:rest-assured:$restAssuredVersion")
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
testImplementation("io.ktor:ktor-client-cio:$ktorVersion")
}
application {
mainClass.set("MainKt")
}
tasks.withType<Test> {
useJUnitPlatform()
}

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
gradlew vendored Executable file
View File

@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright <20> 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions <20>$var<61>, <20>${var}<7D>, <20>${var:-default}<7D>, <20>${var+SET}<7D>,
# <20>${var#prefix}<7D>, <20>${var%suffix}<7D>, and <20>$( cmd )<29>;
# * compound commands having a testable exit status, especially <20>case<73>;
# * various built-in commands including <20>command<6E>, <20>set<65>, and <20>ulimit<69>.
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx1024m" "-Xms1024m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'kotlin-ktor-exposed-starter'

40
src/main/kotlin/Main.kt Normal file
View File

@@ -0,0 +1,40 @@
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import service.DatabaseFactory
//import service.WidgetService
import util.JsonMapper
import web.index
//import web.widget
fun Application.module() {
install(DefaultHeaders)
install(CallLogging)
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(JsonMapper.defaultMapper)
}
install(ContentNegotiation) {
json(JsonMapper.defaultMapper)
}
DatabaseFactory.connectAndMigrate()
//val widgetService = WidgetService()
routing {
index()
// widget(widgetService)
}
}
fun main(args: Array<String>) {
EngineMain.main(args)
}

View File

@@ -0,0 +1,12 @@
package dao
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.EntityID
import tables.DictTypes
class DictTypeDao(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<DictTypeDao>(DictTypes)
var shortName by DictTypes.shortName
var fullName by DictTypes.fullName
}

View File

@@ -0,0 +1,12 @@
package dao
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.EntityID
import tables.Dictionaries
class DictionaryDao(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<DictionaryDao>(Dictionaries)
var name by Dictionaries.name
var fullName by Dictionaries.fullName
}

View File

@@ -0,0 +1,12 @@
package dao
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.EntityID
import tables.Languages
class LanguageDao(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<LanguageDao>(Languages)
var name by Languages.name
var shortName by Languages.shortName
}

View File

@@ -0,0 +1,13 @@
package dao
import models.Translation
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.EntityID
import tables.Pronunciations
class PronunciationDao(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<PronunciationDao>(Pronunciations)
var ipa by Pronunciations.ipa
var filename by Pronunciations.filename
}

View File

@@ -0,0 +1,12 @@
package dao
import models.Translation
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.EntityID
import tables.PronunciationTypes
class PronunciationTypeDao(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<PronunciationTypeDao>(PronunciationTypes)
var name by PronunciationTypes.name
}

View File

@@ -0,0 +1,11 @@
package dao
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.EntityID
import tables.Suffixes
class SuffixDao(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SuffixDao>(Suffixes)
var text by Suffixes.text
}

View File

@@ -0,0 +1,14 @@
package dao
import models.Translation
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.EntityID
import tables.Translations
class TranslationDao(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TranslationDao>(Translations)
var langName1 by Translations.langName1
var langName2 by Translations.langName2
var direction by Translations.direction
}

View File

@@ -0,0 +1,193 @@
package db.migration
import org.flywaydb.core.api.migration.BaseJavaMigration
import org.flywaydb.core.api.migration.Context
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import kotlin.collections.*
import kotliquery.*
import de.m3y.kformat.*
import de.m3y.kformat.Table.Hints
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.Json
val format = Json { encodeDefaults = true }
data class Dictionary(
val id: Int,
val lang1Id: Int,
val lang2Id: Int,
val name: String,
val fullName: String)
data class DictType(
val id: Int,
val shortName: String,
val fullName: String
)
data class Language(
val id: Int,
val name: String,
val shortName: String
)
data class Translation(
val id: Int,
val dictionaryId: Int,
val lang1Id: Int,
val lang2Id: Int,
val lang1Name: String,
val lang2Name: String,
val direction: Int
)
data class Pronunciation(
val id: Int,
val typeId: Int,
val ipa: String?,
val filename: String?
)
data class PronunciationType(
val id: Int,
val name: String?
)
data class TermsPronunciation(
val termId: Int,
val pronunciationId: Int,
)
data class Suffix(
val id: Int,
val text: String
)
data class Term(
val id: Int,
val dictionaryId: Int,
val string1: String,
val string2: String,
val suffix1Id: Int?,
val suffix2Id: Int?,
val typeId: Int?,
val member: String?,
val order2: Int?,
val flags: String?
)
val toDictionary: (Row) -> Dictionary = { row ->
Dictionary(
row.int("id"),
row.int("lang1_id"),
row.int("lang2_id"),
row.string("name"),
row.string("full_name")
)
}
val toDictType: (Row) -> DictType = { row ->
DictType(
row.int("id"),
row.string("short_name"),
row.string("full_name")
)
}
val toLanguage: (Row) -> Language = { row ->
Language(
row.int("id"),
row.string("name"),
row.string("short_name")
)
}
val toTranslation: (Row) -> Translation = { row ->
Translation(
row.int("id"),
row.int("dictionary_id"),
row.int("lang1_id"),
row.int("lang2_id"),
row.string("lang_name1"),
row.string("lang_name2"),
row.int("direction"),
)
}
val toPronunciation: (Row) -> Pronunciation = { row ->
Pronunciation(
row.int("id"),
row.int("type_id"),
row.stringOrNull("ipa"),
row.stringOrNull("filename")
)
}
val toTermsPronunciation: (Row) -> TermsPronunciation = { row ->
TermsPronunciation(
row.int("term_id"),
row.int("pronunciation_id"),
)
}
val toSuffix: (Row) -> Suffix = { row ->
Suffix(
row.int("id"),
row.string("text")
)
}
val toTerm: (Row) -> Term = { row ->
Term(
row.int("id"),
row.int("dictionary_id"),
row.string("string1"),
row.string("string2"),
row.intOrNull("suffix1_id"),
row.intOrNull("suffix2_id"),
row.intOrNull("type_id"),
row.stringOrNull("member"),
row.intOrNull("order2"),
row.stringOrNull("flags")
)
}
object SharedData {
var hashDict: Map<Int,Dictionary> = mapOf()
var hashDictType: Map<Int,DictType> = mapOf()
var hashLang: Map<Int,Language> = mapOf()
var hashTransMap: Map<Int,Translation> = mapOf()
var hashPron: Map<Int,Pronunciation> = mapOf()
var hashSuffix: Map<Int,Suffix> = mapOf()
var hashTermPron : Map<Int,TermsPronunciation> = mapOf()
var listTerm : List<Term> = listOf()
var getListofTerms : (Int) -> List<Term> = {
listOf()
}
}
class V1__test_connection: BaseJavaMigration() {
override fun migrate(context: Context?) {
val session = sessionOf("jdbc:postgresql://nuc.lan:5432/dict", "dict_user", "PW4dbdict")
val allNameQuery = queryOf("select * from dictionary").map(toDictionary).asList
val allDict = session.run(allNameQuery)
SharedData.hashDict = allDict.associateBy { it.id }
val allDictTypeQuery = queryOf("select * from dict_type").map(toDictType).asList
val allDictType = session.run(allDictTypeQuery)
SharedData.hashDictType = allDictType.associateBy { it.id }
val allTranslationQuery = queryOf("select * from translation").map(toTranslation).asList
val allTranslation = session.run(allTranslationQuery)
SharedData.hashTransMap = allTranslation.associateBy { it.id }
val allLangQuery = queryOf("select * from language").map(toLanguage).asList
val allLanguages = session.run(allLangQuery)
SharedData.hashLang = allLanguages.associateBy { it.id }
val allSuffixQuery = queryOf("select * from suffix").map(toSuffix).asList
val allSuffix = session.run(allSuffixQuery)
SharedData.hashSuffix = allSuffix.associateBy { it.id }
val allPronunciationQuery = queryOf("select * from pronunciation").map(toPronunciation).asList
val allPronunciation = session.run(allPronunciationQuery)
SharedData.hashPron = allPronunciation.associateBy { it.id }
val allTermsPronunciationQuery = queryOf("select * from terms_pronunciations").map(toTermsPronunciation).asList
val allTermsPronunciation = session.run(allTermsPronunciationQuery)
SharedData.hashTermPron = allTermsPronunciation.associateBy { it.termId }
SharedData.getListofTerms = {
session.run(queryOf("select * from term WHERE dictionary_id=${it}").map(toTerm).asList)
}
}
}

View File

@@ -0,0 +1,53 @@
package db.migration
import dao.DictionaryDao
import kotlinx.serialization.decodeFromString
import tables.*
import org.flywaydb.core.api.migration.BaseJavaMigration
import org.flywaydb.core.api.migration.Context
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.upsert
class V2__create_settingsdb: BaseJavaMigration() {
override fun migrate(context: Context?) {
transaction {
SchemaUtils.create(Dictionaries, Languages, Translations, Settings)
for ((di, lang) in SharedData.hashLang) {
Languages.insert {
it[id] = lang.id
it[name] = lang.name
it[shortName] = lang.shortName
}
}
for ((di, dict) in SharedData.hashDict) {
Dictionaries.insert {
it[id] = dict.id
it[lang1] = dict.lang1Id
it[lang2] = dict.lang2Id
it[name] = dict.name
it[fullName] = dict.fullName
}
}
for ((di, trans) in SharedData.hashTransMap) {
Translations.insert {
it[id] = trans.id
it[lang1Id] = trans.lang1Id
it[lang2Id] = trans.lang2Id
it[dictionaryId] = trans.dictionaryId
it[langName1] = trans.lang1Name
it[langName2] = trans.lang2Name
it[direction] = trans.direction
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
package db.migration
import dao.DictionaryDao
import kotlinx.serialization.decodeFromString
import tables.*
import org.flywaydb.core.api.migration.BaseJavaMigration
import org.flywaydb.core.api.migration.Context
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.upsert
import kotlin.system.exitProcess
class V3__create_dictionaries: BaseJavaMigration() {
override fun migrate(context: Context?) {
println(SharedData.hashDict)
//exitProcess(0)
for ((db_id,dict) in SharedData.hashDict) {
val db_name = "jdbc:h2:file:/Users/jaro/data/${SharedData.hashLang[dict.lang1Id]?.shortName}-${SharedData.hashLang[dict.lang2Id]?.shortName}"
println(db_name)
val db = Database.connect(db_name)
transaction (db) {
SchemaUtils.create(Dictionaries, Languages, Translations, Pronunciations, PronunciationTypes, DictTypes, Suffixes, Terms)
for ((di, lang) in SharedData.hashLang) {
Languages.insert {
it[id] = lang.id
it[name] = lang.name
it[shortName] = lang.shortName
}
}
for ((di, d) in SharedData.hashDict) {
Dictionaries.insert {
it[id] = d.id
it[lang1] = d.lang1Id
it[lang2] = d.lang2Id
it[name] = d.name
it[fullName] = d.fullName
}
}
for ((di, trans) in SharedData.hashTransMap) {
Translations.insert {
it[id] = trans.id
it[lang1Id] = trans.lang1Id
it[lang2Id] = trans.lang2Id
it[dictionaryId] = trans.dictionaryId
it[langName1] = trans.lang1Name
it[langName2] = trans.lang2Name
it[direction] = trans.direction
}
}
PronunciationTypes.insert {
it[id] = 1
it[name] = "English"
}
PronunciationTypes.insert {
it[id] = 2
it[name] = "American english"
}
PronunciationTypes.insert {
it[id] = 3
it[name] = "Business english"
}
for ((di, pron) in SharedData.hashPron) {
Pronunciations.insert {
it[id] = pron.id
it[typeId] = pron.typeId
it[ipa] = pron.ipa
it[filename] = pron.filename
}
}
for ((di, dt) in SharedData.hashDictType) {
DictTypes.insert {
it[id] = dt.id
it[shortName] = dt.shortName
it[fullName] = dt.fullName
}
}
for ((di, ss) in SharedData.hashSuffix) {
Suffixes.insert {
it[id] = ss.id
it[text] = ss.text
}
}
var dictTerms = SharedData.getListofTerms(db_id)
for (t in dictTerms) {
Terms.insert {
it[id] = t.id
it[string1] = t.string1
it[string2] = t.string2
it[suffix1] = t.suffix1Id
it[suffix2] = t.suffix2Id
it[type] = t.typeId
it[member] = t.member
it[order2] = t.order2
it[flags] = format.decodeFromString<IntArray?>(t.flags?.toString() ?:"[]")
}
}
dictTerms = listOf()
}
}
}
}

View File

@@ -0,0 +1,14 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class DictType(
val id: Int,
val shortName: String,
val fullName: String
)

View File

@@ -0,0 +1,17 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class Dictionary(
val id: Int,
val lang1Id: Int,
val lang2Id: Int,
val name: String?,
val fullName: String?,
val translations: List<Translation> = emptyList()
)

View File

@@ -0,0 +1,15 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class Language(
val id: Int,
val name: String,
val shortName: String
)

View File

@@ -0,0 +1,12 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class Pronunciation(
val id: Int,
val typeId: Int,
val ipa: String?,
val filename: String?
)

View File

@@ -0,0 +1,10 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class PronunciationType(
val id: Int,
val name: String?
)

View File

@@ -0,0 +1,12 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class Settings(
val id: Int,
val dictionary: Int?,
val lastSearch: String?,
)

View File

@@ -0,0 +1,11 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class Suffix(
val id: Int,
val text: String
)

View File

@@ -0,0 +1,20 @@
package models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import org.jetbrains.exposed.sql.Table
@Serializable
data class Term(
val id: Int,
val dictionaryId: Int,
val string1: String,
val string2: String,
val typeId: Int,
val suffix1Id: Int,
val suffix2Id: Int,
val member: String?,
val order2: Int,
val flags: JsonObject?
)

View File

@@ -0,0 +1,17 @@
package models
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.Table
@Serializable
data class Translation(
val id: Int,
val dictionaryId: Int,
val lang1Id: Int,
val lang2Id: Int,
val lang1Name: String?,
val lang2Name: String?,
val direction: Int
)

View File

@@ -0,0 +1,54 @@
package service
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.flywaydb.core.Flyway
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import javax.sql.DataSource
object DatabaseFactory {
private val log = LoggerFactory.getLogger(this::class.java)
fun connectAndMigrate() {
log.info("Initialising database")
val pool = hikari()
Database.connect(pool)
runFlyway(pool)
}
private fun hikari(): HikariDataSource {
val config = HikariConfig().apply {
driverClassName = "org.h2.Driver"
jdbcUrl = "jdbc:h2:file:/Users/jaro/data/dict_settings"
maximumPoolSize = 3
isAutoCommit = false
transactionIsolation = "TRANSACTION_REPEATABLE_READ"
validate()
}
return HikariDataSource(config)
}
private fun runFlyway(datasource: DataSource) {
val flyway = Flyway.configure().dataSource(datasource).load()
try {
flyway.info()
flyway.migrate()
} catch (e: Exception) {
log.error("Exception running flyway migration", e)
throw e
}
log.info("Flyway migration has finished")
}
suspend fun <T> dbExec(
block: () -> T
): T = withContext(Dispatchers.IO) {
transaction { block() }
}
}

View File

@@ -0,0 +1,8 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object DictTypes : IntIdTable() {
val shortName = varchar("short_name", 255)
val fullName = varchar("full_name", 255)
}

View File

@@ -0,0 +1,10 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object Dictionaries : IntIdTable() {
val lang1 = reference("lang1_id", Languages)
val lang2 = reference("lang2_id", Languages)
val name = varchar("name", 255)
val fullName = varchar("full_name", 255)
}

View File

@@ -0,0 +1,8 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object Languages : IntIdTable() {
val name = varchar("name", 255)
val shortName = varchar("short_name", 255)
}

View File

@@ -0,0 +1,10 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object Pronunciations : IntIdTable() {
val typeId = reference("type_id",PronunciationTypes)
val ipa = varchar("ipa", 255).nullable()
val filename = varchar("filename", 255).nullable()
}

View File

@@ -0,0 +1,7 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object PronunciationTypes : IntIdTable() {
val name = varchar("name", 255)
}

View File

@@ -0,0 +1,8 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object Settings : IntIdTable() {
val dictionary = reference("dictionary_id",Dictionaries)
val lastSearch = varchar("last_search", 255).nullable()
}

View File

@@ -0,0 +1,7 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object Suffixes : IntIdTable() {
val text = varchar("text", 255)
}

View File

@@ -0,0 +1,17 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.json.json
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import tables.Suffixes
object Terms : IntIdTable() {
val string1 = varchar("string1", 255)
val string2 = varchar("string2", 255)
val suffix1 = reference("suffix1_id",Suffixes).nullable()
val suffix2 = reference("suffix2_id",Suffixes).nullable()
val type = reference("type_id",DictTypes).nullable()
val member = varchar("member", 255).nullable()
val order2 = integer("order2").nullable()
val flags = json<IntArray>("flags", Json.Default).nullable()
}

View File

@@ -0,0 +1,13 @@
package tables
import org.jetbrains.exposed.dao.id.IntIdTable
object Translations : IntIdTable() {
val dictionaryId = reference("dictionary_id",Dictionaries)
val lang1Id = reference("lang1_id",Languages)
val lang2Id = reference("lang2_id",Languages)
val langName1 = varchar("lang_name1", 255)
val langName2 = varchar("lang_name2", 255)
val direction = integer("direction")
}

View File

@@ -0,0 +1,11 @@
package util
import kotlinx.serialization.json.Json
object JsonMapper {
val defaultMapper = Json {
prettyPrint = true
}
}

View File

@@ -0,0 +1,14 @@
package web
import io.ktor.http.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.index() {
val indexPage = javaClass.getResource("/index.html").readText()
get("/") {
call.respondText(indexPage, ContentType.Text.Html)
}
}

View File

@@ -0,0 +1,64 @@
package web
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import model.NewWidget
import service.WidgetService
fun Route.widget(widgetService: WidgetService) {
route("/widgets") {
get {
call.respond(widgetService.getAllWidgets())
}
get("/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalStateException("Must provide id")
val widget = widgetService.getWidget(id)
if (widget == null) call.respond(HttpStatusCode.NotFound)
else call.respond(widget)
}
post {
val widget = call.receive<NewWidget>()
call.respond(HttpStatusCode.Created, widgetService.addWidget(widget))
}
put {
val widget = call.receive<NewWidget>()
val updated = widgetService.updateWidget(widget)
if (updated == null) call.respond(HttpStatusCode.NotFound)
else call.respond(HttpStatusCode.OK, updated)
}
delete("/{id}") {
val id = call.parameters["id"]?.toInt() ?: throw IllegalStateException("Must provide id")
val removed = widgetService.deleteWidget(id)
if (removed) call.respond(HttpStatusCode.OK)
else call.respond(HttpStatusCode.NotFound)
}
}
webSocket("/updates") {
try {
widgetService.addChangeListener(this.hashCode()) {
sendSerialized(it)
}
for (frame in incoming) {
if (frame.frameType == FrameType.CLOSE) {
break
} else if (frame is Frame.Text) {
call.application.environment.log.info("Received websocket message: {}", frame.readText())
}
}
} finally {
widgetService.removeChangeListener(this.hashCode())
}
}
}

View File

@@ -0,0 +1,10 @@
ktor {
deployment {
port = 8080
watch = [ build ]
}
application {
modules = [ MainKt.module ]
}
}

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Kotlin/Ktor/Exposed Starter - It's Working!</title>
<meta name="author" content="Ryan Harrison">
</head>
<body>
<h1 id="it-s-working-">It&#39;s Working!</h1>
<p>This starter project creates a new in-memory H2 database with one table for <code>Widget</code> instances. A simple RESTful interface is provided
to perform CRUD operations on <code>Widgets</code> alongside a websocket to be notified in real-time of any changes.</p>
<h2 id="routes-">Routes:</h2>
<p><code>GET /widgets</code> --&gt; get all widgets in the database</p>
<p><code>GET /widgets/{id}</code> --&gt; get one widget instance by id (integer)</p>
<p><code>POST /widgets</code> --&gt; add a new widget to the database by providing a JSON object (converted to a NewWidget instance).
e.g - </p>
<pre><code>{
<span class="hljs-attr">"name"</span>: <span class="hljs-string">"new widget"</span>,
<span class="hljs-attr">"quantity"</span>: <span class="hljs-number">64</span>
}
</code></pre>
<p>returns</p>
<pre><code>{
<span class="hljs-attr">"id"</span>: <span class="hljs-number">4</span>,
<span class="hljs-attr">"name"</span>: <span class="hljs-string">"new widget"</span>,
<span class="hljs-attr">"quantity"</span>: <span class="hljs-number">64</span>,
<span class="hljs-attr">"dateCreated"</span>: <span class="hljs-number">1519926898</span>
}
</code></pre>
<p><code>PUT /widgets</code> --&gt; update an existing widgets name or quantity. Pass in the id in the JSON request to determine which record to update
</p>
<p><code>DELETE /widgets/{id}</code> --&gt; delete the widget with the specified id</p>
<h2 id="notifications-websocket-">Notifications (WebSocket)</h2>
<p>All updates (creates, updates and deletes) to <code>Widget</code> instances are served as notifications through a WebSocket endpoint:</p>
<p><code>WS /updates</code> --&gt; returns <code>Notification</code> instances containing the change type, id and entity (if applicable) e.g:</p>
<pre><code class="lang-json">{
<span class="hljs-attr">"type"</span>: <span class="hljs-string">"CREATE"</span>,
<span class="hljs-attr">"id"</span>: <span class="hljs-number">12</span>,
<span class="hljs-attr">"entity"</span>: {
<span class="hljs-attr">"id"</span>: <span class="hljs-number">12</span>,
<span class="hljs-attr">"name"</span>: <span class="hljs-string">"widget1"</span>,
<span class="hljs-attr">"quantity"</span>: <span class="hljs-number">5</span>,
<span class="hljs-attr">"dateUpdated"</span>: <span class="hljs-number">1533583858169</span>
}
}
</code></pre>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="Exposed" level="DEBUG" />
<logger name="ktor.application" level="TRACE" />
</configuration>