diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d340b76 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +# Change these settings to your own preference +indent_style = space +indent_size = 4 + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.{json,yaml,yml}] +indent_size = 2 diff --git a/.github/workflows/publish_snapshots.yml b/.github/workflows/publish_snapshots.yml index 871a927..ef1acb0 100644 --- a/.github/workflows/publish_snapshots.yml +++ b/.github/workflows/publish_snapshots.yml @@ -17,10 +17,6 @@ jobs: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v3 - - name: Has SNAPSHOT version - id: is-snapshot - run: grep 'version = ".*-SNAPSHOT"' build.gradle.kts - - uses: actions/setup-java@v3 with: distribution: temurin @@ -29,6 +25,11 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v2 + - name: Has SNAPSHOT version + id: is-snapshot + run: | + ./gradlew properties | grep 'version: .*-SNAPSHOT' + - name: Install gpg secret key run: | cat <(echo -e "${{ secrets.OSSRH_GPG_SECRET_KEY }}") | gpg --batch --import diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7db5901..5de2f4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,13 @@ jobs: # Compile code - name: Compile code - run: ./gradlew assemble + run: ./gradlew assemble collectLicenses # Upload it to GitHub - name: Upload to GitHub - uses: AButler/upload-release-assets@v2.0 + uses: AButler/upload-release-assets@v2.0.2 with: - files: 'radar-jersey/build/libs/*;radar-jersey-hibernate/build/libs/*' + files: 'radar-jersey/build/libs/*;radar-jersey-hibernate/build/libs/*;radar-jersey*/build/reports/*.tar.gz' repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install gpg secret key diff --git a/README.md b/README.md index e705299..24e43c5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ repositories { } dependencies { - api("org.radarbase:radar-jersey:0.10.0") + api("org.radarbase:radar-jersey:0.11.0") } ``` @@ -21,27 +21,62 @@ Any path or resource that should be authenticated against the ManagementPortal, @Path("/projects") @Authenticated class Users( - @Context projectService: MyProjectService + @Context private val projectService: MyProjectService, + @Context private val asyncService: AsyncCoroutineService, + @Context private val authService: AuthService, ) { + // Most services can be run as coroutines with + // asynchronous handling @GET @NeedsPermission(Permission.PROJECT_READ) - fun getProjects(@Context auth: Auth): List { - return projectService.read() - .filter { auth.token.hasPermissionOnProject(PROJECT_READ, it.name) } + fun getProjects( + @Suspended asyncResponse: AsyncResponse, + ) = asyncService.runAsCoroutine(asyncResponse) { + projectService.read() + .filter { authService.hasPermission(PROJECT_READ, entityDetails { project(it.name) }) } } @POST @Path("/{projectId}") @NeedsPermission(Permission.PROJECT_UPDATE, "projectId") - fun updateProject(@PathParam("projectId") projectId: String, project: Project) { - return projectService.update(projectId, project) + fun updateProject( + @PathParam("projectId") projectId: String, + project: Project, + @Suspended asyncResponse: AsyncResponse, + ) = asyncService.runAsCoroutine(asyncResponse) { + projectService.update(projectId, project) } @GET @Path("/{projectId}/users/{userId}") @NeedsPermission(Permission.SUBJECT_READ, "projectId", "userId") - fun getUsers(@PathParam("projectId") projectId: String, @PathParam("userId") userId: String) { - return projectService.readUser(projectId, userId) + fun getUsers( + @PathParam("projectId") projectId: String, + @PathParam("userId") userId: String, + @Suspended asyncResponse: AsyncResponse, + ) = asyncService.runAsCoroutine(asyncResponse) { + projectService.readUser(projectId, userId) + } + + // Simple responses can be handled without context switches + @GET + @Path("/{projectId}/settings") + @NeedsPermission(Permission.PROJECT_READ, "projectId") + fun getProjectSettings( + @PathParam("projectId") projectId: String, + ): ProjectSettingsDto { + return ProjectSettingsDto(projectId = projectId) + } + + // Simple coroutine responses can also handled without context switches + @GET + @Path("/{projectId}/users/{userId}/settings") + @NeedsPermission(Permission.SUBJECT_READ, "projectId", "userId") + fun getProjectSettings( + @PathParam("projectId") projectId: String, + @PathParam("userId") userId: String, + ) = asyncService.runBlocking { + UserSettingsDto(projectId = projectId, userId = userId) } } ``` diff --git a/build.gradle.kts b/build.gradle.kts index b3b3a0a..93de5e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,227 +1,47 @@ -import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask -import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.radarbase.gradle.plugin.radarKotlin +import org.radarbase.gradle.plugin.radarPublishing +import org.radarbase.gradle.plugin.radarRootProject plugins { - kotlin("jvm") apply false - `maven-publish` - signing - id("org.jetbrains.dokka") apply false - id("com.github.ben-manes.versions") version "0.46.0" - id("io.github.gradle-nexus.publish-plugin") version "1.3.0" + id("org.radarbase.radar-root-project") version Versions.radarCommons + id("org.radarbase.radar-dependency-management") version Versions.radarCommons + id("org.radarbase.radar-kotlin") version Versions.radarCommons apply false + id("org.radarbase.radar-publishing") version Versions.radarCommons apply false } -allprojects { - group = "org.radarbase" - version = "0.10.0" +radarRootProject { + projectVersion.set(Versions.project) + gradleVersion.set(Versions.wrapper) } subprojects { - apply(plugin = "kotlin") - apply(plugin = "maven-publish") - apply(plugin = "signing") - apply(plugin = "org.jetbrains.dokka") - - val githubRepoName = "RADAR-base/radar-jersey" - val githubUrl = "https://github.com/$githubRepoName.git" - val githubIssueUrl = "https://github.com/$githubRepoName/issues" - - repositories { - mavenCentral() { - mavenContent { - releasesOnly() - } - } - mavenLocal() - maven(url = "https://oss.sonatype.org/content/repositories/snapshots") { - mavenContent { - snapshotsOnly() - } - } - } - - dependencies { - val dokkaVersion: String by project - configurations["dokkaHtmlPlugin"]("org.jetbrains.dokka:kotlin-as-java-plugin:$dokkaVersion") - - val jacksonVersion: String by project - val jsoupVersion: String by project - val kotlinVersion: String by project - - sequenceOf("dokkaPlugin", "dokkaRuntime") - .map { configurations[it] } - .forEach { conf -> - conf(platform("com.fasterxml.jackson:jackson-bom:$jacksonVersion")) - conf("org.jsoup:jsoup:$jsoupVersion") - conf(platform("org.jetbrains.kotlin:kotlin-bom:$kotlinVersion")) - } - - val log4j2Version: String by project - val testRuntimeOnly by configurations - testRuntimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:$log4j2Version") - testRuntimeOnly("org.apache.logging.log4j:log4j-core:$log4j2Version") - testRuntimeOnly("org.apache.logging.log4j:log4j-jul:$log4j2Version") - } - - val sourcesJar by tasks.registering(Jar::class) { - from(this@subprojects.the()["main"].allSource) - archiveClassifier.set("sources") - val classes by tasks - dependsOn(classes) - } - - val dokkaJar by tasks.registering(Jar::class) { - from("$buildDir/dokka/javadoc") - archiveClassifier.set("javadoc") - val dokkaJavadoc by tasks - dependsOn(dokkaJavadoc) - } - - val jvmTargetVersion = 17 - - tasks.withType { - options.release.set(jvmTargetVersion) - } - - tasks.withType { - compilerOptions { - jvmTarget.set(JvmTarget.fromTarget(jvmTargetVersion.toString())) - apiVersion.set(KotlinVersion.KOTLIN_1_8) - languageVersion.set(KotlinVersion.KOTLIN_1_8) - } - } - - afterEvaluate { - configurations.all { - resolutionStrategy.cacheChangingModulesFor(0, TimeUnit.SECONDS) - resolutionStrategy.cacheDynamicVersionsFor(0, TimeUnit.SECONDS) - } - - tasks.withType { - testLogging { - events("passed", "skipped", "failed") - showStandardStreams = true - exceptionFormat = FULL - } - useJUnitPlatform() - systemProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager") - } - - tasks.withType { - compression = Compression.GZIP - archiveExtension.set("tar.gz") - } - - tasks.withType { - manifest { - attributes( - "Implementation-Title" to this@subprojects.name, - "Implementation-Version" to this@subprojects.version - ) + apply(plugin = "org.radarbase.radar-kotlin") + apply(plugin = "org.radarbase.radar-publishing") + + radarKotlin { + javaVersion.set(Versions.java) + kotlinVersion.set(Versions.kotlin) + log4j2Version.set(Versions.log4j2) + slf4jVersion.set(Versions.slf4j) + junitVersion.set(Versions.junit) + } + + radarPublishing { + val githubRepoName = "RADAR-base/radar-jersey" + githubUrl.set("https://github.com/$githubRepoName.git") + developers { + developer { + id.set("blootsvoets") + name.set("Joris Borgdorff") + email.set("joris@thehyve.nl") + organization.set("The Hyve") } - } - - val assemble by tasks - assemble.dependsOn(sourcesJar) - assemble.dependsOn(dokkaJar) - - publishing { - publications { - create("mavenJar") { - afterEvaluate { - from(components["java"]) - } - artifact(sourcesJar) - artifact(dokkaJar) - - pom { - name.set(this@subprojects.name) - description.set(this@subprojects.description) - url.set(githubUrl) - licenses { - license { - name.set("The Apache Software License, Version 2.0") - url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") - distribution.set("repo") - } - } - developers { - developer { - id.set("blootsvoets") - name.set("Joris Borgdorff") - email.set("joris@thehyve.nl") - organization.set("The Hyve") - } - developer { - id.set("nivemaham") - name.set("Nivethika Mahasivam") - email.set("nivethika@thehyve.nl") - organization.set("The Hyve") - } - } - issueManagement { - system.set("GitHub") - url.set(githubIssueUrl) - } - organization { - name.set("RADAR-base") - url.set("https://radar-base.org") - } - scm { - connection.set("scm:git:$githubUrl") - url.set(githubUrl) - } - } - } + developer { + id.set("nivemaham") + name.set("Nivethika Mahasivam") + email.set("nivethika@thehyve.nl") + organization.set("The Hyve") } } - - signing { - useGpgCmd() - isRequired = true - sign(tasks["sourcesJar"], tasks["dokkaJar"]) - sign(publishing.publications["mavenJar"]) - } - - tasks.withType().configureEach { - onlyIf { gradle.taskGraph.hasTask("${project.path}:publish") } - } } } - -tasks.withType { - doFirst { - allprojects { - repositories.removeAll { - it is MavenArtifactRepository && it.url.toString().endsWith("/snapshots") - } - } - } - val isStable = "(^[0-9,.v-]+(-r)?|RELEASE|FINAL|GA|-CE)$".toRegex(RegexOption.IGNORE_CASE) - rejectVersionIf { - !isStable.containsMatchIn(candidate.version) - } -} - -fun Project.propertyOrEnv(propertyName: String, envName: String): String? { - return if (hasProperty(propertyName)) { - property(propertyName)?.toString() - } else { - System.getenv(envName) - } -} - -nexusPublishing { - repositories { - sonatype { - username.set(propertyOrEnv("ossrh.user", "OSSRH_USER")) - password.set(propertyOrEnv("ossrh.password", "OSSRH_PASSWORD")) - } - } -} - -tasks.wrapper { - gradleVersion = "8.0.2" -} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..da83967 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,21 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version "1.9.10" +} + +repositories { + mavenCentral() +} + +tasks.withType { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +tasks.withType { + sourceCompatibility = "11" + targetCompatibility = "11" +} diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt new file mode 100644 index 0000000..3faa5ee --- /dev/null +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -0,0 +1,39 @@ +@Suppress("ConstPropertyName") +object Versions { + const val project = "0.11.0" + const val kotlin = "1.9.10" + + const val java: Int = 17 + const val jersey = "3.1.3" + const val grizzly = "4.0.0" + const val okhttp = "4.11.0" + const val junit = "5.10.0" + const val hamcrest = "2.2" + const val mockitoKotlin = "5.1.0" + + const val hk2 = "3.0.4" + const val managementPortal = "2.1.0" + const val radarCommons = "1.1.1" + const val javaJwt = "4.4.0" + const val jakartaWsRs = "3.1.0" + const val jakartaAnnotation = "2.1.1" + const val jackson = "2.15.3" + const val slf4j = "2.0.9" + const val log4j2 = "2.20.0" + const val jakartaXmlBind = "4.0.1" + const val jakartaJaxbCore = "4.0.3" + const val jakartaJaxbRuntime = "4.0.3" + const val jakartaValidation = "3.0.2" + const val hibernateValidator = "8.0.1.Final" + const val glassfishJakartaEl = "4.0.2" + const val jakartaActivation = "2.1.2" + const val swagger = "2.2.17" + const val mustache = "0.9.11" + + const val hibernate = "6.3.1.Final" + const val liquibase = "4.24.0" + const val postgres = "42.6.0" + const val h2 = "2.2.224" + + const val wrapper = "8.4" +} diff --git a/gradle.properties b/gradle.properties index 8900d32..545f5db 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,38 +1,2 @@ org.gradle.jvmargs=-Xmx2000m - -org.gradle.vfs.watch=true kotlin.code.style=official - -kotlinVersion=1.8.10 -dokkaVersion=1.8.10 -jsoupVersion=1.15.4 - -jerseyVersion=3.1.1 -grizzlyVersion=4.0.0 -okhttpVersion=4.10.0 -junitVersion=5.9.2 -hamcrestVersion=2.2 -mockitoKotlinVersion=4.1.0 - -hk2Version=3.0.3 -managementPortalVersion=2.0.0 -javaJwtVersion=4.3.0 -jakartaWsRsVersion=3.1.0 -jakartaAnnotationVersion=2.1.1 -jacksonVersion=2.14.2 -slf4jVersion=2.0.6 -log4j2Version=2.20.0 -jakartaXmlBindVersion=4.0.0 -jakartaJaxbCoreVersion=4.0.2 -jakartaJaxbRuntimeVersion=4.0.2 -jakartaValidationVersion=3.0.2 -hibernateValidatorVersion=8.0.0.Final -glassfishJakartaElVersion=4.0.2 -jakartaActivation=2.1.1 -swaggerVersion=2.2.8 -mustacheVersion=0.9.10 - -hibernateVersion=6.1.7.Final -liquibaseVersion=4.20.0 -postgresVersion=42.5.4 -h2Version=2.1.214 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7..7f93135 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bdc9a83..3fa8f86 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 79a61d4..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -83,10 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -# 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"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,10 +131,13 @@ 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. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. @@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -197,11 +198,15 @@ if "$cygwin" || "$msys" ; then 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. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/radar-jersey-hibernate/build.gradle.kts b/radar-jersey-hibernate/build.gradle.kts index 3154683..befaf10 100644 --- a/radar-jersey-hibernate/build.gradle.kts +++ b/radar-jersey-hibernate/build.gradle.kts @@ -5,43 +5,29 @@ plugins { description = "Library for Jersey with Hibernate with the RADAR platform" dependencies { - val kotlinVersion: String by project - implementation(kotlin("reflect", version=kotlinVersion)) - api(kotlin("stdlib-jdk8", version=kotlinVersion)) + implementation(kotlin("reflect", version = Versions.kotlin)) + api(kotlin("stdlib-jdk8", version = Versions.kotlin)) api(project(":radar-jersey")) - val hibernateVersion: String by project - api("org.hibernate:hibernate-core:$hibernateVersion") - runtimeOnly("org.hibernate:hibernate-c3p0:$hibernateVersion") - - val jakartaValidationVersion: String by project - runtimeOnly("jakarta.validation:jakarta.validation-api:$jakartaValidationVersion") - val hibernateValidatorVersion: String by project - runtimeOnly("org.hibernate.validator:hibernate-validator:$hibernateValidatorVersion") - val glassfishJakartaElVersion: String by project - runtimeOnly("org.glassfish:jakarta.el:$glassfishJakartaElVersion") - - val slf4jVersion: String by project - implementation("org.slf4j:slf4j-api:$slf4jVersion") - - val liquibaseVersion: String by project - implementation("org.liquibase:liquibase-core:$liquibaseVersion") - - val postgresVersion: String by project - runtimeOnly("org.postgresql:postgresql:$postgresVersion") - - val grizzlyVersion: String by project - testRuntimeOnly("org.glassfish.grizzly:grizzly-http-server:$grizzlyVersion") - val jerseyVersion: String by project - testRuntimeOnly("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:$jerseyVersion") - - val h2Version: String by project - testImplementation("com.h2database:h2:$h2Version") - - val junitVersion: String by project - testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") - val hamcrestVersion: String by project - testImplementation("org.hamcrest:hamcrest:$hamcrestVersion") - val okhttpVersion: String by project - testImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") + api("org.hibernate:hibernate-core:${Versions.hibernate}") + runtimeOnly("org.hibernate:hibernate-hikaricp:${Versions.hibernate}") + + implementation("org.radarbase:radar-commons-kotlin:${Versions.radarCommons}") + + runtimeOnly("jakarta.validation:jakarta.validation-api:${Versions.jakartaValidation}") + runtimeOnly("org.hibernate.validator:hibernate-validator:${Versions.hibernateValidator}") + + runtimeOnly("org.glassfish:jakarta.el:${Versions.glassfishJakartaEl}") + + implementation("org.liquibase:liquibase-core:${Versions.liquibase}") + + runtimeOnly("org.postgresql:postgresql:${Versions.postgres}") + + testRuntimeOnly("org.glassfish.grizzly:grizzly-http-server:${Versions.grizzly}") + testRuntimeOnly("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:${Versions.jersey}") + + testImplementation("com.h2database:h2:${Versions.h2}") + + testImplementation("org.hamcrest:hamcrest:${Versions.hamcrest}") + testImplementation("com.squareup.okhttp3:okhttp:${Versions.okhttp}") } diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetrics.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetrics.kt index 4df1339..f756d4f 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetrics.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetrics.kt @@ -3,36 +3,43 @@ package org.radarbase.jersey.hibernate import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.ws.rs.core.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.radarbase.jersey.hibernate.DatabaseInitialization.Companion.useConnection import org.radarbase.jersey.hibernate.config.DatabaseConfig +import org.radarbase.jersey.service.AsyncCoroutineService import org.radarbase.jersey.service.HealthService import org.radarbase.jersey.service.HealthService.Metric -import org.radarbase.jersey.util.CacheConfig -import org.radarbase.jersey.util.CachedValue -import java.time.Duration +import org.radarbase.kotlin.coroutines.CacheConfig +import org.radarbase.kotlin.coroutines.CachedValue +import kotlin.time.Duration.Companion.seconds class DatabaseHealthMetrics( - @Context private val entityManager: Provider, - @Context dbConfig: DatabaseConfig -): Metric(name = "db") { + @Context private val entityManager: Provider, + @Context dbConfig: DatabaseConfig, + @Context private val asyncService: AsyncCoroutineService, +) : Metric(name = "db") { private val cachedStatus = CachedValue( CacheConfig( - refreshDuration = Duration.ofSeconds(dbConfig.healthCheckValiditySeconds), - retryDuration = Duration.ofSeconds(dbConfig.healthCheckValiditySeconds), + refreshDuration = dbConfig.healthCheckValiditySeconds.seconds, + retryDuration = dbConfig.healthCheckValiditySeconds.seconds, ), ::testConnection, ) - override val status: HealthService.Status - get() = cachedStatus.get { it == HealthService.Status.UP } + override suspend fun computeStatus(): HealthService.Status = + cachedStatus.get { it == HealthService.Status.UP }.value - override val metrics: Any - get() = mapOf("status" to status) + override suspend fun computeMetrics(): Map = mapOf("status" to computeStatus()) - private fun testConnection(): HealthService.Status = try { - entityManager.get().useConnection { connection -> connection.close() } - HealthService.Status.UP - } catch (ex: Throwable) { - HealthService.Status.DOWN + private suspend fun testConnection(): HealthService.Status = withContext(Dispatchers.IO) { + try { + asyncService.runInRequestScope { + entityManager.get().useConnection { it.close() } + } + HealthService.Status.UP + } catch (ex: Throwable) { + HealthService.Status.DOWN + } } } diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseInitialization.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseInitialization.kt index 8f09901..f9ddf7a 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseInitialization.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseInitialization.kt @@ -4,11 +4,11 @@ import jakarta.persistence.EntityManager import jakarta.persistence.EntityManagerFactory import jakarta.ws.rs.core.Context import jakarta.ws.rs.ext.Provider -import liquibase.Contexts -import liquibase.Liquibase +import liquibase.command.CommandScope +import liquibase.command.core.UpdateCommandStep +import liquibase.command.core.helpers.DbUrlConnectionCommandStep.DATABASE_ARG import liquibase.database.DatabaseFactory import liquibase.database.jvm.JdbcConnection -import liquibase.resource.ClassLoaderResourceAccessor import org.glassfish.jersey.server.monitoring.ApplicationEvent import org.glassfish.jersey.server.monitoring.ApplicationEventListener import org.glassfish.jersey.server.monitoring.RequestEvent @@ -46,11 +46,14 @@ class DatabaseInitialization( val database = DatabaseFactory.getInstance() .findCorrectDatabaseImplementation(JdbcConnection(connection)) - Liquibase( - dbConfig.liquibase.changelogs, - ClassLoaderResourceAccessor(), - database, - ).use { it.update(Contexts(dbConfig.liquibase.contexts)) } + CommandScope(UpdateCommandStep.COMMAND_NAME[0]).run { + addArgumentValue(UpdateCommandStep.CHANGELOG_FILE_ARG, dbConfig.liquibase.changelogs) + dbConfig.liquibase.contexts.forEach { context -> + addArgumentValue(UpdateCommandStep.CONTEXTS_ARG, context) + } + addArgumentValue(DATABASE_ARG, database) + execute() + } } override fun onRequest(requestEvent: RequestEvent?): RequestEventListener? = null diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/HibernateRepository.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/HibernateRepository.kt index 250c480..a1c29f5 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/HibernateRepository.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/HibernateRepository.kt @@ -3,65 +3,103 @@ package org.radarbase.jersey.hibernate import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.persistence.EntityTransaction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.hibernate.Session import org.radarbase.jersey.exception.HttpInternalServerException import org.radarbase.jersey.hibernate.config.CloseableTransaction +import org.radarbase.jersey.service.AsyncCoroutineService import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException open class HibernateRepository( - private val entityManagerProvider: Provider + private val entityManagerProvider: Provider, + private val asyncService: AsyncCoroutineService, ) { @Suppress("MemberVisibilityCanBePrivate") protected val entityManager: EntityManager get() = entityManagerProvider.get() - @Suppress("unused") - fun transact(transactionOperation: EntityManager.() -> T) = entityManager.transact(transactionOperation) - @Suppress("unused") - fun createTransaction(transactionOperation: EntityManager.(CloseableTransaction) -> T) = entityManager.createTransaction(transactionOperation) - /** * Run a transaction and commit it. If an exception occurs, the transaction is rolled back. */ - open fun EntityManager.transact(transactionOperation: EntityManager.() -> T) = createTransaction { - it.use { transactionOperation() } + suspend fun transact( + transactionOperation: EntityManager.() -> T, + ): T = withContext(Dispatchers.IO) { + createTransaction( + block = { transaction -> + try { + val result = transactionOperation() + transaction.commit() + return@createTransaction result + } catch (ex: Throwable) { + logger.warn("Rolling back failed operation: {}", ex.toString()) + transaction.abort() + throw ex + } + }, + invokeOnCancellation = { transaction, _ -> + transaction?.cancel() + }, + ) } /** * Start a transaction without committing it. If an exception occurs, the transaction is rolled back. */ - open fun EntityManager.createTransaction(transactionOperation: EntityManager.(CloseableTransaction) -> T): T { - val currentTransaction = transaction - ?: throw HttpInternalServerException("transaction_not_found", "Cannot find a transaction from EntityManager") - - currentTransaction.begin() + suspend fun createTransaction( + block: EntityManager.(CloseableTransaction) -> T, + invokeOnCancellation: ((CloseableTransaction?, cause: Throwable?) -> Unit)? = null, + ): T = asyncService.suspendInRequestScope { continuation -> + val storedTransaction = AtomicReference(null) + if (invokeOnCancellation != null) { + continuation.invokeOnCancellation { invokeOnCancellation(storedTransaction.get(), it) } + } + val em = entityManager + val session = em.unwrap(Session::class.java) + ?: throw HttpInternalServerException("session_not_found", "Cannot find a session from EntityManager") + val suspendTransaction = SuspendableCloseableTransaction(session) + storedTransaction.set(suspendTransaction) try { - return transactionOperation(object : CloseableTransaction { - override val transaction: EntityTransaction = currentTransaction - - override fun close() { - try { - transaction.commit() - } catch (ex: Exception) { - logger.error("Rolling back operation", ex) - if (currentTransaction.isActive) { - currentTransaction.rollback() - } - throw ex - } - } - }) + suspendTransaction.begin() + continuation.resume(em.block(suspendTransaction)) } catch (ex: Exception) { logger.error("Rolling back operation", ex) - if (currentTransaction.isActive) { - currentTransaction.rollback() - } - throw ex + suspendTransaction.abort() + continuation.resumeWithException(ex) + } finally { + storedTransaction.set(null) } } - companion object { private val logger = LoggerFactory.getLogger(HibernateRepository::class.java) + private class SuspendableCloseableTransaction( + private val session: Session, + ) : CloseableTransaction { + + override val transaction: EntityTransaction = session.transaction + ?: throw HttpInternalServerException("transaction_not_found", "Cannot find a transaction from EntityManager") + + fun begin() { + transaction.begin() + } + + override fun abort() { + if (transaction.isActive) { + transaction.rollback() + } + } + + override fun commit() { + transaction.commit() + } + + override fun cancel() { + session.cancelQuery() + } + } } } - diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactory.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactory.kt index 717d0df..a04e7f4 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactory.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactory.kt @@ -7,7 +7,7 @@ import org.glassfish.jersey.internal.inject.DisposableSupplier import org.slf4j.LoggerFactory class RadarEntityManagerFactory( - @Context private val emf: EntityManagerFactory + @Context private val emf: EntityManagerFactory, ) : DisposableSupplier { override fun get(): EntityManager { diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactoryFactory.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactoryFactory.kt index a4fb71e..0e27a66 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactoryFactory.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactoryFactory.kt @@ -8,14 +8,14 @@ import org.hibernate.jpa.HibernatePersistenceProvider import org.radarbase.jersey.hibernate.config.DatabaseConfig import org.radarbase.jersey.hibernate.config.RadarPersistenceInfo import org.slf4j.LoggerFactory -import java.util.* +import java.util.Properties /** * Creates EntityManagerFactory using Hibernate. When an [EntityManagerFactory] is created, * Liquibase is used to initialize the database, if so configured. */ class RadarEntityManagerFactoryFactory( - @Context config: DatabaseConfig + @Context config: DatabaseConfig, ) : DisposableSupplier { private val persistenceInfo = RadarPersistenceInfo(config) private val persistenceProvider = HibernatePersistenceProvider() @@ -49,4 +49,3 @@ class RadarEntityManagerFactoryFactory( } } } - diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/CloseableTransaction.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/CloseableTransaction.kt index d4f37e2..bb68baa 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/CloseableTransaction.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/CloseableTransaction.kt @@ -1,9 +1,10 @@ package org.radarbase.jersey.hibernate.config import jakarta.persistence.EntityTransaction -import java.io.Closeable -interface CloseableTransaction : Closeable { +interface CloseableTransaction { val transaction: EntityTransaction - override fun close() + fun commit() + fun abort() + fun cancel() } diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/DatabaseConfig.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/DatabaseConfig.kt index f3ddd28..f3654f3 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/DatabaseConfig.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/DatabaseConfig.kt @@ -2,7 +2,6 @@ package org.radarbase.jersey.hibernate.config import org.radarbase.jersey.config.ConfigLoader.copyEnv - data class DatabaseConfig( /** Classes that can be used in Hibernate queries. */ val managedClasses: List = emptyList(), @@ -19,7 +18,7 @@ data class DatabaseConfig( val properties: Map = emptyMap(), /** Liquibase initialization configuration. */ val liquibase: LiquibaseConfig = LiquibaseConfig(), - val healthCheckValiditySeconds: Long = 60 + val healthCheckValiditySeconds: Long = 60, ) { fun withEnv(): DatabaseConfig = this .copyEnv("DATABASE_URL") { copy(url = it) } diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/HibernateResourceEnhancer.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/HibernateResourceEnhancer.kt index 6cf1968..8f4f4fb 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/HibernateResourceEnhancer.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/HibernateResourceEnhancer.kt @@ -13,25 +13,25 @@ import org.radarbase.jersey.hibernate.RadarEntityManagerFactoryFactory import org.radarbase.jersey.service.HealthService class HibernateResourceEnhancer( - private val databaseConfig: DatabaseConfig + private val databaseConfig: DatabaseConfig, ) : JerseyResourceEnhancer { override val classes: Array> = arrayOf(DatabaseInitialization::class.java) override fun AbstractBinder.enhance() { bind(databaseConfig.withEnv()) - .to(DatabaseConfig::class.java) + .to(DatabaseConfig::class.java) bind(DatabaseHealthMetrics::class.java) - .named("db") - .to(HealthService.Metric::class.java) - .`in`(Singleton::class.java) + .named("db") + .to(HealthService.Metric::class.java) + .`in`(Singleton::class.java) bindFactory(RadarEntityManagerFactoryFactory::class.java) - .to(EntityManagerFactory::class.java) - .`in`(Singleton::class.java) + .to(EntityManagerFactory::class.java) + .`in`(Singleton::class.java) bindFactory(RadarEntityManagerFactory::class.java) - .to(EntityManager::class.java) - .`in`(RequestScoped::class.java) + .to(EntityManager::class.java) + .`in`(RequestScoped::class.java) } } diff --git a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/RadarPersistenceInfo.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/RadarPersistenceInfo.kt index 1ea8ee3..62d1617 100644 --- a/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/RadarPersistenceInfo.kt +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/RadarPersistenceInfo.kt @@ -7,35 +7,31 @@ import jakarta.persistence.spi.PersistenceUnitInfo import jakarta.persistence.spi.PersistenceUnitTransactionType import org.hibernate.jpa.HibernatePersistenceProvider import java.net.URL -import java.util.* +import java.util.Objects +import java.util.Properties import javax.sql.DataSource class RadarPersistenceInfo( - config: DatabaseConfig -): PersistenceUnitInfo { + config: DatabaseConfig, +) : PersistenceUnitInfo { @Suppress("UNCHECKED_CAST") private val properties: Properties = Properties().apply { put("jakarta.persistence.schema-generation.database.action", "none") put("org.hibernate.flushMode", "COMMIT") - put("hibernate.connection.provider_class", "org.hibernate.connection.C3P0ConnectionProvider") - put("hibernate.c3p0.max_size", "50") - put("hibernate.c3p0.min_size", "0") - put("hibernate.c3p0.acquire_increment", "1") - put("hibernate.c3p0.idle_test_period", "300") - put("hibernate.c3p0.max_statements", "0") - put("hibernate.c3p0.timeout", "100") - put("hibernate.c3p0.checkoutTimeout", "5000") - put("hibernate.c3p0.acquireRetryAttempts", "3") - put("hibernate.c3p0.breakAfterAcquireFailure", "false") - - putAll((mapOf( - "jakarta.persistence.jdbc.driver" to config.driver, - "jakarta.persistence.jdbc.url" to config.url, - "jakarta.persistence.jdbc.user" to config.user, - "jakarta.persistence.jdbc.password" to config.password, - "hibernate.dialect" to config.dialect) - + config.properties) - .filterValues { it != null } as Map) + put("hibernate.connection.provider_class", "org.hibernate.hikaricp.internal.HikariCPConnectionProvider") + put("hibernate.hikari.connectionTimeout", "1000") + + sequenceOf( + "jakarta.persistence.jdbc.driver" to config.driver, + "jakarta.persistence.jdbc.url" to config.url, + "jakarta.persistence.jdbc.user" to config.user, + "jakarta.persistence.jdbc.password" to config.password, + "hibernate.dialect" to config.dialect, + ) + .filter { (_, v) -> v != null } + .forEach { (k, v) -> put(k, v) } + + putAll(config.properties) } private val managedClasses = config.managedClasses @@ -82,8 +78,8 @@ class RadarPersistenceInfo( other as RadarPersistenceInfo - return properties == other.properties - && managedClasses == other.managedClasses + return properties == other.properties && + managedClasses == other.managedClasses } override fun hashCode(): Int = Objects.hash(properties, managedClasses) diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetricsTest.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetricsTest.kt index 9cb53e1..8337d3d 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetricsTest.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetricsTest.kt @@ -1,10 +1,9 @@ package org.radarbase.jersey.hibernate import okhttp3.OkHttpClient -import okhttp3.Request import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.`is` import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.radarbase.jersey.GrizzlyServer @@ -20,12 +19,13 @@ internal class DatabaseHealthMetricsTest { @Test fun existsTest() { val authConfig = AuthConfig( - jwtResourceName = "res_jerseyTest") + jwtResourceName = "res_jerseyTest", + ) val databaseConfig = DatabaseConfig( - managedClasses = listOf(ProjectDao::class.qualifiedName!!), - driver = "org.h2.Driver", - url = "jdbc:h2:mem:test", - dialect = "org.hibernate.dialect.H2Dialect", + managedClasses = listOf(ProjectDao::class.qualifiedName!!), + driver = "org.h2.Driver", + url = "jdbc:h2:mem:test", + dialect = "org.hibernate.dialect.H2Dialect", ) val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) @@ -36,9 +36,9 @@ internal class DatabaseHealthMetricsTest { try { val client = OkHttpClient() - client.newCall(Request.Builder() - .url("http://localhost:9091/health") - .build()).execute().use { response -> + client.call { + url("http://localhost:9091/health") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat( response.body?.string(), @@ -53,12 +53,13 @@ internal class DatabaseHealthMetricsTest { @Test fun databaseDoesNotExistTest() { val authConfig = AuthConfig( - jwtResourceName = "res_jerseyTest") + jwtResourceName = "res_jerseyTest", + ) val databaseConfig = DatabaseConfig( - managedClasses = listOf(ProjectDao::class.qualifiedName!!), - driver = "org.h2.Driver", - url = "jdbc:h2:tcp://localhost:9999/./test.db", - dialect = "org.hibernate.dialect.H2Dialect", + managedClasses = listOf(ProjectDao::class.qualifiedName!!), + driver = "org.h2.Driver", + url = "jdbc:h2:tcp://localhost:9999/./test.db", + dialect = "org.hibernate.dialect.H2Dialect", ) val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) @@ -66,20 +67,20 @@ internal class DatabaseHealthMetricsTest { assertThrows { GrizzlyServer(URI.create("http://localhost:9091"), resources) } } - @Test fun databaseIsDisabledTest() { val tcp = org.h2.tools.Server.createTcpServer("-tcpPort", "9999", "-baseDir", "build/resources/test", "-ifNotExists") tcp.start() val authConfig = AuthConfig( - jwtResourceName = "res_jerseyTest") + jwtResourceName = "res_jerseyTest", + ) val databaseConfig = DatabaseConfig( - managedClasses = listOf(ProjectDao::class.qualifiedName!!), - driver = "org.h2.Driver", - url = "jdbc:h2:tcp://localhost:9999/./test.db", - dialect = "org.hibernate.dialect.H2Dialect", - healthCheckValiditySeconds = 1L, + managedClasses = listOf(ProjectDao::class.qualifiedName!!), + driver = "org.h2.Driver", + url = "jdbc:h2:tcp://localhost:9999/./test.db", + dialect = "org.hibernate.dialect.H2Dialect", + healthCheckValiditySeconds = 1L, ) val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) @@ -89,13 +90,12 @@ internal class DatabaseHealthMetricsTest { try { val client = OkHttpClient.Builder() - .readTimeout(30, TimeUnit.SECONDS) - .build() - + .readTimeout(30, TimeUnit.SECONDS) + .build() - client.newCall(Request.Builder() - .url("http://localhost:9091/health") - .build()).execute().use { response -> + client.call { + url("http://localhost:9091/health") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("{\"status\":\"UP\",\"db\":{\"status\":\"UP\"}}")) } @@ -104,9 +104,9 @@ internal class DatabaseHealthMetricsTest { tcp.stop() Thread.sleep(1_000L) - client.newCall(Request.Builder() - .url("http://localhost:9091/health") - .build()).execute().use { response -> + client.call { + url("http://localhost:9091/health") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("{\"status\":\"DOWN\",\"db\":{\"status\":\"DOWN\"}}")) } diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/HibernateTest.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/HibernateTest.kt index a44036f..8b22a89 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/HibernateTest.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/HibernateTest.kt @@ -1,23 +1,40 @@ package org.radarbase.jersey.hibernate -import okhttp3.MediaType.Companion.toMediaTypeOrNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.RequestBody -import okio.BufferedSink +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.`is` import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.assertThrows import org.radarbase.jersey.GrizzlyServer import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.config.ConfigLoader import org.radarbase.jersey.hibernate.config.DatabaseConfig import org.radarbase.jersey.hibernate.db.ProjectDao import org.radarbase.jersey.hibernate.mock.MockResourceEnhancerFactory +import org.radarbase.kotlin.coroutines.forkJoin +import java.io.IOException +import java.io.InterruptedIOException import java.net.URI +import java.time.Duration +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.time.Duration.Companion.nanoseconds internal class HibernateTest { private lateinit var client: OkHttpClient @@ -26,12 +43,13 @@ internal class HibernateTest { @BeforeEach fun setUp() { val authConfig = AuthConfig( - jwtResourceName = "res_jerseyTest") + jwtResourceName = "res_jerseyTest", + ) val databaseConfig = DatabaseConfig( - managedClasses = listOf(ProjectDao::class.qualifiedName!!), - driver = "org.h2.Driver", - url = "jdbc:h2:mem:test", - dialect = "org.hibernate.dialect.H2Dialect", + managedClasses = listOf(ProjectDao::class.qualifiedName!!), + driver = "org.h2.Driver", + url = "jdbc:h2:mem:test", + dialect = "org.hibernate.dialect.H2Dialect", ) val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) @@ -47,85 +65,210 @@ internal class HibernateTest { server.shutdown() } - @Test fun testBasicGet() { - client.newCall(Request.Builder() - .url("http://localhost:9091/projects") - .build()).execute().use { response -> + client.call { + url("http://localhost:9091/projects") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("[]")) } } - @Test fun testMissingProject() { - client.newCall(Request.Builder() - .url("http://localhost:9091/projects/1") - .build()).execute().use { response -> + client.call { + url("http://localhost:9091/projects/1") + }.use { response -> assertThat(response.isSuccessful, `is`(false)) assertThat(response.code, `is`(404)) } } + @Test + fun testCancellation() { + client = OkHttpClient.Builder() + .callTimeout(Duration.ofMillis(250)) + .build() + assertThrows { + client.call { + post("test".toRequestBody(JSON_TYPE)) + url("http://localhost:9091/projects/query") + } + } + } @Test - fun testExistingGet() { - client.newCall(Request.Builder() - .post(object : RequestBody() { - override fun contentType() = "application/json".toMediaTypeOrNull() + fun testTime(): Unit = runBlocking { + client = OkHttpClient.Builder() + .connectionPool(ConnectionPool(64, 30, TimeUnit.MINUTES)) + .dispatcher( + Dispatcher().apply { + maxRequestsPerHost = 64 + }, + ) + .build() - override fun writeTo(sink: BufferedSink) { - sink.writeUtf8("""{"name": "a","organization":"main"}""") - } - }) - .url("http://localhost:9091/projects") - .build()).execute().use { response -> + val size = 20 + + time("blocking 1") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty") + } + } + + time("async 1") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-suspend") + } + } + + time("blocking coroutine") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-blocking") + } + } + + time("blocking 2") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty") + } + } + + time("async 2") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-suspend") + } + } + } + + suspend fun time(label: String, block: suspend () -> Unit) { + val startTime = System.nanoTime() + block() + val diff = (System.nanoTime() - startTime).nanoseconds + println("$label: $diff") + } + + @Test + @Timeout(2) + fun testOverload(): Unit = runBlocking { + client = OkHttpClient.Builder() + .connectionPool(ConnectionPool(64, 30, TimeUnit.MINUTES)) + .dispatcher( + Dispatcher().apply { + maxRequestsPerHost = 64 + }, + ) + .build() + (0 until 64) + .forkJoin { i -> + suspendCancellableCoroutine { continuation -> + val call = client.newCall( + Request.Builder().run { + post("test".toRequestBody(JSON_TYPE)) + url("http://localhost:9091/projects/query") + build() + }, + ) + + continuation.invokeOnCancellation { call.cancel() } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + }) + } + println("$i finished") + } + } + + @Test + fun testExistingGet() { + client.call { + post("""{"name": "a","organization":"main"}""".toRequestBody(JSON_TYPE)) + url("http://localhost:9091/projects") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("""{"id":1000,"name":"a","organization":"main"}""")) } - client.newCall(Request.Builder() + client.newCall( + Request.Builder() .url("http://localhost:9091/projects/1000") - .build()).execute().use { response -> + .build(), + ).execute().use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("""{"id":1000,"name":"a","organization":"main"}""")) } - - client.newCall(Request.Builder() - .url("http://localhost:9091/projects") - .build()).execute().use { response -> + client.call { + url("http://localhost:9091/projects") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("""[{"id":1000,"name":"a","organization":"main"}]""")) } - client.newCall(Request.Builder() - .post(object : RequestBody() { - override fun contentType() = "application/json".toMediaTypeOrNull() - - override fun writeTo(sink: BufferedSink) { - sink.writeUtf8("""{"name": "a","description":"d","organization":"main"}""") - } - }) - .url("http://localhost:9091/projects/1000") - .build()).execute().use { response -> + client.call { + post("""{"name": "a","description":"d","organization":"main"}""".toRequestBody(JSON_TYPE)) + url("http://localhost:9091/projects/1000") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("""{"id":1000,"name":"a","description":"d","organization":"main"}""")) } - client.newCall(Request.Builder() - .delete() - .url("http://localhost:9091/projects/1000") - .build()).execute().use { response -> + client.call { + delete() + url("http://localhost:9091/projects/1000") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) } - client.newCall(Request.Builder() - .url("http://localhost:9091/projects") - .build()).execute().use { response -> + client.call { + url("http://localhost:9091/projects") + }.use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("[]")) } } + + companion object { + val JSON_TYPE = "application/json".toMediaType() + } } + +internal suspend fun OkHttpClient.makeCalls(size: Int, requestBuilder: Request.Builder.() -> Unit) { + val request = Request.Builder().run { + requestBuilder() + build() + } + (0 until size) + .forkJoin(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + val call = newCall(request) + + continuation.invokeOnCancellation { call.cancel() } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + response.close() + continuation.resume(response) + } + }) + } + } +} + +internal inline fun OkHttpClient.call(builder: Request.Builder.() -> Unit): Response = newCall( + Request.Builder().run { + builder() + build() + }, +).execute() diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectDao.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectDao.kt index 1f37faf..237437d 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectDao.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectDao.kt @@ -1,6 +1,12 @@ package org.radarbase.jersey.hibernate.db -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.SequenceGenerator +import jakarta.persistence.Table @Entity(name = "Project") @Table(name = "project") diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepository.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepository.kt index 0e77a19..28f3913 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepository.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepository.kt @@ -1,9 +1,9 @@ package org.radarbase.jersey.hibernate.db interface ProjectRepository { - fun list(): List - fun create(name: String, description: String?, organization: String): ProjectDao - fun update(id: Long, description: String?, organization: String): ProjectDao? - fun delete(id: Long) - fun get(id: Long): ProjectDao? + suspend fun list(): List + suspend fun create(name: String, description: String?, organization: String): ProjectDao + suspend fun update(id: Long, description: String?, organization: String): ProjectDao? + suspend fun delete(id: Long) + suspend fun get(id: Long): ProjectDao? } diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepositoryImpl.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepositoryImpl.kt index 263750c..e038097 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepositoryImpl.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepositoryImpl.kt @@ -4,16 +4,18 @@ import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.ws.rs.core.Context import org.radarbase.jersey.hibernate.HibernateRepository +import org.radarbase.jersey.service.AsyncCoroutineService class ProjectRepositoryImpl( - @Context em: Provider -): ProjectRepository, HibernateRepository(em) { - override fun list(): List = transact { + @Context em: Provider, + @Context asyncCoroutineService: AsyncCoroutineService, +) : ProjectRepository, HibernateRepository(em, asyncCoroutineService) { + override suspend fun list(): List = transact { createQuery("SELECT p FROM Project p", ProjectDao::class.java) - .resultList + .resultList } - override fun create(name: String, description: String?, organization: String): ProjectDao = transact { + override suspend fun create(name: String, description: String?, organization: String): ProjectDao = transact { ProjectDao().apply { this.name = name this.description = description @@ -22,30 +24,30 @@ class ProjectRepositoryImpl( } } - override fun update(id: Long, description: String?, organization: String): ProjectDao? = transact { + override suspend fun update(id: Long, description: String?, organization: String): ProjectDao? = transact { createQuery("SELECT p FROM Project p WHERE p.id = :id", ProjectDao::class.java) - .apply { setParameter("id", id) } - .resultList - .firstOrNull() - ?.apply { - this.description = description - this.organization = organization - merge(this) - } + .apply { setParameter("id", id) } + .resultList + .firstOrNull() + ?.apply { + this.description = description + this.organization = organization + merge(this) + } } - override fun get(id: Long): ProjectDao? = transact { + override suspend fun get(id: Long): ProjectDao? = transact { createQuery("SELECT p FROM Project p WHERE p.id = :id", ProjectDao::class.java) - .apply { setParameter("id", id) } - .resultList - .firstOrNull() + .apply { setParameter("id", id) } + .resultList + .firstOrNull() } - override fun delete(id: Long): Unit = transact { + override suspend fun delete(id: Long): Unit = transact { createQuery("SELECT p FROM Project p WHERE p.id = :id", ProjectDao::class.java) - .apply { setParameter("id", id) } - .resultList - .firstOrNull() - ?.apply { remove(merge(this)) } + .apply { setParameter("id", id) } + .resultList + .firstOrNull() + ?.apply { remove(merge(this)) } } } diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockProjectService.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockProjectService.kt index 1300e1f..8dde742 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockProjectService.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockProjectService.kt @@ -15,30 +15,30 @@ import org.radarbase.jersey.hibernate.db.ProjectRepository import org.radarbase.jersey.service.ProjectService class MockProjectService( - @Context private val projects: ProjectRepository + @Context private val projects: ProjectRepository, ) : ProjectService { - override fun ensureOrganization(organizationId: String) { + override suspend fun ensureOrganization(organizationId: String) { if (projects.list().none { it.organization == organizationId }) { throw HttpNotFoundException("organization_not_found", "Organization $organizationId not found.") } } - override fun listProjects(organizationId: String): List = projects.list() + override suspend fun listProjects(organizationId: String): List = projects.list() .filter { it.organization == organizationId } .map { it.name } - override fun projectOrganization(projectId: String): String = projects.list() + override suspend fun projectOrganization(projectId: String): String = projects.list() .firstOrNull { it.name == projectId } ?.organization ?: throw HttpNotFoundException("project_not_found", "Project $projectId not found.") - override fun ensureProject(projectId: String) { + override suspend fun ensureProject(projectId: String) { if (projects.list().none { it.name == projectId }) { throw HttpNotFoundException("project_not_found", "Project $projectId not found.") } } - override fun ensureSubject(projectId: String, userId: String) { + override suspend fun ensureSubject(projectId: String, userId: String) { ensureProject(projectId) } } diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancer.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancer.kt index 1b965fc..50bf083 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancer.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancer.kt @@ -10,18 +10,20 @@ import org.radarbase.jersey.service.ProjectService class MockResourceEnhancer : JerseyResourceEnhancer { override val classes: Array> = arrayOf( - Filters.logResponse) + Filters.logResponse, + ) override val packages: Array = arrayOf( - "org.radarbase.jersey.hibernate.mock.resource") + "org.radarbase.jersey.hibernate.mock.resource", + ) override fun AbstractBinder.enhance() { bind(ProjectRepositoryImpl::class.java) - .to(ProjectRepository::class.java) - .`in`(Singleton::class.java) + .to(ProjectRepository::class.java) + .`in`(Singleton::class.java) bind(MockProjectService::class.java) - .to(ProjectService::class.java) - .`in`(Singleton::class.java) + .to(ProjectService::class.java) + .`in`(Singleton::class.java) } } diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/resource/ProjectResource.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/resource/ProjectResource.kt index 20b0be9..3acd1f0 100644 --- a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/resource/ProjectResource.kt +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/resource/ProjectResource.kt @@ -1,34 +1,85 @@ package org.radarbase.jersey.hibernate.mock.resource -import jakarta.ws.rs.* +import jakarta.ws.rs.Consumes +import jakarta.ws.rs.DELETE +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended import jakarta.ws.rs.core.Context +import kotlinx.coroutines.delay import org.radarbase.jersey.exception.HttpNotFoundException -import org.radarbase.jersey.hibernate.db.ProjectDao import org.radarbase.jersey.hibernate.db.ProjectRepository +import org.radarbase.jersey.service.AsyncCoroutineService +import kotlin.time.Duration.Companion.seconds @Path("projects") @Consumes("application/json") @Produces("application/json") class ProjectResource( - @Context private val projects: ProjectRepository + @Context private val projects: ProjectRepository, + @Context private val asyncService: AsyncCoroutineService, ) { + @POST + @Path("query") + fun query(@Suspended asyncResponse: AsyncResponse) = asyncService.runAsCoroutine(asyncResponse) { + delay(1.seconds) + "{\"result\": 1}" + } + + @GET + fun projects(@Suspended asyncResponse: AsyncResponse) = asyncService.runAsCoroutine(asyncResponse) { + projects.list() + } + + @GET + @Path("empty") + fun empty() = listOf() + + @GET + @Path("empty-suspend") + fun emptySuspend(@Suspended asyncResponse: AsyncResponse) = asyncService.runAsCoroutine(asyncResponse) { listOf() } + @GET - fun projects(): List = projects.list() + @Path("empty-blocking") + fun emptyBlocking() = asyncService.runBlocking { listOf() } @GET @Path("{id}") - fun project(@PathParam("id") id: Long) = projects.get(id) + fun project( + @PathParam("id") id: Long, + @Suspended asyncResponse: AsyncResponse, + ) = asyncService.runAsCoroutine(asyncResponse) { + projects.get(id) ?: throw HttpNotFoundException("project_not_found", "Project with ID $id does not exist") + } @POST @Path("{id}") - fun updateProject(@PathParam("id") id: Long, values: Map) = projects.update(id, values["description"], values.getValue("organization")) + fun updateProject( + @PathParam("id") id: Long, + values: Map, + @Suspended asyncResponse: AsyncResponse, + ) = asyncService.runAsCoroutine(asyncResponse) { + projects.update(id, values["description"], values.getValue("organization")) ?: throw HttpNotFoundException("project_not_found", "Project with ID $id does not exist") + } @POST - fun createProject(values: Map) = projects.create(values.getValue("name"), values["description"], values.getValue("organization")) + fun createProject( + values: Map, + @Suspended asyncResponse: AsyncResponse, + ) = asyncService.runAsCoroutine(asyncResponse) { + projects.create(values.getValue("name"), values["description"], values.getValue("organization")) + } @DELETE @Path("{id}") - fun deleteProject(@PathParam("id") id: Long) = projects.delete(id) + fun deleteProject( + @PathParam("id") id: Long, + @Suspended asyncResponse: AsyncResponse, + ) = asyncService.runAsCoroutine(asyncResponse) { projects.delete(id) } } diff --git a/radar-jersey/build.gradle.kts b/radar-jersey/build.gradle.kts index db5747e..85cbbc2 100644 --- a/radar-jersey/build.gradle.kts +++ b/radar-jersey/build.gradle.kts @@ -5,75 +5,54 @@ plugins { description = "Library for Jersey authorization, exception handling and configuration with the RADAR platform" dependencies { - val kotlinVersion: String by project - implementation(kotlin("reflect", version=kotlinVersion)) - api(kotlin("stdlib-jdk8", version=kotlinVersion)) - - val managementPortalVersion: String by project - api("org.radarbase:radar-auth:$managementPortalVersion") - api("org.radarbase:managementportal-client:$managementPortalVersion") - - val javaJwtVersion: String by project - implementation("com.auth0:java-jwt:$javaJwtVersion") - - val jakartaWsRsVersion: String by project - api("jakarta.ws.rs:jakarta.ws.rs-api:$jakartaWsRsVersion") - val jakartaAnnotationVersion: String by project - api("jakarta.annotation:jakarta.annotation-api:$jakartaAnnotationVersion") - val hk2Version: String by project - api("org.glassfish.hk2:hk2:$hk2Version") - - val jerseyVersion: String by project - api("org.glassfish.jersey.inject:jersey-hk2:$jerseyVersion") - api("org.glassfish.jersey.core:jersey-server:$jerseyVersion") - implementation("org.glassfish.jersey.media:jersey-media-json-jackson:$jerseyVersion") - - val jacksonVersion: String by project - api(platform("com.fasterxml.jackson:jackson-bom:$jacksonVersion")) + implementation(kotlin("reflect", version = Versions.kotlin)) + api(kotlin("stdlib-jdk8", version = Versions.kotlin)) + + api("org.radarbase:radar-auth:${Versions.managementPortal}") + api("org.radarbase:managementportal-client:${Versions.managementPortal}") + + implementation("org.radarbase:radar-commons-kotlin:${Versions.radarCommons}") + + implementation("com.auth0:java-jwt:${Versions.javaJwt}") + + api("jakarta.ws.rs:jakarta.ws.rs-api:${Versions.jakartaWsRs}") + api("jakarta.annotation:jakarta.annotation-api:${Versions.jakartaAnnotation}") + api("org.glassfish.hk2:hk2:${Versions.hk2}") + + api("org.glassfish.jersey.inject:jersey-hk2:${Versions.jersey}") + api("org.glassfish.jersey.core:jersey-server:${Versions.jersey}") + implementation("org.glassfish.jersey.media:jersey-media-json-jackson:${Versions.jersey}") + + api(platform("com.fasterxml.jackson:jackson-bom:${Versions.jackson}")) api("com.fasterxml.jackson.core:jackson-databind") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8") implementation("com.fasterxml.jackson.jakarta.rs:jackson-jakarta-rs-json-provider") - val okhttpVersion: String by project - implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") - - implementation("org.glassfish.jersey.containers:jersey-container-grizzly2-http:$jerseyVersion") + implementation("org.glassfish.jersey.containers:jersey-container-grizzly2-http:${Versions.jersey}") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") // exception template rendering - val mustacheVersion: String by project - implementation("com.github.spullara.mustache.java:compiler:$mustacheVersion") - - val slf4jVersion: String by project - implementation("org.slf4j:slf4j-api:$slf4jVersion") + implementation("com.github.spullara.mustache.java:compiler:${Versions.mustache}") - val swaggerVersion: String by project - implementation("io.swagger.core.v3:swagger-jaxrs2-jakarta:$swaggerVersion") { + implementation("io.swagger.core.v3:swagger-jaxrs2-jakarta:${Versions.swagger}") { exclude(group = "com.fasterxml.jackson.jaxrs", module = "jackson-jaxrs-json-provider") } - val jakartaXmlBindVersion: String by project - val jakartaJaxbCoreVersion: String by project - val jakartaJaxbRuntimeVersion: String by project - val jakartaActivation: String by project - runtimeOnly("jakarta.xml.bind:jakarta.xml.bind-api:$jakartaXmlBindVersion") - runtimeOnly("org.glassfish.jaxb:jaxb-core:$jakartaJaxbCoreVersion") - runtimeOnly("org.glassfish.jaxb:jaxb-runtime:$jakartaJaxbRuntimeVersion") - runtimeOnly("jakarta.activation:jakarta.activation-api:$jakartaActivation") - - val grizzlyVersion: String by project - testRuntimeOnly("org.glassfish.grizzly:grizzly-http-server:$grizzlyVersion") - testRuntimeOnly("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:$jerseyVersion") - - val junitVersion: String by project - testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") - val hamcrestVersion: String by project - testImplementation("org.hamcrest:hamcrest:$hamcrestVersion") - - val mockitoKotlinVersion: String by project - testImplementation("org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion") + runtimeOnly("jakarta.xml.bind:jakarta.xml.bind-api:${Versions.jakartaXmlBind}") + runtimeOnly("org.glassfish.jaxb:jaxb-core:${Versions.jakartaJaxbCore}") + runtimeOnly("org.glassfish.jaxb:jaxb-runtime:${Versions.jakartaJaxbRuntime}") + runtimeOnly("jakarta.activation:jakarta.activation-api:${Versions.jakartaActivation}") + + testImplementation("com.squareup.okhttp3:okhttp:${Versions.okhttp}") + + testRuntimeOnly("org.glassfish.grizzly:grizzly-http-server:${Versions.grizzly}") + testRuntimeOnly("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:${Versions.jersey}") + + testImplementation("org.hamcrest:hamcrest:${Versions.hamcrest}") + + testImplementation("org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlin}") } tasks.processResources { diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt index eea71d7..890de90 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt @@ -57,8 +57,12 @@ class GrizzlyServer( try { server.start() - logger.info(String.format("Jersey app started on %s.\nPress Ctrl+C to exit...", - baseUri)) + logger.info( + String.format( + "Jersey app started on %s.\nPress Ctrl+C to exit...", + baseUri, + ), + ) Thread.currentThread().join() } catch (e: Exception) { logger.error("Error starting server: {}", e.toString()) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Auth.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Auth.kt deleted file mode 100644 index 8407a5c..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Auth.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (c) 2019. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.jersey.auth - -import com.fasterxml.jackson.databind.JsonNode -import org.radarbase.auth.authorization.Permission -import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.filter.AuthenticationFilter -import org.radarbase.jersey.exception.HttpBadRequestException -import org.radarbase.jersey.exception.HttpForbiddenException -import org.radarbase.jersey.exception.HttpUnauthorizedException.Companion.wwwAuthenticateHeader -import org.slf4j.LoggerFactory - -interface Auth { - /** Default project to apply operations to. */ - val defaultProject: String? - - val token: RadarToken - - /** ID of the OAuth client. */ - val clientId: String? - get() = token.clientId - - /** User ID, if set in the authentication. This may be null if a client credentials grant type is used. */ - val userId: String? - get() = token.subject?.takeUnless { it.isEmpty() } - - /** - * Check whether the current authentication has given permissions on a subject in a project. - * - * @throws HttpBadRequestException if a parameter is null - * @throws HttpForbiddenException if the current authentication does not authorize for the permission. - */ - fun checkPermissionOnSubject(permission: Permission, projectId: String?, userId: String?, location: String? = null) { - if ( - !token.hasPermissionOnSubject( - permission, - projectId ?: throw HttpBadRequestException("project_id_missing", "Missing project ID in request"), - userId ?: throw HttpBadRequestException("user_id_missing", "Missing user ID in request"), - ) - ) { - throw forbiddenException( - permission = permission, - location = location, - projectIds = listOf(projectId), - userId = userId, - ) - } - - logAuthorized( - permission = permission, - location = location, - projectIds = listOf(projectId), - userId = userId - ) - } - - /** - * Check whether the current authentication has given permissions on a project. - * - * @throws HttpBadRequestException if a parameter is null - * @throws HttpForbiddenException if the current authentication does not authorize for the permission. - */ - fun checkPermissionOnProject(permission: Permission, projectId: String?, location: String? = null) { - if ( - !token.hasPermissionOnProject( - permission, - projectId ?: throw HttpBadRequestException("project_id_missing", "Missing project ID in request"), - ) - ) { - throw forbiddenException( - permission = permission, - location = location, - projectIds = listOf(projectId), - ) - } - logAuthorized(permission, location, projectIds = listOf(projectId)) - } - - /** - * Check whether the current authentication has given permissions. - * - * @throws HttpBadRequestException if a parameter is null - * @throws HttpForbiddenException if the current authentication does not authorize for the permission. - */ - fun checkPermissionOnSource(permission: Permission, projectId: String?, userId: String?, sourceId: String?, location: String? = null) { - if ( - !token.hasPermissionOnSource( - permission, - projectId ?: throw HttpBadRequestException("project_id_missing", "Missing project ID in request"), - userId ?: throw HttpBadRequestException("user_id_missing", "Missing user ID in request"), - sourceId ?: throw HttpBadRequestException("source_id_missing", "Missing source ID in request"), - ) - ) { - throw forbiddenException( - permission = permission, - location = location, - projectIds = listOf(projectId), - userId = userId, - sourceId = sourceId, - ) - } - logAuthorized( - permission = permission, - location = location, - projectIds = listOf(projectId), - userId = userId, - sourceId = sourceId, - ) - } - - /** - * Get a claim from the token used for this authentication. - */ - fun getClaim(name: String): JsonNode - - /** - * Whether the current authentication is for a user with a role in given project. - */ - fun hasRole(projectId: String, role: String): Boolean - - fun forbiddenException( - permission: Permission, - location: String? = null, - organizationId: String? = null, - projectIds: List? = null, - userId: String? = null, - sourceId: String? = null, - ): HttpForbiddenException { - val message = logPermission( - false, - permission, - location, - organizationId, - projectIds, - userId, - sourceId, - ) - return HttpForbiddenException( - "permission_mismatch", - message, - wwwAuthenticateHeader = wwwAuthenticateHeader( - error = "insufficient_scope", - errorDescription = message, - scope = permission.toString() - ), - ) - } - - fun logAuthorized( - permission: Permission, - location: String? = null, - organizationId: String? = null, - projectIds: List? = null, - userId: String? = null, - sourceId: String? = null, - ) = logPermission(true, permission, location, organizationId, projectIds, userId, sourceId) - - private fun logPermission( - isAuthorized: Boolean, - permission: Permission, - location: String? = null, - organizationId: String? = null, - projectIds: List? = null, - userId: String? = null, - sourceId: String? = null, - ): String { - val message = if (!logger.isInfoEnabled && isAuthorized) { - "" - } else { - buildString(140) { - (location ?: findCallerMethod())?.let { - append(it) - append(" - ") - } - if (token.isClientCredentials) { - append(clientId) - } else { - append('@') - append(this@Auth.userId) - } - - append(" - ") - - append(if (isAuthorized) "GRANTED " else "DENIED ") - append(permission.scope()) - append(' ') - - buildList(4) { - organizationId?.let { add("organization: $it") } - projectIds?.let { add("projects: $it") } - userId?.let { add("subject: $it") } - sourceId?.let { add("source: $it") } - }.joinTo(this, separator = ", ", prefix = "{", postfix = "}") - } - } - logger.info(message) - return message - } - - companion object { - private val stackWalker = StackWalker - .getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) - - private fun findCallerMethod(): String? = stackWalker.walk { stream -> stream - .skip(2) // this method and logPermission - .filter { !it.isAuthMethod } - .findFirst() - .map { "${it.declaringClass.simpleName}.${it.methodName}" } - .orElse(null) - } - - private val logger = LoggerFactory.getLogger(Auth::class.java) - - private val StackWalker.StackFrame.isAuthMethod: Boolean - get() = methodName.isAuthMethodName || declaringClass.isAuthClass - - private val String.isAuthMethodName: Boolean - get() = startsWith("logPermission") - || startsWith("checkPermission") - || startsWith("invoke") - || startsWith("internal") - - private val Class<*>.isAuthClass: Boolean - get() = isInstance(Auth::class.java) - || isAnonymousClass - || isLocalClass - || simpleName == "ReflectionHelper" - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt index bbb6b2e..68a3752 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt @@ -10,62 +10,55 @@ package org.radarbase.jersey.auth import com.fasterxml.jackson.annotation.JsonIgnore -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.radarbase.jersey.config.ConfigLoader.copyEnv import org.radarbase.jersey.config.ConfigLoader.copyOnChange import java.time.Duration data class AuthConfig( - /** ManagementPortal configuration. */ - val managementPortal: MPConfig = MPConfig(), - /** OAuth 2.0 resource name. */ - val jwtResourceName: String, - /** OAuth 2.0 issuer. */ - val jwtIssuer: String? = null, - /** ECDSA public keys used for verifying incoming OAuth 2.0 JWT. */ - val jwtECPublicKeys: List? = null, - /** RSA public keys used for verifying incoming OAuth 2.0 JWT. */ - val jwtRSAPublicKeys: List? = null, - /** p12 keystore file path used for verifying incoming OAuth 2.0 JWT. */ - val jwtKeystorePath: String? = null, - /** Key alias in p12 keystore for verifying incoming OAuth 2.0 JWT. */ - val jwtKeystoreAlias: String? = null, - /** Key password for the key alias in the p12 keystore. */ - val jwtKeystorePassword: String? = null, + /** ManagementPortal configuration. */ + val managementPortal: MPConfig = MPConfig(), + /** OAuth 2.0 resource name. */ + val jwtResourceName: String, + /** OAuth 2.0 issuer. */ + val jwtIssuer: String? = null, + /** ECDSA public keys used for verifying incoming OAuth 2.0 JWT. */ + val jwtECPublicKeys: List? = null, + /** RSA public keys used for verifying incoming OAuth 2.0 JWT. */ + val jwtRSAPublicKeys: List? = null, + /** p12 keystore file path used for verifying incoming OAuth 2.0 JWT. */ + val jwtKeystorePath: String? = null, + /** Key alias in p12 keystore for verifying incoming OAuth 2.0 JWT. */ + val jwtKeystoreAlias: String? = null, + /** Key password for the key alias in the p12 keystore. */ + val jwtKeystorePassword: String? = null, + val jwksUrls: List = emptyList(), ) { fun withEnv(): AuthConfig = this - .copyOnChange(managementPortal, { it.withEnv() }) { copy(managementPortal = it) } - .copyEnv("AUTH_KEYSTORE_PASSWORD") { copy(jwtKeystorePassword = it) } + .copyOnChange(managementPortal, { it.withEnv() }) { copy(managementPortal = it) } + .copyEnv("AUTH_KEYSTORE_PASSWORD") { copy(jwtKeystorePassword = it) } } data class MPConfig( - /** URL for the current service to find the ManagementPortal installation. */ - val url: String? = null, - /** OAuth 2.0 client ID to get data from the ManagementPortal with. */ - val clientId: String? = null, - /** OAuth 2.0 client secret to get data from the ManagementPortal with. */ - val clientSecret: String? = null, - /** Interval after which the list of projects should be refreshed (minutes). */ - val syncProjectsIntervalMin: Long = 5, - /** Interval after which the list of subjects in a project should be refreshed (minutes). */ - val syncParticipantsIntervalMin: Long = 5, + /** URL for the current service to find the ManagementPortal installation. */ + val url: String? = null, + /** OAuth 2.0 client ID to get data from the ManagementPortal with. */ + val clientId: String? = null, + /** OAuth 2.0 client secret to get data from the ManagementPortal with. */ + val clientSecret: String? = null, + /** Interval after which the list of projects should be refreshed (minutes). */ + val syncProjectsIntervalMin: Long = 5, + /** Interval after which the list of subjects in a project should be refreshed (minutes). */ + val syncParticipantsIntervalMin: Long = 5, ) { - @JsonIgnore - val httpUrl: HttpUrl? = url - ?.toHttpUrlOrNull() - ?.newBuilder() - ?.addPathSegment("") - ?.build() - /** Interval after which the list of projects should be refreshed. */ @JsonIgnore val syncProjectsInterval: Duration = Duration.ofMinutes(syncProjectsIntervalMin) + /** Interval after which the list of subjects in a project should be refreshed. */ @JsonIgnore val syncParticipantsInterval: Duration = Duration.ofMinutes(syncParticipantsIntervalMin) fun withEnv(): MPConfig = this - .copyEnv("MANAGEMENT_PORTAL_CLIENT_ID") { copy(clientId = it) } - .copyEnv("MANAGEMENT_PORTAL_CLIENT_SECRET") { copy(clientSecret = it) } + .copyEnv("MANAGEMENT_PORTAL_CLIENT_ID") { copy(clientId = it) } + .copyEnv("MANAGEMENT_PORTAL_CLIENT_SECRET") { copy(clientSecret = it) } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt new file mode 100644 index 0000000..982b4cc --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -0,0 +1,330 @@ +package org.radarbase.jersey.auth + +import jakarta.inject.Provider +import jakarta.ws.rs.core.Context +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.radarbase.auth.authorization.AuthorityReferenceSet +import org.radarbase.auth.authorization.AuthorizationOracle +import org.radarbase.auth.authorization.EntityDetails +import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.token.DataRadarToken +import org.radarbase.auth.token.RadarToken +import org.radarbase.jersey.exception.HttpForbiddenException +import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.exception.HttpUnauthorizedException +import org.radarbase.jersey.service.AsyncCoroutineService +import org.radarbase.jersey.service.ProjectService +import org.slf4j.LoggerFactory + +class AuthService( + @Context private val oracle: AuthorizationOracle, + @Context private val tokenProvider: Provider, + @Context private val projectService: ProjectService, + @Context private val asyncService: AsyncCoroutineService, +) { + suspend fun requestScopedToken(): RadarToken = asyncService.runInRequestScope { + try { + DataRadarToken(tokenProvider.get()) + } catch (ex: Throwable) { + throw HttpForbiddenException("unauthorized", "User without authentication does not have permission.") + } + } + + /** + * Check whether given [token] would have the [permission] scope in any of its roles. This doesn't + * check whether [token] has access to a specific entity or global access. + * @throws HttpForbiddenException if identity does not have scope + */ + fun checkScope(permission: Permission, token: RadarToken, location: String? = null) { + if (!oracle.hasScope(token, permission)) { + throw forbiddenException( + permission = permission, + token = token, + location = location, + ) + } + logAuthorized(permission, token, location) + } + + /** + * Check whether [token] has permission [permission], regarding given entity from [builder]. + * The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @throws HttpForbiddenException if identity does not have permission + */ + fun checkScopeAndPermission( + permission: Permission, + token: RadarToken, + location: String? = null, + builder: EntityDetails.() -> Unit, + ): EntityDetails { + if (!oracle.hasScope(token, permission)) { + throw forbiddenException( + permission = permission, + token = token, + location = location, + ) + } + val entity = EntityDetails().apply(builder) + if (entity.minimumEntityOrNull() == null) { + logAuthorized(permission, token, location) + } else { + checkPermissionBlocking(permission, entity, token, location, permission.entity) + } + return entity + } + + suspend fun hasPermission( + permission: Permission, + entity: EntityDetails, + token: RadarToken? = null, + ) = oracle.hasPermission(token ?: requestScopedToken(), permission, entity) + + /** + * Check whether [token] has permission [permission], regarding given [entity]. + * The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @throws HttpForbiddenException if identity does not have permission + */ + fun checkPermissionBlocking( + permission: Permission, + entity: EntityDetails, + token: RadarToken? = null, + location: String? = null, + scope: Permission.Entity = permission.entity, + ) = asyncService.runBlocking { + checkPermission(permission, entity, token, location, scope) + } + + suspend fun activeParticipantProject(token: RadarToken? = null): String? = (token ?: requestScopedToken()).roles + .firstOrNull { it.role == RoleAuthority.PARTICIPANT } + ?.referent + + /** + * Check whether [token] has permission [permission], regarding given [entity]. + * The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @throws HttpForbiddenException if identity does not have permission + */ + suspend fun checkPermission( + permission: Permission, + entity: EntityDetails, + token: RadarToken? = null, + location: String? = null, + scope: Permission.Entity = permission.entity, + ) { + entity.resolve() + val actualToken = token ?: requestScopedToken() + if ( + !oracle.hasPermission( + actualToken, + permission, + entity, + scope, + ) + ) { + throw forbiddenException( + permission = permission, + token = actualToken, + location = location, + entity, + ) + } + + logAuthorized( + permission = permission, + token = actualToken, + location = location, + entity = entity, + ) + } + + /** + * Run [block] if [token] has permission [permission], regarding given [entity]. + * The permission check is run concurrently with the block. The block is cancelled if the + * permission check fails. If the block costs significant resources, consider running + * [checkPermission] before the block instead. + * The permission is checked both for its + * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. + * @throws HttpForbiddenException if identity does not have permission + */ + suspend inline fun withPermission( + permission: Permission, + entity: EntityDetails, + token: RadarToken? = null, + location: String? = null, + scope: Permission.Entity = permission.entity, + crossinline block: suspend () -> T, + ) = coroutineScope { + val checkPermissionJob = async { + checkPermission(permission, entity, token, location, scope) + } + val resultJob = async { + block() + } + checkPermissionJob.await() + resultJob.await() + } + + private suspend fun EntityDetails.resolve() { + val project = project + val organization = organization + if (project != null) { + val org = projectService.projectOrganization(project) + if (organization == null) { + this.organization = org + } else if (org != organization) { + throw HttpNotFoundException( + "organization_not_found", + "Organization $organization not found for project $project.", + ) + } + val subject = subject + if (subject != null) { + projectService.ensureSubject(project, subject) + } + } else if (organization != null) { + projectService.ensureOrganization(organization) + } + } + + suspend fun forbiddenException( + permission: Permission, + location: String? = null, + entityDetails: EntityDetails? = null, + ) = forbiddenException(permission, requestScopedToken(), location, entityDetails) + + fun forbiddenException( + permission: Permission, + token: RadarToken, + location: String? = null, + entityDetails: EntityDetails? = null, + ): HttpForbiddenException { + val message = logPermission( + false, + permission, + token, + location, + entityDetails, + ) + return HttpForbiddenException( + "permission_mismatch", + message, + wwwAuthenticateHeader = HttpUnauthorizedException.wwwAuthenticateHeader( + error = "insufficient_scope", + errorDescription = message, + scope = permission.toString(), + ), + ) + } + + suspend fun logAuthorized( + permission: Permission, + location: String? = null, + entityDetails: EntityDetails? = null, + ) = logAuthorized(permission, requestScopedToken(), location, entityDetails) + + fun logAuthorized( + permission: Permission, + token: RadarToken, + location: String? = null, + entity: EntityDetails? = null, + ) = logPermission(true, permission, token, location, entity) + + private fun logPermission( + isAuthorized: Boolean, + permission: Permission, + token: RadarToken, + location: String? = null, + entity: EntityDetails? = null, + ): String { + val message = if (!logger.isInfoEnabled && isAuthorized) { + "" + } else { + buildString(140) { + (location ?: findCallerMethod())?.let { + append(it) + append(" - ") + } + if (token.isClientCredentials) { + append(token.clientId) + } else { + append('@') + append(token.username) + } + + append(" - ") + + append(if (isAuthorized) "GRANTED " else "DENIED ") + append(permission.scope()) + + if (entity != null) { + append(' ') + + buildList(6) { + entity.organization?.let { add("org: $it") } + entity.project?.let { add("proj: $it") } + entity.subject?.let { add("subj: $it") } + entity.source?.let { add("src: $it") } + entity.user?.let { add("@$it") } + }.joinTo(this, separator = ", ", prefix = "{", postfix = "}") + } + } + } + logger.info(message) + return message + } + + suspend fun referentsByScope(permission: Permission): AuthorityReferenceSet { + return oracle.referentsByScope(requestScopedToken(), permission) + } + + fun mayBeGranted( + role: RoleAuthority, + permission: Permission, + ): Boolean = with(oracle) { + role.mayBeGranted(permission) + } + + fun mayBeGranted( + roles: Collection, + permission: Permission, + ): Boolean = with(oracle) { + roles.mayBeGranted(permission) + } + + companion object { + private val logger = LoggerFactory.getLogger(AuthService::class.java) + + private val stackWalker = StackWalker + .getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + + private fun findCallerMethod(): String? = stackWalker.walk { stream -> + stream + .skip(2) // this method and logPermission + .filter { !it.isAuthMethod } + .findFirst() + .map { "${it.declaringClass.simpleName}.${it.methodName}" } + .orElse(null) + } + + private val StackWalker.StackFrame.isAuthMethod: Boolean + get() = methodName.isAuthMethodName || declaringClass.isAuthClass + + private val String.isAuthMethodName: Boolean + get() = startsWith("logAuthorized") || + startsWith("logPermission") || + startsWith("checkPermission") || + startsWith("invoke") || + startsWith("internal") + + private val Class<*>.isAuthClass: Boolean + get() = this == AuthService::class.java || + isAnonymousClass || + isLocalClass || + name.contains("AuthService") || + simpleName == "ReflectionHelper" + } +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthValidator.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthValidator.kt index b76c22e..8701188 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthValidator.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthValidator.kt @@ -11,18 +11,20 @@ package org.radarbase.jersey.auth import jakarta.ws.rs.container.ContainerRequestContext import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.filter.AuthenticationFilter interface AuthValidator { @Throws(TokenValidationException::class) - fun verify(token: String, request: ContainerRequestContext): Auth? + fun verify(token: String, request: ContainerRequestContext): RadarToken? fun getToken(request: ContainerRequestContext): String? { val authorizationHeader = request.getHeaderString("Authorization") // Check if the HTTP Authorization header is present and formatted correctly - if (authorizationHeader != null - && authorizationHeader.startsWith(AuthenticationFilter.BEARER, ignoreCase = true)) { + if (authorizationHeader != null && + authorizationHeader.startsWith(AuthenticationFilter.BEARER, ignoreCase = true) + ) { // Extract the token from the HTTP Authorization header return authorizationHeader.substring(AuthenticationFilter.BEARER.length).trim { it <= ' ' } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Authenticated.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Authenticated.kt index 01815cf..655fca1 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Authenticated.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Authenticated.kt @@ -15,7 +15,11 @@ import jakarta.ws.rs.NameBinding * Annotation for requests that should be authenticated. */ @NameBinding -@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, - AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, +) @Retention(AnnotationRetention.RUNTIME) annotation class Authenticated diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/NeedsPermission.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/NeedsPermission.kt index 9b56668..bc39b80 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/NeedsPermission.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/NeedsPermission.kt @@ -14,8 +14,11 @@ import org.radarbase.auth.authorization.Permission /** * Indicates that a method needs an authenticated user that has a certain permission. */ -@Target(AnnotationTarget.FUNCTION, - AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, +) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented annotation class NeedsPermission( diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuth.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuth.kt deleted file mode 100644 index c630b4c..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuth.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.radarbase.jersey.auth.disabled - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.NullNode -import org.radarbase.auth.authorization.Permission -import org.radarbase.auth.authorization.RoleAuthority -import org.radarbase.auth.token.AuthorityReference -import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth -import java.util.* -import kotlin.collections.HashSet - -/** Authorization that grants permission to all resources. */ -class DisabledAuth( - private val resourceName: String -) : Auth { - override val defaultProject: String? = null - override val token: RadarToken = EmptyToken() - - override fun getClaim(name: String): JsonNode = NullNode.instance - - override fun hasRole(projectId: String, role: String): Boolean = true - - inner class EmptyToken : RadarToken { - override fun getRoles(): MutableSet = HashSet() - - override fun getAuthorities(): List = emptyList() - - override fun getScopes(): List = emptyList() - - override fun getSources(): List = emptyList() - - override fun getGrantType(): String = "none" - - override fun getSubject(): String = "anonymous" - - override fun getUsername(): String = "anonymous" - - override fun getIssuedAt(): Date = Date() - - override fun getExpiresAt(): Date = Date(Long.MAX_VALUE) - - override fun getAudience(): List = listOf(resourceName) - - override fun getToken(): String = "" - - override fun getIssuer(): String = "empty" - - override fun getType(): String = "none" - - override fun getClientId(): String = "none" - - override fun getClaimString(name: String?): String? = null - - override fun getClaimList(name: String?): List = emptyList() - - override fun hasAuthority(authority: RoleAuthority?): Boolean = true - override fun hasPermission(permission: Permission?): Boolean = true - override fun hasGlobalPermission(permission: Permission?): Boolean = true - - override fun hasPermissionOnOrganization( - permission: Permission?, - organization: String?, - ) = true - - override fun hasPermissionOnOrganizationAndProject( - permission: Permission, - organization: String?, - projectName: String?, - ): Boolean = true - - override fun hasPermissionOnProject(permission: Permission?, projectName: String?): Boolean = true - - override fun hasPermissionOnSubject(permission: Permission?, projectName: String?, subjectName: String?): Boolean = true - - override fun hasPermissionOnSource(permission: Permission?, projectName: String?, subjectName: String?, sourceId: String?): Boolean = true - - override fun isClientCredentials(): Boolean = false - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthValidator.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthValidator.kt index 3bcd98f..03a084c 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthValidator.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthValidator.kt @@ -2,15 +2,23 @@ package org.radarbase.jersey.auth.disabled import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context -import org.radarbase.jersey.auth.Auth +import org.radarbase.auth.token.DataRadarToken +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.auth.AuthValidator +import java.time.Instant /** Authorization validator that grants permission to all resources. */ class DisabledAuthValidator( - @Context private val config: AuthConfig + @Context private val config: AuthConfig, ) : AuthValidator { override fun getToken(request: ContainerRequestContext): String = "" - override fun verify(token: String, request: ContainerRequestContext): Auth = DisabledAuth( - config.jwtResourceName) + override fun verify(token: String, request: ContainerRequestContext): RadarToken = DataRadarToken( + audience = listOf(config.jwtResourceName), + expiresAt = Instant.MAX, + roles = setOf(), + scopes = setOf(), + grantType = "disabled", + username = "anonymous", + ) } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationOracle.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationOracle.kt new file mode 100644 index 0000000..2279c6f --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationOracle.kt @@ -0,0 +1,26 @@ +package org.radarbase.jersey.auth.disabled + +import org.radarbase.auth.authorization.AuthorityReferenceSet +import org.radarbase.auth.authorization.AuthorizationOracle +import org.radarbase.auth.authorization.EntityDetails +import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.token.RadarToken + +class DisabledAuthorizationOracle : AuthorizationOracle { + override suspend fun hasPermission( + identity: RadarToken, + permission: Permission, + entity: EntityDetails, + entityScope: Permission.Entity, + ): Boolean = true + + override fun hasScope(identity: RadarToken, permission: Permission): Boolean = true + + override fun referentsByScope( + identity: RadarToken, + permission: Permission, + ): AuthorityReferenceSet = AuthorityReferenceSet(global = true) + + override fun RoleAuthority.mayBeGranted(permission: Permission): Boolean = true +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationResourceEnhancer.kt index 52aa330..437a682 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationResourceEnhancer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationResourceEnhancer.kt @@ -11,6 +11,7 @@ package org.radarbase.jersey.auth.disabled import jakarta.inject.Singleton import org.glassfish.jersey.internal.inject.AbstractBinder +import org.radarbase.auth.authorization.AuthorizationOracle import org.radarbase.jersey.auth.AuthValidator import org.radarbase.jersey.enhancer.JerseyResourceEnhancer @@ -23,5 +24,9 @@ class DisabledAuthorizationResourceEnhancer : JerseyResourceEnhancer { bind(DisabledAuthValidator::class.java) .to(AuthValidator::class.java) .`in`(Singleton::class.java) + + bind(DisabledAuthorizationOracle::class.java) + .to(AuthorizationOracle::class.java) + .`in`(Singleton::class.java) } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthenticationFilter.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthenticationFilter.kt index 77daf30..8a98136 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthenticationFilter.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthenticationFilter.kt @@ -35,11 +35,11 @@ class AuthenticationFilter( override fun filter(requestContext: ContainerRequestContext) { val rawToken = validator.getToken(requestContext) - ?: throw HttpUnauthorizedException( - code = "token_missing", - detailedMessage = "No bearer token is provided in the request.", - wwwAuthenticateHeader = wwwAuthenticateHeader(), - ) + ?: throw HttpUnauthorizedException( + code = "token_missing", + detailedMessage = "No bearer token is provided in the request.", + wwwAuthenticateHeader = wwwAuthenticateHeader(), + ) val radarToken = try { validator.verify(rawToken, requestContext) @@ -59,7 +59,6 @@ class AuthenticationFilter( } companion object { - const val BEARER_REALM: String = "Bearer realm=\"RADAR-base\"" const val BEARER = "Bearer " } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthorizationFeature.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthorizationFeature.kt index edd180f..f18d3b4 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthorizationFeature.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/AuthorizationFeature.kt @@ -9,6 +9,7 @@ package org.radarbase.jersey.auth.filter +import jakarta.annotation.Priority import jakarta.inject.Singleton import jakarta.ws.rs.Priorities import jakarta.ws.rs.container.DynamicFeature @@ -19,6 +20,7 @@ import org.radarbase.jersey.auth.NeedsPermission /** Authorization for different auth tags. */ @Provider +@Priority(Priorities.AUTHORIZATION) @Singleton class AuthorizationFeature : DynamicFeature { override fun configure(resourceInfo: ResourceInfo, context: FeatureContext) { diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt index faf921a..9e8a14d 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt @@ -14,110 +14,36 @@ import jakarta.ws.rs.container.ContainerRequestFilter import jakarta.ws.rs.container.ResourceInfo import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.UriInfo -import org.radarbase.auth.authorization.Permission -import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth +import org.radarbase.jersey.auth.AuthService import org.radarbase.jersey.auth.NeedsPermission -import org.radarbase.jersey.exception.HttpNotFoundException -import org.radarbase.jersey.service.ProjectService +import org.radarbase.jersey.auth.filter.RadarSecurityContext.Companion.radarSecurityContext /** * Check that the token has given permissions. */ class PermissionFilter( @Context private val resourceInfo: ResourceInfo, - @Context private val auth: Auth, - @Context private val projectService: ProjectService, @Context private val uriInfo: UriInfo, + @Context private val authService: AuthService, ) : ContainerRequestFilter { override fun filter(requestContext: ContainerRequestContext) { val resourceMethod = resourceInfo.resourceMethod val annotation = resourceMethod.getAnnotation(NeedsPermission::class.java) - val permission = annotation.permission - val location = "${requestContext.method} ${requestContext.uriInfo.path}" - if (!auth.token.hasPermission(permission)) { - throw auth.forbiddenException(permission, location) - } - - val projectId = annotation.projectPathParam.fetchPathParam() - val userId = annotation.userPathParam.fetchPathParam() - val organizationId = annotation.organizationPathParam.fetchPathParam() - - val hierarchy = when { - projectId != null -> hierarchyByProject(organizationId, projectId) - userId != null -> throw HttpNotFoundException( - "user_not_found", - "User $userId not found without project ID" - ) - organizationId != null -> hierarchyByOrganization(organizationId) - else -> null - } - - if ( - hierarchy == null || - auth.token.hasPermissionOnOrganization(permission, hierarchy.organizationId) + authService.checkScopeAndPermission( + permission = annotation.permission, + token = requestContext.radarSecurityContext.token, + location = "${requestContext.method} ${requestContext.uriInfo.path}", ) { - // no more detailed authorization is needed or organization permissions are sufficient - } else if (userId != null) { - checkNotNull(projectId) { "Ensured by above hierarchy check." } - if (!auth.token.hasPermissionOnSubject(permission, projectId, userId)) { - throw auth.forbiddenException( - permission, - location, - hierarchy.organizationId, - hierarchy.projectIds, - userId - ) - } - projectService.ensureSubject(projectId, userId) - } else if (!auth.token.hasPermissionOnProjects(permission, hierarchy)) { - throw auth.forbiddenException( - permission, - location, - hierarchy.organizationId, - hierarchy.projectIds - ) - } - - auth.logAuthorized(permission, location, hierarchy?.organizationId, hierarchy?.projectIds, userId) - } - - private fun hierarchyByProject(organizationId: String?, projectId: String): ProjectHierarchy { - projectService.ensureProject(projectId) - val projectOrganization = projectService.projectOrganization(projectId) - if (organizationId != null && organizationId != projectOrganization) { - throw HttpNotFoundException( - "organization_not_found", - "Organization $organizationId not found for project $projectId." - ) + organization = annotation.organizationPathParam.fetchPathParam() + project = annotation.projectPathParam.fetchPathParam() + subject = annotation.userPathParam.fetchPathParam() } - return ProjectHierarchy(projectOrganization, listOf(projectId)) - } - - private fun hierarchyByOrganization(organizationId: String): ProjectHierarchy { - projectService.ensureOrganization(organizationId) - return ProjectHierarchy(organizationId, projectService.listProjects(organizationId)) } private fun String.fetchPathParam(): String? = if (isNotEmpty()) { uriInfo.pathParameters[this]?.firstOrNull() - } else null - - private data class ProjectHierarchy( - val organizationId: String, - val projectIds: List, - ) - - companion object { - private fun RadarToken.hasPermissionOnProjects( - permission: Permission, - hierarchy: ProjectHierarchy, - ) = hierarchy.projectIds.any { - hasPermissionOnProject( - permission, - it - ) - } + } else { + null } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/RadarSecurityContext.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/RadarSecurityContext.kt index 1c8ea6c..e8c1132 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/RadarSecurityContext.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/RadarSecurityContext.kt @@ -9,19 +9,22 @@ package org.radarbase.jersey.auth.filter +import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.SecurityContext -import org.radarbase.jersey.auth.Auth +import org.radarbase.auth.authorization.AuthorityReference +import org.radarbase.auth.token.RadarToken import java.security.Principal /** * Security context from currently parsed authentication. */ class RadarSecurityContext( - /** Get the parsed authentication. */ - val auth: Auth) : SecurityContext { + /** Get the parsed authentication. */ + val token: RadarToken, +) : SecurityContext { override fun getUserPrincipal(): Principal { - return Principal { auth.userId } + return Principal { token.username } } /** @@ -33,8 +36,22 @@ class RadarSecurityContext( * `false` otherwise */ override fun isUserInRole(role: String): Boolean { - val projectRole = role.split(":") - return projectRole.size == 2 && auth.hasRole(projectRole[0], projectRole[1]) + val roleParts = role + .split(":") + .filter { it.isNotEmpty() } + val authRef = if (roleParts.isEmpty()) { + return true + } else if (roleParts.size == 1) { + AuthorityReference( + authority = roleParts[0], + ) + } else { + AuthorityReference( + authority = roleParts[1], + referent = roleParts[0], + ) + } + return authRef in token.roles } override fun isSecure(): Boolean { @@ -44,4 +61,11 @@ class RadarSecurityContext( override fun getAuthenticationScheme(): String { return "JWT" } + + companion object { + val ContainerRequestContext.radarSecurityContext: RadarSecurityContext + get() = checkNotNull(securityContext as? RadarSecurityContext) { + "RequestContext does not have a RadarSecurityContext" + } + } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthFactory.kt deleted file mode 100644 index 11d2a06..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthFactory.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2019. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.jersey.auth.jwt - -import jakarta.ws.rs.container.ContainerRequestContext -import jakarta.ws.rs.core.Context -import org.radarbase.jersey.auth.Auth -import org.radarbase.jersey.auth.filter.RadarSecurityContext -import java.util.function.Supplier - -/** Generates radar tokens from the security context. */ -class AuthFactory( - @Context private val context: ContainerRequestContext -) : Supplier { - override fun get() = (context.securityContext as? RadarSecurityContext)?.auth - ?: throw IllegalStateException("Created null wrapper") -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthorizationOracleFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthorizationOracleFactory.kt new file mode 100644 index 0000000..35bdf02 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthorizationOracleFactory.kt @@ -0,0 +1,20 @@ +package org.radarbase.jersey.auth.jwt + +import jakarta.ws.rs.core.Context +import org.radarbase.auth.authorization.AuthorizationOracle +import org.radarbase.auth.authorization.EntityRelationService +import org.radarbase.auth.authorization.MPAuthorizationOracle +import org.radarbase.jersey.service.ProjectService +import java.util.function.Supplier + +class AuthorizationOracleFactory( + @Context projectService: ProjectService, +) : Supplier { + private val relationService = object : EntityRelationService { + override suspend fun findOrganizationOfProject(project: String): String { + return projectService.projectOrganization(project) + } + } + + override fun get(): AuthorizationOracle = MPAuthorizationOracle(relationService) +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaJwtTokenValidator.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaJwtTokenValidator.kt index 92b94c2..0621d6e 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaJwtTokenValidator.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaJwtTokenValidator.kt @@ -9,103 +9,34 @@ package org.radarbase.jersey.auth.jwt -import com.auth0.jwt.JWT -import com.auth0.jwt.JWTVerifier -import com.auth0.jwt.algorithms.Algorithm -import com.auth0.jwt.exceptions.AlgorithmMismatchException -import com.auth0.jwt.exceptions.JWTVerificationException -import com.auth0.jwt.exceptions.SignatureVerificationException import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context -import org.radarbase.auth.exception.ConfigurationException -import org.radarbase.jersey.auth.Auth -import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.auth.authentication.TokenValidator +import org.radarbase.auth.authorization.AuthorityReference +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.AuthValidator import org.slf4j.Logger import org.slf4j.LoggerFactory -import java.nio.file.Paths -import java.security.KeyFactory -import java.security.KeyStore -import java.security.PublicKey -import java.security.interfaces.ECPublicKey -import java.security.interfaces.RSAPublicKey -import java.security.spec.X509EncodedKeySpec -import java.util.* -import kotlin.io.path.inputStream -class EcdsaJwtTokenValidator constructor(@Context private val config: AuthConfig) : AuthValidator { - private val verifiers: List - init { - val algorithms = mutableListOf() - - config.jwtECPublicKeys?.let { keys -> - algorithms.addAll(keys.map { Algorithm.ECDSA256(parseKey(it, "EC") as ECPublicKey, null) }) - } - config.jwtRSAPublicKeys?.let { keys -> - algorithms.addAll(keys.map { Algorithm.RSA256(parseKey(it, "RSA") as RSAPublicKey, null) }) - } - - config.jwtKeystorePath?.let { keyStorePathString -> - algorithms.add(try { - val pkcs12Store = KeyStore.getInstance("pkcs12") - val keyStorePath = Paths.get(keyStorePathString) - pkcs12Store.load(keyStorePath.inputStream(), config.jwtKeystorePassword?.toCharArray()) - val publicKey: ECPublicKey = pkcs12Store.getCertificate(config.jwtKeystoreAlias).publicKey as ECPublicKey - Algorithm.ECDSA256(publicKey, null) - } catch (ex: Exception) { - throw IllegalStateException("Failed to initialize JWT ECDSA public key", ex) - }) - } - - if (algorithms.isEmpty()) { - throw ConfigurationException("No verification algorithms given") - } else { - logger.info("Verifying JWTs with ${algorithms.size} algorithms") - } - - verifiers = algorithms.map { algorithm -> - val builder = JWT.require(algorithm) - .withAudience(config.jwtResourceName) - config.jwtIssuer?.let { - builder.withIssuer(it) - } - builder.build() - } - } - - private fun parseKey(publicKey: String, algorithm: String): PublicKey { - var trimmedKey = publicKey.replace(Regex("-----BEGIN ([A-Z]+ )?PUBLIC KEY-----"), "") - trimmedKey = trimmedKey.replace(Regex("-----END ([A-Z]+ )?PUBLIC KEY-----"), "") - trimmedKey = trimmedKey.trim() - logger.info("Using following public key for algorithm $algorithm: \n$trimmedKey") - try { - val keyBytes = Base64.getDecoder().decode(trimmedKey) - val spec = X509EncodedKeySpec(keyBytes) - val kf = KeyFactory.getInstance(algorithm) - return kf.generatePublic(spec) - } catch (ex: Exception) { - throw ConfigurationException(ex) - } - } - - override fun verify(token: String, request: ContainerRequestContext): Auth? { +class EcdsaJwtTokenValidator constructor( + @Context private val tokenValidator: TokenValidator, +) : AuthValidator { + override fun verify(token: String, request: ContainerRequestContext): RadarToken? { val project = request.getHeaderString("RADAR-Project") - for (verifier in verifiers) { - try { - val decodedJwt = verifier.verify(token) - - return JwtAuth(project, decodedJwt) - } catch (ex: SignatureVerificationException) { - // try next verifier - } catch (ex: AlgorithmMismatchException) { - // try next verifier - } catch (ex: JWTVerificationException) { - logger.warn("JWT verification exception", ex) - return null - } + return try { + val radarToken = tokenValidator.validateBlocking(token) + return radarToken.copyWithRoles( + buildSet { + addAll(radarToken.roles) + add(AuthorityReference(RoleAuthority.PARTICIPANT, project)) + }, + ) + } catch (ex: Throwable) { + logger.warn("JWT verification exception", ex) + null } - return null } companion object { diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaResourceEnhancer.kt index 86ba6d7..769ae1a 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaResourceEnhancer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/EcdsaResourceEnhancer.kt @@ -11,6 +11,8 @@ package org.radarbase.jersey.auth.jwt import jakarta.inject.Singleton import org.glassfish.jersey.internal.inject.AbstractBinder +import org.radarbase.auth.authentication.TokenValidator +import org.radarbase.auth.authorization.AuthorizationOracle import org.radarbase.jersey.auth.AuthValidator import org.radarbase.jersey.enhancer.JerseyResourceEnhancer @@ -23,8 +25,16 @@ import org.radarbase.jersey.enhancer.JerseyResourceEnhancer */ class EcdsaResourceEnhancer : JerseyResourceEnhancer { override fun AbstractBinder.enhance() { + bindFactory(TokenValidatorFactory::class.java) + .to(TokenValidator::class.java) + .`in`(Singleton::class.java) + bind(EcdsaJwtTokenValidator::class.java) .to(AuthValidator::class.java) .`in`(Singleton::class.java) + + bindFactory(AuthorizationOracleFactory::class.java) + .to(AuthorizationOracle::class.java) + .`in`(Singleton::class.java) } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt deleted file mode 100644 index 01bd23b..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2019. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.jersey.auth.jwt - -import com.auth0.jwt.interfaces.DecodedJWT -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.NullNode -import org.radarbase.auth.authorization.Permission -import org.radarbase.auth.authorization.Permission.Entity -import org.radarbase.auth.token.JwtRadarToken -import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth - -/** - * Parsed JWT for validating authorization of data contents. - */ -class JwtAuth(project: String?, private val jwt: DecodedJWT) : Auth { - override val token: RadarToken = object : JwtRadarToken(jwt) { - override fun hasPermission(permission: Permission) = scopes.contains(permission.scope()) - - override fun hasPermissionOnProject(permission: Permission, projectId: String): Boolean { - return hasPermission(permission) && (claimProject != null && projectId == claimProject) - } - - override fun hasPermissionOnSubject(permission: Permission, projectId: String, userId: String): Boolean { - return hasPermissionOnProject(permission, projectId) - && (userId == this@JwtAuth.userId || hasPermission(Permission.of(Entity.PROJECT, permission.operation))) - } - - override fun hasPermissionOnSource(permission: Permission, projectId: String, userId: String, sourceId: String): Boolean { - return hasPermissionOnSubject(permission, projectId, userId) - } - } - - private val claimProject = jwt.getClaim("project").asString() - override val defaultProject = claimProject ?: project - - override fun hasRole(projectId: String, role: String) = projectId == defaultProject - - override fun getClaim(name: String): JsonNode = jwt.getClaim(name).`as`(JsonNode::class.java) - ?: NullNode.instance -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt new file mode 100644 index 0000000..4ee9858 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2019. The Hyve + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * See the file LICENSE in the root of this repository. + */ + +package org.radarbase.jersey.auth.jwt + +import jakarta.ws.rs.core.Context +import org.glassfish.jersey.server.ContainerRequest +import org.radarbase.auth.token.RadarToken +import org.radarbase.jersey.auth.filter.RadarSecurityContext.Companion.radarSecurityContext +import java.util.function.Supplier + +/** Generates radar tokens from the security context. */ +class RadarTokenFactory( + @Context private val context: ContainerRequest, +) : Supplier { + override fun get(): RadarToken = context.radarSecurityContext.token +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/TokenValidatorFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/TokenValidatorFactory.kt new file mode 100644 index 0000000..3933aac --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/TokenValidatorFactory.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2019. The Hyve + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * See the file LICENSE in the root of this repository. + */ + +package org.radarbase.jersey.auth.jwt + +import jakarta.ws.rs.core.Context +import org.radarbase.auth.authentication.StaticTokenVerifierLoader +import org.radarbase.auth.authentication.TokenValidator +import org.radarbase.auth.authentication.TokenVerifierLoader +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.jwks.ECPEMCertificateParser +import org.radarbase.auth.jwks.JwkAlgorithmParser +import org.radarbase.auth.jwks.JwksTokenVerifierLoader +import org.radarbase.auth.jwks.JwksTokenVerifierLoader.Companion.toTokenVerifier +import org.radarbase.auth.jwks.RSAPEMCertificateParser +import org.radarbase.auth.jwks.toAlgorithm +import org.radarbase.jersey.auth.AuthConfig +import org.slf4j.LoggerFactory +import java.nio.file.Paths +import java.security.KeyStore +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.time.Duration +import java.util.function.Supplier +import kotlin.io.path.inputStream + +class TokenValidatorFactory( + @Context private val config: AuthConfig, +) : Supplier { + override fun get(): TokenValidator { + val tokenVerifierLoaders = buildList { + if (config.managementPortal.url != null) { + add(config.managementPortal.url + "/oauth/token_key") + } + addAll(config.jwksUrls) + }.mapTo(ArrayList()) { + JwksTokenVerifierLoader(it, config.jwtResourceName, JwkAlgorithmParser()) + } + + val algorithms = buildList { + if (!config.jwtECPublicKeys.isNullOrEmpty()) { + val parser = ECPEMCertificateParser() + config.jwtECPublicKeys.mapTo(this) { key -> + parser.parseAlgorithm(key) + } + } + if (!config.jwtRSAPublicKeys.isNullOrEmpty()) { + val parser = RSAPEMCertificateParser() + config.jwtRSAPublicKeys.mapTo(this) { key -> + parser.parseAlgorithm(key) + } + } + + if (!config.jwtKeystorePath.isNullOrEmpty()) { + add( + try { + val pkcs12Store = KeyStore.getInstance("pkcs12") + pkcs12Store.load( + Paths.get(config.jwtKeystorePath).inputStream(), + config.jwtKeystorePassword?.toCharArray(), + ) + when (val publicKey = pkcs12Store.getCertificate(config.jwtKeystoreAlias).publicKey) { + is ECPublicKey -> publicKey.toAlgorithm() + is RSAPublicKey -> publicKey.toAlgorithm() + else -> throw IllegalStateException("Unknown JWT key type ${publicKey.algorithm}") + } + } catch (ex: Exception) { + throw IllegalStateException("Failed to initialize JWT ECDSA public key", ex) + }, + ) + } + } + + if (algorithms.isNotEmpty()) { + tokenVerifierLoaders += StaticTokenVerifierLoader( + algorithms.map { algorithm -> + algorithm.toTokenVerifier(config.jwtResourceName) { + config.jwtIssuer?.let { + withIssuer(it) + } + } + }, + ) + } + + if (tokenVerifierLoaders.isEmpty()) { + throw TokenValidationException("No verification algorithms given") + } + + logger.info("Verifying JWTs with ${tokenVerifierLoaders.size} token verifiers") + + return TokenValidator( + verifierLoaders = tokenVerifierLoaders, + fetchTimeout = Duration.ofMinutes(5), + maxAge = Duration.ofHours(3), + ) + } + + companion object { + private val logger = LoggerFactory.getLogger(TokenValidatorFactory::class.java) + } +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalAuth.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalAuth.kt deleted file mode 100644 index dc29e11..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalAuth.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2019. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.jersey.auth.managementportal - -import com.auth0.jwt.interfaces.DecodedJWT -import com.fasterxml.jackson.databind.JsonNode -import org.radarbase.auth.authorization.Permission.MEASUREMENT_CREATE -import org.radarbase.auth.authorization.RoleAuthority -import org.radarbase.auth.token.JwtRadarToken -import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth - -/** - * Parsed JWT for validating authorization of data contents. - */ -class ManagementPortalAuth(private val jwt: DecodedJWT) : Auth { - override val token: RadarToken = JwtRadarToken(jwt) - override val defaultProject: String? = token.getReferentsWithPermission(RoleAuthority.Scope.PROJECT, MEASUREMENT_CREATE) - .findAny() - .orElse(null) - - override fun getClaim(name: String): JsonNode = jwt.getClaim(name).`as`(JsonNode::class.java) - - override fun hasRole(projectId: String, role: String): Boolean { - val authority = RoleAuthority.valueOfAuthorityOrNull(role) ?: return false - return token.roles.any { it.role == authority && it.referent == projectId } - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalResourceEnhancer.kt index 43f99d0..954e89e 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalResourceEnhancer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalResourceEnhancer.kt @@ -12,8 +12,11 @@ package org.radarbase.jersey.auth.managementportal import jakarta.inject.Singleton import org.glassfish.jersey.internal.inject.AbstractBinder import org.radarbase.auth.authentication.TokenValidator +import org.radarbase.auth.authorization.AuthorizationOracle import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.auth.AuthValidator +import org.radarbase.jersey.auth.jwt.AuthorizationOracleFactory +import org.radarbase.jersey.auth.jwt.TokenValidatorFactory import org.radarbase.jersey.enhancer.JerseyResourceEnhancer import org.radarbase.jersey.service.ProjectService import org.radarbase.jersey.service.managementportal.MPClientFactory @@ -37,6 +40,10 @@ class ManagementPortalResourceEnhancer(private val config: AuthConfig) : JerseyR .to(AuthValidator::class.java) .`in`(Singleton::class.java) + bindFactory(AuthorizationOracleFactory::class.java) + .to(AuthorizationOracle::class.java) + .`in`(Singleton::class.java) + if (config.managementPortal.clientId != null) { bindFactory(MPClientFactory::class.java) .to(MPClient::class.java) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalTokenValidator.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalTokenValidator.kt index 0d8f52f..ab06dc2 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalTokenValidator.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalTokenValidator.kt @@ -9,40 +9,16 @@ package org.radarbase.jersey.auth.managementportal -import com.auth0.jwt.JWT import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context import org.radarbase.auth.authentication.TokenValidator -import org.radarbase.auth.exception.TokenValidationException -import org.radarbase.jersey.auth.Auth +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.AuthValidator -import org.slf4j.LoggerFactory /** Creates a TokenValidator based on the current management portal configuration. */ class ManagementPortalTokenValidator( - @Context private val tokenValidator: TokenValidator + @Context private val tokenValidator: TokenValidator, ) : AuthValidator { - init { - try { - this.tokenValidator.refresh() - logger.debug("Refreshed Token Validator keys") - } catch (ex: Exception) { - logger.error("Failed to immediately initialize token validator, will try again later: {}", - ex.toString()) - } - } - - override fun verify(token: String, request: ContainerRequestContext): Auth { - val jwt = try { - JWT.decode(token) - } catch (ex: Exception) { - throw TokenValidationException("JWT cannot be decoded") - } - tokenValidator.validateAccessToken(token) - return ManagementPortalAuth(jwt) - } - - companion object { - private val logger = LoggerFactory.getLogger(ManagementPortalTokenValidator::class.java) - } + override fun verify(token: String, request: ContainerRequestContext): RadarToken? = + tokenValidator.validateBlocking(token) } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt deleted file mode 100644 index adb2270..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2019. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.jersey.auth.managementportal - -import jakarta.ws.rs.core.Context -import org.radarbase.auth.authentication.TokenValidator -import org.radarbase.auth.config.TokenVerifierPublicKeyConfig -import org.radarbase.jersey.auth.AuthConfig -import java.net.URI -import java.util.function.Supplier - -class TokenValidatorFactory( - @Context private val config: AuthConfig -) : Supplier { - override fun get(): TokenValidator = try { - TokenValidator() - } catch (e: RuntimeException) { - val cfg = TokenVerifierPublicKeyConfig().apply { - publicKeyEndpoints = listOf(URI("${config.managementPortal.url}/oauth/token_key")) - resourceName = config.jwtResourceName - } - TokenValidator(cfg) - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/Cache.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/Cache.kt index 6308812..fb23cb1 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/Cache.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/Cache.kt @@ -13,7 +13,7 @@ package org.radarbase.jersey.cache AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, - AnnotationTarget.PROPERTY_SETTER + AnnotationTarget.PROPERTY_SETTER, ) @Retention(AnnotationRetention.RUNTIME) annotation class Cache( diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/CacheControlFilter.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/CacheControlFilter.kt index 37d9489..92b47eb 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/CacheControlFilter.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/CacheControlFilter.kt @@ -16,12 +16,12 @@ import java.io.IOException @Priority(Priorities.HEADER_DECORATOR) class CacheControlFilter( private val cacheControl: CacheControl, - private val vary: Array + private val vary: Array, ) : ContainerResponseFilter { @Throws(IOException::class) override fun filter( requestContext: ContainerRequestContext, - responseContext: ContainerResponseContext + responseContext: ContainerResponseContext, ) { if (responseContext.status != 200) return diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/NoCache.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/NoCache.kt index 4fc5f5a..d6389d1 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/NoCache.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/cache/NoCache.kt @@ -11,7 +11,7 @@ package org.radarbase.jersey.cache AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, - AnnotationTarget.PROPERTY_SETTER + AnnotationTarget.PROPERTY_SETTER, ) @Retention(AnnotationRetention.RUNTIME) annotation class NoCache( diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt index 79f6132..3ed3080 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt @@ -11,7 +11,6 @@ import org.radarbase.jersey.enhancer.JerseyResourceEnhancer import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.IOException -import java.nio.file.OpenOption import kotlin.io.path.Path import kotlin.io.path.exists import kotlin.io.path.inputStream @@ -72,7 +71,7 @@ object ConfigLoader { enable(KotlinFeature.NullIsSameAsDefault) enable(KotlinFeature.SingletonSupport) enable(KotlinFeature.StrictNullChecks) - } + }, ) } @@ -158,6 +157,8 @@ object ConfigLoader { val newValue = modification(original) return if (newValue != original) { doCopy(newValue) - } else this + } else { + this + } } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestContext.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestContext.kt new file mode 100644 index 0000000..2a13b85 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestContext.kt @@ -0,0 +1,10 @@ +package org.radarbase.jersey.coroutines + +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +open class CoroutineRequestContext( + val requestContext: org.glassfish.jersey.process.internal.RequestContext, +) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt new file mode 100644 index 0000000..5e4591e --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt @@ -0,0 +1,70 @@ +package org.radarbase.jersey.coroutines + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.glassfish.jersey.process.internal.RequestScope +import org.radarbase.jersey.exception.HttpServerUnavailableException +import org.slf4j.LoggerFactory +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class CoroutineRequestWrapper( + private val timeout: Duration? = 30.seconds, + requestScope: RequestScope? = null, + location: String? = null, +) { + private val job = Job() + + val coroutineContext: CoroutineContext + + private val requestContext = try { + if (requestScope != null) { + requestScope.suspendCurrent() + ?: requestScope.createContext() + } else { + null + } + } catch (ex: Throwable) { + logger.debug("Cannot create request scope: {}", ex.toString()) + null + } + + init { + var context = job + contextName(location) + Dispatchers.Default + if (requestContext != null) { + context += CoroutineRequestContext(requestContext) + } + coroutineContext = context + } + + fun cancelRequest() { + try { + requestContext?.release() + } catch (ex: Throwable) { + // this is fine + } + job.cancel() + } + + fun CoroutineScope.timeoutHandler(emit: suspend (Throwable) -> Unit) { + timeout ?: return + launch { + delay(timeout) + emit(HttpServerUnavailableException()) + } + } + + companion object { + private val logger = LoggerFactory.getLogger(CoroutineRequestWrapper::class.java) + + @Suppress("DEPRECATION", "KotlinRedundantDiagnosticSuppress") + private fun contextName(location: String?) = CoroutineName( + "Request coroutine ${location ?: ""}#${Thread.currentThread().id}", + ) + } +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/Enhancers.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/Enhancers.kt index a6ad3e3..0805e5a 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/Enhancers.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/Enhancers.kt @@ -13,25 +13,29 @@ object Enhancers { fun radar( config: AuthConfig, includeMapper: Boolean = true, - includeHttpClient: Boolean = true, - ) = RadarJerseyResourceEnhancer(config, includeMapper = includeMapper, includeHttpClient = includeHttpClient) + ) = RadarJerseyResourceEnhancer(config, includeMapper = includeMapper) + /** Authorization via ManagementPortal. */ fun managementPortal(config: AuthConfig) = ManagementPortalResourceEnhancer(config) + /** Disable all authorization. Useful for a public service. */ val disabledAuthorization = DisabledAuthorizationResourceEnhancer() + /** Handle a generic ECDSA identity provider. */ val ecdsa = EcdsaResourceEnhancer() + /** Adds a health endpoint. */ val health = HealthResourceEnhancer() + /** * Handles any application exceptions including an appropriate response to client. * @see org.radarbase.jersey.exception.HttpApplicationException */ val exception = ExceptionResourceEnhancer() - /** Adds OkHttpClient utility. Not needed if radar(includeHttpClient = true). */ - val okhttp = OkHttpResourceEnhancer() + /** Add ObjectMapper utility. Not needed if radar(includeMapper = true). */ val mapper = MapperResourceEnhancer() + /** * Adds an OpenAPI endpoint to the stack at `/openapi.yaml` and `/openapi.json`. * The description is given with [openApi]. Any routes provided in diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/HealthResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/HealthResourceEnhancer.kt index 4471de8..b10057f 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/HealthResourceEnhancer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/HealthResourceEnhancer.kt @@ -6,7 +6,7 @@ import org.radarbase.jersey.resource.HealthResource import org.radarbase.jersey.service.HealthService import org.radarbase.jersey.service.ImmediateHealthService -class HealthResourceEnhancer: JerseyResourceEnhancer { +class HealthResourceEnhancer : JerseyResourceEnhancer { override val classes: Array> = arrayOf(HealthResource::class.java) override fun AbstractBinder.enhance() { diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancer.kt index 3f76d49..c9413c6 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancer.kt @@ -27,7 +27,7 @@ import org.glassfish.jersey.server.ResourceConfig * * Do not use this class if [RadarJerseyResourceEnhancer] is already being used. */ -class MapperResourceEnhancer: JerseyResourceEnhancer { +class MapperResourceEnhancer : JerseyResourceEnhancer { var mapper: ObjectMapper? = null private val latestMapper: ObjectMapper @@ -47,11 +47,13 @@ class MapperResourceEnhancer: JerseyResourceEnhancer { disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) serializationInclusion(JsonInclude.Include.NON_NULL) - addModule(kotlinModule { - enable(KotlinFeature.NullToEmptyMap) - enable(KotlinFeature.NullToEmptyCollection) - enable(KotlinFeature.NullIsSameAsDefault) - }) + addModule( + kotlinModule { + enable(KotlinFeature.NullToEmptyMap) + enable(KotlinFeature.NullToEmptyCollection) + enable(KotlinFeature.NullIsSameAsDefault) + }, + ) addModule(JavaTimeModule()) addModule(Jdk8Module()) } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/OkHttpResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/OkHttpResourceEnhancer.kt deleted file mode 100644 index 59de668..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/OkHttpResourceEnhancer.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2019. The Hyve - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * See the file LICENSE in the root of this repository. - */ - -package org.radarbase.jersey.enhancer - -import jakarta.inject.Singleton -import okhttp3.OkHttpClient -import org.glassfish.jersey.internal.inject.AbstractBinder -import java.util.concurrent.TimeUnit - -/** - * Add utilities such as a reusable ObjectMapper and OkHttpClient to inject. - * - * Do not use this class if [RadarJerseyResourceEnhancer] is already being used. - */ -class OkHttpResourceEnhancer: JerseyResourceEnhancer { - var client: OkHttpClient? = null - - override fun AbstractBinder.enhance() { - bind(client ?: createDefaultClient()) - .to(OkHttpClient::class.java) - .`in`(Singleton::class.java) - } - - companion object { - fun createDefaultClient(): OkHttpClient = OkHttpClient().newBuilder() - .connectTimeout(10, TimeUnit.SECONDS) - .writeTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/RadarJerseyResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/RadarJerseyResourceEnhancer.kt index b624c32..efa6bf7 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/RadarJerseyResourceEnhancer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/RadarJerseyResourceEnhancer.kt @@ -14,29 +14,29 @@ import org.glassfish.jersey.internal.inject.AbstractBinder import org.glassfish.jersey.jackson.JacksonFeature import org.glassfish.jersey.process.internal.RequestScoped import org.glassfish.jersey.server.ResourceConfig -import org.radarbase.jersey.auth.Auth +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.auth.AuthService import org.radarbase.jersey.auth.filter.AuthenticationFilter import org.radarbase.jersey.auth.filter.AuthorizationFeature -import org.radarbase.jersey.auth.jwt.AuthFactory +import org.radarbase.jersey.auth.jwt.RadarTokenFactory +import org.radarbase.jersey.service.AsyncCoroutineService +import org.radarbase.jersey.service.ScopedAsyncCoroutineService /** * Add RADAR auth to a Jersey project. This requires a {@link ProjectService} implementation to be * added to the Binder first. * * @param includeMapper is set, this also instantiates [MapperResourceEnhancer]. - * @param includeHttpClient is set, this also includes [OkHttpResourceEnhancer]. */ class RadarJerseyResourceEnhancer( private val config: AuthConfig, includeMapper: Boolean = true, - includeHttpClient: Boolean = true, -): JerseyResourceEnhancer { +) : JerseyResourceEnhancer { /** * Utilities. Set to `null` to avoid injection. Modify utility mapper or client to inject * a different mapper or client. */ - private val okHttpResourceEnhancer: OkHttpResourceEnhancer? = if (includeHttpClient) OkHttpResourceEnhancer() else null private val mapperResourceEnhancer: MapperResourceEnhancer? = if (includeMapper) MapperResourceEnhancer() else null override val classes = arrayOf( @@ -46,7 +46,6 @@ class RadarJerseyResourceEnhancer( override fun ResourceConfig.enhance() { register(JacksonFeature.withoutExceptionMappers()) - okHttpResourceEnhancer?.enhanceResources(this) mapperResourceEnhancer?.enhanceResources(this) } @@ -55,14 +54,19 @@ class RadarJerseyResourceEnhancer( .to(AuthConfig::class.java) .`in`(Singleton::class.java) + bind(ScopedAsyncCoroutineService::class.java) + .to(AsyncCoroutineService::class.java) + .`in`(Singleton::class.java) + // Bind factories. - bindFactory(AuthFactory::class.java) - .proxy(true) - .proxyForSameScope(true) - .to(Auth::class.java) + bindFactory(RadarTokenFactory::class.java) + .to(RadarToken::class.java) .`in`(RequestScoped::class.java) - okHttpResourceEnhancer?.enhanceBinder(this) + bind(AuthService::class.java) + .to(AuthService::class.java) + .`in`(Singleton::class.java) + mapperResourceEnhancer?.enhanceBinder(this) } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/ExceptionResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/ExceptionResourceEnhancer.kt index 8562193..9ea3af8 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/ExceptionResourceEnhancer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/ExceptionResourceEnhancer.kt @@ -4,10 +4,18 @@ import jakarta.inject.Singleton import org.glassfish.jersey.internal.inject.AbstractBinder import org.glassfish.jersey.internal.inject.PerThread import org.radarbase.jersey.enhancer.JerseyResourceEnhancer -import org.radarbase.jersey.exception.mapper.* +import org.radarbase.jersey.exception.mapper.DefaultJsonExceptionRenderer +import org.radarbase.jersey.exception.mapper.DefaultTextExceptionRenderer +import org.radarbase.jersey.exception.mapper.ExceptionRenderer +import org.radarbase.jersey.exception.mapper.ExceptionRenderers +import org.radarbase.jersey.exception.mapper.HtmlTemplateExceptionRenderer +import org.radarbase.jersey.exception.mapper.HttpApplicationExceptionMapper +import org.radarbase.jersey.exception.mapper.JsonProcessingExceptionMapper +import org.radarbase.jersey.exception.mapper.UnhandledExceptionMapper +import org.radarbase.jersey.exception.mapper.WebApplicationExceptionMapper /** Add WebApplicationException and any exception handling. */ -class ExceptionResourceEnhancer: JerseyResourceEnhancer { +class ExceptionResourceEnhancer : JerseyResourceEnhancer { /** * Renderers to use, per mediatype. To use different renderers, override the renderer that * should be overridden. diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpApplicationException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpApplicationException.kt index 7de5f80..90f5ef6 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpApplicationException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpApplicationException.kt @@ -11,7 +11,22 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response -open class HttpApplicationException(val status: Int, val code: String, val detailedMessage: String? = null, val additionalHeaders: List> = listOf()) : RuntimeException("[$status] $code: ${detailedMessage ?: "no message"}") { - constructor(status: Response.Status, code: String, detailedMessage: String? = null, additionalHeaders: List> = listOf()) - : this(status.statusCode, code, detailedMessage, additionalHeaders) +open class HttpApplicationException( + val status: Int, + val code: String, + val detailedMessage: String? = null, + val additionalHeaders: List> = listOf(), +) : RuntimeException("[$status] ${codeMessage(code, detailedMessage)}") { + constructor(status: Response.Status, code: String, detailedMessage: String? = null, additionalHeaders: List> = listOf()) : + this(status.statusCode, code, detailedMessage, additionalHeaders) + + val codeMessage: String + get() = codeMessage(code, detailedMessage) + + companion object { + fun codeMessage( + code: String, + detailedMessage: String? = null, + ) = "$code: ${detailedMessage ?: "no message"}" + } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadGatewayException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadGatewayException.kt index 18957d0..92fd5e6 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadGatewayException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadGatewayException.kt @@ -11,5 +11,5 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response.Status -class HttpBadGatewayException(message: String) - : HttpApplicationException(Status.BAD_GATEWAY, "bad_gateway", message) +class HttpBadGatewayException(message: String) : + HttpApplicationException(Status.BAD_GATEWAY, "bad_gateway", message) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadRequestException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadRequestException.kt index 98fb188..a1bfbd7 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadRequestException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpBadRequestException.kt @@ -12,4 +12,4 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response class HttpBadRequestException(code: String, message: String) : - HttpApplicationException(Response.Status.BAD_REQUEST, code, message) + HttpApplicationException(Response.Status.BAD_REQUEST, code, message) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpConflictException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpConflictException.kt index 379e64e..a90463a 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpConflictException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpConflictException.kt @@ -12,4 +12,4 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response class HttpConflictException(code: String, messageText: String) : - HttpApplicationException(Response.Status.CONFLICT, code, messageText) + HttpApplicationException(Response.Status.CONFLICT, code, messageText) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpGatewayTimeoutException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpGatewayTimeoutException.kt index e5fe512..eea5f87 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpGatewayTimeoutException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpGatewayTimeoutException.kt @@ -11,5 +11,5 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response.Status -class HttpGatewayTimeoutException(message: String) - : HttpApplicationException(Status.GATEWAY_TIMEOUT, "gateway_timeout", message) +class HttpGatewayTimeoutException(message: String) : + HttpApplicationException(Status.GATEWAY_TIMEOUT, "gateway_timeout", message) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInternalServerException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInternalServerException.kt index 0efb87f..4dc35e6 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInternalServerException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInternalServerException.kt @@ -12,4 +12,4 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response class HttpInternalServerException(code: String, message: String) : - HttpApplicationException(Response.Status.INTERNAL_SERVER_ERROR, code, message) + HttpApplicationException(Response.Status.INTERNAL_SERVER_ERROR, code, message) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInvalidContentException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInvalidContentException.kt index 5b21358..ca36d9b 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInvalidContentException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpInvalidContentException.kt @@ -9,5 +9,5 @@ package org.radarbase.jersey.exception -class HttpInvalidContentException(s: String) - : HttpApplicationException(422, "invalid_content", s) +class HttpInvalidContentException(s: String) : + HttpApplicationException(422, "invalid_content", s) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpNotFoundException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpNotFoundException.kt index 51f8e39..35246d6 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpNotFoundException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpNotFoundException.kt @@ -12,4 +12,4 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response class HttpNotFoundException(code: String, message: String) : - HttpApplicationException(Response.Status.NOT_FOUND, code, message) + HttpApplicationException(Response.Status.NOT_FOUND, code, message) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpRequestEntityTooLarge.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpRequestEntityTooLarge.kt index ed62b45..846bcf3 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpRequestEntityTooLarge.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpRequestEntityTooLarge.kt @@ -11,5 +11,5 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response -class HttpRequestEntityTooLarge(message: String) - : HttpApplicationException(Response.Status.REQUEST_ENTITY_TOO_LARGE, "request_entity_too_large", message) +class HttpRequestEntityTooLarge(message: String) : + HttpApplicationException(Response.Status.REQUEST_ENTITY_TOO_LARGE, "request_entity_too_large", message) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt new file mode 100644 index 0000000..1c5349e --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt @@ -0,0 +1,13 @@ +package org.radarbase.jersey.exception + +import jakarta.ws.rs.core.Response + +class HttpServerUnavailableException( + message: String? = null, + additionalHeaders: List> = listOf(), +) : HttpApplicationException( + status = Response.Status.SERVICE_UNAVAILABLE, + code = "timeout", + detailedMessage = message, + additionalHeaders = additionalHeaders, +) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpUnauthorizedException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpUnauthorizedException.kt index 771e562..0bd92a5 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpUnauthorizedException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpUnauthorizedException.kt @@ -25,13 +25,13 @@ class HttpUnauthorizedException( add("WWW-Authenticate" to wwwAuthenticateHeader) } addAll(additionalHeaders) - } + }, ) { companion object { fun wwwAuthenticateHeader( error: String? = null, errorDescription: String? = null, - scope: String? = null + scope: String? = null, ): String { return if (error == null && errorDescription == null && scope == null) { "Bearer realm=\"RADAR-base\"" diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultJsonExceptionRenderer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultJsonExceptionRenderer.kt index 0d88cc6..0f8ca40 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultJsonExceptionRenderer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultJsonExceptionRenderer.kt @@ -15,7 +15,7 @@ import org.radarbase.jersey.exception.HttpApplicationException /** * Render an exception using a Mustache HTML document. */ -class DefaultJsonExceptionRenderer: ExceptionRenderer { +class DefaultJsonExceptionRenderer : ExceptionRenderer { override fun render(exception: HttpApplicationException): String { val stringEncoder = JsonStringEncoder.getInstance() val quotedError = stringEncoder.quoteAsUTF8(exception.code).toString(Charsets.UTF_8) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultTextExceptionRenderer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultTextExceptionRenderer.kt index 849f142..730f93e 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultTextExceptionRenderer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/DefaultTextExceptionRenderer.kt @@ -14,7 +14,7 @@ import org.radarbase.jersey.exception.HttpApplicationException /** * Render an exception using a Mustache HTML document. */ -class DefaultTextExceptionRenderer: ExceptionRenderer { +class DefaultTextExceptionRenderer : ExceptionRenderer { override fun render(exception: HttpApplicationException): String { return "[${exception.status}] ${exception.code}: ${exception.detailedMessage ?: "unknown reason"}" } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HtmlTemplateExceptionRenderer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HtmlTemplateExceptionRenderer.kt index ff9d53b..ac5cc8b 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HtmlTemplateExceptionRenderer.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HtmlTemplateExceptionRenderer.kt @@ -20,7 +20,7 @@ import java.io.OutputStreamWriter /** * Render an exception using a Mustache HTML document. */ -class HtmlTemplateExceptionRenderer: ExceptionRenderer { +class HtmlTemplateExceptionRenderer : ExceptionRenderer { private val errorTemplates: Map private val template4xx: Mustache @@ -31,24 +31,24 @@ class HtmlTemplateExceptionRenderer: ExceptionRenderer { val loadTemplate = { code: String -> javaClass.getResourceAsStream("$code.html") - ?.use { stream -> - try { - stream.bufferedReader().use { - mf.compile(it, "$code.html") - } - } catch (ex: IOException) { - logger.error("Failed to read error template $code.html: {}", ex.toString()) - null + ?.use { stream -> + try { + stream.bufferedReader().use { + mf.compile(it, "$code.html") } + } catch (ex: IOException) { + logger.error("Failed to read error template $code.html: {}", ex.toString()) + null } + } } errorTemplates = (400..599) - .mapNotNull { code -> - loadTemplate(code.toString()) - ?.let { code to it } - } - .toMap() + .mapNotNull { code -> + loadTemplate(code.toString()) + ?.let { code to it } + } + .toMap() template4xx = checkNotNull(loadTemplate("4xx")) { "Missing 4xx.html template" } template5xx = checkNotNull(loadTemplate("5xx")) { "Missing 5xx.html template" } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HttpApplicationExceptionMapper.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HttpApplicationExceptionMapper.kt index 7152e0c..b6bfd67 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HttpApplicationExceptionMapper.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/mapper/HttpApplicationExceptionMapper.kt @@ -28,12 +28,11 @@ class HttpApplicationExceptionMapper( ) : ExceptionMapper { override fun toResponse(exception: HttpApplicationException): Response { logger.error( - "[{}] {} {} - {}: {}", + "[{}] {} {} - {}", exception.status, requestContext.method, uriInfo.path, - exception.code, - exception.detailedMessage + exception.codeMessage, ) return renderers.render(exception).build() diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/Filters.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/Filters.kt index e63076c..171f7a2 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/Filters.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/Filters.kt @@ -5,8 +5,10 @@ import org.radarbase.jersey.cache.CacheControlFeature object Filters { /** Adds CORS headers to all responses. */ val cors = CorsFilter::class.java + /** Log the HTTP status responses of all requests. */ val logResponse = ResponseLoggerFilter::class.java + /** Add cache control headers to responses. */ val cache = CacheControlFeature::class.java } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilter.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilter.kt index cdff2ca..d1b5f7d 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilter.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilter.kt @@ -62,6 +62,7 @@ class ResponseLoggerFilter : ContainerResponseFilter { companion object { private val logger = LoggerFactory.getLogger(ResponseLoggerFilter::class.java) + /** Whether given path matches a health endpoint. */ private inline val String.isHealthEndpoint: Boolean get() = this == "health" || endsWith("/health") diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/resource/HealthResource.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/resource/HealthResource.kt index 8d9af79..6a87b81 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/resource/HealthResource.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/resource/HealthResource.kt @@ -5,17 +5,24 @@ import jakarta.inject.Singleton import jakarta.ws.rs.GET import jakarta.ws.rs.Path import jakarta.ws.rs.Produces +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.MediaType.APPLICATION_JSON +import org.radarbase.jersey.service.AsyncCoroutineService import org.radarbase.jersey.service.HealthService +import kotlin.time.Duration.Companion.seconds @Path("/health") @Resource @Singleton class HealthResource( - @Context private val healthService: HealthService + @Context private val asyncService: AsyncCoroutineService, + @Context private val healthService: HealthService, ) { @GET @Produces(APPLICATION_JSON) - fun healthStatus(): Map = healthService.metrics + fun healthStatus(@Suspended asyncResponse: AsyncResponse) = asyncService.runAsCoroutine(asyncResponse, 5.seconds) { + healthService.computeMetrics() + } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/AsyncCoroutineService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/AsyncCoroutineService.kt new file mode 100644 index 0000000..0040efb --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/AsyncCoroutineService.kt @@ -0,0 +1,50 @@ +package org.radarbase.jersey.service + +import jakarta.ws.rs.container.AsyncResponse +import kotlinx.coroutines.CancellableContinuation +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +interface AsyncCoroutineService { + + /** + * Run an AsyncResponse as a coroutine. The result of [block] will be used as the response. If + * [block] throws any exception, that exception will be used to resume instead. If the connection + * is cancelled by the client, the underlying job is also cancelled. If [timeout] is not null, + * after the timeout has expired a 503 Server Unavailable exception will be thrown and the coroutine + * will be cancelled. The current request scope is added to the coroutines scope to later be + * used with [runInRequestScope] or [suspendInRequestScope]. + */ + fun runAsCoroutine( + asyncResponse: AsyncResponse, + timeout: Duration = 30.seconds, + block: suspend () -> T, + ) + + /** + * Run a blocking request as a coroutine. The result of [block] will be used as the response. If + * [block] throws any exception, that exception will be used to resume instead. If the connection + * is cancelled by the client, the underlying job is also cancelled. If [timeout] is not null, + * after the timeout has expired a 503 Server Unavailable exception will be thrown and the coroutine + * will be cancelled. The current request scope is added to the coroutines scope to later be + * used with [runInRequestScope] or [suspendInRequestScope]. + */ + fun runBlocking( + timeout: Duration = 30.seconds, + block: suspend () -> T, + ): T + + /** + * Run given function in request scope. No more coroutines or thread changes should be done + * inside [block]. + */ + suspend fun runInRequestScope(block: () -> T): T + + /** + * Run given function in request scope. Usually the contents of the block will call a callback. + * No more coroutines or thread changes should be done inside [block]. Allow cancelling the + * block by configuring [CancellableContinuation.invokeOnCancellation]. Finish the function by + * calling [CancellableContinuation.resumeWith] or any of its corresponding helper functions. + */ + suspend fun suspendInRequestScope(block: (CancellableContinuation) -> Unit): T +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/HealthService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/HealthService.kt index 77001c6..e8281b3 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/HealthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/HealthService.kt @@ -1,15 +1,17 @@ package org.radarbase.jersey.service interface HealthService { - val status: Status - val metrics: Map - fun add(metric: Metric) fun remove(metric: Metric) - abstract class Metric(val name: String) { - abstract val metrics: Any - open val status: Status? = null + suspend fun computeStatus(): Status + suspend fun computeMetrics(): Map + + abstract class Metric( + val name: String, + ) { + abstract suspend fun computeStatus(): Status? + abstract suspend fun computeMetrics(): Map override fun equals(other: Any?): Boolean { if (other === this) return true diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ImmediateHealthService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ImmediateHealthService.kt index 255333c..37e886a 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ImmediateHealthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ImmediateHealthService.kt @@ -1,33 +1,45 @@ package org.radarbase.jersey.service import jakarta.ws.rs.core.Context +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import org.glassfish.hk2.api.IterableProvider +import org.radarbase.kotlin.coroutines.forkAny +import org.radarbase.kotlin.coroutines.forkJoin class ImmediateHealthService( - @Context healthMetrics: IterableProvider -): HealthService { + @Context healthMetrics: IterableProvider, +) : HealthService { @Volatile private var allMetrics: List = healthMetrics.toList() - override val status: HealthService.Status - get() = if (allMetrics.any { it.status == HealthService.Status.DOWN }) { + override suspend fun computeStatus(): HealthService.Status = + if (allMetrics.forkAny { + val status = it.computeStatus() + status == HealthService.Status.DOWN + } + ) { HealthService.Status.DOWN } else { HealthService.Status.UP } - override val metrics: Map - get() { - val metrics = allMetrics - val result = mutableMapOf( - "status" to if (metrics.any { it.status == HealthService.Status.DOWN }) { - HealthService.Status.DOWN - } else { - HealthService.Status.UP - }) - metrics.forEach { result[it.name] = it.metrics } - return result + override suspend fun computeMetrics(): Map = coroutineScope { + val metrics = async { + allMetrics.forkJoin { + Pair(it.name, it.computeMetrics()) + } } + val status = async { + computeStatus() + } + buildMap { + put("status", status.await()) + metrics.await().forEach { (name, metric) -> + put(name, metric) + } + } + } override fun add(metric: HealthService.Metric) { allMetrics = allMetrics + metric diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ProjectService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ProjectService.kt index 8cf79c3..3d5f672 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ProjectService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ProjectService.kt @@ -19,26 +19,26 @@ interface ProjectService { * Ensure that given organization ID is valid. * @throws HttpApplicationException if the organization ID is not a valid organization ID. */ - fun ensureOrganization(organizationId: String) + suspend fun ensureOrganization(organizationId: String) /** * List all project IDs in an organization * @throws HttpApplicationException if the organization ID is not a valid organization ID. */ - fun listProjects(organizationId: String): List + suspend fun listProjects(organizationId: String): List /** * Find the organization of a project * @throws HttpApplicationException if the organization ID is not a valid organization ID. */ - fun projectOrganization(projectId: String): String + suspend fun projectOrganization(projectId: String): String /** * Ensure that given project ID is valid. * @throws HttpApplicationException if the project ID is not a valid project ID. */ - fun ensureProject(projectId: String) + suspend fun ensureProject(projectId: String) /** Ensure that given subject user exists. */ - fun ensureSubject(projectId: String, userId: String) + suspend fun ensureSubject(projectId: String, userId: String) } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt new file mode 100644 index 0000000..c197160 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt @@ -0,0 +1,106 @@ +package org.radarbase.jersey.service + +import jakarta.inject.Provider +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.ConnectionCallback +import jakarta.ws.rs.container.ContainerRequestContext +import jakarta.ws.rs.core.Context +import jakarta.ws.rs.core.UriInfo +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import org.glassfish.jersey.process.internal.RequestScope +import org.radarbase.jersey.coroutines.CoroutineRequestContext +import org.radarbase.jersey.coroutines.CoroutineRequestWrapper +import org.radarbase.kotlin.coroutines.consumeFirst +import kotlin.time.Duration + +class ScopedAsyncCoroutineService( + @Context private val requestScope: Provider, + @Context private val requestContext: Provider, + @Context private val uriInfo: Provider, +) : AsyncCoroutineService { + + override fun runAsCoroutine( + asyncResponse: AsyncResponse, + timeout: Duration, + block: suspend () -> T, + ) { + withWrapper(timeout) { + asyncResponse.register(ConnectionCallback { cancelRequest() }) + + CoroutineScope(coroutineContext).launch { + timeoutHandler { + asyncResponse.resume(it) + cancelRequest() + } + try { + asyncResponse.resume(block()) + } catch (ex: CancellationException) { + // do nothing, cancel request in finally + } catch (ex: Throwable) { + asyncResponse.resume(ex) + } finally { + cancelRequest() + } + } + } + } + + override fun runBlocking( + timeout: Duration, + block: suspend () -> T, + ): T = withWrapper(timeout) { + try { + runBlocking(coroutineContext) { + consumeFirst> { emit -> + timeoutHandler { emit(Result.failure(it)) } + try { + val result = block() + emit(Result.success(result)) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Throwable) { + emit(Result.failure(ex)) + } + }.getOrThrow() + } + } finally { + cancelRequest() + } + } + + private inline fun withWrapper(timeout: Duration, block: CoroutineRequestWrapper.() -> V): V { + val wrapper = CoroutineRequestWrapper( + timeout, + requestScope.get(), + "${requestContext.get().method} ${uriInfo.get().path}", + ) + return wrapper.block() + } + + override suspend fun runInRequestScope(block: () -> T): T { + val requestContext = checkNotNull(currentCoroutineContext()[CoroutineRequestContext.Key]) { + """Not running inside a request scope. Initialize with AsyncCoroutineService.runAsCoroutine or supply a RequestScope to AsyncResponse.runAsCoroutine.""" + } + return requestScope.get().runInScope( + requestContext.requestContext, + block, + ) + } + + override suspend fun suspendInRequestScope(block: (CancellableContinuation) -> Unit): T { + val requestContext = checkNotNull(currentCoroutineContext()[CoroutineRequestContext.Key]) { + """Not running inside a request scope. Initialize with AsyncCoroutineService.runAsCoroutine or supply a RequestScope to AsyncResponse.runAsCoroutine.""" + } + return suspendCancellableCoroutine { continuation -> + requestScope.get().runInScope(requestContext.requestContext) { + block(continuation) + } + } + } +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPClientFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPClientFactory.kt index 74dd2e2..d28c3bf 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPClientFactory.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPClientFactory.kt @@ -1,24 +1,42 @@ package org.radarbase.jersey.service.managementportal -import com.fasterxml.jackson.databind.ObjectMapper import jakarta.ws.rs.core.Context -import okhttp3.OkHttpClient import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.ktor.auth.ClientCredentialsConfig +import org.radarbase.ktor.auth.clientCredentials import org.radarbase.management.client.MPClient +import org.radarbase.management.client.mpClient +import org.slf4j.LoggerFactory +import java.net.URI import java.util.function.Supplier class MPClientFactory( @Context private val authConfig: AuthConfig, - @Context private val okHttpClient: OkHttpClient, - @Context private val objectMapper: ObjectMapper, ) : Supplier { - override fun get(): MPClient = MPClient( - serverConfig = MPClient.MPServerConfig( - url = requireNotNull(authConfig.managementPortal.httpUrl) { "ManagementPortal client needs a URL" }.toString(), - clientId = requireNotNull(authConfig.managementPortal.clientId) { "ManagementPortal client needs a client ID" }, - clientSecret = requireNotNull(authConfig.managementPortal.clientSecret) { "ManagementPortal client needs a client secret" }, - ), - objectMapper = objectMapper, - httpClient = okHttpClient, - ) + override fun get(): MPClient = mpClient { + url = requireNotNull(authConfig.managementPortal.url) { "ManagementPortal client needs a URL" } + .trimEnd('/') + auth { + val authConfig = ClientCredentialsConfig( + tokenUrl = "$url/oauth/token", + clientId = authConfig.managementPortal.clientId, + clientSecret = authConfig.managementPortal.clientSecret, + ).copyWithEnv() + + logger.info( + "Configuring MPClient with URL {} and client ID {}", + authConfig.tokenUrl, + authConfig.clientId, + ) + + clientCredentials( + authConfig = authConfig, + targetHost = URI.create(url!!).host, + ) + } + } + + companion object { + private val logger = LoggerFactory.getLogger(MPClientFactory::class.java) + } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt index aee1c59..7542aed 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt @@ -16,111 +16,141 @@ package org.radarbase.jersey.service.managementportal +import io.ktor.http.HttpStatusCode +import jakarta.inject.Provider import jakarta.ws.rs.core.Context +import org.radarbase.auth.authorization.EntityDetails import org.radarbase.auth.authorization.Permission -import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.auth.AuthService import org.radarbase.jersey.exception.HttpNotFoundException -import org.radarbase.jersey.util.CacheConfig -import org.radarbase.jersey.util.CachedMap +import org.radarbase.kotlin.coroutines.CacheConfig +import org.radarbase.kotlin.coroutines.CachedMap +import org.radarbase.management.client.HttpStatusException import org.radarbase.management.client.MPClient import org.radarbase.management.client.MPOrganization import org.radarbase.management.client.MPProject import org.radarbase.management.client.MPSubject import org.slf4j.LoggerFactory -import java.time.Duration import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toKotlinDuration class MPProjectService( @Context private val config: AuthConfig, @Context private val mpClient: MPClient, + @Context private val authService: Provider, ) : RadarProjectService { private val projects: CachedMap private val organizations: CachedMap private val participants: ConcurrentMap> = ConcurrentHashMap() + private val projectCacheConfig = CacheConfig( + refreshDuration = config.managementPortal.syncParticipantsInterval.toKotlinDuration(), + retryDuration = RETRY_INTERVAL, + ) + init { val cacheConfig = CacheConfig( - refreshDuration = config.managementPortal.syncProjectsInterval, + refreshDuration = config.managementPortal.syncProjectsInterval.toKotlinDuration(), retryDuration = RETRY_INTERVAL, ) organizations = CachedMap(cacheConfig) { - mpClient.requestOrganizations() - .associateBy { it.id } - .also { logger.debug("Fetched organizations {}", it) } + try { + mpClient.requestOrganizations() + .associateBy { it.id } + .also { logger.debug("Fetched organizations {}", it) } + } catch (ex: HttpStatusException) { + if (ex.code == HttpStatusCode.NotFound) { + logger.warn("Target ManagementPortal does not support organizations. Using default organization main.") + mapOf("main" to defaultOrganization) + } else { + throw ex + } + } } projects = CachedMap(cacheConfig) { mpClient.requestProjects() + .map { project -> + if (project.organization == null) { + project.copy(organization = defaultOrganization) + } else { + project + } + } .associateBy { it.id } .also { logger.debug("Fetched projects {}", it) } } } - override fun userProjects(auth: Auth, permission: Permission): List { - return projects.get().values - .filter { - auth.token.hasPermissionOnOrganizationAndProject( - permission, - it.organization?.id, - it.id - ) - } + override suspend fun userProjects(permission: Permission): List { + val authService = authService.get() + return projects.get() + .values + .filter { + authService.hasPermission( + permission, + EntityDetails( + organization = it.organization?.id, + project = it.id, + ), + ) + } } - override fun ensureProject(projectId: String) { - if (projectId !in projects) { + override suspend fun ensureProject(projectId: String) { + if (!projects.contains(projectId)) { throw HttpNotFoundException("project_not_found", "Project $projectId not found in Management Portal.") } } - override fun project(projectId: String): MPProject = projects[projectId] + override suspend fun project(projectId: String): MPProject = projects.get(projectId) ?: throw HttpNotFoundException("project_not_found", "Project $projectId not found in Management Portal.") - override fun projectSubjects(projectId: String): List = projectUserCache(projectId).get().values.toList() + override suspend fun projectSubjects(projectId: String): List = projectUserCache(projectId).get().values.toList() - override fun subjectByExternalId(projectId: String, externalUserId: String): MPSubject? = projectUserCache(projectId) - .findValue { it.externalId == externalUserId } + override suspend fun subjectByExternalId(projectId: String, externalUserId: String): MPSubject? = projectUserCache(projectId) + .findValue { it.externalId == externalUserId } - override fun ensureSubject(projectId: String, userId: String) { + override suspend fun ensureSubject(projectId: String, userId: String) { ensureProject(projectId) if (!projectUserCache(projectId).contains(userId)) { throw HttpNotFoundException("user_not_found", "User $userId not found in project $projectId of ManagementPortal.") } } - override fun ensureOrganization(organizationId: String) { - if (organizationId !in organizations) { + override suspend fun ensureOrganization(organizationId: String) { + if (!organizations.contains(organizationId)) { throw HttpNotFoundException("organization_not_found", "Organization $organizationId not found in Management Portal.") } } - override fun listProjects(organizationId: String): List = projects.get().asSequence() + override suspend fun listProjects(organizationId: String): List = projects.get().asSequence() .filter { it.value.organization?.id == organizationId } .mapTo(ArrayList()) { it.key } - override fun projectOrganization(projectId: String): String = - projects[projectId]?.organization?.id + override suspend fun projectOrganization(projectId: String): String = + projects.get(projectId)?.organization?.id ?: throw HttpNotFoundException("project_not_found", "Project $projectId not found in Management Portal.") - override fun subject(projectId: String, userId: String): MPSubject? { + override suspend fun subject(projectId: String, userId: String): MPSubject? { ensureProject(projectId) - return projectUserCache(projectId)[userId] + return projectUserCache(projectId).get(userId) } - private fun projectUserCache(projectId: String) = participants.computeIfAbsent(projectId) { - CachedMap(CacheConfig( - refreshDuration = config.managementPortal.syncParticipantsInterval, - retryDuration = RETRY_INTERVAL)) { + private suspend fun projectUserCache(projectId: String) = participants.computeIfAbsent(projectId) { + CachedMap(projectCacheConfig) { mpClient.requestSubjects(projectId) .associateBy { checkNotNull(it.id) } } } companion object { - private val RETRY_INTERVAL = Duration.ofMinutes(1) + private val RETRY_INTERVAL = 1.minutes private val logger = LoggerFactory.getLogger(MPProjectService::class.java) + private val defaultOrganization = MPOrganization("main") } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/ProjectServiceWrapper.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/ProjectServiceWrapper.kt index 0d4808b..3d1b70c 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/ProjectServiceWrapper.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/ProjectServiceWrapper.kt @@ -22,24 +22,24 @@ import org.radarbase.jersey.exception.HttpNotFoundException import org.radarbase.jersey.service.ProjectService class ProjectServiceWrapper( - @Context private val radarProjectService: Provider + @Context private val radarProjectService: Provider, ) : ProjectService { - override fun ensureOrganization(organizationId: String) = + override suspend fun ensureOrganization(organizationId: String) = radarProjectService.get().ensureOrganization(organizationId) - override fun listProjects(organizationId: String): List = + override suspend fun listProjects(organizationId: String): List = radarProjectService.get().listProjects(organizationId) - override fun projectOrganization(projectId: String): String = + override suspend fun projectOrganization(projectId: String): String = radarProjectService.get().projectOrganization(projectId) /** * Ensures that [projectId] exists in RADAR project service. * @throws HttpNotFoundException if the project does not exist. */ - override fun ensureProject(projectId: String) = radarProjectService.get().ensureProject(projectId) + override suspend fun ensureProject(projectId: String) = radarProjectService.get().ensureProject(projectId) - override fun ensureSubject(projectId: String, userId: String) { + override suspend fun ensureSubject(projectId: String, userId: String) { radarProjectService.get().ensureSubject(projectId, userId) } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/RadarProjectService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/RadarProjectService.kt index 8efc1cd..e4eb376 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/RadarProjectService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/RadarProjectService.kt @@ -18,15 +18,13 @@ package org.radarbase.jersey.service.managementportal import org.radarbase.auth.authorization.Permission import org.radarbase.auth.authorization.Permission.PROJECT_READ -import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.exception.HttpNotFoundException import org.radarbase.jersey.service.ProjectService import org.radarbase.management.client.MPProject import org.radarbase.management.client.MPSubject - interface RadarProjectService : ProjectService { - override fun ensureProject(projectId: String) { + override suspend fun ensureProject(projectId: String) { project(projectId) } @@ -34,25 +32,25 @@ interface RadarProjectService : ProjectService { * Ensures that [projectId] exists in ManagementPortal. * @throws HttpNotFoundException if the project does not exist. */ - fun project(projectId: String): MPProject + suspend fun project(projectId: String): MPProject /** * Returns all ManagementPortal projects that the current user has access to. */ - fun userProjects(auth: Auth, permission: Permission = PROJECT_READ): List + suspend fun userProjects(permission: Permission = PROJECT_READ): List /** * Get project with [projectId] in ManagementPortal. * @throws HttpNotFoundException if the project does not exist. */ - fun projectSubjects(projectId: String): List + suspend fun projectSubjects(projectId: String): List /** * Get subject with [externalUserId] from [projectId] in ManagementPortal. * @throws HttpNotFoundException if the project does not exist. */ - fun subjectByExternalId(projectId: String, externalUserId: String): MPSubject? + suspend fun subjectByExternalId(projectId: String, externalUserId: String): MPSubject? /** Get a single subject from a project. */ - fun subject(projectId: String, userId: String): MPSubject? + suspend fun subject(projectId: String, userId: String): MPSubject? } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CacheConfig.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CacheConfig.kt deleted file mode 100644 index 4a768f0..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CacheConfig.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.radarbase.jersey.util - -import java.time.Duration - -data class CacheConfig( - /** Duration after which the cache is considered stale and should be refreshed. */ - val refreshDuration: Duration = Duration.ofMinutes(5), - /** Duration after which the cache may be refreshed if the cache does not fulfill a certain - * requirement. This should be shorter than [refreshDuration] to have effect. */ - val retryDuration: Duration = Duration.ofSeconds(30), - /** - * Time after the cache should have been refreshed, after which the cache is completely - * stale and can be removed. - */ - val staleThresholdDuration: Duration = Duration.ofMinutes(5), - /** - * Whether to store exceptions in cache. - * If true, after an exception is thrown during a state refresh, that exception will be - * rethrown on all calls until the next refresh occurs. If there is an exception, it will - * retry the call as soon as retryDuration has passed. - */ - val cacheExceptions: Boolean = true, - /** Time to wait for a lock to come free when an exception is set for the cache. */ - val exceptionLockDuration: Duration = Duration.ofSeconds(2), - /** - * Number of simultaneous computations that may occur. Increase if the time to computation - * is very variable. - */ - val maxSimultaneousCompute: Int = 1, -) { - val refreshNanos: Long = refreshDuration.toNanos() - val retryNanos: Long = retryDuration.toNanos() - val staleNanos: Long = refreshNanos + staleThresholdDuration.toNanos() - val exceptionLockNanos: Long = exceptionLockDuration.toNanos() - - init { - check(maxSimultaneousCompute > 0) { "Number of simultaneous computations must be at least 1" } - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedMap.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedMap.kt deleted file mode 100644 index 17f2673..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedMap.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2020 The Hyve - * - * 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. - */ - -package org.radarbase.jersey.util - -/** Set of data that is cached for a duration of time. */ -class CachedMap( - cacheConfig: CacheConfig = CacheConfig(), - supplier: () -> Map, -): CachedValue>(cacheConfig, supplier, ::emptyMap) { - /** Whether the cache contains [key]. If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. */ - operator fun contains(key: K): Boolean = state.test { key in it } - - /** - * Find a pair matching [predicate]. - * If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - * @return value if found and null otherwise - */ - fun find(predicate: (K, V) -> Boolean): Pair? = state.query( - { map -> - map.entries - .firstOrNull { (k, v) -> predicate(k, v) } - ?.toPair() - }, - { it != null }, - ) - - /** - * Find a pair matching [predicate]. - * If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - * @return value if found and null otherwise - */ - fun findValue(predicate: (V) -> Boolean): V? = state.query( - { it.values.firstOrNull(predicate) }, - { it != null }, - ) - - /** - * Get the value. - * If the cache is empty and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - */ - override fun get(): Map = get { it.isNotEmpty() } - - /** - * Get the value. - * If the cache is empty and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - */ - operator fun get(key: K): V? = state.query({ it[key] }, { it != null }) -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedSet.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedSet.kt deleted file mode 100644 index 09b3eef..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedSet.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2020 The Hyve - * - * 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. - */ - -package org.radarbase.jersey.util - -import java.time.Duration - -/** - * Set of data that is cached for a duration of time. - * - * @param supplier How to update the cache. - */ -class CachedSet( - cacheConfig: CacheConfig = CacheConfig(), - supplier: () -> Set, -): CachedValue>(cacheConfig, supplier, ::emptySet) { - - constructor( - refreshDuration: Duration, - retryDuration: Duration, - supplier: () -> Set, - ) : this( - CacheConfig( - refreshDuration = refreshDuration, - retryDuration = retryDuration, - ), - supplier, - ) - - /** Whether the cache contains [value]. If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. */ - operator fun contains(value: T): Boolean = state.test { value in it } - - /** - * Find a value matching [predicate]. - * If it does not contain the value and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - * @return value if found and null otherwise - */ - fun find(predicate: (T) -> Boolean): T? = state.query({ it.find(predicate) }, { it != null }) - - /** - * Get the value. - * If the cache is empty and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - */ - override fun get(): Set = get { it.isNotEmpty() } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt deleted file mode 100644 index 66f5824..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt +++ /dev/null @@ -1,201 +0,0 @@ -package org.radarbase.jersey.util - -import java.time.Duration -import java.util.concurrent.Semaphore -import java.util.concurrent.locks.Lock -import java.util.concurrent.locks.ReentrantReadWriteLock - -/** - * Cached value. This value is refreshed after a refresh period has elapsed, or when the - * cache returns an undesirable result and a retry period has elapsed. Using this class ensures - * that the underlying supplier is not taxed too much. Any exceptions during initialization will - * not be caught. - * @param initialValue Initial value of the cache. The value generated by this function is - * considered invalid, but it will still be returned if another thread - * is already computing the new value. - */ -open class CachedValue( - protected val cacheConfig: CacheConfig = CacheConfig(), - /** - * How to update the cache. If no initial value is given, this will - * be called as initialization. - */ - private val supplier: () -> T, - initialValue: (() -> T)? = null, -) { - private val refreshLock = ReentrantReadWriteLock() - private val readLock: Lock = refreshLock.readLock() - private val writeLock = refreshLock.writeLock() - private val computeSemaphore = Semaphore(cacheConfig.maxSimultaneousCompute) - - val exception: Exception? - get() = readLock.locked { _exception } - - val isStale: Boolean - get() = readLock.locked { - System.nanoTime() >= lastUpdateNanos + cacheConfig.staleNanos - } - - private var cache: T - private var lastUpdateNanos: Long - private var _exception: Exception? = null - - init { - if (initialValue != null) { - cache = initialValue() - lastUpdateNanos = Long.MIN_VALUE - } else { - cache = supplier() - lastUpdateNanos = System.nanoTime() - } - } - - val value: T - get() = readLock.locked { cache } - - protected val state: CacheState - get() = readLock.locked { CacheState(cache, lastUpdateNanos, _exception) } - - /** Force refresh of the cache. Use automatic refresh instead, if possible. */ - fun refresh(currentUpdateNanos: Long? = null): T = computeSemaphore.acquired { - doRefresh(currentUpdateNanos) - } - - /** Force refresh of the cache if it is not locked for writing. Use automatic refresh instead, if possible. */ - fun tryRefresh( - currentUpdateNanos: Long? = null, - tryLockNanos: Long? = null, - ): T? = computeSemaphore.tryAcquired(tryLockNanos) { - doRefresh(currentUpdateNanos) - } - - private fun doRefresh(currentUpdateNanos: Long?): T = try { - // Do not actually refresh if there was already a previous update - if (currentUpdateNanos != null) { - readLock.locked { - if (lastUpdateNanos > currentUpdateNanos) { - _exception?.let { throw it } - return cache - } - } - } - supplier() - .also { - writeLock.locked { - cache = it - lastUpdateNanos = System.nanoTime() - _exception = null - } - } - } catch (ex: Exception) { - if (cacheConfig.cacheExceptions) { - writeLock.locked { - val now = System.nanoTime() - // don't write exception if very recently the cache was - // updated by another thread - if (_exception == null && now >= lastUpdateNanos + cacheConfig.retryNanos) { - _exception = ex - lastUpdateNanos = now - } - } - } - throw ex - } - - open fun get(): T = state.get { true } - - /** - * Get the value. - * If the cache is empty and [CacheConfig.retryDuration] - * has passed since the last try, it will update the cache and try once more. - */ - fun get(validityPredicate: (T) -> Boolean): T = state.get(validityPredicate) - - fun query(method: (T) -> S, valueIsValid: (S) -> Boolean): S = state.query(method, valueIsValid) - - /** Immutable state at a point in time. */ - protected inner class CacheState( - /** Cached value. */ - val cache: T, - /** Time that the cache was updated. */ - val lastUpdateNanos: Long, - /** Cached exception. */ - val exception: Exception?, - ) { - val mustRefresh: Boolean - val mayRetry: Boolean - - init { - val now = System.nanoTime() - mustRefresh = now >= lastUpdateNanos + cacheConfig.refreshNanos - mayRetry = now >= lastUpdateNanos + cacheConfig.retryNanos - } - - /** - * Checks for exceptions and throws if necessary. If no exceptions are found, - * proceeds to call [application]. - */ - inline fun applyValidState(method: (T) -> S, application: () -> S): S { - return if (exception != null) { - if (mustRefresh || mayRetry) { - method(tryRefresh(lastUpdateNanos, cacheConfig.exceptionLockNanos) ?: throw exception) - } - else throw exception - } else application() - } - - inline fun shouldRetry( - valueIsValid: (R) -> Boolean, - value: R, - ): Boolean = mayRetry && !valueIsValid(value) - - /** - * Query the current state, applying [method]. If [valueIsValid] does not give a valid - * result, that is, it is false, recompute the value if [mayRetry] is true. If exceptions - * are cached as per [CacheConfig.cacheExceptions], this may return an exception from a - * previous call. - */ - inline fun query(method: (T) -> S, valueIsValid: (S) -> Boolean): S = applyValidState(method) { - if (mustRefresh) { - val value = tryRefresh(lastUpdateNanos) ?: cache - method(value) - } else { - val result = method(cache) - if (shouldRetry(valueIsValid, result)) { - tryRefresh(lastUpdateNanos) - ?.let { method(it) } - ?: result - } else result - } - } - - /** - * Get the current state. If [valueIsValid] does not give a valid - * result, that is, it is false, recompute the value if [mayRetry] is true. If exceptions - * are cached as per [CacheConfig.cacheExceptions], this may return an exception from a - * previous call. - */ - inline fun get(valueIsValid: (T) -> Boolean): T = applyValidState({ it }) { - if (mustRefresh || shouldRetry(valueIsValid, cache)) tryRefresh(lastUpdateNanos) ?: cache - else cache - } - - /** - * Test a predicate on the current state. If the result is false, recompute the value if - * [mayRetry] is true and run the predicate on that. If exceptions - * are cached as per [CacheConfig.cacheExceptions], this may return an exception from a - * previous call. - */ - inline fun test(predicate: (T) -> Boolean): Boolean = applyValidState(predicate) { - when { - mustRefresh -> predicate(tryRefresh(lastUpdateNanos) ?: cache) - predicate(cache) -> true - mayRetry -> { - val refreshed = tryRefresh(lastUpdateNanos) - refreshed != null && predicate(refreshed) - } - else -> false - } - } - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/LockExtensions.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/LockExtensions.kt deleted file mode 100644 index 03426f0..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/LockExtensions.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.radarbase.jersey.util - -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit -import java.util.concurrent.locks.Lock - -inline fun Lock.locked(method: () -> T): T { - lock() - return try { - method() - } finally { - unlock() - } -} - -inline fun Semaphore.tryAcquired( - nanos: Long? = null, - method: () -> T, -): T? { - val isAcquired = if (nanos != null) tryAcquire(nanos, TimeUnit.NANOSECONDS) else tryAcquire() - return if (isAcquired) { - try { - method() - } finally { - release() - } - } else null -} - -inline fun Semaphore.acquired(method: () -> T): T { - acquire() - return try { - method() - } finally { - release() - } -} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt deleted file mode 100644 index 1fe98de..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.radarbase.jersey.util - -import com.fasterxml.jackson.databind.ObjectReader -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.radarbase.jersey.exception.HttpBadGatewayException -import org.slf4j.LoggerFactory - -inline fun OkHttpClient.request(builder: Request.Builder.() -> Unit, callback: (Response) -> T): T = - newCall( - Request.Builder().apply(builder).build() - ).execute().use(callback) - -/** - * Make a [request] and resolves it as JSON using given object reader. [T] is the type that the - * [reader] is initialized with using [com.fasterxml.jackson.databind.ObjectMapper.readerFor]. - * @throws ClassCastException if the [reader] is not initialized for the correct class. - * @throws java.io.IOException if the request failed or the JSON cannot be parsed. - * @throws HttpBadGatewayException if the response had an unsuccessful HTTP status code. - */ -fun OkHttpClient.requestJson(request: Request, reader: ObjectReader): T { - return newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.body?.byteStream() - ?.let { reader.readValue(it) } - ?: throw HttpBadGatewayException("ManagementPortal did not provide a result") - } else { - logger.error("Cannot connect to {}: HTTP status {} - {}", request.url, response.code, response.body?.string()) - throw HttpBadGatewayException("Cannot connect to ${request.url}: HTTP status ${response.code}") - } - } -} - -/** - * Make a [request] and checks the HTTP status code of the response. - * @return true if the call returned a successful HTTP status code, false otherwise. - * @throws java.io.IOException if the request failed - */ -fun OkHttpClient.request(request: Request): Boolean { - return newCall(request).execute().use { response -> - response.isSuccessful - } -} - -private val logger = LoggerFactory.getLogger(OkHttpClient::class.java) diff --git a/radar-jersey/src/main/resources/log4j2.xml b/radar-jersey/src/main/resources/log4j2.xml index 02a551c..23f5842 100644 --- a/radar-jersey/src/main/resources/log4j2.xml +++ b/radar-jersey/src/main/resources/log4j2.xml @@ -15,7 +15,7 @@ - + diff --git a/radar-jersey/src/main/resources/logback.xml b/radar-jersey/src/main/resources/logback.xml index 3ae3656..c08040b 100644 --- a/radar-jersey/src/main/resources/logback.xml +++ b/radar-jersey/src/main/resources/logback.xml @@ -31,7 +31,7 @@ - + diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/OAuthHelper.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/OAuthHelper.kt index 45d57ea..2008af0 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/OAuthHelper.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/OAuthHelper.kt @@ -3,16 +3,16 @@ package org.radarbase.jersey.auth import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import okhttp3.Request +import org.radarbase.auth.authentication.StaticTokenVerifierLoader import org.radarbase.auth.authentication.TokenValidator import org.radarbase.auth.authorization.Permission -import org.radarbase.auth.config.TokenValidatorConfig -import java.net.URI +import org.radarbase.auth.jwks.JwksTokenVerifierLoader.Companion.toTokenVerifier import java.security.KeyStore import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPublicKey import java.time.Duration import java.time.Instant -import java.util.* +import java.util.Date /** * Created by dverbeec on 29/06/2017. @@ -29,43 +29,40 @@ class OAuthHelper { } // get the EC keypair for signing - val privateKey = ks.getKey(TEST_SIGNKEY_ALIAS, - TEST_KEYSTORE_PASSWORD.toCharArray()) as ECPrivateKey + val privateKey = ks.getKey( + TEST_SIGNKEY_ALIAS, + TEST_KEYSTORE_PASSWORD.toCharArray(), + ) as ECPrivateKey val cert = ks.getCertificate(TEST_SIGNKEY_ALIAS) val publicKey = cert.publicKey as ECPublicKey val ecdsa = Algorithm.ECDSA256(publicKey, privateKey) validEcToken = createValidToken(ecdsa) - tokenValidator = TokenValidator.Builder() - .verifiers(listOf(JWT.require(ecdsa).withIssuer(ISS).build())) - .config(object : TokenValidatorConfig { - override fun getPublicKeyEndpoints(): List = emptyList() - - override fun getResourceName(): String = ISS - }) - .build() + tokenValidator = TokenValidator( + listOf(StaticTokenVerifierLoader(listOf(ecdsa.toTokenVerifier("res_ManagementPortal")))), + ) } private fun createValidToken(algorithm: Algorithm): String { val now = Instant.now() val exp = now.plus(Duration.ofMinutes(30)) return JWT.create() - .withIssuer(ISS) - .withIssuedAt(Date.from(now)) - .withExpiresAt(Date.from(exp)) - .withAudience("res_ManagementPortal") - .withSubject(USER) - .withArrayClaim("scope", SCOPES) - .withArrayClaim("authorities", AUTHORITIES) - .withArrayClaim("roles", ROLES) - .withArrayClaim("sources", SOURCES) - .withArrayClaim("aud", AUD) - .withClaim("client_id", CLIENT) - .withClaim("user_name", USER) - .withClaim("jti", JTI) - .withClaim("grant_type", "password") - .sign(algorithm) + .withIssuer(ISS) + .withIssuedAt(Date.from(now)) + .withExpiresAt(Date.from(exp)) + .withAudience("res_ManagementPortal") + .withSubject(USER) + .withArrayClaim("scope", SCOPES) + .withArrayClaim("authorities", AUTHORITIES) + .withArrayClaim("roles", ROLES) + .withArrayClaim("sources", SOURCES) + .withArrayClaim("aud", AUD) + .withClaim("client_id", CLIENT) + .withClaim("user_name", USER) + .withClaim("jti", JTI) + .withClaim("grant_type", "password") + .sign(algorithm) } companion object { @@ -82,7 +79,8 @@ class OAuthHelper { private const val JTI = "some-jwt-id" fun Request.Builder.bearerHeader(oauth: OAuthHelper) = header( - "Authorization", "Bearer ${oauth.validEcToken}") + "Authorization", + "Bearer ${oauth.validEcToken}", + ) } } - diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt index 5d0d5a2..b0fd8fa 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt @@ -17,7 +17,11 @@ import okhttp3.Response import org.glassfish.grizzly.http.server.HttpServer import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -34,8 +38,9 @@ internal class RadarJerseyResourceEnhancerTest { @BeforeEach fun setUp() { val authConfig = AuthConfig( - managementPortal = MPConfig(url = "http://localhost:8080"), - jwtResourceName = "res_ManagementPortal") + managementPortal = MPConfig(url = "http://localhost:8080"), + jwtResourceName = "res_ManagementPortal", + ) val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig) @@ -54,7 +59,7 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun helperTest() { assertThat(oauthHelper, not(nullValue())) - val token = oauthHelper.tokenValidator.validateAccessToken(oauthHelper.validEcToken) + val token = oauthHelper.tokenValidator.validateBlocking(oauthHelper.validEcToken) assertThat(token, not(nullValue())) } @@ -79,16 +84,17 @@ internal class RadarJerseyResourceEnhancerTest { callback = { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("""{"accessToken":"${oauthHelper.validEcToken}"}""")) - } + }, ) } @Test fun testAuthenticatedGetDetailed() { - client.newCall(Request.Builder() - .url("http://localhost:9091/user/detailed") - .bearerHeader(oauthHelper) - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/user/detailed") + .bearerHeader(oauthHelper) + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("""{"accessToken":"${oauthHelper.validEcToken}","name":"name","createdAt":"1970-01-01T01:00:00Z"}""")) @@ -97,11 +103,12 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun testAuthenticatedPostDetailed() { - client.newCall(Request.Builder() - .url("http://localhost:9091/user") - .bearerHeader(oauthHelper) - .post("""{"accessToken":"${oauthHelper.validEcToken}","name":"name","createdAt":"1970-01-01T01:00:00Z"}""".toRequestBody("application/json".toMediaType())) - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/user") + .bearerHeader(oauthHelper) + .post("""{"accessToken":"${oauthHelper.validEcToken}","name":"name","createdAt":"1970-01-01T01:00:00Z"}""".toRequestBody("application/json".toMediaType())) + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(true)) assertThat(response.body?.string(), equalTo("""{"accessToken":"${oauthHelper.validEcToken}","name":"name","createdAt":"1970-01-01T01:00:00Z"}""")) @@ -110,23 +117,24 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun testAuthenticatedPostDetailedBadRequest() { - client.newCall(Request.Builder() - .url("http://localhost:9091/user") - .bearerHeader(oauthHelper) - .post("""{}""".toRequestBody("application/json".toMediaType())) - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/user") + .bearerHeader(oauthHelper) + .post("""{}""".toRequestBody("application/json".toMediaType())) + .build(), ).execute().use { response -> assertThat(response.code, `is`(400)) } } - @Test fun testUnauthenticatedGet() { - client.newCall(Request.Builder() - .url("http://localhost:9091/user") - .header("Accept", "application/json") - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/user") + .header("Accept", "application/json") + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(false)) assertThat(response.code, `is`(401)) @@ -136,10 +144,11 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun testUnauthenticatedGetNoAcceptHeader() { - client.newCall(Request.Builder() - .url("http://localhost:9091/user") - .header("Accept", "*/*") - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/user") + .header("Accept", "*/*") + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(false)) assertThat(response.code, `is`(401)) @@ -149,11 +158,12 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun testBadAuthenticationGet() { - client.newCall(Request.Builder() - .url("http://localhost:9091/user") - .header("Accept", "application/json") - .header("Authorization", "Bearer abcdef") - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/user") + .header("Accept", "application/json") + .header("Authorization", "Bearer abcdef") + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(false)) assertThat(response.code, `is`(401)) @@ -163,10 +173,11 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun testExistingGet() { - client.newCall(Request.Builder() - .url("http://localhost:9091/projects/a/users/b") - .bearerHeader(oauthHelper) - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/projects/a/users/b") + .bearerHeader(oauthHelper) + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(true)) @@ -176,11 +187,12 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun testNonExistingGet() { - client.newCall(Request.Builder() - .url("http://localhost:9091/projects/c/users/b") - .bearerHeader(oauthHelper) - .header("Accept", "application/json") - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/projects/c/users/b") + .bearerHeader(oauthHelper) + .header("Accept", "application/json") + .build(), ).execute().use { response -> assertThat(response.body?.string(), equalTo("{\"error\":\"project_not_found\",\"error_description\":\"Project c not found.\"}")) assertThat(response.isSuccessful, `is`(false)) @@ -190,11 +202,12 @@ internal class RadarJerseyResourceEnhancerTest { @Test fun testNonExistingGetHtml() { - client.newCall(Request.Builder() - .url("http://localhost:9091/projects/c/users/b") - .bearerHeader(oauthHelper) - .header("Accept", "text/html,application/json") - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/projects/c/users/b") + .bearerHeader(oauthHelper) + .header("Accept", "text/html,application/json") + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(false)) @@ -206,14 +219,14 @@ internal class RadarJerseyResourceEnhancerTest { } } - @Test fun testNonExistingGetBrowser() { - client.newCall(Request.Builder() - .url("http://localhost:9091/projects/c/users/b") - .bearerHeader(oauthHelper) - .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - .build() + client.newCall( + Request.Builder() + .url("http://localhost:9091/projects/c/users/b") + .bearerHeader(oauthHelper) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .build(), ).execute().use { response -> assertThat(response.isSuccessful, `is`(false)) @@ -231,7 +244,7 @@ internal class RadarJerseyResourceEnhancerTest { Request.Builder().apply { url("http://localhost:9091/exception") header("Accept", "application/json") - }.build() + }.build(), ).execute().use { response -> assertThat(response.code, `is`(500)) val body = response.body?.string() @@ -245,7 +258,7 @@ internal class RadarJerseyResourceEnhancerTest { Request.Builder().apply { url("http://localhost:9091/badrequest") header("Accept", "application/json") - }.build() + }.build(), ).execute().use { response -> assertThat(response.code, `is`(400)) val body = response.body?.string() @@ -259,7 +272,7 @@ internal class RadarJerseyResourceEnhancerTest { Request.Builder().apply { url("http://localhost:9091/jerseybadrequest") header("Accept", "application/json") - }.build() + }.build(), ).execute().use { response -> assertThat(response.code, `is`(400)) val body = response.body?.string() diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancerTest.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancerTest.kt index b9c7707..848b9a2 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancerTest.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancerTest.kt @@ -4,8 +4,8 @@ import okhttp3.OkHttpClient import org.glassfish.grizzly.http.server.HttpServer import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.`is` import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -21,7 +21,8 @@ internal class DisabledAuthorizationResourceEnhancerTest { @BeforeEach fun setUp() { val authConfig = AuthConfig( - jwtResourceName = "res_jerseyTest") + jwtResourceName = "res_jerseyTest", + ) val resources = ConfigLoader.loadResources(MockDisabledAuthResourceEnhancerFactory::class.java, authConfig) @@ -36,7 +37,6 @@ internal class DisabledAuthorizationResourceEnhancerTest { server.shutdown() } - @Test fun testBasicGet() { client.request({ @@ -47,7 +47,6 @@ internal class DisabledAuthorizationResourceEnhancerTest { } } - @Test fun testAuthenticatedGet() { client.request({ @@ -59,7 +58,6 @@ internal class DisabledAuthorizationResourceEnhancerTest { } } - @Test fun testExistingGet() { client.request({ @@ -70,7 +68,6 @@ internal class DisabledAuthorizationResourceEnhancerTest { } } - @Test fun testNonExistingGet() { client.request({ diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/doc/config/SwaggerResourceEnhancerTest.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/doc/config/SwaggerResourceEnhancerTest.kt index ee4d144..1caff38 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/doc/config/SwaggerResourceEnhancerTest.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/doc/config/SwaggerResourceEnhancerTest.kt @@ -4,7 +4,9 @@ import okhttp3.OkHttpClient import org.glassfish.grizzly.http.server.HttpServer import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* +import org.hamcrest.Matchers.emptyOrNullString +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.not import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -21,7 +23,8 @@ class SwaggerResourceEnhancerTest { @BeforeEach fun setUp() { val authConfig = AuthConfig( - jwtResourceName = "res_jerseyTest") + jwtResourceName = "res_jerseyTest", + ) val resources = ConfigLoader.loadResources(MockSwaggerResourceEnhancerFactory::class.java, authConfig) @@ -36,7 +39,6 @@ class SwaggerResourceEnhancerTest { server.shutdown() } - @Test fun retrieveOpenApiJson() { client.request({ diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancerTest.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancerTest.kt index 02fd35b..497ef7a 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancerTest.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/enhancer/MapperResourceEnhancerTest.kt @@ -24,14 +24,16 @@ internal class MapperResourceEnhancerTest { val enhancer = MapperResourceEnhancer() val resourceConfig = mock() enhancer.enhanceResources(resourceConfig) - verify(resourceConfig).register(check { obj -> - val context = obj as ContextResolver<*> - val mapper = context.getContext(ObjectMapper::class.java) as ObjectMapper - assertThat(mapper.writeValueAsString(InstantWrapper()), equalTo("""{"date":"1970-01-01T01:00:00Z"}""")) - }) + verify(resourceConfig).register( + check { obj -> + val context = obj as ContextResolver<*> + val mapper = context.getContext(ObjectMapper::class.java) as ObjectMapper + assertThat(mapper.writeValueAsString(InstantWrapper()), equalTo("""{"date":"1970-01-01T01:00:00Z"}""")) + }, + ) } data class InstantWrapper( - val date: Instant = Instant.ofEpochSecond(3600) + val date: Instant = Instant.ofEpochSecond(3600), ) } diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockDisabledAuthResourceEnhancerFactory.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockDisabledAuthResourceEnhancerFactory.kt index 1897b2b..caae1bc 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockDisabledAuthResourceEnhancerFactory.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockDisabledAuthResourceEnhancerFactory.kt @@ -7,9 +7,9 @@ import org.radarbase.jersey.enhancer.JerseyResourceEnhancer class MockDisabledAuthResourceEnhancerFactory(private val config: AuthConfig) : EnhancerFactory { override fun createEnhancers(): List = listOf( - MockResourceEnhancer(), - Enhancers.radar(config), - Enhancers.disabledAuthorization, - Enhancers.exception, + MockResourceEnhancer(), + Enhancers.radar(config), + Enhancers.disabledAuthorization, + Enhancers.exception, ) } diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt index 88f91ca..36f51d9 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt @@ -15,27 +15,27 @@ import org.radarbase.jersey.service.ProjectService class MockProjectService( private val projects: Map>, ) : ProjectService { - override fun ensureOrganization(organizationId: String) { + override suspend fun ensureOrganization(organizationId: String) { if (organizationId !in projects) { throw HttpNotFoundException("organization_not_found", "Project $organizationId not found.") } } - override fun listProjects(organizationId: String): List = projects[organizationId] + override suspend fun listProjects(organizationId: String): List = projects[organizationId] ?: throw HttpNotFoundException("organization_not_found", "Project $organizationId not found.") - override fun projectOrganization(projectId: String): String = projects.entries + override suspend fun projectOrganization(projectId: String): String = projects.entries .firstOrNull { (_, ps) -> projectId in ps } ?.key ?: throw HttpNotFoundException("project_not_found", "Project $projectId not found.") - override fun ensureProject(projectId: String) { + override suspend fun ensureProject(projectId: String) { if (projects.values.none { projectId in it }) { throw HttpNotFoundException("project_not_found", "Project $projectId not found.") } } - override fun ensureSubject(projectId: String, userId: String) { + override suspend fun ensureSubject(projectId: String, userId: String) { ensureProject(projectId) } } diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt index 98efaa1..185bd83 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt @@ -10,10 +10,12 @@ import org.radarbase.jersey.service.ProjectService class MockResourceEnhancer : JerseyResourceEnhancer { override val classes: Array> = arrayOf( - Filters.logResponse) + Filters.logResponse, + ) override val packages: Array = arrayOf( - "org.radarbase.jersey.mock.resource") + "org.radarbase.jersey.mock.resource", + ) override fun AbstractBinder.enhance() { bind(MockProjectService(mapOf("main" to listOf("a", "b")))) diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt index 4bca52a..1a3da40 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt @@ -6,7 +6,7 @@ import org.radarbase.jersey.enhancer.Enhancers import org.radarbase.jersey.enhancer.JerseyResourceEnhancer class MockResourceEnhancerFactory( - private val config: AuthConfig + private val config: AuthConfig, ) : EnhancerFactory { override fun createEnhancers(): List = listOf( MockResourceEnhancer(), diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockSwaggerResourceEnhancerFactory.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockSwaggerResourceEnhancerFactory.kt index fd292fe..7a5a602 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockSwaggerResourceEnhancerFactory.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/MockSwaggerResourceEnhancerFactory.kt @@ -7,7 +7,7 @@ import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.enhancer.EnhancerFactory import org.radarbase.jersey.enhancer.Enhancers import org.radarbase.jersey.enhancer.JerseyResourceEnhancer -import java.util.* +import java.util.Properties class MockSwaggerResourceEnhancerFactory(private val config: AuthConfig) : EnhancerFactory { override fun createEnhancers(): List { @@ -18,17 +18,20 @@ class MockSwaggerResourceEnhancerFactory(private val config: AuthConfig) : Enhan MockResourceEnhancer(), Enhancers.radar(config), Enhancers.disabledAuthorization, - Enhancers.swagger(OpenAPI().apply { - info = Info().apply { - version = properties.getProperty("version") - description = "MockProject" - license = License().apply { - name = "Apache-2.0" + Enhancers.swagger( + OpenAPI().apply { + info = Info().apply { + version = properties.getProperty("version") + description = "MockProject" + license = License().apply { + name = "Apache-2.0" + } } - } - }, setOf( - "/health", - )), + }, + setOf( + "/health", + ), + ), ) } } diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/resource/MockResource.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/resource/MockResource.kt index 5553d27..ad1292a 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/resource/MockResource.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/resource/MockResource.kt @@ -13,11 +13,17 @@ import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.annotation.Resource -import jakarta.ws.rs.* +import jakarta.ws.rs.BadRequestException +import jakarta.ws.rs.Consumes +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.MediaType import org.radarbase.auth.authorization.Permission -import org.radarbase.jersey.auth.Auth +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.Authenticated import org.radarbase.jersey.auth.NeedsPermission import org.radarbase.jersey.exception.HttpBadRequestException @@ -37,15 +43,15 @@ class MockResource { @Authenticated @GET @Path("user") - fun someUser(@Context auth: Auth): Map { - return mapOf("accessToken" to auth.token.token) + fun someUser(@Context radarToken: RadarToken): Map { + return mapOf("accessToken" to (radarToken.token ?: "")) } @Authenticated @GET @Path("user/detailed") - fun someUserDetailed(@Context auth: Auth): DetailedUser { - return DetailedUser(auth.token.token, "name") + fun someUserDetailed(@Context radarToken: RadarToken): DetailedUser { + return DetailedUser((radarToken.token ?: ""), "name") } @Authenticated @@ -53,12 +59,15 @@ class MockResource { @Path("projects/{projectId}/users/{subjectId}") @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") @Operation(description = "Get user that is subject in given project") - @ApiResponses(value = [ - ApiResponse(description = "User") - ]) + @ApiResponses( + value = [ + ApiResponse(description = "User"), + ], + ) fun mySubject( - @PathParam("projectId") projectId: String, - @PathParam("subjectId") userId: String): Map { + @PathParam("projectId") projectId: String, + @PathParam("subjectId") userId: String, + ): Map { return mapOf("projectId" to projectId, "userId" to userId) } diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/CachedValueTest.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/CachedValueTest.kt deleted file mode 100644 index 68ee540..0000000 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/CachedValueTest.kt +++ /dev/null @@ -1,241 +0,0 @@ -package org.radarbase.jersey.util - -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.* -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import java.time.Duration - -internal class CachedValueTest { - @Volatile - var calls: Int = 0 - - @BeforeEach - fun setUp() { - calls = 0 - } - - @Test - fun isStale() { - val cache = CachedValue( - CacheConfig( - refreshDuration = Duration.ofMillis(10), - staleThresholdDuration = Duration.ofMillis(10), - ), supplier = { listOf("something") }) - assertThat("Initial value from supplier is not stale", cache.isStale, `is`(false)) - cache.get() - assertThat("After get, cache is not stale", cache.isStale, `is`(false)) - Thread.sleep(10) - assertThat("After refresh duration, cache is not stale", cache.isStale, `is`(false)) - Thread.sleep(10) - assertThat("After refresh + stale duration, cache is stale", cache.isStale, `is`(true)) - } - - @Test - fun isStaleInitial() { - val cache = CachedValue( - CacheConfig( - refreshDuration = Duration.ofMillis(10), - staleThresholdDuration = Duration.ofMillis(10), - ), supplier = { listOf("something") }, { emptyList() }) - assertThat("Initial value from initialValue is stale", cache.isStale, `is`(true)) - cache.get() - assertThat("After get, cache is not stale", cache.isStale, `is`(false)) - } - - @Test - fun get() { - val cache = CachedValue( - CacheConfig( - refreshDuration = Duration.ofMillis(10), - ), - supplier = { calls += 1; calls }, - initialValue = { calls }) - assertThat("Initial value should refresh", cache.get(), `is`(1)) - assertThat("No refresh within threshold", cache.get(), `is`(1)) - Thread.sleep(10) - assertThat("Refresh after threshold", cache.get(), `is`(2)) - assertThat("No refresh after threshold", cache.get(), `is`(2)) - } - - - @Test - fun getInvalid() { - val cache = CachedValue( - CacheConfig( - retryDuration = Duration.ofMillis(10), - ), - supplier = { calls += 1; calls }, - initialValue = { calls }) - assertThat("Initial value should refresh", cache.get { it < 0 }, `is`(1)) - assertThat("No refresh within threshold", cache.get { it < 0 }, `is`(1)) - Thread.sleep(10) - assertThat("Refresh after threshold", cache.get { it < 0 }, `is`(2)) - assertThat("No refresh after threshold", cache.get { it < 0 }, `is`(2)) - } - - @Test - fun getValid() { - var calls = 0 - val cache = CachedValue( - CacheConfig( - retryDuration = Duration.ofMillis(10), - ), - supplier = { calls += 1; calls }, - initialValue = { calls }) - assertThat("Initial value should refresh", cache.get { it >= 0 }, `is`(1)) - assertThat("No refresh within threshold", cache.get { it >= 0 }, `is`(1)) - Thread.sleep(10) - assertThat("No refresh after valid value", cache.get { it >= 0 }, `is`(1)) - } - - @Test - fun getValue() { - val cache = CachedValue( - supplier = { calls += 1; calls }, - initialValue = { calls }) - assertThat("Initial value is initialValue", cache.value, `is`(0)) - assertThat("Initial get calls supplier", cache.get(), `is`(1)) - assertThat("Cache does not change by calling value", cache.value, `is`(1)) - } - - @Test - fun refresh() { - val cache = CachedValue( - cacheConfig = CacheConfig( - refreshDuration = Duration.ofMillis(10), - ), - supplier = { calls += 1; calls }, - initialValue = { calls }, - ) - assertThat("Initial get calls supplier", cache.get(), `is`(1)) - assertThat("Next get uses cache", cache.get(), `is`(1)) - assertThat("Refresh gets new value", cache.refresh(), `is`(2)) - assertThat("Next get uses cache", cache.get(), `is`(2)) - } - - @Test - fun query() { - val cache = CachedValue( - cacheConfig = CacheConfig( - refreshDuration = Duration.ofMillis(20), - retryDuration = Duration.ofMillis(10), - ), - supplier = { calls += 1; calls }, - initialValue = { calls }, - ) - assertThat("Initial value should refresh", cache.query({ it + 1 }, { it > 2 }), `is`(2)) - assertThat("No refresh within threshold", cache.query({ it + 1 }, { it > 2 }), `is`(2)) - Thread.sleep(10) - assertThat("Retry because predicate does not match", cache.query({ it + 1 }, { it > 2 }), `is`(3)) - assertThat("No refresh within threshold", cache.query({ it + 1 }, { it > 2 }), `is`(3)) - Thread.sleep(10) - assertThat("No retry because predicate matches", cache.query({ it + 1 }, { it > 2 }), `is`(3)) - Thread.sleep(10) - assertThat("Refresh after refresh threshold since last retry", cache.query({ it + 1 }, { it > 2 }), `is`(4)) - } - - - @Test - fun getMultithreaded() { - val cache = CachedValue( - cacheConfig = CacheConfig( - refreshDuration = Duration.ofMillis(20), - retryDuration = Duration.ofMillis(10), - ), - supplier = { - Thread.sleep(50L) - calls += 1 - calls - }, - initialValue = { calls }, - ) - - var exception: Throwable? = null - - val thread = Thread { - try { - Thread.sleep(20L) - assertThat("Get initial value while computation is ongoing", cache.get(), `is`(0)) - Thread.sleep(70L) - assertThat("Get new update on refresh", cache.get(), `is`(2)) - } catch (ex: Throwable) { - exception = ex - } - } - thread.start() - assertThat("Initial value should refresh", cache.get(), `is`(1)) - thread.join() - exception?.let { throw it } - } - - @Test - fun getMulti3threaded() { - val cache = CachedValue( - cacheConfig = CacheConfig( - refreshDuration = Duration.ofMillis(20), - retryDuration = Duration.ofMillis(10), - maxSimultaneousCompute = 2, - ), - supplier = { - Thread.sleep(50L) - calls += 1 - calls - }, - initialValue = { calls }, - ) - - var exception: Throwable? = null - - val thread1 = Thread { - try { - Thread.sleep(20L) - assertThat("Also compute while computation is ongoing", cache.get(), `is`(2)) - Thread.sleep(70L) - assertThat("Get new update on refresh", cache.get(), `is`(4)) - } catch (ex: Throwable) { - exception = ex - } - } - val thread2 = Thread { - try { - Thread.sleep(40L) - assertThat("Get initial value while computation is ongoing", cache.get(), `is`(0)) - Thread.sleep(70L) - assertThat("Get new update on refresh", cache.get(), `is`(3)) - } catch (ex: Throwable) { - exception = ex - } - } - thread1.start() - thread2.start() - assertThat("Initial value should refresh", cache.get(), `is`(1)) - thread1.join() - thread2.join() - exception?.let { throw it } - } - - - @Test - fun throwTest() { - val cache = CachedValue( - cacheConfig = CacheConfig( - refreshDuration = Duration.ofMillis(20), - retryDuration = Duration.ofMillis(10), - ), - supplier = { calls += 1; if (calls % 2 == 0) throw IllegalStateException() else calls }, - initialValue = { calls }, - ) - - assertThat(cache.get(), `is`(1)) - assertThat(cache.get(), `is`(1)) - Thread.sleep(21L) - assertThrows { cache.get() } - assertThat(cache.exception, not(nullValue())) - assertThat(cache.exception, instanceOf(IllegalStateException::class.java)) - assertThrows { cache.get() } - Thread.sleep(11L) - assertThat(cache.get(), `is`(3)) - } -} diff --git a/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt new file mode 100644 index 0000000..43e8903 --- /dev/null +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt @@ -0,0 +1,21 @@ +package org.radarbase.jersey.util + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +inline fun OkHttpClient.request(builder: Request.Builder.() -> Unit, callback: (Response) -> T): T = + newCall( + Request.Builder().apply(builder).build(), + ).execute().use(callback) + +/** + * Make a [request] and checks the HTTP status code of the response. + * @return true if the call returned a successful HTTP status code, false otherwise. + * @throws java.io.IOException if the request failed + */ +fun OkHttpClient.request(request: Request): Boolean { + return newCall(request).execute().use { response -> + response.isSuccessful + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index cac26e1..b29b10d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,16 +4,8 @@ include("radar-jersey") include("radar-jersey-hibernate") pluginManagement { - val kotlinVersion: String by settings - val dokkaVersion: String by settings - repositories { gradlePluginPortal() mavenCentral() } - - plugins { - kotlin("jvm") version kotlinVersion - id("org.jetbrains.dokka") version dokkaVersion - } }