diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..15c158a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,124 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +keep_existing_linebreaks = true +max_line_length = off + +[*.{json,xml,yml,yaml,htm,html,js,ts,css,scss,less}] +indent_size = 2 + +[*.{*proj,resx,config,ruleset,cd,props,targets,nuspec}] +end_of_line = crlf +indent_size = 2 + +[*.{txt,cs,ps1,psd1,manifest}] +end_of_line = crlf + +[*.{bat,cmd}] +charset = latin1 +end_of_line = crlf + +[*.sln] +end_of_line = crlf +indent_style = tab + +[*.{md,resx,Designer.*}] +trim_trailing_whitespace = false + +[*.cs] +# Indentation +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_outdent_binary_ops = true +csharp_outdent_dots = true +csharp_align_linq_query = true +csharp_align_multiline_parameter = true +csharp_align_multiline_calls_chain = true +csharp_align_multiline_binary_expressions_chain = true +csharp_align_multiline_array_and_object_initializer = false + +# Line breaks +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_open_brace = all +csharp_blank_lines_around_single_line_field = 0 +csharp_blank_lines_inside_region = 0 +csharp_blank_lines_around_region = 0 +csharp_blank_lines_after_block_statements = 0 +csharp_empty_block_style = together +csharp_place_simple_blocks_on_single_line = true +csharp_place_simple_initializer_on_single_line = true +csharp_place_attribute_on_same_line = if_owner_is_single_line +csharp_place_expr_method_on_single_line = true +csharp_place_constructor_initializer_on_same_line = false +csharp_wrap_object_and_collection_initializer_style = chop_if_long +csharp_wrap_array_initializer_style = chop_if_long +csharp_wrap_parameters_style = chop_if_long +csharp_preserve_single_line_blocks = true +csharp_keep_existing_arrangement = true + +# Spacing +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_before_open_square_brackets = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_square_brackets = false +csharp_space_within_empty_braces = false + +# Style +csharp_parentheses_redundancy_style = remove_if_not_clarifies_precedence +csharp_allow_comment_after_lbrace = true +csharp_braces_for_ifelse = required_for_multiline +csharp_braces_for_for = required_for_multiline +csharp_braces_for_foreach = required_for_multiline +csharp_braces_for_while = required_for_multiline +csharp_braces_for_using = required_for_multiline +csharp_braces_for_lock = required_for_multiline +csharp_braces_for_fixed = required_for_multiline +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_methods = true +csharp_style_expression_bodied_properties = true +csharp_local_function_body = expression_body +csharp_style_qualification_for_event = false +csharp_style_qualification_for_field = false +csharp_style_qualification_for_method = false +csharp_style_qualification_for_property = false +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_object_initializer = true +csharp_style_collection_initializer = true +csharp_style_explicit_tuple_names = true +csharp_style_null_propagation = true +csharp_style_coalesce_expression = true +csharp_style_conditional_delegate_call = true +csharp_style_throw_expression = true +csharp_style_predefined_type_for_locals_parameters_members = true +csharp_style_predefined_type_for_member_access = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3d8d36a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Disable linebreak normalization +* -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..92f6484 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,62 @@ +name: Build +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Prepare + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: gittools/actions/gitversion/setup@v0.10.2 + with: + versionSpec: '5.12.x' + - uses: gittools/actions/gitversion/execute@v0.10.2 + id: gitversion + - name: Set version (release) + if: github.ref_type == 'tag' + run: echo "VERSION=${{steps.gitversion.outputs.semVer}}" >> $GITHUB_ENV + - name: Set version (pre-release) + if: github.ref_type != 'tag' + run: echo "VERSION=${{steps.gitversion.outputs.semVer}}-${{steps.gitversion.outputs.shortSha}}" >> $GITHUB_ENV + + # Build + - run: gradle build + - run: gradle dokkaHtml + - run: gradle test + - name: Report test results + if: always() + uses: dorny/test-reporter@v1 + with: + name: Test Report + reporter: java-junit + path: '*/build/test-results/test/TEST-*.xml' + + # Release + - name: Create GitHub Release + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v1 + + # Publish +# - name: Publish packages (GitHub) +# if: github.event_name == 'push' && !startsWith(github.ref_name, 'renovate/') +# run: gradle publishMavenPublicationToGitHubRepository +# env: +# GITHUB_TOKEN: ${{github.token}} +# - name: Publish packages (Maven Central) +# if: github.ref_type == 'tag' +# run: gradle publishToSonatype closeAndReleaseSonatypeStagingRepository +# env: +# SIGNING_KEY: ${{secrets.SIGNING_KEY}} +# SIGNING_PASSWORD: ${{secrets.SIGNING_PASSWORD}} +# SONATYPE_USERNAME: ${{secrets.SONATYPE_USERNAME}} +# SONATYPE_PASSWORD: ${{secrets.SONATYPE_PASSWORD}} + - name: Publish documentation + if: github.ref_type == 'tag' + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{github.token}} + force_orphan: true + publish_dir: build/dokka/html + cname: java.typedrest.net diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5c8ef1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*/build/ +/.gradle/ +/.idea/ +/.kotlin + diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..4e2090b --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,15 @@ +mode: ContinuousDeployment + +branches: + # Mainline branches + develop: + tag: alpha + increment: patch + master: + tag: beta + + # Stabilization branches + release: + tag: rc + hotfix: + tag: rc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6385605 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Bastian Eicher + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c671ba --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +#  for Java/Kotlin + +TypedRest for Java/Kotlin helps you build type-safe, fluent-style REST API clients. Common REST patterns such as collections are represented as classes, allowing you to write more idiomatic code. + +**Java** + +```java +MyClient client = new MyClient(URI.create("http://example.com/")); + +// GET /contacts +List<Contact> contactList = client.getContacts().readAll(); + +// POST /contacts -> Location: /contacts/1337 +ContactEndpoint smith = client.getContacts().create(new Contact("Smith")); +//ContactEndpoint smith = client.getContacts().get("1337"); + +// GET /contacts/1337 +Contact contact = smith.read(); + +// PUT /contacts/1337/note +smith.getNote().set(new Note("some note")); + +// GET /contacts/1337/note +Note note = smith.getNote().read(); + +// DELETE /contacts/1337 +smith.delete(); +``` + +**Kotlin** + +```kotlin +val client = MyClient(URI("http://example.com/")) + +// GET /contacts +val contactList: List<Contact> = client.contacts.readAll() + +// POST /contacts -> Location: /contacts/1337 +val smith: ContactEndpoint = client.contacts.create(Contact("Smith")) +//val smith: ContactEndpoint = client.contacts["1337"] + +// GET /contacts/1337 +val contact: Contact = smith.read() + +// PUT /contacts/1337/note +smith.note.set(Note("some note")) + +// GET /contacts/1337/note +val note: Note = smith.note.read() + +// DELETE /contacts/1337 +smith.delete() +``` + +Read an **[Introduction](https://typedrest.net/introduction/)** to TypedRest or jump right in with the **[Getting started](https://typedrest.net/getting-started/java/)** guide. + +For information about specific classes or interfaces you can read the **[API Documentation](https://java.typedrest.net/)**. + +## Maven artifacts + +Artifact group: [`net.typedrest`](https://mvnrepository.com/artifact/net.typedrest) + +[](https://mvnrepository.com/artifact/net.typedrest/typedrest) +The main TypedRest library. + +[](https://mvnrepository.com/artifact/net.typedrest/typedrest-serializers-jackson) +Adds support for serializing using [Jackson](https://github.com/FasterXML/jackson) instead of [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html). +Pass `new JacksonJsonSerializer()` to the `EntryEndpoint` constructor. + +[](https://mvnrepository.com/artifact/net.typedrest/typedrest-serializers-moshi) +Adds support for serializing using [Moshi](https://github.com/square/moshi) instead of [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html). +Pass `new MoshiJsonSerializer()` to the `EntryEndpoint` constructor. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a2365c4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + kotlin("jvm") version "2.0.0" apply false + kotlin("plugin.serialization") version "2.0.0" apply false + id("org.jetbrains.dokka") version "1.9.20" apply false +} + +subprojects { + fun kotlin(module: String) = "org.jetbrains.kotlin.${module}" + apply(plugin = kotlin("jvm")) + apply(plugin = kotlin("plugin.serialization")) + apply(plugin = "org.jetbrains.dokka") + + repositories.mavenCentral() + + group = "net.typedrest" + version = System.getenv("VERSION") ?: "1.0-SNAPSHOT" +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..a859b60 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,16 @@ +[versions] +okhttp3 = "4.12.0" +uri-templates = "2.1.7" +kotlinx-serialization = "1.7.0" +jackson = "2.17.1" +moshi = "1.15.1" + +[libraries] +okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp3" } +okhttp3-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp3" } +uri-templates = { module = "com.damnhandy:handy-uri-templates", version.ref = "uri-templates" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } +jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 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..a441313 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b740cf1 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 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 «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# 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/HEAD/platforms/jvm/plugins-application/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 + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + 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 + + +# 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" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@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=. +@rem This is normally unused +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% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +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% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..b75bc79 --- /dev/null +++ b/logo.svg @@ -0,0 +1,146 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="150" + height="68" + viewBox="0 0 39.687498 17.991668" + version="1.1" + id="svg8" + inkscape:version="0.92.4 (5da689c313, 2019-01-14)" + sodipodi:docname="logo.svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="1.4" + inkscape:cx="-130.32891" + inkscape:cy="119.01833" + inkscape:document-units="mm" + inkscape:current-layer="layer1" + showgrid="false" + units="px" + inkscape:window-width="1680" + inkscape:window-height="997" + inkscape:window-x="1912" + inkscape:window-y="20" + inkscape:window-maximized="1" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" /> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Ebene 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(-13.958974,-254.23259)"> + <g + aria-label="{" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="text20" + transform="matrix(0.73701093,0,0,0.73701093,15.203548,70.948228)" /> + <g + id="g4683" + transform="matrix(0.59265843,0,0,0.59265843,-6.3400063,127.35161)"> + <g + id="g4660"> + <g + id="g4582" + transform="translate(15.619414,5.2043533)"> + <path + d="m 37.731336,211.81291 h -3.199805 v 9.54981 H 33.04325 v -9.54981 h -3.199805 v -1.25677 h 7.887891 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#2f5597;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4543" + inkscape:connector-curvature="0" /> + <path + d="m 47.165384,213.06142 -2.827734,7.42486 q -0.438216,1.13275 -0.909505,1.95131 -0.463021,0.81855 -1.016992,1.33945 -0.553971,0.52917 -1.21543,0.77721 -0.661458,0.24805 -1.480013,0.24805 -0.214974,0 -0.388606,-0.008 -0.173633,-0.008 -0.380339,-0.0248 v -1.30638 q 0.181901,0.0248 0.396875,0.0413 0.214974,0.0248 0.454753,0.0248 0.396875,0 0.735872,-0.11575 0.347266,-0.11576 0.65319,-0.38034 0.305924,-0.25632 0.587044,-0.66973 0.28112,-0.41341 0.545703,-1.00045 l -3.315559,-8.3013 h 1.637109 l 2.10013,5.4901 0.42168,1.28984 0.479557,-1.32291 1.943034,-5.45703 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#2f5597;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4545" + inkscape:connector-curvature="0" /> + <path + d="m 56.202558,217.06324 q 0,1.10794 -0.314192,1.93476 -0.305925,0.82683 -0.851628,1.37253 -0.545703,0.5457 -1.289844,0.81855 -0.74414,0.27286 -1.612304,0.27286 -0.396875,0 -0.79375,-0.0413 -0.388607,-0.0413 -0.79375,-0.14056 v 3.47266 h -1.438672 v -11.69127 h 1.281576 l 0.09095,1.38906 q 0.620117,-0.85163 1.322917,-1.19063 0.702799,-0.34726 1.521354,-0.34726 0.711067,0 1.248502,0.29765 0.537435,0.29766 0.901237,0.84336 0.363802,0.53744 0.545703,1.30638 0.181901,0.76068 0.181901,1.70326 z m -1.471744,0.0661 q 0,-0.65319 -0.09922,-1.19889 -0.09095,-0.5457 -0.297656,-0.93431 -0.206706,-0.38861 -0.529167,-0.60358 -0.322461,-0.22324 -0.768945,-0.22324 -0.272852,0 -0.553972,0.0909 -0.281119,0.0827 -0.587044,0.28939 -0.297656,0.19843 -0.636653,0.53743 -0.33073,0.33073 -0.711068,0.82682 v 4.02663 q 0.396875,0.16537 0.835091,0.26458 0.438216,0.091 0.859896,0.091 1.16582,0 1.827278,-0.78549 0.661459,-0.79375 0.661459,-2.38125 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#2f5597;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4547" + inkscape:connector-curvature="0" /> + <path + d="m 65.446439,216.77385 q 0,0.30592 -0.0083,0.51263 -0.0083,0.20671 -0.02481,0.38861 h -5.829101 q 0,1.2733 0.711067,1.95957 0.711068,0.67799 2.050521,0.67799 0.363802,0 0.727604,-0.0248 0.363802,-0.0331 0.7028,-0.0827 0.338997,-0.0496 0.644922,-0.10748 0.314192,-0.0661 0.578776,-0.14056 v 1.18235 q -0.587045,0.16537 -1.331185,0.26459 -0.735873,0.10748 -1.529623,0.10748 -1.066601,0 -1.835546,-0.28938 -0.768946,-0.28939 -1.265039,-0.83509 -0.487826,-0.55398 -0.727604,-1.34773 -0.231511,-0.80201 -0.231511,-1.81074 0,-0.87643 0.248047,-1.65364 0.256315,-0.78549 0.735872,-1.37253 0.487826,-0.59531 1.190625,-0.94258 0.7028,-0.34726 1.595768,-0.34726 0.868164,0 1.537891,0.27285 0.669726,0.27285 1.124479,0.77721 0.463021,0.4961 0.694531,1.21543 0.239779,0.71107 0.239779,1.59577 z m -1.496549,-0.20671 q 0.0248,-0.55397 -0.107487,-1.00872 -0.132292,-0.46302 -0.413412,-0.79375 -0.272851,-0.33073 -0.686263,-0.51263 -0.413411,-0.19017 -0.959114,-0.19017 -0.471289,0 -0.859896,0.1819 -0.388607,0.1819 -0.669727,0.51263 -0.281119,0.33073 -0.454752,0.79375 -0.173633,0.46302 -0.214974,1.01699 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#2f5597;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4549" + inkscape:connector-curvature="0" /> + <path + d="m 67.339862,217.3857 q 0,-1.05833 0.289388,-1.87689 0.289388,-0.82682 0.818555,-1.38906 0.537435,-0.57051 1.281575,-0.8599 0.752409,-0.29765 1.670183,-0.29765 0.396875,0 0.777213,0.0496 0.388607,0.0496 0.760677,0.15709 v -3.48919 h 1.44694 v 11.68301 h -1.289844 l -0.04961,-1.57097 q -0.603581,0.87644 -1.30638,1.29812 -0.702799,0.42167 -1.521354,0.42167 -0.711068,0 -1.256771,-0.29765 -0.537435,-0.29766 -0.901237,-0.83509 -0.355534,-0.54571 -0.537435,-1.30638 -0.181901,-0.76068 -0.181901,-1.68672 z m 1.471745,-0.091 q 0,1.50482 0.438216,2.24896 0.446484,0.73587 1.256771,0.73587 0.545703,0 1.149284,-0.48783 0.611849,-0.48782 1.281575,-1.44694 v -3.85299 q -0.355534,-0.16537 -0.785482,-0.24805 -0.429948,-0.091 -0.851627,-0.091 -1.174089,0 -1.835547,0.76068 -0.65319,0.76068 -0.65319,2.38125 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#2f5597;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4551" + inkscape:connector-curvature="0" /> + </g> + <g + id="g4588" + transform="translate(19.902356,5.2043533)"> + <path + d="m 37.822286,238.29605 h -1.661914 l -1.612305,-3.45612 q -0.181901,-0.39688 -0.37207,-0.65319 -0.190169,-0.26458 -0.413411,-0.41341 -0.214974,-0.1571 -0.479558,-0.22324 -0.256315,-0.0662 -0.578776,-0.0662 h -0.694531 v 4.81211 h -1.471744 v -10.80658 h 2.89388 q 0.942578,0 1.620572,0.20671 0.677995,0.20671 1.107943,0.57878 0.438216,0.37207 0.636654,0.90123 0.206705,0.5209 0.206705,1.15755 0,0.50437 -0.148828,0.95912 -0.148828,0.44648 -0.446484,0.81855 -0.289388,0.36381 -0.727604,0.63666 -0.429948,0.26458 -0.992188,0.39687 0.454753,0.1571 0.768946,0.55397 0.322461,0.38861 0.65319,1.0418 z m -2.356445,-7.83828 q 0,-0.86817 -0.545703,-1.29811 -0.537435,-0.42995 -1.521354,-0.42995 h -1.389063 v 3.57187 h 1.190625 q 0.520899,0 0.93431,-0.11575 0.42168,-0.12403 0.711068,-0.35554 0.297656,-0.23977 0.454752,-0.57877 0.165365,-0.34727 0.165365,-0.79375 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#c55a11;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4553" + inkscape:connector-curvature="0" /> + <path + d="m 46.793314,233.70718 q 0,0.30593 -0.0083,0.51263 -0.0083,0.20671 -0.02481,0.38861 H 40.93114 q 0,1.27331 0.711068,1.95957 0.711067,0.67799 2.05052,0.67799 0.363802,0 0.727604,-0.0248 0.363803,-0.0331 0.7028,-0.0827 0.338997,-0.0496 0.644922,-0.10749 0.314192,-0.0662 0.578776,-0.14056 v 1.18236 q -0.587044,0.16536 -1.331185,0.26458 -0.735872,0.10749 -1.529622,0.10749 -1.066602,0 -1.835547,-0.28939 -0.768945,-0.28939 -1.265039,-0.83509 -0.487826,-0.55397 -0.727604,-1.34772 -0.231511,-0.80202 -0.231511,-1.81075 0,-0.87643 0.248047,-1.65364 0.256315,-0.78548 0.735873,-1.37253 0.487825,-0.59531 1.190624,-0.94258 0.7028,-0.34726 1.595769,-0.34726 0.868164,0 1.53789,0.27285 0.669727,0.27285 1.124479,0.77721 0.463021,0.4961 0.694531,1.21543 0.239779,0.71107 0.239779,1.59577 z m -1.496549,-0.2067 q 0.0248,-0.55397 -0.107487,-1.00873 -0.132292,-0.46302 -0.413412,-0.79375 -0.272851,-0.33073 -0.686263,-0.51263 -0.413411,-0.19017 -0.959114,-0.19017 -0.471289,0 -0.859896,0.1819 -0.388607,0.18191 -0.669727,0.51263 -0.281119,0.33073 -0.454752,0.79375 -0.173633,0.46303 -0.214974,1.017 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#c55a11;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4555" + inkscape:connector-curvature="0" /> + <path + d="m 55.68166,236.03055 q 0,0.43822 -0.148828,0.78549 -0.148828,0.34726 -0.405143,0.62011 -0.256315,0.26459 -0.595313,0.45476 -0.338997,0.19017 -0.727604,0.31419 -0.380339,0.12402 -0.785482,0.1819 -0.405143,0.0579 -0.79375,0.0579 -0.843359,0 -1.554427,-0.0744 -0.702799,-0.0744 -1.380794,-0.23978 v -1.32291 q 0.727604,0.2067 1.44694,0.31419 0.719336,0.10749 1.430404,0.10749 1.033528,0 1.529622,-0.28112 0.496094,-0.28112 0.496094,-0.80202 0,-0.22324 -0.08268,-0.39688 -0.07441,-0.1819 -0.281119,-0.33899 -0.206706,-0.16537 -0.644922,-0.339 -0.429948,-0.17363 -1.182357,-0.39688 -0.562239,-0.16536 -1.041797,-0.37207 -0.471289,-0.21497 -0.818554,-0.50436 -0.347266,-0.28939 -0.545703,-0.67799 -0.198438,-0.38861 -0.198438,-0.91778 0,-0.34726 0.157096,-0.76067 0.165365,-0.41341 0.553972,-0.76895 0.388607,-0.35553 1.050065,-0.58704 0.661458,-0.23978 1.653646,-0.23978 0.487825,0 1.083138,0.0579 0.595312,0.0496 1.240234,0.1819 v 1.28157 q -0.677995,-0.16536 -1.289844,-0.23978 -0.603581,-0.0827 -1.050065,-0.0827 -0.537435,0 -0.909505,0.0827 -0.363802,0.0827 -0.595313,0.23152 -0.223242,0.14056 -0.32246,0.33899 -0.09922,0.19017 -0.09922,0.41341 0,0.22325 0.08268,0.40515 0.09095,0.1819 0.322461,0.35553 0.239779,0.16537 0.661458,0.339 0.42168,0.16536 1.099675,0.3638 0.735872,0.21497 1.240234,0.45475 0.504362,0.23151 0.818555,0.5209 0.314192,0.28939 0.446484,0.65319 0.14056,0.3638 0.14056,0.82682 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#c55a11;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4557" + inkscape:connector-curvature="0" /> + <path + d="m 65.123978,238.18029 q -0.487825,0.12403 -1.008724,0.17364 -0.520898,0.0579 -1.058333,0.0579 -1.562695,0 -2.331641,-0.7028 -0.768945,-0.71106 -0.768945,-2.17454 v -4.33255 h -2.323372 v -1.20716 h 2.323372 v -2.28203 l 1.438672,-0.37207 v 2.6541 h 3.728971 v 1.20716 h -3.728971 v 4.2168 q 0,0.89296 0.471289,1.33945 0.479557,0.43821 1.405599,0.43821 0.396875,0 0.868164,-0.0579 0.471289,-0.0661 0.983919,-0.19844 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#c55a11;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4559" + inkscape:connector-curvature="0" /> + </g> + </g> + <g + id="g4665"> + <path + d="m 43.795077,238.81872 h -0.760876 q -1.984446,0 -2.950964,-0.93568 -0.966517,-0.92539 -0.966517,-2.81729 v -3.53704 q 0,-0.50383 -0.113102,-0.89455 -0.113103,-0.401 -0.401001,-0.66833 -0.277618,-0.27762 -0.750594,-0.42157 -0.462694,-0.14395 -1.17216,-0.14395 h -0.44213 v -1.42921 h 0.44213 q 0.750593,0 1.22357,-0.12336 0.472978,-0.12336 0.740311,-0.37016 0.267335,-0.25705 0.370157,-0.64777 0.102818,-0.39072 0.102818,-0.92538 v -2.43686 q 0,-0.86371 0.205642,-1.55261 0.205642,-0.69918 0.668336,-1.18243 0.472977,-0.49355 1.213288,-0.7506 0.750594,-0.26734 1.830215,-0.26734 h 0.760876 v 1.43949 h -0.606644 q -2.385449,0 -2.385449,2.31349 v 2.39572 q 0,2.50883 -2.169523,2.82759 2.190088,0.21592 2.190088,2.81729 v 3.51647 q 0,2.35461 2.364884,2.35461 h 0.606644 z" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#7f7f7f;fill-opacity:1;stroke:none;stroke-width:0.3290273px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="path4571" + inkscape:connector-curvature="0" /> + <g + aria-label="{" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:16.93333244px;line-height:100%;font-family:Consolas;-inkscape-font-specification:Consolas;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + id="text20-9" + transform="matrix(-1.2435678,0,0,1.2435678,117.84539,-56.297769)"> + <path + d="m 21.047347,237.31435 h -0.611849 q -1.595768,0 -2.372982,-0.75241 -0.777213,-0.74414 -0.777213,-2.26549 v -2.84427 q 0,-0.40515 -0.09095,-0.71934 -0.09095,-0.32246 -0.32246,-0.53743 -0.223243,-0.22325 -0.603581,-0.339 -0.37207,-0.11576 -0.942578,-0.11576 H 14.9702 v -1.14928 h 0.355534 q 0.60358,0 0.983919,-0.0992 0.380339,-0.0992 0.595312,-0.29766 0.214974,-0.2067 0.297657,-0.52089 0.08268,-0.3142 0.08268,-0.74414 v -1.95957 q 0,-0.69454 0.165365,-1.24851 0.165364,-0.56224 0.537434,-0.95084 0.380339,-0.39688 0.975651,-0.60358 0.603581,-0.21498 1.471745,-0.21498 h 0.611849 v 1.15755 h -0.487825 q -1.91823,0 -1.91823,1.86036 v 1.92649 q 0,2.01745 -1.744596,2.27377 1.761133,0.17363 1.761133,2.26549 v 2.82773 q 0,1.89343 1.901693,1.89343 h 0.487825 z" + style="fill:#7f7f7f;fill-opacity:1;stroke-width:0.26458332px" + id="path4571-5" + inkscape:connector-curvature="0" /> + </g> + </g> + </g> + </g> +</svg> diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..e12c8c8 --- /dev/null +++ b/renovate.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base", ":disableDependencyDashboard"], + "packageRules": [ + { + "matchPackagePatterns": ["gradle", "kotlin"], + "groupName": "Infrastructure", + "automerge": true, + "automergeType": "branch" + } + ] +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9b8a29c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "typedrest" +include("typedrest", "typedrest-serializers-jackson", "typedrest-serializers-moshi") diff --git a/typedrest-serializers-jackson/build.gradle.kts b/typedrest-serializers-jackson/build.gradle.kts new file mode 100644 index 0000000..538f9ad --- /dev/null +++ b/typedrest-serializers-jackson/build.gradle.kts @@ -0,0 +1,13 @@ +kotlin.jvmToolchain(21) +tasks.test { useJUnitPlatform() } + +dependencies { + api(libs.okhttp3) + api(libs.jackson.databind) + implementation(libs.jackson.kotlin) + api(project(":typedrest")) + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation(libs.okhttp3.mockwebserver) +} diff --git a/typedrest-serializers-jackson/src/main/kotlin/net/typedrest/serializers/JacksonJsonSerializer.kt b/typedrest-serializers-jackson/src/main/kotlin/net/typedrest/serializers/JacksonJsonSerializer.kt new file mode 100644 index 0000000..a246a08 --- /dev/null +++ b/typedrest-serializers-jackson/src/main/kotlin/net/typedrest/serializers/JacksonJsonSerializer.kt @@ -0,0 +1,27 @@ +package net.typedrest.serializers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import okhttp3.* +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Serializes and deserializes entities as JSON using Jackson. + * + * @param mapper The Jackson object mapper to use for serializing and deserializing. + */ +open class JacksonJsonSerializer @JvmOverloads constructor( + private val mapper: ObjectMapper = ObjectMapper().registerKotlinModule() +) : AbstractJsonSerializer() { + override fun <T> serialize(entity: T, type: Class<T>): RequestBody = + mapper.writeValueAsString(entity).toRequestBody(mediaTypeJson) + + override fun <T> serializeList(entities: Iterable<T>, type: Class<T>): RequestBody = + mapper.writeValueAsString(entities).toRequestBody(mediaTypeJson) + + override fun <T> deserialize(body: ResponseBody, type: Class<T>): T? = + body.byteStream().use { mapper.readerFor(type).readValue(it) } + + override fun <T> deserializeList(body: ResponseBody, type: Class<T>): List<T>? = + body.byteStream().use { mapper.readerForListOf(type).readValue(it) } +} diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/MockEntity.kt new file mode 100644 index 0000000..20d0f09 --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/MockEntity.kt @@ -0,0 +1,3 @@ +package net.typedrest + +data class MockEntity(val id: Long, val name: String) diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt new file mode 100644 index 0000000..a2cb676 --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt @@ -0,0 +1,13 @@ +package net.typedrest.endpoints + +import net.typedrest.serializers.JacksonJsonSerializer +import okhttp3.mockwebserver.* +import kotlin.test.* + +abstract class AbstractEndpointTest { + protected var server: MockWebServer = MockWebServer() + protected var entryEndpoint: EntryEndpoint = EntryEndpoint(server.url("/").toUri(), serializer = JacksonJsonSerializer()) + + @AfterTest + fun after() = server.close() +} diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt new file mode 100644 index 0000000..f8add89 --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt @@ -0,0 +1,81 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.* +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class CollectionEndpointTest : AbstractEndpointTest() { + private val endpoint = CollectionEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testGetByEntityWithLinkHeaderRelative() { + server.enqueue( + MockResponse() + .setJsonBody("[]") + .addHeader("Link", "<children/{id}>; rel=child; templated=true") + ) + + endpoint.readAll() + assertEquals(endpoint.uri.resolve("/children/1"), endpoint[MockEntity(1, "test")].uri) + } + + @Test + fun testReadAll() { + server.enqueue( + MockResponse() + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + ) + + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), endpoint.readAll()) + } + + @Test + fun testReadAllCache() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + ) + + val result1 = endpoint.readAll() + server.assertRequest(HttpMethod.GET) + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), result1) + + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.NotModified) + .setHeader("ETag", "\"123abc\"") + ) + val result2 = endpoint.readAll() + server.assertRequest(HttpMethod.GET).withHeader("If-None-Match", "\"123abc\"") + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), result2) + assertNotSame(result1, result2, message = "Should cache responses, not deserialized objects") + } + + @Test + fun testCreate() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.Created) + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + val element = endpoint.create(MockEntity(0, "test"))!! + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":0,"name":"test"}""") + assertEquals(MockEntity(5, "test"), element.response) + assertEquals(endpoint.uri.resolve("/endpoint/5"), element.uri) + } + + @Test + fun testCreateAll() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.Accepted)) + + endpoint.createAll(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + } +} diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt new file mode 100644 index 0000000..93b56c2 --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt @@ -0,0 +1,118 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.* +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ElementEndpointTest : AbstractEndpointTest() { + private val endpoint = ElementEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testRead() { + server.enqueue( + MockResponse() + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + assertEquals(MockEntity(5, "test"), endpoint.read()) + } + + @Test + fun testReadCustomMimeWithJsonSuffix() { + server.enqueue( + MockResponse() + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + assertEquals(MockEntity(5, "test"), endpoint.read()) + } + + @Test + fun testReadCacheETag() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""{"id":5,"name":"test"}""") + ) + val result1 = endpoint.read() + server.assertRequest(HttpMethod.GET) + assertEquals(MockEntity(5, "test"), result1) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NotModified)) + val result2 = endpoint.read() + server.assertRequest(HttpMethod.GET).withHeader("If-None-Match", "\"123abc\"") + assertEquals(MockEntity(5, "test"), result2) + assertNotSame(result1, result2, message = "Cache responses, not deserialized objects") + } + + @Test + fun testSetResult() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"testXXX"}""")) + + assertEquals(MockEntity(5, "testXXX"), endpoint.set(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testSetNoResult() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + assertNull(endpoint.set(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testSetETag() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""{"id":5,"name":"test"}""") + ) + val result = endpoint.read() + server.assertRequest(HttpMethod.GET) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + endpoint.set(result) + server.assertRequest(HttpMethod.PUT) + .withHeader("If-Match", "\"123abc\"") + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testUpdateRetry() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test1"}""").addHeader("ETag", "\"1\"")) + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.PreconditionFailed)) + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test2"}""").addHeader("ETag", "\"2\"")) + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + endpoint.update({ MockEntity(it.id, "testX") }) + + server.assertRequest(HttpMethod.GET) + server.assertRequest(HttpMethod.PUT).withJsonBody("""{"id":5,"name":"testX"}""").withHeader("If-Match", "\"1\"") + server.assertRequest(HttpMethod.GET) + server.assertRequest(HttpMethod.PUT).withJsonBody("""{"id":5,"name":"testX"}""").withHeader("If-Match", "\"2\"") + } + + @Test + fun testMergeResult() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"testXXX"}""")) + + assertEquals(MockEntity(5, "testXXX"), endpoint.merge(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testMergeNoResult() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + assertNull(endpoint.merge(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""{"id":5,"name":"test"}""") + } +} diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt new file mode 100644 index 0000000..c696e67 --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt @@ -0,0 +1,21 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ConsumerEndpointTest : AbstractEndpointTest() { + private val endpoint = ConsumerEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse()) + + endpoint.invoke(MockEntity(1, "input")) + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":1,"name":"input"}""") + } +} diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt new file mode 100644 index 0000000..0a690ba --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt @@ -0,0 +1,21 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class FunctionEndpointTest : AbstractEndpointTest() { + private val endpoint = FunctionEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java, MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse().setJsonBody("""{"id":2,"name":"input"}""")) + + assertEquals(MockEntity(2, "input"), endpoint.invoke(MockEntity(1, "input"))) + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":1,"name":"input"}""") + } +} diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt new file mode 100644 index 0000000..e5808be --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt @@ -0,0 +1,20 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ProducerEndpointTest : AbstractEndpointTest() { + private val endpoint = ProducerEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse().setJsonBody("""{"id":1,"name":"input"}""")) + + assertEquals(MockEntity(1, "input"), endpoint.invoke()) + server.assertRequest(HttpMethod.POST) + } +} diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt new file mode 100644 index 0000000..4eecc78 --- /dev/null +++ b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt @@ -0,0 +1,45 @@ +package net.typedrest.tests + +import net.typedrest.http.HttpMethod +import net.typedrest.http.HttpStatusCode +import okhttp3.Headers +import okhttp3.mockwebserver.* +import java.nio.charset.Charset +import kotlin.test.* + +const val contentTypeHeader = "Content-Type" + +fun MockResponse.setResponseCode(code: HttpStatusCode): MockResponse { + setResponseCode(code.code) + return this +} + +fun MockResponse.setJsonBody(json: String): MockResponse { + addHeader(contentTypeHeader, "application/json") + setBody(json) + return this +} + +fun MockWebServer.assertRequest(method: HttpMethod, path: String = "/endpoint"): RecordedRequest { + val request = takeRequest() + assertEquals(method.toString(), request.method) + assertEquals(path, request.requestUrl!!.encodedPath) + return request +} + +fun RecordedRequest.withBody(expectedBody: ByteArray, contentType: String): RecordedRequest { + assertEquals(contentType, getHeader(contentTypeHeader)) + assertContentEquals(expectedBody, body.readByteArray()) + return this +} + +fun RecordedRequest.withJsonBody(json: String): RecordedRequest { + assertEquals("application/json; charset=utf-8", getHeader(contentTypeHeader)) + assertEquals(json, body.readString(Charset.defaultCharset())) + return this +} + +fun RecordedRequest.withHeader(name: String, value: String): RecordedRequest { + assertEquals(value, getHeader(name)) + return this +} diff --git a/typedrest-serializers-moshi/build.gradle.kts b/typedrest-serializers-moshi/build.gradle.kts new file mode 100644 index 0000000..3fec738 --- /dev/null +++ b/typedrest-serializers-moshi/build.gradle.kts @@ -0,0 +1,13 @@ +kotlin.jvmToolchain(21) +tasks.test { useJUnitPlatform() } + +dependencies { + api(libs.okhttp3) + api(libs.moshi) + implementation(libs.moshi.kotlin) + api(project(":typedrest")) + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation(libs.okhttp3.mockwebserver) +} diff --git a/typedrest-serializers-moshi/src/main/kotlin/net/typedrest/serializers/MoshiJsonSerializer.kt b/typedrest-serializers-moshi/src/main/kotlin/net/typedrest/serializers/MoshiJsonSerializer.kt new file mode 100644 index 0000000..e7e21ab --- /dev/null +++ b/typedrest-serializers-moshi/src/main/kotlin/net/typedrest/serializers/MoshiJsonSerializer.kt @@ -0,0 +1,28 @@ +package net.typedrest.serializers + +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.* +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * Serializes and deserializes entities as JSON using Moshi. + * + * @param moshi The Moshi instance to use for serializing and deserializing. Uses default instance with reflection-based Kotlin support if unset. + */ +open class MoshiJsonSerializer @JvmOverloads constructor( + private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() +) : AbstractJsonSerializer() { + override fun <T> serialize(entity: T, type: Class<T>): RequestBody = + moshi.adapter(type).toJson(entity).toRequestBody(mediaTypeJson) + + override fun <T> serializeList(entities: Iterable<T>, type: Class<T>): RequestBody = + moshi.adapter<Iterable<T>>(Types.newParameterizedType(List::class.java, type)).toJson(entities.toList()).toRequestBody(mediaTypeJson) + + override fun <T> deserialize(body: ResponseBody, type: Class<T>): T? = + moshi.adapter(type).fromJson(body.source()) + + override fun <T> deserializeList(body: ResponseBody, type: Class<T>): List<T>? = + moshi.adapter<List<T>>(Types.newParameterizedType(List::class.java, type)).fromJson(body.source()) +} diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/MockEntity.kt new file mode 100644 index 0000000..20d0f09 --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/MockEntity.kt @@ -0,0 +1,3 @@ +package net.typedrest + +data class MockEntity(val id: Long, val name: String) diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt new file mode 100644 index 0000000..e436ecc --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt @@ -0,0 +1,13 @@ +package net.typedrest.endpoints + +import net.typedrest.serializers.MoshiJsonSerializer +import okhttp3.mockwebserver.* +import kotlin.test.* + +abstract class AbstractEndpointTest { + protected var server: MockWebServer = MockWebServer() + protected var entryEndpoint: EntryEndpoint = EntryEndpoint(server.url("/").toUri(), serializer = MoshiJsonSerializer()) + + @AfterTest + fun after() = server.close() +} diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt new file mode 100644 index 0000000..f8add89 --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt @@ -0,0 +1,81 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.* +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class CollectionEndpointTest : AbstractEndpointTest() { + private val endpoint = CollectionEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testGetByEntityWithLinkHeaderRelative() { + server.enqueue( + MockResponse() + .setJsonBody("[]") + .addHeader("Link", "<children/{id}>; rel=child; templated=true") + ) + + endpoint.readAll() + assertEquals(endpoint.uri.resolve("/children/1"), endpoint[MockEntity(1, "test")].uri) + } + + @Test + fun testReadAll() { + server.enqueue( + MockResponse() + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + ) + + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), endpoint.readAll()) + } + + @Test + fun testReadAllCache() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + ) + + val result1 = endpoint.readAll() + server.assertRequest(HttpMethod.GET) + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), result1) + + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.NotModified) + .setHeader("ETag", "\"123abc\"") + ) + val result2 = endpoint.readAll() + server.assertRequest(HttpMethod.GET).withHeader("If-None-Match", "\"123abc\"") + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), result2) + assertNotSame(result1, result2, message = "Should cache responses, not deserialized objects") + } + + @Test + fun testCreate() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.Created) + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + val element = endpoint.create(MockEntity(0, "test"))!! + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":0,"name":"test"}""") + assertEquals(MockEntity(5, "test"), element.response) + assertEquals(endpoint.uri.resolve("/endpoint/5"), element.uri) + } + + @Test + fun testCreateAll() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.Accepted)) + + endpoint.createAll(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + } +} diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt new file mode 100644 index 0000000..93b56c2 --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt @@ -0,0 +1,118 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.* +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ElementEndpointTest : AbstractEndpointTest() { + private val endpoint = ElementEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testRead() { + server.enqueue( + MockResponse() + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + assertEquals(MockEntity(5, "test"), endpoint.read()) + } + + @Test + fun testReadCustomMimeWithJsonSuffix() { + server.enqueue( + MockResponse() + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + assertEquals(MockEntity(5, "test"), endpoint.read()) + } + + @Test + fun testReadCacheETag() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""{"id":5,"name":"test"}""") + ) + val result1 = endpoint.read() + server.assertRequest(HttpMethod.GET) + assertEquals(MockEntity(5, "test"), result1) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NotModified)) + val result2 = endpoint.read() + server.assertRequest(HttpMethod.GET).withHeader("If-None-Match", "\"123abc\"") + assertEquals(MockEntity(5, "test"), result2) + assertNotSame(result1, result2, message = "Cache responses, not deserialized objects") + } + + @Test + fun testSetResult() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"testXXX"}""")) + + assertEquals(MockEntity(5, "testXXX"), endpoint.set(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testSetNoResult() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + assertNull(endpoint.set(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testSetETag() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""{"id":5,"name":"test"}""") + ) + val result = endpoint.read() + server.assertRequest(HttpMethod.GET) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + endpoint.set(result) + server.assertRequest(HttpMethod.PUT) + .withHeader("If-Match", "\"123abc\"") + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testUpdateRetry() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test1"}""").addHeader("ETag", "\"1\"")) + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.PreconditionFailed)) + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test2"}""").addHeader("ETag", "\"2\"")) + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + endpoint.update({ MockEntity(it.id, "testX") }) + + server.assertRequest(HttpMethod.GET) + server.assertRequest(HttpMethod.PUT).withJsonBody("""{"id":5,"name":"testX"}""").withHeader("If-Match", "\"1\"") + server.assertRequest(HttpMethod.GET) + server.assertRequest(HttpMethod.PUT).withJsonBody("""{"id":5,"name":"testX"}""").withHeader("If-Match", "\"2\"") + } + + @Test + fun testMergeResult() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"testXXX"}""")) + + assertEquals(MockEntity(5, "testXXX"), endpoint.merge(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testMergeNoResult() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + assertNull(endpoint.merge(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""{"id":5,"name":"test"}""") + } +} diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt new file mode 100644 index 0000000..c696e67 --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt @@ -0,0 +1,21 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ConsumerEndpointTest : AbstractEndpointTest() { + private val endpoint = ConsumerEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse()) + + endpoint.invoke(MockEntity(1, "input")) + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":1,"name":"input"}""") + } +} diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt new file mode 100644 index 0000000..0a690ba --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt @@ -0,0 +1,21 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class FunctionEndpointTest : AbstractEndpointTest() { + private val endpoint = FunctionEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java, MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse().setJsonBody("""{"id":2,"name":"input"}""")) + + assertEquals(MockEntity(2, "input"), endpoint.invoke(MockEntity(1, "input"))) + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":1,"name":"input"}""") + } +} diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt new file mode 100644 index 0000000..e5808be --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt @@ -0,0 +1,20 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.MockEntity +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ProducerEndpointTest : AbstractEndpointTest() { + private val endpoint = ProducerEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse().setJsonBody("""{"id":1,"name":"input"}""")) + + assertEquals(MockEntity(1, "input"), endpoint.invoke()) + server.assertRequest(HttpMethod.POST) + } +} diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt new file mode 100644 index 0000000..4eecc78 --- /dev/null +++ b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt @@ -0,0 +1,45 @@ +package net.typedrest.tests + +import net.typedrest.http.HttpMethod +import net.typedrest.http.HttpStatusCode +import okhttp3.Headers +import okhttp3.mockwebserver.* +import java.nio.charset.Charset +import kotlin.test.* + +const val contentTypeHeader = "Content-Type" + +fun MockResponse.setResponseCode(code: HttpStatusCode): MockResponse { + setResponseCode(code.code) + return this +} + +fun MockResponse.setJsonBody(json: String): MockResponse { + addHeader(contentTypeHeader, "application/json") + setBody(json) + return this +} + +fun MockWebServer.assertRequest(method: HttpMethod, path: String = "/endpoint"): RecordedRequest { + val request = takeRequest() + assertEquals(method.toString(), request.method) + assertEquals(path, request.requestUrl!!.encodedPath) + return request +} + +fun RecordedRequest.withBody(expectedBody: ByteArray, contentType: String): RecordedRequest { + assertEquals(contentType, getHeader(contentTypeHeader)) + assertContentEquals(expectedBody, body.readByteArray()) + return this +} + +fun RecordedRequest.withJsonBody(json: String): RecordedRequest { + assertEquals("application/json; charset=utf-8", getHeader(contentTypeHeader)) + assertEquals(json, body.readString(Charset.defaultCharset())) + return this +} + +fun RecordedRequest.withHeader(name: String, value: String): RecordedRequest { + assertEquals(value, getHeader(name)) + return this +} diff --git a/typedrest/build.gradle.kts b/typedrest/build.gradle.kts new file mode 100644 index 0000000..0eef394 --- /dev/null +++ b/typedrest/build.gradle.kts @@ -0,0 +1,12 @@ +kotlin.jvmToolchain(21) +tasks.test { useJUnitPlatform() } + +dependencies { + api(libs.okhttp3) + implementation(libs.uri.templates) + implementation(libs.kotlinx.serialization.json) + + testImplementation(kotlin("test")) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation(libs.okhttp3.mockwebserver) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/URIExtensions.kt b/typedrest/src/main/kotlin/net/typedrest/URIExtensions.kt new file mode 100644 index 0000000..19f1107 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/URIExtensions.kt @@ -0,0 +1,44 @@ +package net.typedrest + +import net.typedrest.http.HttpCredentials +import java.net.URI + +/** + * Adds a trailing slash to the URI if it does not already have one. + */ +fun URI.ensureTrailingSlash(): URI = + if (this.toString().endsWith("/")) { + this + } else { + URI("${this}/") + } + +/** + * Resolves a relative URI using this URI as the base. + * + * @param relativeUri The relative URI to resolve. Prepend "./" to imply a trailing slash in the base URI even if it is missing there. + */ +fun URI.join(relativeUri: String): URI = + if (relativeUri.startsWith("./")) { + this.ensureTrailingSlash() + } else { + this + }.resolve(relativeUri) + +/** + * Resolves a relative URI using this URI as the base. + * + * @param relativeUri The relative URI to resolve. Prepend "./" to imply a trailing slash in the base URI even if it is missing there. + */ +fun URI.join(relativeUri: URI): URI = + if (relativeUri.toString().startsWith("./")) { + this.ensureTrailingSlash() + } else { + this + }.resolve(relativeUri) + +/** + * Extracts credentials from user info in URI if set. + */ +fun URI.extractCredentials(): HttpCredentials? = + this.userInfo?.split(':')?.let { (username, password) -> HttpCredentials(username, password) } diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/AbstractEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/AbstractEndpoint.kt new file mode 100644 index 0000000..bb4ced3 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/AbstractEndpoint.kt @@ -0,0 +1,233 @@ +package net.typedrest.endpoints + +import com.damnhandy.uri.template.UriTemplate +import net.typedrest.errors.* +import net.typedrest.http.HttpMethod +import net.typedrest.join +import net.typedrest.links.* +import net.typedrest.serializers.Serializer +import okhttp3.* +import java.net.URI +import kotlin.collections.List + +/** + * Base class for building endpoints, i.e., remote HTTP resources. + * + * @param uri The HTTP URI of the remote element. + * @param httpClient The HTTP client used to communicate with the remote element. + * @param serializers A list of serializers used for entities received from the server, sorted from most to least preferred. Always uses first for sending to the server. + * @param errorHandler Handles errors in HTTP responses. + * @param linkExtractor Detects links in HTTP responses. + */ +abstract class AbstractEndpoint( + override val uri: URI, + override val httpClient: OkHttpClient, + override val serializers: List<Serializer>, + override val errorHandler: ErrorHandler, + override val linkExtractor: LinkExtractor +) : Endpoint { + /** + * Creates a new endpoint with a relative URI. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. + */ + constructor(referrer: Endpoint, relativeUri: URI) : this( + referrer.uri.join(relativeUri), + referrer.httpClient, + referrer.serializers, + referrer.errorHandler, + referrer.linkExtractor + ) + + /** + * Executes a request and handles various cross-cutting concerns regarding a response message such as discovering links and handling errors. + * + * @param request A callback that performs the actual HTTP request. + * @return The HTTP response. + */ + protected open fun execute(request: Request): Response { + val response = httpClient.newCall(request).execute() + + links = linkExtractor.getLinks(response) + handleCapabilities(response) + errorHandler.handle(response) + + return response + } + + /** + * Handles various cross-cutting concerns regarding a response message such as discovering links and handling errors. + * + * @param response The HTTP response. + * @return The HTTP response. + */ + protected open fun handle(response: Response): Response { + links = linkExtractor.getLinks(response) + handleCapabilities(response) + errorHandler.handle(response) + + return response + } + + // NOTE: Always replace entire list rather than modifying it to ensure thread-safety. + private var links: List<Link> = emptyList() + + // NOTE: Only modified during initial setup of the endpoint. + private val defaultLinks: MutableMap<String, URI> = mutableMapOf() + private val defaultLinkTemplates: MutableMap<String, UriTemplate> = mutableMapOf() + + /** + * Registers one or more default links for a specific relation type. + * + * These links are used when no links with this relation type are provided by the server. + * This should only be called during initial setup of the endpoint. + * + * @param rel The relation type of the link to add. + * @param href The href of the link relative to this endpoint's URI. Use null to remove any previous entries for the relation type. + */ + fun setDefaultLink(rel: String, href: String?) { + if (href.isNullOrEmpty()) defaultLinks.remove(rel) + else defaultLinks[rel] = uri.join(href) + } + + /** + * Registers a default link template for a specific relation type. + * + * This template is used when no template with this relation type is provided by the server. + * This should only be called during initial setup of the endpoint. + * + * @param rel The relation type of the link template to add. + * @param href The href of the link template relative to this endpoint's URI. Use null to remove any previous entry for the relation type. + */ + fun setDefaultLinkTemplate(rel: String, href: String?) { + if (href.isNullOrEmpty()) defaultLinkTemplates.remove(rel) + else defaultLinkTemplates[rel] = UriTemplate.fromTemplate(href) + } + + override fun getLinks(rel: String): List<Pair<URI, String?>> = links + .filter { !it.templated && it.rel == rel } + .map { uri.join(it.href) to it.title } + .toList() + .ifEmpty { + defaultLinks[rel]?.let { listOf(it to null) } ?: listOf() + } + + override fun link(rel: String): URI { + val foundLinks = getLinks(rel) + if (foundLinks.isEmpty()) { + // Lazy lookup + try { + httpClient.newCall(Request.Builder().head().url(uri.toURL()).build()).execute() + } catch (ex: Exception) { + throw IllegalStateException("No link with rel=$rel provided by endpoint $uri.") + } + + return getLinks(rel).firstOrNull()?.first + ?: throw IllegalStateException("No link with rel=$rel provided by endpoint $uri.") + } + + return foundLinks.first().first + } + + override fun linkTemplate(rel: String, variables: Map<String, Any>): URI = + uri.join(getLinkTemplate(rel).set(variables).expand()) + + /** + * Retrieves a link template with a specific relation type. + * + * @param rel The relation type of the link template to look for. + * @return The unresolved link template. + * @throws NotFoundException if no link template with the specified rel could be found. + */ + fun getLinkTemplate(rel: String): UriTemplate = links + .filter { it.templated && it.rel == rel } + .map { UriTemplate.fromTemplate(it.href) } + .firstOrNull() + ?: defaultLinkTemplates[rel] + // Lazy lookup + ?: try { + httpClient.newCall(Request.Builder().head().url(uri.toURL()).build()).execute() + links + .filter { it.templated && it.rel == rel } + .map { UriTemplate.fromTemplate(it.href) } + .firstOrNull() + ?: throw NotFoundException("No link template with rel=$rel provided by endpoint $uri.") + } catch (ex: Exception) { + throw NotFoundException("No link template with rel=$rel provided by endpoint $uri.") + } + + /** + * Handles allowed HTTP methods and other capabilities reported by the server. + */ + protected open fun handleCapabilities(response: Response) { + val allowedMethodsHeader = response.headers("Allow") + if (allowedMethodsHeader.isNotEmpty()) { + allowedMethods = allowedMethodsHeader + .flatMap { it.split(", ") } + .mapNotNull { HttpMethod.parse(it) } + .toSet() + } + } + + // NOTE: Always replace entire set rather than modifying it to ensure thread-safety. + private var allowedMethods: Set<HttpMethod> = setOf() + + /** + * Shows whether the server has indicated that a specific HTTP method is currently allowed. + * + * @param method The HTTP methods (e.g., GET, POST, ...) to check. + * @return true if the method is allowed, false< if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods + */ + protected fun isMethodAllowed(method: HttpMethod): Boolean? = + if (allowedMethods.isEmpty()) null + else allowedMethods.contains(method) + + /** + * Serializes an entity using the first serializer. + * + * @param entity The entity to serialize. + * @param T The type of entity to serialize. + * @return The serialized entity as a request body. + */ + fun <T> serialize(entity: T, type: Class<T>): RequestBody = + serializers.first().serialize(entity, type) + + /** + * Serializes a list of entities using the first serializer. + * + * @param entities The entities to serialize. + * @param T The type of entity to serialize. + * @return The serialized entity as a request body. + */ + fun <T> serializeList(entities: Iterable<T>, type: Class<T>): RequestBody = + serializers.first().serializeList(entities, type) + + /** + * Deserializes an entity using the first serializer that supports the body's content type. + * + * @param body The request body to deserialize into an entity. + * @param type The type of entity to deserialize. + * @param T The type of entity to deserialize. + * @return The deserialized response body as an entity. null if the body could not be deserialized. + */ + fun <T> deserialize(body: ResponseBody, type: Class<T>): T? = + getSerializer(body).deserialize(body, type) + + /** + * Deserializes a list of entities using the first serializer that supports the body's content type. + * + * @param body The request body to deserialize into an entity. + * @param type The type of entity to deserialize. + * @param T The type of entity to deserialize. + * @return The deserialized response body as a list of entities. null if the body could not be deserialized. + */ + fun <T> deserializeList(body: ResponseBody, type: Class<T>): List<T>? = + getSerializer(body).deserializeList(body, type) + + private fun getSerializer(body: ResponseBody): Serializer { + val mediaType = body.contentType() ?: throw IllegalArgumentException("Response body has no media type") + return serializers.find { it.supportedMediaTypes.contains(mediaType) } + ?: throw IllegalArgumentException("No serializer found for media type: $mediaType") + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/CachingEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/CachingEndpoint.kt new file mode 100644 index 0000000..bd3ffe5 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/CachingEndpoint.kt @@ -0,0 +1,13 @@ +package net.typedrest.endpoints + +import net.typedrest.http.ResponseCache + +/** + * Endpoint that caches the last response. + */ +interface CachingEndpoint { + /** + * A cached copy of the last response. + */ + var responseCache: ResponseCache? +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/Endpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/Endpoint.kt new file mode 100644 index 0000000..859529b --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/Endpoint.kt @@ -0,0 +1,73 @@ +package net.typedrest.endpoints + +import net.typedrest.errors.ErrorHandler +import net.typedrest.errors.NotFoundException +import net.typedrest.links.LinkExtractor +import net.typedrest.serializers.Serializer +import okhttp3.OkHttpClient +import java.net.URI +import kotlin.collections.List + +/** + * Represents an endpoint, i.e., a remote HTTP resource. + */ +interface Endpoint { + /** + * The HTTP URI of the remote resource. + */ + val uri: URI + + /** + * The HTTP client used to communicate with the remote resource. + */ + val httpClient: OkHttpClient + + /** + * A list of serializers used for entities received from the server, + * sorted from most to least preferred. Always uses first for sending to the server. + */ + val serializers: List<Serializer> + + /** + * Handles errors in responses. + */ + val errorHandler: ErrorHandler + + /** + * Extracts links from responses. + */ + val linkExtractor: LinkExtractor + + /** + * Resolves all links with a specific relation type. Uses cached data from last response. + * + * @param rel The relation type of the links to look for. + * @return A list of pairs of URI and optional title. + */ + fun getLinks(rel: String): List<Pair<URI, String?>> + + /** + * Resolves a single link with a specific relation type. Uses cached data from last response if possible. + * + * Tries lazy lookup with HTTP HEAD on cache miss. + * + * @param rel The relation type of the link to look for. + * @throws NotFoundException if no link with the specified relation could be found. + * @return The URI of the link. + */ + @Throws(NotFoundException::class) + fun link(rel: String): URI + + /** + * Resolves a link template with a specific relation type. Uses cached data from last response if possible. + * + * Tries lazy lookup with HTTP HEAD on cache miss. + * + * @param rel The relation type of the link template to look for. + * @param variables Variables for resolving the template. + * @throws NotFoundException if no link template with the specified relation could be found. + * @return The href of the link resolved relative to this endpoint's URI. + */ + @Throws(NotFoundException::class) + fun linkTemplate(rel: String, variables: Map<String, Any>): URI +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/EntryEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/EntryEndpoint.kt new file mode 100644 index 0000000..c9d3ca5 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/EntryEndpoint.kt @@ -0,0 +1,85 @@ +package net.typedrest.endpoints + +import net.typedrest.errors.* +import net.typedrest.extractCredentials +import net.typedrest.http.* +import net.typedrest.links.* +import net.typedrest.serializers.* +import okhttp3.* +import java.net.URI + +/** + * Represents the top-level URI of an API. Derive from this open class and add your own set of child [Endpoint]s as properties. + */ +open class EntryEndpoint : AbstractEndpoint { + /** + * Creates a new entry endpoint. + * + * @param uri The base URI of the REST API. Missing trailing slash will be appended automatically. + * @param httpClient The HTTP client used to communicate with the REST API. + * @param serializers A list of serializers used for entities received from the server, sorted from most to least preferred. Always uses first for sending to the server. + * @param errorHandler Handles errors in HTTP responses. + * @param linkExtractor Detects links in HTTP responses. + */ + @JvmOverloads + constructor( + uri: URI, + httpClient: OkHttpClient, + serializers: List<Serializer>, + errorHandler: ErrorHandler = DefaultErrorHandler(), + linkExtractor: LinkExtractor = AggregateLinkExtractor(HeaderLinkExtractor(), HalLinkExtractor()) + ) : super(uri, httpClient, serializers, errorHandler, linkExtractor) + + /** + * Creates a new entry endpoint. + * + * @param uri The base URI of the REST API. Missing trailing slash will be appended automatically. + * @param httpClient The HTTP client used to communicate with the REST API. + * @param serializer The serializer used for entities sent to and received from the server. + * @param errorHandler Handles errors in HTTP responses. + * @param linkExtractor Detects links in HTTP responses. + */ + @JvmOverloads + constructor( + uri: URI, + httpClient: OkHttpClient, + serializer: Serializer = KotlinxJsonSerializer(), + errorHandler: ErrorHandler = DefaultErrorHandler(), + linkExtractor: LinkExtractor = AggregateLinkExtractor(HeaderLinkExtractor(), HalLinkExtractor()) + ) : super(uri, httpClient, listOf(serializer), errorHandler, linkExtractor) + + /** + * Creates a new entry endpoint. + * + * @param uri The base URI of the REST API. + * @param credentials Optional HTTP Basic authentication credentials used to authenticate against the REST API. + * @param serializer The serializer used for entities sent to and received from the server. + * @param errorHandler Handles errors in HTTP responses. + * @param linkExtractor Detects links in HTTP responses. + */ + @JvmOverloads + constructor( + uri: URI, + credentials: HttpCredentials? = null, + serializer: Serializer = KotlinxJsonSerializer(), + errorHandler: ErrorHandler = DefaultErrorHandler(), + linkExtractor: LinkExtractor = AggregateLinkExtractor(HeaderLinkExtractor(), HalLinkExtractor()) + ) : super( + uri, + OkHttpClient().withBasicAuth(credentials ?: uri.extractCredentials()), + listOf(serializer), + errorHandler, + linkExtractor + ) + + /** + * Fetches metadata such as links from the server. + * + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun readMeta() = + execute(Request.Builder().options().uri(uri).build()).close() +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/_doc.md b/typedrest/src/main/kotlin/net/typedrest/endpoints/_doc.md new file mode 100644 index 0000000..d42d023 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/_doc.md @@ -0,0 +1,5 @@ +# Package net.typedrest.endpoints + +Endpoints represent URIs that provides methods for operating on specific resources. + +See [documentation](https://typedrest.net/endpoints/). diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/AbstractCachingEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/AbstractCachingEndpoint.kt new file mode 100644 index 0000000..f7c8fcd --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/AbstractCachingEndpoint.kt @@ -0,0 +1,80 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.* +import net.typedrest.errors.* +import net.typedrest.http.* +import okhttp3.* +import java.net.URI + +/** + * Base class for building endpoints that use ETags and Last-Modified timestamps for caching and to avoid lost updates. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. + */ +abstract class AbstractCachingEndpoint(referrer: Endpoint, relativeUri: URI) : + AbstractEndpoint(referrer, relativeUri), CachingEndpoint { + override var responseCache: ResponseCache? = null + + /** + * Performs an HTTP GET request on the [Endpoint.uri] and caches the response if the server sends an ETag. + * + * Sends If-None-Match header if there is already a cached ETag. + * + * @return The response of the request or the cached response if the server responded with [HttpStatusCode.NotModified]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + protected fun getContent(): ResponseBody? { + val cache = responseCache // Copy reference for thread-safety + val headers = responseCache?.ifModifiedHeaders() ?: Headers.Builder().build() + val response = httpClient.newCall(Request.Builder().get().uri(uri).headers(headers).build()).execute() + + return if (response.code == HttpStatusCode.NotModified.code && cache != null && !cache.isExpired) + cache.getBody() + else { + responseCache = ResponseCache.from(handle(response)) + responseCache?.getBody() + } + } + + /** + * Performs an [HttpMethod.PUT] request on the [Endpoint.uri]. + * + * Sends If-Match header if there is a cached ETag to detect lost updates. + * + * @param content The content to send to the server. + * @return The response message. + * @throws ConflictException The content has changed since it was last retrieved with [getContent]. Your changes were rejected to prevent a lost update. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + protected fun putContent(content: RequestBody): Response { + val headers = responseCache?.ifUnmodifiedHeaders() ?: Headers.Builder().build() + responseCache = null + return execute(Request.Builder().put(content).uri(uri).headers(headers).build()) + } + + /** + * Performs an [HttpMethod.DELETE] request on the [Endpoint.uri]. + * + * Sends If-Match header if there is a cached ETag to detect lost updates. + * + * @throws ConflictException The content has changed since it was last retrieved with [getContent]. Your changes were rejected to prevent a lost update. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + protected fun deleteContent() { + val headers = responseCache?.ifUnmodifiedHeaders() ?: Headers.Builder().build() + responseCache = null + execute(Request.Builder().delete().uri(uri).headers(headers).build()).close() + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpoint.kt new file mode 100644 index 0000000..c44cbfa --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpoint.kt @@ -0,0 +1,10 @@ +package net.typedrest.endpoints.generic + +/** + * Endpoint for a collection of [TEntity]s addressable as [ElementEndpoint]s. + * + * Use [GenericCollectionEndpoint] instead if you wish to customize the element endpoint type. + * + * @param TEntity The type of individual elements in the collection. + */ +interface CollectionEndpoint<TEntity> : GenericCollectionEndpoint<TEntity, ElementEndpoint<TEntity>> diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointImpl.kt new file mode 100644 index 0000000..64d3bbe --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointImpl.kt @@ -0,0 +1,33 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.Endpoint +import java.net.URI + +/** + * Endpoint for a collection of [TEntity]s addressable as [ElementEndpoint]s. + * + * Use [GenericCollectionEndpointImpl] instead if you wish to customize the element endpoint type. + * + * @param TEntity The type of individual elements in the collection. + */ +open class CollectionEndpointImpl<TEntity> : GenericCollectionEndpointImpl<TEntity, ElementEndpoint<TEntity>>, CollectionEndpoint<TEntity> { + /** + * Creates a new element collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. + * @param entityType The type of individual elements in the collection. + */ + constructor(referrer: Endpoint, relativeUri: URI, entityType: Class<TEntity>) + : super(referrer, relativeUri, entityType, { ref, uri -> ElementEndpointImpl(ref, uri, entityType) }) + + /** + * Creates a new element collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param entityType The type of individual elements in the collection. + */ + constructor(referrer: Endpoint, relativeUri: String, entityType: Class<TEntity>) + : super(referrer, relativeUri, entityType, { ref, uri -> ElementEndpointImpl(ref, uri, entityType) }) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpoint.kt new file mode 100644 index 0000000..bbd0a87 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpoint.kt @@ -0,0 +1,118 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.Endpoint +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode + +/** + * Endpoint for an individual resource. + * + * @param TEntity The type of entity the endpoint represents. + */ +interface ElementEndpoint<TEntity> : Endpoint { + /** + * Determines whether the element currently exists. + * + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws HttpException for other non-success status codes. + */ + fun exists(): Boolean + + /** + * A cached copy of the entity as received from the server. + */ + val response: TEntity? + + /** + * Returns the entity. + * + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun read(): TEntity + + /** + * Shows whether the server has indicated that [set] is currently allowed. + * + * Uses cached data from last response. + * + * @return true if the method is allowed, false if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods. + */ + val isSetAllowed: Boolean? + + /** + * Sets/replaces the entity. + * + * @param entity The new entity. + * @return The entity as returned by the server, possibly with additional fields set. null if the server does not respond with a result entity. + * @throws ConflictException when the entity has changed since it was last retrieved with [read]. Your changes were rejected to prevent a lost update. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun set(entity: TEntity): TEntity? + + /** + * Shows whether the server has indicated that [merge] is currently allowed. + * + * Uses cached data from last response. + * + * @return true if the method is allowed, false if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods. + */ + val isMergeAllowed: Boolean? + + /** + * Modifies an existing entity by merging changes on the server-side. + * + * @param entity The entity data to merge with the existing one. + * @return The modified entity as returned by the server, possibly with additional fields set. null if the server does not respond with a result entity. + * @throws ConflictException when the entity has changed since it was last retrieved with [read]. Your changes were rejected to prevent a lost update. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun merge(entity: TEntity): TEntity? + + /** + * Reads the current state of the entity, applies a change to it and stores the result. Applies optimistic concurrency using automatic retries. + * + * @param updateAction A callback that takes the current state of the entity and returns it with the desired modifications applied. + * @param maxRetries The maximum number of retries to perform for optimistic concurrency before giving up. + * @return The entity as returned by the server, possibly with additional fields set. null if the server does not respond with a result entity. + * @throws ConflictException The number of retries performed for optimistic concurrency exceeded [maxRetries]. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun update(updateAction: (TEntity) -> TEntity, maxRetries: Int = 3): TEntity? + + /** + * Shows whether the server has indicated that [delete] is currently allowed. + * + * Uses cached data from last response. + * + * @return true if the method is allowed, false if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods. + */ + val isDeleteAllowed: Boolean? + + /** + * Deletes the element. + * + * @throws ConflictException when the entity has changed since it was last retrieved with [read]. Your delete call was rejected to prevent a lost update. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun delete() +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpointImpl.kt new file mode 100644 index 0000000..ef51ae8 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpointImpl.kt @@ -0,0 +1,96 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.* +import net.typedrest.errors.* +import net.typedrest.http.* +import okhttp3.* +import java.net.URI + +/** + * Endpoint for an individual resource. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. + * @param entityType The type of entity the endpoint represents. + * @param TEntity The type of entity the endpoint represents. + */ +open class ElementEndpointImpl<TEntity>( + referrer: Endpoint, + relativeUri: URI, + private val entityType: Class<TEntity> +) : AbstractCachingEndpoint(referrer, relativeUri), ElementEndpoint<TEntity> { + /** + * Creates a new element endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param entityType The type of entity the endpoint represents. + */ + constructor(referrer: Endpoint, relativeUri: String, entityType: Class<TEntity>) : + this(referrer, URI(relativeUri), entityType) + + override val response: TEntity? + get() = responseCache?.getBody()?.let { deserialize(it, entityType) } + + override fun read(): TEntity = + getContent()?.let { deserialize(it, entityType) } + ?: throw NotFoundException("Result not deserializable as ${entityType.simpleName}") + + override fun exists(): Boolean = + httpClient.newCall(Request.Builder().head().uri(uri).build()).execute().use { response -> + when { + response.isSuccessful -> true + response.code == HttpStatusCode.NotFound.code || response.code == HttpStatusCode.Gone.code -> false + else -> { + errorHandler.handle(response) + false + } + } + } + + override val isSetAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.PUT) + + override fun set(entity: TEntity): TEntity? = + tryReadAs(putContent(serialize(entity, entityType))) + + override val isMergeAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.PATCH) + + override fun merge(entity: TEntity): TEntity? { + responseCache = null + return tryReadAs(execute(Request.Builder().patch(serialize(entity, entityType)).uri(uri).build())) + } + + override val isDeleteAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.DELETE) + + override fun delete() = deleteContent() + + override fun update(updateAction: (TEntity) -> TEntity, maxRetries: Int): TEntity? { + var retryCounter = 0 + while (true) { + val entity = updateAction(read()) + + try { + return set(entity) + } catch (ex: HttpException) { + if (retryCounter++ >= maxRetries) throw ex + ex.retryAfter?.let(Thread::sleep) + } + } + } + + private fun tryReadAs(response: Response): TEntity? { + val body = response.body + if (body == null || response.code == HttpStatusCode.NoContent.code) { + return null + } + + return try { + deserialize(body, entityType) + } catch (ex: Exception) { + null + } + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpoint.kt new file mode 100644 index 0000000..e7c5554 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpoint.kt @@ -0,0 +1,132 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.errors.ConflictException +import net.typedrest.http.* +import net.typedrest.errors.* + +/** + * Endpoint for a collection of [TEntity]s addressable as [TElementEndpoint]s. + * + * Use the more constrained [CollectionEndpoint] when possible. + * + * @param TEntity The type of individual elements in the collection. + * @param TElementEndpoint The type of [ElementEndpoint] to provide for individual [TEntity]s. + */ +interface GenericCollectionEndpoint<TEntity, out TElementEndpoint : ElementEndpoint<TEntity>> { + /** + * Returns an [ElementEndpoint] for a specific child element. + * + * @param entity An existing entity to extract the ID from. + * @return The [TElementEndpoint] for the specified entity. + */ + operator fun get(entity: TEntity): TElementEndpoint + + /** + * Shows whether the server has indicated that [readAll] is currently allowed. + * + * Uses cached data from last response. + * + * @return true if the method is allowed, false if the method is not allowed, null If no request has been sent yet or the server did not specify allowed methods. + */ + val readAllAllowed: Boolean? + + /** + * Returns all entities in the collection. + * + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + * @return The list of all [TEntity]. + */ + fun readAll(): List<TEntity> + + /** + * Shows whether the server has indicated that [readRange] is allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has been sent yet. + */ + val readRangeAllowed: Boolean? + + /** + * Returns all entities within a specific range of the collection. + * + * @param from The position at which to start sending data. + * @param to The position at which to stop sending data. + * @return A [PartialResponse] containing a subset of the entities and the range they come from. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws ConflictException if the requested range is not satisfiable. + * @throws HttpException for other non-success status codes. + */ + fun readRange(from: Long?, to: Long?): PartialResponse<TEntity> + + /** + * Shows whether the server has indicated that [create] is currently allowed. + * + * Uses cached data from last response. + * + * @return true if the method is allowed, false if the method is not allowed, null If no request has been sent yet or the server did not specify allowed methods. + */ + val createAllowed: Boolean? + + /** + * Adds an entity as a new element to the collection. + * + * @param entity The new entity. + * @return An endpoint for the newly created entity; `null` if the server returned neither a "Location" header nor an entity with an ID in the response body. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws ConflictException for when the server responds with [HttpStatusCode.Conflict]. + * @throws HttpException for other non-success status codes. + */ + fun create(entity: TEntity): TElementEndpoint? + + /** + * Shows whether the server has indicated that [createAll] is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the verb is allowed. If no request has been sent yet or the server did not specify allowed verbs `null` is returned. + */ + val createAllAllowed: Boolean? + + /** + * Adds (or updates) multiple entities as elements in the collection. + * + * Uses a link with the relation type `bulk` to determine the URI to POST to. Defaults to the relative URI `bulk`. + * + * @param entities The entities to create or modify. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws ConflictException for when the server responds with [HttpStatusCode.Conflict]. + * @throws HttpException for other non-success status codes. + */ + fun createAll(entities: Iterable<TEntity>) + + /** + * Shows whether the server has indicated that [setAll] is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the verb is allowed. If no request has been sent yet or the server did not specify allowed verbs `null` is returned. + */ + val setAllAllowed: Boolean? + + /** + * Replaces the entire content of the collection with new entities. + * + * @param entities The new set of entities the collection shall contain. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws ConflictException when the entity has changed since it was last retrieved with [readAll]. Your changes were rejected to prevent a lost update. + * @throws HttpException for other non-success status codes. + */ + fun setAll(entities: Iterable<TEntity>) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpointImpl.kt new file mode 100644 index 0000000..62ea42e --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpointImpl.kt @@ -0,0 +1,123 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.* +import net.typedrest.errors.NotFoundException +import net.typedrest.http.* +import okhttp3.* +import java.net.URI +import java.net.URLEncoder + +/** + * Endpoint for a collection of [TEntity]s addressable as [TElementEndpoint]s. + * + * Use the more constrained [CollectionEndpointImpl] when possible. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. + * @param entityType The type of individual elements in the collection. + * @param elementEndpointFactory The factory for constructing [TElementEndpoint]s to provide for individual elements. + * @param TEntity The type of individual elements in the collection. + * @param TElementEndpoint The type of [ElementEndpoint] to provide for individual [TEntity]s. + */ +open class GenericCollectionEndpointImpl<TEntity, TElementEndpoint : ElementEndpoint<TEntity>>( + referrer: Endpoint, + relativeUri: URI, + private val entityType: Class<TEntity>, + private val elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint +) : AbstractCachingEndpoint(referrer, relativeUri), GenericCollectionEndpoint<TEntity, TElementEndpoint> { + /** + * Creates a new element collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param entityType The type of individual elements in the collection. + * @param elementEndpointFactory The factory for constructing [TElementEndpoint]s to provide for individual elements. + */ + constructor(referrer: Endpoint, relativeUri: String, entityType: Class<TEntity>, elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint) : + this(referrer, URI(relativeUri), entityType, elementEndpointFactory) + + init { + setDefaultLinkTemplate("child", "./{id}") + } + + operator fun get(id: String): TElementEndpoint { + return elementEndpointFactory(this, linkTemplate("child", mapOf("id" to URLEncoder.encode(id, "UTF-8")))) + } + + override operator fun get(entity: TEntity): TElementEndpoint = + get( + tryGetId(entity) ?: throw IllegalStateException("${entityType.simpleName} has no property named id.") + ) + + override val readAllAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.GET) + + override fun readAll(): List<TEntity> = + getContent()?.let { deserializeList(it, entityType) } + ?: throw NotFoundException("Result not deserializable as List<${entityType.simpleName}>") + + /** + * The value used for [HttpContentRangeHeader.unit]. + */ + var rangeUnit: String = "elements" + + override fun handleCapabilities(response: Response) { + super.handleCapabilities(response) + readRangeAllowed = response.headers("Accept-Ranges").contains(rangeUnit) + } + + override var readRangeAllowed: Boolean? = null + + override fun readRange(from: Long?, to: Long?): PartialResponse<TEntity> = + execute(Request.Builder().get().uri(uri).header("Range", "${rangeUnit}=${from ?: ""}-${to ?: ""}").build()) + .use { response -> + PartialResponse( + response.body?.let { deserializeList(it, entityType) } ?: throw NotFoundException("Result not deserializable as List<${entityType.simpleName}>"), + HttpContentRangeHeader.parse(response.headers) + ) + } + + override val createAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.POST) + + override fun create(entity: TEntity): TElementEndpoint? { + val response = execute(Request.Builder().post(serialize(entity, entityType)).uri(uri).build()) + val responseCache = ResponseCache.from(response) + + val location = response.header("Location") + val elementEndpoint = if (location != null) { + // Explicit element endpoint URL from "Location" header + elementEndpointFactory(this, URI(location)) + } else { + // Infer URL from entity ID in response body + responseCache + ?.getBody() + ?.let { deserialize(it, entityType) } + ?.let(::tryGetId) + ?.let(::get) + } + + if (elementEndpoint is CachingEndpoint) { + elementEndpoint.responseCache = responseCache + } + + return elementEndpoint + } + + private fun tryGetId(entity: TEntity) = + entityType.methods.firstOrNull { it.name.lowercase() == "getid" } + ?.invoke(entity)?.toString() + + override val createAllAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.PATCH) + + override fun createAll(entities: Iterable<TEntity>) = + execute(Request.Builder().patch(serializeList(entities, entityType)).uri(uri).build()).close() + + override val setAllAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.PUT) + + override fun setAll(entities: Iterable<TEntity>) { + putContent(serializeList(entities, entityType)) + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpoint.kt new file mode 100644 index 0000000..f03e301 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpoint.kt @@ -0,0 +1,17 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.Endpoint + +/** + * Endpoint that addresses child [TElementEndpoint]s by ID. + * + * @param TElementEndpoint The type of [Endpoint] to provide for individual elements. + */ +interface IndexerEndpoint<out TElementEndpoint : Endpoint> : Endpoint { + /** + * Returns an element endpoint for a specific child element. + * + * @param id The ID identifying the entity. + */ + operator fun get(id: String): TElementEndpoint +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpointImpl.kt new file mode 100644 index 0000000..5994602 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpointImpl.kt @@ -0,0 +1,38 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.endpoints.Endpoint +import net.typedrest.endpoints.AbstractEndpoint +import java.net.URI +import java.net.URLEncoder + +/** + * Endpoint that addresses child [TElementEndpoint]s by ID. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param elementEndpointFactory The factory for constructing [TElementEndpoint]s to provide for individual elements. + * @param TElementEndpoint The type of [Endpoint] to provide for individual elements. + */ +open class IndexerEndpointImpl<TElementEndpoint : Endpoint>( + referrer: Endpoint, + relativeUri: URI, + private val elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint +) : AbstractEndpoint(referrer, relativeUri), IndexerEndpoint<TElementEndpoint> { + /** + * Creates a new indexer endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param elementEndpointFactory The factory for constructing [TElementEndpoint]s to provide for individual elements. + */ + constructor(referrer: Endpoint, relativeUri: String, elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint) : + this(referrer, URI(relativeUri), elementEndpointFactory) + + init { + setDefaultLinkTemplate(rel = "child", href = "./{id}") + } + + override operator fun get(id: String): TElementEndpoint = + if (id.isNotEmpty()) elementEndpointFactory(this, linkTemplate("child", mapOf("id" to URLEncoder.encode(id, "UTF-8")))) + else throw IllegalArgumentException("ID must not be null or empty") +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/_doc.md b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/_doc.md new file mode 100644 index 0000000..141d75a --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/_doc.md @@ -0,0 +1,3 @@ +# Package net.typedrest.endpoints.generic + +Generic endpoints allow you to model collections and elements. diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpoint.kt new file mode 100644 index 0000000..34fa8b9 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpoint.kt @@ -0,0 +1,115 @@ +package net.typedrest.endpoints.raw + +import java.io.InputStream +import net.typedrest.endpoints.* +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode +import java.io.* + +/** + * Endpoint for a binary blob that can be downloaded or uploaded. + */ +interface BlobEndpoint : Endpoint { + /** + * Queries the server about capabilities of the endpoint without performing any action. + * + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun probe() + + /** + * Indicates whether the server has specified that downloading is currently allowed. + * + * Uses cached data from the last response. + * + * @return true if the method is allowed, false if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods. + */ + val isDownloadAllowed: Boolean? + + /** + * Downloads the blob's content to an input stream. + * + * @return An input stream with the blob's content. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun download(): InputStream + + /** + * Downloads the blob's content to a file. + * + * @param path The path of the file to read the upload data from. + * @throws IOException when the file at the specified path can not be written. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws HttpException for other non-success status codes. + */ + fun download(path: String) = + FileOutputStream(path).use { fileStream -> + download().use { downloadStream -> + downloadStream.copyTo(fileStream) + } + } + + /** + * Indicates whether the server has specified that uploading is currently allowed. + * + * Uses cached data from the last response. + * + * @return true if the method is allowed, false if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods. + */ + val isUploadAllowed: Boolean? + + /** + * Uploads data as the blob's content from an input stream. + * + * @param stream The input stream to read the upload data from. + * @param mimeType The MIME type of the data to upload, nullable. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws HttpException for other non-success status codes. + */ + fun uploadFrom(stream: InputStream, mimeType: String? = null) + + /** + * Uploads content as the blob's content from a file. + * + * @param path The path of the file to read the upload data from. + * @param mimeType The MIME type of the data to upload, nullable. + * @throws IOException when the file at the specified path can not be read. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws HttpException for other non-success status codes. + */ + fun uploadFrom(path: String, mimeType: String? = null) = + FileInputStream(path).use { fileStream -> + uploadFrom(fileStream, mimeType) + } + + /** + * Indicates whether the server has specified that deleting is currently allowed. + * + * Uses cached data from the last response. + * + * @return true if the method is allowed, false if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods. + */ + val isDeleteAllowed: Boolean? + + /** + * Deletes the blob from the server. + * + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun delete() +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpointImpl.kt new file mode 100644 index 0000000..2fad94a --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpointImpl.kt @@ -0,0 +1,51 @@ +package net.typedrest.endpoints.raw + +import net.typedrest.endpoints.* +import net.typedrest.http.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.InputStream +import java.net.URI + +/** + * Endpoint for a binary blob that can be downloaded or uploaded. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + */ +open class BlobEndpointImpl(referrer: Endpoint, relativeUri: URI) : AbstractEndpoint(referrer, relativeUri), BlobEndpoint { + /** + * Creates a new blob endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + */ + constructor(referrer: Endpoint, relativeUri: String) : + this(referrer, URI(relativeUri)) + + override fun probe() = + execute(Request.Builder().options().uri(uri).build()).close() + + override val isDownloadAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.GET) + + override fun download(): InputStream { + val response = execute(Request.Builder().get().uri(uri).build()) + return response.body?.byteStream() ?: throw IllegalStateException("Response body is null") + } + + override val isUploadAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.PUT) + + override fun uploadFrom(stream: InputStream, mimeType: String?) { + val body = stream.readBytes().toRequestBody(mimeType?.toMediaTypeOrNull()) + execute(Request.Builder().put(body).uri(uri).build()).close() + } + + override val isDeleteAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.DELETE) + + override fun delete() = + execute(Request.Builder().delete().uri(uri).build()).close() +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpoint.kt new file mode 100644 index 0000000..53ea135 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpoint.kt @@ -0,0 +1,42 @@ +package net.typedrest.endpoints.raw + +import net.typedrest.endpoints.Endpoint +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode +import java.io.* +import kotlin.io.path.Path +import kotlin.io.path.name + +/** + * Endpoint that accepts binary uploads. + */ +interface UploadEndpoint : Endpoint { + /** + * Uploads data to the endpoint from a stream. + * + * @param stream The input stream to read the upload data from. + * @param fileName The name of the uploaded file. + * @param mimeType The MIME type of the data to upload, nullable. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws HttpException for other non-success status codes. + */ + fun uploadFrom(stream: InputStream, fileName: String? = null, mimeType: String? = null) + + /** + * Uploads data to the endpoint from a file. + * + * @param path The path of the file to read the upload data from. + * @param mimeType The MIME type of the data to upload, nullable. + * @throws IOException when the file at the specified path can not be read. + * @throws BadRequestException when the server responds with [HttpStatusCode.BadRequest]. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws HttpException for other non-success status codes. + */ + fun uploadFrom(path: String, mimeType: String? = null) = + FileInputStream(path).use { fileStream -> + uploadFrom(fileStream, Path(path).name, mimeType) + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpointImpl.kt new file mode 100644 index 0000000..86a73bc --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpointImpl.kt @@ -0,0 +1,46 @@ +package net.typedrest.endpoints.raw + +import net.typedrest.endpoints.* +import net.typedrest.http.uri +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.InputStream +import java.net.URI + +/** + * Implementation of an [UploadEndpoint] that accepts binary uploads using multi-part form encoding or raw bodies. + * + * @property referrer The endpoint used to navigate to this one. + * @property relativeUri The URI of this endpoint relative to the [referrer]'s. + * @property formField The name of the form field to place the uploaded data into; null to use raw bodies instead of multi-part forms. + */ +class UploadEndpointImpl( + private val referrer: Endpoint, + private val relativeUri: URI, + private val formField: String? = null +) : AbstractEndpoint(referrer, relativeUri), UploadEndpoint { + /** + * Creates a new upload endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @property formField The name of the form field to place the uploaded data into; null to use raw bodies instead of multi-part forms. + */ + constructor(referrer: Endpoint, relativeUri: String, formField: String? = null) : + this(referrer, URI(relativeUri), formField) + + override fun uploadFrom(stream: InputStream, fileName: String?, mimeType: String?) { + var body = stream.readBytes().toRequestBody(mimeType?.toMediaTypeOrNull()) + + if (formField != null) { + val formDataBuilder = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart(formField, fileName, body) + + body = formDataBuilder.build() + } + + execute(Request.Builder().post(body).uri(uri).build()).close() + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/_doc.md b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/_doc.md new file mode 100644 index 0000000..1d49928 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/_doc.md @@ -0,0 +1,3 @@ +# Package net.typedrest.endpoints.raw + +Raw endpoints allow you to transmit binary data rather than serialized objects. diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/AbstractRpcEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/AbstractRpcEndpoint.kt new file mode 100644 index 0000000..c4dd10b --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/AbstractRpcEndpoint.kt @@ -0,0 +1,29 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.* +import net.typedrest.http.* +import okhttp3.Request +import java.net.URI + +/** + * Base class for building RPC endpoints. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + */ +abstract class AbstractRpcEndpoint(referrer: Endpoint, relativeUri: URI) : AbstractEndpoint(referrer, relativeUri), RpcEndpoint { + /** + * Creates a new RPC endpoint with a relative URI. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + */ + constructor(referrer: Endpoint, relativeUri: String) : + this(referrer, URI(relativeUri)) + + override fun probe() = + execute(Request.Builder().options().uri(uri).build()).close() + + override val isInvokeAllowed: Boolean? + get() = isMethodAllowed(HttpMethod.POST) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpoint.kt new file mode 100644 index 0000000..6fa9b7d --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpoint.kt @@ -0,0 +1,19 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode + +/** + * RPC endpoint that is invoked with no input or output. + */ +interface ActionEndpoint : RpcEndpoint { + /** + * Invokes the action. + * + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun invoke() +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpointImpl.kt new file mode 100644 index 0000000..5b87800 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpointImpl.kt @@ -0,0 +1,28 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.* +import net.typedrest.http.uri +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URI + +/** + * RPC endpoint that is invoked with no input or output. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + */ +open class ActionEndpointImpl(referrer: Endpoint, relativeUri: URI) + : AbstractRpcEndpoint(referrer, relativeUri), ActionEndpoint { + /** + * Creates a new action endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + */ + constructor(referrer: Endpoint, relativeUri: String) : + this(referrer, URI(relativeUri)) + + override fun invoke() = + execute(Request.Builder().post("".toRequestBody()).uri(uri).build()).close() +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpoint.kt new file mode 100644 index 0000000..239ff2d --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpoint.kt @@ -0,0 +1,22 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode + +/** + * RPC endpoint that takes [TEntity] as input when invoked. + * + * @param TEntity The type of entity the endpoint takes as input. + */ +interface ConsumerEndpoint<in TEntity> : RpcEndpoint { + /** + * Sends the entity to the consumer. + * + * @param entity The entity to post as input. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun invoke(entity: TEntity) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointImpl.kt new file mode 100644 index 0000000..3a6ee25 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointImpl.kt @@ -0,0 +1,33 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.* +import net.typedrest.http.uri +import okhttp3.Request +import java.net.URI + +/** + * RPC endpoint that takes [TEntity] as input when invoked. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param entityType The type of entity the endpoint takes as input. + * @param TEntity The type of entity the endpoint takes as input. + */ +open class ConsumerEndpointImpl<TEntity>( + referrer: Endpoint, + relativeUri: URI, + private val entityType: Class<TEntity> +) : AbstractRpcEndpoint(referrer, relativeUri), ConsumerEndpoint<TEntity> { + /** + * Creates a new consumer endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param entityType The type of entity the endpoint takes as input. + */ + constructor(referrer: Endpoint, relativeUri: String, entityType: Class<TEntity>) : + this(referrer, URI(relativeUri), entityType) + + override fun invoke(entity: TEntity) = + execute(Request.Builder().post(serialize(entity, entityType)).uri(uri).build()).close() +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpoint.kt new file mode 100644 index 0000000..4c46cd6 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpoint.kt @@ -0,0 +1,24 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode + +/** + * RPC endpoint that takes [TEntity] as input and returns [TResult] as output when invoked. + * + * @param TEntity The type of entity the endpoint takes as input. + * @param TResult The type of entity the endpoint returns as output. + */ +interface FunctionEndpoint<in TEntity, out TResult> : RpcEndpoint { + /** + * RpcEndpoint + * + * @param entity The entity to post as input. + * @return The result returned by the server. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun invoke(entity: TEntity): TResult +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointImpl.kt new file mode 100644 index 0000000..8de3bfd --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointImpl.kt @@ -0,0 +1,42 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.* +import net.typedrest.errors.NotFoundException +import net.typedrest.http.uri +import okhttp3.Request +import java.net.URI + +/** + * RPC endpoint that takes [TEntity] as input and returns [TResult] as output when invoked. + * + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param entityType The type of entity the endpoint takes as input. + * @param resultType The type of entity the endpoint returns as output. + * @param TEntity The type of entity the endpoint takes as input. + * @param TResult The type of entity the endpoint returns as output. + */ +open class FunctionEndpointImpl<TEntity, TResult>( + referrer: Endpoint, + relativeUri: URI, + private val entityType: Class<TEntity>, + private val resultType: Class<TResult> +) : AbstractRpcEndpoint(referrer, relativeUri), FunctionEndpoint<TEntity, TResult> { + /** + * Creates a new function endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param entityType The type of entity the endpoint takes as input. + * @param resultType The type of entity the endpoint returns as output. + */ + constructor(referrer: Endpoint, relativeUri: String, entityType: Class<TEntity>, resultType: Class<TResult>) : + this(referrer, URI(relativeUri), entityType, resultType) + + override fun invoke(entity: TEntity): TResult = + execute(Request.Builder().post(serialize(entity, entityType)).uri(uri).build()).use { response -> + response.body?.let { deserialize(it, resultType) } + ?: throw NotFoundException("Result not deserializable as ${resultType.simpleName}") + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpoint.kt new file mode 100644 index 0000000..7b55fd7 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpoint.kt @@ -0,0 +1,22 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode + +/** + * RPC endpoint that takes no input and returns [TResult] as output when invoked. + * + * @param TResult The type of entity the endpoint returns as output. + */ +interface ProducerEndpoint<out TResult> : RpcEndpoint { + /** + * Gets a result from the producer. + * + * @return The result returned by the server. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun invoke(): TResult +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointImpl.kt new file mode 100644 index 0000000..211648a --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointImpl.kt @@ -0,0 +1,38 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.* +import net.typedrest.errors.NotFoundException +import net.typedrest.http.uri +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URI + +/** + * RPC endpoint that takes no input and returns [TResult] as output when invoked. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param resultType The type of entity the endpoint returns as output. + * @param TResult The type of entity the endpoint returns as output. + */ +open class ProducerEndpointImpl<TResult>( + referrer: Endpoint, + relativeUri: URI, + private val resultType: Class<TResult> +) : AbstractRpcEndpoint(referrer, relativeUri), ProducerEndpoint<TResult> { + /** + * Creates a new producer endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the referrer's. Add a "./" prefix here to imply a trailing slash on referrer's URI. + * @param resultType The type of entity the endpoint returns as output. + */ + constructor(referrer: Endpoint, relativeUri: String, resultType: Class<TResult>) : + this(referrer, URI(relativeUri), resultType) + + override fun invoke(): TResult = + execute(Request.Builder().post("".toRequestBody()).uri(uri).build()).use { response -> + response.body?.let { deserialize(it, resultType) } + ?: throw NotFoundException("Result not deserializable as ${resultType.simpleName}") + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/RpcEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/RpcEndpoint.kt new file mode 100644 index 0000000..a0747e0 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/RpcEndpoint.kt @@ -0,0 +1,28 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.Endpoint +import net.typedrest.errors.* +import net.typedrest.http.HttpStatusCode + +/** + * An endpoint for a non-RESTful resource that acts like a callable function. + */ +interface RpcEndpoint : Endpoint { + /** + * Queries the server about capabilities of the endpoint without performing any action. + * @throws AuthenticationException when the server responds with [HttpStatusCode.Unauthorized]. + * @throws AuthorizationException when the server responds with [HttpStatusCode.Forbidden]. + * @throws NotFoundException when the server responds with [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]. + * @throws HttpException for other non-success status codes. + */ + fun probe() + + /** + * Indicates whether the server has specified the invoke method is currently allowed. + * + * Uses cached data from the last response. + * + * @return true if the method is allowed, false if the method is not allowed, null if no request has been sent yet or the server did not specify allowed methods. + */ + val isInvokeAllowed: Boolean? +} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/_doc.md b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/_doc.md new file mode 100644 index 0000000..9672108 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/_doc.md @@ -0,0 +1,3 @@ +# Package net.typedrest.endpoints.rpc + +RPC endpoints allow you to interact with non-RESTful resources that act like callable functions. diff --git a/typedrest/src/main/kotlin/net/typedrest/errors/DefaultErrorHandler.kt b/typedrest/src/main/kotlin/net/typedrest/errors/DefaultErrorHandler.kt new file mode 100644 index 0000000..882875a --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/errors/DefaultErrorHandler.kt @@ -0,0 +1,51 @@ +package net.typedrest.errors + +import kotlinx.serialization.* +import kotlinx.serialization.json.Json +import net.typedrest.http.HttpStatusCode +import okhttp3.Response +import java.io.IOException + +/** + * Handles errors in HTTP responses by mapping status codes to common exception types. + */ +class DefaultErrorHandler : ErrorHandler { + @Throws(HttpException::class) + override fun handle(response: Response) { + if (response.isSuccessful) return + + val body = response.body?.string() + val message = extractJsonMessage(response, body) + ?: "${response.request.url} responded with ${response.code} ${response.message}" + + throw mapException(HttpStatusCode.parse(response.code) ?: HttpStatusCode.InternalServerError, message, response) + } + + private fun extractJsonMessage(response: Response, body: String?): String? { + if (body.isNullOrEmpty()) return null + + val mediaType = response.body?.contentType()?.toString() + if (mediaType != "application/json" && (mediaType == null || !mediaType.endsWith("+json"))) return null + + return try { + val decoded = Json.decodeFromString<JsonErrorResponse>(body) + return decoded.message ?: decoded.details + } catch (e: SerializationException) { + null + } + } + + @Serializable + private class JsonErrorResponse(val message: String?, val details: String? = null) + + private fun mapException(status: HttpStatusCode, message: String, response: Response?) = + when (status) { + HttpStatusCode.BadRequest -> BadRequestException(message, status, response) + HttpStatusCode.Unauthorized -> AuthenticationException(message, status, response) + HttpStatusCode.Forbidden -> AuthorizationException(message, status, response) + HttpStatusCode.NotFound, HttpStatusCode.Gone -> NotFoundException(message, status, response) + HttpStatusCode.Conflict, HttpStatusCode.PreconditionFailed, HttpStatusCode.RangeNotSatisfiable -> ConflictException(message, status, response) + HttpStatusCode.RequestTimeout -> TimeoutException(message, status, response) + else -> HttpException(message, status, response) + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/errors/ErrorHandler.kt b/typedrest/src/main/kotlin/net/typedrest/errors/ErrorHandler.kt new file mode 100644 index 0000000..9b205fb --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/errors/ErrorHandler.kt @@ -0,0 +1,15 @@ +package net.typedrest.errors + +import okhttp3.Response + +/** + * Handles errors in HTTP responses. + */ +interface ErrorHandler { + /** + * Throws appropriate `Exception`s based on HTTP status codes and response bodies. + * + * @throws HttpException + */ + fun handle(response: Response) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/errors/HttpException.kt b/typedrest/src/main/kotlin/net/typedrest/errors/HttpException.kt new file mode 100644 index 0000000..ffc0597 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/errors/HttpException.kt @@ -0,0 +1,56 @@ +package net.typedrest.errors + +import net.typedrest.http.HttpStatusCode +import okhttp3.Response +import java.time.Duration + +/** + * Thrown on HTTP response with a non-successful status code (4xx or 5xx). + * @param message The error message. + * @param status The HTTP status code. + * @param response The full HTTP response. + */ +open class HttpException(message: String, val status: HttpStatusCode, val response: Response? = null) : Exception(message) { + /** + * The wait time before retrying suggested by the server. + */ + val retryAfter: Duration? = response + ?.header("Retry-After") + ?.toLongOrNull() + ?.let(Duration::ofSeconds) +} + +/** + * Thrown on HTTP response for a bad request (usually [HttpStatusCode.BadRequest]). + */ +open class BadRequestException(message: String = "Bad request", status: HttpStatusCode = HttpStatusCode.BadRequest, response: Response? = null) : HttpException(message, status, response) + +/** + * Thrown on HTTP response for an unauthenticated request, i.e. missing credentials (usually [HttpStatusCode.Unauthorized]). + */ +open class AuthenticationException(message: String = "Unauthorized", status: HttpStatusCode = HttpStatusCode.Unauthorized, response: Response? = null) : HttpException(message, status, response) + +/** + * Thrown on HTTP response for an unauthorized request, i.e. missing permissions (usually [HttpStatusCode.Forbidden]). + */ +open class AuthorizationException(message: String = "Forbidden", status: HttpStatusCode = HttpStatusCode.Forbidden, response: Response? = null) : HttpException(message, status, response) + +/** + * Thrown on HTTP response for a missing resource (usually [HttpStatusCode.NotFound] or [HttpStatusCode.Gone]). + */ +open class NotFoundException(message: String = "Not found", status: HttpStatusCode = HttpStatusCode.NotFound, response: Response? = null) : HttpException(message, status, response) + +/** + * Thrown on HTTP response for a timed-out operation (usually [HttpStatusCode.RequestTimeout]). + */ +open class TimeoutException(message: String = "Timeout", status: HttpStatusCode = HttpStatusCode.RequestTimeout, response: Response? = null) : HttpException(message, status, response) + +/** + * Thrown on HTTP response for a resource conflict (usually [HttpStatusCode.Conflict]). + */ +open class ConflictException(message: String = "Conflict", status: HttpStatusCode = HttpStatusCode.Conflict, response: Response? = null) : HttpException(message, status, response) + +/** + * Thrown on HTTP response for a failed precondition or mid-air collision (usually [HttpStatusCode.PreconditionFailed]). + */ +open class ConcurrencyException(message: String = "Precondition failed", status: HttpStatusCode = HttpStatusCode.PreconditionFailed, response: Response? = null) : HttpException(message, status, response) diff --git a/typedrest/src/main/kotlin/net/typedrest/errors/_doc.md b/typedrest/src/main/kotlin/net/typedrest/errors/_doc.md new file mode 100644 index 0000000..8b343ca --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/errors/_doc.md @@ -0,0 +1,5 @@ +# Package net.typedrest.errors + +Handling errors in HTTP responses. + +See [documentation](https://typedrest.net/error-handling/). diff --git a/typedrest/src/main/kotlin/net/typedrest/http/HttpContentRangeHeader.kt b/typedrest/src/main/kotlin/net/typedrest/http/HttpContentRangeHeader.kt new file mode 100644 index 0000000..4f14f0a --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/HttpContentRangeHeader.kt @@ -0,0 +1,26 @@ +package net.typedrest.http + +import okhttp3.Headers + +/** + * @param from The position at which the data starts. + * @param to The position at which the data stops. + * @param length The starting or ending point of the range. + */ +data class HttpContentRangeHeader(val unit: String, val from: Long?, val to: Long?, val length: Long?) { + companion object { + @JvmStatic + fun parse(headers: Headers): HttpContentRangeHeader? { + val header = headers["Content-Range"] ?: return null + val matchResult = Regex("(\\w+) (\\d+)-(\\d+)/(\\d+|\\*)").find(header) ?: return null + + val (unit, from, to, length) = matchResult.destructured + return HttpContentRangeHeader( + unit = unit, + from = from.toLongOrNull(), + to = to.toLongOrNull(), + length = if (length == "*") null else length.toLongOrNull() + ) + } + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/http/HttpCredentials.kt b/typedrest/src/main/kotlin/net/typedrest/http/HttpCredentials.kt new file mode 100644 index 0000000..a7ea950 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/HttpCredentials.kt @@ -0,0 +1,12 @@ +package net.typedrest.http + +import okhttp3.Credentials + +/** + * Represents credentials for HTTP Basic authentication. * + * @param username The username. + * @param password The password. + */ +class HttpCredentials(val username: String, val password: String) { + override fun toString() = Credentials.basic(username, password) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/http/HttpMethod.kt b/typedrest/src/main/kotlin/net/typedrest/http/HttpMethod.kt new file mode 100644 index 0000000..328a16c --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/HttpMethod.kt @@ -0,0 +1,25 @@ +package net.typedrest.http + +enum class HttpMethod { + GET, + POST, + PUT, + PATCH, + DELETE, + HEAD, + OPTIONS; + + companion object { + @JvmStatic + fun parse(value: String) = when (value.uppercase()) { + "GET" -> GET + "POST" -> POST + "PUT" -> PUT + "PATCH" -> PATCH + "DELETE" -> DELETE + "HEAD" -> HEAD + "OPTIONS" -> OPTIONS + else -> null + } + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/http/HttpStatusCode.kt b/typedrest/src/main/kotlin/net/typedrest/http/HttpStatusCode.kt new file mode 100644 index 0000000..bca0667 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/HttpStatusCode.kt @@ -0,0 +1,80 @@ +package net.typedrest.http + +enum class HttpStatusCode(val code: Int) { + // 1xx Informational + Continue(100), + SwitchingProtocols(101), + Processing(102), + EarlyHints(103), + + // 2xx Success + OK(200), + Created(201), + Accepted(202), + NonAuthoritativeInformation(203), + NoContent(204), + ResetContent(205), + PartialContent(206), + MultiStatus(207), + AlreadyReported(208), + IMUsed(226), + + // 3xx Redirection + MultipleChoices(300), + MovedPermanently(301), + Found(302), + SeeOther(303), + NotModified(304), + UseProxy(305), + TemporaryRedirect(307), + PermanentRedirect(308), + + // 4xx Client Error + BadRequest(400), + Unauthorized(401), + PaymentRequired(402), + Forbidden(403), + NotFound(404), + MethodNotAllowed(405), + NotAcceptable(406), + ProxyAuthenticationRequired(407), + RequestTimeout(408), + Conflict(409), + Gone(410), + LengthRequired(411), + PreconditionFailed(412), + PayloadTooLarge(413), + URITooLong(414), + UnsupportedMediaType(415), + RangeNotSatisfiable(416), + ExpectationFailed(417), + ImATeapot(418), + MisdirectedRequest(421), + UnprocessableEntity(422), + Locked(423), + FailedDependency(424), + TooEarly(425), + UpgradeRequired(426), + PreconditionRequired(428), + TooManyRequests(429), + RequestHeaderFieldsTooLarge(431), + UnavailableForLegalReasons(451), + + // 5xx Server Error + InternalServerError(500), + NotImplemented(501), + BadGateway(502), + ServiceUnavailable(503), + GatewayTimeout(504), + HTTPVersionNotSupported(505), + VariantAlsoNegotiates(506), + InsufficientStorage(507), + LoopDetected(508), + NotExtended(510), + NetworkAuthenticationRequired(511); + + companion object { + @JvmStatic + fun parse(value: Int) = entries.firstOrNull { it.code == value } + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/http/OkHttpExtensions.kt b/typedrest/src/main/kotlin/net/typedrest/http/OkHttpExtensions.kt new file mode 100644 index 0000000..7362b37 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/OkHttpExtensions.kt @@ -0,0 +1,37 @@ +package net.typedrest.http + +import okhttp3.* +import java.net.URI + +fun OkHttpClient.withAccept(mediaTypes: List<MediaType>): OkHttpClient = + if (mediaTypes.isEmpty()) { + this + } else { + val mediaTypeHeader = mediaTypes.joinToString(separator = ", ") + this.newBuilder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("Accept", mediaTypeHeader) + .build() + ) + }.build() + } + +fun OkHttpClient.withBasicAuth(credentials: HttpCredentials?): OkHttpClient = + if (credentials == null) { + this + } else { + this.newBuilder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("Authorization", credentials.toString()) + .build() + ) + }.build() + } + +fun Request.Builder.uri(uri: URI): Request.Builder = this.url(uri.toURL()) + +fun Request.Builder.options(): Request.Builder = this.method("OPTIONS", null) diff --git a/typedrest/src/main/kotlin/net/typedrest/http/PartialResponse.kt b/typedrest/src/main/kotlin/net/typedrest/http/PartialResponse.kt new file mode 100644 index 0000000..d3ceb09 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/PartialResponse.kt @@ -0,0 +1,20 @@ +package net.typedrest.http + +/** + * Represents a subset of a set of elements. + * + * @property elements The returned elements. + * @property range The range the [elements] come from. + * @param TEntity The type of element the response contains. + */ +class PartialResponse<TEntity>(val elements: List<TEntity>, val range: HttpContentRangeHeader?) { + /** + * Indicates whether the response reaches the end of the elements available on the server. + */ + val endReached: Boolean + get() = when { + range?.to == null -> true // No range specified, must be complete response + range.length == null -> false // No length specified, can't be the end + else -> range.to == range.length - 1 + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/http/ResponseCache.kt b/typedrest/src/main/kotlin/net/typedrest/http/ResponseCache.kt new file mode 100644 index 0000000..65b2722 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/ResponseCache.kt @@ -0,0 +1,75 @@ +package net.typedrest.http + +import okhttp3.* +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.ByteString +import java.text.SimpleDateFormat +import java.util.* + +/** + * Captures the content of a [Response] for caching. + */ +class ResponseCache private constructor(response: Response) { + companion object { + /** + * Creates a [ResponseCache] from a [response] if it is eligible for caching. + * @return The [ResponseCache]; `null` if the response is not eligible for caching. + */ + @JvmStatic + fun from(response: Response): ResponseCache? = + if (response.isSuccessful && response.code != HttpStatusCode.NoContent.code && !response.cacheControl.noStore && response.body != null) { + ResponseCache(response) + } else null + + private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("GMT") } + } + + private val bodyByteString = response.body?.byteString() + private val contentType = response.body?.contentType() + + /** + * Returns a copy of the cached [RequestBody]. + */ + fun getBody() = bodyByteString?.toResponseBody(contentType) ?: throw IllegalArgumentException("Missing content.") + + private var expires = + if (response.cacheControl.noCache) { + Date() // Treat no-cache as expired immediately + } else { + response.headers.getDate("Expires") + ?: response.cacheControl.maxAgeSeconds.let { maxAge -> + if (maxAge == -1) null else Date(System.currentTimeMillis() + maxAge * 1000) + } + ?: if (response.cacheControl.noCache) Date() else null + } + + /** + * Indicates whether this cached response has expired. + */ + val isExpired: Boolean + get() = expires?.before(Date()) ?: false + + private val eTag: String? = response.header("ETag") + private val lastModified: Date? = response.headers.getDate("Last-Modified") + + /** + * Returns request headers that require that the resource has been modified since it was cached. + */ + fun ifModifiedHeaders(): Headers { + val builder = Headers.Builder() + eTag?.let { builder.add("If-None-Match", it) } + ?: lastModified?.let { builder.add("If-Modified-Since", dateFormat.format(it)) } + return builder.build() + } + + /** + * Returns request headers that require that the resource has not been modified since it was cached. + */ + fun ifUnmodifiedHeaders(): Headers { + val builder = Headers.Builder() + eTag?.let { builder.add("If-Match", it) } + ?: lastModified?.let { builder.add("If-Unmodified-Since", dateFormat.format(it)) } + return builder.build() + } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/http/_doc.md b/typedrest/src/main/kotlin/net/typedrest/http/_doc.md new file mode 100644 index 0000000..1685fc5 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/http/_doc.md @@ -0,0 +1,3 @@ +# Package net.typedrest.http + +Helper methods and structures for performing HTTP requests. diff --git a/typedrest/src/main/kotlin/net/typedrest/links/AggregateLinkExtractor.kt b/typedrest/src/main/kotlin/net/typedrest/links/AggregateLinkExtractor.kt new file mode 100644 index 0000000..460f346 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/links/AggregateLinkExtractor.kt @@ -0,0 +1,11 @@ +package net.typedrest.links + +import okhttp3.Response + +/** + * Combines the results of multiple [LinkExtractor]s. + */ +class AggregateLinkExtractor(private vararg val extractors: LinkExtractor) : LinkExtractor { + override fun getLinks(response: Response): List<Link> = + extractors.flatMap { extractor -> extractor.getLinks(response) } +} diff --git a/typedrest/src/main/kotlin/net/typedrest/links/HalLinkExtractor.kt b/typedrest/src/main/kotlin/net/typedrest/links/HalLinkExtractor.kt new file mode 100644 index 0000000..60f55f7 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/links/HalLinkExtractor.kt @@ -0,0 +1,37 @@ +package net.typedrest.links + +import kotlinx.serialization.* +import kotlinx.serialization.json.Json +import okhttp3.Response + +/** + * Extracts links from JSON bodies according to the Hypertext Application Language (HAL) specification. + */ +class HalLinkExtractor : LinkExtractor { + override fun getLinks(response: Response): List<Link> = + if (response.header("Content-Type") == "application/hal+json") { + parseJsonBody(response.body?.string().orEmpty()) + } else emptyList() + + private fun parseJsonBody(body: String): List<Link> = + try { + Json.decodeFromString<HalLinksContainer>(body) + .links.map { (rel, halLink) -> + Link(rel, halLink.href, halLink.title, halLink.templated) + } + } catch (e: SerializationException) { + emptyList() + } + + @Serializable + private class HalLinksContainer( + @SerialName("_links") val links: Map<String, HalLink> + ) + + @Serializable + private class HalLink( + val href: String, + val title: String? = null, + val templated: Boolean = false + ) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/links/HeaderLinkExtractor.kt b/typedrest/src/main/kotlin/net/typedrest/links/HeaderLinkExtractor.kt new file mode 100644 index 0000000..ec0d4e3 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/links/HeaderLinkExtractor.kt @@ -0,0 +1,50 @@ +package net.typedrest.links + +import okhttp3.Response + +/** + * Extracts links from HTTP headers. + */ +class HeaderLinkExtractor : LinkExtractor { + companion object { + private val regexHeaderLinks = Regex("""<[^>]*>\s*(;\s*[^()<>@,;:"/\[\]?={} \t]+=(([^()<>@,;:"/\[\]?={} \t]+)|("[^"]*")))*(,|$)""") + private val regexLinkFields = Regex("""[^()<>@,;:"/\[\]?={} \t]+=(([^()<>@,;:"/\[\]?={} \t]+)|("[^"]*"))""") + } + + override fun getLinks(response: Response): List<Link> = + response.headers("Link") + .flatMap { headerValue -> regexHeaderLinks.findAll(headerValue).toList() } + .map { parseLink(it.value) } + .toList() + + private fun parseLink(value: String): Link { + val split = value.split('>', limit = 2) + val href = split[0].substring(1) + var rel: String? = null + var title: String? = null + var templated = false + + regexLinkFields.findAll(split[1]).forEach { matchResult -> + val param = matchResult.groups.first()!!.value + val paramSplit = param.split('=', limit = 2) + if (paramSplit.size != 2) return@forEach + when (paramSplit[0]) { + "rel" -> rel = paramSplit[1] + "title" -> title = + if (paramSplit[1].startsWith("\"") && paramSplit[1].endsWith("\"")) { + paramSplit[1].substring(1, paramSplit[1].length - 2) + } else paramSplit[1] + "templated" -> templated = paramSplit[1].toBoolean() + } + } + + return Link( + rel ?: throw IllegalArgumentException("The link header is lacking the mandatory 'rel' field."), + href, + title, + templated + ) + } + + private fun String.unquote() = removeSurrounding("\"") +} diff --git a/typedrest/src/main/kotlin/net/typedrest/links/Link.kt b/typedrest/src/main/kotlin/net/typedrest/links/Link.kt new file mode 100644 index 0000000..a0a01c8 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/links/Link.kt @@ -0,0 +1,15 @@ +package net.typedrest.links + +/** + * Represents a link to another resource. + * @param rel The relation type of the link. + * @param href The href/target of the link. + * @param title The title of the link (optional). + * @param templated Indicates whether the link is an URI Template (RFC 6570). + */ +class Link( + val rel: String, + val href: String, + val title: String? = null, + val templated: Boolean = false +) diff --git a/typedrest/src/main/kotlin/net/typedrest/links/LinkExtractor.kt b/typedrest/src/main/kotlin/net/typedrest/links/LinkExtractor.kt new file mode 100644 index 0000000..5b8e0cb --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/links/LinkExtractor.kt @@ -0,0 +1,13 @@ +package net.typedrest.links + +import okhttp3.Response + +/** + * Extracts links from responses. + */ +interface LinkExtractor { + /** + * Extracts links from the `response`. + */ + fun getLinks(response: Response): List<Link> +} diff --git a/typedrest/src/main/kotlin/net/typedrest/links/_doc.md b/typedrest/src/main/kotlin/net/typedrest/links/_doc.md new file mode 100644 index 0000000..930ae50 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/links/_doc.md @@ -0,0 +1,5 @@ +# Package net.typedrest.links + +Handling links between HTTP resources. + +See [documentation](https://typedrest.net/link-handling/). diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/AbstractJsonSerializer.kt b/typedrest/src/main/kotlin/net/typedrest/serializers/AbstractJsonSerializer.kt new file mode 100644 index 0000000..a1af39a --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/serializers/AbstractJsonSerializer.kt @@ -0,0 +1,15 @@ +package net.typedrest.serializers + +import okhttp3.MediaType.Companion.toMediaType + +/** + * Common base class for JSON serializers. + */ +abstract class AbstractJsonSerializer : Serializer { + companion object { + @JvmStatic + protected val mediaTypeJson = "application/json".toMediaType() + } + + override val supportedMediaTypes = listOf(mediaTypeJson) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/KotlinxJsonSerializer.kt b/typedrest/src/main/kotlin/net/typedrest/serializers/KotlinxJsonSerializer.kt new file mode 100644 index 0000000..e6adf17 --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/serializers/KotlinxJsonSerializer.kt @@ -0,0 +1,36 @@ +package net.typedrest.serializers + +import kotlinx.serialization.* +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.RequestBody.Companion.toRequestBody +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +/** + * Serializes and deserializes entities as JSON using Kotlinx.Serialization. + */ +open class KotlinxJsonSerializer : AbstractJsonSerializer() { + override fun <T> serialize(entity: T, type: Class<T>): RequestBody = + Json.encodeToString(getSerializer(type), entity).toRequestBody(mediaTypeJson) + + override fun <T> serializeList(entities: Iterable<T>, type: Class<T>): RequestBody = + Json.encodeToString(getListSerializer(type), entities.toList()).toRequestBody(mediaTypeJson) + + override fun <T> deserialize(body: ResponseBody, type: Class<T>): T? = + Json.decodeFromString(getSerializer(type), body.string()) + + override fun <T> deserializeList(body: ResponseBody, type: Class<T>): List<T>? = + Json.decodeFromString(getListSerializer(type), body.string()) + + @Suppress("UNCHECKED_CAST") + private fun <T> getSerializer(type: Type) = + Json.serializersModule.serializer(type) as KSerializer<T> + + private fun <T> getListSerializer(type: Type) = + getSerializer<List<T>>(object : ParameterizedType { + override fun getOwnerType(): Type? = null + override fun getRawType(): Type = List::class.java + override fun getActualTypeArguments(): Array<Type> = arrayOf(type) + }) +} diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/Serializer.kt b/typedrest/src/main/kotlin/net/typedrest/serializers/Serializer.kt new file mode 100644 index 0000000..5bad2ed --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/serializers/Serializer.kt @@ -0,0 +1,53 @@ +package net.typedrest.serializers + +import okhttp3.* + +/** + * Controls the serialization of entities sent to and received from the server. + */ +interface Serializer { + /** + * A list of MIME types this serializer supports. + */ + val supportedMediaTypes: List<MediaType> + + /** + * Serializes an entity. + * + * @param entity The entity to serialize. + * @param type The type of entity to deserialize. + * @param T The type of entity to serialize. + * @return The serialized entity as a request body. + */ + fun <T> serialize(entity: T, type: Class<T>): RequestBody + + /** + * Serializes a list of entities. + * + * @param entities The entities to serialize. + * @param type The type of entity to deserialize. + * @param T The type of entity to serialize. + * @return The serialized entities as a request body. + */ + fun <T> serializeList(entities: Iterable<T>, type: Class<T>): RequestBody + + /** + * Deserializes an entity. + * + * @param body The request body to deserialize into an entity. + * @param type The type of entity to deserialize. + * @param T The type of entity to deserialize. + * @return The deserialized response body as an entity. null if the body could not be deserialized. + */ + fun <T> deserialize(body: ResponseBody, type: Class<T>): T? + + /** + * Deserializes a list of entities. + * + * @param body The request body to deserialize into an entity. + * @param type The type of entity to deserialize. + * @param T The type of entity to deserialize. + * @return The deserialized response body as a list of entities. null if the body could not be deserialized. + */ + fun <T> deserializeList(body: ResponseBody, type: Class<T>): List<T>? +} diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/_doc.md b/typedrest/src/main/kotlin/net/typedrest/serializers/_doc.md new file mode 100644 index 0000000..11cdf4a --- /dev/null +++ b/typedrest/src/main/kotlin/net/typedrest/serializers/_doc.md @@ -0,0 +1,3 @@ +# Package net.typedrest.serializers + +Serialization of entities sent to and received from the server. diff --git a/typedrest/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest/src/test/kotlin/net/typedrest/MockEntity.kt new file mode 100644 index 0000000..6437b4c --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/MockEntity.kt @@ -0,0 +1,6 @@ +package net.typedrest + +import kotlinx.serialization.Serializable + +@Serializable +data class MockEntity(val id: Long, val name: String) diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt new file mode 100644 index 0000000..112f147 --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt @@ -0,0 +1,12 @@ +package net.typedrest.endpoints + +import okhttp3.mockwebserver.* +import kotlin.test.* + +abstract class AbstractEndpointTest { + protected var server: MockWebServer = MockWebServer() + protected var entryEndpoint: EntryEndpoint = EntryEndpoint(server.url("/").toUri()) + + @AfterTest + fun after() = server.close() +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt new file mode 100644 index 0000000..7a9ca63 --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt @@ -0,0 +1,236 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.errors.ConflictException +import net.typedrest.http.* +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class CollectionEndpointTest : AbstractEndpointTest() { + private val endpoint = CollectionEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testGetById() { + assertEquals("/endpoint/x%2Fy", endpoint["x/y"].uri.path) + } + + @Test + fun testGetByIdWithLinkHeaderRelative() { + server.enqueue( + MockResponse() + .setJsonBody("[]") + .addHeader("Link", "<children{?id}>; rel=child; templated=true") + ) + + endpoint.readAll() + assertEquals(endpoint.uri.resolve("/children?id=1"), endpoint["1"].uri) + } + + @Test + fun testGetByIdWithLinkHeaderAbsolute() { + server.enqueue( + MockResponse() + .setJsonBody("[]") + .addHeader("Link", "<http://localhost/children/{id}>; rel=child; templated=true") + ) + + endpoint.readAll() + assertEquals("http://localhost/children/1", endpoint["1"].uri.toString()) + } + + @Test + fun testGetByEntityWithLinkHeaderRelative() { + server.enqueue( + MockResponse() + .setJsonBody("[]") + .addHeader("Link", "<children/{id}>; rel=child; templated=true") + ) + + endpoint.readAll() + assertEquals(endpoint.uri.resolve("/children/1"), endpoint[MockEntity(1, "test")].uri) + } + + @Test + fun testGetByEntityWithLinkHeaderAbsolute() { + server.enqueue( + MockResponse() + .setJsonBody("[]") + .addHeader("Link", "<http://localhost/children/{id}>; rel=child; templated=true") + ) + + endpoint.readAll() + assertEquals("http://localhost/children/1", endpoint[MockEntity(1, "test")].uri.toString()) + } + + @Test + fun testReadAll() { + server.enqueue( + MockResponse() + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + ) + + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), endpoint.readAll()) + } + + @Test + fun testReadAllCache() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + ) + + val result1 = endpoint.readAll() + server.assertRequest(HttpMethod.GET) + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), result1) + + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.NotModified) + .setHeader("ETag", "\"123abc\"") + ) + val result2 = endpoint.readAll() + server.assertRequest(HttpMethod.GET).withHeader("If-None-Match", "\"123abc\"") + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), result2) + assertNotSame(result1, result2, message = "Should cache responses, not deserialized objects") + } + + @Test + fun testReadRangeOffset() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.PartialContent) + .setHeader("Content-Range", "elements 1-1/2") + .setJsonBody("""[{"id":6,"name":"test2"}]""") + ) + + val response = endpoint.readRange(from = 1, to = null) + server.assertRequest(HttpMethod.GET).withHeader("Range", "elements=1-") + assertEquals(listOf(MockEntity(6, "test2")), response.elements) + assertEquals(HttpContentRangeHeader(unit = "elements", from = 1, to = 1, length = 2), response.range) + } + + @Test + fun testReadRangeHead() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.PartialContent) + .setHeader("Content-Range", "elements 0-1/2") + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + ) + + val response = endpoint.readRange(from = 0, to = 1) + server.assertRequest(HttpMethod.GET).withHeader("Range", "elements=0-1") + assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), response.elements) + assertEquals(HttpContentRangeHeader(unit = "elements", from = 0, to = 1, length = 2), response.range) + } + + @Test + fun testReadRangeTail() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.PartialContent) + .setHeader("Content-Range", "elements 2-2/2") + .setJsonBody("""[{"id":6,"name":"test2"}]""") + ) + + val response = endpoint.readRange(from = null, to = 1) + server.assertRequest(HttpMethod.GET).withHeader("Range", "elements=-1") + assertEquals(listOf(MockEntity(6, "test2")), response.elements) + assertEquals(HttpContentRangeHeader(unit = "elements", from = 2, to = 2, length = 2), response.range) + } + + @Test + fun testReadRangeException() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.RangeNotSatisfiable) + .setJsonBody("""{"message":"test"}""") + ) + + var exceptionMessage: String? = null + try { + endpoint.readRange(from = 5, to = 10) + } catch (ex: ConflictException) { + exceptionMessage = ex.message + } + assertEquals("test", exceptionMessage) + } + + @Test + fun testCreate() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.Created) + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + val element = endpoint.create(MockEntity(0, "test"))!! + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":0,"name":"test"}""") + assertEquals(MockEntity(5, "test"), element.response) + assertEquals(endpoint.uri.resolve("/endpoint/5"), element.uri) + } + + @Test + fun testCreateLocation() { + server.enqueue( + MockResponse() + .setResponseCode(HttpStatusCode.Created) + .setJsonBody("""{"id":5,"name":"test"}""") + .addHeader("Location", "/endpoint/new") + ) + + val element = endpoint.create(MockEntity(0, "test"))!! + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":0,"name":"test"}""") + assertEquals(MockEntity(5, "test"), element.response) + assertEquals(endpoint.uri.resolve("/endpoint/new"), element.uri) + } + + @Test + fun testCreateNull() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + assertNull(endpoint.create(MockEntity(0, "test"))) + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":0,"name":"test"}""") + } + + @Test + fun testCreateAll() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.Accepted)) + + endpoint.createAll(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + } + + @Test + fun testSetAll() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.Accepted)) + + endpoint.setAll(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + } + + @Test + fun testSetAllETag() { + server.enqueue( + MockResponse() + .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + .addHeader("ETag", "\"123abc\"") + ) + val result = endpoint.readAll() + server.assertRequest(HttpMethod.GET) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + endpoint.setAll(result) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""") + .withHeader("If-Match", "\"123abc\"") + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt new file mode 100644 index 0000000..d061072 --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt @@ -0,0 +1,199 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.errors.ConflictException +import net.typedrest.http.* +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ElementEndpointTest : AbstractEndpointTest() { + private val endpoint = ElementEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testRead() { + server.enqueue( + MockResponse() + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + assertEquals(MockEntity(5, "test"), endpoint.read()) + } + + @Test + fun testReadCustomMimeWithJsonSuffix() { + server.enqueue( + MockResponse() + .setJsonBody("""{"id":5,"name":"test"}""") + ) + + assertEquals(MockEntity(5, "test"), endpoint.read()) + } + + @Test + fun testReadCacheETag() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""{"id":5,"name":"test"}""") + ) + val result1 = endpoint.read() + server.assertRequest(HttpMethod.GET) + assertEquals(MockEntity(5, "test"), result1) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NotModified)) + val result2 = endpoint.read() + server.assertRequest(HttpMethod.GET).withHeader("If-None-Match", "\"123abc\"") + assertEquals(MockEntity(5, "test"), result2) + assertNotSame(result1, result2, message = "Cache responses, not deserialized objects") + } + + @Test + fun testReadCacheLastModified() { + server.enqueue( + MockResponse() + .setHeader("Last-Modified", "Wed, 21 Oct 2015 00:00:00 GMT") + .setJsonBody("""{"id":5,"name":"test"}""") + ) + val result1 = endpoint.read() + server.assertRequest(HttpMethod.GET) + assertEquals(MockEntity(5, "test"), result1) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NotModified)) + val result2 = endpoint.read() + server.assertRequest(HttpMethod.GET).withHeader("If-Modified-Since", "Wed, 21 Oct 2015 00:00:00 GMT") + assertEquals(MockEntity(5, "test"), result2) + assertNotSame(result1, result2, message = "Cache responses, not deserialized objects") + } + + @Test + fun testExistsTrue() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.OK)) + assertTrue(endpoint.exists()) + } + + @Test + fun testExistsFalse() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NotFound)) + assertFalse(endpoint.exists()) + } + + @Test + fun testSetResult() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"testXXX"}""")) + + assertEquals(MockEntity(5, "testXXX"), endpoint.set(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testSetNoResult() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + assertNull(endpoint.set(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PUT) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testSetETag() { + server.enqueue( + MockResponse() + .setHeader("ETag", "\"123abc\"") + .setJsonBody("""{"id":5,"name":"test"}""") + ) + val result = endpoint.read() + server.assertRequest(HttpMethod.GET) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + endpoint.set(result) + server.assertRequest(HttpMethod.PUT) + .withHeader("If-Match", "\"123abc\"") + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testSetLastModified() { + server.enqueue( + MockResponse() + .setJsonBody("""{"id":5,"name":"test"}""") + .addHeader("Last-Modified", "Wed, 21 Oct 2015 00:00:00 GMT") + ) + val result = endpoint.read() + server.assertRequest(HttpMethod.GET) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + endpoint.set(result) + server.assertRequest(HttpMethod.PUT) + .withHeader("If-Unmodified-Since", "Wed, 21 Oct 2015 00:00:00 GMT") + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testUpdateRetry() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test1"}""").addHeader("ETag", "\"1\"")) + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.PreconditionFailed)) + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test2"}""").addHeader("ETag", "\"2\"")) + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + endpoint.update({ MockEntity(it.id, "testX") }) + + server.assertRequest(HttpMethod.GET) + server.assertRequest(HttpMethod.PUT).withJsonBody("""{"id":5,"name":"testX"}""").withHeader("If-Match", "\"1\"") + server.assertRequest(HttpMethod.GET) + server.assertRequest(HttpMethod.PUT).withJsonBody("""{"id":5,"name":"testX"}""").withHeader("If-Match", "\"2\"") + } + + @Test + fun testUpdateFail() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test1"}""").addHeader("ETag", "\"1\"")) + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.PreconditionFailed)) + + assertFailsWith<ConflictException> { + endpoint.update({ MockEntity(it.id, "testX") }, maxRetries = 0) + } + + server.assertRequest(HttpMethod.GET) + server.assertRequest(HttpMethod.PUT).withJsonBody("""{"id":5,"name":"testX"}""").withHeader("If-Match", "\"1\"") + } + + @Test + fun testMergeResult() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"testXXX"}""")) + + assertEquals(MockEntity(5, "testXXX"), endpoint.merge(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testMergeNoResult() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + assertNull(endpoint.merge(MockEntity(5, "test"))) + server.assertRequest(HttpMethod.PATCH) + .withJsonBody("""{"id":5,"name":"test"}""") + } + + @Test + fun testDelete() { + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + + endpoint.delete() + server.assertRequest(HttpMethod.DELETE) + } + + @Test + fun testDeleteETag() { + server.enqueue(MockResponse().setJsonBody("""{"id":5,"name":"test"}""").addHeader("ETag", "\"123abc\"")) + endpoint.read() + server.assertRequest(HttpMethod.GET) + + server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) + endpoint.delete() + server.assertRequest(HttpMethod.DELETE) + .withHeader("If-Match", "\"123abc\"") + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/IndexerEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/IndexerEndpointTest.kt new file mode 100644 index 0000000..6fa57ce --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/IndexerEndpointTest.kt @@ -0,0 +1,15 @@ +package net.typedrest.endpoints.generic + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class IndexerEndpointTest : AbstractEndpointTest() { + private val endpoint = IndexerEndpointImpl<ElementEndpoint<MockEntity>>(entryEndpoint, "endpoint") { ref, uri -> ElementEndpointImpl(ref, uri, MockEntity::class.java) } + + @Test + fun testGetById() { + assertEquals("/endpoint/x%2Fy", endpoint["x/y"].uri.path) + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/BlobEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/BlobEndpointTest.kt new file mode 100644 index 0000000..83bfe1d --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/BlobEndpointTest.kt @@ -0,0 +1,45 @@ +package net.typedrest.endpoints.raw + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import okio.Buffer +import java.io.ByteArrayInputStream +import kotlin.test.* + +class BlobEndpointTest : AbstractEndpointTest() { + private val endpoint = BlobEndpointImpl(entryEndpoint, "endpoint") + + @Test + fun testProbe() { + server.enqueue(MockResponse().setHeader("Allow", "PUT")) + + endpoint.probe() + + server.assertRequest(HttpMethod.OPTIONS) + assertEquals(false, endpoint.isDownloadAllowed) + assertEquals(true, endpoint.isUploadAllowed) + } + + @Test + fun testDownload() { + val data = byteArrayOf(1, 2, 3) + + server.enqueue(MockResponse().setBody(Buffer().write(data))) + + val downloadedData = endpoint.download().use { it.readAllBytes() } + server.assertRequest(HttpMethod.GET) + assertContentEquals(data, downloadedData) + } + + @Test + fun testUpload() { + val data = byteArrayOf(1, 2, 3) + + server.enqueue(MockResponse()) + + endpoint.uploadFrom(ByteArrayInputStream(data), mimeType = "mock/type") + server.assertRequest(HttpMethod.PUT).withBody(data, contentType = "mock/type") + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/UploadEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/UploadEndpointTest.kt new file mode 100644 index 0000000..0d1563f --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/UploadEndpointTest.kt @@ -0,0 +1,34 @@ +package net.typedrest.endpoints.raw + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import java.io.ByteArrayInputStream +import kotlin.test.Test + +class UploadEndpointTest : AbstractEndpointTest() { + @Test + fun testUploadRaw() { + val endpoint = UploadEndpointImpl(entryEndpoint, "endpoint") + + val data = byteArrayOf(1, 2, 3) + + server.enqueue(MockResponse()) + + endpoint.uploadFrom(ByteArrayInputStream(data), mimeType = "mock/type") + server.assertRequest(HttpMethod.POST).withBody(data, contentType = "mock/type") + } + + @Test + fun testUploadForm() { + val endpoint = UploadEndpointImpl(entryEndpoint, "endpoint", formField = "data") + + val data = byteArrayOf(1, 2, 3) + + server.enqueue(MockResponse()) + + endpoint.uploadFrom(ByteArrayInputStream(data), mimeType = "mock/type", fileName = "file.dat") + server.assertRequest(HttpMethod.POST) + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ActionEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ActionEndpointTest.kt new file mode 100644 index 0000000..6b42bbc --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ActionEndpointTest.kt @@ -0,0 +1,29 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.HttpMethod +import net.typedrest.tests.assertRequest +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ActionEndpointTest : AbstractEndpointTest() { + private val endpoint = ActionEndpointImpl(entryEndpoint, "endpoint") + + @Test + fun testProbe() { + server.enqueue(MockResponse().setHeader("Allow", "POST")) + + endpoint.probe() + + server.assertRequest(HttpMethod.OPTIONS) + assertEquals(true, endpoint.isInvokeAllowed) + } + + @Test + fun testInvoke() { + server.enqueue(MockResponse()) + + endpoint.invoke() + server.assertRequest(HttpMethod.POST) + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt new file mode 100644 index 0000000..dc5e9df --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt @@ -0,0 +1,21 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ConsumerEndpointTest : AbstractEndpointTest() { + private val endpoint = ConsumerEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse()) + + endpoint.invoke(MockEntity(1, "input")) + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":1,"name":"input"}""") + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt new file mode 100644 index 0000000..d7d4e71 --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt @@ -0,0 +1,21 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class FunctionEndpointTest : AbstractEndpointTest() { + private val endpoint = FunctionEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java, MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse().setJsonBody("""{"id":2,"name":"input"}""")) + + assertEquals(MockEntity(2, "input"), endpoint.invoke(MockEntity(1, "input"))) + server.assertRequest(HttpMethod.POST) + .withJsonBody("""{"id":1,"name":"input"}""") + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt new file mode 100644 index 0000000..8b2a795 --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt @@ -0,0 +1,20 @@ +package net.typedrest.endpoints.rpc + +import net.typedrest.MockEntity +import net.typedrest.endpoints.AbstractEndpointTest +import net.typedrest.http.HttpMethod +import net.typedrest.tests.* +import okhttp3.mockwebserver.MockResponse +import kotlin.test.* + +class ProducerEndpointTest : AbstractEndpointTest() { + private val endpoint = ProducerEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) + + @Test + fun testInvoke() { + server.enqueue(MockResponse().setJsonBody("""{"id":1,"name":"input"}""")) + + assertEquals(MockEntity(1, "input"), endpoint.invoke()) + server.assertRequest(HttpMethod.POST) + } +} diff --git a/typedrest/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt new file mode 100644 index 0000000..4eecc78 --- /dev/null +++ b/typedrest/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt @@ -0,0 +1,45 @@ +package net.typedrest.tests + +import net.typedrest.http.HttpMethod +import net.typedrest.http.HttpStatusCode +import okhttp3.Headers +import okhttp3.mockwebserver.* +import java.nio.charset.Charset +import kotlin.test.* + +const val contentTypeHeader = "Content-Type" + +fun MockResponse.setResponseCode(code: HttpStatusCode): MockResponse { + setResponseCode(code.code) + return this +} + +fun MockResponse.setJsonBody(json: String): MockResponse { + addHeader(contentTypeHeader, "application/json") + setBody(json) + return this +} + +fun MockWebServer.assertRequest(method: HttpMethod, path: String = "/endpoint"): RecordedRequest { + val request = takeRequest() + assertEquals(method.toString(), request.method) + assertEquals(path, request.requestUrl!!.encodedPath) + return request +} + +fun RecordedRequest.withBody(expectedBody: ByteArray, contentType: String): RecordedRequest { + assertEquals(contentType, getHeader(contentTypeHeader)) + assertContentEquals(expectedBody, body.readByteArray()) + return this +} + +fun RecordedRequest.withJsonBody(json: String): RecordedRequest { + assertEquals("application/json; charset=utf-8", getHeader(contentTypeHeader)) + assertEquals(json, body.readString(Charset.defaultCharset())) + return this +} + +fun RecordedRequest.withHeader(name: String, value: String): RecordedRequest { + assertEquals(value, getHeader(name)) + return this +}