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 @@
+# ![TypedRest](logo.svg) 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)
+
+[![typedrest](https://img.shields.io/maven-central/v/net.typedrest/typedrest.svg?label=typedrest)](https://mvnrepository.com/artifact/net.typedrest/typedrest)  
+The main TypedRest library.
+
+[![typedrest-serializers-jackson](https://img.shields.io/maven-central/v/net.typedrest/typedrest-serializers-jackson.svg?label=typedrest-serializers-jackson)](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.
+
+[![typedrest-serializers-moshi](https://img.shields.io/maven-central/v/net.typedrest/typedrest-serializers-moshi.svg?label=typedrest-serializers-moshi)](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
+}