diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..39c21f5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[{*.java,*.kt}] +# See https://github.com/shyiko/ktlint/issues/47#issuecomment-317333157 +max_line_length = 100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e73db9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Created by https://www.gitignore.io/api/java,gradle,kotlin + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +.idea/ + +out/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + +### Gradle ### +.gradle +/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + + +# Intellij files +*.iml + +# Mac +.DS_Store + +# End of https://www.gitignore.io/api/java,gradle,kotlin diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..39e5118 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2018 Dirk-Jan Rutten + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..08f25d3 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# README +Adopted and modified from : https://github.com/excitement-engineer/ktor-graphql + +## About +This is a graphql backend for a small social networking app. + +## Data model +Can be found in `schema.graphql` + +## Execution strategies +For simplicity, we are using singletons to simulate our data access layers. +In the real world, usage of dataloaders is recommended. Read more about that [here](https://www.graphql-java.com/documentation/v12/batching/) + +## Technical +Although this example uses Ktor, any Kotlin server may be used. + +## Schema first vs code first +This example is schema first, but there are also possibilities to write code-first resolvers +(aka schema is defined by the code). Examples of code-first can be found [here](https://github.com/ExpediaDotCom/graphql-kotlin) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..6d083cd --- /dev/null +++ b/build.gradle @@ -0,0 +1,63 @@ +buildscript { + ext.kotlin_version = '1.5.10' + ext.ktor_version = '1.1.1' + + repositories { + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.10" + classpath 'org.junit.platform:junit-platform-gradle-plugin:1.2.0' + } +} + +plugins { + id 'application' + id "com.github.ben-manes.versions" version "0.39.0" +} + + +group 'ktor-graphql-example' +version '1.0' + +apply plugin: 'kotlin' +apply plugin: 'org.junit.platform.gradle.plugin' + +repositories { + mavenCentral() +} + +dependencies { + implementation "com.github.excitement-engineer:ktor-graphql:2.1.0" + implementation "com.graphql-java-kickstart:graphql-java-tools:11.0.1" + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.10" + implementation "com.google.code.gson:gson:2.8.7" + implementation "com.graphql-java:graphql-java:16.2" + implementation 'com.graphql-java:graphql-java-extended-scalars:16.0.1' + implementation 'com.google.guava:guava:30.1.1-jre' + implementation 'com.github.javafaker:javafaker:1.0.2' + implementation "io.ktor:ktor-server-netty:1.6.0" + implementation 'com.graphql-java:java-dataloader:2.2.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.5.0' +} + +compileKotlin { + kotlinOptions.jvmTarget = "11" +} +compileTestKotlin { + kotlinOptions.jvmTarget = "11" +} + +junitPlatform { + filters { + engines { + include 'spek' + } + } +} +task stage(dependsOn: ['build', 'clean']) + +application { + mainClassName = 'Application' +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..457aad0 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bb9ea7f --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Jun 08 16:35:52 CEST 2021 +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..af6708f --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$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='"-Xmx64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# 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 + ;; + 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" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..0f8d593 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@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 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" + +@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 init + +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 init + +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 + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +: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 %CMD_LINE_ARGS% + +: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 diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 0000000..c5974f9 --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,25 @@ +{ + "branchPrefix": "renovate-", + "dependencyDashboard": true, + "extends": [ + "config:base" + ], + // Ensure that major is never automerged + "major": { + "automerge": false + }, + "packageRules": [ + { + "description": "Avoid updates such as from 14.1 to 2020-06-07T01-00-15-98bb45a", + "packagePatterns": ["^com\\.graphql-java:"], + "versioning": "semver" + } + ], + // Default 0, but use this to prevent renovate from spamming PRs + "prConcurrentLimit": 0, + "schedule": [ + "every weekday" + ], + "stabilityDays": 10, + "timezone": "Europe/Oslo" +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..23f23db --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'com.github.excitement-engineer' + diff --git a/src/main/kotlin/ClientException.kt b/src/main/kotlin/ClientException.kt new file mode 100644 index 0000000..786c2a2 --- /dev/null +++ b/src/main/kotlin/ClientException.kt @@ -0,0 +1 @@ +class ClientException(message: String) : Exception(message) \ No newline at end of file diff --git a/src/main/kotlin/Dataloaders.kt b/src/main/kotlin/Dataloaders.kt new file mode 100644 index 0000000..e1e6710 --- /dev/null +++ b/src/main/kotlin/Dataloaders.kt @@ -0,0 +1,59 @@ +import graphql.schema.DataFetchingEnvironment +import kotlinx.coroutines.future.future +import org.dataloader.DataLoader +import org.dataloader.DataLoaderRegistry +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.slf4j.MDCContext +import org.dataloader.BatchLoader + +fun buildRegistry() = DataLoaderRegistry().apply { + register(PetBatchLoader.key, DataLoader(PetBatchLoader())) + register(PersonBatchLoader.key, DataLoader(PersonBatchLoader())) +} + +object PersonBatchLoader : CoroutineScope { + override val coroutineContext: CoroutineContext + get() = MDCContext() + + operator fun invoke() = + BatchLoader { keys -> + future(coroutineContext) { + val people = PersonRepository.findByIds(keys).associateBy { it.id } + keys + .map { + people.getValue(it) + } + } + } + + const val key = "Person" +} + +object PetBatchLoader : CoroutineScope { + override val coroutineContext: CoroutineContext + get() = MDCContext() + + operator fun invoke() = + BatchLoader { keys -> + future(coroutineContext) { + val pets = PetsRepository.findByIds(keys).associateBy { it.id } + keys + .map { + pets.getValue(it) + } + } + } + + const val key = "Pet" +} + +// Define loader as extension method to ensure types are used consistently. +fun DataFetchingEnvironment.getPersonDataLoader(): DataLoader = + dataLoaderRegistry + .getDataLoader(PersonBatchLoader.key) + +fun DataFetchingEnvironment.getPetDataLoader(): DataLoader = + dataLoaderRegistry + .getDataLoader(PetBatchLoader.key) + diff --git a/src/main/kotlin/Inputs.kt b/src/main/kotlin/Inputs.kt new file mode 100644 index 0000000..3cc5cdf --- /dev/null +++ b/src/main/kotlin/Inputs.kt @@ -0,0 +1,12 @@ +data class PersonInput( + val name: String +) + +data class SearchInput( + val queryString: String +) + +data class AddFriendsInput( + val first: Int, + val second: Int +) diff --git a/src/main/kotlin/Payload.kt b/src/main/kotlin/Payload.kt new file mode 100644 index 0000000..7f3b24d --- /dev/null +++ b/src/main/kotlin/Payload.kt @@ -0,0 +1,6 @@ +data class WipeDataPayload(val success: Boolean) { + companion object { + val SUCCESS = WipeDataPayload(true) + val FAILURE = WipeDataPayload(false) + } +} diff --git a/src/main/kotlin/Person.kt b/src/main/kotlin/Person.kt new file mode 100644 index 0000000..15afe16 --- /dev/null +++ b/src/main/kotlin/Person.kt @@ -0,0 +1,32 @@ +import com.github.javafaker.Faker +import graphql.schema.DataFetchingEnvironment +import java.text.SimpleDateFormat +import java.util.concurrent.CompletableFuture + +@Suppress("unused") // GraphQL by reflection +data class Person( + val id: Int, // modified for simplicity + val name: String, + val petIds: List = emptyList() +) : Searchable { + + private val friendIds = mutableListOf() + override fun matches(queryString: String) = name.contains(queryString) + + val dateOfBirth = SimpleDateFormat("yyyy-MM-dd") + .format(Faker().date().birthday()) + .replace("(\\d\\d)(\\d\\d)$", "$1:$2") + + val description = Faker().lorem().paragraphs(2).joinToString("\n") + + fun friends(env: DataFetchingEnvironment): CompletableFuture> = + env.getPersonDataLoader().loadMany(friendIds) + + fun pets(env: DataFetchingEnvironment): CompletableFuture> = + env.getPetDataLoader().loadMany(petIds) + + fun addFriend(other: Person) = + if (friendIds.contains(other.id)) true + else friendIds.add(other.id) + +} diff --git a/src/main/kotlin/PersonRepository.kt b/src/main/kotlin/PersonRepository.kt new file mode 100644 index 0000000..0ee50d6 --- /dev/null +++ b/src/main/kotlin/PersonRepository.kt @@ -0,0 +1,34 @@ +object PersonRepository { + var people: MutableList = mutableListOf() + + fun clear() = people.clear() + + fun addPerson(name: String): Person { + val id = nextId() + val person = Person(id, name, emptyList()) + people.add(person) + return person + } + + fun addPerson(person: Person) = + if (people.find { it.id == person.id } != null) + throw IllegalArgumentException("Already one person with that ID") + else + people.add(person) + + fun addFriends(first: Person, second: Person): Boolean = + first.addFriend(second) && second.addFriend(first) + + fun allPeople() = people.toList().also { + println("Expensive call to get all people") + } + + // Note: this can be expensive, so look at the Person.kt class for a hint on how to batch calls + fun findById(id: Int) = people.first { it.id == id } + + fun findByIds(ids: List) = people.filter { it.id in ids }.also { + println("An expensive call to find the people with id $ids") + } + + fun nextId() = (people.maxByOrNull { it.id }?.id ?: 0) + 1 +} diff --git a/src/main/kotlin/Pets.kt b/src/main/kotlin/Pets.kt new file mode 100644 index 0000000..aa5ba55 --- /dev/null +++ b/src/main/kotlin/Pets.kt @@ -0,0 +1,24 @@ +sealed class Pet: Searchable { + abstract val id: Int + abstract val name: String + abstract fun makeSound(): String + override fun matches(queryString: String) = name.contains(queryString) +} + +@Suppress("unused") // GraphQL by reflection +data class Dog( + override val id: Int, + override val name: String +): Pet() { + override fun makeSound() = "bark" + fun barkLoudly() = "WOOF WOOF" +} + +@Suppress("unused") // GraphQL by reflection +data class Cat( + override val id: Int, + override val name: String +): Pet() { + override fun makeSound() = "purr" + fun beLazy() = "" +} diff --git a/src/main/kotlin/PetsRepository.kt b/src/main/kotlin/PetsRepository.kt new file mode 100644 index 0000000..1d1513d --- /dev/null +++ b/src/main/kotlin/PetsRepository.kt @@ -0,0 +1,22 @@ +object PetsRepository { + var pets = mutableListOf() + + fun clear() = pets.clear() + + fun findById(id: Int) = pets.first { it.id == id } + fun findByIds(ids: List) = pets + .filter { it.id in ids} + .also { + println("Another expensive call to get pets with ids: $ids") + } + + fun allPets() = pets.toList() + + fun addPet(pet: Pet) = + if (pets.find { it.id == pet.id } != null) + throw IllegalArgumentException("Already exists") + else + pets.add(pet) + + fun nextId() = (pets.maxByOrNull { it.id }?.id ?: 0) + 1 +} diff --git a/src/main/kotlin/Searchable.kt b/src/main/kotlin/Searchable.kt new file mode 100644 index 0000000..44220c1 --- /dev/null +++ b/src/main/kotlin/Searchable.kt @@ -0,0 +1,3 @@ +interface Searchable { + fun matches(queryString: String): Boolean +} \ No newline at end of file diff --git a/src/main/kotlin/formatGraphQLError.kt b/src/main/kotlin/formatGraphQLError.kt new file mode 100644 index 0000000..a4ee61d --- /dev/null +++ b/src/main/kotlin/formatGraphQLError.kt @@ -0,0 +1,21 @@ +import graphql.ExceptionWhileDataFetching +import graphql.GraphQLError + +val formatErrorGraphQLError: (GraphQLError.() -> Map) = { + val clientMessage = if (this is ExceptionWhileDataFetching) { + + val formattedMessage = if (exception is ClientException) { + exception.message + } else { + "Internal server error" + } + + formattedMessage + } else { + message + } + + val result = toSpecification() + result["message"] = clientMessage + result +} \ No newline at end of file diff --git a/src/main/kotlin/main.kt b/src/main/kotlin/main.kt new file mode 100644 index 0000000..ccd71b6 --- /dev/null +++ b/src/main/kotlin/main.kt @@ -0,0 +1,7 @@ +import io.ktor.application.Application +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty + +fun main() { + embeddedServer(Netty, port = 8080, module = Application::main).start(wait = true) +} \ No newline at end of file diff --git a/src/main/kotlin/schema.kt b/src/main/kotlin/schema.kt new file mode 100644 index 0000000..6a63fc6 --- /dev/null +++ b/src/main/kotlin/schema.kt @@ -0,0 +1,95 @@ +import com.github.javafaker.Faker +import graphql.kickstart.tools.GraphQLMutationResolver +import graphql.kickstart.tools.GraphQLQueryResolver +import graphql.kickstart.tools.SchemaParser +import com.google.common.io.Resources +import graphql.scalars.ExtendedScalars +import kotlin.random.Random + +val schema = createExecutableSchema() + +@Suppress("UnstableApiUsage") +fun createExecutableSchema() = SchemaParser + .newParser() + .schemaString( + Resources.toString( + Resources.getResource("schema.graphql"), + Charsets.UTF_8 + ) + ) + .dictionary( // Currently required for sealed classes + mapOf( + "Cat" to Cat::class.java, + "Dog" to Dog::class.java + ) + ) + .scalars(ExtendedScalars.Date) + .resolvers(QueryResolver(), MutationResolver()) + .build() + .makeExecutableSchema() + +@Suppress("unused") // GraphQL by reflection +class QueryResolver : GraphQLQueryResolver { + fun person(id: Int) = PersonRepository.findById(id) + fun allPeople() = PersonRepository.allPeople() + fun search(input: SearchInput): List = + (PersonRepository.allPeople() + PetsRepository.allPets()).filter { + it.matches(input.queryString) + } +} + +@Suppress("unused") // GraphQL by reflection +class MutationResolver : GraphQLMutationResolver { + fun wipeData() = run { + try { + PersonRepository.clear() + PetsRepository.clear() + WipeDataPayload.SUCCESS + } catch (e: Exception) { + WipeDataPayload.FAILURE + } + } + + fun generateData(): List { + val faker = Faker() + val newPeople = (1..10).map { + val fakePersonName = faker.name().nameWithMiddle() + + val petIds = (0..Random.nextInt(5)).map { + val petId = PetsRepository.nextId() + val pet = when (Random.nextInt(2)) { + 0 -> Dog(petId, faker.dog().name()) + else -> Cat(petId, faker.cat().name()) + } + PetsRepository.addPet(pet) + pet.id + } + + val person = Person( + PersonRepository.nextId(), + fakePersonName, + petIds + ) + PersonRepository.addPerson(person) + + person + } + + val allPeople = PersonRepository.allPeople() + + newPeople.forEach { newPerson -> + val anotherPerson = allPeople.minus(newPerson).random() + PersonRepository.addFriends(newPerson, anotherPerson) + } + + return newPeople + } + + fun addPerson(input: PersonInput): Person = PersonRepository.addPerson(input.name) + fun addFriends(input: AddFriendsInput): Boolean { + val first = PersonRepository.findById(input.first) + val second = PersonRepository.findById(input.second) + + return PersonRepository.addFriends(first, second) + } +} diff --git a/src/main/kotlin/server.kt b/src/main/kotlin/server.kt new file mode 100644 index 0000000..baddd8a --- /dev/null +++ b/src/main/kotlin/server.kt @@ -0,0 +1,36 @@ +import graphql.ExecutionInput +import graphql.GraphQL +import io.ktor.application.Application +import io.ktor.routing.routing +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import ktor.graphql.Config +import ktor.graphql.fromRequest +import ktor.graphql.graphQL + +fun Application.main() { + val graphQLExecutor = GraphQL.newGraphQL(schema).build() + + val server = embeddedServer(Netty, port = 8080) { + routing { + graphQL("/graphql", schema) { request -> + Config( + formatError = formatErrorGraphQLError, + showExplorer = true, + executeRequest = { + // Building registry per request ensures that we don't share data across requests + val dataloaderRegistry = buildRegistry() + val input = ExecutionInput.newExecutionInput() + .fromRequest(request) + .dataLoaderRegistry(dataloaderRegistry) + .build() + + graphQLExecutor.execute(input) + } + ) + } + } + } + + server.start(wait = true) +} diff --git a/src/main/resources/schema.graphql b/src/main/resources/schema.graphql new file mode 100644 index 0000000..0b08962 --- /dev/null +++ b/src/main/resources/schema.graphql @@ -0,0 +1,62 @@ +input AddFriendsInput { + first: ID! + second: ID! +} + +type Cat implements Pet { + id: ID! + name: String! + makeSound: String! + beLazy: String! +} + +type Dog implements Pet { + id: ID! + name: String! + makeSound: String! + barkLoudly: String! +} + +interface Pet { + id: ID! + name: String! + makeSound: String! +} + +type Person { + id: ID! + name: String! + description: String! + dateOfBirth: Date! + friends: [Person!]! + pets: [Pet!]! +} + +input PersonInput { + name: String! +} + +union SearchResult = Dog | Cat | Person +input SearchInput { + queryString: String! +} + +type Query { + allPeople: [Person!]! + person(id: ID!): Person! + search(input: SearchInput!): [SearchResult!]! +} + +type Mutation { + addPerson(input: PersonInput!): Person! + addFriends(input: AddFriendsInput!): Boolean! + generateData: [Person!]! + wipeData: WipeDataPayload! +} + +type WipeDataPayload { + success: Boolean! +} + +# RFC3339: full-date +scalar Date