diff --git a/.github/workflows/manual-publish-docs.yml b/.github/workflows/manual-publish-docs.yml index c011c70..50fc31d 100644 --- a/.github/workflows/manual-publish-docs.yml +++ b/.github/workflows/manual-publish-docs.yml @@ -8,6 +8,7 @@ on: options: - lib/shared/common - lib/shared/internal + - lib/shared/test-helpers - lib/sdk/server - lib/java-server-sdk-otel - lib/java-server-sdk-redis-store diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index 02a8650..7849df3 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -9,6 +9,7 @@ on: options: - lib/shared/common - lib/shared/internal + - lib/shared/test-helpers - lib/sdk/server - lib/java-server-sdk-otel - lib/java-server-sdk-redis-store diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f0140ec..36edea0 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -12,6 +12,7 @@ jobs: outputs: package-sdk-common-released: ${{ steps.release.outputs['lib/shared/common--release_created'] }} package-sdk-internal-released: ${{ steps.release.outputs['lib/shared/internal--release_created'] }} + package-test-helpers-released: ${{ steps.release.outputs['lib/shared/test-helpers--release_created'] }} package-server-sdk-released: ${{ steps.release.outputs['lib/sdk/server--release_created'] }} package-server-sdk-otel-released: ${{ steps.release.outputs['lib/java-server-sdk-otel--release_created'] }} package-server-sdk-redis-store-released: ${{ steps.release.outputs['lib/java-server-sdk-redis-store--release_created'] }} @@ -188,3 +189,36 @@ jobs: sonatype_password: ${{ env.SONATYPE_PASSWORD }} aws_role: ${{ vars.AWS_ROLE_ARN }} token: ${{ secrets.GITHUB_TOKEN }} + + release-test-helpers: + runs-on: ubuntu-latest + needs: release-please + permissions: + id-token: write + contents: write + pull-requests: write + if: ${{ needs.release-please.outputs.package-test-helpers-released == 'true'}} + steps: + - uses: actions/checkout@v4 + + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.1.0 + name: Get secrets + with: + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + ssm_parameter_pairs: '/production/common/releasing/sonatype/central/username = SONATYPE_USER_NAME, + /production/common/releasing/sonatype/central/password = SONATYPE_PASSWORD, + /production/common/releasing/java/keyId = SIGNING_KEY_ID' + s3_path_pairs: 'launchdarkly-releaser/java/code-signing-keyring.gpg = code-signing-keyring.gpg' + + - uses: ./.github/actions/full-release + with: + workspace_path: lib/shared/test-helpers + dry_run: false + prerelease: false + code_signing_keyring: ${{ github.workspace }}/code-signing-keyring.gpg + signing_key_id: ${{ env.SIGNING_KEY_ID }} + signing_key_passphrase: '' + sonatype_username: ${{ env.SONATYPE_USER_NAME }} + sonatype_password: ${{ env.SONATYPE_PASSWORD }} + aws_role: ${{ vars.AWS_ROLE_ARN }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-helpers.yml b/.github/workflows/test-helpers.yml new file mode 100644 index 0000000..ee4919a --- /dev/null +++ b/.github/workflows/test-helpers.yml @@ -0,0 +1,33 @@ +name: test-helpers + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-helpers: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + java-version: [8, 11, 17, 19] + os: [ubuntu-latest] + include: + - java-version: 11 + os: windows-latest + - java-version: 17 + os: windows-latest + steps: + - uses: actions/checkout@v3 + + - name: Shared CI Steps + uses: ./.github/actions/ci + with: + workspace_path: 'lib/shared/test-helpers' + java_version: ${{ matrix.java-version }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c94dacd..7b37aac 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,5 +3,6 @@ "lib/java-server-sdk-redis-store": "3.0.1", "lib/shared/common": "2.1.2", "lib/shared/internal": "1.5.1", + "lib/shared/test-helpers": "2.0.2", "lib/sdk/server": "7.10.2" } diff --git a/lib/shared/test-helpers/.gitignore b/lib/shared/test-helpers/.gitignore new file mode 100644 index 0000000..454a0b3 --- /dev/null +++ b/lib/shared/test-helpers/.gitignore @@ -0,0 +1,24 @@ +# Eclipse project files +.classpath +.project +.settings + +# Intellij project files +*.iml +*.ipr +*.iws +.idea/ + +#Gradle +.gradletasknamecache +.gradle/ +build/ +bin/ +out/ +classes/ +buildSrc/build +buildSrc/.gradle + +# Test code that gets temporarily copied by our Android CI build +src/androidTest/java/com/launchdarkly/sdk/**/*.java +!src/androidTest/java/com/launchdarkly/sdk/BaseTest.java diff --git a/lib/shared/test-helpers/CHANGELOG.md b/lib/shared/test-helpers/CHANGELOG.md new file mode 100644 index 0000000..a1c69ee --- /dev/null +++ b/lib/shared/test-helpers/CHANGELOG.md @@ -0,0 +1,43 @@ +# Change log + +All notable changes to the project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). + +## [2.0.2] - 2023-06-27 +### Changed: +- Bumping Guava version to incorporate CVE fixes. + +## [2.0.1] - 2022-12-18 +(This release replaces the broken 2.0.0 release, which was accidentally duplicated from 1.3.0.) + +This release improves compatibility of the library with Android code by removing usage of Java 8 APIs that are not supported in Android. It also revises the embedded HTTP mechanism to use a fork of `nanohttpd` rather than the heavier-weight Jetty. + +### Changed: +- All methods that took a `java.time.Duration` now take `long, TimeUnit` instead. +- The `HttpServer` class is now based on a fork of the lightweight `nanohttpd` (https://github.com/launchdarkly-labs/nanohttpd) library. This should work correctly in any server-side Java environment; it has not been validated in Android, but the previous Jetty implementation did not work in Android anyway. + +## [2.0.0] - 2022-11-17 +This release improves compatibility of the library with Android code by removing usage of Java 8 APIs that are not supported in Android. It also revises the embedded HTTP mechanism to use a fork of `nanohttpd` rather than the heavier-weight Jetty. + +### Changed: +- All methods that took a `java.time.Duration` now take `long, TimeUnit` instead. +- The `HttpServer` class is now based on a fork of the lightweight `nanohttpd` (https://github.com/launchdarkly-labs/nanohttpd) library. This should work correctly in any server-side Java environment; it has not been validated in Android, but the previous Jetty implementation did not work in Android anyway. + +## [1.3.0] - 2022-08-29 +### Added: +- `com.launchdarkly.testhelpers.tcptest`: this package is analogous to `httptest` but much simpler, providing a basic TCP listener that can be configured with behaviors like "close connections without sending a response" or "forward the connection to another port". +- `com.launchdarkly.testhelpers.httptest.SpecialHttpConfigurations`: test helpers to validate several standard kinds of HTTP client configurations. + +## [1.2.0] - 2022-07-08 +### Added: +- `TypeBehavior.singletonValueFactory` is a new method that can be used with `TypeBehavior.checkEqualsAndHashCode` to allow testing of types that have interned/singleton values. + +## [1.1.1] - 2022-06-17 +### Fixed: +- Fixed Hamcrest dependency to use `hamcrest-library` rather than `hamcrest-all`, because JUnit (which is commonly used in any unit test code that would also use Hamcrest) has a transitive dependency on `hamcrest-library` and using both would result in duplication on the classpath. + +## [1.1.0] - 2021-07-21 +### Added: +- `Assertions`, `ConcurrentHelpers`, `JsonAssertions`, `TempDir`, `TempFile`, `TypeBehavior`. + +## [1.0.0] - 2021-06-25 +Initial release. diff --git a/lib/shared/test-helpers/CONTRIBUTING.md b/lib/shared/test-helpers/CONTRIBUTING.md new file mode 100644 index 0000000..b07973e --- /dev/null +++ b/lib/shared/test-helpers/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to this project + +## Submitting bug reports and feature requests + +The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launchdarkly/java-core/issues) in the GitHub repository. Bug reports and feature requests specific to this project should be filed in this issue tracker. The SDK team will respond to all newly filed issues within two business days. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Build instructions + +### Prerequisites + +The project builds with [Gradle](https://gradle.org/) and should be built against Java 8. + +### Building + +To build the project without running any tests: +``` +./gradlew jar +``` + +If you wish to clean your working directory between builds, you can clean it by running: +``` +./gradlew clean +``` + +If you wish to use your generated artifact by another Maven/Gradle project, you will likely want to publish the artifact to your local Maven repository so that your other project can access it. +``` +./gradlew publishToMavenLocal +``` + +### Testing + +To build the project and run all unit tests: +``` +./gradlew test +``` diff --git a/lib/shared/test-helpers/LICENSE b/lib/shared/test-helpers/LICENSE new file mode 100644 index 0000000..c27e062 --- /dev/null +++ b/lib/shared/test-helpers/LICENSE @@ -0,0 +1,13 @@ +Copyright 2021 Catamorphic, Co. + +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 + + http://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. \ No newline at end of file diff --git a/lib/shared/test-helpers/NOTICE b/lib/shared/test-helpers/NOTICE new file mode 100644 index 0000000..cd26131 --- /dev/null +++ b/lib/shared/test-helpers/NOTICE @@ -0,0 +1,24 @@ +java-test-helpers +Copyright 2021 Catamorphic, Co. + +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 + + http://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. + +================================================================================ + +This project bundles the following dependencies under the BSD 3-Clause License. +See bundled license files for details. + +- NanoHttpd (core module) + https://github.com/NanoHttpd/nanohttpd + Copyright (c) 2012-2013 by Paul S. Hawke, 2001, 2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias + Files: com.launchdarkly.testhelpers.httptest.nanohttpd.* diff --git a/lib/shared/test-helpers/README.md b/lib/shared/test-helpers/README.md new file mode 100644 index 0000000..f66a84a --- /dev/null +++ b/lib/shared/test-helpers/README.md @@ -0,0 +1,28 @@ +# LaunchDarkly Java Test Helpers + +[![Quality control](https://github.com/launchdarkly/java-test-helpers/actions/workflows/ci.yml/badge.svg)](https://github.com/launchdarkly/java-test-helpers/actions/workflows/ci.yml) +[![Javadocs](http://javadoc.io/badge/com.launchdarkly/java-test-helpers.svg)](http://javadoc.io/doc/com.launchdarkly/java-test-helpers) + +## Overview + +This project centralizes some test support code that is used by LaunchDarkly's Java and Android SDKs and related components, which may be useful in other Java projects. + +See [API documentation](http://javadoc.io/doc/com.launchdarkly/java-test-helpers) for full details. + +## Contributing + +We encourage pull requests and other contributions from the community. See [Contributing](CONTRIBUTING.md). + +## About LaunchDarkly + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates diff --git a/lib/shared/test-helpers/build.gradle.kts b/lib/shared/test-helpers/build.gradle.kts new file mode 100644 index 0000000..27ac7f0 --- /dev/null +++ b/lib/shared/test-helpers/build.gradle.kts @@ -0,0 +1,115 @@ +import java.time.Duration +import org.gradle.external.javadoc.CoreJavadocOptions + +buildscript { + repositories { + mavenCentral() + mavenLocal() + } +} + +plugins { // see Dependencies.kt in buildSrc + Libs.javaBuiltInGradlePlugins.forEach { id(it) } + Libs.javaExtGradlePlugins.forEach { (n, v) -> id(n) version v } +} + +repositories { + mavenLocal() + // Before LaunchDarkly release artifacts get synced to Maven Central they are here along with snapshots: + maven { url = uri("https://oss.sonatype.org/content/groups/public/") } + mavenCentral() +} + +configurations.all { + // check for updates every build for dependencies with: 'changing: true' + resolutionStrategy.cacheChangingModulesFor(0, "seconds") +} + +base { + group = ProjectValues.groupId + archivesBaseName = ProjectValues.artifactId + version = version +} + +java { + withJavadocJar() + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { // see Dependencies.kt in buildSrc + Libs.implementation.forEach { implementation(it) } + Libs.javaTestImplementation.forEach { testImplementation(it) } +} + +checkstyle { + toolVersion = "9.3" + configFile = file("${project.rootDir}/checkstyle.xml") +} + +tasks.checkstyleMain { + // Exclude embedded nanohttpd code from checkstyle + exclude("com/launchdarkly/testhelpers/httptest/nanohttpd/**") +} + +tasks.jar { + manifest { + attributes(mapOf("Implementation-Version" to project.version)) + } + // Include NOTICE file in binary distribution + from(".") { + include("NOTICE") + into("META-INF") + } + // Include nanohttpd license in binary distribution per BSD 3-Clause requirements + from("src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd") { + include("LICENSE.md") + into("META-INF/licenses/nanohttpd") + } +} + +tasks.javadoc { + // Force the Javadoc build to fail if there are any Javadoc warnings. + (options as CoreJavadocOptions).addStringOption("Xwerror") + + // Exclude embedded nanohttpd code from Javadoc generation + exclude("com/launchdarkly/testhelpers/httptest/nanohttpd/**") +} + +helpers.Test.configureTask(tasks.compileTestJava, tasks.test, configurations["testRuntimeClasspath"]) + +helpers.Jacoco.configureTasks( + tasks.jacocoTestReport, + tasks.jacocoTestCoverageVerification +) + +helpers.Idea.configure(idea) + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + + helpers.Pom.standardPom(pom) // see Pom.kt in buildSrc + } + } + repositories { + mavenLocal() + } +} + +nexusPublishing { + clientTimeout.set(Duration.ofMinutes(2)) // we've seen extremely long delays in creating repositories + repositories { + sonatype{ + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + } + } +} + +signing { + setRequired({ findProperty("skipSigning") != "true" }) + sign(publishing.publications["mavenJava"]) +} diff --git a/lib/shared/test-helpers/buildSrc/build.gradle.kts b/lib/shared/test-helpers/buildSrc/build.gradle.kts new file mode 100644 index 0000000..876c922 --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/Dependencies.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 0000000..d94e2e3 --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,44 @@ + +// Centralize dependencies here instead of writing them out in the top-level +// build script(s). + +object Versions { + const val gson = "2.7" + const val guava = "32.0.1-jre" + const val okhttpTls = "4.8.1" + const val hamcrest = "1.3" + const val okhttp = "4.5.0" + const val junit = "4.12" +} + +object PluginVersions { + const val nexusPublish = "1.3.0" +} + +object Libs { + val implementation = listOf( + "com.google.code.gson:gson:${Versions.gson}", + "com.google.guava:guava:${Versions.guava}", + "com.squareup.okhttp3:okhttp-tls:${Versions.okhttpTls}", + "org.hamcrest:hamcrest-library:${Versions.hamcrest}" + ) + + val javaTestImplementation = listOf( + "com.squareup.okhttp3:okhttp:${Versions.okhttp}", + "junit:junit:${Versions.junit}" + ) + + val javaBuiltInGradlePlugins = listOf( + "java", + "java-library", + "checkstyle", + "signing", + "maven-publish", + "idea", + "jacoco" + ) + + val javaExtGradlePlugins = mapOf( + "io.github.gradle-nexus.publish-plugin" to PluginVersions.nexusPublish + ) +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/Idea.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Idea.kt new file mode 100644 index 0000000..c2f85d5 --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Idea.kt @@ -0,0 +1,16 @@ +package helpers + +import org.gradle.api.tasks.TaskProvider +import org.gradle.plugins.ide.idea.model.IdeaModel + +// Idea.configure provides reusable configuration logic for the Idea +// behavior we normally use. + +object Idea { + fun configure(ideaModel: IdeaModel) { + ideaModel.module { + isDownloadJavadoc = true + isDownloadSources = true + } + } +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/Jacoco.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Jacoco.kt new file mode 100644 index 0000000..aa8267a --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Jacoco.kt @@ -0,0 +1,52 @@ +package helpers + +import org.gradle.api.tasks.TaskProvider +import org.gradle.testing.jacoco.tasks.JacocoReport +import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification + +// Jacoco.configureTasks provides reusable configuration logic for using the Jacoco +// test coverage plugin in a Java project. See also: TestCoverageOverrides.kt + +object Jacoco { + fun configureTasks(reportTask: TaskProvider, + verificationTask: TaskProvider) { + reportTask.configure { + reports { + xml.required.set(false) + csv.required.set(false) + html.required.set(true) + } + } + + verificationTask.configure { + // See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure line-by-line code + // coverage overrides within the source code itself, because Jacoco operates on bytecode. + violationRules { + TestCoverageOverrides.methodsWithMissedLineCount.forEach { signature, maxMissedLines -> + rule { + element = "METHOD" + includes = listOf(signature) + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = maxMissedLines.toBigDecimal() + } + } + } + + // General rule that we should expect 100% test coverage; exclude any methods that + // have overrides in TestCoverageOverrides. + rule { + element = "METHOD" + limit { + counter = "LINE" + value = "MISSEDCOUNT" + maximum = 0.toBigDecimal() + } + excludes = TestCoverageOverrides.methodsWithMissedLineCount.map { it.key } + + TestCoverageOverrides.methodsToSkip + } + } + } + } +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/Javadoc.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Javadoc.kt new file mode 100644 index 0000000..2549ebc --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Javadoc.kt @@ -0,0 +1,21 @@ +package helpers + +import org.gradle.api.artifacts.Configuration +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.javadoc.Javadoc +import org.gradle.external.javadoc.CoreJavadocOptions + +object Javadoc { + fun configureTask(javadocTask: TaskProvider, classpathConfig: Configuration?) { + javadocTask.configure { + // Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 + // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) + // for information about the -Xwerror option. + (options as CoreJavadocOptions).addStringOption("Xwerror") + + if (classpathConfig != null) { + classpath += classpathConfig + } + } + } +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/Pom.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Pom.kt new file mode 100644 index 0000000..ac9906e --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Pom.kt @@ -0,0 +1,31 @@ +package helpers + +import org.gradle.api.publish.maven.MavenPom + +// Pom.standardPom provides reusable logic for setting the pom.xml properties +// of LaunchDarkly packages. It gets its values from ProjectValues.kt. + +object Pom { + fun standardPom(pom: MavenPom) { + pom.name.set(ProjectValues.artifactId) + pom.description.set(ProjectValues.description) + pom.url.set("https://github.com/${ProjectValues.githubRepo}") + pom.licenses { + license { + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + name.set("The Apache License, Version 2.0") + } + } + pom.developers { + developer { + name.set(ProjectValues.pomDeveloperName) + email.set(ProjectValues.pomDeveloperEmail) + } + } + pom.scm { + connection.set("scm:git:git://github.com/${ProjectValues.githubRepo}.git") + developerConnection.set("scm:git:ssh:git@github.com:${ProjectValues.githubRepo}.git") + url.set("https://github.com/${ProjectValues.githubRepo}") + } + } +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/ProjectValues.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/ProjectValues.kt new file mode 100644 index 0000000..a6fae71 --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/ProjectValues.kt @@ -0,0 +1,15 @@ + +// This file defines basic properties of the project that are used in the +// build script and the helper code. + +object ProjectValues { + const val groupId = "com.launchdarkly" + const val artifactId = "test-helpers" + const val description = "LaunchDarkly Java test helpers" + const val githubRepo = "launchdarkly/java-core" + + const val sdkBasePackage = "com.launchdarkly.testhelpers" + + const val pomDeveloperName = "LaunchDarkly SDK Team" + const val pomDeveloperEmail = "sdks@launchdarkly.com" +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/Test.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Test.kt new file mode 100644 index 0000000..c213db5 --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/Test.kt @@ -0,0 +1,34 @@ +package helpers + +import org.gradle.api.artifacts.Configuration +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.tasks.testing.Test +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +// Test.configureTask provides reusable configuration logic for the Java test +// behavior we normally use. + +object Test { + fun configureTask(compileTestTask: TaskProvider, testTask: TaskProvider, + classpathConfig: Configuration?) { + + compileTestTask.configure { + if (classpathConfig != null) { + classpath += classpathConfig + } + } + + testTask.configure { + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + exceptionFormat = TestExceptionFormat.FULL + } + + if (classpathConfig != null) { + classpath += classpathConfig + } + } + } +} diff --git a/lib/shared/test-helpers/buildSrc/src/main/kotlin/TestCoverageOverrides.kt b/lib/shared/test-helpers/buildSrc/src/main/kotlin/TestCoverageOverrides.kt new file mode 100644 index 0000000..697eef6 --- /dev/null +++ b/lib/shared/test-helpers/buildSrc/src/main/kotlin/TestCoverageOverrides.kt @@ -0,0 +1,30 @@ + +// See notes in CONTRIBUTING.md on code coverage. Unfortunately we can't configure +// line-by-line code coverage overrides within the source code itself, because Jacoco +// operates on bytecode. + +// These values are used by helpers/Jacoco.kt. + +object TestCoverageOverrides { + val prefixForAllMethodSignatures = ProjectValues.sdkBasePackage + "." + + // Each entry in methodsWithMissedLineCount is an override to tell the Jacoco plugin + // that we're aware of a gap in our test coverage and are OK with it. In each entry, + // the key is the method signature and the value is the number of lines that we + // expect Jacoco to report as missed. + val methodsWithMissedLineCount = mapOf( + "EvaluationReason.error(com.launchdarkly.sdk.EvaluationReason.ErrorKind)" to 1, + "EvaluationReasonTypeAdapter.parse(com.google.gson.stream.JsonReader)" to 1, + "LDContext.urlEncodeKey(java.lang.String)" to 2, + "LDValue.equals(java.lang.Object)" to 1, + "LDValueTypeAdapter.read(com.google.gson.stream.JsonReader)" to 1, + "json.LDGson.LDTypeAdapter.write(com.google.gson.stream.JsonWriter, java.lang.Object)" to 1, + "json.LDJackson.GsonReaderToJacksonParserAdapter.peekInternal()" to 3 + ).mapKeys { prefixForAllMethodSignatures + it.key } + + // Each entry in methodsToSkip is an override to tell the Jacoco plugin to ignore + // code coverage in the method with the specified signature. + val methodsToSkip = listOf( + "json.JsonSerialization.getDeserializableClasses()" + ).map { prefixForAllMethodSignatures + it } +} diff --git a/lib/shared/test-helpers/checkstyle.xml b/lib/shared/test-helpers/checkstyle.xml new file mode 100644 index 0000000..0101956 --- /dev/null +++ b/lib/shared/test-helpers/checkstyle.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/shared/test-helpers/gradle.properties b/lib/shared/test-helpers/gradle.properties new file mode 100644 index 0000000..b4db4c7 --- /dev/null +++ b/lib/shared/test-helpers/gradle.properties @@ -0,0 +1,3 @@ +# x-release-please-start-version +version=2.0.2 +# x-release-please-end diff --git a/lib/shared/test-helpers/gradle/wrapper/gradle-wrapper.jar b/lib/shared/test-helpers/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/lib/shared/test-helpers/gradle/wrapper/gradle-wrapper.jar differ diff --git a/lib/shared/test-helpers/gradle/wrapper/gradle-wrapper.properties b/lib/shared/test-helpers/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..070cb70 --- /dev/null +++ b/lib/shared/test-helpers/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lib/shared/test-helpers/gradlew b/lib/shared/test-helpers/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/lib/shared/test-helpers/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lib/shared/test-helpers/gradlew.bat b/lib/shared/test-helpers/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/lib/shared/test-helpers/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/shared/test-helpers/settings.gradle.kts b/lib/shared/test-helpers/settings.gradle.kts new file mode 100644 index 0000000..ac84ee4 --- /dev/null +++ b/lib/shared/test-helpers/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "test-helpers" diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/Assertions.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/Assertions.java new file mode 100644 index 0000000..f7c31fd --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/Assertions.java @@ -0,0 +1,51 @@ +package com.launchdarkly.testhelpers; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static com.launchdarkly.testhelpers.InternalHelpers.timeUnit; + +/** + * General test assertions that may be helpful in unit tests. + * + * For more specific categories of assertions, see {@link ConcurrentHelpers}, + * {@link JsonAssertions}, and {@link TypeBehavior}. + * + * @since 1.1.0 + */ +public abstract class Assertions { + /** + * Repeatedly calls a function until it returns a non-null value or until a timeout elapses, + * whichever comes first. + * + * @param the return type + * @param timeout maximum time to wait + * @param timeoutUnit time unit for timeout (null defaults to milliseconds) + * @param interval how often to call the function + * @param intervalUnit time unit for interval (null defaults to milliseconds) + * @param fn the function to call + * @return the function's return value + * @throws AssertionError if the function did not return a non-null value before the timeout + */ + public static T assertPolledFunctionReturnsValue( + long timeout, + TimeUnit timeoutUnit, + long interval, + TimeUnit intervalUnit, + Supplier fn + ) { + long deadline = System.currentTimeMillis() + timeUnit(timeoutUnit).toMillis(timeout); + while (System.currentTimeMillis() < deadline) { + T result = fn.get(); + if (result != null) { + return result; + } + try { + Thread.sleep(timeUnit(intervalUnit).toMillis(interval)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + throw new AssertionError("timed out after " + timeout); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/ConcurrentHelpers.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/ConcurrentHelpers.java new file mode 100644 index 0000000..c5ee3b1 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/ConcurrentHelpers.java @@ -0,0 +1,185 @@ +package com.launchdarkly.testhelpers; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static com.launchdarkly.testhelpers.InternalHelpers.timeDesc; +import static com.launchdarkly.testhelpers.InternalHelpers.timeUnit; + +/** + * Helper methods and test assertions related to concurrent data structures. + * + * @since 1.1.0 + */ +public abstract class ConcurrentHelpers { + /** + * Asserts that a future is completed within the specified timeout. + * + * @param the future's value type + * @param future the future + * @param timeout the maximum time to wait + * @param timeoutUnit the time unit for the timeout (null defaults to milliseconds) + * @return the completed value + * @throws AssertionError if the timeout expires + */ + public static T assertFutureIsCompleted(Future future, long timeout, TimeUnit timeoutUnit) { + try { + return future.get(timeout, timeUnit(timeoutUnit)); + } catch (TimeoutException e) { + throw new AssertionError("Future was not completed within " + timeDesc(timeout, timeoutUnit)); + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Equivalent to {@link #assertFutureIsCompleted(Future, long, TimeUnit)}, but as a Hamcrest matcher. + * + * @param the future's value type + * @param timeout the maximum time to wait + * @param timeoutUnit the time unit for the timeout (null defaults to milliseconds) + * @return a matcher + */ + public static Matcher> isCompletedWithin(long timeout, TimeUnit timeoutUnit) { + return new TypeSafeDiagnosingMatcher>() { + @Override + public void describeTo(Description description) { + description.appendText("Future is completed within " + timeDesc(timeout, timeoutUnit)); + } + + @Override + protected boolean matchesSafely(Future item, Description mismatchDescription) { + try { + item.get(timeout, timeUnit(timeoutUnit)); + return true; + } catch (TimeoutException e) { + mismatchDescription.appendText("timed out"); + return false; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + }; + } + + /** + * Asserts that a future is completed within the specified timeout. + * + * @param the future's value type + * @param future the future + * @param timeout the maximum time to wait + * @param timeoutUnit the time unit for the timeout (null defaults to milliseconds) + * @throws AssertionError if the future is completed + */ + public static void assertFutureIsNotCompleted(Future future, long timeout, TimeUnit timeoutUnit) { + try { + T value = future.get(timeout, timeUnit(timeoutUnit)); + throw new AssertionError("Future was unexpectedly completed with value: " + value); + } catch (TimeoutException e) { + return; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Equivalent to {@link #assertFutureIsNotCompleted(Future, long, TimeUnit)}, but as a Hamcrest matcher. + * + * @param the future's value type + * @param timeout the maximum time to wait + * @param timeoutUnit the time unit for the timeout (null defaults to milliseconds) + * @return a matcher + */ + public static Matcher> isNotCompletedWithin(long timeout, TimeUnit timeoutUnit) { + return new TypeSafeDiagnosingMatcher>() { + @Override + public void describeTo(Description description) { + description.appendText("Future is not completed within " + timeDesc(timeout, timeoutUnit)); + } + + @Override + protected boolean matchesSafely(Future item, Description mismatchDescription) { + try { + T value = item.get(timeout, timeUnit(timeoutUnit)); + mismatchDescription.appendText("unexpectedly completed with value: " + value); + return false; + } catch (TimeoutException e) { + return true; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + }; + } + + /** + * Waits for a value to be available from a {@code BlockingQueue} and consumes the value. + * + * @param the value type + * @param values the queue + * @param timeout the maximum time to wait + * @param timeoutUnit the time unit for the timeout (null defaults to milliseconds) + * @return the value obtained from the queue + * @throws AssertionError if the timeout expires + */ + public static T awaitValue(BlockingQueue values, long timeout, TimeUnit timeoutUnit) { + try { + T value = values.poll(timeout, timeUnit(timeoutUnit)); + if (value == null) { + throw new AssertionError("did not receive a value within " + timeDesc(timeout, timeoutUnit)); + } + return value; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Asserts that no values are available fro a queue within the specified timeout. + * + * @param the value type + * @param values the queue + * @param timeout the maximum time to wait + * @param timeoutUnit the time unit for the timeout (null defaults to milliseconds) + * @throws AssertionError if a value was available from the queue + */ + public static void assertNoMoreValues(BlockingQueue values, long timeout, TimeUnit timeoutUnit) { + try { + T value = values.poll(timeout, timeUnit(timeoutUnit)); + if (value != null) { + throw new AssertionError("expected no more values, but received: " + value); + } + } catch (InterruptedException e) {} + } + + /** + * Shortcut for calling {@code Thread.sleep()} when an {@code InterruptedException} is not + * expected, so you do not have to catch it. + * + * @param delay the length of time to wait + * @param delayUnit the time unit for the delay (null defaults to milliseconds) + * @throws RuntimeException if an {@code InterruptedException} unexpectedly happened + */ + public static void trySleep(long delay, TimeUnit delayUnit) { + try { + Thread.sleep(timeUnit(delayUnit).toMillis(delay)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/InternalHelpers.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/InternalHelpers.java new file mode 100644 index 0000000..8ed3ca7 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/InternalHelpers.java @@ -0,0 +1,14 @@ +package com.launchdarkly.testhelpers; + +import java.util.concurrent.TimeUnit; + +abstract class InternalHelpers { + static TimeUnit timeUnit(TimeUnit unit) { + return unit == null ? TimeUnit.MILLISECONDS : unit; + } + + static String timeDesc(long value, TimeUnit unit) { + String unitName = timeUnit(unit).name().toLowerCase(); + return String.format("%d %s", value, value == 1 ? unitName.substring(0, unitName.length() - 1) : unitName); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/JsonAssertions.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/JsonAssertions.java new file mode 100644 index 0000000..06b803b --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/JsonAssertions.java @@ -0,0 +1,482 @@ +package com.launchdarkly.testhelpers; + +import com.google.common.base.Joiner; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonFromValue; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonOf; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Test assertions and matchers related to JSON. + *

+ * The {@code assert} methods here provide simple assertions for strings that are assumed + * to contain JSON. + *

+ * The other methods are factories for type-safe Hamcrest matchers. These are much more + * flexible, as you can use standard Hamcrest combinators like {@code allOf} or {@code not}. + * These use {@link JsonTestValue} as their type parameter, to prevent confusion between + * test code that operates on JSON strings and test code that operates on other kinds of + * strings. {@link JsonTestValue} is easily convertible from strings or other types; + * see {@link JsonTestValue#jsonOf(String)} and {@link JsonTestValue#jsonFromValue(Object)}. + *

+ * Examples: + *


+ *     // check for the exact JSON properties {"a": 1, "b": 2} in any order
+ *     assertThat(jsonOf(myString), jsonEquals("{\"a\":1, \"b\": 2}");
+ *     
+ *     // check that a JSON object's property "p" is equal to a specific boolean value
+ *     assertThat(jsonOf(myString), jsonProperty("p", someBooleanValue));
+ *     
+ *     // check that a JSON object's property "p" is either null or omitted
+ *     assertThat(jsonOf(myString),
+ *         jsonProperty("p", anyOf(jsonNull(), jsonUndefined())));
+ *     
+ *     // check that a JSON object's property "p" is an array containing a specific value
+ *     assertThat(jsonOf(myString),
+ *         jsonProperty("p", isJsonArray(hasItem(jsonEqualsValue(someValue)))));
+ * 
+ *

+ * When comparing unequal JSON objects or arrays, these methods will do their best to + * show you a localized difference such as a specific property, rather than only showing + * the entire actual and expected values. + * + * @since 1.1.0 + */ +public abstract class JsonAssertions { + /** + * Parses two strings as JSON and compares them for deep equality. If they are unequal, + * it tries to describe the difference as specifically as possible by recursing into + * object properties or array elements. + * + * @param expected the expected JSON string + * @param actual the actual JSON string + * @throws AssertionError if the values are not deeply equal, or are not valid JSON + */ + public static void assertJsonEquals(String expected, String actual) { + assertThat(jsonOf(actual), jsonEquals(jsonOf(expected))); + } + + /** + * Equivalent to {@link #assertJsonEquals(String, String)}, but as a typed matcher. + * + * @param expected the expected JSON value + * @return a matcher + */ + public static Matcher jsonEquals(final JsonTestValue expected) { + checkNotNull(expected, "expected"); + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("JSON is equal to: " + expected); + } + + @Override + protected boolean matchesSafely(JsonTestValue actual, Description mismatchDescription) { + if (!actual.isDefined()) { + if (!expected.isDefined()) { + return true; + } + mismatchDescription.appendValue(actual); + return false; + } + if (!expected.isDefined()) { + mismatchDescription.appendValue(expected); + return false; + } + if (actual.parsed.equals(expected.parsed)) { + return true; + } + String diff = describeJsonDifference(expected.parsed, actual.parsed, "", false); + if (diff == null) { + diff = "expected: " + expected + "\nactual: " + actual.raw; + } else { + diff = diff + "\nfull JSON was: " + actual.raw; + } + mismatchDescription.appendText(diff); + return false; + } + }; + } + + /** + * Equivalent to {@code jsonEquals(JsonTestValue.jsonOf(expected))}. + * + * @param expected the expected JSON as a string + * @return a matcher + */ + public static Matcher jsonEquals(String expected) { + return jsonEquals(jsonOf(expected)); + } + + /** + * Equivalent to {@code jsonEquals(JsonTestValue.jsonFromValue(expected))}. + * + * @param expected a value that will be serialized to JSON and matched + * @return a matcher + */ + public static Matcher jsonEqualsValue(Object expected) { + return jsonEquals(jsonFromValue(expected)); + } + + /** + * Same as {@link #assertJsonEquals(String, String)} except that it allows any JSON + * objects in the actual data to contain extra properties that are not in the expected + * data. + * + * @param expected the expected JSON string + * @param actual the actual JSON string + * @throws AssertionError if the expected values are not a subset of the actual + * values, or if the strings are not valid JSON + */ + public static void assertJsonIncludes(String expected, String actual) { + assertThat(jsonOf(actual), jsonIncludes(expected)); + } + + /** + * Equivalent to {@link #assertJsonIncludes(String, String)}, but as a Hamcrest matcher. + * + * @param expected the expected JSON object properties + * @return a string matcher + */ + public static Matcher jsonIncludes(JsonTestValue expected) { + checkNotNull(expected, "expected"); + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("includes these JSON properties: " + expected); + } + + @Override + protected boolean matchesSafely(JsonTestValue actual, Description mismatchDescription) { + if (!actual.isDefined()) { + if (!expected.isDefined()) { + return true; + } + mismatchDescription.appendValue(actual); + return false; + } + if (!expected.isDefined()) { + mismatchDescription.appendValue(expected); + return false; + } + if (isJsonSubset(expected.parsed, actual.parsed)) { + return true; + } + String diff = describeJsonDifference(expected.parsed, actual.parsed, "", true); + if (diff == null) { + diff = "expected: " + expected + "\nactual: " + actual.raw; + } else { + diff = diff + "\nfull JSON was: " + actual.raw; + } + mismatchDescription.appendText(diff); + return false; + } + }; + } + + /** + * Equivalent to {@code jsonIncludes(JsonTestValue.jsonOf(expected))}. + * + * @param expected the expected JSON as a string + * @return a matcher + */ + public static Matcher jsonIncludes(String expected) { + return jsonIncludes(jsonOf(expected)); + } + + /** + * A matcher that verifies that the input value is a JSON null. This is equivalent to + * {@code jsonEquals(JsonTestValue.jsonOf("null"))}. + * + * @return a matcher + */ + public static Matcher jsonNull() { + return jsonEquals(jsonOf("null")); + } + + /** + * A matcher that verifies that the input value is completely undefined (as opposed to + * being a JSON null). + * + * @return a matcher + */ + public static Matcher jsonUndefined() { + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("is undefined"); + } + + @Override + protected boolean matchesSafely(JsonTestValue item, Description mismatchDescription) { + if (item == null || !item.isDefined()) { + return true; + } + mismatchDescription.appendText("had value: " + item.raw); + return false; + } + }; + } + + /** + * A matcher that verifies that the input value is an object that has a property with + * the specified name, and that the property value matches the specified matcher. + * + * @param name the property name + * @param matcher a matcher for the property value + * @return a matcher + */ + + public static Matcher jsonProperty(final String name, final Matcher matcher) { + checkNotNull(name, "name"); + checkNotNull(matcher, "matcher"); + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText(String.format("property \"%s\": ", name)); + matcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(JsonTestValue actual, Description mismatchDescription) { + if (!actual.isDefined()) { + mismatchDescription.appendValue(actual); + return false; + } + if (actual.parsed instanceof JsonObject) { + JsonTestValue propValue = JsonTestValue.ofParsed(((JsonObject)actual.parsed).get(name)); + if (!matcher.matches(propValue)) { + matcher.describeMismatch(propValue, mismatchDescription); + return false; + } + return true; + } + mismatchDescription.appendText("not a JSON object: ").appendText(actual.raw); + return false; + } + }; + } + + /** + * A shortcut for using {@link #jsonProperty} with {@link #jsonEquals(JsonTestValue)}. + * + * @param name a property name + * @param value the desired value + * @return a matcher + */ + public static Matcher jsonProperty(String name, JsonTestValue value) { + return jsonProperty(name, jsonEquals(value)); + } + + /** + * A shortcut for using {@link #jsonProperty(String, JsonTestValue)} with + * {@link JsonTestValue#jsonFromValue(Object)}. + * + * @param name a property name + * @param value a value that will be converted to JSON + * @return a matcher + */ + public static Matcher jsonProperty(String name, boolean value) { + return jsonProperty(name, jsonFromValue(value)); + } + + /** + * A shortcut for using {@link #jsonProperty(String, JsonTestValue)} with + * {@link JsonTestValue#jsonFromValue(Object)}. + * + * @param name a property name + * @param value a value that will be converted to JSON + * @return a matcher + */ + public static Matcher jsonProperty(String name, int value) { + return jsonProperty(name, jsonFromValue(value)); + } + + /** + * A shortcut for using {@link #jsonProperty(String, JsonTestValue)} with + * {@link JsonTestValue#jsonFromValue(Object)}. + * + * @param name a property name + * @param value a value that will be converted to JSON + * @return a matcher + */ + public static Matcher jsonProperty(String name, double value) { + return jsonProperty(name, jsonFromValue(value)); + } + + /** + * A shortcut for using {@link #jsonProperty(String, JsonTestValue)} with + * {@link JsonTestValue#jsonFromValue(Object)}. + * + * @param name a property name + * @param value a value that will be converted to JSON + * @return a matcher + */ + public static Matcher jsonProperty(String name, String value) { + return jsonProperty(name, jsonFromValue(value)); + } + + /** + * A matcher that verifies that the input value is an array whose elements match the + * specified matchers. + * + * @param elementsMatcher a matcher for the contents of the array + * @return a matcher + */ + public static Matcher isJsonArray(final Matcher> elementsMatcher) { + checkNotNull(elementsMatcher, "elementsMatcher"); + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("JSON array: "); + elementsMatcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(JsonTestValue actual, Description mismatchDescription) { + if (!actual.isDefined()) { + mismatchDescription.appendValue(actual); + return false; + } + if (actual.parsed instanceof JsonArray) { + List values = new ArrayList<>(); + for (JsonElement element: (JsonArray)actual.parsed) { + values.add(JsonTestValue.ofParsed(element)); + } + if (!elementsMatcher.matches(values)) { + elementsMatcher.describeMismatch(values, mismatchDescription); + return false; + } + return true; + } + mismatchDescription.appendText("not a JSON array: ").appendText(actual.raw); + return false; + } + }; + } + + private static boolean isJsonSubset(JsonElement expected, JsonElement actual) { + if (expected instanceof JsonObject && actual instanceof JsonObject) { + JsonObject eo = (JsonObject)expected, ao = (JsonObject)actual; + for (Map.Entry e: eo.entrySet()) { + if (!ao.has(e.getKey()) || !isJsonSubset(e.getValue(), ao.get(e.getKey()))) { + return false; + } + } + return true; + } + if (expected instanceof JsonArray && actual instanceof JsonArray) { + JsonArray ea = (JsonArray)expected, aa = (JsonArray)actual; + if (ea.size() != aa.size()) { + return false; + } + for (int i = 0; i < ea.size(); i++) { + if (!isJsonSubset(ea.get(i), aa.get(i))) { + return false; + } + } + return true; + } + return actual.equals(expected); + } + + private static String describeJsonDifference( + JsonElement expected, + JsonElement actual, + String prefix, + boolean allowExtraProps + ) { + if (actual instanceof JsonObject && expected instanceof JsonObject) { + return describeJsonObjectDifference((JsonObject)expected, (JsonObject)actual, prefix, allowExtraProps); + } + if (actual instanceof JsonArray && expected instanceof JsonArray) { + return describeJsonArrayDifference((JsonArray)expected, (JsonArray)actual, prefix, allowExtraProps); + } + return null; + } + + private static String describeJsonObjectDifference( + JsonObject expected, + JsonObject actual, + String prefix, + boolean allowExtraProps + ) { + List diffs = new ArrayList<>(); + Set allKeys = new HashSet<>(); + for (Map.Entry e: expected.entrySet()) { + allKeys.add(e.getKey()); + } + for (Map.Entry e: actual.entrySet()) { + allKeys.add(e.getKey()); + } + for (String key: allKeys) { + String prefixedKey = prefix + (prefix == "" ? "" : ".") + key; + String expectedDesc = null, actualDesc = null, detailDiff = null; + if (expected.has(key)) { + if (actual.has(key)) { + JsonElement actualValue = actual.get(key), expectedValue = expected.get(key); + if (!actualValue.equals(expectedValue)) { + expectedDesc = expectedValue.toString(); + actualDesc = actualValue.toString(); + detailDiff = describeJsonDifference(expectedValue, actualValue, prefixedKey, allowExtraProps); + } + } else { + expectedDesc = expected.get(key).toString(); + actualDesc = ""; + } + } else if (!allowExtraProps) { + actualDesc = actual.get(key).toString(); + expectedDesc = ""; + } + if (expectedDesc != null || actualDesc != null) { + if (detailDiff != null) { + diffs.add(detailDiff); + } else { + diffs.add(String.format("at \"%s\": expected = %s, actual = %s", prefixedKey, + expectedDesc, actualDesc)); + } + } + } + return Joiner.on("\n").join(diffs); + } + + private static String describeJsonArrayDifference( + JsonArray expected, + JsonArray actual, + String prefix, + boolean allowExtraProps + ) { + if (expected.size() != actual.size()) { + return null; // can't provide a detailed diff, just show the whole values + } + List diffs = new ArrayList<>(); + for (int i = 0; i < expected.size(); i++) { + String prefixedIndex = String.format("%s[%d]", prefix, i); + JsonElement actualValue = actual.get(i), expectedValue = expected.get(i); + if (!actualValue.equals(expectedValue)) { + String detailDiff = describeJsonDifference(expectedValue, actualValue, prefixedIndex, allowExtraProps); + if (detailDiff != null) { + diffs.add(detailDiff); + } else { + diffs.add(String.format("at \"%s\": expected = %s, actual = %s", prefixedIndex, + expectedValue.toString(), actualValue.toString())); + } + } + } + return Joiner.on("\n").join(diffs); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/JsonTestValue.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/JsonTestValue.java new file mode 100644 index 0000000..ce81f68 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/JsonTestValue.java @@ -0,0 +1,77 @@ +package com.launchdarkly.testhelpers; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +/** + * A simple wrapper for a string that can be parsed as JSON for tests. + *

+ * This class provides strong typing so that it is clear when test matchers apply to JSON + * values versus strings, and hides the implementation details of parsing and serialization + * which are not relevant to the test logic. + * + * @see JsonAssertions + * @since 1.1.0 + */ +public final class JsonTestValue { + private static final Gson gson = new Gson(); + + final String raw; + final JsonElement parsed; + + private JsonTestValue(String raw, JsonElement parsed) { + this.raw = raw; + this.parsed = parsed; + } + + /** + * Creates a {@code JsonTestValue} from a string that should contain JSON. + *

+ * This method fails immediately for any string that is not well-formed JSON. However, if + * it is a null reference, it returns an "undefined" instance that will return {@code false} + * from {@link #isDefined()}. + * + * @param raw the input string + * @return a {@code JsonTestValue} + * @throws AssertionError for malformed JSON + */ + public static JsonTestValue jsonOf(String raw) { + if (raw == null) { + return new JsonTestValue(null, null); + } + try { + return new JsonTestValue(raw, gson.fromJson(raw, JsonElement.class)); + } catch (Exception e) { + throw new AssertionError("not valid JSON (" + e + "): " + raw); + } + } + + static JsonTestValue ofParsed(JsonElement json) { + return new JsonTestValue(json == null ? null : gson.toJson(json), json); + } + + /** + * Creates a {@code JsonTestValue} by serializing an arbitrary value to JSON. For + * instance, {@code jsonFromValue(true)} is equivalent to {@code jsonOf("true")}. + * + * @param value an arbitrary value + * @return a {@code JsonTestValue} + */ + public static JsonTestValue jsonFromValue(Object value) { + return ofParsed(gson.toJsonTree(value)); + } + + @Override + public String toString() { + return raw == null ? "" : raw; + } + + /** + * Returns true if there is a value (that is, the original string was not a null reference). + * + * @return true if defined + */ + public boolean isDefined() { + return raw != null; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TempDir.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TempDir.java new file mode 100644 index 0000000..499e3ca --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TempDir.java @@ -0,0 +1,115 @@ +package com.launchdarkly.testhelpers; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; + +/** + * Provides a temporary directory for use by a test. The {@link #close} method deletes + * the directory, so a try-with-resources block will ensure that it is cleaned up. + *

+ * All methods that could cause an IOException will throw it as a RuntimeException + * instead, so tests do not need to catch IOException. + * + *


+ *     try (TempDir dir = TempDir.create()) {
+ *         TempFile f = dir.tempFile(".txt");
+ *         f.setContents("test data");
+ *     }
+ * 
+ * + * All IOExceptions are rethrown as RuntimeExceptions so that the test code does + * not need to catch or declare them. + * + * @see TempDir + * @since 1.1.0 + */ +public final class TempDir implements AutoCloseable { + private final Path path; + + private TempDir(Path path) { + this.path = path; + } + + /** + * Creates a temporary directory. + * + * @return a directory object + */ + public static TempDir create() { + try { + return new TempDir(Files.createTempDirectory("java-sdk-tests")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the directory path. + * + * @return a path + */ + public Path getPath() { + return path; + } + + /** + * Calls {@link #delete()} if the directory still exists. + */ + @Override + public void close() { + if (Files.exists(path)) { + delete(); + } + } + + /** + * Deletes the directory and all its contents. + */ + public void delete() { + try { + Files.walkFileTree(path, + new SimpleFileVisitor() { + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Creates a temporary file within the directory. + * + * @return a file object + */ + public TempFile tempFile() { + return tempFile(""); + } + + /** + * Creates a temporary file within the directory. + * + * @param suffix optional filename suffix, may be empty + * @return a file object + */ + public TempFile tempFile(String suffix) { + try { + return new TempFile(Files.createTempFile(path, "java-sdk-tests", suffix)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TempFile.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TempFile.java new file mode 100644 index 0000000..182c476 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TempFile.java @@ -0,0 +1,94 @@ +package com.launchdarkly.testhelpers; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Provides a temporary file for use by a test. The {@link #close} method deletes + * the directory, so a try-with-resources block will ensure that it is cleaned up. + * + *

+ *     try (TempFile f = TempFile.create(".txt") {
+ *         f.setContents("test data");
+ *     }
+ * 
+ * + * All IOExceptions are rethrown as RuntimeExceptions so that the test code does + * not need to catch or declare them. + * + * @see TempDir + * @since 1.1.0 + */ +public final class TempFile implements AutoCloseable { + private final Path path; + + TempFile(Path path) { + this.path = path; + } + + /** + * Creates a temporary file in the default directory for temporary files. + * + * @return a file object + * @see TempDir#tempFile(String) + */ + public static TempFile create() { + return create(""); + } + + /** + * Creates a temporary file in the default directory for temporary files. + * + * @param suffix optional filename suffix, may be empty + * @return a file object + * @see TempDir#tempFile(String) + */ + public static TempFile create(String suffix) { + try { + return new TempFile(Files.createTempFile("", suffix)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public void close() { + if (Files.exists(path)) { + delete(); + } + } + + /** + * Returns the file path. + * + * @return the file path + */ + public Path getPath() { + return path; + } + + /** + * Deletes the file. + */ + public void delete() { + try { + Files.delete(path); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Replaces the file's contents with the specified data (in UTF-8 encoding). + * + * @param content the new content + */ + public void setContents(String content) { + try { + Files.write(path, content.getBytes("UTF-8")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TypeBehavior.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TypeBehavior.java new file mode 100644 index 0000000..ba0c7a0 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/TypeBehavior.java @@ -0,0 +1,170 @@ +package com.launchdarkly.testhelpers; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Test assertions that may be helpful in testing generic type behavior. + * + * @since 1.1.0 + */ +public abstract class TypeBehavior { + /** + * A supplier interface for use {@link #checkEqualsAndHashCode(List)}. + * + * @param the value type + */ + public interface ValueFactory { + /** + * Returns a new instance of the value type. + * + * @return an instance + */ + T get(); + } + + private static class SingletonValueFactory implements ValueFactory { + private final T value; + + SingletonValueFactory(T value) { + this.value = value; + } + + public T get() { + return value; + } + } + + /** + * Creates a simple {@link ValueFactory} that returns the specified instances in order + * each time it is called. After all instances are used, it starts over at the first. + * This is for use with {@link #checkEqualsAndHashCode(List)}. + * + * @param the value type + * @param values the instances + * @return a value factory + */ + @SuppressWarnings("unchecked") + public static ValueFactory valueFactoryFromInstances(T...values) { + AtomicInteger counter = new AtomicInteger(0); + return () -> { + int i = counter.getAndIncrement(); + if (counter.get() >= values.length) { + counter.set(0); + } + return values[i]; + }; + } + + /** + * Creates a simple {@link ValueFactory} that returns the sameinstance each time it + * is called. After all instances are used, it starts over at the first. This is for use + * with {@link #checkEqualsAndHashCode(List)}, and you should use it instead of + * a lambda like {@code () -> value} whenever the type enforces singleton usage, because + * otherwise {@link #checkEqualsAndHashCode(List)} will expect the return values to be + * equal only value and not by reference. + * + * @param the value type + * @param value the instance + * @return a value factory + */ + public static ValueFactory singletonValueFactory(T value) { + return new SingletonValueFactory<>(value); + } + + /** + * Implements a standard test suite for custom implementations of {@code equals()} and + * {@code hashCode()}. + *

+ * The {@code valueFactories} parameter is a list of value factories. Each factory must + * produce only instances that are equal to each other, and not equal to the instances + * produced by any of the other factories. The test suite verifies the following: + *

    + *
  • For any instance {@code a} created by any of the factories, {@code a.equals(a)} + * is true, {@code a.equals(null)} is false, and {@code a.equals(x)} where {@code x} is + * an instance of a different class is false.
  • + *
  • For any two instances {@code a} and {@code b} created by the same factory, + * {@code a.equals(b)}, {@code b.equals(a)}, and {@code a.hashCode() == b.hashCode()} + * are all true.
  • + *
  • For any two instances {@code a} and {@code b} created by different factories, + * {@code a.equals(b)} and {@code b.equals(a)} are false (there is no requirement that + * the hash codes are different).
  • + *
+ *

+ * If the type uses a singleton/interning pattern so that there can only be one + * instance with a particular value, use {@link #singletonValueFactory(Object)} to + * indicate that that is deliberate; otherwise {@link #checkEqualsAndHashCode(List)} + * will assume that it is a test logic error if it sees the same instance twice. + * + * @param the value type + * @param valueFactories list of factories for distinct values + * @throws AssertionError if a test condition fails + */ + public static void checkEqualsAndHashCode(List> valueFactories) { + for (int i = 0; i < valueFactories.size(); i++) { + for (int j = 0; j < valueFactories.size(); j++) { + T value1 = valueFactories.get(i).get(); + T value2 = valueFactories.get(j).get(); + if (i == j) { + // Here, value1 and value2 are from the same value factory, so we expect them to be equal, + // as follows: + // 1. An instance must be equal to itself. + if (!value1.equals(value1)) { + throw new AssertionError("value was not equal to itself: " + value1); + } + + // In normal usage of checkEqualsAndHashCode, we're testing for value equality (and + // consistent hashing by value) between different instances of T that have the same + // properties, so value1 and value2 should *not* be the exact same object. However, + // some types use a singleton or interning pattern where it's not possible to have + // multiple instances with the same properties; if so, the test logic should tell us + // this by explicitly using singletonValueFactory, and then we will skip that check + // as well as other tests that are for multiple instances (2 & 3 below). + if (!(valueFactories.get(i) instanceof SingletonValueFactory)) { + if (value1 == value2) { + throw new AssertionError("value factory for checkEqualsAndHashCode returned the same" + + " instance twice in a row; if this is intentionally a singleton, you must use" + + " TypeBehavior.singletonValueFactory"); + } + + // 2. Commutative equality: value1.equals(value2) and value2.equals(value1) must + // both be true. + if (!value1.equals(value2)) { + throw new AssertionError("(" + value1 + ").equals(" + value2 + ") was false"); + } + if (!value2.equals(value1)) { + throw new AssertionError("(" + value1 + ").equals(" + value2 + ") was true, but (" + + value2 + ").equals(" + value1 + ") was false"); + } + + // 3. The hashCodes for two logically equal instances must be equal. + if (value1.hashCode() != value2.hashCode()) { + throw new AssertionError("(" + value1 + ").hashCode() was " + value1.hashCode() + " but (" + + value2 + ").hashCode() was " + value2.hashCode()); + } + } + + // 4. An instance of anything is always unequal to null. + if (value1.equals(null)) { + throw new AssertionError("value was equal to null: " + value1); + } + // 5. An instance of T is always unequal to an instance of a class that isn't T. + if (value1.equals(new Object())) { + throw new AssertionError("value was equal to Object: " + value1); + } + } else { + // Here, value1 and value2 are not from the same factory, so we expect them to be + // unequal (regardless of which one we call equals on). Note that we do *not* have a + // similar test for the hashCodes being unequal, because that's not a requirement in + // Java-- collisions are allowed. + if (value1.equals(value2)) { + throw new AssertionError("(" + value1 + ").equals(" + value2 + ") was true"); + } + if (value2.equals(value1)) { + throw new AssertionError("(" + value2 + ").equals(" + value1 + ") was true"); + } + } + } + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/Handler.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/Handler.java new file mode 100644 index 0000000..299e3e0 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/Handler.java @@ -0,0 +1,17 @@ +package com.launchdarkly.testhelpers.httptest; + +/** + * An object or lambda that handles HTTP requests for a {@link HttpServer}. + *

+ * Use the factory methods in {@link Handlers} to create standard implementations. + */ +@FunctionalInterface +public interface Handler { + /** + * Processes the request. + * + * @param context a {@link RequestContext} that provides both the request information + * and the ability to modify the response + */ + public void apply(RequestContext context); +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/HandlerSwitcher.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/HandlerSwitcher.java new file mode 100644 index 0000000..4b5df30 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/HandlerSwitcher.java @@ -0,0 +1,40 @@ +package com.launchdarkly.testhelpers.httptest; + +/** + * A delegator that forwards requests to another handler, which can be changed at any time. + */ +public final class HandlerSwitcher implements Handler { + private volatile Handler target; + + /** + * Creates an instance with an initial target. + * + * @param target the handler to delegate to initially + */ + public HandlerSwitcher(Handler target) { + this.target = target; + } + + @Override + public void apply(RequestContext context) { + target.apply(context); + } + + /** + * Returns the current handler that will receive requests. + * + * @return the current target + */ + public Handler getTarget() { + return target; + } + + /** + * Changes the handler that will receive requests. + * + * @param target the new target + */ + public void setTarget(Handler target) { + this.target = target; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/Handlers.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/Handlers.java new file mode 100644 index 0000000..bfa6a5f --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/Handlers.java @@ -0,0 +1,294 @@ +package com.launchdarkly.testhelpers.httptest; + +import java.nio.charset.Charset; +import java.util.concurrent.Semaphore; + +/** + * Factory methods for standard {@link Handler} implementations. + */ +public abstract class Handlers { + /** + * Creates a {@link Handler} that calls all of the specified handlers in order. + *

+ * You can use this to chain together operations like {@link #status(int)} and + * {@link #header(String, String)}. + * + * @param handlers a series of handlers + * @return a {@link Handler} + */ + public static Handler all(Handler... handlers) { + return ctx -> { + for (Handler h: handlers) { + h.apply(ctx); + } + }; + } + + /** + * Creates a {@link Handler} that sets the HTTP response status. + * + * @param status the status code + * @return a {@link Handler} + */ + public static Handler status(int status) { + return ctx -> ctx.setStatus(status); + } + + /** + * Creates a {@link Handler} that sets a response header. + * + * @param name the header name + * @param value the header value + * @return a {@link Handler} + */ + public static Handler header(String name, String value) { + return ctx -> ctx.setHeader(name, value); + } + + /** + * Creates a {@link Handler} that adds a response header, without overwriting previous values. + * + * @param name the header name + * @param value the header value + * @return a {@link Handler} + */ + public static Handler addHeader(String name, String value) { + return ctx -> ctx.addHeader(name, value); + } + + /** + * Creates a {@link Handler} that sends the specified response body. + * + * @param contentType response content type + * @param body response body (null is equivalent to an empty array) + * @return a {@link Handler} + */ + public static Handler body(String contentType, byte[] body) { + return ctx -> { + ctx.setHeader("Content-Type", contentType); + ctx.setHeader("Content-Length", String.valueOf(body == null ? 0 : body.length)); + if (body != null) { + ctx.write(body); + } + }; + } + + /** + * Creates a {@link Handler} that sends the specified response body. + *

+ * The response is encoded with UTF-8 by default, but "charset" is not added to the Content-Type. + * + * @param contentType response content type + * @param body response body (may be null) + * @return a {@link Handler} + */ + public static Handler bodyString(String contentType, String body) { + return bodyString(contentType, body, null); + } + + /** + * Creates a {@link Handler} that sends the specified response body. + *

+ * If specified, the encoding's name is added to the Content-Type as the "charset". + * + * @param contentType response content type + * @param body response body (may be null) + * @param encoding character encoding; if null, UTF-8 will be used + * @return a {@link Handler} + */ + public static Handler bodyString(String contentType, String body, Charset encoding) { + return body( + encoding == null ? contentType : + (contentType.contains("charset=") ? contentType : contentType + ";charset=" + encoding.name().toLowerCase()), + body == null ? null : + body.getBytes(encoding == null ? Charset.forName("UTF-8") : encoding) + ); + } + + /** + * Creates a {@link Handler} that sends a response body with JSON content type. + * + * @param json the JSON data + * @return a {@link Handler} + */ + public static Handler bodyJson(String json) { + return bodyJson(json, null); + } + + /** + * Creates a {@link Handler} that sends a response body with JSON content type. + * + * @param json the JSON data + * @param encoding character encoding; if null, UTF-8 will be used + * @return a {@link Handler} + */ + public static Handler bodyJson(String json, Charset encoding) { + return bodyString("application/json", json, encoding); + } + + /** + * Creates a {@link Handler} that starts writing a chunked response. + * + *


+   * Handler handler = Handlers.all(
+   *     Handlers.startChunks("text/my-stream-data"),
+   *     Handlers.writeChunkString("data1"),
+   *     Handlers.writeChunkString("data2")
+   * );
+   * 
+ * + * @param contentType the content type + * @param encoding character encoding to include in the Content-Type header, if any + * @return a {@link Handler} + */ + public static Handler startChunks(String contentType, Charset encoding) { + return ctx -> { + ctx.setHeader("Content-Type", encoding == null ? contentType : + (contentType + ";charset=" + encoding.name().toLowerCase())); + ctx.setChunked(); + ctx.write(null); + }; + } + + /** + * Creates a {@link Handler} that writes response data in a chunked response. + * + * @param data the chunk data + * @return a {@link Handler} + */ + public static Handler writeChunk(byte[] data) { + return ctx -> ctx.write(data); + } + + /** + * Creates a {@link Handler} that writes response data in a chunked response. + *

+ * This always uses the default character encoding to conver the string to bytes. To + * use a different encoding, do the conversion yourself and call {@link #writeChunk(byte[])}. + * + * @param data the chunk data + * @return a {@link Handler} + */ + public static Handler writeChunkString(String data) { + return writeChunk(data.getBytes()); + } + + /** + * Creates a {@link Handler} that sleeps for the specified amount of time. + * + * @param delayMillis how long to delay, in milliseconds + * @return a {@link Handler} + */ + public static Handler delay(long delayMillis) { + return ctx -> { + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) {} + }; + } + + /** + * Creates a {@link Handler} that waits until the specified semaphore is available. + * This can be used to synchronize test logic so that the HTTP response does not + * proceed until signaled to by the test. + * + * @param semaphore the semaphore to wait on + * @return a {@link Handler} + */ + public static Handler waitFor(Semaphore semaphore) { + return ctx -> { + try { + semaphore.acquire(); + } catch (InterruptedException e) { + return; + } + }; + } + + /** + * Creates a {@link Handler} that sleeps indefinitely, holding the connection open, + * until the server is closed. + * + * @return a {@link Handler} + */ + public static Handler hang() { + return ctx -> { + while (true) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + return; + } + } + }; + } + + /** + * Creates a stateful {@link Handler} that delegates to each of the specified handlers in sequence + * as each request is received. + *

+ * Any requests that happen after the last handler in the list has been used will receive a + * 500 error. + * + * @param handlers a series of handlers + * @return a {@link Handler} + */ + public static Handler sequential(Handler...handlers) { + return new SequentialHandler(handlers); + } + + /** + * Shortcut handlers for simulating a Server-Sent Events stream. + */ + public static abstract class SSE { + /** + * Starts a chunked stream with the standard content type "text/event-stream", + * and the charset UTF-8. + * + * @return a {@link Handler} + */ + public static Handler start() { + return startChunks("text/event-stream", Charset.forName("UTF-8")); + } + + /** + * Writes an SSE comment line. + * + * @param text the content that should appear after the colon + * @return a {@link Handler} + */ + public static Handler comment(String text) { + return writeChunkString(":" + text + "\n"); + } + + /** + * Writes an SSE event terminated by two newlines. + * + * @param content the full event + * @return a {@link Handler} + */ + public static Handler event(String content) { + return writeChunkString(content + "\n\n"); + } + + /** + * Writes an SSE event created from individual fields. + * + * @param message the "event" field + * @param data the "data" field + * @return a {@link Handler} + */ + public static Handler event(String message, String data) { + return event("event: " + message + "\ndata: " + data); + } + + /** + * Waits indefinitely without closing the stream. Equivalent to {@link Handlers#hang()}. + * + * @return a {@link Handler} + */ + public static Handler leaveOpen() { + return hang(); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/HttpServer.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/HttpServer.java new file mode 100644 index 0000000..2dbd8b6 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/HttpServer.java @@ -0,0 +1,208 @@ +package com.launchdarkly.testhelpers.httptest; + +import com.launchdarkly.testhelpers.httptest.impl.HttpServerImpl; +import com.launchdarkly.testhelpers.tcptest.TcpServer; + +import java.io.Closeable; +import java.io.IOException; +import java.net.URI; +import java.net.URL; + +/** + * A simplified wrapper for an embedded test HTTP server. + *

+ * See {@link com.launchdarkly.testhelpers.httptest} for more details and examples. + *

+ * The server can be configured with any implementation of {@link Handler} to specify how + * HTTP requests should be handled. However, this is limited to behavior that is valid in + * HTTP. If you want to simulate a server that does not return a valid HTTP response, use + * {@link TcpServer} instead. + */ +public final class HttpServer implements Closeable { + private final Delegate delegate; + private final int port; + private final URI uri; + private final RequestRecorder recorder; + + /** + * An abstraction for the part of the server implementation that could vary by platform. + */ + public interface Delegate extends Closeable { + /** + * Starts the server and returns the port it's listening on. + * + * @return the port + * @throws IOException if starting the server fails + */ + int start() throws IOException; + + /** + * Factory pattern for the server abstraction. + * + * @since 2.0.0 + * @see com.launchdarkly.testhelpers.httptest.impl.HttpServerImpl + */ + public interface Factory { + /** + * Creates the platform-specific server implementation, but does not start it. + * + * @param port the port it will listen on, or 0 to select any available port + * @param handler the request handler + * @param tlsConfig TLS configuration if using TLS, or null + * @return the delegate implementation + */ + Delegate createServerDelegate(int port, Handler handler, ServerTLSConfiguration tlsConfig); + } + } + + private HttpServer(Delegate delegate, int port, URI uri, RequestRecorder recorder) { + this.delegate = delegate; + this.port = port; + this.uri = uri; + this.recorder = recorder; + } + + /** + * Starts a new test server on a specific port. + * + * @param port the port to listen on + * @param handler An object or lambda that will handle all requests to this server. Use + * the factory methods in {@link Handlers} for standard handlers. If you will need + * to change the behavior of the handler during the lifetime of the server, use + * {@link HandlerSwitcher}. + * @return the started server instance + */ + public static HttpServer start(int port, Handler handler) { + return startInternal(port, handler, null); + } + + /** + * Starts a new test server on any available port. + * + * @param handler An object or lambda that will handle all requests to this server. Use + * the factory methods in {@link Handlers} for standard handlers. If you will need + * to change the behavior of the handler during the lifetime of the server, use + * {@link HandlerSwitcher}. + * @return the started server instance + */ + public static HttpServer start(Handler handler) { + return start(0, handler); + } + + /** + * Starts a new HTTPS test server on a specific port. + * + * @param tlsConfig certificate and key data; to use a self-signed certificate, call + * {@link ServerTLSConfiguration#makeSelfSignedCertificate()} + * @param port the port to listen on + * @param handler An object or lambda that will handle all requests to this server. Use + * the factory methods in {@link Handlers} for standard handlers. If you will need + * to change the behavior of the handler during the lifetime of the server, use + * {@link HandlerSwitcher}. + * @return the started server instance + */ + public static HttpServer startSecure(ServerTLSConfiguration tlsConfig, int port, Handler handler) { + return startInternal(port, handler, tlsConfig); + } + + /** + * Starts a new HTTPS test server on any available port. + * + * @param certData certificate and key data; to use a self-signed certificate, call + * {@link ServerTLSConfiguration#makeSelfSignedCertificate()} + * @param handler An object or lambda that will handle all requests to this server. Use + * the factory methods in {@link Handlers} for standard handlers. If you will need + * to change the behavior of the handler during the lifetime of the server, use + * {@link HandlerSwitcher}. + * @return the started server instance + */ + public static HttpServer startSecure(ServerTLSConfiguration certData, Handler handler) { + return startSecure(certData, 0, handler); + } + + private static HttpServer startInternal(int port, Handler handler, ServerTLSConfiguration tlsConfig) { + RequestRecorder recorder = new RequestRecorder(); + Handler rootHandler = ctx -> { + recorder.apply(ctx); + try { + handler.apply(ctx); + } catch (Exception e) { + ctx.setStatus(500); + ctx.write(e.toString().getBytes()); + } + }; + + Delegate delegate = HttpServerImpl.factory().createServerDelegate(port, rootHandler, tlsConfig); + + int realPort; + try { + realPort = delegate.start(); + } catch (IOException e) { + try { + delegate.close(); + } catch (Exception ignore) {} + throw new RuntimeException(e); + } + + return new HttpServer( + delegate, + realPort, + URI.create(String.format("%s://localhost:%d/", + tlsConfig == null ? "http" : "https", realPort)), + recorder + ); + } + + /** + * Returns the server's port. + * + * @return the port + */ + public int getPort() { + return port; + } + + /** + * Returns the server's base URI. + * + * @return the base URI + */ + public URI getUri() { + return uri; + } + + /** + * Returns the server's base URI. + * + * @return the base URI as a URL + */ + public URL getUrl() { + try { + return uri.toURL(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the {@link RequestRecorder} that receives all requests to this server, + * unless you disable it with {@link RequestRecorder#setEnabled(boolean)}. + * + * @return the recorder + */ + public RequestRecorder getRecorder() { + return recorder; + } + + /** + * Shuts down the server. + */ + @Override + public void close() { + try { + delegate.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestContext.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestContext.java new file mode 100644 index 0000000..b4106da --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestContext.java @@ -0,0 +1,68 @@ +package com.launchdarkly.testhelpers.httptest; + +/** + * An abstraction used by {@link Handler} implementations to hide the details of + * the underlying HTTP server framework. + */ +public interface RequestContext { + /** + * Returns the {@link RequestInfo}. + * + * @return a {@link RequestInfo} + */ + RequestInfo getRequest(); + + /** + * Sets the response status. + * + * @param status the status code + */ + void setStatus(int status); + + /** + * Sets a response header. + * + * @param name the header name + * @param value the header value + */ + void setHeader(String name, String value); + + /** + * Adds a response header, without overwriting any previous values. + * + * @param name the header name + * @param value the header value + */ + void addHeader(String name, String value); + + /** + * Turns on chunked encoding. + *

+ * It's only valid to call this when {@link #write(byte[])} has not yet been called. After + * {@link #write(byte[])} is called, the behavior of {@link #setChunked()} is undefined. + */ + void setChunked(); + + /** + * Writes data to the output stream. + * + * @param data the data to write; null or zero-length data means to only flush the stream + */ + void write(byte[] data); + + /** + * Returns a path parameter, if any path parameters were captured. + *

+ * By default, this will always return null. It is non-null only if you used + * {@link SimpleRouter} and matched a regex pattern that was added with + * {@link SimpleRouter#addRegex(java.util.regex.Pattern, Handler)}, and the pattern + * contained capture groups. For instance, if the pattern was {@code /a/([^/]*)/c/(.*)} + * and the request path was {@code /a/b/c/d/e}, {@code getPathParam(0)} would return + * {@code "b"} and {@code getPathParam(1)} would return {@code "d/e"}. + * + * @param i a zero-based positional index + * @return the path parameter string; null if there were no path parameters, or if the index + * is out of range + */ + String getPathParam(int i); +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestInfo.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestInfo.java new file mode 100644 index 0000000..befbe61 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestInfo.java @@ -0,0 +1,104 @@ +package com.launchdarkly.testhelpers.httptest; + +import com.google.common.collect.ImmutableMap; + +import java.net.URI; + +/** + * Properties of a request received by {@link HttpServer}. + *

+ * We capture all of the request properties, including the request body, before passing the request + * to the configured handler, because tests often need to record and inspect the request. + */ +public final class RequestInfo { + private final String method; + private final URI uri; + private final String path; + private final String query; + private final ImmutableMap headers; + private final String body; + + /** + * Constructs an instance, specifying all properties. + * + * @param method the HTTP method + * @param uri the URI + * @param path the request path + * @param query the query string + * @param headers the headers + * @param body the body, or null + */ + public RequestInfo(String method, URI uri, String path, String query, + ImmutableMap headers, String body) { + this.method = method.toUpperCase(); + this.uri = uri; + this.path = path; + this.query = query; + this.headers = headers == null ? ImmutableMap.of() : headers; + this.body = body; + } + + /** + * Returns the HTTP method. + * + * @return the HTTP method + */ + public String getMethod() { + return method; + } + + /** + * Returns the full request URI. + * + * @return the request URI + */ + public URI getUri() { + return uri; + } + + /** + * Returns the request path. + * + * @return the path + */ + public String getPath() { + return path; + } + + /** + * Returns the request query string. + * + * @return the query string (including the leading "?"), or null if there is none + */ + public String getQuery() { + return query; + } + + /** + * Returns a request header by name. + * + * @param name a case-insensitive header name + * @return the header value, or null if not found + */ + public String getHeader(String name) { + return headers.get(name.toLowerCase()); + } + + /** + * Returns all request header names. + * + * @return the header names + */ + public Iterable getHeaderNames() { + return headers.keySet(); + } + + /** + * Returns the request body as a string. + * + * @return the request body, or null if there is none + */ + public String getBody() { + return body; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestRecorder.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestRecorder.java new file mode 100644 index 0000000..412fc11 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/RequestRecorder.java @@ -0,0 +1,108 @@ +package com.launchdarkly.testhelpers.httptest; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * An object that records all requests. + *

+ * Normally you won't need to use this class directly, because {@link HttpServer} has a + * built-in instance that captures all requests. You can use it if you need to capture + * only a subset of requests. + */ +public final class RequestRecorder implements Handler { + /** + * The default timeout for {@link #requireRequest()}: 5 seconds. + */ + public static final int DEFAULT_TIMEOUT_MILLIS = 5000; + + private final BlockingQueue requests = new LinkedBlockingQueue<>(); + private final AtomicBoolean enabled = new AtomicBoolean(true); + + @Override + public void apply(RequestContext context) { + if (enabled.get()) { + requests.add(context.getRequest()); + } + } + + /** + * The number of requests currently in the queue. + * + * @return the number of stored requests that have not been consumed + */ + public int count() { + return requests.size(); + } + + /** + * Returns true if the recorder is capturing requests. This is true by default. + * + * @return true if enabled + */ + public boolean isEnabled() { + return enabled.get(); + } + + /** + * Sets whether the recorder should capture requests. This is true by default. + * + * @param enabled true to enable the recorder, false to disable + */ + public void setEnabled(boolean enabled) { + this.enabled.set(enabled); + } + + /** + * Consumes and returns the first request in the queue, blocking until one is available, + * using {@link #DEFAULT_TIMEOUT_MILLIS}. + * + * @return the request information + * @throws IllegalStateException if the timeout expires + */ + public RequestInfo requireRequest() { + return requireRequest(DEFAULT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + } + + /** + * Consumes and returns the first request in the queue, blocking until one is available. + * + * @param timeout the maximum length of time to wait + * @param timeoutUnit the time unit for the timeout + * @return the request information + * @throws RuntimeException if the timeout expires + */ + public RequestInfo requireRequest(long timeout, TimeUnit timeoutUnit) { + try { + RequestInfo ret = requests.poll(timeout, timeoutUnit == null ? TimeUnit.MILLISECONDS : timeoutUnit); + if (ret == null) { + throw new IllegalStateException(new TimeoutException()); + } + return ret; + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Asserts that there are no requests in the queue and none are received within + * the specified timeout. + * + * @param timeout the maximum length of time to wait + * @param timeoutUnit the time unit for the timeout + * @throws IllegalStateException if a request was received + */ + public void requireNoRequests(long timeout, TimeUnit timeoutUnit) { + try { + RequestInfo ret = requests.poll(timeout, timeoutUnit == null ? TimeUnit.MILLISECONDS : timeoutUnit); + if (ret != null) { + throw new IllegalStateException("received an unexpected request"); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SequentialHandler.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SequentialHandler.java new file mode 100644 index 0000000..ea4e073 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SequentialHandler.java @@ -0,0 +1,22 @@ +package com.launchdarkly.testhelpers.httptest; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; + +final class SequentialHandler implements Handler { + private final Handler[] handlers; + private final AtomicInteger index = new AtomicInteger(0); + + SequentialHandler(Handler[] handlers) { + this.handlers = Arrays.copyOf(handlers, handlers.length); + } + + @Override + public void apply(RequestContext context) { + int i = index.getAndIncrement(); + if (i >= handlers.length) { + throw new RuntimeException("server received unexpected request"); + } + handlers[i].apply(context); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/ServerTLSConfiguration.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/ServerTLSConfiguration.java new file mode 100644 index 0000000..11d205a --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/ServerTLSConfiguration.java @@ -0,0 +1,123 @@ +package com.launchdarkly.testhelpers.httptest; + +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import okhttp3.tls.HandshakeCertificates; +import okhttp3.tls.HeldCertificate; + +/** + * Holds all certificate/key data required to configure an {@link HttpServer} for HTTPS, + * and to configure a client to make requests to that server. + *

+ * This implementation uses OkHttp's {@code okhttp-tls} package. + */ +public final class ServerTLSConfiguration { + private final X509Certificate certificate; + private final PrivateKey privateKey; + private final PublicKey publicKey; + private final SSLSocketFactory socketFactory; + private final X509TrustManager trustManager; + + private ServerTLSConfiguration(X509Certificate certificate, PrivateKey privateKey, PublicKey publicKey, + SSLSocketFactory socketFactory, X509TrustManager trustManager) { + this.certificate = certificate; + this.privateKey = privateKey; + this.publicKey = publicKey; + this.socketFactory = socketFactory; + this.trustManager = trustManager; + } + + /** + * Creates an instance with a self-signed certificate. + *

+ * HTTP clients will normally reject this certificate. To configure a client to accept it, + * use the objects provided by {@link #getSocketFactory()} and {@link #getTrustManager()}. + *

+ * The certificate's hostname is "localhost". It expires in 24 hours. + * + * @return a {@link ServerTLSConfiguration} + */ + public static ServerTLSConfiguration makeSelfSignedCertificate() { + String hostname; + try { + hostname = InetAddress.getByName("localhost").getCanonicalHostName(); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + + HeldCertificate certInfo = new HeldCertificate.Builder() + .rsa2048() + .serialNumber(BigInteger.ONE) + .certificateAuthority(1) + .commonName(hostname) + .addSubjectAlternativeName("localhost") + .build(); + + HandshakeCertificates hc = new HandshakeCertificates.Builder() + .heldCertificate(certInfo) + .addTrustedCertificate(certInfo.certificate()) + .build(); + + return new ServerTLSConfiguration( + certInfo.certificate(), + certInfo.keyPair().getPrivate(), + certInfo.keyPair().getPublic(), + hc.sslSocketFactory(), + hc.trustManager() + ); + } + + /** + * Returns the server certificate. + * + * @return the server certificate + */ + public X509Certificate getCertificate() { + return certificate; + } + + /** + * Returns the private key. + * + * @return the private key + */ + public PrivateKey getPrivateKey() { + return privateKey; + } + + /** + * Returns the public key. + * + * @return the public key + */ + public PublicKey getPublicKey() { + return publicKey; + } + + /** + * Returns an {@link SSLSocketFactory} for use by the client. + * + * @return an {@link SSLSocketFactory} + */ + public SSLSocketFactory getSocketFactory() { + return socketFactory; + } + + /** + * Returns a {@link TrustManager} for use by the client. + * + * @return a {@link TrustManager} + */ + public X509TrustManager getTrustManager() { + return trustManager; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SimpleRouter.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SimpleRouter.java new file mode 100644 index 0000000..7d09d21 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SimpleRouter.java @@ -0,0 +1,152 @@ +package com.launchdarkly.testhelpers.httptest; + +import com.google.common.collect.ImmutableList; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A delegator that provides simple request path/method matching. + *

+ * The request is sent to the handler for the first matching path. If there is no matching path, it + * returns a 404. If there is a matching path but only for a different HTTP method, it returns a 405. + */ +public final class SimpleRouter implements Handler { + private final List routes = new ArrayList<>(); + + private static class Route { + final String method; + final Pattern pattern; + final Handler handler; + + Route(String method, Pattern pattern, Handler handler) { + this.method = method; + this.pattern = pattern; + this.handler = handler; + } + } + + @Override + public void apply(RequestContext context) { + boolean matchedPath = false; + for (Route r: routes) { + Matcher m = r.pattern.matcher(context.getRequest().getPath()); + if (m.matches()) { + matchedPath = true; + if (r.method != null && !r.method.equalsIgnoreCase(context.getRequest().getMethod())) { + continue; + } + if (m.groupCount() > 0) { + ImmutableList.Builder params = ImmutableList.builder(); + for (int i = 1; i <= m.groupCount(); i++) { + params.add(m.group(i)); + } + context = new RequestContextWithPathParams(context, params.build()); + } + r.handler.apply(context); + return; + } + } + context.setStatus(matchedPath ? 405 : 404); + } + + /** + * Adds an exact-match path. + * + * @param path the desired path + * @param handler the handler to call for a matching request + * @return the same instance + */ + public SimpleRouter add(String path, Handler handler) { + return add(null, path, handler); + } + + /** + * Adds an exact-match path, specifying the HTTP method. + * + * @param method the desired method + * @param path the desired path + * @param handler the handler to call for a matching request + * @return the same instance + */ + public SimpleRouter add(String method, String path, Handler handler) { + return addRegex(method, Pattern.compile(Pattern.quote(path)), handler); + } + + /** + * Adds a regex path pattern. + *

+ * The regex must match the entire path. If it contains any capture groups, the matched groups + * will be available from {@link RequestContext#getPathParam(int)}. + * + * @param regex the regex to match + * @param handler the handler to call for a matching request + * @return the same instance + */ + public SimpleRouter addRegex(Pattern regex, Handler handler) { + return addRegex(null, regex, handler); + } + + /** + * Adds a regex path pattern, speifying the HTTP method. + *

+ * The regex must match the entire path. If it contains any capture groups, the matched groups + * will be available from {@link RequestContext#getPathParam(int)}. + * + * @param method the desired method + * @param regex the regex to match + * @param handler the handler to call for a matching request + * @return the same instance + */ + public SimpleRouter addRegex(String method, Pattern regex, Handler handler) { + routes.add(new Route(method, regex, handler)); + return this; + } + + private static final class RequestContextWithPathParams implements RequestContext { + private final RequestContext wrapped; + private final ImmutableList pathParams; + + RequestContextWithPathParams(RequestContext wrapped, ImmutableList pathParams) { + this.wrapped = wrapped; + this.pathParams = pathParams; + } + + @Override + public RequestInfo getRequest() { + return wrapped.getRequest(); + } + + @Override + public void setStatus(int status) { + wrapped.setStatus(status); + } + + @Override + public void setHeader(String name, String value) { + wrapped.setHeader(name, value); + } + + @Override + public void addHeader(String name, String value) { + wrapped.addHeader(name, value); + } + + @Override + public void setChunked() { + wrapped.setChunked(); + } + + @Override + public void write(byte[] data) { + wrapped.write(data); + } + + @Override + public String getPathParam(int i) { + return i < 0 || i >= pathParams.size() ? null : pathParams.get(i); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SpecialHttpConfigurations.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SpecialHttpConfigurations.java new file mode 100644 index 0000000..c41eff4 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/SpecialHttpConfigurations.java @@ -0,0 +1,389 @@ +package com.launchdarkly.testhelpers.httptest; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URI; + +import javax.net.SocketFactory; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +/** + * Testing tools for validating that HTTP client logic is correctly applying configuration parameters. + * The test methods set up a server with a defined behavior, then delegate to a provided test action + * that is expected to succeed or fail depending on the test conditions. + * + * @since 1.3.0 + */ +public class SpecialHttpConfigurations { + /** + * See {@link TestAction}. + */ + public static class Params { + private final ServerTLSConfiguration tlsConfig; + private final SocketFactory socketFactory; + private final String proxyHost; + private final int proxyPort; + private final String proxyBasicAuthUser; + private final String proxyBasicAuthPassword; + + Params(ServerTLSConfiguration tlsConfig, SocketFactory socketFactory, String proxyHost, int proxyPort, + String proxyBasicAuthUser, String proxyBasicAuthPassword) { + this.tlsConfig = tlsConfig; + this.socketFactory = socketFactory; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + this.proxyBasicAuthUser = proxyBasicAuthUser; + this.proxyBasicAuthPassword = proxyBasicAuthPassword; + } + + /** + * If the {@link ServerTLSConfiguration#getSocketFactory()} and {@link ServerTLSConfiguration#getTrustManager()} + * properties are non-null, the {@link TestAction} should configure its HTTP client to use these + * values for TLS configuration. + * @return the TLS configuration + */ + public ServerTLSConfiguration getTlsConfig() { + return tlsConfig; + } + + /** + * If this property is non-null, the {@link TestAction} should configure its HTTP client to use it. + * @return a custom socket factory + */ + public SocketFactory getSocketFactory() { + return socketFactory; + } + + /** + * If this property is non-null, the {@link TestAction} should configure its HTTP client to use a + * web proxy with this hostname plus the other proxy properties. + * @return the proxy hostname + */ + public String getProxyHost() { + return proxyHost; + } + + /** + * See {@link #getProxyHost()}. + * @return the proxy port + */ + public int getProxyPort() { + return proxyPort; + } + + /** + * See {@link #getProxyHost()}. + * @return the username for proxy basicauth or null + */ + public String getProxyBasicAuthUser() { + return proxyBasicAuthUser; + } + + /** + * See {@link #getProxyHost()}. + * @return the password for proxy basicauth or null + */ + public String getProxyBasicAuthPassword() { + return proxyBasicAuthPassword; + } + } + + /** + * See {@link TestAction}. + */ + @SuppressWarnings("serial") + public static class UnexpectedResponseException extends Exception { + @SuppressWarnings("javadoc") + public UnexpectedResponseException(String message) { + super(message); + } + } + + /** + * Implemented by the caller to perform some action against an HTTP server. + */ + public interface TestAction { + /** + * Implement this method to perform whatever kind of HTTP client action you are testing. + * The test framework has already set up a server with some predefined configuration, and has + * provided the base URI of that server in {@code targetUri}. The properties in {@code params} + * indicate how you should customize your HTTP client. The goal is to verify that 1. these + * features (such as TLS and proxy configuration) work correctly in the client and 2. your + * configuration logic is accurately transferring these parameters to the client. + * + * @param targetUri the URI to query + * @param params client configuration options + * @return true if successful; false if your client implementation does not support some of + * those parameters and you are deliberately skipping this test + * @throws IOException if the connection failed + * @throws UnexpectedResponseException if you were able to connect and send the request, but + * the content of the response was not consistent with the {@code handler} you passed to + * the test method + */ + boolean doTest(URI targetUri, Params params) throws IOException, UnexpectedResponseException; + } + + /** + * Runs all of the other {@code test} methods with the same client logic. + * + * @param handler determines what the server should return for all responses + * @param testAction a {@link TestAction} implementation + */ + public static void testAll( + Handler handler, + TestAction testAction + ) { + testHttpClientDoesNotAllowSelfSignedCertByDefault(handler, testAction); + testHttpClientCanBeConfiguredToAllowSelfSignedCert(handler, testAction); + testHttpClientCanUseCustomSocketFactory(handler, testAction); + testHttpClientCanUseProxy(handler, testAction); + testHttpClientCanUseProxyWithBasicAuth(handler, testAction); + } + + /** + * Runs a test to verify that the HTTP client logic in the {@link TestAction} will fail if it + * connects to an HTTPS endpoint that has a self-signed certificate, when it has not been + * specifically configured to accept that certificate. + * + * @param handler determines what the server should return for all responses + * @param testAction a {@link TestAction} implementation + * @return true if successful; false if the {@link TestAction} returned false to indicate + * that the client does not support this kind of test + */ + public static boolean testHttpClientDoesNotAllowSelfSignedCertByDefault(Handler handler, + TestAction testAction) { + Params params = new Params(null, null, null, 0, null, null); + // deliberately don't include a TLS configuration in the Params, so the client doesn't know about the cert + ServerTLSConfiguration tlsConfig = ServerTLSConfiguration.makeSelfSignedCertificate(); + try (HttpServer secureServer = HttpServer.startSecure(tlsConfig, handler)) { + boolean didTest; + try { + didTest = testAction.doTest(secureServer.getUri(), params); + // test was expected to throw an exception, so we should only get here if the test really didn't do anything + assertThat("test action appears to have succeeded, but it should have failed", !didTest); + return false; + } catch (UnexpectedResponseException e) { + assertThat("test action was able to get a response, even if its content was invalid; should have gotten an IOException", false); + } catch (Exception e) { // any other kind of failure counts as an expected result + } + assertThat("expected the server not to receive a request due to TLS negotiation failure for a self-signed certificate", + secureServer.getRecorder().count(), equalTo(0)); + return true; + } + } + + /** + * Runs a test to verify that the HTTP client logic in the {@link TestAction} can be + * configured to accept a specific HTTPS certificate (which in this case is self-signed). + * + * @param handler determines what the server should return for all responses + * @param testAction a {@link TestAction} implementation + * @return true if successful; false if the {@link TestAction} returned false to indicate + * that the client does not support this kind of test + */ + public static boolean testHttpClientCanBeConfiguredToAllowSelfSignedCert(Handler handler, + TestAction testAction) { + String desc = "when the client was configured to accept a self-signed certificate"; + try { + ServerTLSConfiguration tlsConfig = ServerTLSConfiguration.makeSelfSignedCertificate(); + Params params = new Params(tlsConfig, null, null, 0, null, null); + try (HttpServer secureServer = HttpServer.startSecure(tlsConfig, handler)) { + boolean didTest = testAction.doTest(secureServer.getUri(), params); + if (didTest) { + assertThat("expected the server to receive a request " + desc, + secureServer.getRecorder().count(), equalTo(1)); + } + return didTest; + } + } catch (Exception e) { + throw unexpectedRequestFailure(e, desc); + } + } + + /** + * Runs a test to verify that the HTTP client logic in the {@link TestAction} can be + * configured to use a custom {@link SocketFactory}. The test method will provide a + * deliberately incorrect URI, plus a custom socket factory that rewrites requests to go + * to the correct URI, to verify that the socket factory is really being used. + * + * @param handler determines what the server should return for all responses + * @param testAction a {@link TestAction} implementation + * @return true if successful; false if the {@link TestAction} returned false to indicate + * that the client does not support this kind of test + */ + public static boolean testHttpClientCanUseCustomSocketFactory(Handler handler, + TestAction testAction) { + String desc = "when the client was configured with a custom socket factory"; + try { + try (HttpServer server = HttpServer.start(handler)) { + Params params = new Params(null, + makeSocketFactoryThatChangesHostAndPort(server.getUri().getHost(), server.getPort()), + null, 0, null, null); + URI uriWithWrongPort = URI.create("http://localhost:1"); + boolean didTest = testAction.doTest(uriWithWrongPort, params); + if (didTest) { + assertThat("expected the server to receive a request " + desc, + server.getRecorder().count(), equalTo(1)); + } + return didTest; + } + } catch (Exception e) { + throw unexpectedRequestFailure(e, desc); + } + } + + /** + * Runs a test to verify that the HTTP client logic in the {@link TestAction} can be + * configured to use a web proxy. The test method will provide a deliberately incorrect + * URI, plus the host/port of a fake proxy that accepts requests and returns the + * configured response, to verify that the proxy settings are really being used. + * + * @param handler determines what the server should return for all responses + * @param testAction a {@link TestAction} implementation + * @return true if successful; false if the {@link TestAction} returned false to indicate + * that the client does not support this kind of test + */ + public static boolean testHttpClientCanUseProxy(Handler handler, + TestAction testAction) { + String desc = "when the client was configured with a proxy"; + try { + try (HttpServer server = HttpServer.start(handler)) { + Params params = new Params(null, null, server.getUri().getHost(), server.getPort(), null, null); + URI fakeBaseUri = URI.create("http://not-a-real-host"); + boolean didTest = testAction.doTest(fakeBaseUri, params); + if (didTest) { + assertThat("expected the server to receive a request " + desc, + server.getRecorder().count(), equalTo(1)); + } + return didTest; + } + } catch (Exception e) { + throw unexpectedRequestFailure(e, desc); + } + } + + /** + * Runs a test to verify that the HTTP client logic in the {@link TestAction} can be + * configured to use a web proxy with basic authentication. The test method will provide + * a deliberately incorrect URI, plus the host/port of a fake proxy that accepts requests + * and returns the configured response, to verify that the proxy settings are really being + * used; then it will verify that the fake proxy received the expected authorization header. + * + * @param handler determines what the server should return for all responses + * @param testAction a {@link TestAction} implementation + * @return true if successful; false if the {@link TestAction} returned false to indicate + * that the client does not support this kind of test + */ + public static boolean testHttpClientCanUseProxyWithBasicAuth(Handler handler, + TestAction testAction) { + String desc = "when the client was configured with a proxy with basicauth"; + Handler proxyHandler = ctx -> { + if (ctx.getRequest().getHeader("Proxy-Authorization") == null) { + ctx.setStatus(407); + ctx.setHeader("Proxy-Authenticate", "Basic realm=x"); + } else { + handler.apply(ctx); + } + }; + try { + try (HttpServer server = HttpServer.start(proxyHandler)) { + Params params = new Params(null, null, server.getUri().getHost(), server.getPort(), "user", "pass"); + + URI fakeBaseUri = URI.create("http://not-a-real-host"); + boolean didTest = testAction.doTest(fakeBaseUri, params); + if (didTest) { + assertThat("expected the server to receive two requests " + desc, + server.getRecorder().count(), equalTo(2)); + RequestInfo req1 = server.getRecorder().requireRequest(); + assertThat("expected the first request not to have a Proxy-Authorization header", + req1.getHeader("Proxy-Authorization"), nullValue()); + RequestInfo req2 = server.getRecorder().requireRequest(); + assertThat("expected the second request (response to challenge) to have a valid Proxy-Authorization header", + req2.getHeader("Proxy-Authorization"), equalTo("Basic dXNlcjpwYXNz")); + } + return didTest; + } + } catch (Exception e) { + throw unexpectedRequestFailure(e, desc); + } + } + + private static AssertionError unexpectedRequestFailure(Exception e, String desc) { + return new AssertionError("request failed " + desc + ": " + e); + } + + /** + * Creates a {@link SocketFactory} implementation that rewrites all requests to go to the + * specified host and port, instead of the ones given in the URI. This is a simple way to + * verify that a given piece of client logic is really using the configured socket factory. + * + * @param host the hostname to send requests to + * @param port the port to send requests to + * @return a socket factory + */ + public static SocketFactorySingleHost makeSocketFactoryThatChangesHostAndPort(String host, int port) { + return new SocketFactorySingleHost(host, port); + } + + private static final class SocketSingleHost extends Socket { + private final String host; + private final int port; + + SocketSingleHost(String host, int port) { + this.host = host; + this.port = port; + } + + @Override public void connect(SocketAddress endpoint) throws IOException { + super.connect(new InetSocketAddress(this.host, this.port), 0); + } + + @Override public void connect(SocketAddress endpoint, int timeout) throws IOException { + super.connect(new InetSocketAddress(this.host, this.port), timeout); + } + } + + static final class SocketFactorySingleHost extends SocketFactory { + private final String host; + private final int port; + + public SocketFactorySingleHost(String host, int port) { + this.host = host; + this.port = port; + } + + @Override public Socket createSocket() throws IOException { + return new SocketSingleHost(this.host, this.port); + } + + @Override public Socket createSocket(String host, int port) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + + @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + + @Override public Socket createSocket(InetAddress host, int port) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + + @Override public Socket createSocket(InetAddress host, int port, InetAddress localAddress, int localPort) throws IOException { + Socket socket = createSocket(); + socket.connect(new InetSocketAddress(this.host, this.port)); + return socket; + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/impl/HttpServerImpl.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/impl/HttpServerImpl.java new file mode 100644 index 0000000..41e4bc1 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/impl/HttpServerImpl.java @@ -0,0 +1,22 @@ +package com.launchdarkly.testhelpers.httptest.impl; + +import com.launchdarkly.testhelpers.httptest.HttpServer; + +/** + * This class just contains the reference to the specific HTTP server implementation we will use, + * so that HttpServer can contain only portable code. + * + * @since 2.0.0 + */ +public abstract class HttpServerImpl { + private static final HttpServer.Delegate.Factory FACTORY = + (port, handler, tlsConfig) -> new NanoHttpdServerDelegate(port, handler, tlsConfig); + + /** + * Returns the implementation factory. + * @return the factory + */ + public static HttpServer.Delegate.Factory factory() { + return FACTORY; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/impl/NanoHttpdServerDelegate.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/impl/NanoHttpdServerDelegate.java new file mode 100644 index 0000000..e6bc0bf --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/impl/NanoHttpdServerDelegate.java @@ -0,0 +1,296 @@ +package com.launchdarkly.testhelpers.httptest.impl; + +import com.google.common.collect.ImmutableMap; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestContext; +import com.launchdarkly.testhelpers.httptest.RequestInfo; +import com.launchdarkly.testhelpers.httptest.ServerTLSConfiguration; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.IHTTPSession; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.NanoHTTPD; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.IStatus; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.Response; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.Status; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.net.URI; +import java.nio.charset.Charset; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import javax.net.ssl.KeyManagerFactory; + +class NanoHttpdServerDelegate implements HttpServer.Delegate { + private final ServerImpl server; + + public NanoHttpdServerDelegate(int port, Handler handler, ServerTLSConfiguration tlsConfig) { + server = new ServerImpl(port, handler, tlsConfig); // NanoHTTPD will pick a port for us if this is zero + } + + @Override + public void close() throws IOException { + server.closeAllConnections(); + server.stop(); + } + + @Override + public int start() throws IOException { + server.start(); + return server.getListeningPort(); + } + + private static final class ServerImpl extends NanoHTTPD { + private final Handler handler; + + ServerImpl(int port, Handler handler, ServerTLSConfiguration tlsConfig) { + super(port); + this.handler = handler; + + if (tlsConfig != null) { + try { + char[] fakePassword = "secret".toCharArray(); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null); + keyStore.setCertificateEntry("localhost", tlsConfig.getCertificate()); + keyStore.setEntry("localhost", + new KeyStore.PrivateKeyEntry(tlsConfig.getPrivateKey(), new Certificate[] { tlsConfig.getCertificate() }), + new KeyStore.PasswordProtection(fakePassword)); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, fakePassword); + makeSecure(NanoHTTPD.makeSSLSocketFactory(keyStore, keyManagerFactory), null); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + @Override + public Response serve(IHTTPSession session) { + // We need to call the handler on a separate thread so that we can support chunked streaming. + // NanoHTTPD doesn't have an imperative "start writing the response" method; instead, we need + // to return the response to it, and *then* if there is additional streaming content, the + // handler will continue writing it. + CompletableFuture responseReceiver = new CompletableFuture<>(); + RequestContextImpl ctx = new RequestContextImpl(makeRequestInfo(session), responseReceiver); + + new Thread(() -> { + try { + handler.apply(ctx); + ctx.commit(); + } catch (Exception e) { + responseReceiver.completeExceptionally(e); + } + }).start(); + + try { + Response response = responseReceiver.get(); + return response; + } catch (ExecutionException e) { + throw new RuntimeException(e.getCause()); + } catch (InterruptedException e) { + throw new RuntimeException(e.getCause()); + } + } + + private RequestInfo makeRequestInfo(IHTTPSession session) { + String path = session.getUri(); // NanoHTTPD calls this the URI but it's really the path + String query = session.getQueryParameterString(); + String queryWithPrefix = query == null || query.isEmpty() ? "" : ("?" + query); + URI requestUri = URI.create(getBaseUri() + path + queryWithPrefix); + ImmutableMap.Builder headers = ImmutableMap.builder(); + String body = ""; + int contentLength = 0; + + for (Map.Entry h: session.getHeaders().entrySet()) { + headers.put(h.getKey().toLowerCase(), h.getValue()); + if (h.getKey().equalsIgnoreCase("content-length")) { + contentLength = Integer.parseInt(h.getValue()); + } + } + if (contentLength > 0) { + try { + InputStream bodyStream = session.getInputStream(); + byte[] data = new byte[contentLength]; + int n = bodyStream.read(data); + body = new String(data, 0, n, Charset.forName("UTF-8")); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + return new RequestInfo(session.getMethod().toString(), requestUri, path, + queryWithPrefix.isEmpty() ? null : queryWithPrefix, + headers.build(), body); + } + + private String getBaseUri() { + return "http://" + (this.getHostname() == null ? "localhost" : this.getHostname()) + + ":" + this.getListeningPort(); + } + } + + private static final class RequestContextImpl implements RequestContext { + private final RequestInfo requestInfo; + private final CompletableFuture responseReceiver; + + int status = 200; + String contentType = null; + Map> headers = new HashMap<>(); + + boolean chunked = false; + volatile Response response = null; + PipedOutputStream chunkedPipe = null; + + RequestContextImpl(RequestInfo requestInfo, CompletableFuture responseReceiver) { + this.requestInfo = requestInfo; + this.responseReceiver = responseReceiver; + } + + void commit() { + if (chunked) { + try { + chunkedPipe.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else { + if (response == null) { + // a status was set but nothing was written; call write() to force us to create a response + write(null); + } + responseReceiver.complete(response); + } + } + + @Override + public RequestInfo getRequest() { + return requestInfo; + } + + @Override + public void setStatus(int status) { + this.status = status; + } + + @Override + public void setHeader(String name, String value) { + headers.remove(name); + addHeader(name, value); + } + + @Override + public void addHeader(String name, String value) { + String lowerName = name.toLowerCase(); + List values = headers.get(lowerName); + if (values == null) { + values = new ArrayList<>(); + headers.put(lowerName, values); + } + values.add(value); + } + + @Override + public void setChunked() { + if (!chunked) { + if (response != null) { + throw new RuntimeException("setChunked was called after writing a non-chunked response"); + } + chunked = true; + chunkedPipe = new PipedOutputStream(); + InputStream pipeReader; + try { + pipeReader = new PipedInputStream(chunkedPipe); + } catch (IOException e) { + throw new RuntimeException(e); + } + response = Response.newChunkedResponse(statusWithCode(status), + contentType, pipeReader); + setHeaders(response); + response.setUseGzip(false); + + // We need to tell the ServerImpl code to return this response immediately to the server, + // while the handler (which will write the actual stream data) continues executing. That's + // what it provided this CompletableFuture for. + responseReceiver.complete(response); + } + } + + @Override + public void write(byte[] data) { + if (chunked) { + try { + if (data != null) { + chunkedPipe.write(data); + } + chunkedPipe.flush(); + Thread.sleep(200); + } catch (Exception e) { + throw new RuntimeException(e); + } + return; + } + + if (response != null) { + throw new RuntimeException("write was called twice for a non-chunked response"); + } + if (data == null) { + data = new byte[0]; + } + if (data.length != 0 && contentType == null) { + contentType = "text/plain"; + } + response = Response.newFixedLengthResponse(statusWithCode(status), + contentType, new ByteArrayInputStream(data), data.length); + setHeaders(response); + } + + @Override + public String getPathParam(int i) { + return null; + } + + private void setHeaders(Response r) { + for (Map.Entry> h: headers.entrySet()) { + String name = h.getKey(); + String value = String.join(",", h.getValue()); + if (name.equals("content-type")) { + r.setMimeType(value); + } else { + r.addHeader(name, value); + // The name addHeader in NanoHTTPD is misleading: it replaces any previous value, so we need + // to pre-concatenate with String.join() if we want multiple values to work. + // https://github.com/NanoHttpd/nanohttpd/issues/629 + } + } + } + + private IStatus statusWithCode(int statusCode) { + IStatus builtin = Status.lookup(statusCode); + if (builtin != null) { + return builtin; + } + return new IStatus() { + @Override + public int getRequestStatus() { + return statusCode; + } + + @Override + public String getDescription() { + return statusCode + " UNKNOWN"; + } + }; + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/CHANGES.md b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/CHANGES.md new file mode 100644 index 0000000..e542b12 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/CHANGES.md @@ -0,0 +1,19 @@ +# Changes Made to nanohttpd Source Code + +This directory contains source code from the nanohttpd project (https://github.com/launchdarkly-labs/nanohttpd) that has been modified for embedding into this project. + +The project was originally forked from: https://github.com/NanoHttpd/nanohttpd +The initial fork was in order to allow for any HTTP verb to be used instead of an enumerated list supported by nanohttpd. + +This package has now been vendored as it was only used by this test-helpers project and only a subset of the entire project was required. + +## Modifications + +### Package Namespace Change +- Changed all package declarations from `org.nanohttpd.*` to `com.launchdarkly.testhelpers.httptest.nanohttpd.*` +- Updated all import statements to reference the new package namespace + +## Original Source +- Repository: https://github.com/NanoHttpd/nanohttpd +- License: BSD 3-Clause (see LICENSE.md in this directory) +- All original copyright notices and license headers have been preserved in source files diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/LICENSE.md b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/LICENSE.md new file mode 100644 index 0000000..6a11673 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/LICENSE.md @@ -0,0 +1,17 @@ +================================================================================ +NOTE: This license applies only to the source code in the +com.launchdarkly.testhelpers.httptest.nanohttpd package and its subpackages. +================================================================================ + +Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, 2010 by Konstantinos Togias +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +* Neither the name of the NanoHttpd organization nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/ClientHandler.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/ClientHandler.java new file mode 100644 index 0000000..c11da19 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/ClientHandler.java @@ -0,0 +1,95 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.util.logging.Level; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles.ITempFileManager; + +/** + * The runnable that will be used for every new client connection. + */ +public class ClientHandler implements Runnable { + + private final NanoHTTPD httpd; + + private final InputStream inputStream; + + private final Socket acceptSocket; + + public ClientHandler(NanoHTTPD httpd, InputStream inputStream, Socket acceptSocket) { + this.httpd = httpd; + this.inputStream = inputStream; + this.acceptSocket = acceptSocket; + } + + public void close() { + NanoHTTPD.safeClose(this.inputStream); + NanoHTTPD.safeClose(this.acceptSocket); + } + + @Override + public void run() { + OutputStream outputStream = null; + try { + outputStream = this.acceptSocket.getOutputStream(); + ITempFileManager tempFileManager = httpd.getTempFileManagerFactory().create(); + HTTPSession session = new HTTPSession(httpd, tempFileManager, this.inputStream, outputStream, this.acceptSocket.getInetAddress()); + while (!this.acceptSocket.isClosed()) { + session.execute(); + } + } catch (Exception e) { + // When the socket is closed by the client, + // we throw our own SocketException + // to break the "keep alive" loop above. If + // the exception was anything other + // than the expected SocketException OR a + // SocketTimeoutException, print the + // stacktrace + if (!(e instanceof SocketException && "NanoHttpd Shutdown".equals(e.getMessage())) && !(e instanceof SocketTimeoutException)) { + NanoHTTPD.LOG.log(Level.SEVERE, "Communication with the client broken, or an bug in the handler code", e); + } + } finally { + NanoHTTPD.safeClose(outputStream); + NanoHTTPD.safeClose(this.inputStream); + NanoHTTPD.safeClose(this.acceptSocket); + httpd.asyncRunner.closed(this); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/HTTPSession.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/HTTPSession.java new file mode 100644 index 0000000..d440bb1 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/HTTPSession.java @@ -0,0 +1,698 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutput; +import java.io.DataOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.net.InetAddress; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.StringTokenizer; +import java.util.logging.Level; +import java.util.regex.Matcher; + +import javax.net.ssl.SSLException; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.NanoHTTPD.ResponseException; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.content.ContentType; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.content.CookieHandler; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.request.Method; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.Response; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.Status; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles.ITempFile; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles.ITempFileManager; + +public class HTTPSession implements IHTTPSession { + + public static final String POST_DATA = "postData"; + + private static final int REQUEST_BUFFER_LEN = 512; + + private static final int MEMORY_STORE_LIMIT = 1024; + + public static final int BUFSIZE = 8192; + + public static final int MAX_HEADER_SIZE = 1024; + + private final NanoHTTPD httpd; + + private final ITempFileManager tempFileManager; + + private final OutputStream outputStream; + + private final BufferedInputStream inputStream; + + private int splitbyte; + + private int rlen; + + private String uri; + + private Method method; + + private Map> parms; + + private Map headers; + + private CookieHandler cookies; + + private String queryParameterString; + + private String remoteIp; + + private String protocolVersion; + + public HTTPSession(NanoHTTPD httpd, ITempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream) { + this.httpd = httpd; + this.tempFileManager = tempFileManager; + this.inputStream = new BufferedInputStream(inputStream, HTTPSession.BUFSIZE); + this.outputStream = outputStream; + } + + public HTTPSession(NanoHTTPD httpd, ITempFileManager tempFileManager, InputStream inputStream, OutputStream outputStream, InetAddress inetAddress) { + this.httpd = httpd; + this.tempFileManager = tempFileManager; + this.inputStream = new BufferedInputStream(inputStream, HTTPSession.BUFSIZE); + this.outputStream = outputStream; + this.remoteIp = inetAddress.isLoopbackAddress() || inetAddress.isAnyLocalAddress() ? "127.0.0.1" : inetAddress.getHostAddress().toString(); + this.headers = new HashMap(); + } + + /** + * Decodes the sent headers and loads the data into Key/value pairs + */ + private void decodeHeader(BufferedReader in, Map pre, Map> parms, Map headers) throws ResponseException { + try { + // Read the request line + String inLine = in.readLine(); + if (inLine == null) { + return; + } + + StringTokenizer st = new StringTokenizer(inLine); + if (!st.hasMoreTokens()) { + throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Syntax error. Usage: GET /example/file.html"); + } + + pre.put("method", st.nextToken()); + + if (!st.hasMoreTokens()) { + throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Missing URI. Usage: GET /example/file.html"); + } + + String uri = st.nextToken(); + + // Decode parameters from the URI + int qmi = uri.indexOf('?'); + if (qmi >= 0) { + decodeParms(uri.substring(qmi + 1), parms); + uri = NanoHTTPD.decodePercent(uri.substring(0, qmi)); + } else { + uri = NanoHTTPD.decodePercent(uri); + } + + // If there's another token, its protocol version, + // followed by HTTP headers. + // NOTE: this now forces header names lower case since they are + // case insensitive and vary by client. + if (st.hasMoreTokens()) { + protocolVersion = st.nextToken(); + } else { + protocolVersion = "HTTP/1.1"; + NanoHTTPD.LOG.log(Level.FINE, "no protocol version specified, strange. Assuming HTTP/1.1."); + } + String line = in.readLine(); + while (line != null && !line.trim().isEmpty()) { + int p = line.indexOf(':'); + if (p >= 0) { + headers.put(line.substring(0, p).trim().toLowerCase(Locale.US), line.substring(p + 1).trim()); + } + line = in.readLine(); + } + + pre.put("uri", uri); + } catch (IOException ioe) { + throw new ResponseException(Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe); + } + } + + /** + * Decodes the Multipart Body data and put it into Key/Value pairs. + */ + private void decodeMultipartFormData(ContentType contentType, ByteBuffer fbuf, Map> parms, Map files) throws ResponseException { + int pcount = 0; + try { + int[] boundaryIdxs = getBoundaryPositions(fbuf, contentType.getBoundary().getBytes()); + if (boundaryIdxs.length < 2) { + throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but contains less than two boundary strings."); + } + + byte[] partHeaderBuff = new byte[MAX_HEADER_SIZE]; + for (int boundaryIdx = 0; boundaryIdx < boundaryIdxs.length - 1; boundaryIdx++) { + fbuf.position(boundaryIdxs[boundaryIdx]); + int len = (fbuf.remaining() < MAX_HEADER_SIZE) ? fbuf.remaining() : MAX_HEADER_SIZE; + fbuf.get(partHeaderBuff, 0, len); + BufferedReader in = + new BufferedReader(new InputStreamReader(new ByteArrayInputStream(partHeaderBuff, 0, len), Charset.forName(contentType.getEncoding())), len); + + int headerLines = 0; + // First line is boundary string + String mpline = in.readLine(); + headerLines++; + if (mpline == null || !mpline.contains(contentType.getBoundary())) { + throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but chunk does not start with boundary."); + } + + String partName = null, fileName = null, partContentType = null; + // Parse the reset of the header lines + mpline = in.readLine(); + headerLines++; + while (mpline != null && mpline.trim().length() > 0) { + Matcher matcher = NanoHTTPD.CONTENT_DISPOSITION_PATTERN.matcher(mpline); + if (matcher.matches()) { + String attributeString = matcher.group(2); + matcher = NanoHTTPD.CONTENT_DISPOSITION_ATTRIBUTE_PATTERN.matcher(attributeString); + while (matcher.find()) { + String key = matcher.group(1); + if ("name".equalsIgnoreCase(key)) { + partName = matcher.group(2); + } else if ("filename".equalsIgnoreCase(key)) { + fileName = matcher.group(2); + // add these two line to support multiple + // files uploaded using the same field Id + if (!fileName.isEmpty()) { + if (pcount > 0) + partName = partName + String.valueOf(pcount++); + else + pcount++; + } + } + } + } + matcher = NanoHTTPD.CONTENT_TYPE_PATTERN.matcher(mpline); + if (matcher.matches()) { + partContentType = matcher.group(2).trim(); + } + mpline = in.readLine(); + headerLines++; + } + int partHeaderLength = 0; + while (headerLines-- > 0) { + partHeaderLength = scipOverNewLine(partHeaderBuff, partHeaderLength); + } + // Read the part data + if (partHeaderLength >= len - 4) { + throw new ResponseException(Status.INTERNAL_ERROR, "Multipart header size exceeds MAX_HEADER_SIZE."); + } + int partDataStart = boundaryIdxs[boundaryIdx] + partHeaderLength; + int partDataEnd = boundaryIdxs[boundaryIdx + 1] - 4; + + fbuf.position(partDataStart); + + List values = parms.get(partName); + if (values == null) { + values = new ArrayList(); + parms.put(partName, values); + } + + if (partContentType == null) { + // Read the part into a string + byte[] data_bytes = new byte[partDataEnd - partDataStart]; + fbuf.get(data_bytes); + + values.add(new String(data_bytes, contentType.getEncoding())); + } else { + // Read it into a file + String path = saveTmpFile(fbuf, partDataStart, partDataEnd - partDataStart, fileName); + if (!files.containsKey(partName)) { + files.put(partName, path); + } else { + int count = 2; + while (files.containsKey(partName + count)) { + count++; + } + files.put(partName + count, path); + } + values.add(fileName); + } + } + } catch (ResponseException re) { + throw re; + } catch (Exception e) { + throw new ResponseException(Status.INTERNAL_ERROR, e.toString()); + } + } + + private int scipOverNewLine(byte[] partHeaderBuff, int index) { + while (partHeaderBuff[index] != '\n') { + index++; + } + return ++index; + } + + /** + * Decodes parameters in percent-encoded URI-format ( e.g. + * "name=Jack%20Daniels&pass=Single%20Malt" ) and adds them to given Map. + */ + private void decodeParms(String parms, Map> p) { + if (parms == null) { + this.queryParameterString = ""; + return; + } + + this.queryParameterString = parms; + StringTokenizer st = new StringTokenizer(parms, "&"); + while (st.hasMoreTokens()) { + String e = st.nextToken(); + int sep = e.indexOf('='); + String key = null; + String value = null; + + if (sep >= 0) { + key = NanoHTTPD.decodePercent(e.substring(0, sep)).trim(); + value = NanoHTTPD.decodePercent(e.substring(sep + 1)); + } else { + key = NanoHTTPD.decodePercent(e).trim(); + value = ""; + } + + List values = p.get(key); + if (values == null) { + values = new ArrayList(); + p.put(key, values); + } + + values.add(value); + } + } + + @Override + public void execute() throws IOException { + Response r = null; + try { + // Read the first 8192 bytes. + // The full header should fit in here. + // Apache's default header limit is 8KB. + // Do NOT assume that a single read will get the entire header + // at once! + byte[] buf = new byte[HTTPSession.BUFSIZE]; + this.splitbyte = 0; + this.rlen = 0; + + int read = -1; + this.inputStream.mark(HTTPSession.BUFSIZE); + try { + read = this.inputStream.read(buf, 0, HTTPSession.BUFSIZE); + } catch (SSLException e) { + throw e; + } catch (IOException e) { + NanoHTTPD.safeClose(this.inputStream); + NanoHTTPD.safeClose(this.outputStream); + throw new SocketException("NanoHttpd Shutdown"); + } + if (read == -1) { + // socket was been closed + NanoHTTPD.safeClose(this.inputStream); + NanoHTTPD.safeClose(this.outputStream); + throw new SocketException("NanoHttpd Shutdown"); + } + while (read > 0) { + this.rlen += read; + this.splitbyte = findHeaderEnd(buf, this.rlen); + if (this.splitbyte > 0) { + break; + } + read = this.inputStream.read(buf, this.rlen, HTTPSession.BUFSIZE - this.rlen); + } + + if (this.splitbyte < this.rlen) { + this.inputStream.reset(); + this.inputStream.skip(this.splitbyte); + } + + this.parms = new HashMap>(); + if (null == this.headers) { + this.headers = new HashMap(); + } else { + this.headers.clear(); + } + + // Create a BufferedReader for parsing the header. + BufferedReader hin = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, this.rlen))); + + // Decode the header into parms and header java properties + Map pre = new HashMap(); + decodeHeader(hin, pre, this.parms, this.headers); + + if (null != this.remoteIp) { + this.headers.put("remote-addr", this.remoteIp); + this.headers.put("http-client-ip", this.remoteIp); + } + + this.method = Method.lookup(pre.get("method")); + if (this.method == null) { + throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Syntax error. HTTP verb " + pre.get("method") + " not allowed"); + } + + this.uri = pre.get("uri"); + + this.cookies = new CookieHandler(this.headers); + + String connection = this.headers.get("connection"); + boolean keepAlive = "HTTP/1.1".equals(protocolVersion) && (connection == null || !connection.matches("(?i).*close.*")); + + // Ok, now do the serve() + + // TODO: long body_size = getBodySize(); + // TODO: long pos_before_serve = this.inputStream.totalRead() + // (requires implementation for totalRead()) + r = httpd.handle(this); + // TODO: this.inputStream.skip(body_size - + // (this.inputStream.totalRead() - pos_before_serve)) + + if (r == null) { + throw new ResponseException(Status.INTERNAL_ERROR, "SERVER INTERNAL ERROR: Serve() returned a null response."); + } else { + String acceptEncoding = this.headers.get("accept-encoding"); + this.cookies.unloadQueue(r); + r.setRequestMethod(this.method); + if (acceptEncoding == null || !acceptEncoding.contains("gzip")) { + r.setUseGzip(false); + } + r.setKeepAlive(keepAlive); + r.send(this.outputStream); + } + if (!keepAlive || r.isCloseConnection()) { + throw new SocketException("NanoHttpd Shutdown"); + } + } catch (SocketException e) { + // throw it out to close socket object (finalAccept) + throw e; + } catch (SocketTimeoutException ste) { + // treat socket timeouts the same way we treat socket exceptions + // i.e. close the stream & finalAccept object by throwing the + // exception up the call stack. + throw ste; + } catch (SSLException ssle) { + Response resp = Response.newFixedLengthResponse(Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SSL PROTOCOL FAILURE: " + ssle.getMessage()); + resp.send(this.outputStream); + NanoHTTPD.safeClose(this.outputStream); + } catch (IOException ioe) { + Response resp = Response.newFixedLengthResponse(Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage()); + resp.send(this.outputStream); + NanoHTTPD.safeClose(this.outputStream); + } catch (ResponseException re) { + Response resp = Response.newFixedLengthResponse(re.getStatus(), NanoHTTPD.MIME_PLAINTEXT, re.getMessage()); + resp.send(this.outputStream); + NanoHTTPD.safeClose(this.outputStream); + } finally { + NanoHTTPD.safeClose(r); + this.tempFileManager.clear(); + } + } + + /** + * Find byte index separating header from body. It must be the last byte of + * the first two sequential new lines. + */ + private int findHeaderEnd(final byte[] buf, int rlen) { + int splitbyte = 0; + while (splitbyte + 1 < rlen) { + + // RFC2616 + if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && splitbyte + 3 < rlen && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') { + return splitbyte + 4; + } + + // tolerance + if (buf[splitbyte] == '\n' && buf[splitbyte + 1] == '\n') { + return splitbyte + 2; + } + splitbyte++; + } + return 0; + } + + /** + * Find the byte positions where multipart boundaries start. This reads a + * large block at a time and uses a temporary buffer to optimize (memory + * mapped) file access. + */ + private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) { + int[] res = new int[0]; + if (b.remaining() < boundary.length) { + return res; + } + + int search_window_pos = 0; + byte[] search_window = new byte[4 * 1024 + boundary.length]; + + int first_fill = (b.remaining() < search_window.length) ? b.remaining() : search_window.length; + b.get(search_window, 0, first_fill); + int new_bytes = first_fill - boundary.length; + + do { + // Search the search_window + for (int j = 0; j < new_bytes; j++) { + for (int i = 0; i < boundary.length; i++) { + if (search_window[j + i] != boundary[i]) + break; + if (i == boundary.length - 1) { + // Match found, add it to results + int[] new_res = new int[res.length + 1]; + System.arraycopy(res, 0, new_res, 0, res.length); + new_res[res.length] = search_window_pos + j; + res = new_res; + } + } + } + search_window_pos += new_bytes; + + // Copy the end of the buffer to the start + System.arraycopy(search_window, search_window.length - boundary.length, search_window, 0, boundary.length); + + // Refill search_window + new_bytes = search_window.length - boundary.length; + new_bytes = (b.remaining() < new_bytes) ? b.remaining() : new_bytes; + b.get(search_window, boundary.length, new_bytes); + } while (new_bytes > 0); + return res; + } + + @Override + public CookieHandler getCookies() { + return this.cookies; + } + + @Override + public final Map getHeaders() { + return this.headers; + } + + @Override + public final InputStream getInputStream() { + return this.inputStream; + } + + @Override + public final Method getMethod() { + return this.method; + } + + /** + * @deprecated use {@link #getParameters()} instead. + */ + @Override + @Deprecated + public final Map getParms() { + Map result = new HashMap(); + for (String key : this.parms.keySet()) { + result.put(key, this.parms.get(key).get(0)); + } + + return result; + } + + @Override + public final Map> getParameters() { + return this.parms; + } + + @Override + public String getQueryParameterString() { + return this.queryParameterString; + } + + private RandomAccessFile getTmpBucket() { + try { + ITempFile tempFile = this.tempFileManager.createTempFile(null); + return new RandomAccessFile(tempFile.getName(), "rw"); + } catch (Exception e) { + throw new Error(e); // we won't recover, so throw an error + } + } + + @Override + public final String getUri() { + return this.uri; + } + + /** + * Deduce body length in bytes. Either from "content-length" header or read + * bytes. + */ + public long getBodySize() { + if (this.headers.containsKey("content-length")) { + return Long.parseLong(this.headers.get("content-length")); + } else if (this.splitbyte < this.rlen) { + return this.rlen - this.splitbyte; + } + return 0; + } + + @Override + public void parseBody(Map files) throws IOException, ResponseException { + RandomAccessFile randomAccessFile = null; + try { + long size = getBodySize(); + ByteArrayOutputStream baos = null; + DataOutput requestDataOutput = null; + + // Store the request in memory or a file, depending on size + if (size < MEMORY_STORE_LIMIT) { + baos = new ByteArrayOutputStream(); + requestDataOutput = new DataOutputStream(baos); + } else { + randomAccessFile = getTmpBucket(); + requestDataOutput = randomAccessFile; + } + + // Read all the body and write it to request_data_output + byte[] buf = new byte[REQUEST_BUFFER_LEN]; + while (this.rlen >= 0 && size > 0) { + this.rlen = this.inputStream.read(buf, 0, (int) Math.min(size, REQUEST_BUFFER_LEN)); + size -= this.rlen; + if (this.rlen > 0) { + requestDataOutput.write(buf, 0, this.rlen); + } + } + + ByteBuffer fbuf = null; + if (baos != null) { + fbuf = ByteBuffer.wrap(baos.toByteArray(), 0, baos.size()); + } else { + fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length()); + randomAccessFile.seek(0); + } + + // If the method is POST, there may be parameters + // in data section, too, read it: + if (Method.POST.equals(this.method)) { + ContentType contentType = new ContentType(this.headers.get("content-type")); + if (contentType.isMultipart()) { + String boundary = contentType.getBoundary(); + if (boundary == null) { + throw new ResponseException(Status.BAD_REQUEST, "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html"); + } + decodeMultipartFormData(contentType, fbuf, this.parms, files); + } else { + byte[] postBytes = new byte[fbuf.remaining()]; + fbuf.get(postBytes); + String postLine = new String(postBytes, contentType.getEncoding()).trim(); + // Handle application/x-www-form-urlencoded + if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType.getContentType())) { + decodeParms(postLine, this.parms); + } else if (postLine.length() != 0) { + // Special case for raw POST data => create a + // special files entry "postData" with raw content + // data + files.put(POST_DATA, postLine); + } + } + } else if (Method.PUT.equals(this.method)) { + files.put("content", saveTmpFile(fbuf, 0, fbuf.limit(), null)); + } + } finally { + NanoHTTPD.safeClose(randomAccessFile); + } + } + + /** + * Retrieves the content of a sent file and saves it to a temporary file. + * The full path to the saved file is returned. + */ + private String saveTmpFile(ByteBuffer b, int offset, int len, String filename_hint) { + String path = ""; + if (len > 0) { + FileOutputStream fileOutputStream = null; + try { + ITempFile tempFile = this.tempFileManager.createTempFile(filename_hint); + ByteBuffer src = b.duplicate(); + fileOutputStream = new FileOutputStream(tempFile.getName()); + FileChannel dest = fileOutputStream.getChannel(); + src.position(offset).limit(offset + len); + dest.write(src.slice()); + path = tempFile.getName(); + } catch (Exception e) { // Catch exception if any + throw new Error(e); // we won't recover, so throw an error + } finally { + NanoHTTPD.safeClose(fileOutputStream); + } + } + return path; + } + + @Override + public String getRemoteIpAddress() { + return this.remoteIp; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/IHTTPSession.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/IHTTPSession.java new file mode 100644 index 0000000..d81c86f --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/IHTTPSession.java @@ -0,0 +1,93 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.NanoHTTPD.ResponseException; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.content.CookieHandler; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.request.Method; + +/** + * Handles one session, i.e. parses the HTTP request and returns the response. + */ +public interface IHTTPSession { + + void execute() throws IOException; + + CookieHandler getCookies(); + + Map getHeaders(); + + InputStream getInputStream(); + + Method getMethod(); + + /** + * This method will only return the first value for a given parameter. You + * will want to use getParameters if you expect multiple values for a given + * key. + * + * @deprecated use {@link #getParameters()} instead. + */ + @Deprecated + Map getParms(); + + Map> getParameters(); + + String getQueryParameterString(); + + /** + * @return the path part of the URL. + */ + String getUri(); + + /** + * Adds the files in the request body to the files map. + * + * @param files + * map to modify + */ + void parseBody(Map files) throws IOException, ResponseException; + + /** + * Get the remote ip address of the requester. + * + * @return the IP address. + */ + String getRemoteIpAddress(); +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/NanoHTTPD.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/NanoHTTPD.java new file mode 100644 index 0000000..0a12a49 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/NanoHTTPD.java @@ -0,0 +1,641 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.net.URLDecoder; +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.StringTokenizer; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.TrustManagerFactory; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.Response; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.Status; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.sockets.DefaultServerSocketFactory; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.sockets.SecureServerSocketFactory; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles.DefaultTempFileManagerFactory; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles.ITempFileManager; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.threading.DefaultAsyncRunner; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.threading.IAsyncRunner; +import com.launchdarkly.testhelpers.httptest.nanohttpd.util.IFactory; +import com.launchdarkly.testhelpers.httptest.nanohttpd.util.IFactoryThrowing; +import com.launchdarkly.testhelpers.httptest.nanohttpd.util.IHandler; + +/** + * A simple, tiny, nicely embeddable HTTP server in Java + *

+ *

+ * NanoHTTPD + *

+ * Copyright (c) 2012-2013 by Paul S. Hawke, 2001,2005-2013 by Jarno Elonen, + * 2010 by Konstantinos Togias + *

+ *

+ *

+ * Features + limitations: + *

    + *

    + *

  • Only one Java file
  • + *
  • Java 5 compatible
  • + *
  • Released as open source, Modified BSD licence
  • + *
  • No fixed config files, logging, authorization etc. (Implement yourself if + * you need them.)
  • + *
  • Supports parameter parsing of GET and POST methods (+ rudimentary PUT + * support in 1.25)
  • + *
  • Supports both dynamic content and file serving
  • + *
  • Supports file upload (since version 1.2, 2010)
  • + *
  • Supports partial content (streaming)
  • + *
  • Supports ETags
  • + *
  • Never caches anything
  • + *
  • Doesn't limit bandwidth, request time or simultaneous connections
  • + *
  • Default code serves files and shows all HTTP parameters and headers
  • + *
  • File server supports directory listing, index.html and index.htm
  • + *
  • File server supports partial content (streaming)
  • + *
  • File server supports ETags
  • + *
  • File server does the 301 redirection trick for directories without '/'
  • + *
  • File server supports simple skipping for files (continue download)
  • + *
  • File server serves also very long files without memory overhead
  • + *
  • Contains a built-in list of most common MIME types
  • + *
  • All header names are converted to lower case so they don't vary between + * browsers/clients
  • + *

    + *

+ *

+ *

+ * How to use: + *

    + *

    + *

  • Subclass and implement serve() and embed to your own program
  • + *

    + *

+ *

+ * See the separate "LICENSE.md" file for the distribution license (Modified BSD + * licence) + */ +public abstract class NanoHTTPD { + + public static final String CONTENT_DISPOSITION_REGEX = "([ |\t]*Content-Disposition[ |\t]*:)(.*)"; + + public static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern.compile(CONTENT_DISPOSITION_REGEX, Pattern.CASE_INSENSITIVE); + + public static final String CONTENT_TYPE_REGEX = "([ |\t]*content-type[ |\t]*:)(.*)"; + + public static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile(CONTENT_TYPE_REGEX, Pattern.CASE_INSENSITIVE); + + public static final String CONTENT_DISPOSITION_ATTRIBUTE_REGEX = "[ |\t]*([a-zA-Z]*)[ |\t]*=[ |\t]*['|\"]([^\"^']*)['|\"]"; + + public static final Pattern CONTENT_DISPOSITION_ATTRIBUTE_PATTERN = Pattern.compile(CONTENT_DISPOSITION_ATTRIBUTE_REGEX); + + public static final class ResponseException extends Exception { + + private static final long serialVersionUID = 6569838532917408380L; + + private final Status status; + + public ResponseException(Status status, String message) { + super(message); + this.status = status; + } + + public ResponseException(Status status, String message, Exception e) { + super(message, e); + this.status = status; + } + + public Status getStatus() { + return this.status; + } + } + + /** + * Maximum time to wait on Socket.getInputStream().read() (in milliseconds) + * This is required as the Keep-Alive HTTP connections would otherwise block + * the socket reading thread forever (or as long the browser is open). + */ + public static final int SOCKET_READ_TIMEOUT = 5000; + + /** + * Common MIME type for dynamic content: plain text + */ + public static final String MIME_PLAINTEXT = "text/plain"; + + /** + * Common MIME type for dynamic content: html + */ + public static final String MIME_HTML = "text/html"; + + /** + * Pseudo-Parameter to use to store the actual query string in the + * parameters map for later re-processing. + */ + private static final String QUERY_STRING_PARAMETER = "NanoHttpd.QUERY_STRING"; + + /** + * logger to log to. + */ + public static final Logger LOG = Logger.getLogger(NanoHTTPD.class.getName()); + + /** + * Hashtable mapping (String)FILENAME_EXTENSION -> (String)MIME_TYPE + */ + protected static Map MIME_TYPES; + + public static Map mimeTypes() { + if (MIME_TYPES == null) { + MIME_TYPES = new HashMap(); + loadMimeTypes(MIME_TYPES, "META-INF/nanohttpd/default-mimetypes.properties"); + loadMimeTypes(MIME_TYPES, "META-INF/nanohttpd/mimetypes.properties"); + if (MIME_TYPES.isEmpty()) { + LOG.log(Level.WARNING, "no mime types found in the classpath! please provide mimetypes.properties"); + } + } + return MIME_TYPES; + } + + @SuppressWarnings({ + "unchecked", + "rawtypes" + }) + private static void loadMimeTypes(Map result, String resourceName) { + try { + Enumeration resources = NanoHTTPD.class.getClassLoader().getResources(resourceName); + while (resources.hasMoreElements()) { + URL url = (URL) resources.nextElement(); + Properties properties = new Properties(); + InputStream stream = null; + try { + stream = url.openStream(); + properties.load(stream); + } catch (IOException e) { + LOG.log(Level.SEVERE, "could not load mimetypes from " + url, e); + } finally { + safeClose(stream); + } + result.putAll((Map) properties); + } + } catch (IOException e) { + LOG.log(Level.INFO, "no mime types available at " + resourceName); + } + }; + + /** + * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and an + * array of loaded KeyManagers. These objects must properly + * loaded/initialized by the caller. + */ + public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManager[] keyManagers) throws IOException { + SSLServerSocketFactory res = null; + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(loadedKeyStore); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(keyManagers, trustManagerFactory.getTrustManagers(), null); + res = ctx.getServerSocketFactory(); + } catch (Exception e) { + throw new IOException(e.getMessage()); + } + return res; + } + + /** + * Creates an SSLSocketFactory for HTTPS. Pass a loaded KeyStore and a + * loaded KeyManagerFactory. These objects must properly loaded/initialized + * by the caller. + */ + public static SSLServerSocketFactory makeSSLSocketFactory(KeyStore loadedKeyStore, KeyManagerFactory loadedKeyFactory) throws IOException { + try { + return makeSSLSocketFactory(loadedKeyStore, loadedKeyFactory.getKeyManagers()); + } catch (Exception e) { + throw new IOException(e.getMessage()); + } + } + + /** + * Creates an SSLSocketFactory for HTTPS. Pass a KeyStore resource with your + * certificate and passphrase + */ + public static SSLServerSocketFactory makeSSLSocketFactory(String keyAndTrustStoreClasspathPath, char[] passphrase) throws IOException { + try { + KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); + InputStream keystoreStream = NanoHTTPD.class.getResourceAsStream(keyAndTrustStoreClasspathPath); + + if (keystoreStream == null) { + throw new IOException("Unable to load keystore from classpath: " + keyAndTrustStoreClasspathPath); + } + + keystore.load(keystoreStream, passphrase); + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keystore, passphrase); + return makeSSLSocketFactory(keystore, keyManagerFactory); + } catch (Exception e) { + throw new IOException(e.getMessage()); + } + } + + /** + * Get MIME type from file name extension, if possible + * + * @param uri + * the string representing a file + * @return the connected mime/type + */ + public static String getMimeTypeForFile(String uri) { + int dot = uri.lastIndexOf('.'); + String mime = null; + if (dot >= 0) { + mime = mimeTypes().get(uri.substring(dot + 1).toLowerCase()); + } + return mime == null ? "application/octet-stream" : mime; + } + + public static final void safeClose(Object closeable) { + try { + if (closeable != null) { + if (closeable instanceof Closeable) { + ((Closeable) closeable).close(); + } else if (closeable instanceof Socket) { + ((Socket) closeable).close(); + } else if (closeable instanceof ServerSocket) { + ((ServerSocket) closeable).close(); + } else { + throw new IllegalArgumentException("Unknown object to close"); + } + } + } catch (IOException e) { + NanoHTTPD.LOG.log(Level.SEVERE, "Could not close", e); + } + } + + public final String hostname; + + public final int myPort; + + private volatile ServerSocket myServerSocket; + + public ServerSocket getMyServerSocket() { + return myServerSocket; + } + + private IFactoryThrowing serverSocketFactory = new DefaultServerSocketFactory(); + + private Thread myThread; + + private IHandler httpHandler; + + protected List> interceptors = new ArrayList>(4); + + /** + * Pluggable strategy for asynchronously executing requests. + */ + protected IAsyncRunner asyncRunner; + + /** + * Pluggable strategy for creating and cleaning up temporary files. + */ + private IFactory tempFileManagerFactory; + + /** + * Constructs an HTTP server on given port. + */ + public NanoHTTPD(int port) { + this(null, port); + } + + // ------------------------------------------------------------------------------- + // // + // + // Threading Strategy. + // + // ------------------------------------------------------------------------------- + // // + + /** + * Constructs an HTTP server on given hostname and port. + */ + public NanoHTTPD(String hostname, int port) { + this.hostname = hostname; + this.myPort = port; + setTempFileManagerFactory(new DefaultTempFileManagerFactory()); + setAsyncRunner(new DefaultAsyncRunner()); + + // creates a default handler that redirects to deprecated serve(); + this.httpHandler = new IHandler() { + + @Override + public Response handle(IHTTPSession input) { + return NanoHTTPD.this.serve(input); + } + }; + } + + public void setHTTPHandler(IHandler handler) { + this.httpHandler = handler; + } + + public void addHTTPInterceptor(IHandler interceptor) { + interceptors.add(interceptor); + } + + /** + * Forcibly closes all connections that are open. + */ + public synchronized void closeAllConnections() { + stop(); + } + + /** + * create a instance of the client handler, subclasses can return a subclass + * of the ClientHandler. + * + * @param finalAccept + * the socket the cleint is connected to + * @param inputStream + * the input stream + * @return the client handler + */ + protected ClientHandler createClientHandler(final Socket finalAccept, final InputStream inputStream) { + return new ClientHandler(this, inputStream, finalAccept); + } + + /** + * Instantiate the server runnable, can be overwritten by subclasses to + * provide a subclass of the ServerRunnable. + * + * @param timeout + * the socet timeout to use. + * @return the server runnable. + */ + protected ServerRunnable createServerRunnable(final int timeout) { + return new ServerRunnable(this, timeout); + } + + /** + * Decode parameters from a URL, handing the case where a single parameter + * name might have been supplied several times, by return lists of values. + * In general these lists will contain a single element. + * + * @param parms + * original NanoHTTPD parameters values, as passed to the + * serve() method. + * @return a map of String (parameter name) to + * List<String> (a list of the values supplied). + */ + protected static Map> decodeParameters(Map parms) { + return decodeParameters(parms.get(NanoHTTPD.QUERY_STRING_PARAMETER)); + } + + // ------------------------------------------------------------------------------- + // // + + /** + * Decode parameters from a URL, handing the case where a single parameter + * name might have been supplied several times, by return lists of values. + * In general these lists will contain a single element. + * + * @param queryString + * a query string pulled from the URL. + * @return a map of String (parameter name) to + * List<String> (a list of the values supplied). + */ + protected static Map> decodeParameters(String queryString) { + Map> parms = new HashMap>(); + if (queryString != null) { + StringTokenizer st = new StringTokenizer(queryString, "&"); + while (st.hasMoreTokens()) { + String e = st.nextToken(); + int sep = e.indexOf('='); + String propertyName = sep >= 0 ? decodePercent(e.substring(0, sep)).trim() : decodePercent(e).trim(); + if (!parms.containsKey(propertyName)) { + parms.put(propertyName, new ArrayList()); + } + String propertyValue = sep >= 0 ? decodePercent(e.substring(sep + 1)) : null; + if (propertyValue != null) { + parms.get(propertyName).add(propertyValue); + } + } + } + return parms; + } + + /** + * Decode percent encoded String values. + * + * @param str + * the percent encoded String + * @return expanded form of the input, for example "foo%20bar" becomes + * "foo bar" + */ + public static String decodePercent(String str) { + String decoded = null; + try { + decoded = URLDecoder.decode(str, "UTF8"); + } catch (UnsupportedEncodingException ignored) { + NanoHTTPD.LOG.log(Level.WARNING, "Encoding not supported, ignored", ignored); + } + return decoded; + } + + public final int getListeningPort() { + return this.myServerSocket == null ? -1 : this.myServerSocket.getLocalPort(); + } + + public final boolean isAlive() { + return wasStarted() && !this.myServerSocket.isClosed() && this.myThread.isAlive(); + } + + public IFactoryThrowing getServerSocketFactory() { + return serverSocketFactory; + } + + public void setServerSocketFactory(IFactoryThrowing serverSocketFactory) { + this.serverSocketFactory = serverSocketFactory; + } + + public String getHostname() { + return hostname; + } + + public IFactory getTempFileManagerFactory() { + return tempFileManagerFactory; + } + + /** + * Call before start() to serve over HTTPS instead of HTTP + */ + public void makeSecure(SSLServerSocketFactory sslServerSocketFactory, String[] sslProtocols) { + this.serverSocketFactory = new SecureServerSocketFactory(sslServerSocketFactory, sslProtocols); + } + + /** + * This is the "master" method that delegates requests to handlers and makes + * sure there is a response to every request. You are not supposed to call + * or override this method in any circumstances. But no one will stop you if + * you do. I'm a Javadoc, not Code Police. + * + * @param session + * the incoming session + * @return a response to the incoming session + */ + public Response handle(IHTTPSession session) { + for (IHandler interceptor : interceptors) { + Response response = interceptor.handle(session); + if (response != null) + return response; + } + return httpHandler.handle(session); + } + + /** + * Override this to customize the server. + *

+ *

+ * (By default, this returns a 404 "Not Found" plain text error response.) + * + * @param session + * The HTTP session + * @return HTTP response, see class Response for details + */ + @Deprecated + protected Response serve(IHTTPSession session) { + return Response.newFixedLengthResponse(Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Not Found"); + } + + /** + * Pluggable strategy for asynchronously executing requests. + * + * @param asyncRunner + * new strategy for handling threads. + */ + public void setAsyncRunner(IAsyncRunner asyncRunner) { + this.asyncRunner = asyncRunner; + } + + /** + * Pluggable strategy for creating and cleaning up temporary files. + * + * @param tempFileManagerFactory + * new strategy for handling temp files. + */ + public void setTempFileManagerFactory(IFactory tempFileManagerFactory) { + this.tempFileManagerFactory = tempFileManagerFactory; + } + + /** + * Start the server. + * + * @throws IOException + * if the socket is in use. + */ + public void start() throws IOException { + start(NanoHTTPD.SOCKET_READ_TIMEOUT); + } + + /** + * Starts the server (in setDaemon(true) mode). + */ + public void start(final int timeout) throws IOException { + start(timeout, true); + } + + /** + * Start the server. + * + * @param timeout + * timeout to use for socket connections. + * @param daemon + * start the thread daemon or not. + * @throws IOException + * if the socket is in use. + */ + public void start(final int timeout, boolean daemon) throws IOException { + this.myServerSocket = this.getServerSocketFactory().create(); + this.myServerSocket.setReuseAddress(true); + + ServerRunnable serverRunnable = createServerRunnable(timeout); + this.myThread = new Thread(serverRunnable); + this.myThread.setDaemon(daemon); + this.myThread.setName("NanoHttpd Main Listener"); + this.myThread.start(); + while (!serverRunnable.hasBinded() && serverRunnable.getBindException() == null) { + try { + Thread.sleep(10L); + } catch (Throwable e) { + // on android this may not be allowed, that's why we + // catch throwable the wait should be very short because we are + // just waiting for the bind of the socket + } + } + if (serverRunnable.getBindException() != null) { + throw serverRunnable.getBindException(); + } + } + + /** + * Stop the server. + */ + public void stop() { + try { + safeClose(this.myServerSocket); + this.asyncRunner.closeAll(); + if (this.myThread != null) { + this.myThread.join(); + } + } catch (Exception e) { + NanoHTTPD.LOG.log(Level.SEVERE, "Could not stop all connections", e); + } + } + + public final boolean wasStarted() { + return this.myServerSocket != null && this.myThread != null; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/ServerRunnable.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/ServerRunnable.java new file mode 100644 index 0000000..3ef23de --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/ServerRunnable.java @@ -0,0 +1,90 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.logging.Level; + +/** + * The runnable that will be used for the main listening thread. + */ +public class ServerRunnable implements Runnable { + + private NanoHTTPD httpd; + + private final int timeout; + + private IOException bindException; + + private boolean hasBinded = false; + + public ServerRunnable(NanoHTTPD httpd, int timeout) { + this.httpd = httpd; + this.timeout = timeout; + } + + @Override + public void run() { + try { + httpd.getMyServerSocket().bind(httpd.hostname != null ? new InetSocketAddress(httpd.hostname, httpd.myPort) : new InetSocketAddress(httpd.myPort)); + hasBinded = true; + } catch (IOException e) { + this.bindException = e; + return; + } + do { + try { + final Socket finalAccept = httpd.getMyServerSocket().accept(); + if (this.timeout > 0) { + finalAccept.setSoTimeout(this.timeout); + } + final InputStream inputStream = finalAccept.getInputStream(); + httpd.asyncRunner.exec(httpd.createClientHandler(finalAccept, inputStream)); + } catch (IOException e) { + NanoHTTPD.LOG.log(Level.FINE, "Communication with the client broken", e); + } + } while (!httpd.getMyServerSocket().isClosed()); + } + + public IOException getBindException() { + return bindException; + } + + public boolean hasBinded() { + return hasBinded; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/ContentType.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/ContentType.java new file mode 100644 index 0000000..dbe26d4 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/ContentType.java @@ -0,0 +1,112 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.content; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ContentType { + + private static final String ASCII_ENCODING = "US-ASCII"; + + private static final String MULTIPART_FORM_DATA_HEADER = "multipart/form-data"; + + private static final String CONTENT_REGEX = "[ |\t]*([^/^ ^;^,]+/[^ ^;^,]+)"; + + private static final Pattern MIME_PATTERN = Pattern.compile(CONTENT_REGEX, Pattern.CASE_INSENSITIVE); + + private static final String CHARSET_REGEX = "[ |\t]*(charset)[ |\t]*=[ |\t]*['|\"]?([^\"^'^;^,]*)['|\"]?"; + + private static final Pattern CHARSET_PATTERN = Pattern.compile(CHARSET_REGEX, Pattern.CASE_INSENSITIVE); + + private static final String BOUNDARY_REGEX = "[ |\t]*(boundary)[ |\t]*=[ |\t]*['|\"]?([^\"^'^;^,]*)['|\"]?"; + + private static final Pattern BOUNDARY_PATTERN = Pattern.compile(BOUNDARY_REGEX, Pattern.CASE_INSENSITIVE); + + private final String contentTypeHeader; + + private final String contentType; + + private final String encoding; + + private final String boundary; + + public ContentType(String contentTypeHeader) { + this.contentTypeHeader = contentTypeHeader; + if (contentTypeHeader != null) { + contentType = getDetailFromContentHeader(contentTypeHeader, MIME_PATTERN, "", 1); + encoding = getDetailFromContentHeader(contentTypeHeader, CHARSET_PATTERN, null, 2); + } else { + contentType = ""; + encoding = "UTF-8"; + } + if (MULTIPART_FORM_DATA_HEADER.equalsIgnoreCase(contentType)) { + boundary = getDetailFromContentHeader(contentTypeHeader, BOUNDARY_PATTERN, null, 2); + } else { + boundary = null; + } + } + + private String getDetailFromContentHeader(String contentTypeHeader, Pattern pattern, String defaultValue, int group) { + Matcher matcher = pattern.matcher(contentTypeHeader); + return matcher.find() ? matcher.group(group) : defaultValue; + } + + public String getContentTypeHeader() { + return contentTypeHeader; + } + + public String getContentType() { + return contentType; + } + + public String getEncoding() { + return encoding == null ? ASCII_ENCODING : encoding; + } + + public String getBoundary() { + return boundary; + } + + public boolean isMultipart() { + return MULTIPART_FORM_DATA_HEADER.equalsIgnoreCase(contentType); + } + + public ContentType tryUTF8() { + if (encoding == null) { + return new ContentType(this.contentTypeHeader + "; charset=UTF-8"); + } + return this; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/Cookie.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/Cookie.java new file mode 100644 index 0000000..b537faf --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/Cookie.java @@ -0,0 +1,78 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.content; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * A simple cookie representation. This is old code and is flawed in many ways. + * + * @author LordFokas + */ +public class Cookie { + + public static String getHTTPTime(int days) { + Calendar calendar = Calendar.getInstance(); + SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); + calendar.add(Calendar.DAY_OF_MONTH, days); + return dateFormat.format(calendar.getTime()); + } + + private final String n, v, e; + + public Cookie(String name, String value) { + this(name, value, 30); + } + + public Cookie(String name, String value, int numDays) { + this.n = name; + this.v = value; + this.e = getHTTPTime(numDays); + } + + public Cookie(String name, String value, String expires) { + this.n = name; + this.v = value; + this.e = expires; + } + + public String getHTTPHeader() { + String fmt = "%s=%s; expires=%s"; + return String.format(fmt, this.n, this.v, this.e); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/CookieHandler.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/CookieHandler.java new file mode 100644 index 0000000..26822fa --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/content/CookieHandler.java @@ -0,0 +1,127 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.content; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response.Response; + +/** + * Provides rudimentary support for cookies. Doesn't support 'path', 'secure' + * nor 'httpOnly'. Feel free to improve it and/or add unsupported features. This + * is old code and it's flawed in many ways. + * + * @author LordFokas + */ +public class CookieHandler implements Iterable { + + private final HashMap cookies = new HashMap(); + + private final ArrayList queue = new ArrayList(); + + public CookieHandler(Map httpHeaders) { + String raw = httpHeaders.get("cookie"); + if (raw != null) { + String[] tokens = raw.split(";"); + for (String token : tokens) { + String[] data = token.trim().split("="); + if (data.length == 2) { + this.cookies.put(data[0], data[1]); + } + } + } + } + + /** + * Set a cookie with an expiration date from a month ago, effectively + * deleting it on the client side. + * + * @param name + * The cookie name. + */ + public void delete(String name) { + set(name, "-delete-", -30); + } + + @Override + public Iterator iterator() { + return this.cookies.keySet().iterator(); + } + + /** + * Read a cookie from the HTTP Headers. + * + * @param name + * The cookie's name. + * @return The cookie's value if it exists, null otherwise. + */ + public String read(String name) { + return this.cookies.get(name); + } + + public void set(Cookie cookie) { + this.queue.add(cookie); + } + + /** + * Sets a cookie. + * + * @param name + * The cookie's name. + * @param value + * The cookie's value. + * @param expires + * How many days until the cookie expires. + */ + public void set(String name, String value, int expires) { + this.queue.add(new Cookie(name, value, Cookie.getHTTPTime(expires))); + } + + /** + * Internally used by the webserver to add all queued cookies into the + * Response's HTTP Headers. + * + * @param response + * The Response object to which headers the queued cookies will + * be added. + */ + public void unloadQueue(Response response) { + for (Cookie cookie : this.queue) { + response.addCookieHeader(cookie.getHTTPHeader()); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/request/Method.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/request/Method.java new file mode 100644 index 0000000..3eb9439 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/request/Method.java @@ -0,0 +1,122 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.request; + +import java.util.HashMap; +import java.util.Map; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +/** + * Represents an HTTP request method/verb. This class includes predefined instances for commonly + * used verbs, but it is not an enum, because HTTP allows other verbs to be used as long as the + * client and server both agree they are valid. + */ +public final class Method { + private String name; + + private Method(String name) { + this.name = name; + } + + private static Map BUILTINS = new HashMap<>(); + private static Method makeBuiltin(String name) { + Method m = new Method(name); + BUILTINS.put(name, m); + return m; + } + + public static Method + GET = makeBuiltin("GET"), + PUT = makeBuiltin("PUT"), + POST = makeBuiltin("POST"), + DELETE = makeBuiltin("DELETE"), + HEAD = makeBuiltin("HEAD"), + OPTIONS = makeBuiltin("OPTIONS"), + TRACE = makeBuiltin("TRACE"), + CONNECT = makeBuiltin("CONNECT"), + PATCH = makeBuiltin("PATCH"), + PROPFIND = makeBuiltin("PROPFIND"), + PROPPATCH = makeBuiltin("PROPPATCH"), + MKCOL = makeBuiltin("MKCOL"), + MOVE = makeBuiltin("MOVE"), + LOCK = makeBuiltin("LOCK"), + UNLOCK = makeBuiltin("UNLOCK"), + NOTIFY = makeBuiltin("NOTIFY"), + SUBSCRIBE = makeBuiltin("SUBSCRIBE"); + + /** + * Returns a Method instance for the given name, as long as it is syntactically valid. + * @param name the method name as a string + * @return a Method, or null if not valid + */ + public static Method lookup(String name) { + if (name == null) { + return null; + } + Method m = BUILTINS.get(name); + if (m != null) { + return m; + } + return isValid(name) ? new Method(name) : null; + } + + private static boolean isValid(String name) { + // allowable character set is the same as for any "token" in HTTP: no control chars or separators + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if (ch <= ' ' || "()<>@,;:\\\"/[]?={}".contains(String.valueOf(ch))) { + return false; + } + } + return true; + } + + public String name() { // for backward compatibility with code that treated this as an enum + return name; + } + + @Override + public String toString() { + return name; + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public boolean equals(Object other) { + return other instanceof Method && ((Method)other).name.equals(name); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/ChunkedOutputStream.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/ChunkedOutputStream.java new file mode 100644 index 0000000..004b17d --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/ChunkedOutputStream.java @@ -0,0 +1,76 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * Output stream that will automatically send every write to the wrapped + * OutputStream according to chunked transfer: + * http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1 + */ +public class ChunkedOutputStream extends FilterOutputStream { + + public ChunkedOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(int b) throws IOException { + byte[] data = { + (byte) b + }; + write(data, 0, 1); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (len == 0) + return; + out.write(String.format("%x\r\n", len).getBytes()); + out.write(b, off, len); + out.write("\r\n".getBytes()); + } + + public void finish() throws IOException { + out.write("0\r\n\r\n".getBytes()); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/IStatus.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/IStatus.java new file mode 100644 index 0000000..c7657cb --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/IStatus.java @@ -0,0 +1,41 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +public interface IStatus { + + String getDescription(); + + int getRequestStatus(); +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/Response.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/Response.java new file mode 100644 index 0000000..b42392a --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/Response.java @@ -0,0 +1,448 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.BufferedWriter; +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TimeZone; +import java.util.logging.Level; +import java.util.zip.GZIPOutputStream; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.NanoHTTPD; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.content.ContentType; +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.request.Method; + +/** + * HTTP response. Return one of these from serve(). + */ +public class Response implements Closeable { + + /** + * HTTP status code after processing, e.g. "200 OK", Status.OK + */ + private IStatus status; + + /** + * MIME type of content, e.g. "text/html" + */ + private String mimeType; + + /** + * Data of the response, may be null. + */ + private InputStream data; + + private long contentLength; + + /** + * Headers for the HTTP response. Use addHeader() to add lines. the + * lowercase map is automatically kept up to date. + */ + @SuppressWarnings("serial") + private final Map header = new HashMap() { + + public String put(String key, String value) { + lowerCaseHeader.put(key == null ? key : key.toLowerCase(), value); + return super.put(key, value); + }; + }; + + /** + * copy of the header map with all the keys lowercase for faster searching. + */ + private final Map lowerCaseHeader = new HashMap(); + + /** + * The request method that spawned this response. + */ + private Method requestMethod; + + /** + * Use chunkedTransfer + */ + private boolean chunkedTransfer; + + private boolean keepAlive; + + private List cookieHeaders; + + private GzipUsage gzipUsage = GzipUsage.DEFAULT; + + private static enum GzipUsage { + DEFAULT, + ALWAYS, + NEVER; + } + + /** + * Creates a fixed length response if totalBytes>=0, otherwise chunked. + */ + @SuppressWarnings({ + "rawtypes", + "unchecked" + }) + protected Response(IStatus status, String mimeType, InputStream data, long totalBytes) { + this.status = status; + this.mimeType = mimeType; + if (data == null) { + this.data = new ByteArrayInputStream(new byte[0]); + this.contentLength = 0L; + } else { + this.data = data; + this.contentLength = totalBytes; + } + this.chunkedTransfer = this.contentLength < 0; + this.keepAlive = true; + this.cookieHeaders = new ArrayList(10); + } + + @Override + public void close() throws IOException { + if (this.data != null) { + this.data.close(); + } + } + + /** + * Adds a cookie header to the list. Should not be called manually, this is + * an internal utility. + */ + public void addCookieHeader(String cookie) { + cookieHeaders.add(cookie); + } + + /** + * Should not be called manually. This is an internally utility for JUnit + * test purposes. + * + * @return All unloaded cookie headers. + */ + public List getCookieHeaders() { + return cookieHeaders; + } + + /** + * Adds given line to the header. + */ + public void addHeader(String name, String value) { + this.header.put(name, value); + } + + /** + * Indicate to close the connection after the Response has been sent. + * + * @param close + * {@code true} to hint connection closing, {@code false} to let + * connection be closed by client. + */ + public void closeConnection(boolean close) { + if (close) + this.header.put("connection", "close"); + else + this.header.remove("connection"); + } + + /** + * @return {@code true} if connection is to be closed after this Response + * has been sent. + */ + public boolean isCloseConnection() { + return "close".equals(getHeader("connection")); + } + + public InputStream getData() { + return this.data; + } + + public String getHeader(String name) { + return this.lowerCaseHeader.get(name.toLowerCase()); + } + + public String getMimeType() { + return this.mimeType; + } + + public Method getRequestMethod() { + return this.requestMethod; + } + + public IStatus getStatus() { + return this.status; + } + + public void setKeepAlive(boolean useKeepAlive) { + this.keepAlive = useKeepAlive; + } + + /** + * Sends given response to the socket. + */ + public void send(OutputStream outputStream) { + SimpleDateFormat gmtFrmt = new SimpleDateFormat("E, d MMM yyyy HH:mm:ss 'GMT'", Locale.US); + gmtFrmt.setTimeZone(TimeZone.getTimeZone("GMT")); + + try { + if (this.status == null) { + throw new Error("sendResponse(): Status can't be null."); + } + PrintWriter pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(outputStream, new ContentType(this.mimeType).getEncoding())), false); + pw.append("HTTP/1.1 ").append(this.status.getDescription()).append(" \r\n"); + if (this.mimeType != null) { + printHeader(pw, "Content-Type", this.mimeType); + } + if (getHeader("date") == null) { + printHeader(pw, "Date", gmtFrmt.format(new Date())); + } + for (Entry entry : this.header.entrySet()) { + printHeader(pw, entry.getKey(), entry.getValue()); + } + for (String cookieHeader : this.cookieHeaders) { + printHeader(pw, "Set-Cookie", cookieHeader); + } + if (getHeader("connection") == null) { + printHeader(pw, "Connection", (this.keepAlive ? "keep-alive" : "close")); + } + if (getHeader("content-length") != null) { + setUseGzip(false); + } + if (useGzipWhenAccepted()) { + printHeader(pw, "Content-Encoding", "gzip"); + setChunkedTransfer(true); + } + long pending = this.data != null ? this.contentLength : 0; + if (this.requestMethod != Method.HEAD && this.chunkedTransfer) { + printHeader(pw, "Transfer-Encoding", "chunked"); + } else if (!useGzipWhenAccepted()) { + pending = sendContentLengthHeaderIfNotAlreadyPresent(pw, pending); + } + pw.append("\r\n"); + pw.flush(); + sendBodyWithCorrectTransferAndEncoding(outputStream, pending); + outputStream.flush(); + NanoHTTPD.safeClose(this.data); + } catch (IOException ioe) { + NanoHTTPD.LOG.log(Level.SEVERE, "Could not send response to the client", ioe); + } + } + + @SuppressWarnings("static-method") + protected void printHeader(PrintWriter pw, String key, String value) { + pw.append(key).append(": ").append(value).append("\r\n"); + } + + protected long sendContentLengthHeaderIfNotAlreadyPresent(PrintWriter pw, long defaultSize) { + String contentLengthString = getHeader("content-length"); + long size = defaultSize; + if (contentLengthString != null) { + try { + size = Long.parseLong(contentLengthString); + } catch (NumberFormatException ex) { + NanoHTTPD.LOG.severe("content-length was no number " + contentLengthString); + } + }else{ + pw.print("Content-Length: " + size + "\r\n"); + } + return size; + } + + private void sendBodyWithCorrectTransferAndEncoding(OutputStream outputStream, long pending) throws IOException { + if (this.requestMethod != Method.HEAD && this.chunkedTransfer) { + ChunkedOutputStream chunkedOutputStream = new ChunkedOutputStream(outputStream); + sendBodyWithCorrectEncoding(chunkedOutputStream, -1); + try { + chunkedOutputStream.finish(); + } catch (Exception e) { + if(this.data != null) { + this.data.close(); + } + } + } else { + sendBodyWithCorrectEncoding(outputStream, pending); + } + } + + private void sendBodyWithCorrectEncoding(OutputStream outputStream, long pending) throws IOException { + if (useGzipWhenAccepted()) { + GZIPOutputStream gzipOutputStream = null; + try { + gzipOutputStream = new GZIPOutputStream(outputStream); + } catch (Exception e) { + if(this.data != null) { + this.data.close(); + } + } + if (gzipOutputStream != null) { + sendBody(gzipOutputStream, -1); + gzipOutputStream.finish(); + } + } else { + sendBody(outputStream, pending); + } + } + + /** + * Sends the body to the specified OutputStream. The pending parameter + * limits the maximum amounts of bytes sent unless it is -1, in which case + * everything is sent. + * + * @param outputStream + * the OutputStream to send data to + * @param pending + * -1 to send everything, otherwise sets a max limit to the + * number of bytes sent + * @throws IOException + * if something goes wrong while sending the data. + */ + private void sendBody(OutputStream outputStream, long pending) throws IOException { + long BUFFER_SIZE = 16 * 1024; + byte[] buff = new byte[(int) BUFFER_SIZE]; + boolean sendEverything = pending == -1; + while (pending > 0 || sendEverything) { + long bytesToRead = sendEverything ? BUFFER_SIZE : Math.min(pending, BUFFER_SIZE); + int read = this.data.read(buff, 0, (int) bytesToRead); + if (read <= 0) { + break; + } + try { + outputStream.write(buff, 0, read); + } catch (Exception e) { + if(this.data != null) { + this.data.close(); + } + } + if (!sendEverything) { + pending -= read; + } + } + } + + public void setChunkedTransfer(boolean chunkedTransfer) { + this.chunkedTransfer = chunkedTransfer; + } + + public void setData(InputStream data) { + this.data = data; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public void setRequestMethod(Method requestMethod) { + this.requestMethod = requestMethod; + } + + public void setStatus(IStatus status) { + this.status = status; + } + + /** + * Create a response with unknown length (using HTTP 1.1 chunking). + */ + public static Response newChunkedResponse(IStatus status, String mimeType, InputStream data) { + return new Response(status, mimeType, data, -1); + } + + public static Response newFixedLengthResponse(IStatus status, String mimeType, byte[] data) { + return newFixedLengthResponse(status, mimeType, new ByteArrayInputStream(data), data.length); + } + + /** + * Create a response with known length. + */ + public static Response newFixedLengthResponse(IStatus status, String mimeType, InputStream data, long totalBytes) { + return new Response(status, mimeType, data, totalBytes); + } + + /** + * Create a text response with known length. + */ + public static Response newFixedLengthResponse(IStatus status, String mimeType, String txt) { + ContentType contentType = new ContentType(mimeType); + if (txt == null) { + return newFixedLengthResponse(status, mimeType, new ByteArrayInputStream(new byte[0]), 0); + } else { + byte[] bytes; + try { + CharsetEncoder newEncoder = Charset.forName(contentType.getEncoding()).newEncoder(); + if (!newEncoder.canEncode(txt)) { + contentType = contentType.tryUTF8(); + } + bytes = txt.getBytes(contentType.getEncoding()); + } catch (UnsupportedEncodingException e) { + NanoHTTPD.LOG.log(Level.SEVERE, "encoding problem, responding nothing", e); + bytes = new byte[0]; + } + return newFixedLengthResponse(status, contentType.getContentTypeHeader(), new ByteArrayInputStream(bytes), bytes.length); + } + } + + /** + * Create a text response with known length. + */ + public static Response newFixedLengthResponse(String msg) { + return newFixedLengthResponse(Status.OK, NanoHTTPD.MIME_HTML, msg); + } + + public Response setUseGzip(boolean useGzip) { + gzipUsage = useGzip ? GzipUsage.ALWAYS : GzipUsage.NEVER; + return this; + } + + // If a Gzip usage has been enforced, use it. + // Else decide whether or not to use Gzip. + public boolean useGzipWhenAccepted() { + if (gzipUsage == GzipUsage.DEFAULT) + return getMimeType() != null && (getMimeType().toLowerCase().contains("text/") || getMimeType().toLowerCase().contains("/json")); + else + return gzipUsage == GzipUsage.ALWAYS; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/Status.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/Status.java new file mode 100644 index 0000000..9a6ff33 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/response/Status.java @@ -0,0 +1,111 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.response; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +/** + * Some HTTP response status codes + */ +public enum Status implements IStatus { + SWITCH_PROTOCOL(101, "Switching Protocols"), + + OK(200, "OK"), + CREATED(201, "Created"), + ACCEPTED(202, "Accepted"), + NO_CONTENT(204, "No Content"), + PARTIAL_CONTENT(206, "Partial Content"), + MULTI_STATUS(207, "Multi-Status"), + + REDIRECT(301, "Moved Permanently"), + /** + * Many user agents mishandle 302 in ways that violate the RFC1945 spec + * (i.e., redirect a POST to a GET). 303 and 307 were added in RFC2616 to + * address this. You should prefer 303 and 307 unless the calling user agent + * does not support 303 and 307 functionality + */ + @Deprecated + FOUND(302, "Found"), + REDIRECT_SEE_OTHER(303, "See Other"), + NOT_MODIFIED(304, "Not Modified"), + TEMPORARY_REDIRECT(307, "Temporary Redirect"), + + BAD_REQUEST(400, "Bad Request"), + UNAUTHORIZED(401, "Unauthorized"), + FORBIDDEN(403, "Forbidden"), + NOT_FOUND(404, "Not Found"), + METHOD_NOT_ALLOWED(405, "Method Not Allowed"), + NOT_ACCEPTABLE(406, "Not Acceptable"), + REQUEST_TIMEOUT(408, "Request Timeout"), + CONFLICT(409, "Conflict"), + GONE(410, "Gone"), + LENGTH_REQUIRED(411, "Length Required"), + PRECONDITION_FAILED(412, "Precondition Failed"), + PAYLOAD_TOO_LARGE(413, "Payload Too Large"), + UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"), + RANGE_NOT_SATISFIABLE(416, "Requested Range Not Satisfiable"), + EXPECTATION_FAILED(417, "Expectation Failed"), + TOO_MANY_REQUESTS(429, "Too Many Requests"), + + INTERNAL_ERROR(500, "Internal Server Error"), + NOT_IMPLEMENTED(501, "Not Implemented"), + SERVICE_UNAVAILABLE(503, "Service Unavailable"), + UNSUPPORTED_HTTP_VERSION(505, "HTTP Version Not Supported"); + + private final int requestStatus; + + private final String description; + + Status(int requestStatus, String description) { + this.requestStatus = requestStatus; + this.description = description; + } + + public static Status lookup(int requestStatus) { + for (Status status : Status.values()) { + if (status.getRequestStatus() == requestStatus) { + return status; + } + } + return null; + } + + @Override + public String getDescription() { + return "" + this.requestStatus + " " + this.description; + } + + @Override + public int getRequestStatus() { + return this.requestStatus; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/sockets/DefaultServerSocketFactory.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/sockets/DefaultServerSocketFactory.java new file mode 100644 index 0000000..2bfebbb --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/sockets/DefaultServerSocketFactory.java @@ -0,0 +1,51 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.sockets; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.IOException; +import java.net.ServerSocket; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.util.IFactoryThrowing; + +/** + * Creates a normal ServerSocket for TCP connections + */ +public class DefaultServerSocketFactory implements IFactoryThrowing { + + @Override + public ServerSocket create() throws IOException { + return new ServerSocket(); + } + +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/sockets/SecureServerSocketFactory.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/sockets/SecureServerSocketFactory.java new file mode 100644 index 0000000..e57df5c --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/sockets/SecureServerSocketFactory.java @@ -0,0 +1,73 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.sockets; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.IOException; +import java.net.ServerSocket; + +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.util.IFactoryThrowing; + +/** + * Creates a new SSLServerSocket + */ +public class SecureServerSocketFactory implements IFactoryThrowing { + + private SSLServerSocketFactory sslServerSocketFactory; + + private String[] sslProtocols; + + public SecureServerSocketFactory(SSLServerSocketFactory sslServerSocketFactory, String[] sslProtocols) { + this.sslServerSocketFactory = sslServerSocketFactory; + this.sslProtocols = sslProtocols; + } + + @Override + public ServerSocket create() throws IOException { + SSLServerSocket ss = null; + ss = (SSLServerSocket) this.sslServerSocketFactory.createServerSocket(); + if (this.sslProtocols != null) { + ss.setEnabledProtocols(this.sslProtocols); + } else { + ss.setEnabledProtocols(ss.getSupportedProtocols()); + } + ss.setUseClientMode(false); + ss.setWantClientAuth(false); + ss.setNeedClientAuth(false); + return ss; + } + +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFile.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFile.java new file mode 100644 index 0000000..9c38799 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFile.java @@ -0,0 +1,79 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.NanoHTTPD; + +/** + * Default strategy for creating and cleaning up temporary files. + *

+ *

+ * By default, files are created by File.createTempFile() in the + * directory specified. + *

+ */ +public class DefaultTempFile implements ITempFile { + + private final File file; + + private final OutputStream fstream; + + public DefaultTempFile(File tempdir) throws IOException { + this.file = File.createTempFile("NanoHTTPD-", "", tempdir); + this.fstream = new FileOutputStream(this.file); + } + + @Override + public void delete() throws Exception { + NanoHTTPD.safeClose(this.fstream); + if (!this.file.delete()) { + throw new Exception("could not delete temporary file: " + this.file.getAbsolutePath()); + } + } + + @Override + public String getName() { + return this.file.getAbsolutePath(); + } + + @Override + public OutputStream open() throws Exception { + return this.fstream; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFileManager.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFileManager.java new file mode 100644 index 0000000..f1a4a3e --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFileManager.java @@ -0,0 +1,85 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.NanoHTTPD; + +/** + * Default strategy for creating and cleaning up temporary files. + *

+ *

+ * This class stores its files in the standard location (that is, wherever + * java.io.tmpdir points to). Files are added to an internal list, + * and deleted when no longer needed (that is, when clear() is + * invoked at the end of processing a request). + *

+ */ +public class DefaultTempFileManager implements ITempFileManager { + + private final File tmpdir; + + private final List tempFiles; + + public DefaultTempFileManager() { + this.tmpdir = new File(System.getProperty("java.io.tmpdir")); + if (!tmpdir.exists()) { + tmpdir.mkdirs(); + } + this.tempFiles = new ArrayList(); + } + + @Override + public void clear() { + for (ITempFile file : this.tempFiles) { + try { + file.delete(); + } catch (Exception ignored) { + NanoHTTPD.LOG.log(Level.WARNING, "could not delete file ", ignored); + } + } + this.tempFiles.clear(); + } + + @Override + public ITempFile createTempFile(String filename_hint) throws Exception { + DefaultTempFile tempFile = new DefaultTempFile(this.tmpdir); + this.tempFiles.add(tempFile); + return tempFile; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFileManagerFactory.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFileManagerFactory.java new file mode 100644 index 0000000..76112fa --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/DefaultTempFileManagerFactory.java @@ -0,0 +1,47 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import com.launchdarkly.testhelpers.httptest.nanohttpd.util.IFactory; + +/** + * Default strategy for creating and cleaning up temporary files. + */ +public class DefaultTempFileManagerFactory implements IFactory { + + @Override + public ITempFileManager create() { + return new DefaultTempFileManager(); + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/ITempFile.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/ITempFile.java new file mode 100644 index 0000000..2da5c7d --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/ITempFile.java @@ -0,0 +1,53 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.OutputStream; + +/** + * A temp file. + *

+ *

+ * Temp files are responsible for managing the actual temporary storage and + * cleaning themselves up when no longer needed. + *

+ */ +public interface ITempFile { + + public void delete() throws Exception; + + public String getName(); + + public OutputStream open() throws Exception; +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/ITempFileManager.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/ITempFileManager.java new file mode 100644 index 0000000..563cfa1 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/tempfiles/ITempFileManager.java @@ -0,0 +1,49 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.tempfiles; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +/** + * Temp file manager. + *

+ *

+ * Temp file managers are created 1-to-1 with incoming requests, to create and + * cleanup temporary files created as a result of handling the request. + *

+ */ +public interface ITempFileManager { + + void clear(); + + public ITempFile createTempFile(String filename_hint) throws Exception; +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/threading/DefaultAsyncRunner.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/threading/DefaultAsyncRunner.java new file mode 100644 index 0000000..3ffea99 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/threading/DefaultAsyncRunner.java @@ -0,0 +1,90 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.threading; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.ClientHandler; + +/** + * Default threading strategy for NanoHTTPD. + *

+ *

+ * By default, the server spawns a new Thread for every incoming request. These + * are set to daemon status, and named according to the request number. + * The name is useful when profiling the application. + *

+ */ +public class DefaultAsyncRunner implements IAsyncRunner { + + protected long requestCount; + + private final List running = Collections.synchronizedList(new ArrayList()); + + /** + * @return a list with currently running clients. + */ + public List getRunning() { + return running; + } + + @Override + public void closeAll() { + // copy of the list for concurrency + for (ClientHandler clientHandler : new ArrayList(this.running)) { + clientHandler.close(); + } + } + + @Override + public void closed(ClientHandler clientHandler) { + this.running.remove(clientHandler); + } + + @Override + public void exec(ClientHandler clientHandler) { + ++this.requestCount; + this.running.add(clientHandler); + createThread(clientHandler).start(); + } + + protected Thread createThread(ClientHandler clientHandler) { + Thread t = new Thread(clientHandler); + t.setDaemon(true); + t.setName("NanoHttpd Request Processor (#" + this.requestCount + ")"); + return t; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/threading/IAsyncRunner.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/threading/IAsyncRunner.java new file mode 100644 index 0000000..7a6350b --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/protocols/http/threading/IAsyncRunner.java @@ -0,0 +1,48 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.threading; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.ClientHandler; + +/** + * Pluggable strategy for asynchronously executing requests. + */ +public interface IAsyncRunner { + + void closeAll(); + + void closed(ClientHandler clientHandler); + + void exec(ClientHandler code); +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IFactory.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IFactory.java new file mode 100644 index 0000000..5c11969 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IFactory.java @@ -0,0 +1,46 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.util; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +/** + * Represents a simple factory + * + * @author LordFokas + * @param + * The Type of object to create + */ +public interface IFactory { + + T create(); +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IFactoryThrowing.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IFactoryThrowing.java new file mode 100644 index 0000000..4796667 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IFactoryThrowing.java @@ -0,0 +1,49 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.util; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +/** + * Represents a factory that can throw an exception instead of actually creating + * an object + * + * @author LordFokas + * @param + * The Type of object to create + * @param + * The base Type of exceptions that can be thrown + */ +public interface IFactoryThrowing { + + T create() throws E; +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IHandler.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IHandler.java new file mode 100644 index 0000000..ce7648f --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/IHandler.java @@ -0,0 +1,49 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.util; + +/* + * #%L + * NanoHttpd-Core + * %% + * Copyright (C) 2012 - 2016 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +/** + * Defines a generic handler that returns an object of type O when given an + * object of type I. + * + * @author LordFokas + * @param + * The input type. + * @param + * The output type. + */ +public interface IHandler { + + public O handle(I input); +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/ServerRunner.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/ServerRunner.java new file mode 100644 index 0000000..82c74ba --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/nanohttpd/util/ServerRunner.java @@ -0,0 +1,75 @@ +package com.launchdarkly.testhelpers.httptest.nanohttpd.util; + +/* + * #%L + * NanoHttpd-Webserver + * %% + * Copyright (C) 2012 - 2015 nanohttpd + * %% + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the nanohttpd nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.launchdarkly.testhelpers.httptest.nanohttpd.protocols.http.NanoHTTPD; + +public class ServerRunner { + + /** + * logger to log to. + */ + private static final Logger LOG = Logger.getLogger(ServerRunner.class.getName()); + + public static void executeInstance(NanoHTTPD server) { + try { + server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, false); + } catch (IOException ioe) { + System.err.println("Couldn't start server:\n" + ioe); + System.exit(-1); + } + + System.out.println("Server started, Hit Enter to stop.\n"); + + try { + System.in.read(); + } catch (Throwable ignored) { + } + + server.stop(); + System.out.println("Server stopped.\n"); + } + + public static void run(Class serverClass) { + try { + executeInstance(serverClass.newInstance()); + } catch (Exception e) { + ServerRunner.LOG.log(Level.SEVERE, "Could not create server", e); + } + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/package-info.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/package-info.java new file mode 100644 index 0000000..8a9af8d --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/httptest/package-info.java @@ -0,0 +1,107 @@ +/** + * A simple portable HTTP test server with response mocking. + *

+ * This package provides a simple abstraction for setting up embedded HTTP test servers + * that return programmed responses, and verifying that the expected requests have been + * made in tests. + *

+ * Although Java has a standard servlet API for representing HTTP requests and responses, + * it does not have a built-in HTTP server implementation. Some other Java libraries + * provide a mockable server, but these have generally not been suitable for LaunchDarkly's + * testing needs due to limitations in their design (for instance, they generally do not + * support streaming responses). + *

+ * This package uses a fork of nanohttpd as its underlying implementation. Only the core + * server implementation is used, without a webapp framework, so it is fairly lightweight + * and has dependencies other than Java 7 or above. However, this implementation has only + * been validated for server-side Java; it may not work correctly in Android. + *

+ * It is possible to build a simple web service with this package, but it should not be used + * for production services. + *

+ * An {@link com.launchdarkly.testhelpers.httptest.HttpServer} is an HTTP server that + * starts listening on an arbitrarily chosen port as soon as you create it. You should + * normally do this inside a try-with-resources block to ensure that the server is shut + * down when you're done with it. The server's {@link com.launchdarkly.testhelpers.httptest.HttpServer#getUri()} + * method gives you the address for making your test requests. + *

+ * You configure the server with a single {@link com.launchdarkly.testhelpers.httptest.Handler} + * that receives all requests. The library provides a variety of handler implementations and + * combinators, or you can define your own. + *

+ * Examples + *

+ *

+ * 1. Invariant response with error status + *


+ *     HttpServer server = HttpServer.start(Handlers.status(500));
+ * 
+ *

+ * 2. Invariant response with status, headers, and body + *


+ *     HttpServer server = HttpServer.start(
+ *         Handlers.all(
+ *             Handlers.status(202),
+ *             Handlers.header("Etag", "123"),
+ *             Handlers.bodyString("text/plain", "thanks")
+ *         )
+ *     );
+ * 
+ *

+ * 3. Verifying requests made to the server + *


+ *     try (HttpServer server = HttpServer.start(Handlers.status(200))) {
+ *         doSomethingThatMakesARequest(server.getUri());
+ *         doSomethingElseThatMakesARequest(server.getUri());
+ *         
+ *         RequestInfo request1 = server.getRecorder().requireRequest();
+ *         assertEquals("/path1", request1.getPath());
+ *         
+ *         RequestInfo request2 = server.getRecorder().requireRequest();
+ *         assertEquals("/path2", request2.getPath());
+ *     }
+ * 
+ *

+ * 4. Response with custom logic depending on the request + *


+ *     HttpServer server = HttpServer.start(
+ *         ctx -> {
+ *             if (ctx.getRequest().getHeader("Header-Name").equals("good-value")) {
+ *                 Handlers.status(200).apply(ctx);
+ *             } else {
+ *                 Handlers.status(400).apply(ctx);
+ *             }
+ *         }
+ *     );
+ * 
+ *

+ * 5. Simple routing to simulate two endpoints + *


+ *     SimpleRouter router = new SimpleRouter();
+ *     router.add("/path1", Handlers.status(200));
+ *     router.add("/path2", Handlers.status(500));
+ *     HttpServer server = HttpServer.start(router);
+ * 
+ *

+ * 6. Programmed sequence of responses + *


+ *     HttpServer server = HttpServer.start(
+ *         Handlers.sequential(
+ *             Handlers.status(200), // first request gets a 200
+ *             Handlers.status(500)  // next request gets a 500
+ *         )
+ *     );
+ * 
+ *

+ * 7. Changing server behavior during a test + *


+ *     HandlerSwitcher switcher = new HandlerSwitcher(Handlers.status(200));
+ *     try (HttpServer server = HttpServer.start(switcher) {
+ *         // Initially the server returns 200 for all requests
+ *         
+ *         switcher.setTarget(Handlers.status(500));
+ *         // Now the server returns 500 for all requests
+ *     }
+ * 
+ */ +package com.launchdarkly.testhelpers.httptest; diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpHandler.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpHandler.java new file mode 100644 index 0000000..a16c72d --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpHandler.java @@ -0,0 +1,19 @@ +package com.launchdarkly.testhelpers.tcptest; + +import java.io.IOException; +import java.net.Socket; + +/** + * Use with {@link TcpServer} to define behavior for a TCP endpoint in a test. + * + * @since 1.3.0 + */ +public interface TcpHandler { + /** + * Processes the request. + * + * @param socket the incoming socket + * @throws IOException for any I/O error + */ + void apply(Socket socket) throws IOException; +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpHandlers.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpHandlers.java new file mode 100644 index 0000000..347ada8 --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpHandlers.java @@ -0,0 +1,137 @@ +package com.launchdarkly.testhelpers.tcptest; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Factory methods for standard {@link TcpHandler} implementations. + * + * @since 1.3.0 + */ +public abstract class TcpHandlers { + private TcpHandlers() {} + + /** + * Creates an implementation of {@link TcpHandler} that writes some data to the socket. + * + * @param data the data buffer + * @param offset the starting offset + * @param length the number of bytes to write + * @return a handler + */ + public static TcpHandler writeData(final byte[] data, final int offset, final int length) { + return new TcpHandler() { + @Override + public void apply(Socket socket) throws IOException { + socket.getOutputStream().write(data, offset, length); + } + }; + } + + /** + * Creates an implementation of {@link TcpHandler} that writes a UTF-8 string to the socket. + * + * @param s the string + * @return a handler + */ + public static TcpHandler writeString(String s) { + byte[] data = s.getBytes(Charset.forName("UTF-8")); + return writeData(data, 0, data.length); + } + + /** + * Creates an implementation of {@link TcpHandler} that, for each incoming request, opens + * a socket connection to the specified port and then forwards all traffic from the incoming + * request to that socket, and vice versa. + * + * @param forwardToPort the port to forward to + * @return a handler + */ + public static TcpHandler forwardToPort(final int forwardToPort) { + return new TcpHandler() { + @Override + public void apply(Socket incomingSocket) throws IOException { + InputStream incomingSocketRead = incomingSocket.getInputStream(); + OutputStream incomingSocketWrite = incomingSocket.getOutputStream(); + try (Socket forwardedSocket = new Socket(incomingSocket.getInetAddress().getHostAddress(), forwardToPort)) { + InputStream forwardedSocketRead = forwardedSocket.getInputStream(); + OutputStream forwardedSocketWrite = forwardedSocket.getOutputStream(); + final CountDownLatch closeSignal = new CountDownLatch(1); + new Thread(newForwarder(incomingSocketRead, forwardedSocketWrite, closeSignal)).start(); + new Thread(newForwarder(forwardedSocketRead, incomingSocketWrite, closeSignal)).start(); + try { + closeSignal.await(); + } catch (InterruptedException e) {} + } + } + }; + } + + private static Runnable newForwarder(InputStream fromStream, OutputStream toStream, CountDownLatch closeSignal) { + return new Runnable() { + @Override + public void run() { + byte[] buffer = new byte[1000]; + while (true) { + try { + int n = fromStream.read(buffer); + if (n < 0) { + break; + } + toStream.write(buffer, 0, n); + toStream.flush(); + } catch (IOException e) { + break; + } + } + closeSignal.countDown(); + } + }; + } + + /** + * Returns an implementation of {@link TcpHandler} that immediately exits, so that + * {@link TcpServer} will close the socket with no response. + *

+ * A typical use case would be to simulate an I/O error when testing client code that is + * making HTTP requests; if the (simulated) HTTP server closes the socket without writing + * a response, clients will treat this as a broken connection error. + * + * @return a handler + */ + public static TcpHandler noResponse() { + return new TcpHandler() { + @Override + public void apply(Socket socket) {} + }; + } + + /** + * Creates a stateful {@link TcpHandler} that delegates to each of the specified handlers in sequence + * as each request is received. + * + * @param handlers a sequence of handlers + * @return a handler + */ + public static TcpHandler sequential(TcpHandler... handlers) { + final AtomicInteger index = new AtomicInteger(0); + final TcpHandler[] h = Arrays.copyOf(handlers, handlers.length); + + return new TcpHandler() { + @Override + public void apply(Socket socket) throws IOException { + int i = index.getAndIncrement(); + if (i >= h.length) { + throw new RuntimeException("received more requests than the number of configured handlers"); + } + h[i].apply(socket); + } + }; + } +} diff --git a/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpServer.java b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpServer.java new file mode 100644 index 0000000..c62c7df --- /dev/null +++ b/lib/shared/test-helpers/src/main/java/com/launchdarkly/testhelpers/tcptest/TcpServer.java @@ -0,0 +1,118 @@ +package com.launchdarkly.testhelpers.tcptest; + +import com.launchdarkly.testhelpers.httptest.HttpServer; + +import java.io.Closeable; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.util.Date; + +/** + * A simple mechanism for creating a TCP listener and configuring its behavior. + *

+ * This is analogous to {@link HttpServer}, but much simpler since it has no knowledge of + * any particular protocol that might be used over TCP. See {@link TcpHandlers} for examples + * of configurable behavior. + * + * @since 1.3.0 + */ +public class TcpServer implements Closeable { + private final ServerSocket listener; + private final int listenerPort; + + /** + * Starts a new TCP test server on a specific port. + * + * @param port the port to listen on + * @param handler a {@link TcpHandler} implementation + * @return a server + */ + public static TcpServer start(int port, TcpHandler handler) { + return new TcpServer(port, handler); + } + + /** + * Starts a new TCP test server on any available port. + * + * @param handler a {@link TcpHandler} implementation + * @return a server + */ + public static TcpServer start(TcpHandler handler) { + return new TcpServer(0, handler); + } + + TcpServer(int port, final TcpHandler handler) { + try { + listener = new ServerSocket(port); + } catch (IOException e) { + throw new RuntimeException("unable to create TCP listener", e); + } + listenerPort = port == 0 ? listener.getLocalPort() : port; + + new Thread(new Runnable() { + @Override + public void run() { + while (true) { + final Socket socket; + try { + socket = listener.accept(); + } catch (IOException e) { + // almost certainly means we closed the socket + return; + } + new Thread(new Runnable() { + @Override + public void run() { + try { + handler.apply(socket); + } catch (Exception e) { + logError("handler threw exception: " + e); + } + try { + socket.close(); + } catch (IOException e) { + logError("failed to close socket: " + e); + } + } + }).run(); + } + } + }).start(); + } + + @Override + public void close() { + try { + listener.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns the port we are listening on. + * + * @return the port + */ + public int getPort() { + return listenerPort; + } + + /** + * Convenience method for constructing an HTTP URI with the listener port. This does not + * mean the listener necessarily can accept HTTP requests, but it may be useful if for + * instance you have configured it with {@link TcpHandlers#forwardToPort(int)} to forward + * requests to an {@link HttpServer}. + * + * @return an HTTP URI using localhost and the value of {@link #getPort()} + */ + public URI getHttpUri() { + return URI.create("http://localhost:" + listenerPort); + } + + private void logError(String message) { + System.err.println("TcpServer [" + new Date() + "]: " + message); + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/AssertionsTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/AssertionsTest.java new file mode 100644 index 0000000..bface7e --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/AssertionsTest.java @@ -0,0 +1,59 @@ +package com.launchdarkly.testhelpers; + +import org.junit.Test; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; + +@SuppressWarnings("javadoc") +public class AssertionsTest { + @Test + public void assertPolledFunctionReturnsValueSuccessOnFirstTry() { + String value = Assertions.assertPolledFunctionReturnsValue( + 1, TimeUnit.SECONDS, + 10, TimeUnit.MILLISECONDS, + () -> "yes" + ); + assertThat(value, equalTo("yes")); + } + + @Test + public void assertPolledFunctionReturnsValueSuccessOnLaterTry() { + AtomicInteger i = new AtomicInteger(0); + String value = Assertions.assertPolledFunctionReturnsValue( + 200, TimeUnit.MILLISECONDS, + 10, TimeUnit.MILLISECONDS, + () -> { + return i.incrementAndGet() >= 5 ? "yes" : null; + }); + assertThat(value, equalTo("yes")); + } + + @Test + public void assertPolledFunctionReturnsValueFailure() { + AtomicInteger i = new AtomicInteger(0); + requireAssertionError(() -> { + Assertions.assertPolledFunctionReturnsValue( + 200, TimeUnit.MILLISECONDS, + 10, TimeUnit.MILLISECONDS, + () -> { + i.incrementAndGet(); + return null; + }); + }); + assertThat(i.get(), greaterThan(1)); + } + + public static String requireAssertionError(Runnable action) { + try { + action.run(); + throw new AssertionError("expected AssertionError, did not get one"); + } catch (AssertionError e) { + return e.getMessage(); + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/ConcurrentHelpersTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/ConcurrentHelpersTest.java new file mode 100644 index 0000000..245eb9e --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/ConcurrentHelpersTest.java @@ -0,0 +1,97 @@ +package com.launchdarkly.testhelpers; + +import org.junit.Test; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.testhelpers.AssertionsTest.requireAssertionError; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertFutureIsCompleted; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.assertNoMoreValues; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.awaitValue; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.isCompletedWithin; +import static com.launchdarkly.testhelpers.ConcurrentHelpers.trySleep; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +@SuppressWarnings("javadoc") +public class ConcurrentHelpersTest { + @Test + public void awaitValueSuccess() { + BlockingQueue q = new LinkedBlockingQueue<>(); + new Thread(() -> { + q.add("a"); + }).start(); + String value = awaitValue(q, 1, TimeUnit.SECONDS); + assertThat(value, equalTo("a")); + } + + @Test + public void awaitValueFailure() { + BlockingQueue q = new LinkedBlockingQueue<>(); + String message = requireAssertionError(() -> { + awaitValue(q, 100, TimeUnit.MILLISECONDS); + }); + assertThat(message, equalTo("did not receive a value within 100 milliseconds")); + } + + @Test + public void assertNoMoreValuesSuccess() { + BlockingQueue q = new LinkedBlockingQueue<>(); + assertNoMoreValues(q, 50, TimeUnit.MILLISECONDS); + } + + @Test + public void assertNoMoreValuesFailure() { + BlockingQueue q = new LinkedBlockingQueue<>(); + new Thread(() -> { + trySleep(10, TimeUnit.MILLISECONDS); + q.add("a"); + }).start(); + String message = requireAssertionError(() -> { + assertNoMoreValues(q, 100, TimeUnit.MILLISECONDS); + }); + assertThat(message, equalTo("expected no more values, but received: a")); + } + + @Test + public void assertFutureIsCompletedSuccess() { + CompletableFuture f = new CompletableFuture(); + new Thread(() -> { + f.complete("a"); + }).start(); + String value = assertFutureIsCompleted(f, 1, TimeUnit.SECONDS); + assertThat(value, equalTo("a")); + } + + @Test + public void assertFutureIsCompletedFailure() { + CompletableFuture f = new CompletableFuture(); + String message = requireAssertionError(() -> { + assertFutureIsCompleted(f, 50, TimeUnit.MILLISECONDS); + }); + assertThat(message, equalTo("Future was not completed within 50 milliseconds")); + } + + @Test + public void futureIsCompletedMatcherSuccess() { + CompletableFuture f = new CompletableFuture(); + new Thread(() -> { + f.complete("a"); + }).start(); + assertThat(f, isCompletedWithin(1, TimeUnit.SECONDS)); + } + + @Test + public void futureIsCompletedMatcherFailure() { + CompletableFuture f = new CompletableFuture(); + String message = requireAssertionError(() -> { + assertThat(f, isCompletedWithin(50, TimeUnit.MILLISECONDS)); + }); + assertThat(message, containsString("Expected: Future is completed within 50 milliseconds")); + assertThat(message, containsString("but: timed out")); + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/JsonAssertionsTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/JsonAssertionsTest.java new file mode 100644 index 0000000..29f51e9 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/JsonAssertionsTest.java @@ -0,0 +1,202 @@ +package com.launchdarkly.testhelpers; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.TypeSafeDiagnosingMatcher; +import org.junit.Test; + +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonEquals; +import static com.launchdarkly.testhelpers.JsonAssertions.assertJsonIncludes; +import static com.launchdarkly.testhelpers.JsonAssertions.isJsonArray; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonEquals; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonEqualsValue; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonIncludes; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonNull; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonProperty; +import static com.launchdarkly.testhelpers.JsonAssertions.jsonUndefined; +import static com.launchdarkly.testhelpers.JsonTestValue.jsonOf; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyIterable; + +@SuppressWarnings("javadoc") +public class JsonAssertionsTest { + @Test + public void assertJsonEqualsSuccess() { + jsonEqualsShouldSucceed("null", "null"); + jsonEqualsShouldSucceed("true", "true"); + jsonEqualsShouldSucceed("1", "1"); + jsonEqualsShouldSucceed("\"x\"", "\"x\""); + jsonEqualsShouldSucceed("{\"a\":1,\"b\":{\"c\":2}}", "{\"b\":{\"c\":2},\"a\":1}"); + jsonEqualsShouldSucceed("[1,2,[3,4]]","[1,2,[3,4]]"); + + assertThat(jsonOf("true"), jsonEqualsValue(true)); + } + + private static void jsonEqualsShouldSucceed(String expected, String actual) { + assertJsonEquals(expected, actual); + assertThat(jsonOf(actual), jsonEquals(jsonOf(expected))); + assertThat(jsonOf(actual), jsonEquals(expected)); + } + + @Test + public void assertJsonEqualsFailureWithNoDetailedDiff() { + jsonEqualsShouldFail("null", null, "no value"); + jsonEqualsShouldFail("null", "{", "not valid JSON"); + jsonEqualsShouldFail("null", "true", "expected: null\nactual: true"); + jsonEqualsShouldFail("false", "true", "expected: false\nactual: true"); + jsonEqualsShouldFail("{\"a\":1}", "3", "expected: {\"a\":1}\nactual: 3"); + jsonEqualsShouldFail("[1,2]", "3", "expected: [1,2]\nactual: 3"); + jsonEqualsShouldFail("[1,2]", "[1,2,3]", "expected: [1,2]\nactual: [1,2,3]"); + } + + @Test + public void assertJsonEqualsFailureWithDetailedDiff() { + jsonEqualsShouldFail("{\"a\":1,\"b\":2}", "{\"a\":1,\"b\":3}", + "at \"b\": expected = 2, actual = 3"); + + jsonEqualsShouldFail("{\"a\":1,\"b\":2}", "{\"a\":1}", + "at \"b\": expected = 2, actual = "); + + jsonEqualsShouldFail("{\"a\":1}", "{\"a\":1,\"b\":2}", + "at \"b\": expected = , actual = 2"); + + jsonEqualsShouldFail("{\"a\":1,\"b\":{\"c\":2}}", "{\"a\":1,\"b\":{\"c\":3}}", + "at \"b.c\": expected = 2, actual = 3"); + + jsonEqualsShouldFail("{\"a\":1,\"b\":[2,3]}", "{\"a\":1,\"b\":[3,3]}", + "at \"b[0]\": expected = 2, actual = 3"); + + jsonEqualsShouldFail("[100,200,300]", "[100,201,300]", + "at \"[1]\": expected = 200, actual = 201"); + + jsonEqualsShouldFail("[100,[200,210],300]", "[100,[201,210],300]", + "at \"[1][0]\": expected = 200, actual = 201"); + + jsonEqualsShouldFail("[100,{\"a\":1},300]", "[100,{\"a\":2},300]", + "at \"[1].a\": expected = 1, actual = 2"); + } + + private static void jsonEqualsShouldFail(String expected, String actual, String expectedMessage) { + assertThat(() -> assertJsonEquals(expected, actual), + shouldFailWithMessage(Matchers.containsString(expectedMessage))); + assertThat(() -> assertThat(jsonOf(actual), jsonEquals(jsonOf(expected))), + shouldFailWithMessage(Matchers.containsString(expectedMessage))); + } + + @Test + public void assertJsonIncludesSuccess() { + jsonIncludesShouldSucceed("{\"a\":1,\"b\":2}", "{\"b\":2,\"a\":1}"); + jsonIncludesShouldSucceed("{\"a\":1,\"b\":2}", "{\"b\":2,\"a\":1,\"c\":3}"); + jsonIncludesShouldSucceed("{\"a\":1,\"b\":{\"c\":2}}", "{\"b\":{\"c\":2,\"d\":3},\"a\":1}"); + } + + private void jsonIncludesShouldSucceed(String expected, String actual) { + assertJsonIncludes(expected, actual); + assertThat(jsonOf(actual), jsonIncludes(jsonOf(expected))); + assertThat(jsonOf(actual), jsonIncludes(expected)); + } + + @Test + public void assertJsonIncludesFailure() { + jsonIncludesShouldFail("null", null, "no value"); + jsonIncludesShouldFail("null", "{", "not valid JSON"); + + jsonIncludesShouldFail("{\"a\":1}", "{\"a\":0,\"b\":2,\"c\":3}", + "at \"a\": expected = 1, actual = 0"); + + jsonIncludesShouldFail("{\"a\":1}", "{\"b\":2,\"c\":3}", + "at \"a\": expected = 1, actual = "); + + jsonIncludesShouldFail("{\"b\":2,\"a\":1,\"c\":3}", "{\"a\":1,\"b\":2}", + "at \"c\": expected = 3, actual = "); + + jsonIncludesShouldFail("{\"b\":{\"c\":2,\"d\":3},\"a\":1}", "{\"a\":1,\"b\":{\"c\":2}}", + "at \"b.d\": expected = 3, actual = "); + } + + private static void jsonIncludesShouldFail(String expected, String actual, String expectedMessage) { + assertThat(() -> assertJsonIncludes(expected, actual), + shouldFailWithMessage(Matchers.containsString(expectedMessage))); + assertThat(() -> assertThat(jsonOf(actual), jsonIncludes(expected)), + shouldFailWithMessage(Matchers.containsString(expectedMessage))); + } + + @Test + public void jsonPropertySuccess() { + assertThat(jsonOf("{\"a\":true}"), jsonProperty("a", jsonEquals("true"))); + assertThat(jsonOf("{\"a\":true}"), jsonProperty("a", true)); + + assertThat(jsonOf("{\"a\":1}"), jsonProperty("a", 1)); + assertThat(jsonOf("{\"a\":2.5}"), jsonProperty("a", 2.5)); + assertThat(jsonOf("{\"a\":\"x\"}"), jsonProperty("a", "x")); + + assertThat(jsonOf("{\"a\":{\"b\": 1}}"), jsonProperty("a", jsonProperty("b", 1))); + + assertThat(jsonOf("{\"a\":true}"), jsonProperty("b", jsonUndefined())); + assertThat(jsonOf("{\"a\":null}"), jsonProperty("a", jsonNull())); + } + + @Test + public void jsonPropertyFailure() { + assertThat(() -> assertThat(jsonOf(null), jsonProperty("a", true)), + shouldFailWithMessage(containsString("no value"))); + + assertThat(() -> assertThat(jsonOf("[]"), jsonProperty("a", true)), + shouldFailWithMessage(containsString("not a JSON object"))); + + assertThat(() -> assertThat(jsonOf("{\"a\":1}"), jsonProperty("b", 1)), + shouldFailWithMessage(Matchers.allOf(containsString("Expected: property \"b\""), containsString("no value")))); + + assertThat(() -> assertThat(jsonOf("{\"a\":1}"), jsonProperty("a", 2)), + shouldFailWithMessage(Matchers.allOf(containsString("Expected: property \"a\""), containsString("actual: 1")))); +} + + @SuppressWarnings("unchecked") + @Test + public void isJsonArraySuccess() { + assertThat(jsonOf("[]"), isJsonArray(emptyIterable())); + assertThat(jsonOf("[true]"), isJsonArray(contains(jsonEqualsValue(true)))); + assertThat(jsonOf("[true, false]"), isJsonArray(contains(jsonEqualsValue(true), jsonEqualsValue(false)))); + } + + @Test + public void isJsonArrayFailure() { + assertThat(() -> assertThat(jsonOf(null), isJsonArray(emptyIterable())), + shouldFailWithMessage(containsString("no value"))); + + assertThat(() -> assertThat(jsonOf("{}"), isJsonArray(emptyIterable())), + shouldFailWithMessage(containsString("not a JSON array"))); + + assertThat(() -> assertThat(jsonOf("[true]"), isJsonArray(contains(jsonEqualsValue(false)))), + shouldFailWithMessage(containsString("item 0: expected: false\nactual: true"))); + } + + private static Matcher shouldFailWithMessage(Matcher matcher) { + return new TypeSafeDiagnosingMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("should fail with message:"); + matcher.describeTo(description); + } + + @Override + protected boolean matchesSafely(Runnable item, Description mismatchDescription) { + try { + item.run(); + mismatchDescription.appendText("did not throw exception"); + return false; + } catch (AssertionError e) { + String message = e.getMessage().trim(); + if (!matcher.matches(message)) { + matcher.describeMismatch(message, mismatchDescription); + return false; + } + return true; + } + } + }; + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/JsonTestValueTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/JsonTestValueTest.java new file mode 100644 index 0000000..4b29b1e --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/JsonTestValueTest.java @@ -0,0 +1,69 @@ +package com.launchdarkly.testhelpers; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + +import org.junit.Test; + +import static com.launchdarkly.testhelpers.AssertionsTest.requireAssertionError; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +@SuppressWarnings("javadoc") +public class JsonTestValueTest { + @Test + public void parseUndefined() { + JsonTestValue v = JsonTestValue.jsonOf(null); + assertThat(v, notNullValue()); + assertThat(v.isDefined(), equalTo(false)); + assertThat(v.raw, nullValue()); + assertThat(v.parsed, nullValue()); + } + + @Test + public void parseMalformed() { + assertThat(requireAssertionError(() -> JsonTestValue.jsonOf("{no")), + allOf(containsString("not valid JSON"), containsString("{no"))); + } + + @Test + public void parseSuccess() { + JsonTestValue v = JsonTestValue.jsonOf("123"); + assertThat(v, notNullValue()); + assertThat(v.isDefined(), equalTo(true)); + assertThat(v.raw, equalTo("123")); + assertThat(v.parsed, equalTo(new JsonPrimitive(123))); + } + + @Test + public void fromParsedUndefined() { + JsonTestValue v = JsonTestValue.ofParsed(null); + assertThat(v, notNullValue()); + assertThat(v.isDefined(), equalTo(false)); + assertThat(v.raw, nullValue()); + assertThat(v.parsed, nullValue()); + } + + @Test + public void fromParsedValue() { + JsonElement parsed = new JsonPrimitive(123); + JsonTestValue v = JsonTestValue.ofParsed(parsed); + assertThat(v, notNullValue()); + assertThat(v.isDefined(), equalTo(true)); + assertThat(v.raw, equalTo("123")); + assertThat(v.parsed, equalTo(parsed)); + } + + @Test + public void fromValue() { + JsonTestValue v = JsonTestValue.jsonFromValue(true); + assertThat(v, notNullValue()); + assertThat(v.isDefined(), equalTo(true)); + assertThat(v.raw, equalTo("true")); + assertThat(v.parsed, equalTo(new JsonPrimitive(true))); + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TempDirTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TempDirTest.java new file mode 100644 index 0000000..50c8890 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TempDirTest.java @@ -0,0 +1,48 @@ +package com.launchdarkly.testhelpers; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + +@SuppressWarnings("javadoc") +public class TempDirTest { + @Test + public void tempDir() { + Path path = null; + try (TempDir dir = TempDir.create()) { + path = dir.getPath(); + assertThat(Files.isDirectory(path), is(true)); + + TempFile f1 = dir.tempFile(); + assertThat(Files.isRegularFile(f1.getPath()), is(true)); + assertThat(f1.getPath().toString(), startsWith(path.toString())); + + TempFile f2 = dir.tempFile(".x"); + assertThat(Files.isRegularFile(f2.getPath()), is(true)); + assertThat(f2.getPath().toString(), startsWith(path.toString())); + assertThat(f2.getPath().toString(), endsWith(".x")); + } + assertThat(Files.exists(path), is(false)); + } + + @Test + public void canDeleteTempDirBeforeClosing() { + Path path = null; + try (TempDir dir = TempDir.create()) { + path = dir.getPath(); + assertThat(Files.isDirectory(path), is(true)); + + dir.tempFile(""); + + dir.delete(); + assertThat(Files.exists(path), is(false)); + } + assertThat(Files.exists(path), is(false)); + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TempFileTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TempFileTest.java new file mode 100644 index 0000000..3ce7e37 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TempFileTest.java @@ -0,0 +1,55 @@ +package com.launchdarkly.testhelpers; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +@SuppressWarnings("javadoc") +public class TempFileTest { + @Test + public void tempFile() throws Exception { + Path path = null; + try (TempFile file = TempFile.create()) { + path = file.getPath(); + assertThat(Files.isRegularFile(path), is(true)); + + assertThat(new String(Files.readAllBytes(path)), equalTo("")); + + file.setContents("xyz"); + + assertThat(new String(Files.readAllBytes(path)), equalTo("xyz")); + } + assertThat(Files.exists(path), is(false)); + } + + @Test + public void tempFileWithSuffix() throws Exception { + Path path = null; + try (TempFile file = TempFile.create(".x")) { + path = file.getPath(); + assertThat(Files.isRegularFile(path), is(true)); + assertThat(path.toString(), endsWith(".x")); + } + assertThat(Files.exists(path), is(false)); + } + + @Test + public void canDeleteTempFileBeforeClosing() throws Exception { + Path path = null; + try (TempFile file = TempFile.create()) { + path = file.getPath(); + assertThat(Files.isRegularFile(path), is(true)); + + file.delete(); + + assertThat(Files.exists(path), is(false)); + } + assertThat(Files.exists(path), is(false)); + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TypeBehaviorTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TypeBehaviorTest.java new file mode 100644 index 0000000..7ff31d6 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/TypeBehaviorTest.java @@ -0,0 +1,192 @@ +package com.launchdarkly.testhelpers; + +import org.junit.Test; + +import java.util.Arrays; + +import static com.launchdarkly.testhelpers.TypeBehavior.checkEqualsAndHashCode; +import static com.launchdarkly.testhelpers.TypeBehavior.valueFactoryFromInstances; + +@SuppressWarnings("javadoc") +public class TypeBehaviorTest { + @Test + public void checkEqualsAndHashCodeSuccess() { + checkEqualsAndHashCode( + Arrays.asList( + valueFactoryFromInstances( + new TypeWithValueAndHashCode("a", 1), + new TypeWithValueAndHashCode("a", 1)), + valueFactoryFromInstances( + new TypeWithValueAndHashCode("b", 2), + new TypeWithValueAndHashCode("b", 2)), + valueFactoryFromInstances( + new TypeWithValueAndHashCode("c", 2), + new TypeWithValueAndHashCode("c", 2)) // hash codes deliberately the same as b - that is allowed + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForIncorrectEquality() { + checkEqualsAndHashCode( + Arrays.asList( + () -> new TypeThatEqualsEveryObjectAndAlwaysHasSameHashCode(), + () -> new TypeThatEqualsEveryObjectAndAlwaysHasSameHashCode() + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForEqualingNull() { + checkEqualsAndHashCode( + Arrays.asList( + () -> new TypeThatEqualsEveryObjectOrNullAndAlwaysHasSameHashCode() + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForIncorrectInequality() { + checkEqualsAndHashCode( + Arrays.asList( + () -> new TypeThatEqualsOnlyItself() + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForObjectNotEqualingItself() { + checkEqualsAndHashCode( + Arrays.asList( + () -> new TypeThatEqualsNothing() + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForNonTransitiveEquality() { + checkEqualsAndHashCode( + Arrays.asList( + valueFactoryFromInstances( + new TypeThatEqualsSameOrHigherValue(1), + new TypeThatEqualsSameOrHigherValue(2) + ) + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForNonTransitiveInequality() { + checkEqualsAndHashCode( + Arrays.asList( + valueFactoryFromInstances( + new TypeThatEqualsSameOrHigherValue(1), + new TypeThatEqualsSameOrHigherValue(1)), + valueFactoryFromInstances( + new TypeThatEqualsSameOrHigherValue(2), + new TypeThatEqualsSameOrHigherValue(2)) + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForInconsistentHashCode() { + checkEqualsAndHashCode( + Arrays.asList( + valueFactoryFromInstances( + new TypeWithValueAndHashCode("a", 1), + new TypeWithValueAndHashCode("a", 2)) + )); + } + + @Test(expected=AssertionError.class) + public void checkEqualsAndHashCodeFailureForSameInstanceSeenTwice() { + TypeThatEqualsOnlyItself instance1 = new TypeThatEqualsOnlyItself(); + TypeThatEqualsOnlyItself instance2 = new TypeThatEqualsOnlyItself(); + checkEqualsAndHashCode( + Arrays.asList( + () -> instance1, + () -> instance2 + )); + } + + @Test + public void checkEqualsAndHashCodeAllowsSingletonPattern() { + TypeThatEqualsOnlyItself instance1 = new TypeThatEqualsOnlyItself(); + TypeThatEqualsOnlyItself instance2 = new TypeThatEqualsOnlyItself(); + checkEqualsAndHashCode( + Arrays.asList( + TypeBehavior.singletonValueFactory(instance1), + TypeBehavior.singletonValueFactory(instance2) + )); + } + + private static class TypeWithValueAndHashCode { + private final String value; + private final int hashCode; + + public TypeWithValueAndHashCode(String value, int hashCode) { + this.value = value; + this.hashCode = hashCode; + } + + public boolean equals(Object o) { + return o instanceof TypeWithValueAndHashCode && + ((TypeWithValueAndHashCode)o).value.equals(this.value); + } + + public int hashCode() { + return this.hashCode; + } + + public String toString() { + return value + "/" + hashCode; + } + } + + private static class TypeThatEqualsEveryObjectAndAlwaysHasSameHashCode { + public boolean equals(Object o) { + return o != null; + } + + public int hashCode() { + return 1; + } + } + + private static class TypeThatEqualsEveryObjectOrNullAndAlwaysHasSameHashCode { + public boolean equals(Object o) { + return true; + } + + public int hashCode() { + return 1; + } + } + + private static class TypeThatEqualsOnlyItself { + public boolean equals(Object o) { + return this == o; + } + + public int hashCode() { + return 1; + } + } + + private static class TypeThatEqualsNothing { + public boolean equals(Object o) { + return false; + } + + public int hashCode() { + return 1; + } + } + + private static class TypeThatEqualsSameOrHigherValue { + private final int index; + + TypeThatEqualsSameOrHigherValue(int index) { + this.index = index; + } + + public boolean equals(Object o) { + return o instanceof TypeThatEqualsSameOrHigherValue && + ((TypeThatEqualsSameOrHigherValue)o).index >= this.index; + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HandlerSwitcherTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HandlerSwitcherTest.java new file mode 100644 index 0000000..1e2c6ca --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HandlerSwitcherTest.java @@ -0,0 +1,27 @@ +package com.launchdarkly.testhelpers.httptest; + +import org.junit.Test; + +import static com.launchdarkly.testhelpers.httptest.TestUtil.simpleGet; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class HandlerSwitcherTest { + @Test + public void switchHandlers() throws Exception { + HandlerSwitcher switchable = new HandlerSwitcher(Handlers.status(200)); + + try (HttpServer server = HttpServer.start(switchable)) { + Response resp1 = simpleGet(server.getUri()); + assertThat(resp1.code(), equalTo(200)); + + switchable.setTarget(Handlers.status(400)); + + Response resp2 = simpleGet(server.getUri()); + assertThat(resp2.code(), equalTo(400)); + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HandlersTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HandlersTest.java new file mode 100644 index 0000000..b51a63e --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HandlersTest.java @@ -0,0 +1,178 @@ +package com.launchdarkly.testhelpers.httptest; + +import com.google.common.collect.ImmutableList; + +import org.junit.Test; + +import java.nio.charset.Charset; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.testhelpers.httptest.TestUtil.client; +import static com.launchdarkly.testhelpers.httptest.TestUtil.simpleGet; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import okhttp3.Request; +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class HandlersTest { + @Test + public void status() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(419))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(419)); + assertThat(resp.header("Content-Type"), nullValue()); + assertThat(resp.body().string(), equalTo("")); + } + } + } + + @Test + public void header() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.header("header-name", "value"))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.header("Header-Name"), equalTo("value")); + } + } + } + + @Test + public void replaceHeader() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.header("header-name", "old-value"), + Handlers.header("header-name", "new-value") + ))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.headers("Header-Name"), equalTo(ImmutableList.of("new-value"))); + } + } + } + + @Test + public void addHeader() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.addHeader("header-name", "old-value"), + Handlers.addHeader("header-name", "new-value") + ))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + // Not all HTTP server implementations are able to write multiple header lines. The HTTP spec + // says that sending a comma-delimited list is exactly equivalent. + assertThat(resp.headers("Header-Name"), + anyOf( + equalTo(ImmutableList.of("old-value", "new-value")), + equalTo(ImmutableList.of("old-value,new-value")) + )); + } + } + } + + @Test + public void body() throws Exception { + byte[] data = new byte[] { 1, 2, 3 }; + try (HttpServer server = HttpServer.start(Handlers.body("application/weird", data))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.body().bytes(), equalTo(data)); + } + } + } + + @Test + public void bodyStringWithNoCharset() throws Exception { + String body = "hello"; + try (HttpServer server = HttpServer.start(Handlers.bodyString("text/weird", body))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.header("content-type"), equalTo("text/weird")); + assertThat(resp.body().string(), equalTo(body)); + } + } + } + + @Test + public void bodyStringWithCharset() throws Exception { + String body = "hello"; + try (HttpServer server = HttpServer.start(Handlers.bodyString("text/weird", body, Charset.forName("UTF-8")))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.header("content-type"), equalTo("text/weird;charset=utf-8")); + assertThat(resp.body().string(), equalTo(body)); + } + } + } + + @Test + public void bodyJsonWithoutCharset() throws Exception { + String body = "true"; + try (HttpServer server = HttpServer.start(Handlers.bodyJson(body))) { + try (Response resp = client.newCall(new Request.Builder().url(server.getUrl()).build()).execute()) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.header("content-type"), equalTo("application/json")); + assertThat(resp.body().string(), equalTo(body)); + } + } + } + + @Test + public void bodyJsonWithCharset() throws Exception { + String body = "true"; + try (HttpServer server = HttpServer.start(Handlers.bodyJson(body, Charset.forName("UTF-8")))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.header("content-type"), equalTo("application/json;charset=utf-8")); + assertThat(resp.body().string(), equalTo(body)); + } + } + } + + @Test + public void chainStatusAndHeadersAndBody() throws Exception { + Handler handler = Handlers.all( + Handlers.status(201), + Handlers.header("name1", "value1"), + Handlers.header("name2", "value2"), + Handlers.bodyString("text/plain", "hello") + ); + try (HttpServer server = HttpServer.start(handler)) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(201)); + assertThat(resp.header("name1"), equalTo("value1")); + assertThat(resp.header("name2"), equalTo("value2")); + assertThat(resp.header("content-type"), equalTo("text/plain")); + assertThat(resp.body().string(), equalTo("hello")); + } + } + } + + @Test + public void waitFor() throws Exception { + Semaphore signal = new Semaphore(0); + Handler handler = Handlers.all( + Handlers.waitFor(signal), + Handlers.status(200) + ); + try (HttpServer server = HttpServer.start(handler)) { + AtomicBoolean signaled = new AtomicBoolean(false); + new Thread(() -> { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + signaled.set(true); + signal.release(); + }).start(); + try (Response resp = simpleGet(server.getUri())) { + assertThat(signaled.get(), equalTo(true)); + assertThat(resp.code(), equalTo(200)); + } + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HttpServerTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HttpServerTest.java new file mode 100644 index 0000000..beabe6f --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/HttpServerTest.java @@ -0,0 +1,78 @@ +package com.launchdarkly.testhelpers.httptest; + +import org.junit.Test; + +import static com.launchdarkly.testhelpers.httptest.TestUtil.simpleGet; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class HttpServerTest { + @Test + public void serverWithSimpleStatusHandler() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(419))) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(419)); + } + } + } + + @Test + public void serverOnSpecificPort() throws Exception { + try (HttpServer server = HttpServer.start(12345, Handlers.status(419))) { + assertThat(server.getPort(), equalTo(12345)); + assertThat(server.getUri().toString(), equalTo("http://localhost:12345/")); + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(419)); + } + } + } + + @Test + public void multipleServers() throws Exception { + try (HttpServer server1 = HttpServer.start(Handlers.status(200))) { + try (HttpServer server2 = HttpServer.start(Handlers.status(419))) { + try (Response resp1 = simpleGet(server1.getUri())) { + assertThat(resp1.code(), equalTo(200)); + } + try (Response resp2 = simpleGet(server2.getUri())) { + assertThat(resp2.code(), equalTo(419)); + } + } + } + } + + @Test + public void serverReturns500StatusForExceptionFromHandler() throws Exception { + Handler handler = ctx -> { + throw new RuntimeException("unfortunate"); + }; + try (HttpServer server = HttpServer.start(handler)) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(500)); + assertThat(resp.body().string(), equalTo("java.lang.RuntimeException: unfortunate")); + } + } + } + + @Test + public void secureServerWithSelfSignedCert() throws Exception { + ServerTLSConfiguration certData = ServerTLSConfiguration.makeSelfSignedCertificate(); + OkHttpClient client = new OkHttpClient.Builder() + .sslSocketFactory(certData.getSocketFactory(), certData.getTrustManager()) + .build(); + + try (HttpServer server = HttpServer.startSecure(certData, Handlers.status(419))) { + assertThat(server.getUri().toString(), startsWith("https:")); + + try (Response resp = client.newCall(new Request.Builder().url(server.getUrl()).build()).execute()) { + assertThat(resp.code(), equalTo(419)); + } + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/RequestRecorderTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/RequestRecorderTest.java new file mode 100644 index 0000000..1207eea --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/RequestRecorderTest.java @@ -0,0 +1,152 @@ +package com.launchdarkly.testhelpers.httptest; + +import org.junit.Test; + +import java.net.URI; + +import static com.launchdarkly.testhelpers.httptest.TestUtil.client; +import static com.launchdarkly.testhelpers.httptest.TestUtil.simpleGet; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class RequestRecorderTest { + // Note that these tests are really testing two things: the RequestRecorder API, and the + // ability of the underlying server implementation to correctly get the request properties. + + @Test + public void getMethodAndUri() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(200))) { + URI requestedUri = server.getUri().resolve("/request/path"); + Response resp = client.newCall( + new Request.Builder().url(requestedUri.toURL()).build() + ).execute(); + + assertThat(resp.code(), equalTo(200)); + + RequestInfo received = server.getRecorder().requireRequest(); + assertThat(received.getMethod(), equalTo("GET")); + assertThat(received.getUri(), equalTo(requestedUri)); + assertThat(received.getPath(), equalTo("/request/path")); + assertThat(received.getQuery(), nullValue()); + } + } + + @Test + public void queryString() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(200))) { + URI requestedUri = server.getUri().resolve("/request/path?a=b"); + Response resp = client.newCall( + new Request.Builder().url(requestedUri.toURL()).build() + ).execute(); + + assertThat(resp.code(), equalTo(200)); + + RequestInfo received = server.getRecorder().requireRequest(); + assertThat(received.getMethod(), equalTo("GET")); + assertThat(received.getUri(), equalTo(requestedUri)); + assertThat(received.getPath(), equalTo("/request/path")); + assertThat(received.getQuery(), equalTo("?a=b")); + } + } + + @Test + public void requestHeaders() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(200))) { + Response resp = client.newCall( + new Request.Builder().url(server.getUri().toURL()) + .header("name1", "value1") + .header("name2", "value2") + .build() + ).execute(); + + assertThat(resp.code(), equalTo(200)); + + RequestInfo received = server.getRecorder().requireRequest(); + assertThat(received.getHeader("name1"), equalTo("value1")); + assertThat(received.getHeader("name2"), equalTo("value2")); + } + } + + @Test + public void emptyRequestBodyByDefault() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(200))) { + Response resp = client.newCall( + new Request.Builder().url(server.getUri().toURL()).build() + ).execute(); + + assertThat(resp.code(), equalTo(200)); + + RequestInfo received = server.getRecorder().requireRequest(); + assertThat(received.getBody(), equalTo("")); + } + } + + @Test + public void patchRequestWithBody() throws Exception { + doRequestWithBody("PATCH"); + } + + @Test + public void postRequestWithBody() throws Exception { + doRequestWithBody("POST"); + } + + @Test + public void putRequestWithBody() throws Exception { + doRequestWithBody("PUT"); + } + + @Test + public void reportRequestWithBody() throws Exception { + doRequestWithBody("REPORT"); + } + + private void doRequestWithBody(String method) throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(200))) { + Response resp = client.newCall( + new Request.Builder().url(server.getUri().toURL()) + .method(method, RequestBody.create("{}", MediaType.parse("application/json"))) + .build() + ).execute(); + + assertThat(resp.code(), equalTo(200)); + + RequestInfo received = server.getRecorder().requireRequest(); + assertThat(received.getMethod(), equalTo(method)); + assertThat(received.getHeader("Content-Type"), startsWith("application/json")); + assertThat(received.getBody(), equalTo("{}")); + } + } + + @Test + public void canDisableRecorder() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(200))) { + simpleGet(server.getUri().resolve("/path1")); + + server.getRecorder().setEnabled(false); + + simpleGet(server.getUri().resolve("/path2")); + simpleGet(server.getUri().resolve("/path3")); + + server.getRecorder().setEnabled(true); + + simpleGet(server.getUri().resolve("/path4")); + + RequestInfo received1 = server.getRecorder().requireRequest(); + assertThat(received1.getPath(), equalTo("/path1")); + + RequestInfo received2 = server.getRecorder().requireRequest(); + assertThat(received2.getPath(), equalTo("/path4")); + + assertThat(server.getRecorder().count(), equalTo(0)); + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SequentialHandlerTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SequentialHandlerTest.java new file mode 100644 index 0000000..626c0a6 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SequentialHandlerTest.java @@ -0,0 +1,28 @@ +package com.launchdarkly.testhelpers.httptest; + +import org.junit.Test; + +import static com.launchdarkly.testhelpers.httptest.TestUtil.simpleGet; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class SequentialHandlerTest { + @Test + public void handlersAreCalledInSequence() throws Exception { + Handler handler = Handlers.sequential(Handlers.status(200), Handlers.status(201)); + + try (HttpServer server = HttpServer.start(handler)) { + Response resp1 = simpleGet(server.getUri()); + assertThat(resp1.code(), equalTo(200)); + + Response resp2 = simpleGet(server.getUri()); + assertThat(resp2.code(), equalTo(201)); + + Response resp3 = simpleGet(server.getUri()); + assertThat(resp3.code(), equalTo(500)); + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SimpleRouterTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SimpleRouterTest.java new file mode 100644 index 0000000..54a41ff --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SimpleRouterTest.java @@ -0,0 +1,118 @@ +package com.launchdarkly.testhelpers.httptest; + +import org.junit.Test; + +import java.util.regex.Pattern; + +import static com.launchdarkly.testhelpers.httptest.TestUtil.client; +import static com.launchdarkly.testhelpers.httptest.TestUtil.simpleGet; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class SimpleRouterTest { + @Test + public void noPathsMatchByDefault() throws Exception { + SimpleRouter router = new SimpleRouter(); + + try (HttpServer server = HttpServer.start(router)) { + Response resp = simpleGet(server.getUri()); + assertThat(resp.code(), equalTo(404)); + } + } + + @Test + public void simplePathMatch() throws Exception { + SimpleRouter router = new SimpleRouter(); + router.add("/path1", Handlers.status(201)); + router.add("/path2", Handlers.status(419)); + + try (HttpServer server = HttpServer.start(router)) { + Response resp1 = simpleGet(server.getUri().resolve("/path1")); + assertThat(resp1.code(), equalTo(201)); + + Response resp2 = simpleGet(server.getUri().resolve("/path2")); + assertThat(resp2.code(), equalTo(419)); + + Response resp3 = simpleGet(server.getUri().resolve("/path3")); + assertThat(resp3.code(), equalTo(404)); + } + } + + @Test + public void simplePathMatchWithMethod() throws Exception { + SimpleRouter router = new SimpleRouter(); + router.add("GET", "/path1", Handlers.status(201)); + router.add("DELETE", "/path1", Handlers.status(204)); + + try (HttpServer server = HttpServer.start(router)) { + Response resp1 = simpleGet(server.getUri().resolve("/path1")); + assertThat(resp1.code(), equalTo(201)); + + Response resp2 = client.newCall(new Request.Builder().url(server.getUri().resolve("/path1").toURL()) + .method("DELETE", null).build()).execute(); + assertThat(resp2.code(), equalTo(204)); + } + } + + @Test + public void pathRegexMatch() throws Exception { + SimpleRouter router = new SimpleRouter(); + router.addRegex(Pattern.compile("/path[12]"), Handlers.status(201)); + router.addRegex(Pattern.compile("/path[34]"), Handlers.status(419)); + + try (HttpServer server = HttpServer.start(router)) { + Response resp1 = simpleGet(server.getUri().resolve("/path1")); + assertThat(resp1.code(), equalTo(201)); + + Response resp2 = simpleGet(server.getUri().resolve("/path3")); + assertThat(resp2.code(), equalTo(419)); + + Response resp3 = simpleGet(server.getUri().resolve("/path5")); + assertThat(resp3.code(), equalTo(404)); + } + } + + @Test + public void pathRegexMatchWithMethod() throws Exception { + SimpleRouter router = new SimpleRouter(); + router.addRegex("GET", Pattern.compile("/path[12]"), Handlers.status(201)); + router.addRegex("DELETE", Pattern.compile("/path[12]"), Handlers.status(419)); + + try (HttpServer server = HttpServer.start(router)) { + Response resp1 = simpleGet(server.getUri().resolve("/path1")); + assertThat(resp1.code(), equalTo(201)); + + Response resp2 = client.newCall(new Request.Builder().url(server.getUri().resolve("/path1").toURL()) + .method("DELETE", null).build()).execute(); + assertThat(resp2.code(), equalTo(419)); + + Response resp3 = client.newCall(new Request.Builder().url(server.getUri().resolve("/path1").toURL()) + .method("POST", RequestBody.create(new byte[0])).build()).execute(); + assertThat(resp3.code(), equalTo(405)); + + Response resp4 = client.newCall(new Request.Builder().url(server.getUri().resolve("/path3").toURL()) + .method("DELETE", null).build()).execute(); + assertThat(resp4.code(), equalTo(404)); + } + } + + @Test + public void pathRegexMatchWithPathParam() throws Exception { + SimpleRouter router = new SimpleRouter(); + router.addRegex(Pattern.compile("/path1/([^/]*)/do/(.*)"), ctx -> { + String message = "I did " + ctx.getPathParam(1) + " in " + ctx.getPathParam(0); + Handlers.bodyString("text/plain", message).apply(ctx); + }); + + try (HttpServer server = HttpServer.start(router)) { + Response resp1 = simpleGet(server.getUri().resolve("/path1/Chicago/do/something/or/other")); + assertThat(resp1.code(), equalTo(200)); + assertThat(resp1.body().string(), equalTo("I did something/or/other in Chicago")); + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SpecialHttpConfigurationsTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SpecialHttpConfigurationsTest.java new file mode 100644 index 0000000..3079dc8 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/SpecialHttpConfigurationsTest.java @@ -0,0 +1,171 @@ +package com.launchdarkly.testhelpers.httptest; + +import com.launchdarkly.testhelpers.httptest.SpecialHttpConfigurations.Params; +import com.launchdarkly.testhelpers.httptest.SpecialHttpConfigurations.UnexpectedResponseException; + +import org.junit.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; + +import static org.junit.Assert.assertFalse; + +import okhttp3.Authenticator; +import okhttp3.Credentials; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Route; + +@SuppressWarnings("javadoc") +public class SpecialHttpConfigurationsTest { + private static final int EXPECTED_STATUS = 418; + + // An implementation of TestAction that just converts the specified parameters to OkHttp settings + // and makes a request with the OkHttp client. + public static class MyTestClientAction implements SpecialHttpConfigurations.TestAction { + @Override + public boolean doTest(URI targetUri, Params params) throws IOException, SpecialHttpConfigurations.UnexpectedResponseException { + OkHttpClient.Builder cb = new OkHttpClient.Builder(); + if (params.getTlsConfig() != null && params.getTlsConfig().getSocketFactory() != null) { + cb.sslSocketFactory(params.getTlsConfig().getSocketFactory(), params.getTlsConfig().getTrustManager()); + } + if (params.getSocketFactory() != null) { + cb.socketFactory(params.getSocketFactory()); + } + if (params.getProxyHost() != null) { + cb.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(params.getProxyHost(), params.getProxyPort()))); + if (params.getProxyBasicAuthUser() != null) { + cb.proxyAuthenticator(new Authenticator() { + public Request authenticate(Route route, Response response) throws IOException { + return response.request().newBuilder() + .header("Proxy-Authorization", + Credentials.basic(params.getProxyBasicAuthUser(), params.getProxyBasicAuthPassword())) + .build(); + } + }); + } + } + + OkHttpClient client = cb.build(); + + Request req = new Request.Builder().url(targetUri.toString()).build(); + + Response resp = client.newCall(req).execute(); + if (resp.code() != EXPECTED_STATUS) { // we know we will be configuring the server with testActionHandler() + throw new SpecialHttpConfigurations.UnexpectedResponseException("got unexpected response status " + resp.code()); + } + + return true; + } + } + + private static Handler testActionHandler() { + return Handlers.status(EXPECTED_STATUS); + } + + @Test + public void testAllCorrect() { + SpecialHttpConfigurations.testAll(testActionHandler(), new MyTestClientAction()); + } + + @Test + public void testSelfSignedCertFails() { + SpecialHttpConfigurations.TestAction testActionThatIgnoresTlsConfigParam = new SpecialHttpConfigurations.TestAction() { + @Override + public boolean doTest(URI targetUri, Params params) throws IOException, UnexpectedResponseException { + params = new Params(null, + params.getSocketFactory(), params.getProxyHost(), params.getProxyPort(), + params.getProxyBasicAuthUser(), params.getProxyBasicAuthPassword()); + return new MyTestClientAction().doTest(targetUri, params); + } + }; + boolean passed = false; + try { + SpecialHttpConfigurations.testHttpClientCanBeConfiguredToAllowSelfSignedCert(testActionHandler(), + testActionThatIgnoresTlsConfigParam); + passed = true; + } catch (AssertionError e) {} + assertFalse("expected test to fail", passed); + } + + @Test + public void testSocketFactoryFails() { + SpecialHttpConfigurations.TestAction testActionThatIgnoresSocketFactoryParam = new SpecialHttpConfigurations.TestAction() { + @Override + public boolean doTest(URI targetUri, Params params) throws IOException, UnexpectedResponseException { + params = new Params(params.getTlsConfig(), + null, + params.getProxyHost(), params.getProxyPort(), params.getProxyBasicAuthUser(), params.getProxyBasicAuthPassword()); + return new MyTestClientAction().doTest(targetUri, params); + } + }; + boolean passed = false; + try { + SpecialHttpConfigurations.testHttpClientCanUseCustomSocketFactory(testActionHandler(), + testActionThatIgnoresSocketFactoryParam); + passed = true; + } catch (AssertionError e) {} + assertFalse("expected test to fail", passed); + } + + @Test + public void testProxyFails() { + SpecialHttpConfigurations.TestAction testActionThatIgnoresProxyParams = new SpecialHttpConfigurations.TestAction() { + @Override + public boolean doTest(URI targetUri, Params params) throws IOException, UnexpectedResponseException { + params = new Params(params.getTlsConfig(), params.getSocketFactory(), + null, 0, + params.getProxyBasicAuthUser(), params.getProxyBasicAuthPassword()); + return new MyTestClientAction().doTest(targetUri, params); + } + }; + boolean passed = false; + try { + SpecialHttpConfigurations.testHttpClientCanUseProxy(testActionHandler(), + testActionThatIgnoresProxyParams); + passed = true; + } catch (AssertionError e) {} + assertFalse("expected test to fail", passed); + } + + @Test + public void testProxyAuthFailsWithNoAuthProvided() { + SpecialHttpConfigurations.TestAction testActionThatIgnoresProxyAuthParams = new SpecialHttpConfigurations.TestAction() { + @Override + public boolean doTest(URI targetUri, Params params) throws IOException, UnexpectedResponseException { + params = new Params(params.getTlsConfig(), params.getSocketFactory(), params.getProxyHost(), params.getProxyPort(), + null, null); + return new MyTestClientAction().doTest(targetUri, params); + } + }; + boolean passed = false; + try { + SpecialHttpConfigurations.testHttpClientCanUseProxyWithBasicAuth(testActionHandler(), + testActionThatIgnoresProxyAuthParams); + passed = true; + } catch (AssertionError e) {} + assertFalse("expected test to fail", passed); + } + + @Test + public void testProxyAuthFailsWithWrongAuthProvided() { + SpecialHttpConfigurations.TestAction testActionThatChangesProxyAuthParams = new SpecialHttpConfigurations.TestAction() { + @Override + public boolean doTest(URI targetUri, Params params) throws IOException, UnexpectedResponseException { + params = new Params(params.getTlsConfig(), params.getSocketFactory(), params.getProxyHost(), params.getProxyPort(), + params.getProxyBasicAuthUser(), "x" + params.getProxyBasicAuthPassword()); + return new MyTestClientAction().doTest(targetUri, params); + } + }; + boolean passed = false; + try { + SpecialHttpConfigurations.testHttpClientCanUseProxyWithBasicAuth(testActionHandler(), + testActionThatChangesProxyAuthParams); + passed = true; + } catch (AssertionError e) {} + assertFalse("expected test to fail", passed); + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/StreamingTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/StreamingTest.java new file mode 100644 index 0000000..91d6234 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/StreamingTest.java @@ -0,0 +1,116 @@ +package com.launchdarkly.testhelpers.httptest; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; + +import org.junit.Test; + +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.List; +import java.util.concurrent.Semaphore; + +import static com.launchdarkly.testhelpers.httptest.TestUtil.simpleGet; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class StreamingTest { + @Test + public void basicChunkedResponseWithNoCharsetInHeader() throws Exception { + List chunks = ImmutableList.of("first.", "second.", "third"); + doStreamingTest( + Handlers.startChunks("text/plain", null), + Iterables.toArray(Iterables.transform(chunks, Handlers::writeChunkString), Handler.class), + Handlers.hang(), + "text/plain", + chunks + ); + } + + @Test + public void basicChunkedResponseWithCharsetInHeader() throws Exception { + List chunks = ImmutableList.of("first.", "second.", "third"); + doStreamingTest( + Handlers.startChunks("text/plain", Charset.forName("UTF-8")), + Iterables.toArray(Iterables.transform(chunks, Handlers::writeChunkString), Handler.class), + Handlers.hang(), + "text/plain;charset=utf-8", + chunks + ); + } + + @Test + public void sseStream() throws Exception { + doStreamingTest( + Handlers.SSE.start(), + new Handler[] { + Handlers.SSE.event("e1", "d1"), + Handlers.SSE.comment("comment"), + Handlers.SSE.event("e2", "d2"), + Handlers.SSE.event("data: all done"), + }, + Handlers.SSE.leaveOpen(), + "text/event-stream;charset=utf-8", + ImmutableList.of( + "event: e1\ndata: d1\n\n", + ":comment\n", + "event: e2\ndata: d2\n\n", + "data: all done\n\n" + ) + ); + } + + private void doStreamingTest( + Handler startAction, + Handler[] chunkActions, + Handler endAction, + String expectedContentType, + List expectedChunks + ) throws Exception { + Semaphore[] didWriteChunk = new Semaphore[expectedChunks.size()]; + Semaphore[] didReadChunk = new Semaphore[expectedChunks.size()]; + for (int i = 0; i < expectedChunks.size(); i++) { + didWriteChunk[i] = new Semaphore(0); + didReadChunk[i] = new Semaphore(0); + } + + Handler handler = Handlers.all( + startAction, + ctx -> { + for (int i = 0; i < expectedChunks.size(); i++) { + chunkActions[i].apply(ctx); + didWriteChunk[i].release(); + try { + didReadChunk[i].acquire(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + }, + endAction + ); + + try (HttpServer server = HttpServer.start(handler)) { + try (Response resp = simpleGet(server.getUri())) { + assertThat(resp.code(), equalTo(200)); + assertThat(resp.header("Content-Type"), equalTo(expectedContentType)); + + InputStream stream = resp.body().byteStream(); + + for (int i = 0; i < expectedChunks.size(); i++) { + didWriteChunk[i].acquire(); + + byte[] buf = new byte[100]; + int n = stream.read(buf); + String s = new String(buf, 0, n); + assertThat(s, equalTo(expectedChunks.get(i))); + + didReadChunk[i].release(); + } + } + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/TestUtil.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/TestUtil.java new file mode 100644 index 0000000..0b1074a --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/httptest/TestUtil.java @@ -0,0 +1,24 @@ +package com.launchdarkly.testhelpers.httptest; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +@SuppressWarnings("javadoc") +public class TestUtil { + public static final OkHttpClient client = new OkHttpClient.Builder() + .readTimeout(5, TimeUnit.MINUTES) + .retryOnConnectionFailure(false) + .build(); + + public static Response simpleGet(URI uri) { + try { + return client.newCall(new Request.Builder().url(uri.toURL()).build()).execute(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TcpHandlersTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TcpHandlersTest.java new file mode 100644 index 0000000..10a3125 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TcpHandlersTest.java @@ -0,0 +1,94 @@ +package com.launchdarkly.testhelpers.tcptest; + +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.Socket; + +import static com.launchdarkly.testhelpers.tcptest.TestUtil.readStreamFully; +import static com.launchdarkly.testhelpers.tcptest.TestUtil.toUtf8Bytes; +import static com.launchdarkly.testhelpers.tcptest.TestUtil.toUtf8String; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class TcpHandlersTest { + @Test + public void writeData() throws IOException { + byte[] expected = new byte[] { 100, 101, 102 }; + TcpHandler handler = TcpHandlers.writeData(expected, 0, expected.length); + + try (TcpServer server = TcpServer.start(handler)) { + try (Socket s = new Socket("localhost", server.getPort())) { + byte[] actual = readStreamFully(s.getInputStream()); + assertArrayEquals(expected, actual); + } + } + } + + @Test + public void writeString() throws IOException { + String message = "hello"; + TcpHandler handler = TcpHandlers.writeString(message); + + try (TcpServer server = TcpServer.start(handler)) { + try (Socket s = new Socket("localhost", server.getPort())) { + assertEquals(message, toUtf8String(readStreamFully(s.getInputStream()))); + } + } + } + + @Test + public void forwardToPort() throws IOException { + String question = "question?"; + String answer = "answer!"; + ByteArrayOutputStream receivedData = new ByteArrayOutputStream(); + TcpHandler handler = new TcpHandler() { + @Override + public void apply(Socket socket) throws IOException { + byte[] data = TestUtil.readStream(socket.getInputStream(), toUtf8Bytes(question).length); + receivedData.write(data); + TcpHandlers.writeString(answer).apply(socket); + } + }; + + try (TcpServer underlyingServer = TcpServer.start(handler)) { + try (TcpServer forwardingServer = TcpServer.start(TcpHandlers.forwardToPort(underlyingServer.getPort()))) { + try (Socket s = new Socket("localhost", forwardingServer.getPort())) { + TcpHandlers.writeString(question).apply(s); + assertEquals(answer, toUtf8String(readStreamFully(s.getInputStream()))); + } + } + } + } + + @Test + public void noResponse() throws IOException { + TcpHandler handler = TcpHandlers.noResponse(); + + try (TcpServer server = TcpServer.start(handler)) { + try (Socket s = new Socket("localhost", server.getPort())) { + byte[] data = readStreamFully(s.getInputStream()); + assertEquals(0, data.length); + } + } + } + + @Test + public void sequential() throws IOException { + String message = "hello"; + TcpHandler handler = TcpHandlers.sequential( + TcpHandlers.noResponse(), + TcpHandlers.writeString(message)); + + try (TcpServer server = TcpServer.start(handler)) { + try (Socket s1 = new Socket("localhost", server.getPort())) { + assertEquals("", toUtf8String(readStreamFully(s1.getInputStream()))); + } + try (Socket s2 = new Socket("localhost", server.getPort())) { + assertEquals(message, toUtf8String(readStreamFully(s2.getInputStream()))); + } + } + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TcpServerTest.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TcpServerTest.java new file mode 100644 index 0000000..5778f66 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TcpServerTest.java @@ -0,0 +1,42 @@ +package com.launchdarkly.testhelpers.tcptest; + +import org.junit.Test; + +import java.io.IOException; +import java.net.Socket; + +import static com.launchdarkly.testhelpers.tcptest.TestUtil.doesPortHaveListener; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class TcpServerTest { + @Test + public void listensOnAnyAvailablePort() throws IOException { + int port; + try (TcpServer server = TcpServer.start(TcpHandlers.noResponse())) { + assertNotEquals(0, server.getPort()); + port = server.getPort(); + try (Socket s = new Socket("localhost", server.getPort())) {} // just verify that we can connect + } + assertFalse("expected listener to be closed, but it wasn't", doesPortHaveListener(port)); + } + + @Test + public void listensOnSpecificPort() throws IOException { + int specificPort = 10000; + while (doesPortHaveListener(specificPort)) { + if (specificPort == 65535) { + fail("test could not find an available port"); + } + specificPort++; + } + try (TcpServer server = TcpServer.start(specificPort, TcpHandlers.noResponse())) { + assertEquals(specificPort, server.getPort()); + try (Socket s = new Socket("localhost", specificPort)) {} // just verify that we can connect + } + assertFalse("expected listener to be closed, but it wasn't", doesPortHaveListener(specificPort)); + } +} diff --git a/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TestUtil.java b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TestUtil.java new file mode 100644 index 0000000..5b69184 --- /dev/null +++ b/lib/shared/test-helpers/src/test/java/com/launchdarkly/testhelpers/tcptest/TestUtil.java @@ -0,0 +1,47 @@ +package com.launchdarkly.testhelpers.tcptest; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.Socket; +import java.nio.charset.Charset; + +@SuppressWarnings("javadoc") +public class TestUtil { + public static boolean doesPortHaveListener(int port) { + try { + try (Socket s = new Socket("localhost", port)) {} + return true; + } catch (IOException e) { + return false; + } + } + + public static byte[] readStreamFully(InputStream input) throws IOException { + return readStream(input, -1); + } + + public static byte[] readStream(InputStream input, int maxLength) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[1000]; + while (true) { + int n = input.read(buffer); + if (n < 0) { + break; + } + bytes.write(buffer, 0, n); + if (maxLength > 0 && bytes.size() >= maxLength) { + break; + } + } + return bytes.toByteArray(); + } + + public static String toUtf8String(byte[] data) { + return new String(data, Charset.forName("UTF-8")); + } + + public static byte[] toUtf8Bytes(String s) { + return s.getBytes(Charset.forName("UTF-8")); + } +} diff --git a/release-please-config.json b/release-please-config.json index 1b7cb83..6b9cf39 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -35,6 +35,14 @@ "gradle.properties" ] }, + "lib/shared/test-helpers": { + "package-name": "test-helpers", + "bump-minor-pre-major": true, + "include-v-in-tag": false, + "extra-files": [ + "gradle.properties" + ] + }, "lib/sdk/server": { "package-name": "launchdarkly-java-server-sdk", "bump-minor-pre-major": true,