diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 15c158a0..00000000 --- a/.editorconfig +++ /dev/null @@ -1,124 +0,0 @@ -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 index 3d8d36a8..4542e087 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ -# Disable linebreak normalization -* -text +# Normalize linebreaks +* text=auto diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index e8a5eb94..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Build -on: [push, pull_request] - -jobs: - build: - runs-on: ubuntu-latest - steps: - # Prepare - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: gittools/actions/gitversion/setup@v4.7.0 - with: - versionSpec: '6.7.x' - - uses: gittools/actions/gitversion/execute@v4.7.0 - 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 dokkaGenerateHtml - - run: gradle test - - name: Report test results - if: always() - uses: dorny/test-reporter@v3 - with: - name: Test Report - reporter: java-junit - path: '*/build/test-results/test/TEST-*.xml' - - # Release - - name: Get tag message - if: github.ref_type == 'tag' - id: tag_message - run: | - { - echo "message<> "$GITHUB_OUTPUT" - - name: Create GitHub Release - if: github.ref_type == 'tag' - uses: softprops/action-gh-release@v3 - with: - body: | - **[Maven package](https://central.sonatype.com/artifact/net.typedrest/typedrest/${{steps.gitversion.outputs.semVer}})** - ## Changes - ${{steps.tag_message.outputs.message}} - - # Publish - - name: Publish packages (GitHub) - if: github.event_name == 'push' && github.ref_type != 'tag' && !startsWith(github.ref_name, 'renovate/') - run: gradle publishAllPublicationsToGitHubRepository - env: - GITHUB_ACTOR: ${{github.actor}} - GITHUB_TOKEN: ${{github.token}} - - name: Publish packages (Maven Central) - if: github.ref_type == 'tag' - run: gradle publishToSonatype closeAndReleaseSonatypeStagingRepository - env: - GPG_KEY: ${{secrets.GPG_KEY}} - ORG_GRADLE_PROJECT_sonatypeUsername: ${{secrets.SONATYPE_USERNAME}} - ORG_GRADLE_PROJECT_sonatypePassword: ${{secrets.SONATYPE_PASSWORD}} - - name: Publish documentation - if: github.ref_type == 'tag' - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{github.token}} - force_orphan: true - publish_dir: build/dokka/html - cname: java.typedrest.net diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml new file mode 100644 index 00000000..c47d790c --- /dev/null +++ b/.github/workflows/doc.yml @@ -0,0 +1,27 @@ +# Generate and publish source documentation +name: Documentation +on: push + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Set up 0install + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends 0install-core + - name: Clone repo + uses: actions/checkout@v2 + - name: Build documentation + run: doc/build.sh + - name: Upload documentation + uses: actions/upload-artifact@v1 + with: + name: Documentation + path: target/doc + - name: Publish documentation + uses: peaceiris/actions-gh-pages@v3 + if: startsWith(github.event.ref, 'refs/tags') + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + force_orphan: true + publish_dir: target/doc + cname: java.typedrest.net diff --git a/.gitignore b/.gitignore index 5bb6a68d..c020fd71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ -*/build/ -/build/ -/.gradle/ -/.idea/ -/.kotlin - +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties + +#JetBrains IntelliJ IDEA +.idea/ +*.iml diff --git a/GitVersion.yml b/GitVersion.yml deleted file mode 100644 index 1474fc5f..00000000 --- a/GitVersion.yml +++ /dev/null @@ -1,16 +0,0 @@ -mode: ContinuousDeployment - -branches: - # Mainline branches - develop: - label: alpha - increment: Patch - main: - mode: ContinuousDelivery - label: beta - - # Stabilization branches - release: - label: rc - hotfix: - label: rc diff --git a/LICENSE b/LICENSE index 6385605c..2041da13 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2021 Bastian Eicher +Copyright (c) 2015-2016 1&1 Internet SE Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ 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. +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index d0447190..95053de7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ -# ![TypedRest](logo.svg) for Java/Kotlin +# ![TypedRest](logo.svg) for Java -[![Build](https://github.com/TypedRest/TypedRest-Java/actions/workflows/build.yml/badge.svg)](https://github.com/TypedRest/TypedRest-Java/actions/workflows/build.yml) -[![API documentation](https://img.shields.io/badge/api-docs-orange.svg)](https://java.typedrest.net/) -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** +TypedRest for Java 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 MyClient client = new MyClient(URI.create("http://example.com/")); @@ -29,62 +25,22 @@ Note note = smith.getNote().read(); smith.delete(); ``` -**Kotlin** - -```kotlin -val client = MyClient(URI("http://example.com/")) - -// GET /contacts -val contactList: List = client.contacts.readAll() - -// POST /contacts -> Location: /contacts/1337 -val smith: ContactEndpoint = client.contacts.create(Contact("Smith")) -//val smith: ContactEndpoint = client.contacts["1337"] +Read an **[Introduction](https://typedrest.net/introduction/)** to TypedRest or jump right in with the **[Getting started](https://typedrest.net/getting-started/java/)** guide. -// 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 **[setup guide](https://typedrest.net/setup/java/)**. - -You can also take a look at our [Sample project](https://github.com/TypedRest/Sample-Kotlin). +For information about specific Java classes or interfaces you can read the **[API Documentation](https://java.typedrest.net/)**. ## Maven artifacts -Artifact group: [`net.typedrest`](https://central.sonatype.com/artifact/net.typedrest) +Artifact group: `com.oneandone` -[![typedrest](https://img.shields.io/maven-central/v/net.typedrest/typedrest.svg?label=typedrest)](https://central.sonatype.com/artifact/net.typedrest/typedrest) +[![typedrest-core](https://img.shields.io/maven-central/v/com.oneandone/typedrest-core.svg?label=typedrest-core)](https://mvnrepository.com/artifact/com.oneandone/typedrest-core) The main TypedRest library. -[![typedrest-reactive](https://img.shields.io/maven-central/v/net.typedrest/typedrest-reactive.svg?label=typedrest-reactive)](https://mvnrepository.com/artifact/net.typedrest/typedrest-reactive) -Adds support for streaming with [ReactiveX (Rx)](http://reactivex.io/). -Create endpoints using the types in the `net.typedrest.endpoints.reactive` package. - -[![typedrest-serializers-moshi](https://img.shields.io/maven-central/v/net.typedrest/typedrest-serializers-moshi.svg?label=typedrest-serializers-moshi)](https://central.sonatype.com/artifact/net.typedrest/typedrest-serializers-moshi) -Adds support for serializing to JSON using [Moshi](https://github.com/square/moshi) instead of [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html). -Pass `new MoshiJsonSerializer()` to the `EntryEndpoint` constructor. - -[![typedrest-serializers-jackson](https://img.shields.io/maven-central/v/net.typedrest/typedrest-serializers-jackson.svg?label=typedrest-serializers-jackson)](https://central.sonatype.com/artifact/net.typedrest/typedrest-serializers-jackson) -Adds support for serializing to JSON 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-jackson-xml](https://img.shields.io/maven-central/v/net.typedrest/typedrest-serializers-jackson-xml.svg?label=typedrest-serializers-jackson-xml)](https://central.sonatype.com/artifact/net.typedrest/typedrest-serializers-jackson-xml) -Adds support for serializing to XML using [Jackson XML](https://github.com/FasterXML/jackson-dataformat-xml). -Pass `new JacksonXmlSerializer()` to the `EntryEndpoint` constructor. +[![typedrest-annotations](https://img.shields.io/maven-central/v/com.oneandone/typedrest-annotations.svg?label=typedrest-annotations)](https://mvnrepository.com/artifact/com.oneandone/typedrest-annotations) +Annotations for data models to be used with TypedRest. -[![typedrest-serializers-xmlutil](https://img.shields.io/maven-central/v/net.typedrest/typedrest-serializers-xmlutil.svg?label=typedrest-serializers-xmlutil)](https://central.sonatype.com/artifact/net.typedrest/typedrest-serializers-xmlutil) -Adds support for serializing to XML using [XmlUtil](https://github.com/pdvrieze/xmlutil). -Pass `new XmlUtilSerializer()` to the `EntryEndpoint` constructor. +[![typedrest-vaadin](https://img.shields.io/maven-central/v/com.oneandone/typedrest-vaadin.svg?label=typedrest-vaadin)](https://mvnrepository.com/artifact/com.oneandone/typedrest-vaadin) +Build [Vaadin](https://vaadin.com/) GUIs for TypedRest clients. -[![typedrest-serializers-jaxb](https://img.shields.io/maven-central/v/net.typedrest/typedrest-serializers-jaxb.svg?label=typedrest-serializers-jaxb)](https://central.sonatype.com/artifact/net.typedrest/typedrest-serializers-jaxb) -Adds support for serializing to XML using [JAXB](https://github.com/eclipse-ee4j/jaxb-ri). -Pass `new JaxbSerializer()` to the `EntryEndpoint` constructor. +[![typedrest-archetype](https://img.shields.io/maven-central/v/com.oneandone/typedrest-archetype.svg?label=typedrest-archetype)](https://mvnrepository.com/artifact/com.oneandone/typedrest-archetype) +[Maven archetype](https://maven.apache.org/guides/introduction/introduction-to-archetypes.html) (template) for creating TypedRest projects. diff --git a/annotations/pom.xml b/annotations/pom.xml new file mode 100644 index 00000000..ae8114df --- /dev/null +++ b/annotations/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + com.oneandone + typedrest-parent + 0.31-SNAPSHOT + ../parent + + typedrest-annotations + + TypedRest Annotations + Annotations for data models to be used with TypedRest. + + + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + + + diff --git a/annotations/src/main/java/net/typedrest/Description.java b/annotations/src/main/java/net/typedrest/Description.java new file mode 100644 index 00000000..f424453e --- /dev/null +++ b/annotations/src/main/java/net/typedrest/Description.java @@ -0,0 +1,14 @@ +package net.typedrest; + +import java.lang.annotation.*; + +/** + * A human-readable description for a property or class. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD, ElementType.TYPE}) +@Inherited +public @interface Description { + + String value(); +} diff --git a/annotations/src/main/java/net/typedrest/EditorHidden.java b/annotations/src/main/java/net/typedrest/EditorHidden.java new file mode 100644 index 00000000..bee9889b --- /dev/null +++ b/annotations/src/main/java/net/typedrest/EditorHidden.java @@ -0,0 +1,12 @@ +package net.typedrest; + +import java.lang.annotation.*; + +/** + * Hides a property in editor UIs. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD}) +@Inherited +public @interface EditorHidden { +} diff --git a/annotations/src/main/java/net/typedrest/Id.java b/annotations/src/main/java/net/typedrest/Id.java new file mode 100644 index 00000000..c6dda8af --- /dev/null +++ b/annotations/src/main/java/net/typedrest/Id.java @@ -0,0 +1,12 @@ +package net.typedrest; + +import java.lang.annotation.*; + +/** + * Marks a property as the entity's ID. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD}) +@Inherited +public @interface Id { +} diff --git a/annotations/src/main/java/net/typedrest/ListerHidden.java b/annotations/src/main/java/net/typedrest/ListerHidden.java new file mode 100644 index 00000000..1f85e1c1 --- /dev/null +++ b/annotations/src/main/java/net/typedrest/ListerHidden.java @@ -0,0 +1,12 @@ +package net.typedrest; + +import java.lang.annotation.*; + +/** + * Hides a property in lister UIs. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD}) +@Inherited +public @interface ListerHidden { +} diff --git a/annotations/src/main/java/net/typedrest/MultiLine.java b/annotations/src/main/java/net/typedrest/MultiLine.java new file mode 100644 index 00000000..a4a15003 --- /dev/null +++ b/annotations/src/main/java/net/typedrest/MultiLine.java @@ -0,0 +1,12 @@ +package net.typedrest; + +import java.lang.annotation.*; + +/** + * Marks a String property as containing multi-line text. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD}) +@Inherited +public @interface MultiLine { +} diff --git a/annotations/src/main/java/net/typedrest/NotEmpty.java b/annotations/src/main/java/net/typedrest/NotEmpty.java new file mode 100644 index 00000000..7a9e803b --- /dev/null +++ b/annotations/src/main/java/net/typedrest/NotEmpty.java @@ -0,0 +1,12 @@ +package net.typedrest; + +import java.lang.annotation.*; + +/** + * Marks a String property as not empty or null. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD}) +@Inherited +public @interface NotEmpty { +} diff --git a/annotations/src/main/java/net/typedrest/Required.java b/annotations/src/main/java/net/typedrest/Required.java new file mode 100644 index 00000000..34bfc900 --- /dev/null +++ b/annotations/src/main/java/net/typedrest/Required.java @@ -0,0 +1,12 @@ +package net.typedrest; + +import java.lang.annotation.*; + +/** + * Marks a property as required (not null). + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {ElementType.METHOD, ElementType.FIELD}) +@Inherited +public @interface Required { +} diff --git a/archetype/pom.xml b/archetype/pom.xml new file mode 100644 index 00000000..5f43c86d --- /dev/null +++ b/archetype/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + com.oneandone + typedrest-parent + 0.31-SNAPSHOT + ../parent + + typedrest-archetype + maven-archetype + + TypedRest Archetype + Archetype for building TypedRest web applications that can run standalone or be consumed as a library. + + + + + org.apache.maven.archetype + archetype-packaging + 2.4 + + + + + maven-archetype-plugin + 2.4 + true + + + org.apache.maven.plugins + maven-resources-plugin + 2.7 + + UTF-8 + \ + + + + + + src/main/resources + true + + archetype-resources/parent/pom.xml + + + + src/main/resources + false + + archetype-resources/parent/pom.xml + + + + + diff --git a/archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/archetype/src/main/resources/META-INF/maven/archetype-metadata.xml new file mode 100644 index 00000000..fe02bdee --- /dev/null +++ b/archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -0,0 +1,41 @@ + + + + + + + src/main/java + + + + + + + src/main/java + + + src/test/java + + + + + + + src/main/java + + + + + + + src/main/java + + + src/main/resources + + + + + \ No newline at end of file diff --git a/archetype/src/main/resources/archetype-resources/client-vaadin-boot/pom.xml b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/pom.xml new file mode 100644 index 00000000..68dd3f63 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + ${groupId} + ${artifactId}-parent + ${version} + ../parent + + ${artifactId}-client-vaadin-boot + + ${artifactId} Client Vaadin Boot + Bootstrapper for hosting the ${artifactId} Vaadin GUI + + + 1.3.8.RELEASE + + + + + ${groupId} + ${artifactId}-client-vaadin + ${project.version} + + + + + org.springframework.boot + spring-boot-starter-web + ${springboot.version} + + + com.vaadin + vaadin-spring-boot + 1.0.2 + + + + + + ${artifactId}-webapp + + + + + org.apache.maven.plugins + maven-deploy-plugin + 2.4 + + true + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${springboot.version} + + + + repackage + + + + + + + diff --git a/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/java/client/vaadin/boot/Application.java b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/java/client/vaadin/boot/Application.java new file mode 100644 index 00000000..84f39dca --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/java/client/vaadin/boot/Application.java @@ -0,0 +1,20 @@ +package ${package}.client.vaadin.boot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.*; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.web.*; + +@SpringBootApplication +@EnableAutoConfiguration +public class Application extends SpringBootServletInitializer { + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + return application.sources(Application.class); + } + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/java/client/vaadin/boot/MainUI.java b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/java/client/vaadin/boot/MainUI.java new file mode 100644 index 00000000..3e8faa17 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/java/client/vaadin/boot/MainUI.java @@ -0,0 +1,34 @@ +package ${package}.client.vaadin.boot; + +import ${package}.client.*; +import ${package}.client.vaadin.*; +import net.typedrest.vaadin.NotificationErrorHandler; +import com.vaadin.annotations.*; +import com.vaadin.server.VaadinRequest; +import com.vaadin.spring.annotation.SpringUI; +import com.vaadin.ui.UI; +import com.vaadin.ui.themes.ValoTheme; +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; + +@SpringUI +@Title("${artifactId}") +@Theme(ValoTheme.THEME_NAME) +@Push +public class MainUI extends UI { + + @Value("${api.uri}") + private URI apiUri; + @Value("${api.username}") + private String apiUsername; + @Value("${api.password}") + private String apiPassword; + + @Override + protected void init(VaadinRequest request) { + setErrorHandler(new NotificationErrorHandler()); + + setContent(new MyEntryView( + new MyEntryEndpoint(apiUri, apiUsername, apiPassword))); + } +} diff --git a/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/resources/application.properties b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/resources/application.properties new file mode 100644 index 00000000..e60f7743 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client-vaadin-boot/src/main/resources/application.properties @@ -0,0 +1,3 @@ +api.uri = http://localhost:12345/ +api.username = myuser +api.password = changeit diff --git a/archetype/src/main/resources/archetype-resources/client-vaadin/pom.xml b/archetype/src/main/resources/archetype-resources/client-vaadin/pom.xml new file mode 100644 index 00000000..ef952b20 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client-vaadin/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + + ${groupId} + ${artifactId}-parent + ${version} + ../parent + + ${artifactId}-client-vaadin + + ${artifactId} Client Vaadin + Vaadin GUI for ${artifactId} + + + + ${groupId} + ${artifactId}-client + ${project.version} + + + + + com.oneandone + typedrest-vaadin + ${typedrest.version} + + + diff --git a/archetype/src/main/resources/archetype-resources/client-vaadin/src/main/java/client/vaadin/MyEntryView.java b/archetype/src/main/resources/archetype-resources/client-vaadin/src/main/java/client/vaadin/MyEntryView.java new file mode 100644 index 00000000..9e3a0ee2 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client-vaadin/src/main/java/client/vaadin/MyEntryView.java @@ -0,0 +1,20 @@ +package ${package}.client.vaadin; + +import ${package}.client.*; +import net.typedrest.vaadin.views.*; +import com.vaadin.ui.Component; +import com.vaadin.ui.TabSheet; + +public class MyEntryView + extends AbstractEntryView { + + public MyEntryView(MyEntryEndpoint endpoint) { + super(endpoint); + } + + @Override + protected Component buildRoot() { + return new TabSheet( + new CollectionView<>(endpoint.getEntities(), eventBus)); + } +} diff --git a/archetype/src/main/resources/archetype-resources/client/pom.xml b/archetype/src/main/resources/archetype-resources/client/pom.xml new file mode 100644 index 00000000..5bf660c8 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + + ${groupId} + ${artifactId}-parent + ${version} + ../parent + + ${artifactId}-client + + ${artifactId} Client + Client library for communicating with the ${artifactId} API + + + + ${groupId} + ${artifactId}-model + ${project.version} + + + + + com.oneandone + typedrest-core + ${typedrest.version} + + + + + com.github.tomakehurst + wiremock + 1.58 + test + + + diff --git a/archetype/src/main/resources/archetype-resources/client/src/main/java/client/MyEntryEndpoint.java b/archetype/src/main/resources/archetype-resources/client/src/main/java/client/MyEntryEndpoint.java new file mode 100644 index 00000000..2a246794 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client/src/main/java/client/MyEntryEndpoint.java @@ -0,0 +1,49 @@ +package ${package}.client; + +import net.typedrest.*; +import java.net.URI; +import ${package}.model.*; + +public class MyEntryEndpoint extends EntryEndpoint { + + /** + * Creates a new entry point with no authentication. + * + * @param uri The base URI of the REST interface. Missing trailing slash + * will be appended automatically. + */ + public PlannerEntryEndpoint(URI uri) { + super(uri); + } + + /** + * Creates a new entry point using HTTP Basic Auth. + * + * @param uri The base URI of the REST interface. Missing trailing slash + * will be appended automatically. + * @param username The username used to authenticate against the REST + * interface. + * @param password The password used to authenticate against the REST + * interface. + */ + public PlannerEntryEndpoint(URI uri, String username, String password) { + super(uri, username, password); + } + + /** + * Creates a new entry point using a token for authentication. + * + * @param uri The base URI of the REST interface. Missing trailing slash + * will be appended automatically. + * @param token The token used to authenticate against the REST + * interface. + */ + public PlannerEntryEndpoint(URI uri, String token) { + super(uri); + defaultHeaders.add(new BasicHeader("Authorization", "Bearer " + token)); + } + + public CollectionEndpoint getEntities() { + return new CollectionEndpointImpl<>(this, "entities", MyEntity.class); + } +} diff --git a/archetype/src/main/resources/archetype-resources/client/src/test/java/client/AbstractEndpointTest.java b/archetype/src/main/resources/archetype-resources/client/src/test/java/client/AbstractEndpointTest.java new file mode 100644 index 00000000..f764152e --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client/src/test/java/client/AbstractEndpointTest.java @@ -0,0 +1,26 @@ +package ${package}.client; + +import org.junit.*; +import com.github.tomakehurst.wiremock.junit.*; +import net.typedrest.EntryEndpoint; +import java.net.URI; +import org.apache.http.entity.ContentType; + +public abstract class AbstractEndpointTest { + + protected EntryEndpoint entryEndpoint; + protected static final String JSON_MIME = ContentType.APPLICATION_JSON.getMimeType(); + protected static final String LINK = "Link"; + + private static final int port = 8089; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(port); + + protected static final URI serverUri = URI.create("http://localhost:" + port + "/"); + + @Before + public void before() { + entryEndpoint = new EntryEndpoint(serverUri); + } +} diff --git a/archetype/src/main/resources/archetype-resources/client/src/test/java/client/EntitiesEndpointTest.java b/archetype/src/main/resources/archetype-resources/client/src/test/java/client/EntitiesEndpointTest.java new file mode 100644 index 00000000..895d92aa --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/client/src/test/java/client/EntitiesEndpointTest.java @@ -0,0 +1,18 @@ +package ${package}.client; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import java.net.URI; +import net.typedrest.CollectionEndpointImpl; +import ${package}.model.*; +import org.junit.Before; + +public class EntitiesEndpointTest extends AbstractEndpointTest { + private CollectionEndpointImpl endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new CollectionEndpointImpl<>(entryEndpoint, "entities", MyEntity.class); + } +} diff --git a/archetype/src/main/resources/archetype-resources/model/pom.xml b/archetype/src/main/resources/archetype-resources/model/pom.xml new file mode 100644 index 00000000..94d9e2f7 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/model/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + ${groupId} + ${artifactId}-parent + ${version} + ../parent + + ${artifactId}-model + + ${artifactId} Model + Domain model for ${artifactId} + + + + + com.oneandone + typedrest-annotations + ${typedrest.version} + + + diff --git a/archetype/src/main/resources/archetype-resources/model/src/main/java/model/MyEntity.java b/archetype/src/main/resources/archetype-resources/model/src/main/java/model/MyEntity.java new file mode 100644 index 00000000..c67e5938 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/model/src/main/java/model/MyEntity.java @@ -0,0 +1,11 @@ +package ${package}.model; + +import net.typedrest.*; +import lombok.*; + +@Data +public class MyEntity { + + @Id + private String id; +} diff --git a/archetype/src/main/resources/archetype-resources/parent/pom.xml b/archetype/src/main/resources/archetype-resources/parent/pom.xml new file mode 100644 index 00000000..cbb0faa7 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/parent/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + com.oneandone + common-parent + 1.0.1 + + + + \${groupId} + \${artifactId}-parent + \${version} + pom + + \${artifactId} Parent + Parent POM for configuration shared across \${artifactId} libraries + + + \${version} + ${typedrest.version} + + diff --git a/archetype/src/main/resources/archetype-resources/pom.xml b/archetype/src/main/resources/archetype-resources/pom.xml new file mode 100644 index 00000000..a91a3069 --- /dev/null +++ b/archetype/src/main/resources/archetype-resources/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + ${groupId} + ${artifactId} + ${version} + pom + + ${artifactId} + Libraries for interacting with ${artifactId} in Java + + + parent + model + client + client-vaadin + client-vaadin-boot + + + + + + + org.apache.maven.plugins + maven-deploy-plugin + 2.4 + + true + + + + + diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 9469b598..00000000 --- a/build.gradle.kts +++ /dev/null @@ -1,126 +0,0 @@ -import org.jetbrains.dokka.gradle.* - -group = "net.typedrest" - -repositories.mavenCentral() - -plugins { - kotlin("jvm") version "2.4.21" - kotlin("plugin.serialization") version "2.4.21" apply false - id("org.jetbrains.dokka") version "2.2.0" - id("org.jetbrains.dokka-javadoc") version "2.2.0" - id("maven-publish") - id("signing") - id("io.github.gradle-nexus.publish-plugin") version "2.0.0" -} - -subprojects { - group = "net.typedrest" - version = System.getenv("VERSION") ?: "1.0-SNAPSHOT" - - repositories.mavenCentral() - - fun kotlin(module: String) = "org.jetbrains.kotlin.${module}" - apply(plugin = kotlin("jvm")) - apply(plugin = kotlin("plugin.serialization")) - apply(plugin = "org.jetbrains.dokka") - apply(plugin = "org.jetbrains.dokka-javadoc") - apply(plugin = "maven-publish") - apply(plugin = "signing") - - kotlin { - compilerOptions.allWarningsAsErrors = true - } - - dokka { - dokkaSourceSets.configureEach { - // Package-level documentation - val mdFiles = fileTree("src/main") { - include("**/_doc.md") - } - mdFiles.files.forEach { mdFile -> - // Avoid duplicate file name conflicts - val relativePath = mdFile.relativeTo(project.file("src/main")).path.replace("/", "-").replace("\\", "-") - val uniqueFile = project.layout.buildDirectory.file("tmp/dokka-includes/$relativePath").get().asFile - uniqueFile.parentFile.mkdirs() - mdFile.copyTo(uniqueFile, overwrite = true) - includes.from(uniqueFile) - } - } - } - - java { - withSourcesJar() - withJavadocJar() - } - - tasks.named("javadocJar") { - from(tasks.dokkaGeneratePublicationJavadoc.flatMap { it.outputDirectory }) - } - - configure { - publications { - create("maven") { - from(components["java"]) - - pom { - name.set(project.name) - description.set(provider { project.description }) - url.set("https://typedrest.net/") - - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/licenses/MIT") - } - } - - developers { - developer { - name.set("Bastian Eicher") - url.set("https://github.com/bastianeicher") - } - } - - scm { - connection.set("scm:git:https://github.com/TypedRest/TypedRest-Java.git") - developerConnection.set("scm:git:git@github.com:TypedRest/TypedRest-Java.git") - url.set("https://github.com/TypedRest/TypedRest-Java") - } - } - } - } - - configure { - val gpgKey = System.getenv("GPG_KEY") - if (gpgKey != null) { - useInMemoryPgpKeys(gpgKey, "") - sign(the().publications["maven"]) - } - } - - repositories { - maven { - name = "GitHub" - url = uri("https://maven.pkg.github.com/TypedRest/TypedRest-Java") - credentials { - username = System.getenv("GITHUB_ACTOR") - password = System.getenv("GITHUB_TOKEN") - } - } - } - } -} - -dependencies { - subprojects.forEach { dokka(it) } -} - -nexusPublishing { - repositories { - sonatype { - nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) - snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) - } - } -} diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 00000000..351f3307 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + com.oneandone + typedrest-parent + 0.31-SNAPSHOT + ../parent + + typedrest-core + + TypedRest Core + TypedRest helps you build type-safe fluent-style REST API clients. + + + + com.oneandone + typedrest-annotations + ${typedrest.version} + + + + org.apache.commons + commons-lang3 + 3.4 + + + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + + + org.apache.httpcomponents + fluent-hc + 4.5.2 + + + + + com.damnhandy + handy-uri-templates + 2.1.5 + + + + + io.reactivex + rxjava + 1.1.5 + + + io.reactivex + rxjava-async-util + 0.21.0 + + + + + com.github.tomakehurst + wiremock + 2.12.0 + test + + + diff --git a/core/src/main/java/net/typedrest/AbstractBulkCollectionEndpoint.java b/core/src/main/java/net/typedrest/AbstractBulkCollectionEndpoint.java new file mode 100644 index 00000000..7209952e --- /dev/null +++ b/core/src/main/java/net/typedrest/AbstractBulkCollectionEndpoint.java @@ -0,0 +1,46 @@ +package net.typedrest; + +import java.net.URI; + +/** + * Base class for building REST endpoints that represents a collection of + * TEntitys as TElementEndpoints with bulk create and + * replace support. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + * + * @deprecated Use {@link AbstractCollectionEndpoint} instead. + */ +@Deprecated +public abstract class AbstractBulkCollectionEndpoint + extends AbstractCollectionEndpoint implements GenericBulkCollectionEndpoint { + + /** + * Creates a new bulk collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractBulkCollectionEndpoint(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + /** + * Creates a new bulk collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractBulkCollectionEndpoint(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } +} diff --git a/core/src/main/java/net/typedrest/AbstractCollectionEndpoint.java b/core/src/main/java/net/typedrest/AbstractCollectionEndpoint.java new file mode 100644 index 00000000..058f425c --- /dev/null +++ b/core/src/main/java/net/typedrest/AbstractCollectionEndpoint.java @@ -0,0 +1,220 @@ +package net.typedrest; + +import com.fasterxml.jackson.databind.JavaType; +import static net.typedrest.BeanUtils.*; +import static net.typedrest.URIUtils.ensureTrailingSlash; +import org.apache.http.*; +import org.apache.http.client.fluent.Request; +import org.apache.http.entity.ContentType; +import org.apache.http.util.EntityUtils; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import java.beans.PropertyDescriptor; +import java.io.*; +import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.util.*; +import static java.util.Arrays.stream; +import lombok.*; +import org.apache.http.entity.StringEntity; + +/** + * Base class for building REST endpoints that represents a collection of + * TEntitys as TElementEndpoints. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + */ +public abstract class AbstractCollectionEndpoint + extends AbstractETagEndpoint implements GenericCollectionEndpoint { + + @Getter + protected final Class entityType; + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractCollectionEndpoint(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, ensureTrailingSlash(relativeUri)); + this.entityType = entityType; + this.idProperty = getPropertiesWithAnnotation(entityType, Id.class).stream().findFirst(); + + setDefaultLinkTemplate("child", "{id}"); + } + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractCollectionEndpoint(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri.endsWith("/") ? relativeUri : relativeUri + "/"); + this.entityType = entityType; + this.idProperty = getPropertiesWithAnnotation(entityType, Id.class).stream().findFirst(); + + setDefaultLinkTemplate("child", "{id}"); + } + + /** + * Builds a {@link ElementEndpoint} for a specific child element of this + * collection. Does not perform any network traffic yet. + * + * @param relativeUri The URI of the child endpoint relative to the this + * endpoint. + * @return An {@link ElementEndpoint} for a specific element of this + * collection. + */ + protected abstract TElementEndpoint buildElementEndpoint(URI relativeUri); + + @Override + public TElementEndpoint get(String id) { + if (id == null) { + throw new IllegalArgumentException("id"); + } + + return buildElementEndpoint(linkTemplate("child", "id", id)); + } + + private final Optional idProperty; + + @Override + public TElementEndpoint get(TEntity entity) { + try { + String id = idProperty + .orElseThrow(() -> new IllegalStateException(entityType.getSimpleName() + " has no property marked with @Id annotation.")) + .getReadMethod().invoke(entity).toString(); + return get(id); + } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + public Optional isReadAllAllowed() { + return isMethodAllowed("GET"); + } + + @Override + public List readAll() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + HttpEntity content = getContent(); + + JavaType collectionType = serializer.getTypeFactory().constructCollectionType(List.class, entityType); + return serializer.readValue(EntityUtils.toString(content), collectionType); + } + + /** + * The value used for the Range header unit. + */ + @Getter + @Setter + private String rangeUnit = "elements"; + + @Override + protected void handleCapabilities(HttpResponse response) { + super.handleCapabilities(response); + readRangeAllowed = Optional.of( + stream(response.getHeaders(ACCEPT_RANGES)) + .anyMatch(x -> x.getValue().equals(rangeUnit))); + } + + private Optional readRangeAllowed; + + @Override + public Optional isReadRangeAllowed() { + return readRangeAllowed; + } + + @Override + public PartialResponse readRange(Long from, Long to) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + String range = rangeUnit + "=" + + (from == null ? "" : from.toString()) + "-" + + (to == null ? "" : to.toString()); + + HttpResponse response = executeAndHandle(Request.Get(uri).addHeader(RANGE, range)); + + JavaType collectionType = serializer.getTypeFactory().constructCollectionType(List.class, entityType); + List elements = serializer.readValue(EntityUtils.toString(response.getEntity()), collectionType); + + Header contentRange = response.getFirstHeader(CONTENT_RANGE); + if (contentRange == null) { + // Server provided full instead of partial response + return new PartialResponse<>(elements, 0L, null, null); + } + + String[] split = contentRange.getValue().split(" "); + split = split[1].split("/"); + Long contentLength = (split[1].equals("*")) ? null : Long.parseLong(split[1]); + split = split[0].split("-"); + Long contentFrom = (split[0].isEmpty() ? null : Long.parseLong(split[0])); + Long contentTo = (split[1].isEmpty() ? null : Long.parseLong(split[1])); + + return new PartialResponse<>(elements, contentFrom, contentTo, contentLength); + } + + @Override + public Optional isCreateAllowed() { + return isMethodAllowed("POST"); + } + + @Override + public TElementEndpoint create(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + if (entity == null) { + throw new IllegalArgumentException("entity must not be null."); + } + + String jsonSend = serializer.writeValueAsString(entity); + HttpResponse response = executeAndHandle(Request.Post(uri).bodyString(jsonSend, ContentType.APPLICATION_JSON)); + Header locationHeader = response.getFirstHeader(LOCATION); + return (response.getStatusLine().getStatusCode() == SC_CREATED || response.getStatusLine().getStatusCode() == SC_ACCEPTED) && (locationHeader != null) + ? buildElementEndpoint(URI.create(locationHeader.getValue())) + : null; + } + + @Override + public Optional isCreateAllAllowed() { + return isMethodAllowed("PATCH"); + } + + @Override + public void createAll(Iterable entities) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + if (entities == null) { + throw new IllegalArgumentException("entities must not be null."); + } + + String jsonSend = serializer.writeValueAsString(entities); + Request request = Request.Patch(uri).bodyString(jsonSend, ContentType.APPLICATION_JSON); + executeAndHandle(request); + } + + @Override + public Optional isSetAllAllowed() { + return isMethodAllowed("PUT"); + } + + @Override + public void setAll(Collection entities) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + if (entities == null) { + throw new IllegalArgumentException("entities must not be null."); + } + + HttpEntity content = new StringEntity(serializer.writeValueAsString(entities), ContentType.APPLICATION_JSON); + putContent(content); + } +} diff --git a/core/src/main/java/net/typedrest/AbstractETagEndpoint.java b/core/src/main/java/net/typedrest/AbstractETagEndpoint.java new file mode 100644 index 00000000..dcd2e769 --- /dev/null +++ b/core/src/main/java/net/typedrest/AbstractETagEndpoint.java @@ -0,0 +1,129 @@ +package net.typedrest; + +import java.io.*; +import java.net.*; +import lombok.*; +import org.apache.http.*; +import static org.apache.http.HttpHeaders.IF_NONE_MATCH; +import static org.apache.http.HttpStatus.SC_NOT_MODIFIED; +import org.apache.http.client.fluent.*; + +/** + * Base class for building REST endpoints that use ETags (entity tags) for + * caching and to avoid lost updates. + */ +public class AbstractETagEndpoint + extends AbstractEndpoint { + + protected AbstractETagEndpoint(Endpoint referrer, URI relativeUri) { + super(referrer, relativeUri); + } + + protected AbstractETagEndpoint(Endpoint referrer, String relativeUri) { + super(referrer, relativeUri); + } + + @AllArgsConstructor + private class Memory { + + public final String etag; + public final HttpEntity content; + } + + // NOTE: Replace entire object rather than modifying it to ensure thread-safety. + private Memory last; + + /** + * Performs an HTTP GET request on the {@link Endpoint#getUri()} and caches + * the response if the server sends an ETag header. + * + * Sends {@link HttpHeaders#IF_NONE_MATCH} if there is already a cached + * ETag. + * + * @return The response of the request or the cached response if the server + * responded with {@link HttpStatus#SC_NOT_MODIFIED}. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + protected HttpEntity getContent() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + Request request = Request.Get(uri); + if (last != null) { + request = request.addHeader(IF_NONE_MATCH, last.etag); + } + + HttpResponse response = execute(request); + if (response.getStatusLine().getStatusCode() == SC_NOT_MODIFIED && last != null) { + return last.content; + } + + handleResponse(response, request); + Header etagHeader = response.getFirstHeader(HttpHeaders.ETAG); + last = (etagHeader == null) + ? null + : new Memory(etagHeader.getValue(), response.getEntity()); + return response.getEntity(); + } + + /** + * Performs an HTTP PUT request on the {@link Endpoint#getUri()}. Sets + * {@link HttpHeaders#IF_MATCH} if there is a cached ETag to detect lost + * updates. + * + * @param content The content to send to the server. + * @return The response message. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The content has changed since it was last + * retrieved with {@link #getContent()}. Your changes were rejected to + * prevent a lost update. + * @throws RuntimeException Other non-success status code. + */ + protected HttpResponse putContent(HttpEntity content) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + Request request = Request.Put(uri).body(content); + if (last != null) { + request.addHeader(HttpHeaders.IF_MATCH, last.etag); + } + + return executeAndHandle(request); + } + + /** + * Performs an HTTP DELETE request on the {@link Endpoint#getUri()}. Sets + * {@link HttpHeaders#IF_MATCH} if there is a cached ETag to detect lost + * updates. + * + * @return The response message. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #getContent()}. Your delete call was rejected to + * prevent a lost update. + * @throws RuntimeException Other non-success status code. + */ + protected HttpResponse deleteContent() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + Request request = Request.Delete(uri); + if (last != null) { + request.addHeader(HttpHeaders.IF_MATCH, last.etag); + } + + return executeAndHandle(request); + } +} diff --git a/core/src/main/java/net/typedrest/AbstractEndpoint.java b/core/src/main/java/net/typedrest/AbstractEndpoint.java new file mode 100644 index 00000000..2adde80c --- /dev/null +++ b/core/src/main/java/net/typedrest/AbstractEndpoint.java @@ -0,0 +1,544 @@ +package net.typedrest; + +import com.damnhandy.uri.template.UriTemplate; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import static net.typedrest.HeaderUtils.getLinkHeaders; +import static net.typedrest.URIUtils.ensureTrailingSlash; +import java.io.*; +import java.net.*; +import java.util.*; +import static java.util.Collections.*; +import static java.util.stream.Collectors.*; +import lombok.*; +import org.apache.http.*; +import org.apache.http.client.fluent.*; +import org.apache.http.util.*; +import static java.util.Arrays.stream; +import static java.util.Arrays.stream; +import static java.util.Arrays.stream; +import static java.util.Arrays.stream; + +/** + * Base class for building REST endpoints, i.e. remote HTTP resources. + */ +public abstract class AbstractEndpoint + implements Endpoint { + + @Getter + protected final URI uri; + + @Getter + protected final Executor executor; + + @Getter + protected final ObjectMapper serializer; + + /** + * A set of default HTTP headers to be added to each request. + */ + protected final Collection
defaultHeaders = new LinkedList<>(); + + /** + * Creates a new REST endpoint with an absolute URI. + * + * @param uri The HTTP URI of the remote element. + * @param executor The REST executor used to communicate with the remote + * element. + * @param serializer Controls the serialization of entities sent to and + * received from the server. + */ + protected AbstractEndpoint(URI uri, Executor executor, ObjectMapper serializer) { + if (uri == null) { + throw new IllegalArgumentException("uri must not be null."); + } + if (executor == null) { + throw new IllegalArgumentException("executor must not be null."); + } + if (serializer == null) { + throw new IllegalArgumentException("serializer must not be null."); + } + + this.uri = uri; + this.executor = executor; + this.serializer = serializer; + } + + /** + * Creates a new REST endpoint with a relative URI. + * + * @param referrer The parent endpoint containing this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. + */ + protected AbstractEndpoint(Endpoint referrer, URI relativeUri) { + this(referrer.getUri().resolve(relativeUri), referrer.getExecutor(), referrer.getSerializer()); + + if (referrer instanceof AbstractEndpoint) { + defaultHeaders.addAll(((AbstractEndpoint) referrer).defaultHeaders); + } + } + + /** + * Creates a new REST endpoint with a relative URI. + * + * @param referrer The parent endpoint containing this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Prefix ./ to append a trailing + * slash to the referrer URI if missing. Prefix ./ + * to append a trailing slash to the referrer URI if missing. + */ + protected AbstractEndpoint(Endpoint referrer, String relativeUri) { + this((relativeUri.startsWith("./") ? ensureTrailingSlash(referrer.getUri()) : referrer.getUri()) + .resolve(relativeUri), referrer.getExecutor(), referrer.getSerializer()); + + if (referrer instanceof AbstractEndpoint) { + defaultHeaders.addAll(((AbstractEndpoint) referrer).defaultHeaders); + } + } + + /** + * 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 method is not thread-safe! Call this before performing any requests. + * + * @param rel The relation type of the links to add. + * @param hrefs The hrefs of links relative to this endpoint's URI. Use + * null or an empty list to remove all previous entries for the + * relation type. + * + * @see Endpoint#getLinks(java.lang.String) + * @see Endpoint#getLinksWithTitles(java.lang.String) + * @see Endpoint#link(java.lang.String) + */ + public final void setDefaultLink(String rel, String... hrefs) { + if (hrefs == null || hrefs.length == 0) { + defaultLinks.remove(rel); + } else { + defaultLinks.put(rel, + stream(hrefs).map(uri::resolve).collect(toSet())); + } + } + + /** + * 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 method is not thread-safe! Call this before performing any requests. + * + * @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. + * + * @see Endpoint#linkTemplate(java.lang.String) + */ + public final void setDefaultLinkTemplate(String rel, String href) { + if (href == null) { + defaultLinkTemplates.remove(rel); + } else { + defaultLinkTemplates.put(rel, href); + } + } + + /** + * Executes a REST request and wraps HTTP status codes in appropriate + * {@link Exception} types. + * + * @param request The request to execute. + * @return The HTTP response to the request. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException + * {@link HttpStatus#SC_CONFLICT}, {@link HttpStatus#SC_PRECONDITION_FAILED} + * or {@link HttpStatus#SC_REQUESTED_RANGE_NOT_SATISFIABLE} + * @throws RuntimeException Other non-success status code. + */ + protected HttpResponse executeAndHandle(Request request) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + HttpResponse response = execute(request); + handleResponse(response, request); + return response; + } + + /** + * Executes a REST request adding any configured {@link #defaultHeaders}. + * + * @param request The request to execute. + * @return The HTTP response to the request. + * + * @throws IOException Network communication failed. + * @throws RuntimeException Other non-success status code. + */ + protected HttpResponse execute(Request request) + throws IOException { + defaultHeaders.forEach(request::addHeader); + return executor.execute(request).returnResponse(); + } + + /** + * Handles the response of a REST request and wraps HTTP status codes in + * appropriate {@link Exception} types. + * + * @param response The response to handle. + * @param request The original request the reponse is for. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException + * {@link HttpStatus#SC_CONFLICT}, {@link HttpStatus#SC_PRECONDITION_FAILED} + * or {@link HttpStatus#SC_REQUESTED_RANGE_NOT_SATISFIABLE} + * @throws RuntimeException Other non-success status code. + */ + protected void handleResponse(HttpResponse response, Request request) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + handleLinks(response); + handleCapabilities(response); + handleErrors(response, request); + } + + /** + * Wraps HTTP status codes in appropriate {@link Exception} types. + * + * @param response The response to check for errors. + * @param request The original request the reponse is for. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException + * {@link HttpStatus#SC_CONFLICT}, {@link HttpStatus#SC_PRECONDITION_FAILED} + * or {@link HttpStatus#SC_REQUESTED_RANGE_NOT_SATISFIABLE} + * @throws RuntimeException Other non-success status code. + */ + protected void handleErrors(HttpResponse response, Request request) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + StatusLine statusLine = response.getStatusLine(); + if (statusLine.getStatusCode() <= 299) { + return; + } + + String message = request + " responded with " + statusLine.getStatusCode() + " " + statusLine.getReasonPhrase(); + + HttpEntity entity = response.getEntity(); + String body; + if (entity == null) { + body = null; + } else { + body = EntityUtils.toString(entity); + Header contentType = entity.getContentType(); + if ((contentType != null) && contentType.getValue().startsWith("application/json")) { + try { + JsonNode messageNode = serializer.readTree(body).get("message"); + if (messageNode != null) { + message = messageNode.asText(); + } + } catch (JsonProcessingException ex) { + } + } + } + + Exception inner = (body == null) ? null : new HttpException(body); + switch (statusLine.getStatusCode()) { + case HttpStatus.SC_BAD_REQUEST: + throw new IllegalArgumentException(message, inner); + case HttpStatus.SC_UNAUTHORIZED: + //throw new InvalidCredentialException(message); + throw new IllegalAccessException(message); + case HttpStatus.SC_FORBIDDEN: + throw new IllegalAccessException(message); + case HttpStatus.SC_NOT_FOUND: + case HttpStatus.SC_GONE: + throw new FileNotFoundException(message); + case HttpStatus.SC_CONFLICT: + throw new IllegalStateException(message, inner); + case HttpStatus.SC_PRECONDITION_FAILED: + //throw new VersionNotFoundException(message, inner); + throw new IllegalStateException(message, inner); + case HttpStatus.SC_REQUESTED_RANGE_NOT_SATISFIABLE: + //throw new IndexOutOfBoundsException(message); + throw new IllegalStateException(message, inner); + case HttpStatus.SC_REQUEST_TIMEOUT: + //throw new TimeoutException(message); + default: + throw new RuntimeException(message, inner); + } + } + + /** + * Handles links embedded in an HTTP response. + * + * @param response The response to check for links. + */ + @SuppressWarnings("LocalVariableHidesMemberVariable") + private void handleLinks(HttpResponse response) { + Map> links = new HashMap<>(); + Map linkTemplates = new HashMap<>(); + + handleHeaderLinks(response, links, linkTemplates); + + HttpEntity entity = response.getEntity(); + if (entity != null) { + Header contentType = entity.getContentType(); + if ((contentType != null) && contentType.getValue().startsWith("application/json")) { + try { + handleBodyLinks(serializer.readTree(entity.getContent()), links, linkTemplates); + } catch (IOException ex) { + throw new RuntimeException(); + // Body error handling is done elsewhere + } + } + } + + this.links = unmodifiableMap(links); + this.linkTemplates = unmodifiableMap(linkTemplates); + } + + /** + * Handles links embedded in HTTP response headers. + * + * @param response The response to check for links. + * @param links A dictionary to add found links to. + * @param linkTemplates A dictionary to add found link templates to. + */ + protected void handleHeaderLinks(HttpResponse response, Map> links, Map linkTemplates) { + getLinkHeaders(response).forEach(header -> { + if (header.getRel() == null) { + return; + } + if (header.isTemplated()) { + linkTemplates.put(header.getRel(), header.getHref()); + } else { + getOrAdd(links, header.getRel()) + .put(uri.resolve(header.getHref()), header.getTitle()); + } + }); + } + + /** + * Handles links embedded in JSON response bodies. + * + * @param jsonBody The body to check for links. + * @param links A dictionary to add found links to. + * @param linkTemplates A dictionary to add found link templates to. + */ + protected void handleBodyLinks(JsonNode jsonBody, Map> links, Map linkTemplates) { + if (jsonBody.getNodeType() != JsonNodeType.OBJECT) { + return; + } + + JsonNode linksNode = jsonBody.get("_links"); + if (linksNode == null) { + linksNode = jsonBody.get("links"); + } + if (linksNode == null) { + return; + } + + linksNode.fields().forEachRemaining(x -> { + String rel = x.getKey(); + Map linksForRel = getOrAdd(links, rel); + + switch (x.getValue().getNodeType()) { + case ARRAY: + x.getValue().forEach(subobj -> { + if (subobj.getNodeType() == JsonNodeType.OBJECT) { + parseLinkObject(rel, (ObjectNode) subobj, linksForRel, linkTemplates); + } + }); + break; + case OBJECT: + parseLinkObject(rel, (ObjectNode) x.getValue(), linksForRel, linkTemplates); + break; + } + }); + } + + /** + * Parses a JSON object for link information. + * + * @param rel The relation type of the link. + * @param obj The JSON object to parse for link information. + * @param linksForRel A dictionary to add found links to. Maps hrefs to + * titles. + * @param linkTemplates A dictionary to add found link templates to. Maps + * rels to templated hrefs. + */ + private void parseLinkObject(String rel, ObjectNode obj, Map linksForRel, Map linkTemplates) { + JsonNode href = obj.findValue("href"); + if (href == null) { + return; + } + + JsonNode templated = obj.findValue("templated"); + if (templated != null && templated.isBoolean() && templated.asBoolean()) { + linkTemplates.put(rel, href.asText()); + } else { + JsonNode title = obj.findValue("title"); + linksForRel.put( + uri.resolve(href.asText()), + (title != null && title.getNodeType() == JsonNodeType.STRING) ? title.asText() : null); + } + } + + /** + * Returns the element with the specified key from the map. Creates, adds + * and returns a new element if no match was found. + * + * @param map The map to look in. + * @param key The key to look for. + * @return The existing or new element. + */ + private static Map getOrAdd(Map> map, String key) { + Map value = map.get(key); + if (value == null) { + map.put(key, value = new HashMap<>()); + } + return value; + } + + // NOTE: Always replace entire dictionary rather than modifying it to ensure thread-safety. + private Map> links = unmodifiableMap(new HashMap<>()); + + // NOTE: Only modify during initial setup + private final Map> defaultLinks = new HashMap<>(); + + @Override + public Set getLinks(String rel) { + Map linksForRel = links.get(rel); + if (linksForRel != null) { + return linksForRel.keySet(); + } + + Set defaulLinksForRel = defaultLinks.get(rel); + if (defaulLinksForRel != null) { + return defaulLinksForRel; + } + + return new HashSet<>(); + } + + @Override + public Map getLinksWithTitles(String rel) { + Map linksForRel = links.get(rel); + if (linksForRel != null) { + return linksForRel; + } + + Set defaulLinksForRel = defaultLinks.get(rel); + if (defaulLinksForRel != null) { + Map result = new HashMap<>(); + defaulLinksForRel.forEach(link -> result.put(link, null)); + return result; + } + + return new HashMap<>(); + } + + @Override + public URI link(String rel) { + Set linkSet = getLinks(rel); + if (linkSet.isEmpty()) { + // Lazy lookup + try { + executeAndHandle(Request.Head(uri)); + } catch (IOException | IllegalAccessException | RuntimeException ex) { + throw new RuntimeException("No link with rel=" + rel + " provided by endpoint " + getUri() + ".", ex); + } + + linkSet = getLinks(rel); + if (linkSet.isEmpty()) { + throw new RuntimeException("No link with rel=" + rel + " provided by endpoint " + getUri() + "."); + } + } + + return linkSet.iterator().next(); + } + + // NOTE: Always replace entire dictionary rather than modifying it to ensure thread-safety. + private Map linkTemplates = unmodifiableMap(new HashMap<>()); + + // NOTE: Only modify during initial setup + private final Map defaultLinkTemplates = new HashMap<>(); + + @Override + public UriTemplate linkTemplate(String rel) { + String template = linkTemplates.get(rel); + if (template == null) { + template = defaultLinkTemplates.get(rel); + } + if (template == null) { + // Lazy lookup + try { + executeAndHandle(Request.Head(uri)); + } catch (IOException | IllegalAccessException | RuntimeException ex) { + // HTTP HEAD server-side implementation is optional + } + + if (linkTemplates != null) { + template = linkTemplates.get(rel); + if (template == null) { + throw new RuntimeException("No link template with rel=" + rel + " provided by endpoint " + getUri() + "."); + } + } + } + + // Create new template instance for each request because UriTemplate is not immutable + return UriTemplate.fromTemplate(template); + } + + /** + * Handles allowed HTTP methods and other capabilities reported by the + * server. + * + * @param response The response to check for the "Allow" header. + */ + protected void handleCapabilities(HttpResponse response) { + allowedMethods = unmodifiableSet(stream(response.getHeaders("Allow")) + .filter(x -> x.getName().equals("Allow")) + .flatMap(x -> stream(x.getElements())).map(x -> x.getName()) + .collect(toSet())); + } + + // NOTE: Always replace entire set rather than modifying it to ensure thread-safety. + private Set allowedMethods = unmodifiableSet(new HashSet<>()); + + /** + * Shows whether the server has indicated that a specific HTTP method is + * currently allowed. + * + * Uses cached data from last response. + * + * @param method The HTTP method (e.g. GET, POST, ...) to check. + * @return An indicator whether the method is allowed. If no request has been + * sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + protected Optional isMethodAllowed(String method) { + if (allowedMethods.isEmpty()) { + return Optional.empty(); + } + return Optional.of(allowedMethods.contains(method)); + } + + @Override + public String toString() { + return getClass().getName() + ": " + uri; + } +} diff --git a/core/src/main/java/net/typedrest/AbstractPagedCollectionEndpoint.java b/core/src/main/java/net/typedrest/AbstractPagedCollectionEndpoint.java new file mode 100644 index 00000000..9b9d3440 --- /dev/null +++ b/core/src/main/java/net/typedrest/AbstractPagedCollectionEndpoint.java @@ -0,0 +1,46 @@ +package net.typedrest; + +import java.net.URI; + +/** + * Base class for building REST endpoints that represents a collection of + * TEntitys as TElementEndpoints with pagination + * support using the HTTP Range header. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + * + * @deprecated Use {@link CollectionEndpoint} instead. + */ +@Deprecated +public abstract class AbstractPagedCollectionEndpoint + extends AbstractCollectionEndpoint implements GenericPagedCollectionEndpoint { + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractPagedCollectionEndpoint(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractPagedCollectionEndpoint(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } +} diff --git a/core/src/main/java/net/typedrest/AbstractStreamEndpoint.java b/core/src/main/java/net/typedrest/AbstractStreamEndpoint.java new file mode 100644 index 00000000..d6113cb0 --- /dev/null +++ b/core/src/main/java/net/typedrest/AbstractStreamEndpoint.java @@ -0,0 +1,90 @@ +package net.typedrest; + +import java.io.IOException; +import java.net.*; +import rx.*; +import rx.schedulers.Schedulers; +import rx.util.async.StoppableObservable; +import static rx.util.async.Async.runAsync; + +/** + * Base class for building REST endpoints that represents a stream of + * TEntitys as TElementEndpoints. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + */ +public abstract class AbstractStreamEndpoint + extends AbstractCollectionEndpoint implements GenericStreamEndpoint { + + /** + * Creates a new element stream endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractStreamEndpoint(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + /** + * Creates a new element stream endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + protected AbstractStreamEndpoint(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + @Override + public StoppableObservable getObservable(long startIndex) { + return getObservable(startIndex, Schedulers.newThread()); + } + + /** + * Provides an observable stream of elements. + * + * @param startIndex The index of the first element to return in the stream. + * Use negative values to start counting from the end of the stream. + * @param scheduler The scheduler used to run the background thread. + * @return An observable stream of elements. + */ + StoppableObservable getObservable(final long startIndex, Scheduler scheduler) { + return runAsync(scheduler, (rx.Observer observer, Subscription subscription) -> { + long currentStartIndex = startIndex; + while (!subscription.isUnsubscribed()) { + PartialResponse response; + try { + response = (currentStartIndex >= 0) + ? readRange(currentStartIndex, null) + : readRange(null, -currentStartIndex); + } catch (IllegalStateException ex) { + // No new data available yet, keep polling + continue; + } catch (IOException | IllegalArgumentException | IllegalAccessException error) { + observer.onError(error); + return; + } + + response.getElements().stream().forEach(observer::onNext); + + if (response.isEndReached()) { + observer.onCompleted(); + return; + } + + // Continue polling for more data + currentStartIndex = response.getTo() + 1; + } + }); + } +} diff --git a/core/src/main/java/net/typedrest/AbstractTriggerEndpoint.java b/core/src/main/java/net/typedrest/AbstractTriggerEndpoint.java new file mode 100644 index 00000000..a97c89b1 --- /dev/null +++ b/core/src/main/java/net/typedrest/AbstractTriggerEndpoint.java @@ -0,0 +1,33 @@ +package net.typedrest; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.util.Optional; +import org.apache.http.client.fluent.Request; + +/** + * Base class for building REST RPC-like endpoints. + */ +public abstract class AbstractTriggerEndpoint + extends AbstractEndpoint + implements TriggerEndpoint { + + protected AbstractTriggerEndpoint(Endpoint referrer, URI relativeUri) { + super(referrer, relativeUri); + } + + protected AbstractTriggerEndpoint(Endpoint referrer, String relativeUri) { + super(referrer, relativeUri); + } + + @Override + public void probe() throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + executeAndHandle(Request.Options(uri)); + } + + @Override + public Optional isTriggerAllowed() { + return isMethodAllowed("POST"); + } +} diff --git a/core/src/main/java/net/typedrest/ActionEndpoint.java b/core/src/main/java/net/typedrest/ActionEndpoint.java new file mode 100644 index 00000000..d22c60bb --- /dev/null +++ b/core/src/main/java/net/typedrest/ActionEndpoint.java @@ -0,0 +1,26 @@ +package net.typedrest; + +import java.io.*; +import org.apache.http.*; + +/** + * REST endpoint that represents an RPC-like action. + */ +public interface ActionEndpoint + extends TriggerEndpoint { + + /** + * Triggers the action. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void trigger() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; +} diff --git a/core/src/main/java/net/typedrest/ActionEndpointImpl.java b/core/src/main/java/net/typedrest/ActionEndpointImpl.java new file mode 100644 index 00000000..08a32b44 --- /dev/null +++ b/core/src/main/java/net/typedrest/ActionEndpointImpl.java @@ -0,0 +1,42 @@ +package net.typedrest; + +import java.io.*; +import java.net.*; +import org.apache.http.client.fluent.*; + +/** + * REST endpoint that represents a single RPC-like action. + */ +public class ActionEndpointImpl + extends AbstractTriggerEndpoint + implements ActionEndpoint { + + /** + * Creates a new action 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. + */ + public ActionEndpointImpl(Endpoint referrer, URI relativeUri) { + super(referrer, relativeUri); + } + + /** + * Creates a new action 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. Prefix ./ to append a trailing + * slash to the referrer URI if missing. + */ + public ActionEndpointImpl(Endpoint referrer, String relativeUri) { + super(referrer, relativeUri); + } + + @Override + public void trigger() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + executeAndHandle(Request.Post(uri)); + } +} diff --git a/core/src/main/java/net/typedrest/BeanUtils.java b/core/src/main/java/net/typedrest/BeanUtils.java new file mode 100644 index 00000000..9e9fe528 --- /dev/null +++ b/core/src/main/java/net/typedrest/BeanUtils.java @@ -0,0 +1,107 @@ +package net.typedrest; + +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.util.*; +import static java.util.Arrays.stream; +import lombok.SneakyThrows; +import static org.apache.commons.lang3.reflect.FieldUtils.getField; + +/** + * Utility methods for Java beans (classes with getters and setters). + */ +public final class BeanUtils { + + private BeanUtils() { + } + + /** + * Lists all properties on a bean type. Ensures properties annotated with + * {@link Id} or called "name" are always listed first. + * + * @param beanType The type of bean to check for properties. + * @return A list of properties. + */ + @SneakyThrows + public static List getProperties(Class beanType) { + LinkedList properties = new LinkedList<>(); + for (PropertyDescriptor property : Introspector.getBeanInfo(beanType).getPropertyDescriptors()) { + if (getAnnotation(beanType, property, Id.class).isPresent() || property.getName().equals("name")) { + properties.addFirst(property); + } else { + properties.add(property); + } + } + return properties; + } + + /** + * Lists all properties on a bean type that have a specific annotation on + * their getter or backing field. + * + * @param The annotation type to look for. + * @param beanType The type of bean to check for properties. + * @param annotationType The annotation type to look for. + * @return A list of matching properties. + */ + public static List getPropertiesWithAnnotation(Class beanType, Class annotationType) { + LinkedList result = new LinkedList<>(); + getProperties(beanType).forEach(property -> { + if (property.getReadMethod() != null && property.getReadMethod().getAnnotation(annotationType) != null + || isFieldAnnotated(beanType, property.getName(), annotationType)) { + result.add(property); + } + }); + return result; + } + + /** + * Lists all readable and writable properties on a bean type that do not + * have a specific annotation on their getter or backing field. + * + * @param The annotation type to look for. + * @param beanType The type of bean to check for properties. + * @param annotationType The annotation type to look for. + * @return A list of matching properties. + */ + public static List getPropertiesWithoutAnnotation(Class beanType, Class annotationType) { + LinkedList result = new LinkedList<>(); + getProperties(beanType).forEach(property -> { + if ((property.getReadMethod() == null || property.getReadMethod().getAnnotation(annotationType) == null) + && !isFieldAnnotated(beanType, property.getName(), annotationType)) { + result.add(property); + } + }); + return result; + } + + private static boolean isFieldAnnotated(Class beanType, String fieldName, Class annotationType) { + Field field = getField(beanType, fieldName, true); + return (field != null) && (field.getAnnotation(annotationType) != null); + } + + /** + * Returns an annotation of a specific type on a property's getter or its + * backing field. + * + * @param The type of annotation to look for. + * @param beanType The type of bean the property is declared on. + * @param property The property to check for the annotation. + * @param annotationType The type of annotation to look for. + * @return The annotation if present. + */ + public static Optional getAnnotation(Class beanType, PropertyDescriptor property, Class annotationType) { + Optional annotation = stream(property.getReadMethod().getAnnotationsByType(annotationType)).findAny(); + return annotation.isPresent() + ? annotation + : getAnnotationOnField(beanType, property.getName(), annotationType); + } + + private static Optional getAnnotationOnField(Class beanType, String fieldName, Class annotationType) { + Field field = getField(beanType, fieldName, true); + TAnnotation annotation = (field == null) ? null : field.getAnnotation(annotationType); + return Optional.ofNullable(annotation); + } +} diff --git a/core/src/main/java/net/typedrest/BlobEndpoint.java b/core/src/main/java/net/typedrest/BlobEndpoint.java new file mode 100644 index 00000000..2ea5a6c6 --- /dev/null +++ b/core/src/main/java/net/typedrest/BlobEndpoint.java @@ -0,0 +1,153 @@ +package net.typedrest; + +import java.io.*; +import java.util.Optional; +import org.apache.http.*; +import org.apache.http.entity.ContentType; + +/** + * REST endpoint that represents a single binary blob that can downloaded and + * uploaded. + */ +public interface BlobEndpoint extends Endpoint { + + /** + * Queries the server about capabilities of the endpoint without performing + * any action. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void probe() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that {@link #download()} is + * currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isDownloadAllowed(); + + /** + * Downloads the blob's content. + * + * @return A stream with the blob's content. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + InputStream download() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that + * {@link #upload(java.io.InputStream, org.apache.http.entity.ContentType)} + * is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isUploadAllowed(); + + /** + * Uploads new content for the blob. + * + * @param stream A stream with the blob's new content. + * @param contentType The MIME type of the file to upload. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void upload(InputStream stream, ContentType contentType) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException; + + /** + * Uploads a byte array as the blob's content. + * + * @param data The byte array holding the data to upload. + * @param contentType The MIME type of the file to upload. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void upload(byte[] data, ContentType contentType) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException; + + /** + * Uploads a local file as the blob's content. + * + * @param file The local file to read the data from. + * @param contentType The MIME type of the file to upload. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void upload(File file, ContentType contentType) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that {@link #delete()} is + * currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isDeleteAllowed(); + + /** + * Deletes the blob from the server. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void delete() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException; + +} diff --git a/core/src/main/java/net/typedrest/BlobEndpointImpl.java b/core/src/main/java/net/typedrest/BlobEndpointImpl.java new file mode 100644 index 00000000..f1cbbcc5 --- /dev/null +++ b/core/src/main/java/net/typedrest/BlobEndpointImpl.java @@ -0,0 +1,70 @@ +package net.typedrest; + +import java.io.*; +import java.net.URI; +import java.util.Optional; +import org.apache.http.*; +import org.apache.http.client.fluent.*; +import org.apache.http.entity.ContentType; + +/** + * REST endpoint that represents a single binary blob that can downloaded and + * uploaded. + */ +public class BlobEndpointImpl + extends AbstractEndpoint implements BlobEndpoint { + + public BlobEndpointImpl(Endpoint referrer, URI relativeUri) { + super(referrer, relativeUri); + } + + public BlobEndpointImpl(Endpoint referrer, String relativeUri) { + super(referrer, relativeUri); + } + + @Override + public void probe() throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + executeAndHandle(Request.Options(uri)); + } + + @Override + public Optional isDownloadAllowed() { + return isMethodAllowed("GET"); + } + + @Override + public InputStream download() throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + HttpResponse response = executeAndHandle(Request.Get(uri)); + return response.getEntity().getContent(); + } + + @Override + public Optional isUploadAllowed() { + return isMethodAllowed("PUT"); + } + + @Override + public void upload(InputStream stream, ContentType contentType) throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + executeAndHandle(Request.Put(uri).bodyStream(stream, contentType)); + } + + @Override + public void upload(byte[] data, ContentType contentType) throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + executeAndHandle(Request.Put(uri).bodyByteArray(data, contentType)); + } + + @Override + public void upload(File file, ContentType contentType) throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + executeAndHandle(Request.Put(uri).bodyFile(file, contentType)); + } + + @Override + public Optional isDeleteAllowed() { + return isMethodAllowed("DELETE"); + } + + @Override + public void delete() throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + executeAndHandle(Request.Delete(uri)); + } +} diff --git a/core/src/main/java/net/typedrest/BulkCollectionEndpoint.java b/core/src/main/java/net/typedrest/BulkCollectionEndpoint.java new file mode 100644 index 00000000..749163bb --- /dev/null +++ b/core/src/main/java/net/typedrest/BulkCollectionEndpoint.java @@ -0,0 +1,16 @@ +package net.typedrest; + +import org.apache.http.*; + +/** + * REST endpoint that represents a collection of TEntitys as + * {@link ElementEndpoint}s with bulk create and replace support. + * + * @param The type of entity the endpoint represents. + * + * @deprecated Use {@link CollectionEndpoint} instead. + */ +@Deprecated +public interface BulkCollectionEndpoint + extends GenericBulkCollectionEndpoint>, CollectionEndpoint { +} diff --git a/core/src/main/java/net/typedrest/BulkCollectionEndpointImpl.java b/core/src/main/java/net/typedrest/BulkCollectionEndpointImpl.java new file mode 100644 index 00000000..f07f1002 --- /dev/null +++ b/core/src/main/java/net/typedrest/BulkCollectionEndpointImpl.java @@ -0,0 +1,49 @@ +package net.typedrest; + +import java.net.*; + +/** + * REST endpoint that represents a collection of TEntitys as + * {@link ElementEndpoint}s with bulk create and replace support. + * + * @param The type of entity the endpoint represents. + * + * @deprecated Use {@link CollectionEndpointImpl} instead. + */ +@Deprecated +public class BulkCollectionEndpointImpl + extends AbstractBulkCollectionEndpoint> + implements BulkCollectionEndpoint { + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + public BulkCollectionEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + public BulkCollectionEndpointImpl(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + @Override + protected ElementEndpoint buildElementEndpoint(URI relativeUri) { + return new ElementEndpointImpl<>(this, relativeUri, entityType); + } +} diff --git a/core/src/main/java/net/typedrest/CollectionEndpoint.java b/core/src/main/java/net/typedrest/CollectionEndpoint.java new file mode 100644 index 00000000..aea390ed --- /dev/null +++ b/core/src/main/java/net/typedrest/CollectionEndpoint.java @@ -0,0 +1,132 @@ +package net.typedrest; + +import java.io.FileNotFoundException; +import java.io.IOException; +import org.apache.http.*; + +/** + * REST endpoint that represents a collection of TEntitys as + * {@link ElementEndpoint}s. + * + * @param The type of entity the endpoint represents. + */ +public interface CollectionEndpoint + extends GenericCollectionEndpoint> { + + /** + * Determines whether the collection contains a specific entity. + * + * @param id The ID identifying the entity in the collection. + * @return true if the entity currently exists, + * false if it does not. + * + * @throws IOException Network communication failed. + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws RuntimeException Other non-success status code. + */ + default boolean contains(String id) + throws IOException, IllegalAccessException { + return get(id).exists(); + } + + /** + * Determines whether the collection contains a specific entity. + * + * @param element The element to be checked. + * @return true if the entity currently exists, + * false if it does not. + * + * @throws IOException Network communication failed. + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws RuntimeException Other non-success status code. + */ + default boolean contains(TEntity element) + throws IOException, IllegalAccessException { + return get(element).exists(); + } + + /** + * Sets/replaces an existing element in the collection. + * + * @param element The new state of the element. + * @return The TEntity as returned by the server, possibly with + * additional fields set. null if the server does not respond + * with a result entity. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved. Your delete call was rejected to prevent a lost update. + * @throws RuntimeException Other non-success status code. + */ + default TEntity set(TEntity element) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + return get(element).set(element); + } + + /** + * Modifies an existing element in the collection by merging changes. + * + * @param element The TEntity data to merge with the existing + * element. + * @return The TEntity as returned by the server, possibly with + * additional fields set. null if the server does not respond + * with a result entity. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved. Your delete call was rejected to prevent a lost update. + * @throws RuntimeException Other non-success status code. + */ + default TEntity merge(TEntity element) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + return get(element).merge(element); + } + + /** + * Deletes an existing element from the collection. + * + * @param id The ID identifying the entity in the collection. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved. Your delete call was rejected to prevent a lost update. + * @throws RuntimeException Other non-success status code. + */ + default void delete(String id) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + get(id).delete(); + } + + /** + * Deletes an existing element from the collection. + * + * @param element The element to be deleted. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved. Your delete call was rejected to prevent a lost update. + * @throws RuntimeException Other non-success status code. + */ + default void delete(TEntity element) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + get(element).delete(); + } +} diff --git a/core/src/main/java/net/typedrest/CollectionEndpointImpl.java b/core/src/main/java/net/typedrest/CollectionEndpointImpl.java new file mode 100644 index 00000000..cd42a940 --- /dev/null +++ b/core/src/main/java/net/typedrest/CollectionEndpointImpl.java @@ -0,0 +1,46 @@ +package net.typedrest; + +import java.net.*; + +/** + * REST endpoint that represents a collection of TEntitys as + * {@link ElementEndpoint}s. + * + * @param The type of entity the endpoint represents. + */ +public class CollectionEndpointImpl + extends AbstractCollectionEndpoint> + implements CollectionEndpoint { + + /** + * 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. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + public CollectionEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, 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. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + public CollectionEndpointImpl(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + @Override + protected ElementEndpoint buildElementEndpoint(URI relativeUri) { + return new ElementEndpointImpl<>(this, relativeUri, entityType); + } +} diff --git a/core/src/main/java/net/typedrest/ConsumerEndpoint.java b/core/src/main/java/net/typedrest/ConsumerEndpoint.java new file mode 100644 index 00000000..ede9202e --- /dev/null +++ b/core/src/main/java/net/typedrest/ConsumerEndpoint.java @@ -0,0 +1,37 @@ +package net.typedrest; + +import java.io.*; +import org.apache.http.*; + +/** + * REST endpoint that represents an RPC-like action which takes + * TEntity as input. + * + * @param The type of entity the endpoint takes as input. + */ +public interface ConsumerEndpoint + extends TriggerEndpoint { + + /** + * Returns the type of entity the endpoint takes as input. + * + * @return The class type. + */ + Class getEntityType(); + + /** + * Triggers the action. + * + * @param entity The TEntity to post as input. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void trigger(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; +} diff --git a/core/src/main/java/net/typedrest/ConsumerEndpointImpl.java b/core/src/main/java/net/typedrest/ConsumerEndpointImpl.java new file mode 100644 index 00000000..48875fac --- /dev/null +++ b/core/src/main/java/net/typedrest/ConsumerEndpointImpl.java @@ -0,0 +1,59 @@ +package net.typedrest; + +import java.io.*; +import java.net.*; +import lombok.Getter; +import org.apache.http.client.fluent.*; +import org.apache.http.entity.*; + +/** + * REST endpoint that represents an RPC-like action which takes + * TEntity as input. + * + * @param The type of entity the endpoint takes as input. + */ +public class ConsumerEndpointImpl + extends AbstractTriggerEndpoint + implements ConsumerEndpoint { + + @Getter + private final Class entityType; + + /** + * Creates a new action 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. + * @param entityType The type of entity the endpoint takes as input. + */ + public ConsumerEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri); + this.entityType = entityType; + } + + /** + * Creates a new action 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. Prefix ./ to append a trailing + * slash to the referrer URI if missing. + * @param entityType The type of entity the endpoint takes as input. + */ + public ConsumerEndpointImpl(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri); + this.entityType = entityType; + } + + @Override + public void trigger(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + if (entity == null) { + throw new IllegalArgumentException("entity must not be null."); + } + + String jsonSend = serializer.writeValueAsString(entity); + executeAndHandle(Request.Post(uri).bodyString(jsonSend, ContentType.APPLICATION_JSON)); + } +} diff --git a/core/src/main/java/net/typedrest/ElementEndpoint.java b/core/src/main/java/net/typedrest/ElementEndpoint.java new file mode 100644 index 00000000..246fb811 --- /dev/null +++ b/core/src/main/java/net/typedrest/ElementEndpoint.java @@ -0,0 +1,234 @@ +package net.typedrest; + +import java.io.*; +import java.util.Optional; +import java.util.function.Consumer; +import org.apache.http.*; + +/** + * REST endpoint that represents a single entity. + * + * @param The type of entity the endpoint represents. + */ +public interface ElementEndpoint + extends Endpoint { + + /** + * Returns the type of entity the endpoint represents. + * + * @return The class type. + */ + Class getEntityType(); + + /** + * Returns the specific TEntity. + * + * @return The specific TEntity. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + TEntity read() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Determines whether the entity currently exists. + * + * @return true if the entity currently exists, + * false if it does not. + * + * @throws IOException Network communication failed. + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws RuntimeException Other non-success status code. + */ + boolean exists() + throws IOException, IllegalAccessException; + + /** + * Shows whether the server has indicated that + * {@link #set(java.lang.Object)} is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isSetAllowed(); + + /** + * Sets/replaces the TEntity. + * + * @param entity The new TEntity. + * @return The TEntity as returned by the server, possibly with + * additional fields set. null if the server does not respond + * with a result entity. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #read()}. Your changes were rejected to prevent a + * lost update. + * @throws RuntimeException Other non-success status code. + */ + TEntity set(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Sets/replaces the TEntity. + * + * @param entity The new TEntity. + * @return The TEntity as returned by the server, possibly with + * additional fields set. null if the server does not respond + * with a result entity. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #read()}. Your changes were rejected to prevent a + * lost update. + * @throws RuntimeException Other non-success status code. + * + * @deprecated Use {@link #set(java.lang.Object)} instead. + */ + @Deprecated + default TEntity update(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + return set(entity); + } + + /** + * 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 applies the desired modifications. + * @return The TEntity as returned by the server, possibly with + * additional fields set. null if the server does not respond + * with a result entity. + * @throws IOException + * @throws IllegalArgumentException + * @throws IllegalAccessException + * @throws FileNotFoundException + * @throws IllegalStateException + */ + default TEntity update(Consumer updateAction) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + return update(updateAction, 3); + } + + /** + * 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 applies the desired modifications. + * @param maxRetries The maximum number of retries to perform for optimistic + * concurrency before giving up. + * @return The TEntity as returned by the server, possibly with + * additional fields set. null if the server does not respond + * with a result entity. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #read()}. Your changes were rejected to prevent a + * lost update. + * @throws RuntimeException Other non-success status code. + */ + default TEntity update(Consumer updateAction, int maxRetries) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + int retryCounter = 0; + while (true) { + TEntity entity = read(); + updateAction.accept(entity); + + try { + return set(entity); + } catch (IllegalStateException ex) { + if (retryCounter++ >= maxRetries) { + throw ex; + } + } + } + } + + /** + * Shows whether the server has indicated that + * {@link #merge(java.lang.Object)} is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isMergeAllowed(); + + /** + * Modifies an existing TEntity by merging changes. + * + * @param entity The TEntity data to merge with the existing + * one. + * @return The TEntity as returned by the server, possibly with + * additional fields set. null if the server does not respond + * with a result entity. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #read()}. Your changes were rejected to prevent a + * lost update. + * @throws RuntimeException Other non-success status code. + */ + TEntity merge(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that {@link #delete()} is + * currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isDeleteAllowed(); + + /** + * Deletes the TEntity. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #read()}. Your delete call was rejected to prevent + * a lost update. + * @throws RuntimeException Other non-success status code. + */ + void delete() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; +} diff --git a/core/src/main/java/net/typedrest/ElementEndpointImpl.java b/core/src/main/java/net/typedrest/ElementEndpointImpl.java new file mode 100644 index 00000000..de56fef1 --- /dev/null +++ b/core/src/main/java/net/typedrest/ElementEndpointImpl.java @@ -0,0 +1,117 @@ +package net.typedrest; + +import java.io.*; +import java.net.*; +import java.util.Optional; +import lombok.Getter; +import org.apache.http.*; +import org.apache.http.client.fluent.*; +import org.apache.http.entity.*; +import org.apache.http.util.*; + +/** + * REST endpoint that represents a single TEntity. + * + * @param The type of entity the endpoint represents. + */ +public class ElementEndpointImpl + extends AbstractETagEndpoint implements ElementEndpoint { + + @Getter + private final Class entityType; + + /** + * 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. + * @param entityType The type of entity the endpoint represents. + */ + public ElementEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri); + this.entityType = entityType; + } + + /** + * 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. Prefix ./ to append a trailing + * slash to the referrer URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + public ElementEndpointImpl(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri); + this.entityType = entityType; + } + + @Override + public TEntity read() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + HttpEntity content = getContent(); + return serializer.readValue(EntityUtils.toString(content), entityType); + } + + @Override + public boolean exists() + throws IOException, IllegalAccessException { + try { + executeAndHandle(Request.Head(uri)); + } catch (FileNotFoundException ex) { + return false; + } + return true; + } + + @Override + public Optional isSetAllowed() { + return isMethodAllowed("PUT"); + } + + @Override + public TEntity set(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + if (entity == null) { + throw new IllegalArgumentException("entity must not be null."); + } + + HttpEntity content = new StringEntity(serializer.writeValueAsString(entity), ContentType.APPLICATION_JSON); + HttpResponse response = putContent(content); + return (response.getEntity() == null) + ? null + : serializer.readValue(EntityUtils.toString(response.getEntity()), entityType); + } + + @Override + public Optional isMergeAllowed() { + return isMethodAllowed("PATCH"); + } + + @Override + public TEntity merge(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + if (entity == null) { + throw new IllegalArgumentException("entity must not be null."); + } + + HttpEntity content = new StringEntity(serializer.writeValueAsString(entity), ContentType.APPLICATION_JSON); + Request request = Request.Patch(uri).body(content); + HttpResponse response = executeAndHandle(request); + return (response.getEntity() == null) + ? null + : serializer.readValue(EntityUtils.toString(response.getEntity()), entityType); + } + + @Override + public Optional isDeleteAllowed() { + return isMethodAllowed("DELETE"); + } + + @Override + public void delete() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + deleteContent(); + } +} diff --git a/core/src/main/java/net/typedrest/Endpoint.java b/core/src/main/java/net/typedrest/Endpoint.java new file mode 100644 index 00000000..b314d6b9 --- /dev/null +++ b/core/src/main/java/net/typedrest/Endpoint.java @@ -0,0 +1,94 @@ +package net.typedrest; + +import com.damnhandy.uri.template.UriTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.*; +import java.util.Map; +import java.util.Set; +import org.apache.http.client.fluent.*; + +/** + * REST endpoint, i.e. a remote HTTP resource. + */ +public interface Endpoint { + + /** + * The HTTP URI of the remote resource. + * + * @return The HTTP URI of the remote resource. + */ + URI getUri(); + + /** + * The REST executor used to communicate with the remote resource. + * + * @return The REST executor used to communicate with the remote resource. + */ + Executor getExecutor(); + + /** + * Controls the serialization of entities sent to and received from the server. + * + * @return Controls the serialization of entities sent to and received from the server. + */ + ObjectMapper getSerializer(); + + /** + * Retrieves all links with a specific relation type cached from the last + * request. + * + * @param rel The relation type of the links to look for. + * @return The hrefs of the links resolved relative to this endpoint's URI. + */ + Set getLinks(String rel); + + /** + * Retrieves all links (with titles) with a specific relation type cached + * from the last request. + * + * @param rel The relation type of the links to look for. + * @return A map of hrefs (resolved relative to this endpoint's URI) to + * titles (may be null). + */ + Map getLinksWithTitles(String rel); + + /** + * Retrieves 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. + * @return The href of the link resolved relative to this endpoint's URI. + * @throws RuntimeException No link with the specified relation type could + * be found. + */ + URI link(String rel); + + /** + * Retrieves 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 template to look for. + * @return The unresolved link template. + * @throws RuntimeException No link template with the specified relation + * type could be found. + */ + UriTemplate linkTemplate(String rel); + + /** + * Retrieves a link template with a specific relation type and resolves it. + * + * @param rel The relation type of the template to look for. + * @param variableName The name of the variable to insert. + * @param value The value to insert for the variable. + * @return The href of the link resolved relative to this endpoint's URI. + * @throws RuntimeException No link template with the specified relation + * type could be found. + */ + default URI linkTemplate(String rel, String variableName, Object value) { + return getUri().resolve(linkTemplate(rel).set(variableName, value).expand()); + } +} diff --git a/core/src/main/java/net/typedrest/EntryEndpoint.java b/core/src/main/java/net/typedrest/EntryEndpoint.java new file mode 100644 index 00000000..d44e4b0d --- /dev/null +++ b/core/src/main/java/net/typedrest/EntryEndpoint.java @@ -0,0 +1,113 @@ +package net.typedrest; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.*; +import static net.typedrest.URIUtils.ensureTrailingSlash; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.*; +import java.util.Base64; +import lombok.SneakyThrows; +import static org.apache.http.HttpHeaders.*; +import org.apache.http.HttpStatus; +import org.apache.http.client.fluent.*; +import static org.apache.http.entity.ContentType.APPLICATION_JSON; +import org.apache.http.message.BasicHeader; + +/** + * Entry point to a REST interface. Derive from this class and add your own set + * of child-{@link Endpoint}s as properties. + */ +public class EntryEndpoint + extends AbstractEndpoint { + + /** + * Creates a new REST interface. + * + * @param uri The base URI of the REST interface. Missing trailing slash + * will be appended automatically. + */ + public EntryEndpoint(URI uri) { + this(uri, defaultSerializer()); + setAcceptJsonHeader(); + } + + /** + * Creates a new REST interface. + * + * @param uri The base URI of the REST interface. Missing trailing slash + * will be appended automatically. + * @param serializer Controls the serialization of entities sent to and + * received from the server. + */ + public EntryEndpoint(URI uri, ObjectMapper serializer) { + super(ensureTrailingSlash(uri), Executor.newInstance(), serializer); + } + + /** + * Creates a new REST interface. + * + * @param uri The base URI of the REST interface. Missing trailing slash + * will be appended automatically. + * @param username The username used to authenticate against the REST + * interface. + * @param password The password used to authenticate against the REST + * interface. + */ + public EntryEndpoint(URI uri, String username, String password) { + this(uri, username, password, defaultSerializer()); + setAcceptJsonHeader(); + } + + private static ObjectMapper defaultSerializer() { + return new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .findAndRegisterModules(); + } + + private void setAcceptJsonHeader() { + defaultHeaders.add(new BasicHeader(ACCEPT, APPLICATION_JSON.getMimeType())); + } + + /** + * Creates a new REST interface. + * + * @param uri The base URI of the REST interface. Missing trailing slash + * will be appended automatically. + * @param username The username used to authenticate against the REST + * interface. + * @param password The password used to authenticate against the REST + * interface. + * @param serializer Controls the serialization of entities sent to and + * received from the server. + */ + public EntryEndpoint(URI uri, String username, String password, ObjectMapper serializer) { + super(ensureTrailingSlash(uri), Executor.newInstance(), serializer); + setBasicAuthHeader(username, password); + } + + @SneakyThrows + private void setBasicAuthHeader(String username, String password) { + byte[] bytesEncoded = Base64.getEncoder().encode((username + ":" + password).getBytes("ISO-8859-1")); + defaultHeaders.add(new BasicHeader(AUTHORIZATION, "Basic " + new String(bytesEncoded))); + } + + /** + * Fetches meta data such as links from the server. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + public void readMeta() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + executeAndHandle(Request.Get(uri)); + } +} diff --git a/core/src/main/java/net/typedrest/FunctionEndpoint.java b/core/src/main/java/net/typedrest/FunctionEndpoint.java new file mode 100644 index 00000000..3d517cb3 --- /dev/null +++ b/core/src/main/java/net/typedrest/FunctionEndpoint.java @@ -0,0 +1,46 @@ +package net.typedrest; + +import java.io.*; +import org.apache.http.*; + +/** + * REST endpoint that represents an RPC-like function which takes + * TEntity as input and returns TResult as output. + * + * @param The type of entity the endpoint takes as input. + * @param The type of entity the endpoint returns as output. + */ +public interface FunctionEndpoint + extends TriggerEndpoint { + + /** + * Returns the type of entity the endpoint takes as input. + * + * @return The class type. + */ + Class getEntityType(); + + /** + * Returns The type of entity the endpoint returns as output. + * + * @return The class type. + */ + Class getResultType(); + + /** + * Triggers the function. + * + * @param entity The TEntity to post as input. + * @return The result returned by the server. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + TResult trigger(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; +} diff --git a/core/src/main/java/net/typedrest/FunctionEndpointImpl.java b/core/src/main/java/net/typedrest/FunctionEndpointImpl.java new file mode 100644 index 00000000..5adf2d35 --- /dev/null +++ b/core/src/main/java/net/typedrest/FunctionEndpointImpl.java @@ -0,0 +1,70 @@ +package net.typedrest; + +import java.io.*; +import java.net.*; +import lombok.Getter; +import org.apache.http.*; +import org.apache.http.client.fluent.*; +import org.apache.http.entity.ContentType; +import org.apache.http.util.*; + +/** + * REST endpoint that represents an RPC-like function which takes + * TEntity as input and returns TResult as output. + * + * @param The type of entity the endpoint takes as input. + * @param The type of entity the endpoint returns as output. + */ +public class FunctionEndpointImpl + extends AbstractTriggerEndpoint + implements FunctionEndpoint { + + @Getter + private final Class entityType; + + @Getter + private final Class resultType; + + /** + * Creates a new function 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. + * @param entityType The type of entity the endpoint takes as input. + * @param resultType The type of entity the endpoint returns as output. + */ + public FunctionEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType, Class resultType) { + super(referrer, relativeUri); + this.entityType = entityType; + this.resultType = resultType; + } + + /** + * Creates a new function 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. Prefix ./ to append a trailing + * slash to the referrer URI if missing. + * @param entityType The type of entity the endpoint takes as input. + * @param resultType The type of entity the endpoint returns as output. + */ + public FunctionEndpointImpl(Endpoint referrer, String relativeUri, Class entityType, Class resultType) { + super(referrer, relativeUri); + this.entityType = entityType; + this.resultType = resultType; + } + + @Override + public TResult trigger(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + if (entity == null) { + throw new IllegalArgumentException("entity must not be null."); + } + + String jsonSend = serializer.writeValueAsString(entity); + HttpResponse response = executeAndHandle(Request.Post(uri).bodyString(jsonSend, ContentType.APPLICATION_JSON)); + return serializer.readValue(EntityUtils.toString(response.getEntity()), resultType); + } +} diff --git a/core/src/main/java/net/typedrest/GenericBulkCollectionEndpoint.java b/core/src/main/java/net/typedrest/GenericBulkCollectionEndpoint.java new file mode 100644 index 00000000..bab5e912 --- /dev/null +++ b/core/src/main/java/net/typedrest/GenericBulkCollectionEndpoint.java @@ -0,0 +1,44 @@ +package net.typedrest; + +import java.io.FileNotFoundException; +import java.io.IOException; +import org.apache.http.*; + +/** + * REST endpoint that represents a collection of TEntitys as + * TElementEndpoints with bulk create and replace support. + * + * Use the more constrained {@link BulkCollectionEndpoint} when possible. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + * + * @deprecated Use {@link GenericCollectionEndpoint} instead. + */ +@Deprecated +public interface GenericBulkCollectionEndpoint + extends GenericCollectionEndpoint { + + /** + * Creates multiple new TEntitys. + * + * @param entities The new TEntitys. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + * + * @deprecated Use + * {@link GenericCollectionEndpoint#createAll(java.lang.Iterable)} instead. + */ + default void create(Iterable entities) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + createAll(entities); + } +} diff --git a/core/src/main/java/net/typedrest/GenericCollectionEndpoint.java b/core/src/main/java/net/typedrest/GenericCollectionEndpoint.java new file mode 100644 index 00000000..341b2692 --- /dev/null +++ b/core/src/main/java/net/typedrest/GenericCollectionEndpoint.java @@ -0,0 +1,204 @@ +package net.typedrest; + +import java.io.*; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.apache.http.*; + +/** + * REST endpoint that represents a collection of TEntitys as + * TElementEndpoints. + * + * Use the more constrained {@link CollectionEndpoint} when possible. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + */ +public interface GenericCollectionEndpoint + extends Endpoint { + + /** + * Returns the type of entity the endpoint represents. + * + * @return The class type. + */ + Class getEntityType(); + + /** + * Returns a {@link ElementEndpoint} for a specific child element of this + * collection. + * + * @param id The ID identifying the entity in the collection. + * @return An {@link ElementEndpoint} for a specific element of this + * collection. + */ + TElementEndpoint get(String id); + + /** + * Returns a {@link ElementEndpoint} for a specific child element of this + * collection. + * + * @param entity An existing entity to extract the ID from. + * @return An {@link ElementEndpoint} for a specific element of this + * collection. + */ + TElementEndpoint get(TEntity entity); + + /** + * Shows whether the server has indicated that + * {@link #readAll()} is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isReadAllAllowed(); + + /** + * Returns all TEntitys. + * + * @return All TEntitys. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + List readAll() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that {@link #readRange(Long,Long)} is + * allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet {@link Optional#empty()} is returned. + */ + Optional isReadRangeAllowed(); + + /** + * Returns all TElementEndpoints within a specific range of the + * set. + * + * @param from The index of the first element to return. null + * to use to to specify a start point counting from the end of + * the set. + * @param to The index of the last element to return. Alternatively the + * index of the first element to return counting from the end of the set if + * from is null. null to read to the + * end. + * @return A subset of the TElementEndpoints and the range they + * come from. May not exactly match the request range. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The requested range is not satisfiable. + * @throws RuntimeException Other non-success status code. + */ + PartialResponse readRange(Long from, Long to) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that + * {@link #create(java.lang.Object)} is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isCreateAllowed(); + + /** + * Creates a new TEntity. + * + * @param entity The new TEntity. + * @return The newly created TEntity; may be null + * if the server deferred creating the resource. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + TElementEndpoint create(TEntity entity) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that + * {@link #createAll(java.lang.Iterable)} is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isCreateAllAllowed(); + + /** + * Creates multiple new TEntitys. + * + * @param entities The new TEntitys. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void createAll(Iterable entities) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + + /** + * Shows whether the server has indicated that + * {@link #setAll(java.util.Collection)} is currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isSetAllAllowed(); + + /** + * Replaces the entire content of the collection with new + * TEntitys. + * + * @param entities The new set of TEntitys the collection + * shall contain. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entities have changed since they were + * last retrieved with {@link #readAll()}. Your changes were rejected to + * prevent a lost update. + * @throws RuntimeException Other non-success status code. + */ + void setAll(Collection entities) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException; +} diff --git a/core/src/main/java/net/typedrest/GenericPagedCollectionEndpoint.java b/core/src/main/java/net/typedrest/GenericPagedCollectionEndpoint.java new file mode 100644 index 00000000..f23c281d --- /dev/null +++ b/core/src/main/java/net/typedrest/GenericPagedCollectionEndpoint.java @@ -0,0 +1,19 @@ +package net.typedrest; + +/** + * REST endpoint that represents a collection of TEntitys as + * TElementEndpoints with pagination support. + * + * Use the more constrained {@link PagedCollectionEndpoint} when possible. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + * + * @deprecated Use {@link CollectionEndpoint} instead. + */ +@Deprecated +public interface GenericPagedCollectionEndpoint + extends GenericCollectionEndpoint { + +} diff --git a/core/src/main/java/net/typedrest/GenericStreamEndpoint.java b/core/src/main/java/net/typedrest/GenericStreamEndpoint.java new file mode 100644 index 00000000..765105da --- /dev/null +++ b/core/src/main/java/net/typedrest/GenericStreamEndpoint.java @@ -0,0 +1,35 @@ +package net.typedrest; + +import rx.util.async.StoppableObservable; + +/** + * REST endpoint that represents a stream of TEntitys as + * TElementEndpoints. + * + * Use the more constrained {@link StreamEndpoint} when possible. + * + * @param The type of entity the endpoint represents. + * @param The specific type of {@link ElementEndpoint} to + * provide for individual TEntitys. + */ +public interface GenericStreamEndpoint + extends GenericCollectionEndpoint { + + /** + * Provides an observable stream of elements. + * + * @return An observable stream of elements. + */ + default StoppableObservable getObservable() { + return getObservable(0); + } + + /** + * Provides an observable stream of elements. + * + * @param startIndex The index of the first element to return in the stream. + * Use negative values to start counting from the end of the stream. + * @return An observable stream of elements. + */ + StoppableObservable getObservable(long startIndex); +} diff --git a/core/src/main/java/net/typedrest/HeaderUtils.java b/core/src/main/java/net/typedrest/HeaderUtils.java new file mode 100644 index 00000000..bae22a6b --- /dev/null +++ b/core/src/main/java/net/typedrest/HeaderUtils.java @@ -0,0 +1,26 @@ +package net.typedrest; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toList; +import org.apache.http.HttpResponse; + +/** + * Provides utility methods for HTTP headers. + */ +public final class HeaderUtils { + + private HeaderUtils() { + } + + /** + * Returns all Link headers listed in an {@link HttpResponse}. + * + * @param response The response to check for Link headers. + * @return The Link headers found. + */ + public static Iterable getLinkHeaders(HttpResponse response) { + return stream(response.getHeaders("Link")) + .flatMap(x -> stream(x.getElements()).map(LinkHeader::new)) + .collect(toList()); + } +} diff --git a/core/src/main/java/net/typedrest/LinkHeader.java b/core/src/main/java/net/typedrest/LinkHeader.java new file mode 100644 index 00000000..ad87c22f --- /dev/null +++ b/core/src/main/java/net/typedrest/LinkHeader.java @@ -0,0 +1,53 @@ +package net.typedrest; + +import lombok.*; +import org.apache.http.HeaderElement; +import org.apache.http.NameValuePair; + +/** + * Represents an HTTP Link header. + */ +public class LinkHeader { + + /** + * The href/target of the link. + */ + @Getter + private final String href; + + /** + * The relation type of the link. + */ + @Getter + private final String rel; + + /** + * The title of the link (optional). + */ + @Getter + private final String title; + + /** + * Indicates whether the link is an URI Template (RFC 6570). + */ + @Getter + private final boolean templated; + + /** + * Parses a {@link HeaderElement} into an HTTP Link header. + * + * @param element The {@link HeaderElement} to parse. + */ + public LinkHeader(HeaderElement element) { + href = element.getName().substring(1, element.getName().length() - 1); + + NameValuePair relParameter = element.getParameterByName("rel"); + rel = (relParameter == null) ? null : relParameter.getValue(); + + NameValuePair titleParameter = element.getParameterByName("title"); + title = (titleParameter == null) ? null : titleParameter.getValue(); + + NameValuePair templatedParameter = element.getParameterByName("templated"); + templated = (templatedParameter != null && templatedParameter.getValue().equals("true")); + } +} diff --git a/core/src/main/java/net/typedrest/PagedCollectionEndpoint.java b/core/src/main/java/net/typedrest/PagedCollectionEndpoint.java new file mode 100644 index 00000000..6f9df85d --- /dev/null +++ b/core/src/main/java/net/typedrest/PagedCollectionEndpoint.java @@ -0,0 +1,14 @@ +package net.typedrest; + +/** + * REST endpoint that represents a collection of TEntitys as + * {@link ElementEndpoint}s with pagination support. + * + * @param The type of entity the endpoint represents. + * + * @deprecated Use {@link CollectionEndpoint} instead. + */ +@Deprecated +public interface PagedCollectionEndpoint + extends GenericPagedCollectionEndpoint>, CollectionEndpoint { +} diff --git a/core/src/main/java/net/typedrest/PagedCollectionEndpointImpl.java b/core/src/main/java/net/typedrest/PagedCollectionEndpointImpl.java new file mode 100644 index 00000000..0cf347a0 --- /dev/null +++ b/core/src/main/java/net/typedrest/PagedCollectionEndpointImpl.java @@ -0,0 +1,49 @@ +package net.typedrest; + +import java.net.*; + +/** + * REST endpoint that represents a collection of TEntitys as + * {@link ElementEndpoint}s with pagination support using the HTTP Range header. + * + * @param The type of entity the endpoint represents. + * + * @deprecated Use {@link CollectionEndpoint} instead. + */ +@Deprecated +public class PagedCollectionEndpointImpl + extends AbstractPagedCollectionEndpoint> + implements PagedCollectionEndpoint { + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + public PagedCollectionEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + /** + * Creates a new paged collection endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + public PagedCollectionEndpointImpl(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + @Override + protected ElementEndpoint buildElementEndpoint(URI relativeUri) { + return new ElementEndpointImpl<>(this, relativeUri, entityType); + } +} diff --git a/core/src/main/java/net/typedrest/PartialResponse.java b/core/src/main/java/net/typedrest/PartialResponse.java new file mode 100644 index 00000000..d5e6210b --- /dev/null +++ b/core/src/main/java/net/typedrest/PartialResponse.java @@ -0,0 +1,54 @@ +package net.typedrest; + +import java.util.List; +import lombok.Data; + +/** + * Represents a subset of a set of elements. + * + * @param The type of element the response contains. + */ +@Data +public class PartialResponse { + + /** + * The returned elements. + */ + private final List elements; + + /** + * The index of the first element. null if unset. + */ + private final Long from; + + /** + * The index of the last element. null if unset. + */ + private final Long to; + + /** + * The total count of elements available on the server. null if + * unset. + */ + private final Long length; + + /** + * Indicates whether the response reaches the end of the elements available + * on the server. + * + * @return true if there are no more elements. + */ + public boolean isEndReached() { + if (to == null || from == null) { + // No range specified, must be complete response + return true; + } + + if (length == null) { + // No lenth specified, can't be end + return false; + } + + return to == length - 1; + } +} diff --git a/core/src/main/java/net/typedrest/PollingEndpoint.java b/core/src/main/java/net/typedrest/PollingEndpoint.java new file mode 100644 index 00000000..8d91d717 --- /dev/null +++ b/core/src/main/java/net/typedrest/PollingEndpoint.java @@ -0,0 +1,38 @@ +package net.typedrest; + +import rx.util.async.StoppableObservable; + +/** + * REST endpoint that represents an entity that can be polled for state changes. + * + * @param The type of entity the endpoint represents. + */ +public interface PollingEndpoint + extends ElementEndpoint { + + /** + * Gets the interval in which to send requests to the server. + * + * The server can modify this value using the "Retry-After" header. + * + * @return An interval in seconds. + */ + int getPollingInterval(); + + /** + * Sets the interval in which to send requests to the server. + * + * The server can modify this value using the "Retry-After" header. + * + * @param interval An interval in seconds. + */ + void setPollingInterval(int interval); + + /** + * Provides an observable stream of element states. Compares entities using + * {@link Object#equals(java.lang.Object)} to detect changes. + * + * @return An observable stream of element states. + */ + StoppableObservable getObservable(); +} diff --git a/core/src/main/java/net/typedrest/PollingEndpointImpl.java b/core/src/main/java/net/typedrest/PollingEndpointImpl.java new file mode 100644 index 00000000..c8f10f28 --- /dev/null +++ b/core/src/main/java/net/typedrest/PollingEndpointImpl.java @@ -0,0 +1,157 @@ +package net.typedrest; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.util.function.Predicate; +import lombok.*; +import org.apache.http.Header; +import static org.apache.http.HttpHeaders.RETRY_AFTER; +import org.apache.http.HttpResponse; +import org.apache.http.client.fluent.Request; +import rx.Scheduler; +import rx.Subscription; +import rx.schedulers.Schedulers; +import rx.util.async.StoppableObservable; +import static java.lang.Thread.sleep; +import static rx.util.async.Async.runAsync; + +/** + * REST endpoint that represents an entity that can be polled for state changes. + * + * @param The type of entity the endpoint represents. + */ +public class PollingEndpointImpl + extends ElementEndpointImpl + implements PollingEndpoint { + + private final Predicate endCondition; + + /** + * Creates a new polling 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 entity the endpoint represents. + */ + public PollingEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + endCondition = null; + } + + /** + * Creates a new polling 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 entity the endpoint represents. + * @param endCondition A check to determine whether the entity has reached + * its final state an no further polling is required. + */ + public PollingEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType, Predicate endCondition) { + super(referrer, relativeUri, entityType); + this.endCondition = endCondition; + } + + /** + * Creates a new polling endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Prefix ./ to append a trailing + * slash to the referrer URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + public PollingEndpointImpl(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + endCondition = null; + } + + /** + * Creates a new polling endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Prefix ./ to append a trailing + * slash to the referrer URI if missing. + * @param entityType The type of entity the endpoint represents. + * @param endCondition A check to determine whether the entity has reached + * its final state an no further polling is required. + */ + public PollingEndpointImpl(Endpoint referrer, String relativeUri, Class entityType, Predicate endCondition) { + super(referrer, relativeUri, entityType); + this.endCondition = endCondition; + } + + @Override + public StoppableObservable getObservable() { + return getObservable(Schedulers.newThread()); + } + + @Override + protected void handleResponse(HttpResponse response, Request request) + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + Header retryAfterHeader = response.getFirstHeader(RETRY_AFTER); + if (retryAfterHeader != null) { + try { + pollingInterval = Integer.parseInt(retryAfterHeader.getValue()); + } catch (NumberFormatException ex) { + } + } + super.handleResponse(response, request); + } + + @Getter + @Setter + private int pollingInterval = 3; + + /** + * Provides an observable stream of element states. Compares entities using + * {@link Object#equals(java.lang.Object)} to detect changes. + * + * @param pollingInterval The interval in milliseconds in which to send + * requests to the server. + * @param endCondition An optional predicate determining which entity state + * ends the polling process. + * @param scheduler The scheduler used to run the background thread. + * @return An observable stream of element states. + */ + StoppableObservable getObservable(Scheduler scheduler) { + return runAsync(scheduler, (rx.Observer observer, Subscription subscription) -> { + TEntity previousEntity; + try { + previousEntity = read(); + } catch (IOException | IllegalArgumentException | IllegalAccessException ex) { + observer.onError(ex); + return; + } + observer.onNext(previousEntity); + + while (endCondition == null || !endCondition.test(previousEntity)) { + try { + sleep(pollingInterval * 1000); + } catch (InterruptedException ex) { + } + if (subscription.isUnsubscribed()) { + break; + } + + TEntity newEntity; + try { + newEntity = read(); + } catch (IOException | IllegalArgumentException | IllegalAccessException ex) { + observer.onError(ex); + return; + } + if (!newEntity.equals(previousEntity)) { + observer.onNext(newEntity); + } + + previousEntity = newEntity; + } + observer.onCompleted(); + }); + } +} diff --git a/core/src/main/java/net/typedrest/StreamEndpoint.java b/core/src/main/java/net/typedrest/StreamEndpoint.java new file mode 100644 index 00000000..0b1c6470 --- /dev/null +++ b/core/src/main/java/net/typedrest/StreamEndpoint.java @@ -0,0 +1,11 @@ +package net.typedrest; + +/** + * REST endpoint that represents a stream of TEntitys as + * {@link ElementEndpoint}s. + * + * @param The type of entity the endpoint represents. + */ +public interface StreamEndpoint + extends GenericStreamEndpoint>, CollectionEndpoint { +} diff --git a/core/src/main/java/net/typedrest/StreamEndpointImpl.java b/core/src/main/java/net/typedrest/StreamEndpointImpl.java new file mode 100644 index 00000000..cbf2136b --- /dev/null +++ b/core/src/main/java/net/typedrest/StreamEndpointImpl.java @@ -0,0 +1,46 @@ +package net.typedrest; + +import java.net.*; + +/** + * REST endpoint that represents a stream of TEntitys as + * {@link ElementEndpoint}s. + * + * @param The type of entity the endpoint represents. + */ +public class StreamEndpointImpl + extends AbstractStreamEndpoint> + implements StreamEndpoint { + + /** + * Creates a new stream endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. + * @param entityType The type of entity the endpoint represents. + */ + public StreamEndpointImpl(Endpoint referrer, URI relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + /** + * Creates a new stream endpoint. + * + * @param referrer The endpoint used to navigate to this one. + * @param relativeUri The URI of this endpoint relative to the + * referrer's. Missing trailing slash will be appended + * automatically. Prefix ./ to append a trailing slash to the + * parent URI if missing. + * @param entityType The type of entity the endpoint represents. + */ + public StreamEndpointImpl(Endpoint referrer, String relativeUri, Class entityType) { + super(referrer, relativeUri, entityType); + } + + @Override + protected ElementEndpoint buildElementEndpoint(URI relativeUri) { + return new ElementEndpointImpl<>(this, relativeUri, entityType); + } +} diff --git a/core/src/main/java/net/typedrest/SupplierEndpoint.java b/core/src/main/java/net/typedrest/SupplierEndpoint.java new file mode 100644 index 00000000..f9858572 --- /dev/null +++ b/core/src/main/java/net/typedrest/SupplierEndpoint.java @@ -0,0 +1,38 @@ +package net.typedrest; + +import java.io.*; +import org.apache.http.*; + +/** + * REST endpoint that represents an RPC-like function which returns + * TResult as output. + * + * @param The type of entity the endpoint returns as output. + */ +public interface SupplierEndpoint + extends TriggerEndpoint { + + /** + * Returns The type of entity the endpoint returns as output. + * + * @return The class type. + */ + Class getResultType(); + + /** + * Triggers the function. + * + * @return The result returned by the server. + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + TResult trigger() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; + +} diff --git a/core/src/main/java/net/typedrest/SupplierEndpointImpl.java b/core/src/main/java/net/typedrest/SupplierEndpointImpl.java new file mode 100644 index 00000000..4ff916da --- /dev/null +++ b/core/src/main/java/net/typedrest/SupplierEndpointImpl.java @@ -0,0 +1,56 @@ +package net.typedrest; + +import java.io.*; +import java.net.*; +import lombok.Getter; +import org.apache.http.*; +import org.apache.http.client.fluent.*; +import org.apache.http.util.*; + +/** + * REST endpoint that represents a single RPC-like function which returns + * TResult. + * + * @param The type of entity the endpoint returns as output. + */ +public class SupplierEndpointImpl + extends AbstractTriggerEndpoint + implements SupplierEndpoint { + + @Getter + private final Class resultType; + + /** + * Creates a new function 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. + * @param resultType The type of entity the endpoint returns as output. + */ + public SupplierEndpointImpl(Endpoint referrer, URI relativeUri, Class resultType) { + super(referrer, relativeUri); + this.resultType = resultType; + } + + /** + * Creates a new function 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. Prefix ./ to append a trailing + * slash to the referrer URI if missing. + * @param resultType The type of entity the endpoint returns as output. + */ + public SupplierEndpointImpl(Endpoint referrer, String relativeUri, Class resultType) { + super(referrer, relativeUri); + this.resultType = resultType; + } + + @Override + public TResult trigger() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException { + HttpResponse response = executeAndHandle(Request.Post(uri)); + return serializer.readValue(EntityUtils.toString(response.getEntity()), resultType); + } +} diff --git a/core/src/main/java/net/typedrest/ThrowableUtils.java b/core/src/main/java/net/typedrest/ThrowableUtils.java new file mode 100644 index 00000000..9dc87e9d --- /dev/null +++ b/core/src/main/java/net/typedrest/ThrowableUtils.java @@ -0,0 +1,27 @@ +package net.typedrest; + +/** + * Provides utility methods for {@link Throwable}. + */ +public final class ThrowableUtils { + + private ThrowableUtils() { + } + + /** + * Concatenates the {@link Throwable#getLocalizedMessage()}s of the entire + * {@link Throwable#getCause()} tree. + * + * @param throwable The top-level throwable to walk for messages. + * @return A newline separated list of messages. + */ + @SuppressWarnings("ThrowableResultIgnored") + public static String getFullMessage(Throwable throwable) { + StringBuilder builder = new StringBuilder(); + do { + builder.append(throwable.getLocalizedMessage()).append("\n"); + throwable = throwable.getCause(); + } while (throwable != null); + return builder.toString(); + } +} diff --git a/core/src/main/java/net/typedrest/TriggerEndpoint.java b/core/src/main/java/net/typedrest/TriggerEndpoint.java new file mode 100644 index 00000000..174c737d --- /dev/null +++ b/core/src/main/java/net/typedrest/TriggerEndpoint.java @@ -0,0 +1,38 @@ +package net.typedrest; + +import java.io.*; +import java.util.Optional; +import org.apache.http.*; + +/** + * An RPC-like trigger endpoint. + */ +public interface TriggerEndpoint extends Endpoint { + + /** + * Shows whether the server has indicated that the trigger method is + * currently allowed. + * + * Uses cached data from last response. + * + * @return An indicator whether the method is allowed. If no request has + * been sent yet or the server did not specify allowed methods + * {@link Optional#empty()} is returned. + */ + Optional isTriggerAllowed(); + + /** + * Queries the server about capabilities of the endpoint without performing + * any action. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + void probe() throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException; +} diff --git a/core/src/main/java/net/typedrest/URIUtils.java b/core/src/main/java/net/typedrest/URIUtils.java new file mode 100644 index 00000000..d80bdb1f --- /dev/null +++ b/core/src/main/java/net/typedrest/URIUtils.java @@ -0,0 +1,29 @@ +package net.typedrest; + +import java.net.*; +import lombok.SneakyThrows; +import org.apache.http.client.utils.URIBuilder; + +/** + * Provides utility methods for {@link URI}. + */ +public final class URIUtils { + + private URIUtils() { + } + + /** + * Adds a trailing slash to the URI if it does not already have one. + * + * @param uri The original URI. + * @return The URI with appended slash. + */ + @SneakyThrows + public static URI ensureTrailingSlash(URI uri) { + URIBuilder builder = new URIBuilder(uri); + if (!builder.getPath().endsWith("/")) { + builder.setPath(builder.getPath() + "/"); + } + return builder.build(); + } +} diff --git a/core/src/test/java/net/typedrest/AbstractEndpointTest.java b/core/src/test/java/net/typedrest/AbstractEndpointTest.java new file mode 100644 index 00000000..155f7b12 --- /dev/null +++ b/core/src/test/java/net/typedrest/AbstractEndpointTest.java @@ -0,0 +1,25 @@ +package net.typedrest; + +import org.junit.*; +import com.github.tomakehurst.wiremock.junit.*; +import java.net.URI; +import org.apache.http.entity.ContentType; + +public abstract class AbstractEndpointTest { + + protected EntryEndpoint entryEndpoint; + protected static final String JSON_MIME = ContentType.APPLICATION_JSON.getMimeType(); + protected static final String LINK = "Link"; + + private final int port = 8089; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(port); + + protected final URI serverUri = URI.create("http://localhost:" + port + "/"); + + @Before + public void before() { + entryEndpoint = new EntryEndpoint(serverUri); + } +} diff --git a/core/src/test/java/net/typedrest/ActionEndpointTest.java b/core/src/test/java/net/typedrest/ActionEndpointTest.java new file mode 100644 index 00000000..0cc25841 --- /dev/null +++ b/core/src/test/java/net/typedrest/ActionEndpointTest.java @@ -0,0 +1,41 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import org.junit.*; + +public class ActionEndpointTest extends AbstractEndpointTest { + + private ActionEndpoint endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new ActionEndpointImpl(entryEndpoint, "endpoint"); + } + + @Test + public void testProbe() throws Exception { + stubFor(options(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(ALLOW, "POST"))); + + endpoint.probe(); + + assertThat(endpoint.isTriggerAllowed().get(), is(true)); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testTrigger() throws Exception { + stubFor(post(urlEqualTo("/endpoint")) + .willReturn(aResponse().withStatus(SC_OK))); + + endpoint.trigger(); + } +} diff --git a/core/src/test/java/net/typedrest/BlobEndpointTest.java b/core/src/test/java/net/typedrest/BlobEndpointTest.java new file mode 100644 index 00000000..03d1428c --- /dev/null +++ b/core/src/test/java/net/typedrest/BlobEndpointTest.java @@ -0,0 +1,64 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import com.google.common.base.Charsets; +import static com.google.common.io.ByteStreams.toByteArray; +import java.io.File; +import java.io.InputStream; +import org.apache.http.*; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import org.apache.http.entity.ContentType; +import org.junit.*; +import static org.junit.Assert.*; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +public class BlobEndpointTest extends AbstractEndpointTest { + + private BlobEndpoint endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new BlobEndpointImpl(entryEndpoint, "endpoint"); + } + + @Test + public void testProbe() throws Exception { + stubFor(options(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(HttpHeaders.ALLOW, "PUT"))); + + endpoint.probe(); + + assertThat(endpoint.isDownloadAllowed().get(), is(false)); + assertThat(endpoint.isUploadAllowed().get(), is(true)); + } + + @Test + public void testDownload() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withBody("unit-test"))); + + byte[] downloaded = toByteArray(endpoint.download()); + + assertArrayEquals(Charsets.UTF_8.encode("unit-test").array(), downloaded); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testUpload() throws Exception { + stubFor(put(urlEqualTo("/endpoint")) + .withHeader(CONTENT_TYPE, matching("mock/type")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + File file = File.createTempFile("unit-test", ".tmp"); + endpoint.upload(file, ContentType.create("mock/type")); + } +} diff --git a/core/src/test/java/net/typedrest/CollectionEndpointTest.java b/core/src/test/java/net/typedrest/CollectionEndpointTest.java new file mode 100644 index 00000000..62d472db --- /dev/null +++ b/core/src/test/java/net/typedrest/CollectionEndpointTest.java @@ -0,0 +1,259 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import java.net.URI; +import static java.util.Arrays.asList; +import java.util.List; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; +import org.junit.*; +import static net.typedrest.AbstractEndpointTest.JSON_MIME; +import static java.util.Arrays.asList; +import org.hamcrest.CoreMatchers; + +public class CollectionEndpointTest extends AbstractEndpointTest { + + private CollectionEndpointImpl endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new CollectionEndpointImpl<>(entryEndpoint, "endpoint", MockEntity.class); + } + + @Test + public void testReadAll() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]"))); + + List expected = asList( + new MockEntity(5, "test1"), + new MockEntity(6, "test2")); + assertThat(endpoint.readAll(), is(equalTo(expected))); + } + + @Test + public void testReadAllCache() throws Exception { + List expected = asList( + new MockEntity(5, "test1"), + new MockEntity(6, "test2")); + + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"123abc\"") + .withBody("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]"))); + List result1 = endpoint.readAll(); + assertThat(result1, is(equalTo(expected))); + + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(IF_NONE_MATCH, equalTo("\"123abc\"")) + .willReturn(aResponse() + .withStatus(SC_NOT_MODIFIED))); + List result2 = endpoint.readAll(); + assertThat(result2, is(equalTo(expected))); + + assertThat("Cache responses, not deserialized objects", + result2, is(not(sameInstance(result1)))); + } + + @Test + public void testReadRangeOffset() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(RANGE, equalTo("elements=1-")) + .willReturn(aResponse() + .withStatus(SC_PARTIAL_CONTENT) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(CONTENT_RANGE, "elements 1-1/2") + .withBody("[{\"id\":6,\"name\":\"test2\"}]"))); + + PartialResponse response = endpoint.readRange(1l, null); + + List expected = asList(new MockEntity(6, "test2")); + assertThat(response.getElements(), is(equalTo(expected))); + + assertThat(response.getFrom(), is(equalTo(1l))); + assertThat(response.getTo(), is(equalTo(1l))); + assertThat(response.getLength(), is(equalTo(2l))); + } + + @Test + public void testReadRangeHead() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(RANGE, equalTo("elements=0-1")) + .willReturn(aResponse() + .withStatus(SC_PARTIAL_CONTENT) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(CONTENT_RANGE, "elements 0-1/2") + .withBody("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]"))); + + PartialResponse response = endpoint.readRange(0l, 1l); + + List expected = asList( + new MockEntity(5, "test1"), + new MockEntity(6, "test2")); + assertThat(response.getElements(), is(equalTo(expected))); + + assertThat(response.getFrom(), is(equalTo(0l))); + assertThat(response.getTo(), is(equalTo(1l))); + assertThat(response.getLength(), is(equalTo(2l))); + } + + @Test + public void testReadRangeTail() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(RANGE, equalTo("elements=-1")) + .willReturn(aResponse() + .withStatus(SC_PARTIAL_CONTENT) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(CONTENT_RANGE, "elements 2-2/*") + .withBody("[{\"id\":6,\"name\":\"test2\"}]"))); + + PartialResponse response = endpoint.readRange(null, 1l); + + List expected = asList(new MockEntity(6, "test2")); + assertThat(response.getElements(), is(equalTo(expected))); + + assertThat(response.getFrom(), is(equalTo(2l))); + assertThat(response.getTo(), is(equalTo(2l))); + assertThat(response.getLength(), is(nullValue())); + } + + @Test + public void testReadRangeException() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(RANGE, equalTo("elements=5-10")) + .willReturn(aResponse() + .withStatus(SC_REQUESTED_RANGE_NOT_SATISFIABLE) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"message\":\"test\"}"))); + + String exceptionMessage = null; + try { + endpoint.readRange(5l, 10l); + } catch (IllegalStateException ex) { + exceptionMessage = ex.getMessage(); + } + + assertThat(exceptionMessage, is(CoreMatchers.equalTo("test"))); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testCreate() throws Exception { + URI location = URI.create("/endpoint/new"); + + stubFor(post(urlEqualTo("/endpoint/")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"test\"}")) + .willReturn(aResponse() + .withStatus(SC_CREATED) + .withHeader(LOCATION, location.toASCIIString()))); + + ElementEndpoint element = endpoint.create(new MockEntity(5, "test")); + assertThat(element.getUri(), is(equalTo(serverUri.resolve(location)))); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testCreateAll() throws Exception { + stubFor(patch(urlEqualTo("/endpoint/")) + .withRequestBody(equalToJson("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]")) + .willReturn(aResponse() + .withStatus(SC_ACCEPTED))); + + endpoint.createAll(asList( + new MockEntity(5, "test1"), + new MockEntity(6, "test2"))); + } + + @Test + public void testBuildElementEndpoint() { + assertThat(endpoint.buildElementEndpoint(URI.create("1")).getUri(), + is(equalTo(endpoint.getUri().resolve("1")))); + } + + @Test + public void testGetByEntity() { + assertThat(endpoint.get(new MockEntity(1, "test")).getUri(), + is(equalTo(endpoint.getUri().resolve("1")))); + } + + @Test + public void testGetByEntityWithLinkHeaderRelative() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(LINK, "; rel=child; templated=true") + .withBody("[]"))); + + endpoint.readAll(); + + assertThat(endpoint.get(new MockEntity(1, "test")).getUri(), + is(equalTo(endpoint.getUri().resolve("children/1")))); + } + + @Test + public void testGetByEntityWithLinkHeaderAbsolute() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(LINK, "<" + endpoint.getUri() + "children/{id}>; rel=child; templated=true") + .withBody("[]"))); + + endpoint.readAll(); + + assertThat(endpoint.get(new MockEntity(1, "test")).getUri(), + is(equalTo(endpoint.getUri().resolve("children/1")))); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testSetAll() throws Exception { + stubFor(put(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withRequestBody(equalToJson("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + endpoint.setAll(asList( + new MockEntity(5, "test1"), + new MockEntity(6, "test2"))); + } + + @Test + public void testSetAllETag() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"123abc\"") + .withBody("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]"))); + List result = endpoint.readAll(); + + stubFor(put(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(IF_MATCH, matching("\"123abc\"")) + .withRequestBody(equalToJson("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + endpoint.setAll(result); + } +} diff --git a/core/src/test/java/net/typedrest/ConsumerEndpointTest.java b/core/src/test/java/net/typedrest/ConsumerEndpointTest.java new file mode 100644 index 00000000..94c30ac2 --- /dev/null +++ b/core/src/test/java/net/typedrest/ConsumerEndpointTest.java @@ -0,0 +1,27 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.apache.http.HttpStatus.*; +import org.junit.*; + +public class ConsumerEndpointTest extends AbstractEndpointTest { + + private ConsumerEndpoint endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new ConsumerEndpointImpl<>(entryEndpoint, "endpoint", MockEntity.class); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testTrigger() throws Exception { + stubFor(post(urlEqualTo("/endpoint")) + .withRequestBody(equalToJson("{\"id\":1,\"name\":\"input\"}")) + .willReturn(aResponse().withStatus(SC_OK))); + + endpoint.trigger(new MockEntity(1, "input")); + } +} diff --git a/core/src/test/java/net/typedrest/CustomEndpointTest.java b/core/src/test/java/net/typedrest/CustomEndpointTest.java new file mode 100644 index 00000000..b30cb887 --- /dev/null +++ b/core/src/test/java/net/typedrest/CustomEndpointTest.java @@ -0,0 +1,292 @@ +package net.typedrest; + +import com.github.tomakehurst.wiremock.client.WireMock; +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import static org.apache.http.HttpStatus.*; +import static org.apache.http.HttpHeaders.*; +import org.apache.http.client.fluent.Request; +import org.apache.http.message.BasicHeader; +import static org.hamcrest.CoreMatchers.equalTo; +import org.junit.*; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import org.hamcrest.core.StringContains; +import org.junit.rules.ExpectedException; + +public class CustomEndpointTest extends AbstractEndpointTest { + + private CustomEndpoint endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new CustomEndpoint(entryEndpoint, "endpoint"); + } + + @Test + public void testDefaultHeader() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .withHeader("X-Mock", WireMock.equalTo("mock")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=target1-template"))); + + endpoint.get(); + } + + @Test + public void testAllowHeader() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader("Allow", "PUT, POST"))); + + endpoint.get(); + + assertThat(endpoint.isMethodAllowed("PUT").get(), is(true)); + assertThat(endpoint.isMethodAllowed("POST").get(), is(true)); + assertThat(endpoint.isMethodAllowed("DELETE").get(), is(false)); + } + + @Test + public void testLink() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=target1, ; rel=target2"))); + + endpoint.get(); + + assertThat(endpoint.link("target1"), is(equalTo(endpoint.getUri().resolve("a")))); + assertThat(endpoint.link("target2"), is(equalTo(endpoint.getUri().resolve("b")))); + } + + @Test + public void testLinkLazy() throws Exception { + stubFor(head(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=target1, ; rel=target2"))); + + assertThat(endpoint.link("target1"), is(equalTo(endpoint.getUri().resolve("a")))); + assertThat(endpoint.link("target2"), is(equalTo(endpoint.getUri().resolve("b")))); + } + + @Test + public void testLinkAbsolute() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=target1"))); + + endpoint.get(); + + assertThat(endpoint.link("target1"), is(equalTo(URI.create("http://localhost/b")))); + } + + @Test(expected = RuntimeException.class) + public void testLinkException() throws Exception { + stubFor(head(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=target1"))); + + endpoint.link("target2"); + } + + @Test + public void testGetLinks() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=notify, ; rel=notify") + .withHeader(LINK, "; rel=notify"))); + + endpoint.get(); + + assertThat(endpoint.getLinks("notify"), containsInAnyOrder( + endpoint.getUri().resolve("target1"), + endpoint.getUri().resolve("target2"), + endpoint.getUri().resolve("target3"))); + } + + @Test + public void testGetLinksWithTitles() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=child; title=\"Title 1\"") + .withHeader(LINK, "; rel=child"))); + + endpoint.get(); + + Map expected = new HashMap<>(); + expected.put(endpoint.getUri().resolve("target1"), "Title 1"); + expected.put(endpoint.getUri().resolve("target2"), null); + assertThat(endpoint.getLinksWithTitles("child").entrySet(), equalTo( + expected.entrySet())); + } + + @Test + public void testGetLinksWithTitlesEscaping() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=child; title=\"Title,1\", ; rel=child"))); + + endpoint.get(); + + Map expected = new HashMap<>(); + expected.put(endpoint.getUri().resolve("target1"), "Title,1"); + expected.put(endpoint.getUri().resolve("target2"), null); + assertThat(endpoint.getLinksWithTitles("child").entrySet(), equalTo( + expected.entrySet())); + } + + @Test + public void testSetDefaultLink() throws Exception { + endpoint.setDefaultLink("child", new String[]{"target1", "target2"}); + + Map expected = new HashMap<>(); + expected.put(endpoint.getUri().resolve("target1"), null); + expected.put(endpoint.getUri().resolve("target2"), null); + assertThat(endpoint.getLinksWithTitles("child").entrySet(), equalTo( + expected.entrySet())); + } + + @Test + public void testLinkTemplate() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=child; templated=true"))); + + endpoint.get(); + + assertThat(endpoint.linkTemplate("child").getTemplate(), + is(equalTo("a{?x}"))); + } + + @Test + @Ignore("Full link escaping not implemented yet") + public void testLinkTemplateEscaping() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=search; templated=true"))); + + endpoint.get(); + + assertThat(endpoint.linkTemplate("search").getTemplate(), + is(equalTo("http://localhost/b{?x,y}"))); + } + + @Test + public void testLinkTemplateResolve() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=child; templated=true"))); + + endpoint.get(); + + assertThat(endpoint.linkTemplate("child", "x", 1), + is(equalTo(endpoint.getUri().resolve("a?x=1")))); + } + + @Test + public void testLinkTemplateResolveAbsolute() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=child; templated=true"))); + + endpoint.get(); + + assertThat(endpoint.linkTemplate("child", "x", 1), + is(equalTo(URI.create("http://localhost/b?x=1")))); + } + + @Test(expected = RuntimeException.class) + public void testLinkTemplateException() throws Exception { + stubFor(head(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT) + .withHeader(LINK, "; rel=child; templated=true"))); + + endpoint.linkTemplate("child2"); + } + + @Test + public void testSetDefaultLinkTemplate() throws Exception { + endpoint.setDefaultLinkTemplate("child", "a"); + + assertThat(endpoint.linkTemplate("child").getTemplate(), is(equalTo("a"))); + } + + @Test + public void testLinkBody() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"links\": {" + + " \"single\": {\"href\": \"a\"}," + + " \"collection\": [{\"href\": \"b\", \"title\": \"Title 1\"},{\"href\": \"c\"},true,{\"something\":false}]," + + " \"template\": {\"href\": \"{id}\",\"templated\": true}" + + "}}"))); + + endpoint.get(); + + assertThat(endpoint.link("single"), is(equalTo(endpoint.getUri().resolve("a")))); + assertThat(endpoint.getLinks("collection"), containsInAnyOrder( + endpoint.getUri().resolve("b"), + endpoint.getUri().resolve("c"))); + assertThat(endpoint.linkTemplate("template").getTemplate(), is(equalTo("{id}"))); + + Map expected = new HashMap<>(); + expected.put(endpoint.getUri().resolve("b"), "Title 1"); + expected.put(endpoint.getUri().resolve("c"), null); + assertThat(endpoint.getLinksWithTitles("collection").entrySet(), equalTo( + expected.entrySet())); + } + + @Test + public void testEnsureTrailingSlashOnReferrerUri() { + assertThat(new ActionEndpointImpl(endpoint, "subresource").getUri(), equalTo( + URI.create("http://localhost:8089/subresource"))); + assertThat(new ActionEndpointImpl(endpoint, "./subresource").getUri(), equalTo( + URI.create("http://localhost:8089/endpoint/subresource"))); + } + + @Rule + public final ExpectedException exception = ExpectedException.none(); + + @Test + public void testErrorHandling() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_CONFLICT))); + + exception.expectMessage(new StringContains(" responded with 409 Conflict")); + endpoint.get(); + } + + private class CustomEndpoint extends AbstractEndpoint { + + public CustomEndpoint(Endpoint referrer, String relativeUri) { + super(referrer, relativeUri); + + defaultHeaders.add(new BasicHeader("X-Mock", "mock")); + } + + public void get() throws Exception { + executeAndHandle(Request.Get(uri)); + } + } +} diff --git a/core/src/test/java/net/typedrest/ElementEndpointTest.java b/core/src/test/java/net/typedrest/ElementEndpointTest.java new file mode 100644 index 00000000..e48750d3 --- /dev/null +++ b/core/src/test/java/net/typedrest/ElementEndpointTest.java @@ -0,0 +1,256 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import static org.hamcrest.CoreMatchers.*; +import org.hamcrest.CoreMatchers; +import static org.hamcrest.MatcherAssert.*; +import org.junit.*; + +public class ElementEndpointTest extends AbstractEndpointTest { + + private ElementEndpoint endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new ElementEndpointImpl<>(entryEndpoint, "endpoint", MockEntity.class); + } + + @Test + public void testRead() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"id\":5,\"name\":\"test\"}"))); + + assertThat(endpoint.read(), + is(equalTo(new MockEntity(5, "test")))); + } + + @Test + public void testReadCache() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"123abc\"") + .withBody("{\"id\":5,\"name\":\"test\"}"))); + MockEntity result1 = endpoint.read(); + assertThat(result1, is(equalTo(new MockEntity(5, "test")))); + + stubFor(get(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(IF_NONE_MATCH, equalTo("\"123abc\"")) + .willReturn(aResponse() + .withStatus(SC_NOT_MODIFIED))); + MockEntity result2 = endpoint.read(); + assertThat(result2, is(equalTo(new MockEntity(5, "test")))); + + assertThat("Cache responses, not deserialized objects", + result2, is(not(sameInstance(result1)))); + } + + @Test + public void testExistsTrue() throws Exception { + stubFor(head(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK))); + + assertThat(endpoint.exists(), is(true)); + } + + @Test + public void testExistsFalse() throws Exception { + stubFor(head(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NOT_FOUND))); + + assertThat(endpoint.exists(), is(false)); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testSetNoResult() throws Exception { + stubFor(put(urlEqualTo("/endpoint")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"test\"}")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + endpoint.set(new MockEntity(5, "test")); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testSetResult() throws Exception { + stubFor(put(urlEqualTo("/endpoint")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"test\"}")) + .willReturn(aResponse() + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"id\":5,\"name\":\"testXXX\"}"))); + + assertThat(endpoint.set(new MockEntity(5, "test")), + is(equalTo(new MockEntity(5, "testXXX")))); + } + + @Test + public void testSetETag() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"123abc\"") + .withBody("{\"id\":5,\"name\":\"test\"}"))); + MockEntity entity = endpoint.read(); + + stubFor(put(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(IF_MATCH, matching("\"123abc\"")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"test\"}")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + endpoint.set(entity); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testSetWithNullValue() throws Exception { + stubFor(put(urlEqualTo("/endpoint")) + .withRequestBody(equalToJson("{\"id\":5}")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + endpoint.set(new MockEntity(5, null)); + } + + @Test + public void testUpdateRetry() throws Exception { + stubFor(get(urlEqualTo("/endpoint")).inScenario("testUpdateRetry") + .whenScenarioStateIs(STARTED) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"1\"") + .withBody("{\"id\":5,\"name\":\"test1\"}")) + .willSetStateTo("Second attempt")); + stubFor(put(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(IF_MATCH, matching("\"1\"")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"testX\"}")) + .willReturn(aResponse() + .withStatus(SC_PRECONDITION_FAILED))); + stubFor(get(urlEqualTo("/endpoint")).inScenario("testUpdateRetry") + .whenScenarioStateIs("Second attempt") + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"2\"") + .withBody("{\"id\":5,\"name\":\"test2\"}"))); + stubFor(put(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(IF_MATCH, matching("\"2\"")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"testX\"}")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + endpoint.update(x -> x.setName("testX")); + } + + @Test(expected = IllegalStateException.class) + public void testUpdateFail() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"1\"") + .withBody("{\"id\":5,\"name\":\"test1\"}"))); + stubFor(put(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(IF_MATCH, matching("\"1\"")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"testX\"}")) + .willReturn(aResponse() + .withStatus(SC_PRECONDITION_FAILED))); + + endpoint.update(x -> x.setName("testX"), 0); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testMergeNoResult() throws Exception { + stubFor(put(urlEqualTo("/endpoint")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"test\"}")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + endpoint.merge(new MockEntity(5, "test")); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testMergeResult() throws Exception { + stubFor(put(urlEqualTo("/endpoint")) + .withRequestBody(equalToJson("{\"id\":5,\"name\":\"test\"}")) + .willReturn(aResponse() + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"id\":5,\"name\":\"testXXX\"}"))); + + assertThat(endpoint.merge(new MockEntity(5, "test")), + is(equalTo(new MockEntity(5, "testXXX")))); + } + + @Test + public void testDelete() throws Exception { + stubFor(delete(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + endpoint.delete(); + } + + @Test + public void testDeleteETag() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(ETAG, "\"123abc\"") + .withBody("{\"id\":5,\"name\":\"test\"}"))); + endpoint.read(); + + stubFor(delete(urlEqualTo("/endpoint")) + .withHeader(IF_MATCH, matching("\"123abc\"")) + .willReturn(aResponse() + .withStatus(SC_NO_CONTENT))); + + endpoint.delete(); + } + + @Test + public void testException() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_BAD_REQUEST) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"message\":\"test\"}"))); + + String exceptionMessage = null; + try { + endpoint.read(); + } catch (IllegalArgumentException ex) { + exceptionMessage = ex.getMessage(); + } + + assertThat(exceptionMessage, is(CoreMatchers.equalTo("test"))); + } +} diff --git a/core/src/test/java/net/typedrest/EntryEndpointTest.java b/core/src/test/java/net/typedrest/EntryEndpointTest.java new file mode 100644 index 00000000..8ae896eb --- /dev/null +++ b/core/src/test/java/net/typedrest/EntryEndpointTest.java @@ -0,0 +1,68 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import java.net.URI; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import org.junit.*; + +public class EntryEndpointTest { + + private final int port = 8089; + + @Rule + public WireMockRule wireMockRule = new WireMockRule(port); + + private final URI serverUri = URI.create("http://localhost:" + port + "/"); + + @Test + public void testBasicAuthPreemtive() throws Exception { + stubFor(get(urlEqualTo("/")) + .withHeader(AUTHORIZATION, equalTo("Basic dXNlcjpwYXNzd29yZA==")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withBody("ok"))); + + EntryEndpoint endpoint = new EntryEndpoint(serverUri, "user", "password"); + endpoint.readMeta(); + } + + @Test + @Ignore("Currently not implemented") + public void testBasicAuthNegotiated() throws Exception { + stubFor(get(urlEqualTo("/")) + .willReturn(aResponse() + .withStatus(SC_UNAUTHORIZED) + .withHeader(WWW_AUTHENTICATE, "Basic") + .withBody("ok"))); + + stubFor(get(urlEqualTo("/")) + .withHeader(AUTHORIZATION, equalTo("Basic dXNlcjpwYXNzd29yZA==")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withBody("ok"))); + + EntryEndpoint endpoint = new EntryEndpoint(serverUri, "user", "password"); + endpoint.readMeta(); + } + + @Test + @Ignore("Currently not implemented") + public void testBasicAuthNegotiatedWithAlternatives() throws Exception { + stubFor(get(urlEqualTo("/")) + .willReturn(aResponse() + .withStatus(SC_UNAUTHORIZED) + .withHeader(WWW_AUTHENTICATE, "Basic, Bearer") + .withBody("ok"))); + + stubFor(get(urlEqualTo("/")) + .withHeader(AUTHORIZATION, equalTo("Basic dXNlcjpwYXNzd29yZA==")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withBody("ok"))); + + EntryEndpoint endpoint = new EntryEndpoint(serverUri, "user", "password"); + endpoint.readMeta(); + } +} diff --git a/core/src/test/java/net/typedrest/FunctionEndpointTest.java b/core/src/test/java/net/typedrest/FunctionEndpointTest.java new file mode 100644 index 00000000..4b98b577 --- /dev/null +++ b/core/src/test/java/net/typedrest/FunctionEndpointTest.java @@ -0,0 +1,35 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static net.typedrest.AbstractEndpointTest.JSON_MIME; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import static org.hamcrest.CoreMatchers.equalTo; +import org.junit.*; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +public class FunctionEndpointTest extends AbstractEndpointTest { + + private FunctionEndpoint endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new FunctionEndpointImpl<>(entryEndpoint, "endpoint", MockEntity.class, MockEntity.class); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testTrigger() throws Exception { + stubFor(post(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"id\":2,\"name\":\"result\"}"))); + + assertThat(endpoint.trigger(new MockEntity(1, "input")), + is(equalTo(new MockEntity(2, "result")))); + } +} diff --git a/core/src/test/java/net/typedrest/MockEntity.java b/core/src/test/java/net/typedrest/MockEntity.java new file mode 100644 index 00000000..d3c266f3 --- /dev/null +++ b/core/src/test/java/net/typedrest/MockEntity.java @@ -0,0 +1,30 @@ +package net.typedrest; + +import lombok.*; + +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class MockEntity { + + private long id; + + @Id + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/core/src/test/java/net/typedrest/PollingEndpointTest.java b/core/src/test/java/net/typedrest/PollingEndpointTest.java new file mode 100644 index 00000000..ad39f84d --- /dev/null +++ b/core/src/test/java/net/typedrest/PollingEndpointTest.java @@ -0,0 +1,71 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; +import static java.util.Arrays.asList; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import org.junit.*; +import rx.*; +import rx.observers.TestSubscriber; +import rx.schedulers.Schedulers; +import rx.schedulers.TestScheduler; +import static net.typedrest.AbstractEndpointTest.JSON_MIME; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +public class PollingEndpointTest extends AbstractEndpointTest { + + private PollingEndpointImpl endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new PollingEndpointImpl<>(entryEndpoint, "endpoint", MockEntity.class, + x -> x.getId() == 3); + endpoint.setPollingInterval(0); + } + + @Test + public void testGetObservable() throws Exception { + stubFor(get(urlEqualTo("/endpoint")) + .inScenario("Polling").whenScenarioStateIs(STARTED) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"id\":1,\"name\":\"test\"}")) + .willSetStateTo("2")); + stubFor(get(urlEqualTo("/endpoint")) + .inScenario("Polling").whenScenarioStateIs("2") + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"id\":2,\"name\":\"test\"}")) + .willSetStateTo("3")); + stubFor(get(urlEqualTo("/endpoint")) + .inScenario("Polling").whenScenarioStateIs("3") + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(RETRY_AFTER, "42") + .withBody("{\"id\":3,\"name\":\"test\"}"))); + + TestScheduler scheduler = Schedulers.test(); + Observable observable = endpoint.getObservable(scheduler); + + TestSubscriber subscriber = new TestSubscriber<>(); + observable.subscribe(subscriber); + scheduler.triggerActions(); + + subscriber.assertReceivedOnNext(asList( + new MockEntity(1, "test"), + new MockEntity(2, "test"), + new MockEntity(3, "test"))); + subscriber.assertNoErrors(); + subscriber.assertCompleted(); + + assertThat(endpoint.getPollingInterval(), is(equalTo(42))); + } +} diff --git a/core/src/test/java/net/typedrest/StreamEndpointTest.java b/core/src/test/java/net/typedrest/StreamEndpointTest.java new file mode 100644 index 00000000..c281425b --- /dev/null +++ b/core/src/test/java/net/typedrest/StreamEndpointTest.java @@ -0,0 +1,105 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static java.util.Arrays.asList; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import org.junit.*; +import rx.*; +import rx.observers.TestSubscriber; +import rx.schedulers.Schedulers; +import rx.schedulers.TestScheduler; +import static net.typedrest.AbstractEndpointTest.JSON_MIME; + +public class StreamEndpointTest extends AbstractEndpointTest { + + private StreamEndpointImpl endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new StreamEndpointImpl<>(entryEndpoint, "endpoint", MockEntity.class); + } + + @Test + public void testGetObservable() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(RANGE, equalTo("elements=0-")) + .willReturn(aResponse() + .withStatus(SC_PARTIAL_CONTENT) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(CONTENT_RANGE, "elements 0-1/*") + .withBody("[{\"id\":5,\"name\":\"test1\"},{\"id\":6,\"name\":\"test2\"}]"))); + + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(RANGE, equalTo("elements=2-")) + .willReturn(aResponse() + .withStatus(SC_PARTIAL_CONTENT) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(CONTENT_RANGE, "elements 2-2/3") + .withBody("[{\"id\":7,\"name\":\"test3\"}]"))); + + TestScheduler scheduler = Schedulers.test(); + Observable observable = endpoint.getObservable(0, scheduler); + + TestSubscriber subscriber = new TestSubscriber<>(); + observable.subscribe(subscriber); + scheduler.triggerActions(); + + subscriber.assertReceivedOnNext(asList( + new MockEntity(5, "test1"), + new MockEntity(6, "test2"), + new MockEntity(7, "test3"))); + subscriber.assertNoErrors(); + subscriber.assertCompleted(); + } + + @Test + public void testGetObservableOffset() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(RANGE, equalTo("elements=2-")) + .willReturn(aResponse() + .withStatus(SC_PARTIAL_CONTENT) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(CONTENT_RANGE, "elements 2-2/3") + .withBody("[{\"id\":7,\"name\":\"test3\"}]"))); + + TestScheduler scheduler = Schedulers.test(); + Observable observable = endpoint.getObservable(2, scheduler); + + TestSubscriber subscriber = new TestSubscriber<>(); + observable.subscribe(subscriber); + scheduler.triggerActions(); + + subscriber.assertReceivedOnNext(asList(new MockEntity(7, "test3"))); + subscriber.assertNoErrors(); + subscriber.assertCompleted(); + } + + @Test + public void testGetObservableTail() throws Exception { + stubFor(get(urlEqualTo("/endpoint/")) + .withHeader(ACCEPT, equalTo(JSON_MIME)) + .withHeader(RANGE, equalTo("elements=-1")) + .willReturn(aResponse() + .withStatus(SC_PARTIAL_CONTENT) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withHeader(CONTENT_RANGE, "elements 2-2/3") + .withBody("[{\"id\":7,\"name\":\"test3\"}]"))); + + TestScheduler scheduler = Schedulers.test(); + Observable observable = endpoint.getObservable(-1, scheduler); + + TestSubscriber subscriber = new TestSubscriber<>(); + observable.subscribe(subscriber); + scheduler.triggerActions(); + + subscriber.assertReceivedOnNext(asList(new MockEntity(7, "test3"))); + subscriber.assertNoErrors(); + subscriber.assertCompleted(); + } +} diff --git a/core/src/test/java/net/typedrest/SupplierEndpointTest.java b/core/src/test/java/net/typedrest/SupplierEndpointTest.java new file mode 100644 index 00000000..9aff2e0d --- /dev/null +++ b/core/src/test/java/net/typedrest/SupplierEndpointTest.java @@ -0,0 +1,34 @@ +package net.typedrest; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.apache.http.HttpHeaders.*; +import static org.apache.http.HttpStatus.*; +import static org.hamcrest.CoreMatchers.equalTo; +import org.junit.*; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +public class SupplierEndpointTest extends AbstractEndpointTest { + + private SupplierEndpoint endpoint; + + @Before + @Override + public void before() { + super.before(); + endpoint = new SupplierEndpointImpl<>(entryEndpoint, "endpoint", MockEntity.class); + } + + @Test + @Ignore("Works in isolation but fails when executed as part of test suite") + public void testTrigger() throws Exception { + stubFor(post(urlEqualTo("/endpoint")) + .willReturn(aResponse() + .withStatus(SC_OK) + .withHeader(CONTENT_TYPE, JSON_MIME) + .withBody("{\"id\":2,\"name\":\"result\"}"))); + + assertThat(endpoint.trigger(), + is(equalTo(new MockEntity(2, "result")))); + } +} diff --git a/core/src/test/java/net/typedrest/URIUtilsTest.java b/core/src/test/java/net/typedrest/URIUtilsTest.java new file mode 100644 index 00000000..51cde081 --- /dev/null +++ b/core/src/test/java/net/typedrest/URIUtilsTest.java @@ -0,0 +1,46 @@ +package net.typedrest; + +import static net.typedrest.URIUtils.*; +import java.net.*; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; +import org.junit.*; + +public class URIUtilsTest { + + @Test + public void testEnsureTrailingSlashRelativeUnrooted() { + assertThat(ensureTrailingSlash(URI.create("test")), + is(equalTo(URI.create("test/")))); + } + + @Test + public void testEnsureTrailingSlashRelativeRooted() { + assertThat(ensureTrailingSlash(URI.create("/test")), + is(equalTo(URI.create("/test/")))); + } + + @Test + public void testEnsureTrailingSlashAbsolute() { + assertThat(ensureTrailingSlash(URI.create("http://localhost/test")), + is(equalTo(URI.create("http://localhost/test/")))); + } + + @Test + public void testEnsureTrailingSlashRelativeUnrootedWithQuery() { + assertThat(ensureTrailingSlash(URI.create("test?x=1")), + is(equalTo(URI.create("test/?x=1")))); + } + + @Test + public void testEnsureTrailingSlashRelativeRootedWithQuery() { + assertThat(ensureTrailingSlash(URI.create("/test?x=1")), + is(equalTo(URI.create("/test/?x=1")))); + } + + @Test + public void testEnsureTrailingSlashAbsoluteWithQuery() { + assertThat(ensureTrailingSlash(URI.create("http://localhost/test?x=1")), + is(equalTo(URI.create("http://localhost/test/?x=1")))); + } +} diff --git a/doc/Doxyfile b/doc/Doxyfile new file mode 100644 index 00000000..6c772364 --- /dev/null +++ b/doc/Doxyfile @@ -0,0 +1,21 @@ +PROJECT_NAME = "TypedRest for Java" + +INPUT = . ../annotations/src/main/java ../core/src/main/java ../vaadin/src/main/java +USE_MDFILE_AS_MAINPAGE = main.md +STRIP_FROM_PATH = ../src +RECURSIVE = YES + +OUTPUT_DIRECTORY = ../target/doc +GENERATE_TAGFILE = ../target/doc/typedrest-java.tag +HTML_OUTPUT = . + +GENERATE_LATEX = NO +CASE_SENSE_NAMES = NO + +JAVADOC_AUTOBRIEF = YES +OPTIMIZE_OUTPUT_JAVA = YES +EXTRACT_PACKAGE = YES +EXTRACT_STATIC = YES +AUTOLINK_SUPPORT = NO +WARN_IF_UNDOCUMENTED = NO +QUIET = YES diff --git a/doc/build.ps1 b/doc/build.ps1 new file mode 100644 index 00000000..1b80507c --- /dev/null +++ b/doc/build.ps1 @@ -0,0 +1,18 @@ +$ErrorActionPreference = "Stop" +pushd $PSScriptRoot + +# Ensure 0install is in PATH +if (!(Get-Command 0install -ErrorAction SilentlyContinue)) { + echo "Downloading 0install" + mkdir -Force "$env:TEMP\zero-install" | Out-Null + Invoke-WebRequest "https://0install.de/files/0install.exe" -OutFile "$env:TEMP\zero-install\0install.exe" + $env:PATH = "$env:TEMP\zero-install;$env:PATH" +} + +if (Test-Path ..\target\doc) {rm -Recurse -Force ..\target\doc} +mkdir ..\target\doc | Out-Null + +0install run --batch https://apps.0install.net/devel/doxygen.xml +if ($LASTEXITCODE -ne 0) {throw "Exit Code: $LASTEXITCODE"} + +popd diff --git a/doc/build.sh b/doc/build.sh new file mode 100755 index 00000000..60ae81df --- /dev/null +++ b/doc/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e +cd `dirname $0` + +rm -rf ../target/doc +mkdir -p ../target/doc + +0install run https://apps.0install.net/devel/doxygen.xml diff --git a/doc/main.md b/doc/main.md new file mode 100644 index 00000000..1b6d9b9e --- /dev/null +++ b/doc/main.md @@ -0,0 +1,10 @@ +# TypedRest for Java + +TypedRest helps you build type-safe fluent-style REST API clients. + +This website documents the TypedRest Java API. +For a general introduction to TypedRest and its concepts visit [typedrest.net](https://typedrest.net/). + +## Building and contributing + +See the [GitHub project](https://github.com/TypedRest/TypedRest-Java) for more information. diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index 7fc6f1ff..00000000 --- a/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index 3ca00f13..00000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,25 +0,0 @@ -[versions] -okhttp3 = "5.5.0" -uri-templates = "2.1.8" -rxkotlin = "3.0.1" -kotlinx-serialization = "1.11.0" -jackson = "3.2.3" -moshi = "1.15.2" -xmlutil = "1.0.2.1" -jaxb-api = "4.0.5" -jaxb-runtime = "4.0.9" - -[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" } -rxkotlin = { module = "io.reactivex.rxjava3:rxkotlin", version.ref = "rxkotlin" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } -jackson-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" } -jackson-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson" } -jackson-dataformat-xml = { module = "tools.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" } -moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } -moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } -xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } -jaxb-api = { module = "jakarta.xml.bind:jakarta.xml.bind-api", version.ref = "jaxb-api" } -jaxb-runtime = { module = "org.glassfish.jaxb:jaxb-runtime", version.ref = "jaxb-runtime" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index b1b8ef56..00000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2e7bb9fd..00000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,9 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip -networkTimeout=10000 -retries=0 -retryBackOffMs=500 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index 249efbb0..00000000 --- a/gradlew +++ /dev/null @@ -1,248 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015 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. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# gradlew 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 gradlew -# -# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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 - - - -# 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" ) - - 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, 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" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# 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 deleted file mode 100644 index 8508ef68..00000000 --- a/gradlew.bat +++ /dev/null @@ -1,82 +0,0 @@ -@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 -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -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 - -"%COMSPEC%" /c exit 1 - -: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 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/parent/pom.xml b/parent/pom.xml new file mode 100644 index 00000000..6919b1c2 --- /dev/null +++ b/parent/pom.xml @@ -0,0 +1,164 @@ + + + 4.0.0 + + com.oneandone + typedrest-parent + pom + 0.31-SNAPSHOT + + + + 0.31-SNAPSHOT + + + 2.9.5 + + UTF-8 + + + TypedRest Parent + Parent POM for configuration shared across TypedRest libraries + https://github.com/TypedRest/TypedRest-Java + + + The MIT License (MIT) + http://opensource.org/licenses/MIT + + + + + scm:git:https://github.com/TypedRest/TypedRest-Java.git + https://github.com/TypedRest/TypedRest-Java + HEAD + + + + Bastian Eicher + bastian@eicher.net + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.7.0 + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-source-plugin + 3.0.1 + + + attach-sources + + jar-no-fork + + + + + + + + org.projectlombok + lombok-maven-plugin + 1.16.20.0 + + + delombok-sources + generate-sources + + delombok + + + + + src/main/java + ${project.build.directory}/delomboked-sources + false + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.0.0 + + + attach-javadocs + + jar + + + + + -Xdoclint:none + target/delomboked-sources + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + deploy + + sign + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + ossrh + https://oss.sonatype.org/ + true + + + + + + + + + org.projectlombok + lombok + 1.16.20 + provided + + + + + junit + junit + 4.12 + test + + + org.hamcrest + hamcrest-all + 1.3 + test + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..b62ee452 --- /dev/null +++ b/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.oneandone + typedrest + 0.31-SNAPSHOT + pom + + TypedRest + Groups all TypedRest libraries as modules. + + + parent + annotations + core + vaadin + archetype + + + + + + + org.apache.maven.plugins + maven-deploy-plugin + 2.8.2 + + true + + + + + diff --git a/renovate.json b/renovate.json deleted file mode 100644 index e12c8c82..00000000 --- a/renovate.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$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 deleted file mode 100644 index 70bdcddc..00000000 --- a/settings.gradle.kts +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = "typedrest" -include("typedrest", "typedrest-reactive", "typedrest-serializers-moshi", "typedrest-serializers-jackson", "typedrest-serializers-jackson-xml", "typedrest-serializers-xmlutil", "typedrest-serializers-jaxb") diff --git a/typedrest-reactive/build.gradle.kts b/typedrest-reactive/build.gradle.kts deleted file mode 100644 index 687b2730..00000000 --- a/typedrest-reactive/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -description = "Adds support for streaming with ReactiveX (http://reactivex.io/)." - -kotlin.jvmToolchain(21) -tasks.test { useJUnitPlatform() } - -dependencies { - api(libs.okhttp3) - api(libs.rxkotlin) - api(project(":typedrest")) - implementation(libs.kotlinx.serialization.json) - - testImplementation(kotlin("test")) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation(libs.okhttp3.mockwebserver) -} diff --git a/typedrest-reactive/src/main/_doc.md b/typedrest-reactive/src/main/_doc.md deleted file mode 100644 index 01f9fd49..00000000 --- a/typedrest-reactive/src/main/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Module typedrest-reactive - -Adds support for streaming with [ReactiveX (Rx)](http://reactivex.io/). diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/EmitterExtensions.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/EmitterExtensions.kt deleted file mode 100644 index 9e3afafc..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/EmitterExtensions.kt +++ /dev/null @@ -1,18 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.ObservableEmitter -import java.time.Duration -import kotlin.math.min - -/** - * Sleep for the specified duration unless the emitter is disposed in the meantime. - */ -fun ObservableEmitter<*>.sleep(duration: Duration) { - val waitMillis = duration.toMillis() - var slept = 0L - while (slept < waitMillis && !isDisposed) { - val step = min(500L, waitMillis - slept) - Thread.sleep(step) - slept += step - } -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/GenericStreamingCollectionEndpoint.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/GenericStreamingCollectionEndpoint.kt deleted file mode 100644 index 11baea10..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/GenericStreamingCollectionEndpoint.kt +++ /dev/null @@ -1,31 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.Observable -import net.typedrest.endpoints.generic.ElementEndpoint -import net.typedrest.endpoints.generic.GenericCollectionEndpoint -import java.time.Duration - -/** - * Endpoint for a collection of [TEntity]s observable as an append-only stream. - * - * Use the more constrained [StreamingCollectionEndpoint] 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 GenericStreamingCollectionEndpoint> - : GenericCollectionEndpoint { - /** - * Interval in which requests are sent to the server. - * The server may update this interval via the `Retry-After` response header. - */ - var pollingInterval: Duration - - /** - * Provides an observable stream of elements. - * - * @param startIndex The index of the first element to return in the stream. Use negative values to start counting from the end of the stream. - * @return A cold observable. HTTP communication only starts once [Observable.subscribe] is invoked - */ - fun getObservable(startIndex: Long = 0): Observable -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/GenericStreamingCollectionEndpointImpl.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/GenericStreamingCollectionEndpointImpl.kt deleted file mode 100644 index 90276d30..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/GenericStreamingCollectionEndpointImpl.kt +++ /dev/null @@ -1,82 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers -import net.typedrest.endpoints.Endpoint -import net.typedrest.endpoints.generic.ElementEndpoint -import net.typedrest.endpoints.generic.GenericCollectionEndpointImpl -import net.typedrest.errors.ConflictException -import net.typedrest.http.retryAfterDuration -import okhttp3.Response -import java.net.URI -import java.time.Duration -import java.time.Duration.ofSeconds - -/** - * Endpoint for a collection of [TEntity]s observable as an append-only stream. - * - * Use the more constrained [StreamingCollectionEndpointImpl] 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 GenericStreamingCollectionEndpointImpl>( - referrer: Endpoint, - relativeUri: URI, - entityType: Class, - elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint -) : GenericCollectionEndpointImpl(referrer, relativeUri, entityType, elementEndpointFactory), - GenericStreamingCollectionEndpoint { - /** - * Creates a new streaming 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, elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint) : - this(referrer, URI(relativeUri), entityType, elementEndpointFactory) - - override fun handle(response: Response): Response { - val response = super.handle(response) - response.retryAfterDuration()?.let { pollingInterval = it } - return response - } - - override var pollingInterval: Duration = ofSeconds(3) - - override fun getObservable(startIndex: Long): Observable = - Observable.create { emitter -> - var currentStartIndex: Long = startIndex - - while (!emitter.isDisposed) { - val response = try { - if (currentStartIndex >= 0) readRange(from = currentStartIndex, to = null) // Offset - else readRange(from = null, to = -currentStartIndex) // Tail - } catch (_: ConflictException) { - // No new data available yet, keep polling - emitter.sleep(pollingInterval) - continue - } - - response.elements.forEach(emitter::onNext) - - if (response.endReached) { - emitter.onComplete() - return@create - } - - // Continue polling for more data - response.range?.to?.let { to -> currentStartIndex = to + 1 } - ?: return@create - - emitter.sleep(pollingInterval) - } - } - .subscribeOn(Schedulers.io()) -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/PollingEndpoint.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/PollingEndpoint.kt deleted file mode 100644 index 5a83a033..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/PollingEndpoint.kt +++ /dev/null @@ -1,25 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.Observable -import net.typedrest.endpoints.generic.ElementEndpoint -import java.time.Duration - -/** - * Endpoint for a resource that can be polled for state changes. - * - * @param TEntity The type of entity the endpoint represents. - */ -interface PollingEndpoint : ElementEndpoint { - /** - * Interval in which requests are sent to the server. - * The server may update this interval via the `Retry-After` response header. - */ - var pollingInterval: Duration - - /** - * Returns an [Observable] stream of entity states. - * Consecutive items are emitted only when the server-supplied entity - * differs from the previously emitted one according to [Any.equals]. - */ - fun getObservable(): Observable -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/PollingEndpointImpl.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/PollingEndpointImpl.kt deleted file mode 100644 index 1d1ad4d9..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/PollingEndpointImpl.kt +++ /dev/null @@ -1,65 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers -import net.typedrest.endpoints.Endpoint -import net.typedrest.endpoints.generic.ElementEndpointImpl -import net.typedrest.http.retryAfterDuration -import okhttp3.Response -import java.net.URI -import java.time.Duration -import java.time.Duration.ofSeconds - -/** - * Endpoint for a resource that can be polled for state changes. - * - * @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 endCondition A check to determine whether the entity has reached its final state and no further polling is required. - * @param TEntity The type of entity the endpoint represents. - */ -open class PollingEndpointImpl( - referrer: Endpoint, - relativeUri: URI, - entityType: Class, - private val endCondition: ((TEntity) -> Boolean)? = null -) : ElementEndpointImpl(referrer, relativeUri, entityType), PollingEndpoint { - /** - * Creates a new polling 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. - * @param endCondition A check to determine whether the entity has reached its final state and no further polling is required. - */ - constructor( - referrer: Endpoint, - relativeUri: String, - entityType: Class, - endCondition: ((TEntity) -> Boolean)? = null - ) : this(referrer, URI(relativeUri), entityType, endCondition) - - override fun handle(response: Response): Response { - val response = super.handle(response) - response.retryAfterDuration()?.let { pollingInterval = it } - return response - } - - override var pollingInterval: Duration = ofSeconds(3) - - override fun getObservable(): Observable = - Observable.create { emitter -> - while (!emitter.isDisposed) { - val entity = read() - emitter.onNext(entity) - if (endCondition?.invoke(entity) == true) break - - emitter.sleep(pollingInterval) - } - - emitter.onComplete() - } - .distinctUntilChanged() - .subscribeOn(Schedulers.io()) -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/SseStreamingEndpointImpl.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/SseStreamingEndpointImpl.kt deleted file mode 100644 index 38d24b9c..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/SseStreamingEndpointImpl.kt +++ /dev/null @@ -1,138 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.ObservableEmitter -import io.reactivex.rxjava3.schedulers.Schedulers -import net.typedrest.endpoints.AbstractEndpoint -import net.typedrest.endpoints.Endpoint -import net.typedrest.http.HttpStatusCode -import net.typedrest.http.SseReader -import net.typedrest.http.parseEntity -import net.typedrest.http.sseReader -import net.typedrest.http.uri -import okhttp3.Request -import java.io.IOException -import java.net.URI -import java.time.Duration -import java.time.Duration.ofSeconds - -/** - * Endpoint for a stream of [TEntity]s using Server-Sent Events (SSE). - * - * Sends `Accept: text/event-stream`. By default, transparently reconnects on connection drops or transient errors, - * honoring the server-supplied `retry:` interval and resuming via the `Last-Event-ID` header. - * - * @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 stream. - * @param eventType If set, only events with this `event:` type are emitted; others are ignored. null emits all events. - * @param TEntity The type of individual elements in the stream. - */ -open class SseStreamingEndpointImpl( - referrer: Endpoint, - relativeUri: URI, - private val entityType: Class, - private val eventType: String? = null -) : AbstractEndpoint(referrer, relativeUri), StreamingEndpoint { - /** - * Creates a new SSE streaming 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 stream. - * @param eventType If set, only events with this `event:` type are emitted; others are ignored. null emits all events. - */ - constructor( - referrer: Endpoint, - relativeUri: String, - entityType: Class, - eventType: String? = null - ) : this(referrer, URI(relativeUri), entityType, eventType) - - /** - * Whether to transparently reconnect on connection drops, transient transport errors and 5xx responses. - */ - var autoReconnect: Boolean = true - - /** - * The reconnection interval used when the server has not (yet) supplied one via the SSE `retry:` field. - */ - var defaultReconnectionInterval: Duration = ofSeconds(3) - - override fun getObservable(): Observable = Observable.create { emitter -> - var lastEventId: String? = null - var reconnectionInterval = defaultReconnectionInterval - - while (!emitter.isDisposed) { - val reconnect = try { - consumeOnce(emitter, lastEventId) { reader -> - reader.lastEventId?.let { lastEventId = it } - reader.reconnectionInterval?.let { reconnectionInterval = it } - } - } catch (ex: IOException) { - // Transport-level failures are the ones worth retrying; anything else is the server telling us no - if (!autoReconnect) { - emitter.onError(ex) - return@create - } - true - } catch (ex: Throwable) { - emitter.onError(ex) - return@create - } - - if (!reconnect) break - emitter.sleep(reconnectionInterval) - } - - emitter.onComplete() - }.subscribeOn(Schedulers.io()) - - /** - * Opens a connection and emits events until the stream ends. - * - * @param updateState Called with the reader once the connection has ended, to carry the last event id and reconnection interval over to the next attempt. - * @return true if another connection attempt should be made, false if the stream has ended for good. - */ - private fun consumeOnce(emitter: ObservableEmitter, lastEventId: String?, updateState: (SseReader) -> Unit): Boolean { - val request = Request.Builder() - .get() - .uri(uri) - .header("Accept", EVENT_STREAM_MEDIA_TYPE) - .apply { if (!lastEventId.isNullOrEmpty()) header("Last-Event-ID", lastEventId) } - .build() - - val call = httpClient.newCall(request) - emitter.setCancellable(call::cancel) - val response = call.execute() - - if (response.code == HttpStatusCode.NoContent.code) { - response.close() - return false - } - if (autoReconnect && response.code >= 500) { - response.close() - return true - } - - handle(response).use { - val reader = it.sseReader() - try { - val serializer = serializers.first() - for (event in reader.events()) { - if (emitter.isDisposed) return false - if (eventType != null && event.type != eventType) continue - parseEntity(serializer, event.data.toByteArray(), entityType)?.let(emitter::onNext) - } - } finally { - updateState(reader) - } - } - - return autoReconnect - } - - private companion object { - const val EVENT_STREAM_MEDIA_TYPE = "text/event-stream" - } -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpoint.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpoint.kt deleted file mode 100644 index fead009f..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpoint.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.typedrest.endpoints.reactive - -import net.typedrest.endpoints.generic.ElementEndpoint - -/** - * Endpoint for a collection of [TEntity]s observable as an append-only stream. - * - * Use [GenericStreamingCollectionEndpoint] instead if you wish to customize the element endpoint type. - * - * @param TEntity The type of entity the endpoint represents. - */ -interface StreamingCollectionEndpoint - : GenericStreamingCollectionEndpoint> diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpointImpl.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpointImpl.kt deleted file mode 100644 index e8273218..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpointImpl.kt +++ /dev/null @@ -1,35 +0,0 @@ -package net.typedrest.endpoints.reactive - -import net.typedrest.endpoints.Endpoint -import net.typedrest.endpoints.generic.ElementEndpoint -import net.typedrest.endpoints.generic.ElementEndpointImpl -import java.net.URI - -/** - * Endpoint for a collection of [TEntity]s observable as an append-only stream using long-polling. - * - * Use [GenericStreamingCollectionEndpointImpl] instead if you wish to customize the element endpoint type. - * - * @param TEntity The type of individual elements in the collection. - */ -open class StreamingCollectionEndpointImpl : GenericStreamingCollectionEndpointImpl>, StreamingCollectionEndpoint { - /** - * 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) - : 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) - : super(referrer, relativeUri, entityType, { ref, uri -> ElementEndpointImpl(ref, uri, entityType) }) -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingEndpoint.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingEndpoint.kt deleted file mode 100644 index 7ccb6327..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingEndpoint.kt +++ /dev/null @@ -1,18 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.Observable -import net.typedrest.endpoints.Endpoint - -/** - * Endpoint for a stream of [TEntity]s. - * - * @param TEntity The type of individual elements in the stream. - */ -interface StreamingEndpoint : Endpoint { - /** - * Provides an [Observable] stream of entities. - * - * @return A cold observable. HTTP communication only starts once [Observable.subscribe] is invoked. - */ - fun getObservable(): Observable -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingEndpointImpl.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingEndpointImpl.kt deleted file mode 100644 index 32eeee40..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/StreamingEndpointImpl.kt +++ /dev/null @@ -1,63 +0,0 @@ -package net.typedrest.endpoints.reactive - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers -import net.typedrest.endpoints.Endpoint -import net.typedrest.endpoints.AbstractEndpoint -import net.typedrest.http.entitySequence -import net.typedrest.http.uri -import okhttp3.Request -import okio.ByteString.Companion.toByteString -import java.net.URI - -/** - * Endpoint for a stream of [TEntity]s using a persistent HTTP connection. - * - * @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 stream. - * @param separator The character sequence used to detect that a new element starts in an HTTP stream. - * @param TEntity The type of individual elements in the stream. - */ -open class StreamingEndpointImpl( - referrer: Endpoint, - relativeUri: URI, - private val entityType: Class, - private val separator: String = "\n" -) : AbstractEndpoint(referrer, relativeUri), StreamingEndpoint { - /** - * Creates a new streaming 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 stream. - * @param separator The character sequence used to detect that a new element starts in an HTTP stream. - */ - constructor( - referrer: Endpoint, - relativeUri: String, - entityType: Class, - separator: String = "\n" - ) : this(referrer, URI(relativeUri), entityType, separator) - - /** - * The size of the buffer used to collect data for deserialization in bytes. - */ - var bufferSize: Int = DEFAULT_BUFFER_SIZE - - override fun getObservable(): Observable = Observable.create { emitter -> - val call = httpClient.newCall(Request.Builder().get().uri(uri).build()) - emitter.setCancellable(call::cancel) - - handle(call.execute()) - .use { response -> - val charset = response.body.contentType()?.charset() ?: Charsets.UTF_8 - response - .entitySequence(serializers.first(), separator.toByteArray(charset).toByteString(), bufferSize, entityType) - .takeWhile { !emitter.isDisposed } - .forEach(emitter::onNext) - } - - emitter.onComplete() - }.subscribeOn(Schedulers.io()) -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/_doc.md b/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/_doc.md deleted file mode 100644 index 0d5d2f00..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/endpoints/reactive/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest.endpoints.reactive - -Reactive endpoints allow you to receive data as push streams rather than explicitly pulling. diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/http/ResponseExtensions.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/http/ResponseExtensions.kt deleted file mode 100644 index be85bb62..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/http/ResponseExtensions.kt +++ /dev/null @@ -1,71 +0,0 @@ -package net.typedrest.http - -import net.typedrest.serializers.Serializer -import okhttp3.MediaType -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody -import okio.Buffer -import okio.ByteString -import java.time.* -import java.time.Duration.ofSeconds -import java.time.format.DateTimeFormatter - -/** - * Returns the relative waiting time extracted from the `Retry-After` header if any. - */ -fun Response.retryAfterDuration(): Duration? = - header("Retry-After") - ?.let { value -> - // A plain integer => seconds - value.toLongOrNull() - ?.takeIf { it >= 0 } - ?.let(::ofSeconds) - // Otherwise try RFC-1123 date - ?: runCatching { - val date = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) - Duration.between(Instant.now(), date.toInstant()) - }.getOrNull() - } - ?.takeUnless(Duration::isNegative) - -/** - * Streams deserialized entities from an HTTP response body. - * - * @param serializer Used to deserialize entities in the body. - * @param separatorBytes The byte sequence used to detect that a new element starts in an HTTP stream. - * @param bufferSize The size of the buffer used to collect data for deserialization in bytes. - * @param entityType The entity type this stream provides. - * @param TEntity The entity type this stream provides. - */ -fun Response.entitySequence( - serializer: Serializer, - separatorBytes: ByteString, - bufferSize: Int, - entityType: Class -): Sequence = sequence { - val source = body.source() - val buffer = Buffer() - - while (source.read(buffer, bufferSize.toLong()) != -1L) { - // Emit every entity that is already complete, keep any partial one in the buffer - while (true) { - val separatorIndex = buffer.indexOf(separatorBytes) - if (separatorIndex == -1L) break - - val bytes = buffer.readByteArray(separatorIndex) - buffer.skip(separatorBytes.size.toLong()) - parseEntity(serializer, bytes, entityType)?.let { yield(it) } - } - } - - // The server may end the stream without a trailing separator - if (!buffer.exhausted()) { - parseEntity(serializer, buffer.readByteArray(), entityType)?.let { yield(it) } - } -} - -internal fun parseEntity(serializer: Serializer, bytes: ByteArray, type: Class): T? { - if (bytes.isEmpty()) return null - val mediaType: MediaType? = serializer.supportedMediaTypes.firstOrNull() - return serializer.deserialize(bytes.toResponseBody(mediaType), type) -} diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/http/ServerSentEvents.kt b/typedrest-reactive/src/main/kotlin/net/typedrest/http/ServerSentEvents.kt deleted file mode 100644 index b7b3e0ae..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/http/ServerSentEvents.kt +++ /dev/null @@ -1,89 +0,0 @@ -package net.typedrest.http - -import okhttp3.Response -import okio.BufferedSource -import java.time.Duration - -/** - * A single event read from a Server-Sent Events (SSE) stream. - * - * @param type The event type from the `event:` field. `message` if the server did not specify one. - * @param data The concatenated content of the event's `data:` fields. - * @param id The id of the most recent `id:` field if any. - */ -data class ServerSentEvent(val type: String, val data: String, val id: String?) - -/** - * The event type used for events without an explicit `event:` field. - */ -const val DEFAULT_EVENT_TYPE = "message" - -/** - * Reads [ServerSentEvent]s from an HTTP response body in the `text/event-stream` format. - * - * @param source The response body to read from. - */ -class SseReader(private val source: BufferedSource) { - /** - * The id of the most recent event that carried an `id:` field. - * - * Send this back as a `Last-Event-ID` header when reconnecting to let the server resume where it left off. - */ - var lastEventId: String? = null - private set - - /** - * The reconnection interval most recently requested by the server via a `retry:` field if any. - */ - var reconnectionInterval: Duration? = null - private set - - /** - * Reads events until the stream ends. - * - * Iterating the sequence blocks while waiting for the server to send more data. Any event that is still incomplete - * when the stream ends is discarded. - */ - fun events(): Sequence = sequence { - var type: String? = null - val data = StringBuilder() - - while (true) { - val line = source.readUtf8Line() ?: break - - if (line.isEmpty()) { - // A blank line dispatches the event collected so far - if (data.isNotEmpty()) { - // Every data field appended a trailing newline; the last one is not part of the payload - yield(ServerSentEvent(type ?: DEFAULT_EVENT_TYPE, data.substring(0, data.length - 1), lastEventId)) - data.setLength(0) - } - type = null - continue - } - - // Lines starting with a colon are comments, e.g. the ":ping" some servers send to keep the connection alive - if (line.startsWith(":")) continue - - val colonIndex = line.indexOf(':') - val field = if (colonIndex == -1) line else line.substring(0, colonIndex) - val value = when { - colonIndex == -1 -> "" - line.getOrNull(colonIndex + 1) == ' ' -> line.substring(colonIndex + 2) - else -> line.substring(colonIndex + 1) - } - - when (field) { - "event" -> type = value - "data" -> data.append(value).append('\n') - "id" -> if (!value.contains(Char(0))) lastEventId = value - "retry" -> value.toLongOrNull()?.takeIf { it >= 0 }?.let { reconnectionInterval = Duration.ofMillis(it) } - } - } - } -} - -/** - * Reads the response body as a Server-Sent Events (SSE) stream. - */ -fun Response.sseReader(): SseReader = SseReader(body.source()) diff --git a/typedrest-reactive/src/main/kotlin/net/typedrest/http/_doc.md b/typedrest-reactive/src/main/kotlin/net/typedrest/http/_doc.md deleted file mode 100644 index 1685fc5c..00000000 --- a/typedrest-reactive/src/main/kotlin/net/typedrest/http/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest.http - -Helper methods and structures for performing HTTP requests. diff --git a/typedrest-reactive/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-reactive/src/test/kotlin/net/typedrest/MockEntity.kt deleted file mode 100644 index 6437b4c7..00000000 --- a/typedrest-reactive/src/test/kotlin/net/typedrest/MockEntity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.typedrest - -import kotlinx.serialization.Serializable - -@Serializable -data class MockEntity(val id: Long, val name: String) diff --git a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt b/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt deleted file mode 100644 index 112f1470..00000000 --- a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -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-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/PollingEndpointTest.kt b/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/PollingEndpointTest.kt deleted file mode 100644 index c374bd53..00000000 --- a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/PollingEndpointTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package net.typedrest.endpoints.reactive - -import net.typedrest.MockEntity -import net.typedrest.endpoints.AbstractEndpointTest -import net.typedrest.tests.* -import okhttp3.mockwebserver.MockResponse -import java.time.Duration -import kotlin.test.* - -class PollingEndpointTest : AbstractEndpointTest() { - private val endpoint = PollingEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java, endCondition = { it.id == 3L }) - .apply { pollingInterval = Duration.ZERO } - - @Test - fun testGetObservable() { - server.enqueue(MockResponse().setJsonBody("""{"id":1,"name":"test"}""")) - server.enqueue(MockResponse().setJsonBody("""{"id":2,"name":"test"}""")) - server.enqueue(MockResponse().setJsonBody("""{"id":3,"name":"test"}""").setHeader("Retry-After", "42")) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals( - listOf( - MockEntity(1, "test"), - MockEntity(2, "test"), - MockEntity(3, "test") - ), - entities - ) - assertEquals(Duration.ofSeconds(42), endpoint.pollingInterval) - } -} diff --git a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/SseStreamingEndpointTest.kt b/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/SseStreamingEndpointTest.kt deleted file mode 100644 index 0b8c6674..00000000 --- a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/SseStreamingEndpointTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -package net.typedrest.endpoints.reactive - -import net.typedrest.MockEntity -import net.typedrest.endpoints.AbstractEndpointTest -import net.typedrest.errors.ConflictException -import net.typedrest.http.HttpMethod -import net.typedrest.http.HttpStatusCode -import net.typedrest.tests.* -import okhttp3.mockwebserver.MockResponse -import java.time.Duration -import java.util.concurrent.TimeUnit -import kotlin.test.* - -class SseStreamingEndpointTest : AbstractEndpointTest() { - private val endpoint = SseStreamingEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) - .apply { autoReconnect = false } - - @Test - fun testGetObservable() { - server.enqueue(MockResponse().setSseBody( - sseEvent("""{"id":5,"name":"test1"}""") + - sseEvent("""{"id":6,"name":"test2"}""") + - sseEvent("""{"id":7,"name":"test3"}""") - )) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals( - listOf( - MockEntity(5, "test1"), - MockEntity(6, "test2"), - MockEntity(7, "test3") - ), - entities - ) - server.assertRequest(HttpMethod.GET).withHeader("Accept", "text/event-stream") - } - - @Test - fun testEventTypeFilter() { - val endpoint = SseStreamingEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java, eventType = "update") - .apply { autoReconnect = false } - - server.enqueue(MockResponse().setSseBody( - sseEvent("""{"id":1,"name":"skip"}""", type = "ignored") + - sseEvent("""{"id":5,"name":"test1"}""", type = "update") + - sseEvent("""{"id":99,"name":"default"}""") + - sseEvent("""{"id":6,"name":"test2"}""", type = "update") - )) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals( - listOf( - MockEntity(5, "test1"), - MockEntity(6, "test2") - ), - entities - ) - } - - @Test - fun testComments() { - server.enqueue(MockResponse().setSseBody( - ": keep-alive\n\n" + sseEvent("""{"id":5,"name":"test1"}""") - )) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals(listOf(MockEntity(5, "test1")), entities) - } - - @Test - fun testErrorHandling() { - server.enqueue(MockResponse() - .setResponseCode(HttpStatusCode.Conflict) - .setJsonBody("""{"message":"my message"}""")) - - endpoint.getObservable() - .test() - .awaitDone(10, TimeUnit.SECONDS) - .assertError { it is ConflictException && it.message == "my message" } - } - - @Test - fun testNoContentCompletes() { - endpoint.autoReconnect = true - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals(emptyList(), entities) - } - - @Test - fun testReconnectsWithLastEventId() { - val endpoint = SseStreamingEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) - .apply { defaultReconnectionInterval = Duration.ZERO } - - server.enqueue(MockResponse().setSseBody(sseEvent("""{"id":5,"name":"test1"}""", id = "42"))) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals(listOf(MockEntity(5, "test1")), entities) - assertNull(server.assertRequest(HttpMethod.GET).getHeader("Last-Event-ID")) - server.assertRequest(HttpMethod.GET).withHeader("Last-Event-ID", "42") - } - - @Test - fun testReconnectsOnServerError() { - val endpoint = SseStreamingEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) - .apply { defaultReconnectionInterval = Duration.ZERO } - - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.ServiceUnavailable)) - server.enqueue(MockResponse().setSseBody(sseEvent("""{"id":5,"name":"test1"}"""))) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals(listOf(MockEntity(5, "test1")), entities) - } - - private fun sseEvent(data: String, type: String? = null, id: String? = null): String = buildString { - if (type != null) append("event: ").append(type).append('\n') - if (id != null) append("id: ").append(id).append('\n') - append("data: ").append(data).append("\n\n") - } -} diff --git a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpointTest.kt b/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpointTest.kt deleted file mode 100644 index 675ad83a..00000000 --- a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/StreamingCollectionEndpointTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -package net.typedrest.endpoints.reactive - -import net.typedrest.MockEntity -import net.typedrest.endpoints.AbstractEndpointTest -import net.typedrest.http.HttpMethod -import net.typedrest.http.HttpStatusCode -import net.typedrest.tests.* -import okhttp3.mockwebserver.MockResponse -import java.time.Duration -import kotlin.test.* - -class StreamingCollectionEndpointTest : AbstractEndpointTest() { - private val endpoint = StreamingCollectionEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) - .apply { pollingInterval = Duration.ZERO } - - @Test - fun testGetObservable() { - server.enqueue(MockResponse() - .setResponseCode(HttpStatusCode.PartialContent) - .setHeader("Content-Range", "elements 0-1/*") - .setJsonBody("""[{"id":5,"name":"test1"},{"id":6,"name":"test2"}]""")) - server.enqueue(MockResponse() - .setResponseCode(HttpStatusCode.PartialContent) - .setHeader("Content-Range", "elements 2-2/3") - .setHeader("Retry-After", "42") - .setJsonBody("""[{"id":7,"name":"test3"}]""")) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals( - listOf( - MockEntity(5, "test1"), - MockEntity(6, "test2"), - MockEntity(7, "test3") - ), - entities - ) - server.assertRequest(HttpMethod.GET).withHeader("Range", "elements=0-") - server.assertRequest(HttpMethod.GET).withHeader("Range", "elements=2-") - assertEquals(Duration.ofSeconds(42), endpoint.pollingInterval) - } - - @Test - fun testGetObservableOffset() { - server.enqueue(MockResponse() - .setResponseCode(HttpStatusCode.PartialContent) - .setHeader("Content-Range", "elements 2-2/3") - .setJsonBody("""[{"id":7,"name":"test3"}]""")) - - val entities = endpoint.getObservable(startIndex = 2) - .toList() - .blockingGet() - - assertEquals(listOf(MockEntity(7, "test3")), entities) - server.assertRequest(HttpMethod.GET).withHeader("Range", "elements=2-") - } - - @Test - fun testGetObservableTail() { - server.enqueue(MockResponse() - .setResponseCode(HttpStatusCode.PartialContent) - .setHeader("Content-Range", "elements 2-2/3") - .setJsonBody("""[{"id":7,"name":"test3"}]""")) - - val entities = endpoint.getObservable(startIndex = -1) - .toList() - .blockingGet() - - assertEquals(listOf(MockEntity(7, "test3")), entities) - server.assertRequest(HttpMethod.GET).withHeader("Range", "elements=-1") - } -} diff --git a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/StreamingEndpointTest.kt b/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/StreamingEndpointTest.kt deleted file mode 100644 index 442db5af..00000000 --- a/typedrest-reactive/src/test/kotlin/net/typedrest/endpoints/reactive/StreamingEndpointTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.typedrest.endpoints.reactive - -import net.typedrest.MockEntity -import net.typedrest.endpoints.AbstractEndpointTest -import net.typedrest.errors.ConflictException -import net.typedrest.http.HttpStatusCode -import net.typedrest.tests.* -import okhttp3.mockwebserver.MockResponse -import java.util.concurrent.TimeUnit -import kotlin.test.* - -class StreamingEndpointTest : AbstractEndpointTest() { - private val endpoint = StreamingEndpointImpl(entryEndpoint, "endpoint", MockEntity::class.java) - - @Test - fun testGetObservable() { - server.enqueue(MockResponse().setJsonBody(""" - {"id":5,"name":"test1"} - {"id":6,"name":"test2"} - {"id":7,"name":"test3"} - """.trimIndent())) - - val entities = endpoint.getObservable() - .toList() - .blockingGet() - - assertEquals( - listOf( - MockEntity(5, "test1"), - MockEntity(6, "test2"), - MockEntity(7, "test3") - ), - entities - ) - } - - @Test - fun testErrorHandling() { - server.enqueue(MockResponse() - .setResponseCode(HttpStatusCode.Conflict) - .setJsonBody("""{"message":"my message"}""")) - - endpoint.getObservable() - .test() - .awaitDone(10, TimeUnit.SECONDS) - .assertError { it is ConflictException && it.message == "my message" } - } -} diff --git a/typedrest-reactive/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest-reactive/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt deleted file mode 100644 index 46ace79d..00000000 --- a/typedrest-reactive/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt +++ /dev/null @@ -1,51 +0,0 @@ -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 MockResponse.setSseBody(body: String): MockResponse { - addHeader(contentTypeHeader, "text/event-stream") - setBody(body) - 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-jackson-xml/build.gradle.kts b/typedrest-serializers-jackson-xml/build.gradle.kts deleted file mode 100644 index e167cba9..00000000 --- a/typedrest-serializers-jackson-xml/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -description = "Adds support for serializing using Jackson XML (https://github.com/FasterXML/jackson-dataformat-xml) instead of kotlinx.serialization." - -kotlin.jvmToolchain(21) -tasks.test { useJUnitPlatform() } - -dependencies { - api(libs.okhttp3) - api(libs.jackson.dataformat.xml) - 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-xml/src/main/_doc.md b/typedrest-serializers-jackson-xml/src/main/_doc.md deleted file mode 100644 index 9c97a930..00000000 --- a/typedrest-serializers-jackson-xml/src/main/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# Module typedrest-serializers-jackson-xml - -Adds support for serializing to XML using [Jackson XML](https://github.com/FasterXML/jackson-dataformat-xml). - -Pass [JacksonXmlSerializer](net.typedrest.serializers.JacksonXmlSerializer) to the [EntryEndpoint](net.typedrest.endpoints.EntryEndpoint) constructor. diff --git a/typedrest-serializers-jackson-xml/src/main/kotlin/net/typedrest/serializers/JacksonXmlSerializer.kt b/typedrest-serializers-jackson-xml/src/main/kotlin/net/typedrest/serializers/JacksonXmlSerializer.kt deleted file mode 100644 index be4ad833..00000000 --- a/typedrest-serializers-jackson-xml/src/main/kotlin/net/typedrest/serializers/JacksonXmlSerializer.kt +++ /dev/null @@ -1,42 +0,0 @@ -package net.typedrest.serializers - -import com.fasterxml.jackson.annotation.JsonInclude -import tools.jackson.databind.DeserializationFeature -import tools.jackson.dataformat.xml.XmlMapper -import tools.jackson.module.kotlin.kotlinModule -import okhttp3.* -import okhttp3.RequestBody.Companion.toRequestBody - -/** - * Serializes and deserializes entities as XML using Jackson. - * - * @param mapper The Jackson XML mapper to use for serializing and deserializing. Defaults to omitting `null` properties when writing and tolerating unknown properties when reading. - */ -open class JacksonXmlSerializer @JvmOverloads constructor( - private val mapper: XmlMapper = defaultMapper() -) : AbstractXmlSerializer() { - companion object { - @JvmStatic - fun defaultMapper(): XmlMapper = - XmlMapper.builder() - .addModule(kotlinModule()) - .changeDefaultPropertyInclusion { it.withValueInclusion(JsonInclude.Include.NON_NULL) } - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .build() - } - - override fun serialize(entity: T, type: Class): RequestBody = - mapper.writeValueAsString(entity).toRequestBody(mediaTypeXml) - - override fun serializeList(entities: Iterable, type: Class): RequestBody = - mapper.writerFor(listType(type)).writeValueAsString(entities.toList()).toRequestBody(mediaTypeXml) - - override fun deserialize(body: ResponseBody, type: Class): T? = - body.byteStream().use { mapper.readerFor(type).readValue(it) } - - override fun deserializeList(body: ResponseBody, type: Class): List? = - body.byteStream().use { mapper.readerFor(listType(type)).readValue(it) } - - private fun listType(type: Class) = - mapper.typeFactory.constructCollectionType(List::class.java, type) -} diff --git a/typedrest-serializers-jackson-xml/src/main/kotlin/net/typedrest/serializers/_doc.md b/typedrest-serializers-jackson-xml/src/main/kotlin/net/typedrest/serializers/_doc.md deleted file mode 100644 index 11cdf4a5..00000000 --- a/typedrest-serializers-jackson-xml/src/main/kotlin/net/typedrest/serializers/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest.serializers - -Serialization of entities sent to and received from the server. diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/MockEntity.kt deleted file mode 100644 index b3431954..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/MockEntity.kt +++ /dev/null @@ -1,3 +0,0 @@ -package net.typedrest - -data class MockEntity(val id: Long, val name: String) diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt deleted file mode 100644 index 5ad9afdd..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.typedrest.endpoints - -import net.typedrest.serializers.JacksonXmlSerializer -import okhttp3.mockwebserver.* -import kotlin.test.* - -abstract class AbstractEndpointTest { - protected var server: MockWebServer = MockWebServer() - protected var entryEndpoint: EntryEndpoint = EntryEndpoint(server.url("/").toUri(), serializer = JacksonXmlSerializer()) - - @AfterTest - fun after() = server.close() -} diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt deleted file mode 100644 index 98f478c8..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -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() - .setXmlListBody(emptyList()) - .addHeader("Link", "; rel=child; templated=true") - ) - - endpoint.readAll() - assertEquals(endpoint.uri.resolve("/children/1"), endpoint[MockEntity(1, "test")].uri) - } - - @Test - fun testReadAll() { - server.enqueue( - MockResponse() - .setXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) - ) - - assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), endpoint.readAll()) - } - - @Test - fun testReadAllCache() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "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) - .setXmlBody(MockEntity(5, "test")) - ) - - val element = endpoint.create(MockEntity(0, "test"))!! - server.assertRequest(HttpMethod.POST) - .withXmlBody(MockEntity(0, "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) - .withXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) - } -} diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt deleted file mode 100644 index a9727ed9..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -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() - .setXmlBody(MockEntity(5, "test")) - ) - - assertEquals(MockEntity(5, "test"), endpoint.read()) - } - - @Test - fun testReadCacheETag() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlBody(MockEntity(5, "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().setXmlBody(MockEntity(5, "testXXX"))) - - assertEquals(MockEntity(5, "testXXX"), endpoint.set(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PUT) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testSetNoResult() { - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - assertNull(endpoint.set(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PUT) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testSetETag() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlBody(MockEntity(5, "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\"") - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testUpdateRetry() { - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "test1")).addHeader("ETag", "\"1\"")) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.PreconditionFailed)) - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "test2")).addHeader("ETag", "\"2\"")) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - endpoint.update({ MockEntity(it.id, "testX") }) - - server.assertRequest(HttpMethod.GET) - server.assertRequest(HttpMethod.PUT).withXmlBody(MockEntity(5, "testX")).withHeader("If-Match", "\"1\"") - server.assertRequest(HttpMethod.GET) - server.assertRequest(HttpMethod.PUT).withXmlBody(MockEntity(5, "testX")).withHeader("If-Match", "\"2\"") - } - - @Test - fun testMergeResult() { - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "testXXX"))) - - assertEquals(MockEntity(5, "testXXX"), endpoint.merge(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PATCH) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testMergeNoResult() { - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - assertNull(endpoint.merge(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PATCH) - .withXmlBody(MockEntity(5, "test")) - } -} diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt deleted file mode 100644 index b7f45395..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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) - .withXmlBody(MockEntity(1, "input")) - } -} diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt deleted file mode 100644 index a6d25f65..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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().setXmlBody(MockEntity(2, "input"))) - - assertEquals(MockEntity(2, "input"), endpoint.invoke(MockEntity(1, "input"))) - server.assertRequest(HttpMethod.POST) - .withXmlBody(MockEntity(1, "input")) - } -} diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt deleted file mode 100644 index 866036a5..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -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().setXmlBody(MockEntity(1, "input"))) - - assertEquals(MockEntity(1, "input"), endpoint.invoke()) - server.assertRequest(HttpMethod.POST) - } -} diff --git a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt deleted file mode 100644 index 2a1fdc26..00000000 --- a/typedrest-serializers-jackson-xml/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt +++ /dev/null @@ -1,62 +0,0 @@ -package net.typedrest.tests - -import net.typedrest.MockEntity -import net.typedrest.http.HttpMethod -import net.typedrest.http.HttpStatusCode -import net.typedrest.serializers.JacksonXmlSerializer -import okhttp3.ResponseBody.Companion.toResponseBody -import okhttp3.mockwebserver.* -import okio.Buffer -import java.nio.charset.Charset -import kotlin.test.* - -const val contentTypeHeader = "Content-Type" - -private val serializer = JacksonXmlSerializer() - -fun MockResponse.setResponseCode(code: HttpStatusCode): MockResponse { - setResponseCode(code.code) - return this -} - -fun MockResponse.setXmlBody(entity: MockEntity): MockResponse { - addHeader(contentTypeHeader, "application/xml") - val buffer = Buffer() - serializer.serialize(entity, MockEntity::class.java).writeTo(buffer) - setBody(buffer.readString(Charsets.UTF_8)) - return this -} - -fun MockResponse.setXmlListBody(entities: List): MockResponse { - addHeader(contentTypeHeader, "application/xml") - val buffer = Buffer() - serializer.serializeList(entities, MockEntity::class.java).writeTo(buffer) - setBody(buffer.readString(Charsets.UTF_8)) - 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.withHeader(name: String, value: String): RecordedRequest { - assertEquals(value, getHeader(name)) - return this -} - -fun RecordedRequest.withXmlBody(entity: MockEntity): RecordedRequest { - assertEquals("application/xml; charset=utf-8", getHeader(contentTypeHeader)) - val body = body.readString(Charset.defaultCharset()).toResponseBody(null) - assertEquals(entity, serializer.deserialize(body, MockEntity::class.java)) - return this -} - -fun RecordedRequest.withXmlListBody(entities: List): RecordedRequest { - assertEquals("application/xml; charset=utf-8", getHeader(contentTypeHeader)) - val body = body.readString(Charset.defaultCharset()).toResponseBody(null) - assertEquals(entities, serializer.deserializeList(body, MockEntity::class.java)) - return this -} diff --git a/typedrest-serializers-jackson/build.gradle.kts b/typedrest-serializers-jackson/build.gradle.kts deleted file mode 100644 index 6c5d9483..00000000 --- a/typedrest-serializers-jackson/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -description = "Adds support for serializing using Jackson (https://github.com/FasterXML/jackson) instead of kotlinx.serialization." - -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/_doc.md b/typedrest-serializers-jackson/src/main/_doc.md deleted file mode 100644 index bba90e86..00000000 --- a/typedrest-serializers-jackson/src/main/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# Module typedrest-serializers-jackson - -Adds support for serializing to JSON using [Jackson](https://github.com/FasterXML/jackson) instead of [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html). - -Pass [JacksonJsonSerializer](net.typedrest.serializers.JacksonJsonSerializer) to the [EntryEndpoint](net.typedrest.endpoints.EntryEndpoint) constructor. 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 deleted file mode 100644 index eb539edd..00000000 --- a/typedrest-serializers-jackson/src/main/kotlin/net/typedrest/serializers/JacksonJsonSerializer.kt +++ /dev/null @@ -1,39 +0,0 @@ -package net.typedrest.serializers - -import com.fasterxml.jackson.annotation.JsonInclude -import tools.jackson.databind.DeserializationFeature -import tools.jackson.databind.json.JsonMapper -import tools.jackson.module.kotlin.kotlinModule -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. Defaults to omitting `null` properties when writing and tolerating unknown properties when reading. - */ -open class JacksonJsonSerializer @JvmOverloads constructor( - private val mapper: JsonMapper = defaultMapper() -) : AbstractJsonSerializer() { - companion object { - @JvmStatic - fun defaultMapper(): JsonMapper = - JsonMapper.builder() - .addModule(kotlinModule()) - .changeDefaultPropertyInclusion { it.withValueInclusion(JsonInclude.Include.NON_NULL) } - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .build() - } - - override fun serialize(entity: T, type: Class): RequestBody = - mapper.writeValueAsString(entity).toRequestBody(mediaTypeJson) - - override fun serializeList(entities: Iterable, type: Class): RequestBody = - mapper.writeValueAsString(entities).toRequestBody(mediaTypeJson) - - override fun deserialize(body: ResponseBody, type: Class): T? = - body.byteStream().use { mapper.readerFor(type).readValue(it) } - - override fun deserializeList(body: ResponseBody, type: Class): List? = - body.byteStream().use { mapper.readerForListOf(type).readValue(it) } -} diff --git a/typedrest-serializers-jackson/src/main/kotlin/net/typedrest/serializers/_doc.md b/typedrest-serializers-jackson/src/main/kotlin/net/typedrest/serializers/_doc.md deleted file mode 100644 index 11cdf4a5..00000000 --- a/typedrest-serializers-jackson/src/main/kotlin/net/typedrest/serializers/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest.serializers - -Serialization of entities sent to and received from the server. diff --git a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/MockEntity.kt deleted file mode 100644 index 20d0f09a..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/MockEntity.kt +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index a2cb676b..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index f8add891..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -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", "; 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 deleted file mode 100644 index 93b56c2c..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index c696e675..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 0a690ba5..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index e5808bea..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 4eecc783..00000000 --- a/typedrest-serializers-jackson/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt +++ /dev/null @@ -1,45 +0,0 @@ -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-jaxb/build.gradle.kts b/typedrest-serializers-jaxb/build.gradle.kts deleted file mode 100644 index ecda5277..00000000 --- a/typedrest-serializers-jaxb/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -description = "Adds support for serializing using JAXB (https://github.com/eclipse-ee4j/jaxb-ri) instead of kotlinx.serialization." - -kotlin.jvmToolchain(21) -tasks.test { useJUnitPlatform() } - -dependencies { - api(libs.okhttp3) - api(libs.jaxb.api) - runtimeOnly(libs.jaxb.runtime) - api(project(":typedrest")) - - testImplementation(kotlin("test")) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation(libs.okhttp3.mockwebserver) -} diff --git a/typedrest-serializers-jaxb/src/main/_doc.md b/typedrest-serializers-jaxb/src/main/_doc.md deleted file mode 100644 index d8ba1d3e..00000000 --- a/typedrest-serializers-jaxb/src/main/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# Module typedrest-serializers-jaxb - -Adds support for serializing to XML using [JAXB](https://github.com/eclipse-ee4j/jaxb-ri). - -Pass [JaxbSerializer](net.typedrest.serializers.JaxbSerializer) to the [EntryEndpoint](net.typedrest.endpoints.EntryEndpoint) constructor. diff --git a/typedrest-serializers-jaxb/src/main/kotlin/net/typedrest/serializers/JaxbSerializer.kt b/typedrest-serializers-jaxb/src/main/kotlin/net/typedrest/serializers/JaxbSerializer.kt deleted file mode 100644 index ebb1c406..00000000 --- a/typedrest-serializers-jaxb/src/main/kotlin/net/typedrest/serializers/JaxbSerializer.kt +++ /dev/null @@ -1,64 +0,0 @@ -package net.typedrest.serializers - -import jakarta.xml.bind.JAXBContext -import jakarta.xml.bind.annotation.XmlAccessType -import jakarta.xml.bind.annotation.XmlAccessorType -import jakarta.xml.bind.annotation.XmlAnyElement -import jakarta.xml.bind.annotation.XmlRootElement -import okhttp3.* -import okhttp3.RequestBody.Companion.toRequestBody -import java.io.StringWriter -import java.util.concurrent.ConcurrentHashMap - -/** - * Serializes and deserializes entities as XML using JAXB. - * - * Entity classes must be annotated for JAXB binding (e.g. with `@XmlRootElement`) and provide a no-arg constructor. - */ -open class JaxbSerializer : AbstractXmlSerializer() { - // JAXBContext instances are expensive to create, so one is cached per entity type. - private val contexts = ConcurrentHashMap, JAXBContext>() - - private fun contextFor(type: Class<*>): JAXBContext = - contexts.computeIfAbsent(type) { JAXBContext.newInstance(it, ListWrapper::class.java) } - - override fun serialize(entity: T, type: Class): RequestBody { - val writer = StringWriter() - contextFor(type).createMarshaller().marshal(entity, writer) - return writer.toString().toRequestBody(mediaTypeXml) - } - - override fun serializeList(entities: Iterable, type: Class): RequestBody { - val writer = StringWriter() - contextFor(type).createMarshaller().marshal(ListWrapper(ArrayList(entities.toList())), writer) - return writer.toString().toRequestBody(mediaTypeXml) - } - - override fun deserialize(body: ResponseBody, type: Class): T? = - body.byteStream().use { - @Suppress("UNCHECKED_CAST") - contextFor(type).createUnmarshaller().unmarshal(it) as T? - } - - @Suppress("UNCHECKED_CAST") - override fun deserializeList(body: ResponseBody, type: Class): List? = - body.byteStream().use { - val wrapper = contextFor(type).createUnmarshaller().unmarshal(it) as ListWrapper - wrapper.items as List - } -} - -/** - * Wraps a list of arbitrary JAXB-bound entities so JAXB can (de)serialize it as a single XML document, since it - * cannot bind a bare list on its own. Each item is (de)serialized using its own `@XmlRootElement` binding. - */ -@XmlRootElement(name = "list") -@XmlAccessorType(XmlAccessType.FIELD) -private class ListWrapper() { - @XmlAnyElement(lax = true) - var items: MutableList = mutableListOf() - - constructor(items: MutableList) : this() { - this.items = items - } -} diff --git a/typedrest-serializers-jaxb/src/main/kotlin/net/typedrest/serializers/_doc.md b/typedrest-serializers-jaxb/src/main/kotlin/net/typedrest/serializers/_doc.md deleted file mode 100644 index 11cdf4a5..00000000 --- a/typedrest-serializers-jaxb/src/main/kotlin/net/typedrest/serializers/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest.serializers - -Serialization of entities sent to and received from the server. diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/MockEntity.kt deleted file mode 100644 index f858c4b2..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/MockEntity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.typedrest - -import jakarta.xml.bind.annotation.XmlRootElement - -@XmlRootElement -data class MockEntity @JvmOverloads constructor(var id: Long = 0, var name: String = "") diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt deleted file mode 100644 index e90d7daf..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.typedrest.endpoints - -import net.typedrest.serializers.JaxbSerializer -import okhttp3.mockwebserver.* -import kotlin.test.* - -abstract class AbstractEndpointTest { - protected var server: MockWebServer = MockWebServer() - protected var entryEndpoint: EntryEndpoint = EntryEndpoint(server.url("/").toUri(), serializer = JaxbSerializer()) - - @AfterTest - fun after() = server.close() -} diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt deleted file mode 100644 index 98f478c8..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -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() - .setXmlListBody(emptyList()) - .addHeader("Link", "; rel=child; templated=true") - ) - - endpoint.readAll() - assertEquals(endpoint.uri.resolve("/children/1"), endpoint[MockEntity(1, "test")].uri) - } - - @Test - fun testReadAll() { - server.enqueue( - MockResponse() - .setXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) - ) - - assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), endpoint.readAll()) - } - - @Test - fun testReadAllCache() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "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) - .setXmlBody(MockEntity(5, "test")) - ) - - val element = endpoint.create(MockEntity(0, "test"))!! - server.assertRequest(HttpMethod.POST) - .withXmlBody(MockEntity(0, "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) - .withXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) - } -} diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt deleted file mode 100644 index a9727ed9..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -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() - .setXmlBody(MockEntity(5, "test")) - ) - - assertEquals(MockEntity(5, "test"), endpoint.read()) - } - - @Test - fun testReadCacheETag() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlBody(MockEntity(5, "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().setXmlBody(MockEntity(5, "testXXX"))) - - assertEquals(MockEntity(5, "testXXX"), endpoint.set(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PUT) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testSetNoResult() { - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - assertNull(endpoint.set(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PUT) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testSetETag() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlBody(MockEntity(5, "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\"") - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testUpdateRetry() { - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "test1")).addHeader("ETag", "\"1\"")) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.PreconditionFailed)) - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "test2")).addHeader("ETag", "\"2\"")) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - endpoint.update({ MockEntity(it.id, "testX") }) - - server.assertRequest(HttpMethod.GET) - server.assertRequest(HttpMethod.PUT).withXmlBody(MockEntity(5, "testX")).withHeader("If-Match", "\"1\"") - server.assertRequest(HttpMethod.GET) - server.assertRequest(HttpMethod.PUT).withXmlBody(MockEntity(5, "testX")).withHeader("If-Match", "\"2\"") - } - - @Test - fun testMergeResult() { - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "testXXX"))) - - assertEquals(MockEntity(5, "testXXX"), endpoint.merge(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PATCH) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testMergeNoResult() { - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - assertNull(endpoint.merge(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PATCH) - .withXmlBody(MockEntity(5, "test")) - } -} diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt deleted file mode 100644 index b7f45395..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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) - .withXmlBody(MockEntity(1, "input")) - } -} diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt deleted file mode 100644 index a6d25f65..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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().setXmlBody(MockEntity(2, "input"))) - - assertEquals(MockEntity(2, "input"), endpoint.invoke(MockEntity(1, "input"))) - server.assertRequest(HttpMethod.POST) - .withXmlBody(MockEntity(1, "input")) - } -} diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt deleted file mode 100644 index 866036a5..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -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().setXmlBody(MockEntity(1, "input"))) - - assertEquals(MockEntity(1, "input"), endpoint.invoke()) - server.assertRequest(HttpMethod.POST) - } -} diff --git a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt deleted file mode 100644 index a42bbe06..00000000 --- a/typedrest-serializers-jaxb/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt +++ /dev/null @@ -1,62 +0,0 @@ -package net.typedrest.tests - -import net.typedrest.MockEntity -import net.typedrest.http.HttpMethod -import net.typedrest.http.HttpStatusCode -import net.typedrest.serializers.JaxbSerializer -import okhttp3.ResponseBody.Companion.toResponseBody -import okhttp3.mockwebserver.* -import okio.Buffer -import java.nio.charset.Charset -import kotlin.test.* - -const val contentTypeHeader = "Content-Type" - -private val serializer = JaxbSerializer() - -fun MockResponse.setResponseCode(code: HttpStatusCode): MockResponse { - setResponseCode(code.code) - return this -} - -fun MockResponse.setXmlBody(entity: MockEntity): MockResponse { - addHeader(contentTypeHeader, "application/xml") - val buffer = Buffer() - serializer.serialize(entity, MockEntity::class.java).writeTo(buffer) - setBody(buffer.readString(Charsets.UTF_8)) - return this -} - -fun MockResponse.setXmlListBody(entities: List): MockResponse { - addHeader(contentTypeHeader, "application/xml") - val buffer = Buffer() - serializer.serializeList(entities, MockEntity::class.java).writeTo(buffer) - setBody(buffer.readString(Charsets.UTF_8)) - 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.withHeader(name: String, value: String): RecordedRequest { - assertEquals(value, getHeader(name)) - return this -} - -fun RecordedRequest.withXmlBody(entity: MockEntity): RecordedRequest { - assertEquals("application/xml; charset=utf-8", getHeader(contentTypeHeader)) - val body = body.readString(Charset.defaultCharset()).toResponseBody(null) - assertEquals(entity, serializer.deserialize(body, MockEntity::class.java)) - return this -} - -fun RecordedRequest.withXmlListBody(entities: List): RecordedRequest { - assertEquals("application/xml; charset=utf-8", getHeader(contentTypeHeader)) - val body = body.readString(Charset.defaultCharset()).toResponseBody(null) - assertEquals(entities, serializer.deserializeList(body, MockEntity::class.java)) - return this -} diff --git a/typedrest-serializers-moshi/build.gradle.kts b/typedrest-serializers-moshi/build.gradle.kts deleted file mode 100644 index 3bfebd9b..00000000 --- a/typedrest-serializers-moshi/build.gradle.kts +++ /dev/null @@ -1,15 +0,0 @@ -description = "Adds support for serializing using Moshi (https://github.com/square/moshi) instead of kotlinx.serialization." - -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/_doc.md b/typedrest-serializers-moshi/src/main/_doc.md deleted file mode 100644 index ee16e7e1..00000000 --- a/typedrest-serializers-moshi/src/main/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# Module typedrest-serializers-moshi - -Adds support for serializing to JSON using [Moshi](https://github.com/square/moshi) instead of [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html). - -Pass [MoshiJsonSerializer](net.typedrest.serializers.MoshiJsonSerializer) to the [EntryEndpoint](net.typedrest.endpoints.EntryEndpoint) constructor. 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 deleted file mode 100644 index e7e21abd..00000000 --- a/typedrest-serializers-moshi/src/main/kotlin/net/typedrest/serializers/MoshiJsonSerializer.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 serialize(entity: T, type: Class): RequestBody = - moshi.adapter(type).toJson(entity).toRequestBody(mediaTypeJson) - - override fun serializeList(entities: Iterable, type: Class): RequestBody = - moshi.adapter>(Types.newParameterizedType(List::class.java, type)).toJson(entities.toList()).toRequestBody(mediaTypeJson) - - override fun deserialize(body: ResponseBody, type: Class): T? = - moshi.adapter(type).fromJson(body.source()) - - override fun deserializeList(body: ResponseBody, type: Class): List? = - moshi.adapter>(Types.newParameterizedType(List::class.java, type)).fromJson(body.source()) -} diff --git a/typedrest-serializers-moshi/src/main/kotlin/net/typedrest/serializers/_doc.md b/typedrest-serializers-moshi/src/main/kotlin/net/typedrest/serializers/_doc.md deleted file mode 100644 index 11cdf4a5..00000000 --- a/typedrest-serializers-moshi/src/main/kotlin/net/typedrest/serializers/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest.serializers - -Serialization of entities sent to and received from the server. diff --git a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/MockEntity.kt deleted file mode 100644 index 20d0f09a..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/MockEntity.kt +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index e436ecc6..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index f8add891..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -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", "; 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 deleted file mode 100644 index 93b56c2c..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index c696e675..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 0a690ba5..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index e5808bea..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 4eecc783..00000000 --- a/typedrest-serializers-moshi/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt +++ /dev/null @@ -1,45 +0,0 @@ -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-xmlutil/build.gradle.kts b/typedrest-serializers-xmlutil/build.gradle.kts deleted file mode 100644 index 92553147..00000000 --- a/typedrest-serializers-xmlutil/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -description = "Adds support for serializing using XmlUtil (https://github.com/pdvrieze/xmlutil) instead of kotlinx.serialization's JSON format." - -kotlin.jvmToolchain(21) -tasks.test { useJUnitPlatform() } - -dependencies { - api(libs.okhttp3) - api(libs.xmlutil.serialization) - api(project(":typedrest")) - - testImplementation(kotlin("test")) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation(libs.okhttp3.mockwebserver) -} diff --git a/typedrest-serializers-xmlutil/src/main/_doc.md b/typedrest-serializers-xmlutil/src/main/_doc.md deleted file mode 100644 index 1c9bf291..00000000 --- a/typedrest-serializers-xmlutil/src/main/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# Module typedrest-serializers-xmlutil - -Adds support for serializing to XML using [XmlUtil](https://github.com/pdvrieze/xmlutil). - -Pass [XmlUtilSerializer](net.typedrest.serializers.XmlUtilSerializer) to the [EntryEndpoint](net.typedrest.endpoints.EntryEndpoint) constructor. diff --git a/typedrest-serializers-xmlutil/src/main/kotlin/net/typedrest/serializers/XmlUtilSerializer.kt b/typedrest-serializers-xmlutil/src/main/kotlin/net/typedrest/serializers/XmlUtilSerializer.kt deleted file mode 100644 index 5cbe5f3f..00000000 --- a/typedrest-serializers-xmlutil/src/main/kotlin/net/typedrest/serializers/XmlUtilSerializer.kt +++ /dev/null @@ -1,40 +0,0 @@ -package net.typedrest.serializers - -import kotlinx.serialization.* -import nl.adaptivity.xmlutil.serialization.XML -import okhttp3.* -import okhttp3.RequestBody.Companion.toRequestBody -import java.lang.reflect.ParameterizedType -import java.lang.reflect.Type - -/** - * Serializes and deserializes entities as XML using XmlUtil. - * - * @param xml The XmlUtil [XML] instance to use. Defaults to XmlUtil's recommended configuration. - */ -open class XmlUtilSerializer @JvmOverloads constructor( - private val xml: XML = XML.recommended_1_0() -) : AbstractXmlSerializer() { - override fun serialize(entity: T, type: Class): RequestBody = - xml.encodeToString(getSerializer(type), entity).toRequestBody(mediaTypeXml) - - override fun serializeList(entities: Iterable, type: Class): RequestBody = - xml.encodeToString(getListSerializer(type), entities.toList()).toRequestBody(mediaTypeXml) - - override fun deserialize(body: ResponseBody, type: Class): T? = - xml.decodeFromString(getSerializer(type), body.string()) - - override fun deserializeList(body: ResponseBody, type: Class): List? = - xml.decodeFromString(getListSerializer(type), body.string()) - - @Suppress("UNCHECKED_CAST") - private fun getSerializer(type: Type) = - xml.serializersModule.serializer(type) as KSerializer - - private fun getListSerializer(type: Type) = - getSerializer>(object : ParameterizedType { - override fun getOwnerType(): Type? = null - override fun getRawType(): Type = List::class.java - override fun getActualTypeArguments(): Array = arrayOf(type) - }) -} diff --git a/typedrest-serializers-xmlutil/src/main/kotlin/net/typedrest/serializers/_doc.md b/typedrest-serializers-xmlutil/src/main/kotlin/net/typedrest/serializers/_doc.md deleted file mode 100644 index 11cdf4a5..00000000 --- a/typedrest-serializers-xmlutil/src/main/kotlin/net/typedrest/serializers/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest.serializers - -Serialization of entities sent to and received from the server. diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/MockEntity.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/MockEntity.kt deleted file mode 100644 index ba21ca93..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/MockEntity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.typedrest - -import kotlinx.serialization.Serializable - -@Serializable -data class MockEntity(val id: Long, val name: String) diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt deleted file mode 100644 index 05590129..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.typedrest.endpoints - -import net.typedrest.serializers.XmlUtilSerializer -import okhttp3.mockwebserver.* -import kotlin.test.* - -abstract class AbstractEndpointTest { - protected var server: MockWebServer = MockWebServer() - protected var entryEndpoint: EntryEndpoint = EntryEndpoint(server.url("/").toUri(), serializer = XmlUtilSerializer()) - - @AfterTest - fun after() = server.close() -} diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt deleted file mode 100644 index 98f478c8..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -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() - .setXmlListBody(emptyList()) - .addHeader("Link", "; rel=child; templated=true") - ) - - endpoint.readAll() - assertEquals(endpoint.uri.resolve("/children/1"), endpoint[MockEntity(1, "test")].uri) - } - - @Test - fun testReadAll() { - server.enqueue( - MockResponse() - .setXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) - ) - - assertEquals(listOf(MockEntity(5, "test1"), MockEntity(6, "test2")), endpoint.readAll()) - } - - @Test - fun testReadAllCache() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "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) - .setXmlBody(MockEntity(5, "test")) - ) - - val element = endpoint.create(MockEntity(0, "test"))!! - server.assertRequest(HttpMethod.POST) - .withXmlBody(MockEntity(0, "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) - .withXmlListBody(listOf(MockEntity(5, "test1"), MockEntity(6, "test2"))) - } -} diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt deleted file mode 100644 index a9727ed9..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -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() - .setXmlBody(MockEntity(5, "test")) - ) - - assertEquals(MockEntity(5, "test"), endpoint.read()) - } - - @Test - fun testReadCacheETag() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlBody(MockEntity(5, "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().setXmlBody(MockEntity(5, "testXXX"))) - - assertEquals(MockEntity(5, "testXXX"), endpoint.set(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PUT) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testSetNoResult() { - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - assertNull(endpoint.set(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PUT) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testSetETag() { - server.enqueue( - MockResponse() - .setHeader("ETag", "\"123abc\"") - .setXmlBody(MockEntity(5, "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\"") - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testUpdateRetry() { - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "test1")).addHeader("ETag", "\"1\"")) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.PreconditionFailed)) - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "test2")).addHeader("ETag", "\"2\"")) - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - endpoint.update({ MockEntity(it.id, "testX") }) - - server.assertRequest(HttpMethod.GET) - server.assertRequest(HttpMethod.PUT).withXmlBody(MockEntity(5, "testX")).withHeader("If-Match", "\"1\"") - server.assertRequest(HttpMethod.GET) - server.assertRequest(HttpMethod.PUT).withXmlBody(MockEntity(5, "testX")).withHeader("If-Match", "\"2\"") - } - - @Test - fun testMergeResult() { - server.enqueue(MockResponse().setXmlBody(MockEntity(5, "testXXX"))) - - assertEquals(MockEntity(5, "testXXX"), endpoint.merge(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PATCH) - .withXmlBody(MockEntity(5, "test")) - } - - @Test - fun testMergeNoResult() { - server.enqueue(MockResponse().setResponseCode(HttpStatusCode.NoContent)) - - assertNull(endpoint.merge(MockEntity(5, "test"))) - server.assertRequest(HttpMethod.PATCH) - .withXmlBody(MockEntity(5, "test")) - } -} diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt deleted file mode 100644 index b7f45395..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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) - .withXmlBody(MockEntity(1, "input")) - } -} diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt deleted file mode 100644 index a6d25f65..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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().setXmlBody(MockEntity(2, "input"))) - - assertEquals(MockEntity(2, "input"), endpoint.invoke(MockEntity(1, "input"))) - server.assertRequest(HttpMethod.POST) - .withXmlBody(MockEntity(1, "input")) - } -} diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt deleted file mode 100644 index 866036a5..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -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().setXmlBody(MockEntity(1, "input"))) - - assertEquals(MockEntity(1, "input"), endpoint.invoke()) - server.assertRequest(HttpMethod.POST) - } -} diff --git a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt deleted file mode 100644 index aa54ed4d..00000000 --- a/typedrest-serializers-xmlutil/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt +++ /dev/null @@ -1,62 +0,0 @@ -package net.typedrest.tests - -import net.typedrest.MockEntity -import net.typedrest.http.HttpMethod -import net.typedrest.http.HttpStatusCode -import net.typedrest.serializers.XmlUtilSerializer -import okhttp3.ResponseBody.Companion.toResponseBody -import okhttp3.mockwebserver.* -import okio.Buffer -import java.nio.charset.Charset -import kotlin.test.* - -const val contentTypeHeader = "Content-Type" - -private val serializer = XmlUtilSerializer() - -fun MockResponse.setResponseCode(code: HttpStatusCode): MockResponse { - setResponseCode(code.code) - return this -} - -fun MockResponse.setXmlBody(entity: MockEntity): MockResponse { - addHeader(contentTypeHeader, "application/xml") - val buffer = Buffer() - serializer.serialize(entity, MockEntity::class.java).writeTo(buffer) - setBody(buffer.readString(Charsets.UTF_8)) - return this -} - -fun MockResponse.setXmlListBody(entities: List): MockResponse { - addHeader(contentTypeHeader, "application/xml") - val buffer = Buffer() - serializer.serializeList(entities, MockEntity::class.java).writeTo(buffer) - setBody(buffer.readString(Charsets.UTF_8)) - 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.withHeader(name: String, value: String): RecordedRequest { - assertEquals(value, getHeader(name)) - return this -} - -fun RecordedRequest.withXmlBody(entity: MockEntity): RecordedRequest { - assertEquals("application/xml; charset=utf-8", getHeader(contentTypeHeader)) - val body = body.readString(Charset.defaultCharset()).toResponseBody(null) - assertEquals(entity, serializer.deserialize(body, MockEntity::class.java)) - return this -} - -fun RecordedRequest.withXmlListBody(entities: List): RecordedRequest { - assertEquals("application/xml; charset=utf-8", getHeader(contentTypeHeader)) - val body = body.readString(Charset.defaultCharset()).toResponseBody(null) - assertEquals(entities, serializer.deserializeList(body, MockEntity::class.java)) - return this -} diff --git a/typedrest/build.gradle.kts b/typedrest/build.gradle.kts deleted file mode 100644 index f091bff6..00000000 --- a/typedrest/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -description = "TypedRest helps you build type-safe, fluent-style REST API clients." - -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/_doc.md b/typedrest/src/main/_doc.md deleted file mode 100644 index bf7e2a52..00000000 --- a/typedrest/src/main/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Module typedrest - -The main TypedRest library. diff --git a/typedrest/src/main/kotlin/net/typedrest/URIExtensions.kt b/typedrest/src/main/kotlin/net/typedrest/URIExtensions.kt deleted file mode 100644 index 96215100..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/URIExtensions.kt +++ /dev/null @@ -1,45 +0,0 @@ -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 { - val path = this.path ?: "" - return if (path.endsWith("/")) this - else URI(scheme, authority, "$path/", query, fragment) -} - -/** - * Resolves a relative URI using this URI as the base. - * - * @param relativeUri The relative URI to resolve. Add a `./` prefix here 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. Add a `./` prefix here 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?.takeIf { it.isNotEmpty() }?.split(':', limit = 2)?.let { - HttpCredentials(it[0], it.getOrElse(1) { "" }) - } diff --git a/typedrest/src/main/kotlin/net/typedrest/_doc.md b/typedrest/src/main/kotlin/net/typedrest/_doc.md deleted file mode 100644 index 2c38374b..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# Package net.typedrest - -Common helpers and extensions. diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/AbstractEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/AbstractEndpoint.kt deleted file mode 100644 index 94617d56..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/AbstractEndpoint.kt +++ /dev/null @@ -1,233 +0,0 @@ -package net.typedrest.endpoints - -import com.damnhandy.uri.template.UriTemplate -import net.typedrest.errors.* -import net.typedrest.http.HttpMethod -import net.typedrest.http.essence -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, - 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 The HTTP request to send. - * @return The HTTP response. - */ - protected open fun execute(request: Request): Response = - handle(httpClient.newCall(request).execute()) - - /** - * 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 { - try { - links = linkExtractor.getLinks(response) - handleCapabilities(response) - errorHandler.handle(response) - } catch (ex: Throwable) { - response.close() - throw ex - } - return response - } - - // NOTE: Always replace entire list rather than modifying it to ensure thread-safety. - private var links: List = emptyList() - - // NOTE: Only modified during initial setup of the endpoint. - private val defaultLinks: MutableMap = mutableMapOf() - private val defaultLinkTemplates: MutableMap = 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> = 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 NotFoundException("No link with rel=$rel provided by endpoint $uri.") - } - - return getLinks(rel).firstOrNull()?.first - ?: throw NotFoundException("No link with rel=$rel provided by endpoint $uri.") - } - - return foundLinks.first().first - } - - override fun linkTemplate(rel: String, variables: Map): 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(",") } - .map { it.trim() } - .mapNotNull { HttpMethod.parse(it) } - .toSet() - } - } - - // NOTE: Always replace entire set rather than modifying it to ensure thread-safety. - private var allowedMethods: Set = 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 serialize(entity: T, type: Class): 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 serializeList(entities: Iterable, type: Class): 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 deserialize(body: ResponseBody, type: Class): 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 deserializeList(body: ResponseBody, type: Class): List? = - 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 { serializer -> - serializer.supportedMediaTypes.any { it.essence == mediaType.essence } - } ?: 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 deleted file mode 100644 index bd3ffe5b..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/CachingEndpoint.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 859529bd..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/Endpoint.kt +++ /dev/null @@ -1,73 +0,0 @@ -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 - - /** - * 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> - - /** - * 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): URI -} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/EntryEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/EntryEndpoint.kt deleted file mode 100644 index c69a0405..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/EntryEndpoint.kt +++ /dev/null @@ -1,86 +0,0 @@ -package net.typedrest.endpoints - -import net.typedrest.ensureTrailingSlash -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, - errorHandler: ErrorHandler = DefaultErrorHandler(), - linkExtractor: LinkExtractor = AggregateLinkExtractor(HeaderLinkExtractor(), HalLinkExtractor()) - ) : super(uri.ensureTrailingSlash(), httpClient.withAccept(serializers.flatMap { it.supportedMediaTypes }.distinct()), 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.ensureTrailingSlash(), httpClient.withAccept(serializer.supportedMediaTypes), 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.ensureTrailingSlash(), - OkHttpClient().withAccept(serializer.supportedMediaTypes).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().get().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 deleted file mode 100644 index d42d023c..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index efaf9ce5..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/AbstractCachingEndpoint.kt +++ /dev/null @@ -1,84 +0,0 @@ -package net.typedrest.endpoints.generic - -import net.typedrest.endpoints.* -import net.typedrest.errors.* -import net.typedrest.http.* -import okhttp3.* -import okhttp3.ResponseBody.Companion.toResponseBody -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() - return httpClient.newCall(Request.Builder().get().uri(uri).headers(headers).build()).execute().use { response -> - if (response.code == HttpStatusCode.NotModified.code && cache != null && !cache.isExpired) { - cache.getBody() - } else { - handle(response) - val bodyByteString = response.body.byteString() - val contentType = response.body.contentType() - responseCache = ResponseCache.from(response, bodyByteString, contentType) - bodyByteString.toResponseBody(contentType) - } - } - } - - /** - * 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 deleted file mode 100644 index c44cbfa7..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpoint.kt +++ /dev/null @@ -1,10 +0,0 @@ -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 : GenericCollectionEndpoint> diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointExtensions.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointExtensions.kt deleted file mode 100644 index c3593b01..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointExtensions.kt +++ /dev/null @@ -1,84 +0,0 @@ -package net.typedrest.endpoints.generic - -import net.typedrest.errors.* -import net.typedrest.http.HttpStatusCode - -/** - * Determines whether the collection contains a specific element. - * - * @param id The ID identifying the element. - * @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 > IndexerEndpoint.contains(id: String): Boolean = - this[id].exists() - -/** - * Determines whether the collection contains a specific element. - * - * @param entity An existing entity to extract the ID from. - * @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 > GenericCollectionEndpoint.contains(entity: TEntity): Boolean = - this[entity].exists() - -/** - * Sets/replaces an existing element in the collection. - * - * @param entity The entity to store. Its ID is used to identify the element to replace. - * @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. 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 > GenericCollectionEndpoint.set(entity: TEntity): TEntity? = - this[entity].set(entity) - -/** - * Modifies an existing element in the collection by merging changes on the server-side. - * - * @param entity The entity data to merge with the existing one. Its ID is used to identify the element to modify. - * @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. 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 > GenericCollectionEndpoint.merge(entity: TEntity): TEntity? = - this[entity].merge(entity) - -/** - * Deletes an existing element from the collection. - * - * @param id The ID identifying the element to delete. - * @throws ConflictException when the entity has changed since it was last retrieved. 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 > IndexerEndpoint.delete(id: String) = - this[id].delete() - -/** - * Deletes an existing element from the collection. - * - * @param entity An existing entity to extract the ID from. - * @throws ConflictException when the entity has changed since it was last retrieved. 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 > GenericCollectionEndpoint.delete(entity: TEntity) = - this[entity].delete() diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointImpl.kt deleted file mode 100644 index b6eec442..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/CollectionEndpointImpl.kt +++ /dev/null @@ -1,33 +0,0 @@ -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 : GenericCollectionEndpointImpl>, CollectionEndpoint { - /** - * 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) - : 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) - : 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 deleted file mode 100644 index bbd0a87f..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpoint.kt +++ /dev/null @@ -1,118 +0,0 @@ -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 : 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 deleted file mode 100644 index 38695e38..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/ElementEndpointImpl.kt +++ /dev/null @@ -1,93 +0,0 @@ -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( - referrer: Endpoint, - relativeUri: URI, - protected val entityType: Class -) : AbstractCachingEndpoint(referrer, relativeUri), ElementEndpoint { - /** - * 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) : - 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 IllegalStateException("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: ConflictException) { - if (retryCounter++ >= maxRetries) throw ex - ex.retryAfter?.let(Thread::sleep) - } - } - } - - private fun tryReadAs(response: Response): TEntity? = response.use { - if (it.code == HttpStatusCode.NoContent.code) return@use null - - try { - deserialize(it.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 deleted file mode 100644 index 14d05d49..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpoint.kt +++ /dev/null @@ -1,132 +0,0 @@ -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> : IndexerEndpoint { - /** - * 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 - - /** - * 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 - - /** - * 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) - - /** - * 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) -} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpointImpl.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpointImpl.kt deleted file mode 100644 index 62189c3a..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/GenericCollectionEndpointImpl.kt +++ /dev/null @@ -1,127 +0,0 @@ -package net.typedrest.endpoints.generic - -import net.typedrest.endpoints.* -import net.typedrest.http.* -import okhttp3.* -import okhttp3.ResponseBody.Companion.toResponseBody -import java.net.URI - -/** - * 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>( - referrer: Endpoint, - relativeUri: URI, - private val entityType: Class, - private val elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint -) : AbstractCachingEndpoint(referrer, relativeUri), GenericCollectionEndpoint { - /** - * 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, elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint) : - this(referrer, URI(relativeUri), entityType, elementEndpointFactory) - - init { - setDefaultLinkTemplate("child", "./{id}") - } - - override operator fun get(id: String): TElementEndpoint { - return elementEndpointFactory(this, linkTemplate("child", mapOf("id" to id))) - } - - 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 = - getContent()?.let { deserializeList(it, entityType) } - ?: throw IllegalStateException("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") - .flatMap { it.split(",") } - .map { it.trim() } - .contains(rangeUnit) - } - - override var readRangeAllowed: Boolean? = null - - override fun readRange(from: Long?, to: Long?): PartialResponse = - execute(Request.Builder().get().uri(uri).header("Range", "${rangeUnit}=${from ?: ""}-${to ?: ""}").build()) - .use { response -> - PartialResponse( - deserializeList(response.body, entityType) ?: throw IllegalStateException("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? = - execute(Request.Builder().post(serialize(entity, entityType)).uri(uri).build()).use { response -> - val bodyByteString = response.body.byteString() - val contentType = response.body.contentType() - val responseCache = ResponseCache.from(response, bodyByteString, contentType) - - 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 - bodyByteString.takeIf { it.size > 0 } - ?.toResponseBody(contentType) - ?.let { deserialize(it, entityType) } - ?.let(::tryGetId) - ?.let(::get) - } - - if (elementEndpoint is CachingEndpoint) { - elementEndpoint.responseCache = responseCache - } - - 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) = - execute(Request.Builder().patch(serializeList(entities, entityType)).uri(uri).build()).close() - - override val setAllAllowed: Boolean? - get() = isMethodAllowed(HttpMethod.PUT) - - override fun setAll(entities: Iterable) { - putContent(serializeList(entities, entityType)).close() - } -} diff --git a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpoint.kt b/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpoint.kt deleted file mode 100644 index f03e3012..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpoint.kt +++ /dev/null @@ -1,17 +0,0 @@ -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 : 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 deleted file mode 100644 index 8ad8a653..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/IndexerEndpointImpl.kt +++ /dev/null @@ -1,37 +0,0 @@ -package net.typedrest.endpoints.generic - -import net.typedrest.endpoints.Endpoint -import net.typedrest.endpoints.AbstractEndpoint -import java.net.URI - -/** - * 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( - referrer: Endpoint, - relativeUri: URI, - private val elementEndpointFactory: (referrer: Endpoint, relativeUri: URI) -> TElementEndpoint -) : AbstractEndpoint(referrer, relativeUri), IndexerEndpoint { - /** - * 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 id))) - 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 deleted file mode 100644 index 141d75ac..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/generic/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 34fa8b90..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpoint.kt +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 2614d292..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/BlobEndpointImpl.kt +++ /dev/null @@ -1,48 +0,0 @@ -package net.typedrest.endpoints.raw - -import net.typedrest.endpoints.* -import net.typedrest.http.* -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.Request -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 = - execute(Request.Builder().get().uri(uri).build()).body.byteStream() - - override val isUploadAllowed: Boolean? - get() = isMethodAllowed(HttpMethod.PUT) - - override fun uploadFrom(stream: InputStream, mimeType: String?) { - val body = InputStreamRequestBody(mimeType?.toMediaTypeOrNull(), stream) - 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 deleted file mode 100644 index 53ea1355..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpoint.kt +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 2cd15fa2..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/UploadEndpointImpl.kt +++ /dev/null @@ -1,53 +0,0 @@ -package net.typedrest.endpoints.raw - -import net.typedrest.endpoints.* -import net.typedrest.http.InputStreamRequestBody -import net.typedrest.http.uri -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okio.BufferedSink -import java.io.InputStream -import java.net.URI - -/** - * Implementation of an [UploadEndpoint] that accepts binary uploads using multi-part form encoding or raw bodies. - * - * @param referrer The endpoint used to navigate to this one. - * @param relativeUri The URI of this endpoint relative to the [referrer]'s. - * @param 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( - referrer: Endpoint, - 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. - * @param 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: RequestBody = InputStreamRequestBody(mimeType?.toMediaTypeOrNull(), stream) - - if (formField != null) { - val multipart = MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart(formField, fileName, body) - .build() - - body = object : RequestBody() { - override fun contentType() = multipart.contentType() - override fun contentLength() = multipart.contentLength() - override fun isOneShot() = true - override fun writeTo(sink: BufferedSink) = multipart.writeTo(sink) - } - } - - 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 deleted file mode 100644 index 1d499288..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/raw/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 9fb0949a..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/AbstractRpcEndpoint.kt +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 6fa9b7d2..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpoint.kt +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index cc6bb7a0..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ActionEndpointImpl.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 239ff2d2..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpoint.kt +++ /dev/null @@ -1,22 +0,0 @@ -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 : 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 deleted file mode 100644 index 1acc7a72..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointImpl.kt +++ /dev/null @@ -1,33 +0,0 @@ -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( - referrer: Endpoint, - relativeUri: URI, - private val entityType: Class -) : AbstractRpcEndpoint(referrer, relativeUri), ConsumerEndpoint { - /** - * 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) : - 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 deleted file mode 100644 index 4c46cd69..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpoint.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 : 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 deleted file mode 100644 index 6b5206a1..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointImpl.kt +++ /dev/null @@ -1,41 +0,0 @@ -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 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( - referrer: Endpoint, - relativeUri: URI, - private val entityType: Class, - private val resultType: Class -) : AbstractRpcEndpoint(referrer, relativeUri), FunctionEndpoint { - /** - * 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, resultType: Class) : - this(referrer, URI(relativeUri), entityType, resultType) - - override fun invoke(entity: TEntity): TResult = - execute(Request.Builder().post(serialize(entity, entityType)).uri(uri).build()).use { response -> - deserialize(response.body, resultType) - ?: throw IllegalStateException("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 deleted file mode 100644 index 7b55fd78..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpoint.kt +++ /dev/null @@ -1,22 +0,0 @@ -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 : 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 deleted file mode 100644 index 01d47e25..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointImpl.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 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( - referrer: Endpoint, - relativeUri: URI, - private val resultType: Class -) : AbstractRpcEndpoint(referrer, relativeUri), ProducerEndpoint { - /** - * 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) : - this(referrer, URI(relativeUri), resultType) - - override fun invoke(): TResult = - execute(Request.Builder().post("".toRequestBody()).uri(uri).build()).use { response -> - deserialize(response.body, resultType) - ?: throw IllegalStateException("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 deleted file mode 100644 index a0747e02..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/RpcEndpoint.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 9672108b..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/endpoints/rpc/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 52877aa2..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/errors/DefaultErrorHandler.kt +++ /dev/null @@ -1,58 +0,0 @@ -package net.typedrest.errors - -import kotlinx.serialization.* -import kotlinx.serialization.json.* -import net.typedrest.http.HttpStatusCode -import net.typedrest.http.isJson -import okhttp3.Response - -/** - * Handles errors in HTTP responses by mapping status codes to common exception types. - */ -open class DefaultErrorHandler : ErrorHandler { - @Throws(HttpException::class) - override fun handle(response: Response) { - if (response.isSuccessful) return - - val message = extractJsonMessage(response) - ?: "${response.request.url} responded with ${response.code} ${response.message}" - - throw mapException(HttpStatusCode.parse(response.code) ?: HttpStatusCode.InternalServerError, message, response) - } - - /** - * Tries to extract an error message from the response body. - * - * @param response The HTTP response. - */ - protected open fun extractJsonMessage(response: Response): String? { - if (response.body.contentType()?.isJson != true) return null - - return try { - val root = Json.parseToJsonElement(response.peekBody(Long.MAX_VALUE).string()) as? JsonObject ?: return null - (root["message"] ?: root["details"])?.jsonPrimitive?.contentOrNull - } catch (_: SerializationException) { - null - } catch (_: IllegalArgumentException) { - null - } - } - - /** - * Maps the HTTP status code to an exception. - * - * @param status The status code. - * @param message An error message to include in the exception. - * @param response The HTTP response to include in the exception. - */ - protected open 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 deleted file mode 100644 index 9b205fb3..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/errors/ErrorHandler.kt +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index ffc0597f..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/errors/HttpException.kt +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 8b343ca5..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/errors/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 4f14f0a7..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/HttpContentRangeHeader.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index a7ea9506..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/HttpCredentials.kt +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 328a16cd..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/HttpMethod.kt +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index bca0667d..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/HttpStatusCode.kt +++ /dev/null @@ -1,80 +0,0 @@ -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/InputStreamRequestBody.kt b/typedrest/src/main/kotlin/net/typedrest/http/InputStreamRequestBody.kt deleted file mode 100644 index 29f59f4c..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/InputStreamRequestBody.kt +++ /dev/null @@ -1,27 +0,0 @@ -package net.typedrest.http - -import okhttp3.MediaType -import okhttp3.RequestBody -import okio.BufferedSink -import okio.source -import java.io.InputStream - -/** - * A [RequestBody] that streams its content from an [InputStream] without buffering the entire payload in memory. - * - * @param contentType The MIME type of the body, or `null` if unknown. - * @param stream The input stream to read the body from. Will be consumed when the request is sent. - */ -internal class InputStreamRequestBody( - private val contentType: MediaType?, - private val stream: InputStream -) : RequestBody() { - override fun contentType(): MediaType? = contentType - - // The stream can only be read once, so disable retries. - override fun isOneShot(): Boolean = true - - override fun writeTo(sink: BufferedSink) { - stream.source().use { sink.writeAll(it) } - } -} diff --git a/typedrest/src/main/kotlin/net/typedrest/http/OkHttpExtensions.kt b/typedrest/src/main/kotlin/net/typedrest/http/OkHttpExtensions.kt deleted file mode 100644 index 1e9ff0d9..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/OkHttpExtensions.kt +++ /dev/null @@ -1,67 +0,0 @@ -package net.typedrest.http - -import okhttp3.* -import java.net.URI - -/** - * Returns a new client that sends an `Accept` header listing [mediaTypes] on every request that does not set one itself. - * - * @param mediaTypes The media types to advertise as acceptable response formats. - */ -fun OkHttpClient.withAccept(mediaTypes: List): OkHttpClient = - if (mediaTypes.isEmpty()) { - this - } else { - val mediaTypeHeader = mediaTypes.joinToString(separator = ", ") - this.newBuilder() - .addInterceptor { chain -> - val request = chain.request() - chain.proceed( - if (request.header("Accept") == null) - request.newBuilder().header("Accept", mediaTypeHeader).build() - else request - ) - }.build() - } - -/** - * Returns a new client that sends an `Authorization` header with [credentials] on every request. - * - * @param credentials The credentials to include, or null to leave the client unmodified. - */ -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() - } - -/** - * Sets the request URL from a [URI]. - * - * @param uri The URI to use as the request URL. - */ -fun Request.Builder.uri(uri: URI): Request.Builder = this.url(uri.toURL()) - -/** - * Sets the request method to `OPTIONS` with no body. - */ -fun Request.Builder.options(): Request.Builder = this.method("OPTIONS", null) - -/** - * The media type without parameters, i.e., `type/subtype`. - */ -val MediaType.essence: String get() = "$type/$subtype" - -/** - * Whether this media type represents JSON, including structured-syntax suffixes such as `application/problem+json`. - */ -val MediaType.isJson: Boolean - get() = essence == "application/json" || subtype.endsWith("+json") diff --git a/typedrest/src/main/kotlin/net/typedrest/http/PartialResponse.kt b/typedrest/src/main/kotlin/net/typedrest/http/PartialResponse.kt deleted file mode 100644 index 7c982392..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/PartialResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -package net.typedrest.http - -/** - * Represents a subset of a set of elements. - * - * @param elements The returned elements. - * @param range The range the [elements] come from. - * @param TEntity The type of element the response contains. - */ -class PartialResponse(val elements: List, 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 deleted file mode 100644 index b440e862..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/ResponseCache.kt +++ /dev/null @@ -1,110 +0,0 @@ -package net.typedrest.http - -import okhttp3.* -import okhttp3.ResponseBody.Companion.toResponseBody -import okio.ByteString -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.util.* - -/** - * Captures the content of a [Response] for caching. - */ -class ResponseCache private constructor( - response: Response, - private val bodyByteString: ByteString, - private val contentType: MediaType? -) { - companion object { - /** - * Creates a [ResponseCache] from [response] if it is eligible for caching. - * - * The response body is consumed (buffered) by this call. If you also need the body for other purposes, buffer it first and use the [from] overload that accepts a pre-buffered [ByteString]. - * - * @param response The HTTP response whose body will be consumed and cached. - * @return The [ResponseCache]; `null` if the response is not eligible for caching. - */ - @JvmStatic - fun from(response: Response): ResponseCache? = - if (isEligible(response)) { - ResponseCache(response, response.body.byteString(), response.body.contentType()) - } else null - - /** - * Creates a [ResponseCache] from [response] using a pre-buffered body if the response is eligible for caching. - * - * Use this overload when the body has already been read and must remain available for other consumers after this call returns. - * - * @param response The HTTP response whose headers and status code determine eligibility. - * @param bodyByteString The already-buffered response body bytes. - * @param contentType The media type of the body, used when reconstructing the [ResponseBody]. - * @return The [ResponseCache]; `null` if the response is not eligible for caching. - */ - @JvmStatic - fun from(response: Response, bodyByteString: ByteString, contentType: MediaType?): ResponseCache? = - if (isEligible(response)) { - ResponseCache(response, bodyByteString, contentType) - } else null - - private fun isEligible(response: Response): Boolean = - response.isSuccessful && response.code != HttpStatusCode.NoContent.code && !response.cacheControl.noStore - - private val dateFormat: DateTimeFormatter = - DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) - - private fun Date.formatHttp(): String = - ZonedDateTime.ofInstant(toInstant(), ZoneOffset.UTC).format(dateFormat) - } - - /** - * Returns a new [ResponseBody] backed by the cached bytes. - * - * Each call produces an independent copy so the body can be consumed multiple times. - */ - fun getBody() = bodyByteString.toResponseBody(contentType) - - 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.toLong() * 1000L) - } - ?: 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") - - /** - * Builds conditional request headers asserting that the resource *has* been modified since it was cached - * - * Suitable for GET/HEAD requests that should bypass the cache. - */ - fun ifModifiedHeaders(): Headers { - val builder = Headers.Builder() - eTag?.let { builder.add("If-None-Match", it) } - ?: lastModified?.let { builder.add("If-Modified-Since", it.formatHttp()) } - return builder.build() - } - - /** - * Builds conditional request headers asserting that the resource has *not* been modified since it was cached. - * - * Suitable for PUT/DELETE requests to prevent lost updates. - */ - fun ifUnmodifiedHeaders(): Headers { - val builder = Headers.Builder() - eTag?.let { builder.add("If-Match", it) } - ?: lastModified?.let { builder.add("If-Unmodified-Since", it.formatHttp()) } - 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 deleted file mode 100644 index 1685fc5c..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/http/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 460f346a..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/links/AggregateLinkExtractor.kt +++ /dev/null @@ -1,11 +0,0 @@ -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 = - 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 deleted file mode 100644 index 27af582d..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/links/HalLinkExtractor.kt +++ /dev/null @@ -1,51 +0,0 @@ -package net.typedrest.links - -import kotlinx.serialization.* -import kotlinx.serialization.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 { - val mediaType = response.body.contentType() ?: return emptyList() - if ("${mediaType.type}/${mediaType.subtype}" != "application/hal+json") return emptyList() - return parseJsonBody(response.peekBody(Long.MAX_VALUE).string()) - } - - private fun parseJsonBody(body: String): List = - try { - Json.decodeFromString(body) - .links.flatMap { (rel, element) -> toLinks(rel, element) } - } catch (_: SerializationException) { - emptyList() - } - - private fun toLinks(rel: String, element: JsonElement): List = - when (element) { - is JsonObject -> listOfNotNull(toLink(rel, element)) - is JsonArray -> element.mapNotNull { (it as? JsonObject)?.let { obj -> toLink(rel, obj) } } - else -> emptyList() - } - - private fun toLink(rel: String, obj: JsonObject): Link? = - try { - val halLink = Json.decodeFromJsonElement(obj) - Link(rel, halLink.href, halLink.title, halLink.templated) - } catch (_: SerializationException) { - null - } - - @Serializable - private class HalLinksContainer( - @SerialName("_links") val links: Map - ) - - @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 deleted file mode 100644 index e66b3dbd..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/links/HeaderLinkExtractor.kt +++ /dev/null @@ -1,47 +0,0 @@ -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 = - 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].unquote() - "title" -> title = paramSplit[1].unquote() - "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 deleted file mode 100644 index a0a01c8d..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/links/Link.kt +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 5b8e0cbd..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/links/LinkExtractor.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.typedrest.links - -import okhttp3.Response - -/** - * Extracts links from responses. - */ -interface LinkExtractor { - /** - * Extracts links from the `response`. - */ - fun getLinks(response: Response): List -} diff --git a/typedrest/src/main/kotlin/net/typedrest/links/_doc.md b/typedrest/src/main/kotlin/net/typedrest/links/_doc.md deleted file mode 100644 index 930ae50f..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/links/_doc.md +++ /dev/null @@ -1,5 +0,0 @@ -# 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 deleted file mode 100644 index 26810839..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/serializers/AbstractJsonSerializer.kt +++ /dev/null @@ -1,18 +0,0 @@ -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() - - @JvmStatic - protected val mediaTypeTextJson = "text/json".toMediaType() - } - - override val supportedMediaTypes = listOf(mediaTypeJson, mediaTypeTextJson) -} diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/AbstractXmlSerializer.kt b/typedrest/src/main/kotlin/net/typedrest/serializers/AbstractXmlSerializer.kt deleted file mode 100644 index 6da4f51b..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/serializers/AbstractXmlSerializer.kt +++ /dev/null @@ -1,18 +0,0 @@ -package net.typedrest.serializers - -import okhttp3.MediaType.Companion.toMediaType - -/** - * Common base class for XML serializers. - */ -abstract class AbstractXmlSerializer : Serializer { - companion object { - @JvmStatic - protected val mediaTypeXml = "application/xml".toMediaType() - - @JvmStatic - protected val mediaTypeTextXml = "text/xml".toMediaType() - } - - override val supportedMediaTypes = listOf(mediaTypeXml, mediaTypeTextXml) -} diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/KotlinxJsonSerializer.kt b/typedrest/src/main/kotlin/net/typedrest/serializers/KotlinxJsonSerializer.kt deleted file mode 100644 index 0489fc10..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/serializers/KotlinxJsonSerializer.kt +++ /dev/null @@ -1,49 +0,0 @@ -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. - * - * @param json The Kotlinx [Json] instance to use. Defaults to omitting `null` properties when writing and tolerating unknown properties when reading. - */ -open class KotlinxJsonSerializer @JvmOverloads constructor( - private val json: Json = defaultJson -) : AbstractJsonSerializer() { - companion object { - @JvmStatic - val defaultJson: Json = Json { - explicitNulls = false - ignoreUnknownKeys = true - isLenient = true - } - } - - override fun serialize(entity: T, type: Class): RequestBody = - json.encodeToString(getSerializer(type), entity).toRequestBody(mediaTypeJson) - - override fun serializeList(entities: Iterable, type: Class): RequestBody = - json.encodeToString(getListSerializer(type), entities.toList()).toRequestBody(mediaTypeJson) - - override fun deserialize(body: ResponseBody, type: Class): T? = - json.decodeFromString(getSerializer(type), body.string()) - - override fun deserializeList(body: ResponseBody, type: Class): List? = - json.decodeFromString(getListSerializer(type), body.string()) - - @Suppress("UNCHECKED_CAST") - private fun getSerializer(type: Type) = - json.serializersModule.serializer(type) as KSerializer - - private fun getListSerializer(type: Type) = - getSerializer>(object : ParameterizedType { - override fun getOwnerType(): Type? = null - override fun getRawType(): Type = List::class.java - override fun getActualTypeArguments(): Array = arrayOf(type) - }) -} diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/Serializer.kt b/typedrest/src/main/kotlin/net/typedrest/serializers/Serializer.kt deleted file mode 100644 index 5bad2eda..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/serializers/Serializer.kt +++ /dev/null @@ -1,53 +0,0 @@ -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 - - /** - * 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 serialize(entity: T, type: Class): 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 serializeList(entities: Iterable, type: Class): 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 deserialize(body: ResponseBody, type: Class): 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 deserializeList(body: ResponseBody, type: Class): List? -} diff --git a/typedrest/src/main/kotlin/net/typedrest/serializers/_doc.md b/typedrest/src/main/kotlin/net/typedrest/serializers/_doc.md deleted file mode 100644 index 11cdf4a5..00000000 --- a/typedrest/src/main/kotlin/net/typedrest/serializers/_doc.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100644 index 6437b4c7..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/MockEntity.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 112f1470..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/AbstractEndpointTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 0fc0c406..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/CollectionEndpointTest.kt +++ /dev/null @@ -1,236 +0,0 @@ -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.rawPath) - } - - @Test - fun testGetByIdWithLinkHeaderRelative() { - server.enqueue( - MockResponse() - .setJsonBody("[]") - .addHeader("Link", "; 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", "; rel=child; templated=true") - ) - - endpoint.readAll() - assertEquals("http://localhost/children/1", endpoint["1"].uri.toString()) - } - - @Test - fun testGetByEntityWithLinkHeaderRelative() { - server.enqueue( - MockResponse() - .setJsonBody("[]") - .addHeader("Link", "; 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", "; 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 deleted file mode 100644 index d0610728..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/ElementEndpointTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -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 { - 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 deleted file mode 100644 index 1208de02..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/generic/IndexerEndpointTest.kt +++ /dev/null @@ -1,15 +0,0 @@ -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>(entryEndpoint, "endpoint") { ref, uri -> ElementEndpointImpl(ref, uri, MockEntity::class.java) } - - @Test - fun testGetById() { - assertEquals("/endpoint/x%2Fy", endpoint["x/y"].uri.rawPath) - } -} diff --git a/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/BlobEndpointTest.kt b/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/BlobEndpointTest.kt deleted file mode 100644 index 83bfe1d0..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/BlobEndpointTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index 0d1563fe..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/raw/UploadEndpointTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 6b42bbc1..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ActionEndpointTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index dc5e9df4..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ConsumerEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index d7d4e71b..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/FunctionEndpointTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 8b2a7958..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/endpoints/rpc/ProducerEndpointTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -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/links/HalLinkExtractorTest.kt b/typedrest/src/test/kotlin/net/typedrest/links/HalLinkExtractorTest.kt deleted file mode 100644 index 192e6d08..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/links/HalLinkExtractorTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package net.typedrest.links - -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.ResponseBody.Companion.toResponseBody -import kotlin.test.Test -import kotlin.test.assertEquals - -class HalLinkExtractorTest { - private val extractor = HalLinkExtractor() - - private fun response(body: String): Response = - Response.Builder() - .request(Request.Builder().url("http://localhost/").build()) - .protocol(Protocol.HTTP_1_1) - .code(200) - .message("OK") - .body(body.toResponseBody("application/hal+json".toMediaType())) - .build() - - private fun List.tuples() = - map { listOf(it.rel, it.href, it.title, it.templated) } - - @Test - fun testSingleLink() { - val body = """{"_links":{"single":{"href":"a"}}}""" - assertEquals( - listOf(listOf("single", "a", null, false)), - extractor.getLinks(response(body)).tuples() - ) - } - - @Test - fun testArrayOfLinks() { - val body = """{"_links":{"collection":[{"href":"b","title":"Title 1"},{"href":"c"}]}}""" - assertEquals( - listOf( - listOf("collection", "b", "Title 1", false), - listOf("collection", "c", null, false) - ), - extractor.getLinks(response(body)).tuples() - ) - } - - @Test - fun testTemplatedLink() { - val body = """{"_links":{"template":{"href":"{id}","templated":true}}}""" - assertEquals( - listOf(listOf("template", "{id}", null, true)), - extractor.getLinks(response(body)).tuples() - ) - } - - @Test - fun testMixed() { - val body = """{"_links":{"single":{"href":"a"},"collection":[{"href":"b","title":"Title 1"},{"href":"c"}],"template":{"href":"{id}","templated":true}}}""" - assertEquals( - listOf( - listOf("single", "a", null, false), - listOf("collection", "b", "Title 1", false), - listOf("collection", "c", null, false), - listOf("template", "{id}", null, true) - ), - extractor.getLinks(response(body)).tuples() - ) - } -} diff --git a/typedrest/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt b/typedrest/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt deleted file mode 100644 index 4eecc783..00000000 --- a/typedrest/src/test/kotlin/net/typedrest/tests/MockWebServerExtensions.kt +++ /dev/null @@ -1,45 +0,0 @@ -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/vaadin/pom.xml b/vaadin/pom.xml new file mode 100644 index 00000000..a147d9ae --- /dev/null +++ b/vaadin/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + com.oneandone + typedrest-parent + 0.31-SNAPSHOT + ../parent + + typedrest-vaadin + + TypedRest Vaadin + Build Vaadin GUIs using a TypedRest client + + + 7.7.0 + + + + + com.oneandone + typedrest-core + ${typedrest.version} + + + + + com.vaadin + vaadin-server + ${vaadin.version} + + + com.vaadin + vaadin-client-compiled + ${vaadin.version} + + + com.vaadin + vaadin-themes + ${vaadin.version} + + + com.vaadin + vaadin-push + ${vaadin.version} + + + + + org.vaadin.addon + confirmdialog + 2.1.3 + + + + + com.google.guava + guava + 19.0 + + + + + + vaadin-addons + http://maven.vaadin.com/vaadin-addons + + + diff --git a/vaadin/src/main/java/net/typedrest/vaadin/GridUtils.java b/vaadin/src/main/java/net/typedrest/vaadin/GridUtils.java new file mode 100644 index 00000000..ace636cb --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/GridUtils.java @@ -0,0 +1,44 @@ +package net.typedrest.vaadin; + +import com.vaadin.data.Container; +import com.vaadin.data.util.filter.SimpleStringFilter; +import com.vaadin.server.Sizeable; +import com.vaadin.ui.Grid; +import com.vaadin.ui.TextField; +import com.vaadin.ui.themes.ValoTheme; + +/** + * Utility methods for Vaadin {@link Grid}s. + */ +public final class GridUtils { + + private GridUtils() { + } + + /** + * Adds a row of filter text boxes to a Vaadin {@link Grid}. + * + * @param grid The grid to add the filter row to. + */ + public static void addFilterRow(Grid grid) { + if (grid.getHeaderRowCount() < 2) { + grid.appendHeaderRow(); + } + Grid.HeaderRow headerRow = grid.getHeaderRow(1); + + Container.Indexed container = grid.getContainerDataSource(); + container.getContainerPropertyIds().forEach(pid -> { + TextField filterField = new TextField(); + filterField.setInputPrompt("Filter"); + filterField.addStyleName(ValoTheme.TEXTFIELD_SMALL); + filterField.setWidth(100, Sizeable.Unit.PERCENTAGE); + filterField.addTextChangeListener(event -> { + ((Container.SimpleFilterable) container).removeContainerFilters(pid); + if (!event.getText().isEmpty()) { + ((Container.Filterable) container).addContainerFilter(new SimpleStringFilter(pid, event.getText(), true, false)); + } + }); + headerRow.getCell(pid).setComponent(filterField); + }); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/NotificationErrorHandler.java b/vaadin/src/main/java/net/typedrest/vaadin/NotificationErrorHandler.java new file mode 100644 index 00000000..c1d5b0e2 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/NotificationErrorHandler.java @@ -0,0 +1,20 @@ +package net.typedrest.vaadin; + +import static net.typedrest.ThrowableUtils.getFullMessage; +import com.vaadin.server.ErrorEvent; +import com.vaadin.server.ErrorHandler; +import com.vaadin.ui.Notification; +import java.util.logging.*; + +/** + * Reports errors using {@link Notification} and {@link Logger}. + */ +public class NotificationErrorHandler implements ErrorHandler { + + @Override + public void error(ErrorEvent event) { + Throwable ex = event.getThrowable(); + Logger.getLogger(NotificationErrorHandler.class.getName()).log(Level.SEVERE, null, ex); + Notification.show("System error", getFullMessage(ex), Notification.Type.ERROR_MESSAGE); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/events/BlobUploadEvent.java b/vaadin/src/main/java/net/typedrest/vaadin/events/BlobUploadEvent.java new file mode 100644 index 00000000..262bd4ec --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/events/BlobUploadEvent.java @@ -0,0 +1,18 @@ +package net.typedrest.vaadin.events; + +import net.typedrest.BlobEndpoint; + +/** + * Indicates that an upload method on {@link BlobEndpoint} was called. + */ +public class BlobUploadEvent extends EndpointEvent { + + /** + * Creates a new blob upload event. + * + * @param endpoint The endpoint that data was uploaded to. + */ + public BlobUploadEvent(BlobEndpoint endpoint) { + super(endpoint); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/events/ElementCreatedEvent.java b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementCreatedEvent.java new file mode 100644 index 00000000..fbc4efc7 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementCreatedEvent.java @@ -0,0 +1,22 @@ +package net.typedrest.vaadin.events; + +import net.typedrest.CollectionEndpoint; +import net.typedrest.ElementEndpoint; + +/** + * Indicates that a new element was created. + * + * @param The type of entity that was created. + * @see CollectionEndpoint#create(java.lang.Object) + */ +public class ElementCreatedEvent extends ElementEvent { + + /** + * Creates a new element created event. + * + * @param endpoint The endpoint representing the newly created entity. + */ + public ElementCreatedEvent(ElementEndpoint endpoint) { + super(endpoint); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/events/ElementDeletedEvent.java b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementDeletedEvent.java new file mode 100644 index 00000000..aa589c7f --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementDeletedEvent.java @@ -0,0 +1,21 @@ +package net.typedrest.vaadin.events; + +import net.typedrest.ElementEndpoint; + +/** + * Indicates that an element was deleted. + * + * @param The type of entity that was deleted. + * @see ElementEndpoint#delete() + */ +public class ElementDeletedEvent extends ElementEvent { + + /** + * Creates a new element delete event. + * + * @param endpoint The endpoint representing the deleted entity. + */ + public ElementDeletedEvent(ElementEndpoint endpoint) { + super(endpoint); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/events/ElementEvent.java b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementEvent.java new file mode 100644 index 00000000..673aed96 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementEvent.java @@ -0,0 +1,22 @@ +package net.typedrest.vaadin.events; + +import com.google.common.eventbus.EventBus; +import net.typedrest.ElementEndpoint; + +/** + * An event for the {@link EventBus} that references an {@link ElementEndpoint}. + * + * @param The type of entity the endpoint represents. + */ +public abstract class ElementEvent + extends EndpointEvent> { + + /** + * Creates a new element event. + * + * @param endpoint The endpoint that raised the event. + */ + protected ElementEvent(ElementEndpoint endpoint) { + super(endpoint); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/events/ElementUpdatedEvent.java b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementUpdatedEvent.java new file mode 100644 index 00000000..c8b449b1 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/events/ElementUpdatedEvent.java @@ -0,0 +1,21 @@ +package net.typedrest.vaadin.events; + +import net.typedrest.ElementEndpoint; + +/** + * Indicates that an existing element was updated. + * + * @param The type of entity that was updated. + * @see ElementEndpoint#update(java.lang.Object) + */ +public class ElementUpdatedEvent extends ElementEvent { + + /** + * Creates a new element update event. + * + * @param endpoint The endpoint representing the updated entity. + */ + public ElementUpdatedEvent(ElementEndpoint endpoint) { + super(endpoint); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/events/EndpointEvent.java b/vaadin/src/main/java/net/typedrest/vaadin/events/EndpointEvent.java new file mode 100644 index 00000000..7660965b --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/events/EndpointEvent.java @@ -0,0 +1,28 @@ +package net.typedrest.vaadin.events; + +import com.google.common.eventbus.EventBus; +import net.typedrest.Endpoint; +import lombok.Getter; + +/** + * An event for the {@link EventBus} that references an {@link Endpoint}. + * + * @param The type of endpoint that raised the event. + */ +public abstract class EndpointEvent { + + /** + * The endpoint that raised the event. + */ + @Getter + private final TEndpoint endpoint; + + /** + * Creates a new endpoint event. + * + * @param endpoint The endpoint that raised the event. + */ + protected EndpointEvent(TEndpoint endpoint) { + this.endpoint = endpoint; + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/events/TriggerEvent.java b/vaadin/src/main/java/net/typedrest/vaadin/events/TriggerEvent.java new file mode 100644 index 00000000..17846907 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/events/TriggerEvent.java @@ -0,0 +1,18 @@ +package net.typedrest.vaadin.events; + +import net.typedrest.ActionEndpoint; + +/** + * Indicates that {@link ActionEndpoint#trigger()} was called. + */ +public class TriggerEvent extends EndpointEvent { + + /** + * Creates a new trigger event. + * + * @param endpoint The endpoint that was triggered. + */ + public TriggerEvent(ActionEndpoint endpoint) { + super(endpoint); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/AbstractEntityForm.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/AbstractEntityForm.java new file mode 100644 index 00000000..dfe648a5 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/AbstractEntityForm.java @@ -0,0 +1,90 @@ +package net.typedrest.vaadin.forms; + +import static net.typedrest.BeanUtils.*; +import net.typedrest.*; +import com.vaadin.data.Validator; +import com.vaadin.data.fieldgroup.*; +import com.vaadin.data.util.*; +import com.vaadin.data.validator.*; +import com.vaadin.ui.*; +import java.lang.annotation.Annotation; + +/** + * Base class for building entity form implementations. + * + * @param The type of entity the form operates on. + */ +public abstract class AbstractEntityForm + extends CustomComponent implements EntityForm { + + protected final Class entityType; + protected final BeanFieldGroup fieldGroup; + + /** + * Creates a new entity form. + * + * @param entityType The type of entity the form operates on. + */ + protected AbstractEntityForm(Class entityType) { + this.entityType = entityType; + + fieldGroup = new BeanFieldGroup<>(entityType); + fieldGroup.setFieldFactory(new FieldGroupFieldFactory() { + @Override + public T createField(Class dataType, Class fieldType) { + return (fieldType.equals(Field.class)) + ? (T) buildField(dataType) + : DefaultFieldGroupFieldFactory.get().createField(dataType, fieldType); + } + }); + } + + /** + * Builds a field based on the data type to be edited. + * + * @param dataType The type to be edited using the field. + * @return A field that is capable of editing the given type of data. + */ + protected Field buildField(Class dataType) { + return DefaultFieldGroupFieldFactory.get().createField(dataType, Field.class); + } + + @Override + public TEntity getEntity() + throws Validator.InvalidValueException { + try { + fieldGroup.commit(); + } catch (FieldGroup.CommitException ex) { + throw new Validator.InvalidValueException("Invalid input!"); + } + return fieldGroup.getItemDataSource().getBean(); + } + + @Override + public void setEntity(TEntity entity) { + // Temporarily remove read-only flag to allow entity replacement + boolean wasReadOnly = fieldGroup.isReadOnly(); + fieldGroup.setReadOnly(false); + + fieldGroup.setItemDataSource(new BeanItem<>(entity, entityType)); + applyAnnotations(Required.class, new NullValidator("Must be set!", false)); + applyAnnotations(NotEmpty.class, new StringLengthValidator("Must not be empty!", 1, -1, false)); + + fieldGroup.setReadOnly(wasReadOnly); + } + + private void applyAnnotations(Class annotationType, Validator validator) { + getPropertiesWithAnnotation(entityType, annotationType).forEach(property -> { + Field field = fieldGroup.getField(property.getName()); + if (field != null) { + field.addValidator(validator); + } + }); + } + + @Override + public void setReadOnly(boolean readOnly) { + super.setReadOnly(readOnly); + fieldGroup.setReadOnly(readOnly); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/AbstractEntityLister.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/AbstractEntityLister.java new file mode 100644 index 00000000..5023fbd5 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/AbstractEntityLister.java @@ -0,0 +1,58 @@ +package net.typedrest.vaadin.forms; + +import static net.typedrest.BeanUtils.*; +import net.typedrest.ListerHidden; +import com.vaadin.data.util.BeanItemContainer; +import com.vaadin.ui.CustomComponent; +import java.util.*; + +/** + * Base class for building entity lister implementations. + * + * @param The type of entities the list shows. + */ +public abstract class AbstractEntityLister + extends CustomComponent implements EntityLister { + + protected final Class entityType; + protected final BeanItemContainer container; + + /** + * Creates a new entity lister. + * + * @param entityType The type of entities the list shows. + */ + protected AbstractEntityLister(Class entityType) { + this.entityType = entityType; + + this.container = new BeanItemContainer<>(entityType); + getPropertiesWithAnnotation(entityType, ListerHidden.class) + .forEach(x -> container.removeContainerProperty(x.getName())); + } + + @Override + public void addEntities(Collection entities) { + container.addAll(entities); + } + + @Override + public void clearEntities() { + container.removeAllItems(); + } + + @Override + public int entityCount() { + return container.getItemIds().size(); + } + + private final Collection> clickListeners = new LinkedList<>(); + + @Override + public void addEntityClickListener(EntityClickListener listener) { + clickListeners.add(listener); + } + + protected void onClick(TEntity entity) { + clickListeners.forEach(x -> x.entityClick(entity)); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/AutoEntityForm.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/AutoEntityForm.java new file mode 100644 index 00000000..1925d03e --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/AutoEntityForm.java @@ -0,0 +1,91 @@ +package net.typedrest.vaadin.forms; + +import static net.typedrest.BeanUtils.*; +import net.typedrest.*; +import com.vaadin.shared.ui.datefield.Resolution; +import static com.vaadin.shared.util.SharedUtil.propertyIdToHumanFriendly; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import java.beans.*; + +/** + * An entity form that uses auto-generated fields. + * + * @param The type of entity the form operates on. + */ +public class AutoEntityForm + extends AbstractEntityForm { + + /** + * Creates a new entity form. + * + * @param entityType The type of entity the form operates on. + */ + public AutoEntityForm(Class entityType) { + super(entityType); + setCompositionRoot(buildAndBind(entityType)); + } + + /** + * Builds a form layout with fields for all properties exposed by the + * entity. + * + * @param entityType The type of entity to build a form for. + * @return The form layout. + */ + protected Component buildAndBind(Class entityType) { + FormLayout layout = new FormLayout(); + + getPropertiesWithoutAnnotation(entityType, EditorHidden.class).forEach((property) -> { + if (property.getWriteMethod() == null) { + return; + } + + Component component = buildAndBind(property); + component.setWidth(100, Unit.PERCENTAGE); + if (component.getCaption() == null) { + component.setCaption(propertyIdToHumanFriendly(property.getName())); + } + if (component instanceof DateField) { + ((DateField) component).setResolution(Resolution.SECOND); + } + layout.addComponent(component); + + getAnnotation(entityType, property, Description.class) + .ifPresent(x -> layout.addComponent(buildDescriptionComponent(property, x.value()))); + }); + + return layout; + } + + /** + * Builds a field component (label + input) for a specific property and sets + * up its data binding. + * + * @param property The property to create the field for. + * @return The newly created component. + */ + protected Component buildAndBind(PropertyDescriptor property) { + if (BeanUtils.getAnnotation(entityType, property, MultiLine.class).isPresent()) { + TextArea textArea = new TextArea(); + fieldGroup.bind(textArea, property.getName()); + return textArea; + } else { + return fieldGroup.buildAndBind(property.getName()); + } + } + + /** + * Builds a description component (label) for a specific property. + * + * @param property The property to create the component for. + * @param description The description text to show. + * @return The newly created component. + */ + protected Component buildDescriptionComponent(PropertyDescriptor property, String description) { + Label label = new Label(description); + label.addStyleName(ValoTheme.LABEL_LIGHT); + label.addStyleName(ValoTheme.LABEL_SMALL); + return label; + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/AutoEntityLister.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/AutoEntityLister.java new file mode 100644 index 00000000..dae0d7be --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/AutoEntityLister.java @@ -0,0 +1,39 @@ +package net.typedrest.vaadin.forms; + +import com.vaadin.ui.*; +import static net.typedrest.vaadin.GridUtils.addFilterRow; + +/** + * An entity lister that uses auto-generated {@link Grid}s. + * + * @param The type of entities the list shows. + */ +public class AutoEntityLister + extends AbstractEntityLister { + + protected final Grid grid = new Grid(); + + /** + * Creates a new entity lister. + * + * @param entityType TThe type of entities the list shows. + */ + @SuppressWarnings({ + "OverridableMethodCallInConstructor", // False positive due to lambda + "unchecked"}) // Known types in untyped GUI control + public AutoEntityLister(Class entityType) { + super(entityType); + + grid.setContainerDataSource(container); + grid.addItemClickListener(x -> onClick((TEntity) x.getItemId())); + grid.setSizeFull(); + addFilterRow(grid); + + setCompositionRoot(grid); + } + + @Override + public void scrollToEnd() { + grid.scrollToEnd(); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityClickListener.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityClickListener.java new file mode 100644 index 00000000..b9f42187 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityClickListener.java @@ -0,0 +1,9 @@ +package net.typedrest.vaadin.forms; + +import java.io.Serializable; + +@FunctionalInterface +public interface EntityClickListener extends Serializable { + + public void entityClick(TEntity entity); +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityForm.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityForm.java new file mode 100644 index 00000000..5c6426a9 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityForm.java @@ -0,0 +1,29 @@ +package net.typedrest.vaadin.forms; + +import com.vaadin.data.Validator; +import com.vaadin.ui.Component; + +/** + * Vaadin component for presenting/editing entity instances. + * + * @param The type of entity the from operates on. + */ +public interface EntityForm extends Component { + + /** + * Returns the entity the form operates on. + * + * @throws Validator.InvalidValueException The user input is invalid. + * @return the entity the form operates on. + */ + TEntity getEntity() + throws Validator.InvalidValueException; + + /** + * Sets the entity the form operates on. This must be called before the form + * can be used, even when creating new entities. + * + * @param entity the entity the form operates on. + */ + void setEntity(TEntity entity); +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityLister.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityLister.java new file mode 100644 index 00000000..dc497455 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityLister.java @@ -0,0 +1,53 @@ +package net.typedrest.vaadin.forms; + +import com.vaadin.ui.Component; +import java.util.Collection; + +/** + * Vaadin component for listing entities. + * + * @param The type of entities the list shows. + */ +public interface EntityLister extends Component { + + /** + * Sets the entities entities the list shows. + * + * @param entities the entities the list shows. + */ + default void setEntities(Collection entities) { + clearEntities(); + addEntities(entities); + } + + /** + * Adds a set of entities to the list of entities the list shows. + * + * @param entities + */ + void addEntities(Collection entities); + + /** + * Removes all entities from the list. + */ + void clearEntities(); + + /** + * Returns the number entities currently shown by the list. + * + * @return The number of entities. + */ + int entityCount(); + + /** + * Registers a listener for entity click events. + * + * @param listener invoked when an entity is clicked. + */ + void addEntityClickListener(EntityClickListener listener); + + /** + * Scrolls to the end of the list. + */ + void scrollToEnd(); +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityPicker.java b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityPicker.java new file mode 100644 index 00000000..5ac01f76 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/forms/EntityPicker.java @@ -0,0 +1,95 @@ +package net.typedrest.vaadin.forms; + +import com.vaadin.data.Container.Filter; +import com.vaadin.data.Item; +import com.vaadin.data.util.BeanItemContainer; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import java.util.Collection; +import java.util.Set; + +/** + * UI component for picking a subset of entities from a set of candidates. + * + * Wraps a {@link TwinColSelect} handling the creation of a + * {@link BeanItemContainer} and adding incremental search. + * + * @param The type of entities the picker offers. + */ +public class EntityPicker extends CustomField> { + + private final Class entityType; + private final TextField filterField = new TextField(); + private final TwinColSelect twinColSelect = new TwinColSelect(); + private BeanItemContainer container; + + /** + * Creates a new entity picker. + * + * @param type The type of entities the picker offers. + */ + public EntityPicker(Class type) { + this.entityType = type; + } + + @Override + protected Component initContent() { + filterField.addTextChangeListener(event -> { + if (container == null) { + return; + } + + container.removeAllContainerFilters(); + container.addContainerFilter(new Filter() { + @Override + public boolean passesFilter(Object itemId, Item item) throws UnsupportedOperationException { + return itemId.toString().toLowerCase().contains(event.getText().toLowerCase()) + || ((Collection) twinColSelect.getValue()).contains(itemId); + } + + @Override + public boolean appliesToProperty(Object propertyId) { + return true; + } + }); + }); + filterField.setInputPrompt("Filter"); + filterField.addStyleName(ValoTheme.TEXTFIELD_SMALL); + filterField.setWidth(50, Unit.PERCENTAGE); + + twinColSelect.setLeftColumnCaption("Available"); + twinColSelect.setSizeFull(); + twinColSelect.setRightColumnCaption("Selected"); + twinColSelect.setNullSelectionAllowed(true); + + VerticalLayout layout = new VerticalLayout(filterField, twinColSelect); + layout.setSizeFull(); + return layout; + } + + @Override + public Class> getType() { + return (Class>) ((Class) Set.class); + } + + @Override + protected void setInternalValue(Set newValue) { + twinColSelect.setValue(newValue); + super.setInternalValue(newValue); + } + + @Override + protected Set getInternalValue() { + return (Set) twinColSelect.getValue(); + } + + /** + * Sets a set of candidates for selection. + * + * @param candidates The candidates for selection. + */ + public void setCandidates(Collection candidates) { + twinColSelect.setContainerDataSource( + container = new BeanItemContainer<>(entityType, candidates)); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractBlobView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractBlobView.java new file mode 100644 index 00000000..fafdc95a --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractBlobView.java @@ -0,0 +1,126 @@ +package net.typedrest.vaadin.views; + +import java.io.FileNotFoundException; +import java.io.IOException; +import org.apache.http.*; +import com.google.common.eventbus.EventBus; +import net.typedrest.BlobEndpoint; +import net.typedrest.vaadin.events.BlobUploadEvent; +import com.vaadin.data.Validator; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import org.vaadin.dialogs.ConfirmDialog; + +/** + * Base class for building view components operating on a {@link BlobEndpoint}. + */ +public abstract class AbstractBlobView extends AbstractEndpointView { + + protected final Button deleteButton = new Button("Delete", x -> delete()) { + { + addStyleName(ValoTheme.BUTTON_DANGER); + } + }; + + protected AbstractBlobView(BlobEndpoint endpoint, EventBus eventBus) { + super(endpoint, eventBus); + } + + @Override + protected void onLoad() throws IOException, IllegalArgumentException, IllegalAccessException, IllegalStateException { + try { + endpoint.probe(); + } catch (IOException | IllegalAccessException | RuntimeException ex) { + // HTTP OPTIONS server-side implementation is optional + } + + handleAllowedMethods(); + } + + /** + * Enables or disabled buttons based on the "Allow" HTTP header. + */ + protected void handleAllowedMethods() { + endpoint.isDownloadAllowed().ifPresent(this::setDownloadEnabled); + endpoint.isUploadAllowed().ifPresent(this::setUploadEnabled); + endpoint.isDeleteAllowed().ifPresent(this::setDeleteEnabled); + } + + /** + * Controls whether a download button is shown. + * + * @param val Turns the feature on or off. + */ + public abstract void setDownloadEnabled(boolean val); + + /** + * Controls whether an upload button is shown. + * + * @param val Turns the feature on or off. + */ + public abstract void setUploadEnabled(boolean val); + + /** + * Called to upload new blob data to the server. + */ + protected void upload() { + try { + onUpload(); + eventBus.post(new BlobUploadEvent(endpoint)); + Notification.show("Success", "Upload complete", Notification.Type.TRAY_NOTIFICATION); + } catch (IOException | IllegalArgumentException | IllegalAccessException | IllegalStateException ex) { + onError(ex); + } + } + + /** + * Handler for uploading the blob. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #onLoad()}. Your changes were rejected to prevent a + * lost update. + * @throws Validator.InvalidValueException The user input is invalid. + * @throws RuntimeException Other non-success status code. + */ + protected abstract void onUpload() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException, Validator.InvalidValueException; + + /** + * Controls whether a delete button is shown. + * + * @param val Turns the feature on or off. + */ + public void setDeleteEnabled(boolean val) { + deleteButton.setVisible(val); + } + + /** + * Deletes the blob. + */ + protected void delete() { + String question = "Are you sure you want to delete the data from the server?"; + ConfirmDialog.show(getUI(), question, new ConfirmDialog.Listener() { + @Override + public void onClose(ConfirmDialog cd) { + if (cd.isConfirmed()) { + try { + endpoint.delete(); + close(); + } catch (IOException | IllegalArgumentException | IllegalAccessException | IllegalStateException ex) { + onError(ex); + } catch (RuntimeException ex) { + // Must explicitly send unhandled exceptions to error handler. + // Would otherwise get swallowed silently within callback handler. + getUI().getErrorHandler().error(new com.vaadin.server.ErrorEvent(ex)); + } + } + } + }); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractCollectionView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractCollectionView.java new file mode 100644 index 00000000..12694d79 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractCollectionView.java @@ -0,0 +1,151 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import com.google.common.eventbus.Subscribe; +import net.typedrest.*; +import net.typedrest.vaadin.events.ElementEvent; +import net.typedrest.vaadin.forms.AutoEntityLister; +import net.typedrest.vaadin.forms.EntityLister; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** + * Base class for building view components operating on an + * {@link CollectionEndpoint}. + * + * @param The type of entity the TEndpoint represents. + * @param The specific type of {@link CollectionEndpoint} to operate + * on. + * @param The specific type of {@link ElementEndpoint} the + * TEndpoint provides for individual TEntitys. + */ +public abstract class AbstractCollectionView, TElementEndpoint extends ElementEndpoint> + extends AbstractEndpointView { + + protected final EntityLister lister; + + private final Button createButton = new Button("Create", x -> create()) { + { + addStyleName(ValoTheme.BUTTON_FRIENDLY); + } + }; + protected final Button refreshButton = new Button("Refresh", x -> refresh()); + protected final HorizontalLayout buttonsLayout = new HorizontalLayout(createButton, refreshButton) { + { + setSpacing(true); + } + }; + + protected final VerticalLayout masterLayout; + + /** + * Creates a new REST collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param lister A component for listing entity instances. + */ + @SuppressWarnings("OverridableMethodCallInConstructor") // False positive due to lambda + protected AbstractCollectionView(TEndpoint endpoint, EventBus eventBus, EntityLister lister) { + super(endpoint, eventBus); + setCaption(endpoint.getEntityType().getSimpleName() + "s"); + + this.lister = lister; + lister.setSizeFull(); + lister.addEntityClickListener(x -> { + if (openElementEnabled) { + onOpenElement(x); + } + }); + + masterLayout = new VerticalLayout(lister, buttonsLayout); + masterLayout.setExpandRatio(lister, 1); + masterLayout.setComponentAlignment(buttonsLayout, Alignment.MIDDLE_RIGHT); + masterLayout.setMargin(true); + masterLayout.setSpacing(true); + masterLayout.setSizeFull(); + setCompositionRoot(masterLayout); + } + + /** + * Creates a new REST collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + protected AbstractCollectionView(TEndpoint endpoint, EventBus eventBus) { + this(endpoint, eventBus, new AutoEntityLister<>(endpoint.getEntityType())); + } + + @Override + protected void onLoad() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + lister.setEntities(endpoint.readAll()); + + endpoint.isCreateAllowed().ifPresent(this::setCreateEnabled); + } + + private boolean openElementEnabled = true; + + /** + * Controls whether selecting individual elements opens an edit view. + * + * @param val Turns the feature on or off. + */ + public void setOpenElementEnabled(boolean val) { + openElementEnabled = val; + } + + /** + * Handler for opening an existing element in the collection. + * + * @param entity The entity that was clicked. + */ + protected void onOpenElement(TEntity entity) { + open(buildElementView(endpoint.get(entity))); + } + + /** + * Builds an {@link AbstractEndpointView} for viewing or editing an existing + * TEntity represented by the given element endpoint. + * + * @param elementEndpoint The endpoint representing the entity to be + * updated. + * @return The new component. + */ + protected abstract ViewComponent buildElementView(TElementEndpoint elementEndpoint); + + /** + * Controls whether a create button is shown. + * + * @param val Turns the feature on or off. + */ + public void setCreateEnabled(boolean val) { + createButton.setVisible(val); + } + + /** + * Opens a view for creating a new element in the collection. + */ + public void create() { + open(buildCreateElementView()); + } + + /** + * Builds an {@link AbstractEndpointView} for creating a new + * TEntity in the collection endpoint. + * + * @return The new component. + */ + protected abstract ViewComponent buildCreateElementView(); + + // Refresh when child elements are created or updated + @Subscribe + public void handle(ElementEvent message) { + if (message.getEndpoint().getEntityType() == this.endpoint.getEntityType()) { + refresh(); + } + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractElementView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractElementView.java new file mode 100644 index 00000000..9cb923b1 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractElementView.java @@ -0,0 +1,95 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.forms.EntityForm; +import com.vaadin.data.Validator; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import java.io.FileNotFoundException; +import java.io.IOException; +import org.apache.http.*; +import org.vaadin.dialogs.ConfirmDialog; + +/** + * Base class for building view components that create or update elements. + * + * @param The type of entity the component represents. + * @param The type of {@link Endpoint} to operate on. + */ +public abstract class AbstractElementView + extends AbstractEndpointView { + + protected final EntityForm entityForm; + + protected final Button saveButton = new Button("Save", x -> save()) { + { + addStyleName(ValoTheme.BUTTON_FRIENDLY); + } + }; + protected final HorizontalLayout buttonsLayout = new HorizontalLayout(saveButton) { + { + setSpacing(true); + } + }; + + protected final VerticalLayout masterLayout; + + /** + * Creates a new REST element component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param entityForm A component for viewing/modifying entity instances. + */ + protected AbstractElementView(TEndpoint endpoint, EventBus eventBus, EntityForm entityForm) { + super(endpoint, eventBus); + + this.entityForm = entityForm; + + masterLayout = new VerticalLayout(entityForm, buttonsLayout); + masterLayout.setComponentAlignment(buttonsLayout, Alignment.MIDDLE_RIGHT); + masterLayout.setMargin(true); + masterLayout.setSpacing(true); + setCompositionRoot(masterLayout); + + setHeight(masterLayout.getHeight() * 1.2f, masterLayout.getHeightUnits()); + } + + /** + * Saves the input and closes the {@link Window} (if present). + */ + public void save() { + try { + onSave(); + close(); + } catch (IOException | IllegalArgumentException | IllegalAccessException | Validator.InvalidValueException ex) { + onError(ex); + } catch (IllegalStateException ex) { + // This usually inidicates a "lost update" + ConfirmDialog.show(getUI(), ex.getLocalizedMessage() + "\nDo you want to refresh this page loosing any changes you have made?", (ConfirmDialog cd) -> { + if (cd.isConfirmed()) { + refresh(); + } + }); + } + } + + /** + * Handler for saving the input. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException The entity has changed since it was last + * retrieved with {@link #onLoad()}. Your changes were rejected to prevent a + * lost update. + * @throws Validator.InvalidValueException The user input is invalid. + * @throws RuntimeException Other non-success status code. + */ + protected abstract void onSave() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException, Validator.InvalidValueException; +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractEndpointView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractEndpointView.java new file mode 100644 index 00000000..43fef1be --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractEndpointView.java @@ -0,0 +1,93 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import com.google.common.eventbus.Subscribe; +import net.typedrest.Endpoint; +import net.typedrest.vaadin.events.EndpointEvent; +import com.vaadin.ui.*; +import java.io.*; +import lombok.Getter; +import lombok.Setter; +import org.apache.http.*; + +/** + * Base class for building view components operating on an {@link Endpoint}. + * + * @param The specific type of {@link Endpoint} to operate on. + */ +public abstract class AbstractEndpointView + extends ViewComponent { + + /** + * The REST endpoint this component operates on. + */ + protected final TEndpoint endpoint; + + /** + * Creates a new REST endpoint component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + protected AbstractEndpointView(TEndpoint endpoint, EventBus eventBus) { + super(eventBus); + this.endpoint = endpoint; + setSizeFull(); + } + + @Override + public void attach() { + super.attach(); + refresh(); + } + + /** + * Reloads data from the endpoint. + */ + public void refresh() { + try { + onLoad(); + } catch (IOException | IllegalArgumentException | IllegalAccessException | IllegalStateException ex) { + onError(ex); + } + } + + /** + * Handler for loading data for the endpoint. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + protected void onLoad() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + } + + /** + * Handler for errors reported by REST endpoints. + * + * @param ex The exception reported by the REST endpoint. + */ + protected void onError(Exception ex) { + Notification.show("Error", ex.getLocalizedMessage(), Notification.Type.ERROR_MESSAGE); + } + + /** + * The Link relation type used by the server to send refresh notifications. + */ + @Getter + @Setter + private String notifyRel = "notify"; + + @Subscribe + public void handle(EndpointEvent message) { + if (message.getEndpoint().getLinks(notifyRel).contains(endpoint.getUri())) { + refresh(); + } + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractEntryView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractEntryView.java new file mode 100644 index 00000000..c7e28c13 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractEntryView.java @@ -0,0 +1,43 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.EntryEndpoint; +import com.vaadin.ui.Component; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** + * Base class for building view components operating on an + * {@link EntryEndpoint}. Use this to build a UI that provides access to an + * API's top-level functionality. + * + * @param The specific type of {@link EntryEndpoint} to operate on. + */ +public abstract class AbstractEntryView + extends AbstractEndpointView { + + /** + * Creates a new REST entry component. + * + * @param endpoint The REST endpoint this component operates on. + */ + public AbstractEntryView(TEndpoint endpoint) { + super(endpoint, new EventBus()); + } + + @Override + protected void onLoad() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + endpoint.readMeta(); + + setCompositionRoot(buildRoot()); + } + + /** + * Template method called to build the UI composition root after + * {@link EntryEndpoint#readMeta()} has been called. + * + * @return The UI composition root. + */ + protected abstract Component buildRoot(); +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractPagedCollectionView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractPagedCollectionView.java new file mode 100644 index 00000000..47af7b71 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractPagedCollectionView.java @@ -0,0 +1,123 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.forms.AutoEntityLister; +import net.typedrest.vaadin.forms.EntityLister; +import com.vaadin.shared.ui.*; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** + * Base class for building view components operating on a + * {@link GenericCollectionEndpoint} with pagination. + * + * @param The type of entity the TEndpoint represents. + * @param The specific type of {@link GenericCollectionEndpoint} to + * operate on. + * @param The specific type of {@link ElementEndpoint} the + * TEndpoint provides for individual TEntitys. + */ +public abstract class AbstractPagedCollectionView, TElementEndpoint extends ElementEndpoint> + extends AbstractCollectionView { + + private final Button pageLeftButton = new Button("<"); + private final Button pageRightButton = new Button(">"); + + private long pageSize = 5; + private long currentFrom = 0; + private long currentTo = pageSize; + + /** + * Creates a new REST paged collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param lister A component for listing entity instances. + */ + protected AbstractPagedCollectionView(TEndpoint endpoint, EventBus eventBus, EntityLister lister) { + super(endpoint, eventBus, lister); + pageLeftButton.addClickListener(clickEvent -> { + currentTo = currentFrom; + long diff = currentFrom - pageSize; + currentFrom = diff > 0 ? diff : 0; + + pageRightButton.setEnabled(true); + + refresh(); + }); + + pageRightButton.addClickListener(clickEvent -> { + currentFrom += pageSize; + currentTo += pageSize; + + pageLeftButton.setEnabled(true); + + refresh(); + }); + + pageLeftButton.setEnabled(false); + + pageLeftButton.addStyleName(ValoTheme.BUTTON_TINY); + pageRightButton.addStyleName(ValoTheme.BUTTON_TINY); + + ComboBox pageSizeComboBox = pageSizeComboBox(); + + HorizontalLayout pagingButtonsLayout = new HorizontalLayout(pageLeftButton, pageSizeComboBox, pageRightButton); + pagingButtonsLayout.setComponentAlignment(pageLeftButton, Alignment.MIDDLE_LEFT); + pagingButtonsLayout.setComponentAlignment(pageSizeComboBox, Alignment.MIDDLE_CENTER); + pagingButtonsLayout.setComponentAlignment(pageRightButton, Alignment.MIDDLE_RIGHT); + pagingButtonsLayout.setMargin(new MarginInfo(true, false, false, false)); + pagingButtonsLayout.setWidth(100, Unit.PERCENTAGE); + masterLayout.addComponent(pagingButtonsLayout, masterLayout.getComponentIndex(lister)); + } + + /** + * Creates a new REST paged collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + protected AbstractPagedCollectionView(TEndpoint endpoint, EventBus eventBus) { + this(endpoint, eventBus, new AutoEntityLister<>(endpoint.getEntityType())); + } + + private ComboBox pageSizeComboBox() { + ComboBox pageSizeComboBox = new ComboBox(); + pageSizeComboBox.addStyleName(ValoTheme.COMBOBOX_TINY); + pageSizeComboBox.addItem(5L); + pageSizeComboBox.addItem(10L); + pageSizeComboBox.addItem(15L); + pageSizeComboBox.addItem(20L); + pageSizeComboBox.addItem(50L); + pageSizeComboBox.select(5L); + pageSizeComboBox.addValueChangeListener(valueChangeEvent -> { + pageSize = (long) pageSizeComboBox.getValue(); + currentFrom = 0; + currentTo = pageSize; + refresh(); + }); + return pageSizeComboBox; + } + + @Override + protected void onLoad() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + PartialResponse response = endpoint.readRange(currentFrom, currentTo); + + if (response.isEndReached()) { + pageRightButton.setEnabled(false); + } + + if (currentFrom <= 0) { + pageLeftButton.setEnabled(false); + currentFrom = 0; + } + + lister.setEntities(response.getElements()); + + endpoint.isCreateAllowed().ifPresent(this::setCreateEnabled); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractStreamView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractStreamView.java new file mode 100644 index 00000000..50c4c8e9 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/AbstractStreamView.java @@ -0,0 +1,124 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.forms.AutoEntityLister; +import net.typedrest.vaadin.forms.EntityLister; +import com.vaadin.annotations.Push; +import com.vaadin.ui.*; +import java.util.concurrent.TimeUnit; +import lombok.*; +import rx.util.async.StoppableObservable; + +/** + * Base class for building view components operating on a + * {@link GenericStreamEndpoint}. + * + * To enable server-push (real-time actualization of the web-ui), please make + * sure to have your {@link UI} annotated with {@link Push}. + * + * @param The type of entity the TEndpoint represents. + * @param The specific type of {@link GenericStreamEndpoint} to + * operate on. + * @param The specific type of {@link ElementEndpoint} the + * TEndpoint provides for individual TEntitys. + */ +public abstract class AbstractStreamView, TElementEndpoint extends ElementEndpoint> + extends AbstractCollectionView { + + /** + * Creates a new REST stream component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param lister A component for listing entity instances. + */ + protected AbstractStreamView(TEndpoint endpoint, EventBus eventBus, EntityLister lister) { + super(endpoint, eventBus, lister); + setCreateEnabled(false); + refreshButton.setVisible(false); + } + + /** + * Creates a new REST stream component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + protected AbstractStreamView(TEndpoint endpoint, EventBus eventBus) { + this(endpoint, eventBus, new AutoEntityLister<>(endpoint.getEntityType())); + } + + @Override + protected void onLoad() { + // Do nothing here, loading happens async + } + + private boolean streamingEnabled = true; + + public void setStreamingEnabled(boolean streamingEnabled) { + this.streamingEnabled = streamingEnabled; + if (isAttached()) { + if (streamingEnabled) { + startStreaming(); + } else { + stopStreaming(); + } + } + } + + public boolean isStreamingEnabled() { + return streamingEnabled; + } + + /** + * The index of the first element to load from the stream. Use negative + * values to start counting from the end of the stream. + */ + @Getter + @Setter + private int startIndex; + + @Override + public void attach() { + super.attach(); + if (streamingEnabled) { + startStreaming(); + } + } + + @Override + public void detach() { + stopStreaming(); + super.detach(); + } + + private StoppableObservable observable; + + private void startStreaming() { + stopStreaming(); + + if (startIndex == 0) { + // Continue streaming from where we left off + observable = endpoint.getObservable(lister.entityCount()); + } else { + // Always start fresh from the specified start index + lister.clearEntities(); + observable = endpoint.getObservable(startIndex); + } + + observable + .buffer(1, TimeUnit.SECONDS).filter(x -> !x.isEmpty()) + .subscribe(new UISubscriber<>(x -> { + lister.addEntities(x); + lister.scrollToEnd(); + })); + } + + private void stopStreaming() { + if (observable != null) { + observable.unsubscribe(); + observable = null; + } + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/ActionView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/ActionView.java new file mode 100644 index 00000000..a77a9b4e --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/ActionView.java @@ -0,0 +1,72 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.events.TriggerEvent; +import com.vaadin.ui.*; +import org.apache.http.*; +import java.io.*; + +/** + * View component operating on a {@link ActionEndpoint}. + */ +public class ActionView extends AbstractEndpointView { + + protected final Button button; + + /** + * Creates a new REST trigger endpoint component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param caption A caption for the triggerable action. + */ + @SuppressWarnings("OverridableMethodCallInConstructor") // False positive due to lambda + public ActionView(ActionEndpoint endpoint, EventBus eventBus, String caption) { + super(endpoint, eventBus); + setWidthUndefined(); + + setCompositionRoot(button = new Button(caption, x -> trigger())); + } + + @Override + protected void onLoad() { + try { + endpoint.probe(); + } catch (IOException | IllegalAccessException | RuntimeException ex) { + // HTTP OPTIONS server-side implementation is optional + } + + endpoint.isTriggerAllowed().ifPresent(this::setEnabled); + } + + /** + * Triggers the action. + */ + public void trigger() { + try { + onTrigger(); + Notification.show(getCaption(), "Successful.", Notification.Type.TRAY_NOTIFICATION); + } catch (IOException | IllegalArgumentException | IllegalAccessException | IllegalStateException ex) { + onError(ex); + } + } + + /** + * Handler for triggering the action. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + protected void onTrigger() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + endpoint.trigger(); + eventBus.post(new TriggerEvent(endpoint)); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/BlobView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/BlobView.java new file mode 100644 index 00000000..f232e7ac --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/BlobView.java @@ -0,0 +1,118 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import java.io.*; +import net.typedrest.*; +import com.vaadin.server.*; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import lombok.*; +import org.apache.http.entity.ContentType; + +/** + * View component providing file upload and download for a {@link BlobEndpoint}. + */ +public class BlobView extends AbstractBlobView { + + protected final String TMP_DIR = System.getProperty("java.io.tmpdir"); + + /** + * The file name reported to the browser when downloading the blob content. + */ + @Getter + @Setter + private String downloadFileName = "blob"; + + protected final Button downloadButton = new Button("Download") { + { + addStyleName(ValoTheme.BUTTON_PRIMARY); + } + }; + private final FileDownloader fileDownloader = new FileDownloader(new StreamResource(() -> { + try { + return endpoint.download(); + } catch (IOException | IllegalArgumentException | IllegalAccessException | IllegalStateException ex) { + onError(ex); + return null; + } + }, "")) { + // Get downloadFileName on-demand instead of ahead-of-time + @Override + public boolean handleConnectorRequest(VaadinRequest request, VaadinResponse response, String path) + throws IOException { + ((StreamResource) this.getResource("dl")).setFilename(downloadFileName); + return super.handleConnectorRequest(request, response, path); + } + }; + + protected final Upload uploadButton; + private File uploadTarget; + private ContentType uploadContentType; + + protected final HorizontalLayout masterLayout; + + /** + * Creates a new REST blob component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + @SuppressWarnings("OverridableMethodCallInConstructor") // False positive due to lambda + public BlobView(BlobEndpoint endpoint, EventBus eventBus) { + super(endpoint, eventBus); + + fileDownloader.extend(downloadButton); + + uploadButton = new Upload("", (fileName, mimeType) -> { + try { + uploadContentType = ContentType.create(mimeType); + if (uploadTarget != null) { + uploadTarget.delete(); + } + + uploadTarget = File.createTempFile("typed-rest-blob", "upload", new File(TMP_DIR)); + return new FileOutputStream(uploadTarget); + } catch (IOException ex) { + onError(ex); + return null; + } + }) { + { + addStyleName(ValoTheme.BUTTON_FRIENDLY); + addSucceededListener(x -> upload()); + } + }; + + masterLayout = new HorizontalLayout(uploadButton, downloadButton, deleteButton); + masterLayout.setComponentAlignment(uploadButton, Alignment.MIDDLE_LEFT); + masterLayout.setComponentAlignment(downloadButton, Alignment.BOTTOM_RIGHT); + masterLayout.setComponentAlignment(deleteButton, Alignment.BOTTOM_RIGHT); + masterLayout.setMargin(true); + masterLayout.setSpacing(true); + setCompositionRoot(masterLayout); + } + + @Override + public void detach() { + super.detach(); + if (uploadTarget != null) { + uploadTarget.delete(); + } + } + + @Override + public void setDownloadEnabled(boolean val) { + downloadButton.setVisible(val); + } + + @Override + public void setUploadEnabled(boolean val) { + uploadButton.setVisible(val); + } + + @Override + protected void onUpload() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + endpoint.upload(uploadTarget, uploadContentType); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/CollectionView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/CollectionView.java new file mode 100644 index 00000000..71ac3a6c --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/CollectionView.java @@ -0,0 +1,46 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.forms.EntityLister; + +/** + * View component operating on an {@link CollectionEndpoint}. + * + * @param The type of entity the {@link CollectionEndpoint} + * represents. + */ +public class CollectionView + extends AbstractCollectionView, ElementEndpoint> { + + /** + * Creates a new REST collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param lister A component for listing entity instances. + */ + public CollectionView(CollectionEndpoint endpoint, EventBus eventBus, EntityLister lister) { + super(endpoint, eventBus, lister); + } + + /** + * Creates a new REST collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + public CollectionView(CollectionEndpoint endpoint, EventBus eventBus) { + super(endpoint, eventBus); + } + + @Override + protected ViewComponent buildElementView(ElementEndpoint elementEndpoint) { + return new ElementView<>(elementEndpoint, eventBus); + } + + @Override + protected ViewComponent buildCreateElementView() { + return new CreateElementView<>(endpoint, eventBus); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/ConfirmationActionView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/ConfirmationActionView.java new file mode 100644 index 00000000..102d4538 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/ConfirmationActionView.java @@ -0,0 +1,44 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import com.vaadin.ui.UI; +import org.vaadin.dialogs.ConfirmDialog; +import net.typedrest.ActionEndpoint; + +/** + * View component operating on a {@link ActionEndpoint} that asks a + * confirmation question before triggering. + */ +public class ConfirmationActionView extends ActionView { + + private final String question; + + /** + * Creates a new REST action endpoint component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param caption A caption for the triggerable action. + * @param question A question to show the user asking whether to actually + * trigger the action. + */ + public ConfirmationActionView(ActionEndpoint endpoint, EventBus eventBus, String caption, String question) { + super(endpoint, eventBus, caption); + this.question = question; + } + + @Override + public void trigger() { + ConfirmDialog.show(getUI(), question, (ConfirmDialog cd) -> { + if (cd.isConfirmed()) { + try { + super.trigger(); + } catch (RuntimeException ex) { + // Must explicitly send unhandled exceptions to error handler. + // Would otherwise get swallowed silently within callback handler. + getUI().getErrorHandler().error(new com.vaadin.server.ErrorEvent(ex)); + } + } + }); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/ConfirmationElementView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/ConfirmationElementView.java new file mode 100644 index 00000000..be6dfaf2 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/ConfirmationElementView.java @@ -0,0 +1,53 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.ElementEndpoint; +import net.typedrest.vaadin.forms.EntityForm; +import org.vaadin.dialogs.ConfirmDialog; + +/** + * Component for showing or updating an existing element represented by an + * {@link ElementEndpoint}. Asks a confirmation question before saving changes. + * + * @param The type of entity to represent. + */ +public class ConfirmationElementView extends ElementView { + + private final String question; + + /** + * Creates a new REST element component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param entityForm A component for viewing/modifying entity instances. + * @param question A question to show the user asking whether to actually + * save the changes. + */ + public ConfirmationElementView(ElementEndpoint endpoint, EventBus eventBus, EntityForm entityForm, String question) { + super(endpoint, eventBus, entityForm); + this.question = question; + } + + /** + * Creates a new REST element component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param question A question to show the user asking whether to actually + * save the changes. + */ + public ConfirmationElementView(ElementEndpoint endpoint, EventBus eventBus, String question) { + super(endpoint, eventBus); + this.question = question; + } + + @Override + public void save() { + ConfirmDialog.show(getUI(), question, (ConfirmDialog cd) -> { + if (cd.isConfirmed()) { + super.save(); + } + }); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/CreateElementView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/CreateElementView.java new file mode 100644 index 00000000..f06a3490 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/CreateElementView.java @@ -0,0 +1,58 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.CollectionEndpoint; +import net.typedrest.ElementEndpoint; +import net.typedrest.GenericCollectionEndpoint; +import net.typedrest.vaadin.events.ElementCreatedEvent; +import net.typedrest.vaadin.forms.AutoEntityForm; +import net.typedrest.vaadin.forms.EntityForm; +import com.vaadin.data.Validator; +import java.io.FileNotFoundException; +import java.io.IOException; +import lombok.SneakyThrows; + +/** + * Component for creating a new element in a {@link GenericCollectionEndpoint}. + * + * Use the more constrained {@link CreateElementView} when possible. + * + * @param The type of entity to create. + * @param The specific type of {@link ElementEndpoint} the + * {@link CollectionEndpoint} provides for individual TEntitys. + */ +public class CreateElementView> + extends AbstractElementView> { + + /** + * Creates a new REST element creation component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param entityForm A component for viewing/modifying entity instances. + */ + @SneakyThrows + public CreateElementView(GenericCollectionEndpoint endpoint, EventBus eventBus, EntityForm entityForm) { + super(endpoint, eventBus, entityForm); + setCaption("New " + endpoint.getEntityType().getSimpleName()); + + entityForm.setEntity(endpoint.getEntityType().getConstructor().newInstance()); + } + + /** + * Creates a new REST element creation component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + public CreateElementView(GenericCollectionEndpoint endpoint, EventBus eventBus) { + this(endpoint, eventBus, new AutoEntityForm<>(endpoint.getEntityType())); + } + + @Override + protected void onSave() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException, Validator.InvalidValueException { + TElementEndpoint newEndpoint = endpoint.create(entityForm.getEntity()); + eventBus.post(new ElementCreatedEvent<>(newEndpoint)); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/ElementView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/ElementView.java new file mode 100644 index 00000000..08174246 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/ElementView.java @@ -0,0 +1,129 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.events.*; +import net.typedrest.vaadin.forms.*; +import com.vaadin.data.Validator; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import java.io.FileNotFoundException; +import java.io.IOException; +import org.apache.http.HttpStatus; +import org.vaadin.dialogs.ConfirmDialog; + +/** + * Component for showing or updating an existing element represented by an + * {@link ElementEndpoint}. + * + * @param The type of entity to represent. + */ +public class ElementView + extends AbstractElementView> { + + protected final Button deleteButton = new Button("Delete", x -> delete()) { + { + addStyleName(ValoTheme.BUTTON_DANGER); + } + }; + + /** + * Creates a new REST element component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param entityForm A component for viewing/modifying entity instances. + */ + public ElementView(ElementEndpoint endpoint, EventBus eventBus, EntityForm entityForm) { + super(endpoint, eventBus, entityForm); + + buttonsLayout.addComponent(deleteButton); + } + + /** + * Creates a new REST element component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + public ElementView(ElementEndpoint endpoint, EventBus eventBus) { + this(endpoint, eventBus, new AutoEntityForm<>(endpoint.getEntityType())); + } + + @Override + protected void onLoad() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + TEntity entity = endpoint.read(); + setCaption(entity.toString()); + entityForm.setEntity(entity); + + endpoint.isSetAllowed().ifPresent(this::setSaveEnabled); + endpoint.isDeleteAllowed().ifPresent(this::setDeleteEnabled); + } + + /** + * Controls whether a save button is shown and fields are editable. + * + * @param val Turns the feature on or off. + */ + public void setSaveEnabled(boolean val) { + saveButton.setVisible(val); + setReadOnly(!val); + entityForm.setReadOnly(!val); + } + + @Override + protected void onSave() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException, Validator.InvalidValueException { + endpoint.set(entityForm.getEntity()); + eventBus.post(new ElementUpdatedEvent<>(endpoint)); + } + + /** + * Controls whether a delete button is shown. + * + * @param val Turns the feature on or off. + */ + public void setDeleteEnabled(boolean val) { + deleteButton.setVisible(val); + } + + /** + * Deletes the element. + */ + protected void delete() { + String question = "Are you sure you want to delete " + getCaption() + "?"; + ConfirmDialog.show(getUI(), question, (ConfirmDialog cd) -> { + if (cd.isConfirmed()) { + try { + onDelete(); + close(); + } catch (IOException | IllegalArgumentException | IllegalAccessException | IllegalStateException ex) { + onError(ex); + } catch (RuntimeException ex) { + // Must explicitly send unhandled exceptions to error handler. + // Would otherwise get swallowed silently within callback handler. + getUI().getErrorHandler().error(new com.vaadin.server.ErrorEvent(ex)); + } + } + }); + } + + /** + * Handler for deleting the element. + * + * @throws IOException Network communication failed. + * @throws IllegalArgumentException {@link HttpStatus#SC_BAD_REQUEST} + * @throws IllegalAccessException {@link HttpStatus#SC_UNAUTHORIZED} or + * {@link HttpStatus#SC_FORBIDDEN} + * @throws FileNotFoundException {@link HttpStatus#SC_NOT_FOUND} or + * {@link HttpStatus#SC_GONE} + * @throws IllegalStateException {@link HttpStatus#SC_CONFLICT} + * @throws RuntimeException Other non-success status code. + */ + protected void onDelete() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + endpoint.delete(); + eventBus.post(new ElementDeletedEvent<>(endpoint)); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/PagedCollectionView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/PagedCollectionView.java new file mode 100644 index 00000000..1a8b91b3 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/PagedCollectionView.java @@ -0,0 +1,46 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.forms.EntityLister; + +/** + * View component operating on an {@link CollectionEndpoint} with pagination. + * + * @param The type of entity the {@link CollectionEndpoint} + * represents. + */ +public class PagedCollectionView + extends AbstractPagedCollectionView, ElementEndpoint> { + + /** + * Creates a new REST paged collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param lister A component for listing entity instances. + * @param eventBus Used to send event between components. + */ + public PagedCollectionView(CollectionEndpoint endpoint, EventBus eventBus, EntityLister lister) { + super(endpoint, eventBus, lister); + } + + /** + * Creates a new REST paged collection component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + public PagedCollectionView(CollectionEndpoint endpoint, EventBus eventBus) { + super(endpoint, eventBus); + } + + @Override + protected ViewComponent buildElementView(ElementEndpoint elementEndpoint) { + return new ElementView<>(elementEndpoint, eventBus); + } + + @Override + protected ViewComponent buildCreateElementView() { + return new CreateElementView<>(endpoint, eventBus); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/PollingView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/PollingView.java new file mode 100644 index 00000000..0d2423c1 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/PollingView.java @@ -0,0 +1,102 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.forms.AutoEntityForm; +import net.typedrest.vaadin.forms.EntityForm; +import com.vaadin.data.Validator; +import java.io.FileNotFoundException; +import java.io.IOException; +import lombok.*; +import rx.util.async.StoppableObservable; + +/** + * View component operating on a {@link PollingEndpoint}. + * + * @param The type of entity the {@link PollingEndpoint} represents. + */ +public class PollingView + extends AbstractElementView> { + + /** + * Creates a new REST polling component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param entityForm A component for viewing entity instances. + */ + public PollingView(PollingEndpoint endpoint, EventBus eventBus, EntityForm entityForm) { + super(endpoint, eventBus, entityForm); + + saveButton.setVisible(false); + setReadOnly(true); + entityForm.setReadOnly(true); + } + + /** + * Creates a new REST polling component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + public PollingView(PollingEndpoint endpoint, EventBus eventBus) { + this(endpoint, eventBus, new AutoEntityForm<>(endpoint.getEntityType())); + } + + @Override + protected void onLoad() { + // Do nothing here, loading happens async + } + + @Override + protected void onSave() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException, Validator.InvalidValueException { + throw new UnsupportedOperationException(); + } + + private boolean pollingEnabled = true; + + public void setPollingEnabled(boolean streamingEnabled) { + this.pollingEnabled = streamingEnabled; + if (isAttached()) { + if (streamingEnabled) { + startPolling(); + } else { + stopPolling(); + } + } + } + + public boolean isPollingEnabled() { + return pollingEnabled; + } + + @Override + public void attach() { + super.attach(); + if (pollingEnabled) { + startPolling(); + } + } + + @Override + public void detach() { + stopPolling(); + super.detach(); + } + + private StoppableObservable observable; + + private void startPolling() { + stopPolling(); + observable = endpoint.getObservable(); + observable.subscribe(new UISubscriber<>(entityForm::setEntity)); + } + + private void stopPolling() { + if (observable != null) { + observable.unsubscribe(); + observable = null; + } + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/StreamView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/StreamView.java new file mode 100644 index 00000000..a46c3dac --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/StreamView.java @@ -0,0 +1,45 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import net.typedrest.*; +import net.typedrest.vaadin.forms.EntityLister; + +/** + * View component operating on a {@link StreamEndpoint}. + * + * @param The type of entity the {@link StreamEndpoint} represents. + */ +public class StreamView + extends AbstractStreamView, ElementEndpoint> { + + /** + * Creates a new REST stream component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + * @param lister A component for listing entity instances. + */ + public StreamView(StreamEndpoint endpoint, EventBus eventBus, EntityLister lister) { + super(endpoint, eventBus, lister); + } + + /** + * Creates a new REST stream component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + public StreamView(StreamEndpoint endpoint, EventBus eventBus) { + super(endpoint, eventBus); + } + + @Override + protected ViewComponent buildElementView(ElementEndpoint elementEndpoint) { + return new ElementView<>(elementEndpoint, eventBus); + } + + @Override + protected ViewComponent buildCreateElementView() { + return new CreateElementView<>(endpoint, eventBus); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/TextBlobView.java b/vaadin/src/main/java/net/typedrest/vaadin/views/TextBlobView.java new file mode 100644 index 00000000..f68461ca --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/TextBlobView.java @@ -0,0 +1,89 @@ +package net.typedrest.vaadin.views; + +import com.google.common.base.Charsets; +import com.google.common.eventbus.EventBus; +import com.google.common.io.CharStreams; +import java.io.*; +import net.typedrest.*; +import com.vaadin.ui.*; +import com.vaadin.ui.themes.ValoTheme; +import java.nio.charset.Charset; +import lombok.*; +import org.apache.http.entity.ContentType; + +/** + * View component providing plain-text editing for a {@link BlobEndpoint}. + */ +public class TextBlobView extends AbstractBlobView { + + private final TextArea textArea = new TextArea(); + + protected final Button saveButton = new Button("Save", x -> upload()) { + { + addStyleName(ValoTheme.BUTTON_FRIENDLY); + } + }; + protected final HorizontalLayout buttonsLayout = new HorizontalLayout(saveButton, deleteButton) { + { + setSpacing(true); + } + }; + + protected final VerticalLayout masterLayout = new VerticalLayout(textArea, buttonsLayout); + + /** + * Creates a new REST text component. + * + * @param endpoint The REST endpoint this component operates on. + * @param eventBus Used to send event between components. + */ + public TextBlobView(BlobEndpoint endpoint, EventBus eventBus) { + super(endpoint, eventBus); + + textArea.setSizeFull(); + + masterLayout.setExpandRatio(textArea, 1); + masterLayout.setComponentAlignment(buttonsLayout, Alignment.MIDDLE_RIGHT); + masterLayout.setMargin(true); + masterLayout.setSpacing(true); + masterLayout.setSizeFull(); + setCompositionRoot(masterLayout); + } + + /** + * The charset used for interpreting the blob as text. + */ + @Getter + @Setter + private Charset charset = Charsets.UTF_8; + + @Override + protected void onLoad() throws IOException, IllegalArgumentException, IllegalAccessException, IllegalStateException { + try { + InputStream stream = endpoint.download(); + textArea.setReadOnly(false); + textArea.setValue(CharStreams.toString(new InputStreamReader(stream, charset))); + } catch (FileNotFoundException ex) { + Notification.show("Warning", ex.getLocalizedMessage(), Notification.Type.WARNING_MESSAGE); + } + + handleAllowedMethods(); + } + + @Override + public void setDownloadEnabled(boolean val) { + } + + @Override + public void setUploadEnabled(boolean val) { + textArea.setReadOnly(!val); + saveButton.setVisible(val); + } + + @Override + protected void onUpload() + throws IOException, IllegalArgumentException, IllegalAccessException, FileNotFoundException, IllegalStateException { + byte[] data = textArea.getValue().getBytes(charset); + endpoint.upload(data, ContentType.create("text/plain", charset)); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/UISubscriber.java b/vaadin/src/main/java/net/typedrest/vaadin/views/UISubscriber.java new file mode 100644 index 00000000..95ae2ad9 --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/UISubscriber.java @@ -0,0 +1,53 @@ +package net.typedrest.vaadin.views; + +import static net.typedrest.ThrowableUtils.getFullMessage; +import net.typedrest.vaadin.NotificationErrorHandler; +import com.vaadin.ui.Notification; +import com.vaadin.ui.UI; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; +import rx.Observable; +import rx.Observer; + +/** + * Subscribes to an {@link Observable} and consumes the elements using a Pushy + * {@link UI}. + */ +public class UISubscriber implements Observer { + + private final UI ui; + private final Consumer consumer; + + /** + * Creates a new UI subscriber. Captures {@link UI#getCurrent()}. + * + * @param consumer A consuming delegate called for each element in the + * context of {@link UI#access(java.lang.Runnable)}. + */ + public UISubscriber(Consumer consumer) { + this.ui = UI.getCurrent(); + this.consumer = consumer; + } + + @Override + public void onCompleted() { + } + + @Override + public void onError(final Throwable throwable) { + ui.access(() -> { + Logger.getLogger(NotificationErrorHandler.class.getName()).log(Level.WARNING, null, throwable); + Notification.show("Error", getFullMessage(throwable), Notification.Type.WARNING_MESSAGE); + ui.push(); + }); + } + + @Override + public void onNext(final T element) { + ui.access(() -> { + consumer.accept(element); + ui.push(); + }); + } +} diff --git a/vaadin/src/main/java/net/typedrest/vaadin/views/ViewComponent.java b/vaadin/src/main/java/net/typedrest/vaadin/views/ViewComponent.java new file mode 100644 index 00000000..e38eaf8a --- /dev/null +++ b/vaadin/src/main/java/net/typedrest/vaadin/views/ViewComponent.java @@ -0,0 +1,98 @@ +package net.typedrest.vaadin.views; + +import com.google.common.eventbus.EventBus; +import com.vaadin.navigator.View; +import com.vaadin.navigator.ViewChangeListener; +import com.vaadin.ui.CustomComponent; +import com.vaadin.ui.Window; + +/** + * Base class for building view components that use an {@link EventBus} and can + * be wrapped in {@link Window}s. + */ +public abstract class ViewComponent + extends CustomComponent implements View { + + /** + * Used to send event between components. + */ + protected final EventBus eventBus; + + /** + * Creates a new view component. + * + * @param eventBus Used to send event between components. + */ + protected ViewComponent(EventBus eventBus) { + this.eventBus = eventBus; + } + + private boolean eventBusRegistered; + + @Override + public void attach() { + super.attach(); + + if (!eventBusRegistered) { + eventBus.register(this); + eventBusRegistered = true; + } + } + + @Override + public void detach() { + if (eventBusRegistered) { + eventBus.unregister(this); + eventBusRegistered = false; + } + + super.detach(); + } + + /** + * Opens a child component as a {@link Window}. + * + * @param component The child component to open. + */ + protected void open(ViewComponent component) { + getUI().addWindow(component.asWindow()); + } + + private Window containingWindow; + + /** + * Wraps the control in a {@link Window}. + * + * @return The newly created window. + */ + protected Window asWindow() { + if (containingWindow == null) { + containingWindow = new Window(getCaption(), this); + containingWindow.setWidth(80, Unit.PERCENTAGE); + containingWindow.setHeight(80, Unit.PERCENTAGE); + containingWindow.center(); + } + return containingWindow; + } + + /** + * Closes the containing {@link Window}. + */ + protected void close() { + if (containingWindow != null) { + containingWindow.close(); + } + } + + @Override + public void setCaption(String caption) { + super.setCaption(caption); + if (containingWindow != null) { + containingWindow.setCaption(caption); + } + } + + @Override + public void enter(ViewChangeListener.ViewChangeEvent event) { + } +}