From 2b01bca8f8777e20610fa0e971a28b858759eca2 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 7 Feb 2023 15:27:36 +0100 Subject: [PATCH 01/36] Replace okhttp with ktor --- radar-jersey/build.gradle.kts | 6 +-- .../org/radarbase/jersey/auth/AuthConfig.kt | 7 --- .../radarbase/jersey/enhancer/Enhancers.kt | 7 +-- .../jersey/enhancer/OkHttpResourceEnhancer.kt | 38 --------------- .../enhancer/RadarJerseyResourceEnhancer.kt | 4 -- .../managementportal/MPClientFactory.kt | 30 ++++++------ .../managementportal/MPProjectService.kt | 23 ++++++---- .../radarbase/jersey/util/OkHttpExtensions.kt | 46 ------------------- .../radarbase/jersey/util/OkHttpExtensions.kt | 21 +++++++++ 9 files changed, 58 insertions(+), 124 deletions(-) delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/OkHttpResourceEnhancer.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt create mode 100644 radar-jersey/src/test/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt diff --git a/radar-jersey/build.gradle.kts b/radar-jersey/build.gradle.kts index db5747e..6d96835 100644 --- a/radar-jersey/build.gradle.kts +++ b/radar-jersey/build.gradle.kts @@ -36,9 +36,6 @@ dependencies { 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("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") @@ -63,6 +60,9 @@ dependencies { runtimeOnly("org.glassfish.jaxb:jaxb-runtime:$jakartaJaxbRuntimeVersion") runtimeOnly("jakarta.activation:jakarta.activation-api:$jakartaActivation") + val okhttpVersion: String by project + testImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") + val grizzlyVersion: String by project testRuntimeOnly("org.glassfish.grizzly:grizzly-http-server:$grizzlyVersion") testRuntimeOnly("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:$jerseyVersion") 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..9ff5c77 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 @@ -51,13 +51,6 @@ data class MPConfig( /** 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) 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..514a8d4 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 @@ -12,9 +12,8 @@ object Enhancers { /** Adds authorization framework, configuration and utilities. */ fun radar( config: AuthConfig, - includeMapper: Boolean = true, - includeHttpClient: Boolean = true, - ) = RadarJerseyResourceEnhancer(config, includeMapper = includeMapper, includeHttpClient = includeHttpClient) + includeMapper: Boolean = true + ) = RadarJerseyResourceEnhancer(config, includeMapper = includeMapper) /** Authorization via ManagementPortal. */ fun managementPortal(config: AuthConfig) = ManagementPortalResourceEnhancer(config) /** Disable all authorization. Useful for a public service. */ @@ -28,8 +27,6 @@ object Enhancers { * @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() /** 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..2118b3e 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 @@ -30,13 +30,11 @@ import org.radarbase.jersey.auth.jwt.AuthFactory class RadarJerseyResourceEnhancer( private val config: AuthConfig, includeMapper: Boolean = true, - includeHttpClient: Boolean = true, ): 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 +44,6 @@ class RadarJerseyResourceEnhancer( override fun ResourceConfig.enhance() { register(JacksonFeature.withoutExceptionMappers()) - okHttpResourceEnhancer?.enhanceResources(this) mapperResourceEnhancer?.enhanceResources(this) } @@ -62,7 +59,6 @@ class RadarJerseyResourceEnhancer( .to(Auth::class.java) .`in`(RequestScoped::class.java) - okHttpResourceEnhancer?.enhanceBinder(this) mapperResourceEnhancer?.enhanceBinder(this) } } 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..b61ba84 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,28 @@ 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.management.auth.ClientCredentialsConfig +import org.radarbase.management.auth.clientCredentials import org.radarbase.management.client.MPClient +import org.radarbase.management.client.mpClient 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 { + val mpUrl = requireNotNull(authConfig.managementPortal.url) { "ManagementPortal client needs a URL" } + .trimEnd('/') + '/' + auth { + clientCredentials( + ClientCredentialsConfig( + tokenUrl = "$mpUrl/oauth/token", + clientId = authConfig.managementPortal.clientId, + clientSecret = authConfig.managementPortal.clientSecret, + ).copyWithEnv() + ) + } + url = mpUrl + } } 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..47bfab0 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 @@ -17,6 +17,7 @@ package org.radarbase.jersey.service.managementportal import jakarta.ws.rs.core.Context +import kotlinx.coroutines.* import org.radarbase.auth.authorization.Permission import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.AuthConfig @@ -47,15 +48,19 @@ class MPProjectService( ) organizations = CachedMap(cacheConfig) { - mpClient.requestOrganizations() - .associateBy { it.id } - .also { logger.debug("Fetched organizations {}", it) } + runBlocking { + mpClient.requestOrganizations() + .associateBy { it.id } + .also { logger.debug("Fetched organizations {}", it) } + } } projects = CachedMap(cacheConfig) { - mpClient.requestProjects() - .associateBy { it.id } - .also { logger.debug("Fetched projects {}", it) } + runBlocking { + mpClient.requestProjects() + .associateBy { it.id } + .also { logger.debug("Fetched projects {}", it) } + } } } @@ -114,8 +119,10 @@ class MPProjectService( CachedMap(CacheConfig( refreshDuration = config.managementPortal.syncParticipantsInterval, retryDuration = RETRY_INTERVAL)) { - mpClient.requestSubjects(projectId) - .associateBy { checkNotNull(it.id) } + runBlocking { + mpClient.requestSubjects(projectId) + .associateBy { checkNotNull(it.id) } + } } } 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/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..0493513 --- /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 + } +} From fa147103a4f5522dbc69ae2f4f88f11bd69ae2f6 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 8 Feb 2023 10:46:14 +0100 Subject: [PATCH 02/36] Update MPClient --- .../managementportal/MPClientFactory.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) 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 b61ba84..be83969 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 @@ -6,23 +6,33 @@ import org.radarbase.management.auth.ClientCredentialsConfig import org.radarbase.management.auth.clientCredentials import org.radarbase.management.client.MPClient import org.radarbase.management.client.mpClient +import org.slf4j.LoggerFactory +import java.net.URL import java.util.function.Supplier class MPClientFactory( @Context private val authConfig: AuthConfig, ) : Supplier { override fun get(): MPClient = mpClient { - val mpUrl = requireNotNull(authConfig.managementPortal.url) { "ManagementPortal client needs a URL" } - .trimEnd('/') + '/' + 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 {}", authConfig) + clientCredentials( - ClientCredentialsConfig( - tokenUrl = "$mpUrl/oauth/token", - clientId = authConfig.managementPortal.clientId, - clientSecret = authConfig.managementPortal.clientSecret, - ).copyWithEnv() + authConfig = authConfig, + targetHost = URL(url).host ) } - url = mpUrl + } + + companion object { + private val logger = LoggerFactory.getLogger(MPClientFactory::class.java) } } From 6f1cdfb44cc0f4f58bfae55eec08754a1c60b68f Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 22 Feb 2023 16:12:31 +0100 Subject: [PATCH 03/36] Use updated radar-auth library --- gradle.properties | 2 +- .../jersey/hibernate/DatabaseHealthMetrics.kt | 17 +- .../hibernate/DatabaseInitialization.kt | 5 +- .../hibernate/DatabaseHealthMetricsTest.kt | 13 +- .../kotlin/org/radarbase/jersey/auth/Auth.kt | 205 --------------- .../org/radarbase/jersey/auth/AuthConfig.kt | 3 +- .../org/radarbase/jersey/auth/AuthService.kt | 235 ++++++++++++++++++ .../radarbase/jersey/auth/AuthValidator.kt | 3 +- .../radarbase/jersey/auth/NeedsPermission.kt | 7 +- .../jersey/auth/disabled/DisabledAuth.kt | 80 ------ .../auth/disabled/DisabledAuthValidator.kt | 13 +- .../disabled/DisabledAuthorizationOracle.kt | 22 ++ .../DisabledAuthorizationResourceEnhancer.kt | 5 + .../auth/filter/AuthenticationFilter.kt | 1 - .../jersey/auth/filter/PermissionFilter.kt | 92 +------ .../auth/filter/RadarSecurityContext.kt | 29 ++- .../auth/jwt/AuthorizationOracleFactory.kt | 20 ++ .../jersey/auth/jwt/EcdsaJwtTokenValidator.kt | 106 ++------ .../jersey/auth/jwt/EcdsaResourceEnhancer.kt | 10 + .../org/radarbase/jersey/auth/jwt/JwtAuth.kt | 49 ---- .../{AuthFactory.kt => RadarTokenFactory.kt} | 8 +- .../jersey/auth/jwt/TokenValidatorFactory.kt | 104 ++++++++ .../managementportal/ManagementPortalAuth.kt | 35 --- .../ManagementPortalResourceEnhancer.kt | 7 + .../ManagementPortalTokenValidator.kt | 30 +-- .../managementportal/TokenValidatorFactory.kt | 31 --- .../radarbase/jersey/coroutines/Coroutines.kt | 33 +++ .../enhancer/RadarJerseyResourceEnhancer.kt | 12 +- .../jersey/exception/HttpTimeoutException.kt | 13 + .../jersey/resource/HealthResource.kt | 7 +- .../radarbase/jersey/service/HealthService.kt | 14 +- .../jersey/service/ImmediateHealthService.kt | 33 ++- .../managementportal/MPProjectService.kt | 14 +- .../managementportal/RadarProjectService.kt | 2 +- .../org/radarbase/jersey/util/CachedValue.kt | 1 - .../org/radarbase/jersey/util/Extensions.kt | 67 +++++ .../org/radarbase/jersey/auth/OAuthHelper.kt | 14 +- .../auth/RadarJerseyResourceEnhancerTest.kt | 7 +- .../jersey/mock/resource/MockResource.kt | 14 +- .../radarbase/jersey/util/ExtensionsKtTest.kt | 16 ++ 40 files changed, 713 insertions(+), 666 deletions(-) create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuth.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationOracle.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthorizationOracleFactory.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt rename radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/{AuthFactory.kt => RadarTokenFactory.kt} (86%) create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/TokenValidatorFactory.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/ManagementPortalAuth.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpTimeoutException.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/Extensions.kt create mode 100644 radar-jersey/src/test/kotlin/org/radarbase/jersey/util/ExtensionsKtTest.kt diff --git a/gradle.properties b/gradle.properties index ae7fd8c..4017eb7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ hamcrestVersion=2.2 mockitoKotlinVersion=4.0.0 hk2Version=3.0.3 -managementPortalVersion=0.9.0-SNAPSHOT +managementPortalVersion=0.10.1-SNAPSHOT javaJwtVersion=4.2.1 jakartaWsRsVersion=3.1.0 jakartaAnnotationVersion=2.1.1 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..552c98f 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 @@ -9,6 +9,7 @@ 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 org.slf4j.LoggerFactory import java.time.Duration class DatabaseHealthMetrics( @@ -23,16 +24,22 @@ class DatabaseHealthMetrics( ::testConnection, ) - override val status: HealthService.Status - get() = cachedStatus.get { it == HealthService.Status.UP } + override fun computeStatus(): HealthService.Status = + cachedStatus.get { it == HealthService.Status.UP } + .also { logger.info("Returning status {}", it) } - override val metrics: Any - get() = mapOf("status" to status) + override fun computeMetrics(): Map = mapOf("status" to computeStatus()) private fun testConnection(): HealthService.Status = try { - entityManager.get().useConnection { connection -> connection.close() } + entityManager.get().useConnection { } + logger.info("Database UP") HealthService.Status.UP } catch (ex: Throwable) { + logger.info("Database DOWN") HealthService.Status.DOWN } + + companion object { + private val logger = LoggerFactory.getLogger(DatabaseHealthMetrics::class.java) + } } 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..0f7c330 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 @@ -61,7 +61,10 @@ class DatabaseInitialization( @Throws(HibernateException::class) fun EntityManager.useConnection(work: (Connection) -> Unit) { check(this is Session) { "Cannot use connection of EntityManager that is not a Hibernate Session" } - doWork(work) + doWork { connection -> + work(connection) + connection.close() + } } } } 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..605eb71 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 @@ -73,13 +73,14 @@ internal class DatabaseHealthMetricsTest { 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) 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 index 8407a5c..016f631 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Auth.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Auth.kt @@ -9,14 +9,7 @@ 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. */ @@ -32,206 +25,8 @@ interface Auth { 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 9ff5c77..fa217d2 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,8 +10,6 @@ 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 @@ -33,6 +31,7 @@ data class AuthConfig( 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) } 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..dfe233e --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -0,0 +1,235 @@ +package org.radarbase.jersey.auth + +import jakarta.inject.Provider +import jakarta.ws.rs.core.Context +import org.radarbase.auth.authorization.* +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.ProjectService +import org.slf4j.LoggerFactory + +class AuthService( + @Context private val oracle: AuthorizationOracle, + @Context private val tokenProvider: Provider, + @Context private val projectService: ProjectService, +) { + private val token: RadarToken + get() = try { + 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, location: String? = null) { + if (!oracle.hasScope(token, permission)) { + throw forbiddenException( + permission = permission, + location = location, + ) + } + logAuthorized(permission, 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, + location: String? = null, + builder: EntityDetails.() -> Unit, + ): EntityDetails { + if (!oracle.hasScope(token, permission)) { + throw forbiddenException( + permission = permission, + location = location, + ) + } + val entity = EntityDetails().apply(builder) + if (entity.minimumEntityOrNull() == null) { + logAuthorized(permission, location) + } else { + checkPermission(permission, entity, location, permission.entity) + } + return entity + } + + fun hasPermission( + permission: Permission, + entity: EntityDetails + ) = oracle.hasPermission(token, 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 checkPermission( + permission: Permission, + entity: EntityDetails, + location: String? = null, + scope: Permission.Entity = permission.entity, + ) { + entity.resolve() + if ( + !oracle.hasPermission( + token, + permission, + entity, + scope, + ) + ) { + throw forbiddenException( + permission = permission, + location = location, + entity, + ) + } + + logAuthorized( + permission = permission, + location = location, + entity = entity, + ) + } + + private 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) + } + } + } + + fun forbiddenException( + permission: Permission, + location: String? = null, + entityDetails: EntityDetails? = null, + ): HttpForbiddenException { + val message = logPermission( + false, + permission, + location, + entityDetails, + ) + return HttpForbiddenException( + "permission_mismatch", + message, + wwwAuthenticateHeader = HttpUnauthorizedException.wwwAuthenticateHeader( + error = "insufficient_scope", + errorDescription = message, + scope = permission.toString() + ), + ) + } + + fun logAuthorized( + permission: Permission, + location: String? = null, + entity: EntityDetails? = null, + ) = logPermission(true, permission, location, entity) + + private fun logPermission( + isAuthorized: Boolean, + permission: Permission, + 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("organization: $it") } + entity.project?.let { add("project: $it") } + entity.subject?.let { add("subject: $it") } + entity.source?.let { add("source: $it") } + entity.user?.let { add("user: $it") } + }.joinTo(this, separator = ", ", prefix = "{", postfix = "}") + } + } + } + logger.info(message) + return message + } + + fun referentsByScope(permission: Permission): AuthorityReferenceSet { + val token = token ?: return AuthorityReferenceSet() + return oracle.referentsByScope(token, permission) + } + + fun mayBeGranted(role: RoleAuthority, permission: Permission): Boolean = with(oracle) { + role.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("logPermission") + || startsWith("checkPermission") + || startsWith("invoke") + || startsWith("internal") + + private val Class<*>.isAuthClass: Boolean + get() = isInstance(AuthService::class.java) + || isAnonymousClass + || isLocalClass + || 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..9de3a93 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,11 +11,12 @@ 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") 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..6de7704 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..e0d297a 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,24 @@ package org.radarbase.jersey.auth.disabled import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context +import org.radarbase.auth.token.DataRadarToken +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.Auth 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 ) : 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..4882076 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthorizationOracle.kt @@ -0,0 +1,22 @@ +package org.radarbase.jersey.auth.disabled + +import org.radarbase.auth.authorization.* +import org.radarbase.auth.token.RadarToken + +class DisabledAuthorizationOracle : AuthorizationOracle { + override 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..05f2093 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 @@ -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/PermissionFilter.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt index faf921a..3a9da19 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,10 +14,14 @@ 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.AuthorizationOracle +import org.radarbase.auth.authorization.EntityDetails 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.auth.disabled.DisabledAuthorizationOracle import org.radarbase.jersey.exception.HttpNotFoundException import org.radarbase.jersey.service.ProjectService @@ -26,98 +30,24 @@ import org.radarbase.jersey.service.ProjectService */ 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, + 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 - ) + organization = annotation.organizationPathParam.fetchPathParam() + project = annotation.projectPathParam.fetchPathParam() + subject = annotation.userPathParam.fetchPathParam() } - - 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." - ) - } - 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 - ) - } - } } 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..443c2d4 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 @@ -10,6 +10,10 @@ package org.radarbase.jersey.auth.filter import jakarta.ws.rs.core.SecurityContext +import org.radarbase.auth.authorization.AuthorityReference +import org.radarbase.auth.authorization.AuthorizationOracle +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.Auth import java.security.Principal @@ -17,11 +21,12 @@ 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 +38,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 { 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..4a6c4be --- /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 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..c7bd16d 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,45 @@ 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.auth.authentication.StaticTokenVerifierLoader +import org.radarbase.auth.authentication.TokenValidator +import org.radarbase.auth.authorization.AuthorityReference +import org.radarbase.auth.authorization.RoleAuthority +import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.jwks.ECPEMCertificateParser +import org.radarbase.auth.jwks.JwksTokenVerifierLoader.Companion.toTokenVerifier +import org.radarbase.auth.jwks.RSAPEMCertificateParser +import org.radarbase.auth.jwks.toAlgorithm +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.AuthConfig 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 java.time.Duration 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/AuthFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt similarity index 86% rename from radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthFactory.kt rename to radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt index 11d2a06..ded9449 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/AuthFactory.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt @@ -11,14 +11,14 @@ 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.auth.token.RadarToken import org.radarbase.jersey.auth.filter.RadarSecurityContext import java.util.function.Supplier /** Generates radar tokens from the security context. */ -class AuthFactory( +class RadarTokenFactory( @Context private val context: ContainerRequestContext -) : Supplier { - override fun get() = (context.securityContext as? RadarSecurityContext)?.auth +) : Supplier { + override fun get() = (context.securityContext as? RadarSecurityContext)?.token ?: throw IllegalStateException("Created null wrapper") } 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..98e37ec --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/TokenValidatorFactory.kt @@ -0,0 +1,104 @@ +/* + * 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.* +import org.radarbase.auth.jwks.JwksTokenVerifierLoader.Companion.toTokenVerifier +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 ${algorithms.size} algorithms") + + 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..362e340 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,11 +9,12 @@ package org.radarbase.jersey.auth.managementportal -import com.auth0.jwt.JWT import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context +import kotlinx.coroutines.runBlocking import org.radarbase.auth.authentication.TokenValidator -import org.radarbase.auth.exception.TokenValidationException +import org.radarbase.auth.authorization.AuthorizationOracle +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.AuthValidator import org.slf4j.LoggerFactory @@ -22,27 +23,6 @@ import org.slf4j.LoggerFactory class ManagementPortalTokenValidator( @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/coroutines/Coroutines.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt new file mode 100644 index 0000000..6aa2b29 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt @@ -0,0 +1,33 @@ +package org.radarbase.jersey.coroutines + +import jakarta.ws.rs.container.AsyncResponse +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.radarbase.jersey.exception.HttpTimeoutException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +fun AsyncResponse.runAsCoroutine(timeout: Duration? = 30.seconds, block: suspend () -> Any) { + val job = Job() + + val emit: (Any) -> Unit = { value -> + resume(value) + job.cancel() + } + + CoroutineScope(job).launch { + if (timeout != null) { + launch { + delay(timeout) + emit(HttpTimeoutException()) + } + } + try { + emit(block()) + } catch (ex: Throwable) { + emit(ex) + } + } +} 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 2118b3e..8fa373b 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,11 +14,13 @@ 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.auth.token.RadarToken import org.radarbase.jersey.auth.Auth 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 /** * Add RADAR auth to a Jersey project. This requires a {@link ProjectService} implementation to be @@ -53,12 +55,16 @@ class RadarJerseyResourceEnhancer( .`in`(Singleton::class.java) // Bind factories. - bindFactory(AuthFactory::class.java) + bindFactory(RadarTokenFactory::class.java) .proxy(true) .proxyForSameScope(true) - .to(Auth::class.java) + .to(RadarToken::class.java) .`in`(RequestScoped::class.java) + 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/HttpTimeoutException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpTimeoutException.kt new file mode 100644 index 0000000..b428180 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpTimeoutException.kt @@ -0,0 +1,13 @@ +package org.radarbase.jersey.exception + +import jakarta.ws.rs.core.Response + +class HttpTimeoutException( + message: String? = null, + additionalHeaders: List> = listOf(), +) : HttpApplicationException( + status = Response.Status.REQUEST_TIMEOUT, + code = "timeout", + detailedMessage = message, + additionalHeaders = additionalHeaders +) 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..3401721 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,20 @@ 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.coroutines.runAsCoroutine import org.radarbase.jersey.service.HealthService @Path("/health") @Resource @Singleton class HealthResource( - @Context private val healthService: HealthService + @Context private val healthService: HealthService, ) { @GET @Produces(APPLICATION_JSON) - fun healthStatus(): Map = healthService.metrics + fun healthStatus() = healthService.computeMetrics() } 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..75f7d1d 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 + fun computeStatus(): Status + fun computeMetrics(): Map + + abstract class Metric( + val name: String, + ) { + abstract fun computeStatus(): Status? + abstract 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..6f0a436 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,7 +1,12 @@ 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.jersey.util.concurrentAny +import org.radarbase.jersey.util.forkJoin +import org.slf4j.LoggerFactory class ImmediateHealthService( @Context healthMetrics: IterableProvider @@ -9,25 +14,23 @@ class ImmediateHealthService( @Volatile private var allMetrics: List = healthMetrics.toList() - override val status: HealthService.Status - get() = if (allMetrics.any { it.status == HealthService.Status.DOWN }) { + override fun computeStatus(): HealthService.Status = + if (allMetrics.any { + val status = it.computeStatus() + logger.info("Returning status {} from metric {}", status, it.name) + 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 fun computeMetrics(): Map = buildMap { + put("status", computeStatus()) + allMetrics.forEach { metric -> + put(metric.name, metric.computeMetrics()) } + } override fun add(metric: HealthService.Metric) { allMetrics = allMetrics + metric @@ -36,4 +39,8 @@ class ImmediateHealthService( override fun remove(metric: HealthService.Metric) { allMetrics = allMetrics - metric } + + companion object { + private val logger = LoggerFactory.getLogger(ImmediateHealthService::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 47bfab0..3a3d71b 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,11 +16,14 @@ package org.radarbase.jersey.service.managementportal +import jakarta.inject.Provider import jakarta.ws.rs.core.Context import kotlinx.coroutines.* +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 @@ -36,6 +39,7 @@ import java.util.concurrent.ConcurrentMap 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 @@ -64,13 +68,15 @@ class MPProjectService( } } - override fun userProjects(auth: Auth, permission: Permission): List { + override fun userProjects(permission: Permission): List { return projects.get().values .filter { - auth.token.hasPermissionOnOrganizationAndProject( + authService.get().hasPermission( permission, - it.organization?.id, - it.id + EntityDetails( + organization = it.organization?.id, + project = it.id + ) ) } } 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..ce0fe8b 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 @@ -39,7 +39,7 @@ interface RadarProjectService : ProjectService { /** * Returns all ManagementPortal projects that the current user has access to. */ - fun userProjects(auth: Auth, permission: Permission = PROJECT_READ): List + fun userProjects(permission: Permission = PROJECT_READ): List /** * Get project with [projectId] in ManagementPortal. 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 index 66f5824..1f647ff 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt @@ -1,6 +1,5 @@ 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 diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/Extensions.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/Extensions.kt new file mode 100644 index 0000000..ab62fcf --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/Extensions.kt @@ -0,0 +1,67 @@ +package org.radarbase.jersey.util + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.consume +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Transform each value in the iterable in a separate coroutine and await termination. + */ +internal suspend inline fun Iterable.forkJoin( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + crossinline transform: suspend CoroutineScope.(T) -> R +): List = coroutineScope { + map { t -> async(coroutineContext) { transform(t) } } + .awaitAll() +} + +/** + * Consume the first value produced by the producer on its provided channel. Once a value is sent + * by the producer, its coroutine is cancelled. + * @throws kotlinx.coroutines.channels.ClosedReceiveChannelException if the producer does not + * produce any values. + */ +internal suspend inline fun consumeFirst( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + crossinline producer: suspend CoroutineScope.(emit: suspend (T) -> Unit) -> Unit +): T = coroutineScope { + val channel = Channel() + + val producerJob = launch(coroutineContext) { + producer(channel::send) + channel.close() + } + + val result = channel.consume { receive() } + producerJob.cancel() + result +} + +suspend fun Iterable.concurrentFirstOfNotNullOrNull( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + transform: suspend CoroutineScope.(T) -> R? +): R? = consumeFirst(coroutineContext) { emit -> + forkJoin(coroutineContext) { t -> + val result = transform(t) + if (result != null) { + emit(result) + } + } + emit(null) +} + +suspend fun Iterable.concurrentAny( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + predicate: suspend CoroutineScope.(T) -> Boolean +): Boolean = consumeFirst(coroutineContext) { emit -> + forkJoin(coroutineContext) { t -> + if (predicate(t)) { + emit(true) + } + } + emit(false) +} 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..d4bf8a8 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,9 +3,10 @@ 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 org.radarbase.auth.jwks.JwksTokenVerifierLoader.Companion.toTokenVerifier import java.net.URI import java.security.KeyStore import java.security.interfaces.ECPrivateKey @@ -37,14 +38,9 @@ class OAuthHelper { 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 { 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..beb36c0 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 @@ -34,8 +34,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 +55,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())) } 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..6c84b23 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 @@ -17,6 +17,7 @@ import jakarta.ws.rs.* import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.MediaType import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.Authenticated import org.radarbase.jersey.auth.NeedsPermission @@ -37,15 +38,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 @@ -57,8 +58,9 @@ class MockResource { 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/ExtensionsKtTest.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/ExtensionsKtTest.kt new file mode 100644 index 0000000..27baff6 --- /dev/null +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/ExtensionsKtTest.kt @@ -0,0 +1,16 @@ +package org.radarbase.jersey.util + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class ExtensionsKtTest { + + @Test + fun testConcurrentAny() { + runBlocking { + assertTrue(listOf(1, 2, 3, 4).concurrentAny { it > 3 }) + assertFalse(listOf(1, 2, 3, 4).concurrentAny { it < 1 }) + } + } +} From d35848644d87d08e20418de093f65825e370809a Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 23 Feb 2023 16:22:52 +0100 Subject: [PATCH 04/36] Use radarbase kotlin-util and updated radar-auth --- build.gradle.kts | 6 +- gradle.properties | 32 +-- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 61608 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 12 +- gradlew.bat | 1 + radar-jersey-hibernate/build.gradle.kts | 3 + .../jersey/hibernate/DatabaseHealthMetrics.kt | 42 +-- .../hibernate/DatabaseInitialization.kt | 5 +- .../config/HibernateResourceEnhancer.kt | 2 +- .../hibernate/config/RadarPersistenceInfo.kt | 19 +- .../hibernate/mock/MockProjectService.kt | 10 +- radar-jersey/build.gradle.kts | 1 + .../org/radarbase/jersey/auth/AuthService.kt | 28 +- .../disabled/DisabledAuthorizationOracle.kt | 2 +- .../auth/jwt/AuthorizationOracleFactory.kt | 2 +- .../jersey/resource/HealthResource.kt | 6 +- .../radarbase/jersey/service/HealthService.kt | 8 +- .../jersey/service/ImmediateHealthService.kt | 26 +- .../jersey/service/ProjectService.kt | 10 +- .../managementportal/MPProjectService.kt | 74 +++--- .../managementportal/ProjectServiceWrapper.kt | 10 +- .../managementportal/RadarProjectService.kt | 12 +- .../org/radarbase/jersey/util/CacheConfig.kt | 39 --- .../org/radarbase/jersey/util/CachedMap.kt | 67 ----- .../org/radarbase/jersey/util/CachedSet.kt | 61 ----- .../org/radarbase/jersey/util/CachedValue.kt | 200 --------------- .../org/radarbase/jersey/util/Extensions.kt | 67 ----- .../radarbase/jersey/util/LockExtensions.kt | 37 --- .../jersey/mock/MockProjectService.kt | 10 +- .../radarbase/jersey/util/CachedValueTest.kt | 241 ------------------ .../radarbase/jersey/util/ExtensionsKtTest.kt | 16 -- 32 files changed, 187 insertions(+), 865 deletions(-) delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CacheConfig.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedMap.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedSet.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/Extensions.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/util/LockExtensions.kt delete mode 100644 radar-jersey/src/test/kotlin/org/radarbase/jersey/util/CachedValueTest.kt delete mode 100644 radar-jersey/src/test/kotlin/org/radarbase/jersey/util/ExtensionsKtTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8c9e1cf..175dac7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,8 +7,8 @@ plugins { `maven-publish` signing id("org.jetbrains.dokka") apply false - id("com.github.ben-manes.versions") version "0.43.0" - id("io.github.gradle-nexus.publish-plugin") version "1.1.0" + id("com.github.ben-manes.versions") version "0.46.0" + id("io.github.gradle-nexus.publish-plugin") version "1.2.0" } allprojects { @@ -219,5 +219,5 @@ nexusPublishing { } tasks.wrapper { - gradleVersion = "7.5.1" + gradleVersion = "8.0.1" } diff --git a/gradle.properties b/gradle.properties index 4017eb7..87d0310 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,36 +3,36 @@ org.gradle.jvmargs=-Xmx2000m org.gradle.vfs.watch=true kotlin.code.style=official -kotlinVersion=1.7.21 +kotlinVersion=1.8.10 dokkaVersion=1.7.20 -jsoupVersion=1.15.3 +jsoupVersion=1.15.4 -jerseyVersion=3.1.0 +jerseyVersion=3.1.1 grizzlyVersion=4.0.0 okhttpVersion=4.10.0 -junitVersion=5.9.1 +junitVersion=5.9.2 hamcrestVersion=2.2 -mockitoKotlinVersion=4.0.0 +mockitoKotlinVersion=4.1.0 hk2Version=3.0.3 managementPortalVersion=0.10.1-SNAPSHOT -javaJwtVersion=4.2.1 +javaJwtVersion=4.3.0 jakartaWsRsVersion=3.1.0 jakartaAnnotationVersion=2.1.1 -jacksonVersion=2.14.0 -slf4jVersion=2.0.3 -log4j2Version=2.19.0 +jacksonVersion=2.14.2 +slf4jVersion=2.0.6 +log4j2Version=2.20.0 jakartaXmlBindVersion=4.0.0 -jakartaJaxbCoreVersion=4.0.1 -jakartaJaxbRuntimeVersion=4.0.1 +jakartaJaxbCoreVersion=4.0.2 +jakartaJaxbRuntimeVersion=4.0.2 jakartaValidationVersion=3.0.2 hibernateValidatorVersion=8.0.0.Final glassfishJakartaElVersion=4.0.2 -jakartaActivation=2.1.0 -swaggerVersion=2.2.6 +jakartaActivation=2.1.1 +swaggerVersion=2.2.8 mustacheVersion=0.9.10 -hibernateVersion=6.1.5.Final -liquibaseVersion=4.17.2 -postgresVersion=42.5.0 +hibernateVersion=6.1.7.Final +liquibaseVersion=4.19.0 +postgresVersion=42.5.4 h2Version=2.1.214 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..ccebba7710deaf9f98673a68957ea02138b60d0a 100644 GIT binary patch delta 39304 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 O;LQ#O9OtPYE&l^NyjRr# delta 38549 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 ltSba#rbH)&uY59oK&k!`i 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..fc10b60 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb..79a61d4 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# 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"' @@ -143,12 +143,16 @@ fi 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 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac diff --git a/gradlew.bat b/gradlew.bat index 53a6b23..6689b85 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/radar-jersey-hibernate/build.gradle.kts b/radar-jersey-hibernate/build.gradle.kts index 3154683..fd5b7d2 100644 --- a/radar-jersey-hibernate/build.gradle.kts +++ b/radar-jersey-hibernate/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { api("org.hibernate:hibernate-core:$hibernateVersion") runtimeOnly("org.hibernate:hibernate-c3p0:$hibernateVersion") + val managementPortalVersion: String by project + implementation("org.radarbase:kotlin-util:$managementPortalVersion") + val jakartaValidationVersion: String by project runtimeOnly("jakarta.validation:jakarta.validation-api:$jakartaValidationVersion") val hibernateValidatorVersion: String by project 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 552c98f..c1896e0 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,40 +3,48 @@ 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.glassfish.jersey.process.internal.RequestScope import org.radarbase.jersey.hibernate.DatabaseInitialization.Companion.useConnection import org.radarbase.jersey.hibernate.config.DatabaseConfig 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 org.radarbase.kotlin.coroutines.CacheConfig +import org.radarbase.kotlin.coroutines.CachedValue import org.slf4j.LoggerFactory -import java.time.Duration +import kotlin.time.Duration.Companion.seconds class DatabaseHealthMetrics( - @Context private val entityManager: Provider, - @Context dbConfig: DatabaseConfig + @Context private val entityManager: Provider, + @Context dbConfig: DatabaseConfig, + @Context private val requestScope: RequestScope, ): 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 fun computeStatus(): HealthService.Status = - cachedStatus.get { it == HealthService.Status.UP } + override suspend fun computeStatus(): HealthService.Status = + cachedStatus.get { it == HealthService.Status.UP }.value .also { logger.info("Returning status {}", it) } - override fun computeMetrics(): Map = mapOf("status" to computeStatus()) + override suspend fun computeMetrics(): Map = mapOf("status" to computeStatus()) - private fun testConnection(): HealthService.Status = try { - entityManager.get().useConnection { } - logger.info("Database UP") - HealthService.Status.UP - } catch (ex: Throwable) { - logger.info("Database DOWN") - HealthService.Status.DOWN + private suspend fun testConnection(): HealthService.Status = withContext(Dispatchers.IO) { + try { + requestScope.runInScope { + entityManager.get().useConnection { it.close() } + } + logger.info("Database UP") + HealthService.Status.UP + } catch (ex: Throwable) { + logger.info("Database DOWN: {}", ex.message) + HealthService.Status.DOWN + } } companion object { 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 0f7c330..8f09901 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 @@ -61,10 +61,7 @@ class DatabaseInitialization( @Throws(HibernateException::class) fun EntityManager.useConnection(work: (Connection) -> Unit) { check(this is Session) { "Cannot use connection of EntityManager that is not a Hibernate Session" } - doWork { connection -> - work(connection) - connection.close() - } + doWork(work) } } } 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..2c5595e 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,7 +13,7 @@ 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) 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..90314ca 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 @@ -28,14 +28,17 @@ class RadarPersistenceInfo( 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) + 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 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..253dc00 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 @@ -17,28 +17,28 @@ import org.radarbase.jersey.service.ProjectService class MockProjectService( @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/build.gradle.kts b/radar-jersey/build.gradle.kts index 6d96835..ecbbc79 100644 --- a/radar-jersey/build.gradle.kts +++ b/radar-jersey/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { val managementPortalVersion: String by project api("org.radarbase:radar-auth:$managementPortalVersion") + implementation("org.radarbase:kotlin-util:$managementPortalVersion") api("org.radarbase:managementportal-client:$managementPortalVersion") val javaJwtVersion: String by project 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 index dfe233e..8b15dde 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -2,6 +2,7 @@ package org.radarbase.jersey.auth import jakarta.inject.Provider import jakarta.ws.rs.core.Context +import kotlinx.coroutines.runBlocking import org.radarbase.auth.authorization.* import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.exception.HttpForbiddenException @@ -63,7 +64,7 @@ class AuthService( return entity } - fun hasPermission( + suspend fun hasPermission( permission: Permission, entity: EntityDetails ) = oracle.hasPermission(token, permission, entity) @@ -79,6 +80,21 @@ class AuthService( entity: EntityDetails, location: String? = null, scope: Permission.Entity = permission.entity, + ) = runBlocking { + checkPermissionSuspending(permission, entity, location, scope) + } + + /** + * 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 checkPermissionSuspending( + permission: Permission, + entity: EntityDetails, + location: String? = null, + scope: Permission.Entity = permission.entity, ) { entity.resolve() if ( @@ -103,7 +119,7 @@ class AuthService( ) } - private fun EntityDetails.resolve() { + private suspend fun EntityDetails.resolve() { val project = project val organization = organization if (project != null) { @@ -120,6 +136,8 @@ class AuthService( if (subject != null) { projectService.ensureSubject(project, subject) } + } else if (organization != null) { + projectService.ensureOrganization(organization) } } @@ -195,7 +213,11 @@ class AuthService( } fun referentsByScope(permission: Permission): AuthorityReferenceSet { - val token = token ?: return AuthorityReferenceSet() + val token = try { + tokenProvider.get() + } catch (ex: Throwable) { + return AuthorityReferenceSet() + } return oracle.referentsByScope(token, permission) } 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 index 4882076..a68e4a5 100644 --- 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 @@ -4,7 +4,7 @@ import org.radarbase.auth.authorization.* import org.radarbase.auth.token.RadarToken class DisabledAuthorizationOracle : AuthorizationOracle { - override fun hasPermission( + override suspend fun hasPermission( identity: RadarToken, permission: Permission, entity: EntityDetails, 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 index 4a6c4be..35bdf02 100644 --- 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 @@ -11,7 +11,7 @@ class AuthorizationOracleFactory( @Context projectService: ProjectService, ) : Supplier { private val relationService = object : EntityRelationService { - override fun findOrganizationOfProject(project: String): String { + override suspend fun findOrganizationOfProject(project: String): String { return projectService.projectOrganization(project) } } 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 3401721..febec2a 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 @@ -11,6 +11,8 @@ import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.MediaType.APPLICATION_JSON import org.radarbase.jersey.coroutines.runAsCoroutine import org.radarbase.jersey.service.HealthService +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @Path("/health") @Resource @@ -20,5 +22,7 @@ class HealthResource( ) { @GET @Produces(APPLICATION_JSON) - fun healthStatus() = healthService.computeMetrics() + fun healthStatus(@Suspended asyncResponse: AsyncResponse) = asyncResponse.runAsCoroutine(5.seconds) { + healthService.computeMetrics() + } } 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 75f7d1d..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 @@ -4,14 +4,14 @@ interface HealthService { fun add(metric: Metric) fun remove(metric: Metric) - fun computeStatus(): Status - fun computeMetrics(): Map + suspend fun computeStatus(): Status + suspend fun computeMetrics(): Map abstract class Metric( val name: String, ) { - abstract fun computeStatus(): Status? - abstract fun computeMetrics(): Map + 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 6f0a436..dad3094 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 @@ -4,8 +4,8 @@ import jakarta.ws.rs.core.Context import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.glassfish.hk2.api.IterableProvider -import org.radarbase.jersey.util.concurrentAny -import org.radarbase.jersey.util.forkJoin +import org.radarbase.kotlin.coroutines.forkAny +import org.radarbase.kotlin.coroutines.forkJoin import org.slf4j.LoggerFactory class ImmediateHealthService( @@ -14,8 +14,8 @@ class ImmediateHealthService( @Volatile private var allMetrics: List = healthMetrics.toList() - override fun computeStatus(): HealthService.Status = - if (allMetrics.any { + override suspend fun computeStatus(): HealthService.Status = + if (allMetrics.forkAny { val status = it.computeStatus() logger.info("Returning status {} from metric {}", status, it.name) status == HealthService.Status.DOWN @@ -25,10 +25,20 @@ class ImmediateHealthService( HealthService.Status.UP } - override fun computeMetrics(): Map = buildMap { - put("status", computeStatus()) - allMetrics.forEach { metric -> - put(metric.name, metric.computeMetrics()) + 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) + } } } 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/managementportal/MPProjectService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt index 3a3d71b..8c0958c 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 @@ -18,23 +18,22 @@ package org.radarbase.jersey.service.managementportal import jakarta.inject.Provider import jakarta.ws.rs.core.Context -import kotlinx.coroutines.* 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.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, @@ -45,30 +44,31 @@ class MPProjectService( 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) { - runBlocking { - mpClient.requestOrganizations() - .associateBy { it.id } - .also { logger.debug("Fetched organizations {}", it) } - } + mpClient.requestOrganizations() + .associateBy { it.id } + .also { logger.debug("Fetched organizations {}", it) } } projects = CachedMap(cacheConfig) { - runBlocking { - mpClient.requestProjects() - .associateBy { it.id } - .also { logger.debug("Fetched projects {}", it) } - } + mpClient.requestProjects() + .associateBy { it.id } + .also { logger.debug("Fetched projects {}", it) } } } - override fun userProjects(permission: Permission): List { + override suspend fun userProjects(permission: Permission): List { return projects.get().values .filter { authService.get().hasPermission( @@ -81,59 +81,55 @@ class MPProjectService( } } - 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) + 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)) { - runBlocking { - mpClient.requestSubjects(projectId) - .associateBy { checkNotNull(it.id) } - } + 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) } } 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..a9243b6 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 @@ -24,22 +24,22 @@ import org.radarbase.jersey.service.ProjectService class ProjectServiceWrapper( @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 ce0fe8b..ff20803 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 @@ -26,7 +26,7 @@ import org.radarbase.management.client.MPSubject interface RadarProjectService : ProjectService { - override fun ensureProject(projectId: String) { + override suspend fun ensureProject(projectId: String) { project(projectId) } @@ -34,25 +34,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(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 1f647ff..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt +++ /dev/null @@ -1,200 +0,0 @@ -package org.radarbase.jersey.util - -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/Extensions.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/Extensions.kt deleted file mode 100644 index ab62fcf..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/util/Extensions.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.radarbase.jersey.util - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.channels.consume -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -/** - * Transform each value in the iterable in a separate coroutine and await termination. - */ -internal suspend inline fun Iterable.forkJoin( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - crossinline transform: suspend CoroutineScope.(T) -> R -): List = coroutineScope { - map { t -> async(coroutineContext) { transform(t) } } - .awaitAll() -} - -/** - * Consume the first value produced by the producer on its provided channel. Once a value is sent - * by the producer, its coroutine is cancelled. - * @throws kotlinx.coroutines.channels.ClosedReceiveChannelException if the producer does not - * produce any values. - */ -internal suspend inline fun consumeFirst( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - crossinline producer: suspend CoroutineScope.(emit: suspend (T) -> Unit) -> Unit -): T = coroutineScope { - val channel = Channel() - - val producerJob = launch(coroutineContext) { - producer(channel::send) - channel.close() - } - - val result = channel.consume { receive() } - producerJob.cancel() - result -} - -suspend fun Iterable.concurrentFirstOfNotNullOrNull( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - transform: suspend CoroutineScope.(T) -> R? -): R? = consumeFirst(coroutineContext) { emit -> - forkJoin(coroutineContext) { t -> - val result = transform(t) - if (result != null) { - emit(result) - } - } - emit(null) -} - -suspend fun Iterable.concurrentAny( - coroutineContext: CoroutineContext = EmptyCoroutineContext, - predicate: suspend CoroutineScope.(T) -> Boolean -): Boolean = consumeFirst(coroutineContext) { emit -> - forkJoin(coroutineContext) { t -> - if (predicate(t)) { - emit(true) - } - } - emit(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/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/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/ExtensionsKtTest.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/ExtensionsKtTest.kt deleted file mode 100644 index 27baff6..0000000 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/ExtensionsKtTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.radarbase.jersey.util - -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Assertions.* -import org.junit.jupiter.api.Test - -class ExtensionsKtTest { - - @Test - fun testConcurrentAny() { - runBlocking { - assertTrue(listOf(1, 2, 3, 4).concurrentAny { it > 3 }) - assertFalse(listOf(1, 2, 3, 4).concurrentAny { it < 1 }) - } - } -} From b309f62f2450f873d90f7c6691c6ee05ca0b5639 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 28 Feb 2023 17:54:38 +0100 Subject: [PATCH 05/36] Various improvements --- build.gradle.kts | 2 +- .../jersey/hibernate/DatabaseHealthMetrics.kt | 3 -- .../kotlin/org/radarbase/jersey/auth/Auth.kt | 32 ------------------- .../org/radarbase/jersey/auth/AuthService.kt | 14 +++++--- .../auth/disabled/DisabledAuthValidator.kt | 1 - .../jersey/auth/filter/PermissionFilter.kt | 8 ----- .../auth/filter/RadarSecurityContext.kt | 3 -- .../ManagementPortalTokenValidator.kt | 4 --- .../radarbase/jersey/coroutines/Coroutines.kt | 15 ++++++--- .../enhancer/RadarJerseyResourceEnhancer.kt | 1 - .../jersey/service/ImmediateHealthService.kt | 6 ---- .../managementportal/RadarProjectService.kt | 1 - radar-jersey/src/main/resources/log4j2.xml | 2 +- radar-jersey/src/main/resources/logback.xml | 2 +- .../jersey/mock/resource/MockResource.kt | 1 - 15 files changed, 22 insertions(+), 73 deletions(-) delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Auth.kt diff --git a/build.gradle.kts b/build.gradle.kts index 175dac7..329c117 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,7 +13,7 @@ plugins { allprojects { group = "org.radarbase" - version = "0.10.0-SNAPSHOT" + version = "0.11.0-SNAPSHOT" } subprojects { 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 c1896e0..6621e7b 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 @@ -30,7 +30,6 @@ class DatabaseHealthMetrics( override suspend fun computeStatus(): HealthService.Status = cachedStatus.get { it == HealthService.Status.UP }.value - .also { logger.info("Returning status {}", it) } override suspend fun computeMetrics(): Map = mapOf("status" to computeStatus()) @@ -39,10 +38,8 @@ class DatabaseHealthMetrics( requestScope.runInScope { entityManager.get().useConnection { it.close() } } - logger.info("Database UP") HealthService.Status.UP } catch (ex: Throwable) { - logger.info("Database DOWN: {}", ex.message) HealthService.Status.DOWN } } 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 016f631..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/Auth.kt +++ /dev/null @@ -1,32 +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 org.radarbase.auth.token.RadarToken - -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() } - - /** - * Whether the current authentication is for a user with a role in given project. - */ - fun hasRole(projectId: String, role: String): Boolean -} 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 index 8b15dde..0e13a77 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -16,7 +16,7 @@ class AuthService( @Context private val tokenProvider: Provider, @Context private val projectService: ProjectService, ) { - private val token: RadarToken + val token: RadarToken get() = try { tokenProvider.get() } catch (ex: Throwable) { @@ -59,7 +59,7 @@ class AuthService( if (entity.minimumEntityOrNull() == null) { logAuthorized(permission, location) } else { - checkPermission(permission, entity, location, permission.entity) + checkPermissionBlocking(permission, entity, location, permission.entity) } return entity } @@ -75,22 +75,26 @@ class AuthService( * own entity scope and for the [EntityDetails.minimumEntityOrNull] entity scope. * @throws HttpForbiddenException if identity does not have permission */ - fun checkPermission( + fun checkPermissionBlocking( permission: Permission, entity: EntityDetails, location: String? = null, scope: Permission.Entity = permission.entity, ) = runBlocking { - checkPermissionSuspending(permission, entity, location, scope) + checkPermission(permission, entity, location, scope) } + fun activeParticipantProject(): String? = token.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 checkPermissionSuspending( + suspend fun checkPermission( permission: Permission, entity: EntityDetails, location: String? = null, 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 e0d297a..d88fdc3 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 @@ -4,7 +4,6 @@ import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context import org.radarbase.auth.token.DataRadarToken import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.auth.AuthValidator import java.time.Instant 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 3a9da19..47adffe 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,16 +14,8 @@ 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.AuthorizationOracle -import org.radarbase.auth.authorization.EntityDetails -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.auth.disabled.DisabledAuthorizationOracle -import org.radarbase.jersey.exception.HttpNotFoundException -import org.radarbase.jersey.service.ProjectService /** * Check that the token has given permissions. 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 443c2d4..c362f49 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 @@ -11,10 +11,7 @@ package org.radarbase.jersey.auth.filter import jakarta.ws.rs.core.SecurityContext import org.radarbase.auth.authorization.AuthorityReference -import org.radarbase.auth.authorization.AuthorizationOracle -import org.radarbase.auth.authorization.RoleAuthority import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth import java.security.Principal /** 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 362e340..49e800c 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 @@ -11,13 +11,9 @@ package org.radarbase.jersey.auth.managementportal import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context -import kotlinx.coroutines.runBlocking import org.radarbase.auth.authentication.TokenValidator -import org.radarbase.auth.authorization.AuthorizationOracle import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.AuthValidator -import org.slf4j.LoggerFactory /** Creates a TokenValidator based on the current management portal configuration. */ class ManagementPortalTokenValidator( diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt index 6aa2b29..c650e0b 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt @@ -1,15 +1,16 @@ package org.radarbase.jersey.coroutines import jakarta.ws.rs.container.AsyncResponse -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import jakarta.ws.rs.container.ConnectionCallback +import kotlinx.coroutines.* import org.radarbase.jersey.exception.HttpTimeoutException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -fun AsyncResponse.runAsCoroutine(timeout: Duration? = 30.seconds, block: suspend () -> Any) { +fun AsyncResponse.runAsCoroutine( + timeout: Duration? = 30.seconds, + block: suspend () -> Any, +) { val job = Job() val emit: (Any) -> Unit = { value -> @@ -17,6 +18,8 @@ fun AsyncResponse.runAsCoroutine(timeout: Duration? = 30.seconds, block: suspend job.cancel() } + register(ConnectionCallback { job.cancel() }) + CoroutineScope(job).launch { if (timeout != null) { launch { @@ -26,6 +29,8 @@ fun AsyncResponse.runAsCoroutine(timeout: Duration? = 30.seconds, block: suspend } try { emit(block()) + } catch (ex: CancellationException) { + // do nothing } catch (ex: Throwable) { emit(ex) } 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 8fa373b..ce73053 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 @@ -15,7 +15,6 @@ import org.glassfish.jersey.jackson.JacksonFeature import org.glassfish.jersey.process.internal.RequestScoped import org.glassfish.jersey.server.ResourceConfig import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.auth.AuthService import org.radarbase.jersey.auth.filter.AuthenticationFilter 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 dad3094..c20c736 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 @@ -6,7 +6,6 @@ import kotlinx.coroutines.coroutineScope import org.glassfish.hk2.api.IterableProvider import org.radarbase.kotlin.coroutines.forkAny import org.radarbase.kotlin.coroutines.forkJoin -import org.slf4j.LoggerFactory class ImmediateHealthService( @Context healthMetrics: IterableProvider @@ -17,7 +16,6 @@ class ImmediateHealthService( override suspend fun computeStatus(): HealthService.Status = if (allMetrics.forkAny { val status = it.computeStatus() - logger.info("Returning status {} from metric {}", status, it.name) status == HealthService.Status.DOWN }) { HealthService.Status.DOWN @@ -49,8 +47,4 @@ class ImmediateHealthService( override fun remove(metric: HealthService.Metric) { allMetrics = allMetrics - metric } - - companion object { - private val logger = LoggerFactory.getLogger(ImmediateHealthService::class.java) - } } 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 ff20803..6860d65 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,7 +18,6 @@ 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 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/mock/resource/MockResource.kt b/radar-jersey/src/test/kotlin/org/radarbase/jersey/mock/resource/MockResource.kt index 6c84b23..a1f540d 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 @@ -18,7 +18,6 @@ import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.MediaType import org.radarbase.auth.authorization.Permission import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.Authenticated import org.radarbase.jersey.auth.NeedsPermission import org.radarbase.jersey.exception.HttpBadRequestException From 6e67d99d30ee429faed0bfa156d9c7b6c9040e50 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 7 Mar 2023 16:47:47 +0100 Subject: [PATCH 06/36] Test coroutine asyncresponse --- radar-jersey-hibernate/build.gradle.kts | 2 +- .../jersey/hibernate/DatabaseHealthMetrics.kt | 5 - .../jersey/hibernate/HibernateRepository.kt | 108 +++++++++----- .../hibernate/config/CloseableTransaction.kt | 6 +- .../hibernate/config/RadarPersistenceInfo.kt | 12 +- .../hibernate/DatabaseHealthMetricsTest.kt | 26 ++-- .../jersey/hibernate/HibernateTest.kt | 140 ++++++++++++------ .../jersey/hibernate/db/ProjectRepository.kt | 10 +- .../hibernate/db/ProjectRepositoryImpl.kt | 16 +- .../mock/resource/ProjectResource.kt | 47 +++++- .../radarbase/jersey/coroutines/Coroutines.kt | 26 +++- .../exception/HttpApplicationException.kt | 17 ++- ...n.kt => HttpServerUnavailableException.kt} | 4 +- .../mapper/HttpApplicationExceptionMapper.kt | 5 +- 14 files changed, 282 insertions(+), 142 deletions(-) rename radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/{HttpTimeoutException.kt => HttpServerUnavailableException.kt} (76%) diff --git a/radar-jersey-hibernate/build.gradle.kts b/radar-jersey-hibernate/build.gradle.kts index fd5b7d2..0c51517 100644 --- a/radar-jersey-hibernate/build.gradle.kts +++ b/radar-jersey-hibernate/build.gradle.kts @@ -12,7 +12,7 @@ dependencies { api(project(":radar-jersey")) val hibernateVersion: String by project api("org.hibernate:hibernate-core:$hibernateVersion") - runtimeOnly("org.hibernate:hibernate-c3p0:$hibernateVersion") + runtimeOnly("org.hibernate:hibernate-hikaricp:$hibernateVersion") val managementPortalVersion: String by project implementation("org.radarbase:kotlin-util:$managementPortalVersion") 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 6621e7b..b569e81 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 @@ -12,7 +12,6 @@ import org.radarbase.jersey.service.HealthService import org.radarbase.jersey.service.HealthService.Metric import org.radarbase.kotlin.coroutines.CacheConfig import org.radarbase.kotlin.coroutines.CachedValue -import org.slf4j.LoggerFactory import kotlin.time.Duration.Companion.seconds class DatabaseHealthMetrics( @@ -43,8 +42,4 @@ class DatabaseHealthMetrics( HealthService.Status.DOWN } } - - companion object { - private val logger = LoggerFactory.getLogger(DatabaseHealthMetrics::class.java) - } } 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..75231d7 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.* +import org.glassfish.jersey.process.internal.RequestScope +import org.hibernate.Session import org.radarbase.jersey.exception.HttpInternalServerException import org.radarbase.jersey.hibernate.config.CloseableTransaction import org.slf4j.LoggerFactory +import java.util.concurrent.Callable +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 requestScope: RequestScope, ) { @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) { + suspendCancellableCoroutine { continuation -> + val storedTransaction = AtomicReference(null) + continuation.invokeOnCancellation { storedTransaction.get()?.cancel() } + try { + continuation.resume( + createTransaction { transaction -> + storedTransaction.set(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 + } finally { + storedTransaction.set(null) + } + } + ) + } catch (ex: Throwable) { + continuation.resumeWithException(ex) + } + } } /** * 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 + fun createTransaction( + transactionOperation: EntityManager.(CloseableTransaction) -> T, + ): T = requestScope.runInScope( + Callable { + 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) + try { + suspendTransaction.begin() + em.transactionOperation(suspendTransaction) + } catch (ex: Exception) { + logger.error("Rolling back operation", ex) + suspendTransaction.abort() + throw ex + } + } + ) + + 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") - currentTransaction.begin() - try { - return transactionOperation(object : CloseableTransaction { - override val transaction: EntityTransaction = currentTransaction + fun begin() { + transaction.begin() + } - override fun close() { - try { - transaction.commit() - } catch (ex: Exception) { - logger.error("Rolling back operation", ex) - if (currentTransaction.isActive) { - currentTransaction.rollback() - } - throw ex - } + override fun abort() { + if (transaction.isActive) { + transaction.rollback() } - }) - } catch (ex: Exception) { - logger.error("Rolling back operation", ex) - if (currentTransaction.isActive) { - currentTransaction.rollback() } - throw ex - } - } + override fun commit() { + transaction.commit() + } - companion object { - private val logger = LoggerFactory.getLogger(HibernateRepository::class.java) + override fun cancel() { + session.cancelQuery() + } + } } } - 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..6368bde 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 @@ -3,7 +3,9 @@ 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/RadarPersistenceInfo.kt b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/RadarPersistenceInfo.kt index 90314ca..9135255 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 @@ -17,16 +17,8 @@ class RadarPersistenceInfo( 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") + put("hibernate.connection.provider_class", "org.hibernate.hikaricp.internal.HikariCPConnectionProvider") + put("hibernate.hikari.connectionTimeout", "1000") sequenceOf( "jakarta.persistence.jdbc.driver" to config.driver, 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 605eb71..17b8097 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,10 @@ package org.radarbase.jersey.hibernate +import okhttp3.ConnectionPool 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 @@ -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(), @@ -90,13 +90,13 @@ 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\"}}")) } @@ -105,9 +105,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..0f0cbcd 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,28 @@ package org.radarbase.jersey.hibernate -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import okio.BufferedSink +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.equalTo -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test +import org.hamcrest.Matchers.`is` +import org.junit.jupiter.api.* 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 internal class HibernateTest { private lateinit var client: OkHttpClient @@ -50,9 +55,9 @@ internal class HibernateTest { @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("[]")) } @@ -61,27 +66,67 @@ internal class HibernateTest { @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 + @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.newCall(Request.Builder() - .post(object : RequestBody() { - override fun contentType() = "application/json".toMediaTypeOrNull() - - override fun writeTo(sink: BufferedSink) { - sink.writeUtf8("""{"name": "a","organization":"main"}""") - } - }) - .url("http://localhost:9091/projects") - .build()).execute().use { response -> + 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"}""")) } @@ -94,38 +139,43 @@ internal class HibernateTest { } - 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 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/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..94c3092 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 @@ -3,17 +3,19 @@ package org.radarbase.jersey.hibernate.db import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.ws.rs.core.Context +import org.glassfish.jersey.process.internal.RequestScope import org.radarbase.jersey.hibernate.HibernateRepository class ProjectRepositoryImpl( - @Context em: Provider -): ProjectRepository, HibernateRepository(em) { - override fun list(): List = transact { + @Context em: Provider, + @Context requestScope: RequestScope, +): ProjectRepository, HibernateRepository(em, requestScope) { + override suspend fun list(): List = transact { createQuery("SELECT p FROM Project p", ProjectDao::class.java) .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,7 +24,7 @@ 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 @@ -34,14 +36,14 @@ class ProjectRepositoryImpl( } } - 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() } - 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 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..16d9a24 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,10 +1,17 @@ package org.radarbase.jersey.hibernate.mock.resource import jakarta.ws.rs.* +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended import jakarta.ws.rs.core.Context +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.radarbase.jersey.coroutines.runAsCoroutine import org.radarbase.jersey.exception.HttpNotFoundException -import org.radarbase.jersey.hibernate.db.ProjectDao import org.radarbase.jersey.hibernate.db.ProjectRepository +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.seconds @Path("projects") @Consumes("application/json") @@ -12,23 +19,51 @@ import org.radarbase.jersey.hibernate.db.ProjectRepository class ProjectResource( @Context private val projects: ProjectRepository ) { + @POST + @Path("query") + fun query(@Suspended asyncResponse: AsyncResponse) = asyncResponse.runAsCoroutine { + delay(1.seconds) + "{\"result\": 1}" + } + @GET - fun projects(): List = projects.list() + fun projects(@Suspended asyncResponse: AsyncResponse) = asyncResponse.runAsCoroutine { + projects.list() + } @GET @Path("{id}") - fun project(@PathParam("id") id: Long) = projects.get(id) + fun project( + @PathParam("id") id: Long, + @Suspended asyncResponse: AsyncResponse, + ) = asyncResponse.runAsCoroutine { + 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, + ) = asyncResponse.runAsCoroutine { + 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, + ) = asyncResponse.runAsCoroutine { + 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, + ) = asyncResponse.runAsCoroutine { projects.delete(id) } } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt index c650e0b..c9703d0 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt @@ -3,36 +3,48 @@ package org.radarbase.jersey.coroutines import jakarta.ws.rs.container.AsyncResponse import jakarta.ws.rs.container.ConnectionCallback import kotlinx.coroutines.* -import org.radarbase.jersey.exception.HttpTimeoutException +import org.radarbase.jersey.exception.HttpServerUnavailableException import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -fun AsyncResponse.runAsCoroutine( +/** + * 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. + */ +fun AsyncResponse.runAsCoroutine( timeout: Duration? = 30.seconds, - block: suspend () -> Any, + block: suspend () -> T, ) { val job = Job() - val emit: (Any) -> Unit = { value -> + val emit: (T) -> Unit = { value -> resume(value) job.cancel() } + val cancel: (Throwable) -> Unit = { ex -> + resume(ex) + job.cancel() + } register(ConnectionCallback { job.cancel() }) - CoroutineScope(job).launch { + CoroutineScope(job + Dispatchers.Default).launch { if (timeout != null) { launch { delay(timeout) - emit(HttpTimeoutException()) + cancel(HttpServerUnavailableException()) } } try { emit(block()) } catch (ex: CancellationException) { // do nothing + job.cancel(ex) } catch (ex: Throwable) { - emit(ex) + cancel(ex) } } } 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..768b6e8 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"}") { +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/HttpTimeoutException.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt similarity index 76% rename from radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpTimeoutException.kt rename to radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt index b428180..a1033c6 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpTimeoutException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt @@ -2,11 +2,11 @@ package org.radarbase.jersey.exception import jakarta.ws.rs.core.Response -class HttpTimeoutException( +class HttpServerUnavailableException( message: String? = null, additionalHeaders: List> = listOf(), ) : HttpApplicationException( - status = Response.Status.REQUEST_TIMEOUT, + status = Response.Status.SERVICE_UNAVAILABLE, code = "timeout", detailedMessage = message, additionalHeaders = additionalHeaders 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() From 898e4e651c9745242addc9d522fff3e34d4f2e84 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 13 Mar 2023 13:08:25 +0100 Subject: [PATCH 07/36] Use new radar-kotlin package --- radar-jersey-hibernate/build.gradle.kts | 2 +- radar-jersey/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/radar-jersey-hibernate/build.gradle.kts b/radar-jersey-hibernate/build.gradle.kts index 0c51517..3b62658 100644 --- a/radar-jersey-hibernate/build.gradle.kts +++ b/radar-jersey-hibernate/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { runtimeOnly("org.hibernate:hibernate-hikaricp:$hibernateVersion") val managementPortalVersion: String by project - implementation("org.radarbase:kotlin-util:$managementPortalVersion") + implementation("org.radarbase:radar-kotlin:$managementPortalVersion") val jakartaValidationVersion: String by project runtimeOnly("jakarta.validation:jakarta.validation-api:$jakartaValidationVersion") diff --git a/radar-jersey/build.gradle.kts b/radar-jersey/build.gradle.kts index ecbbc79..5964db1 100644 --- a/radar-jersey/build.gradle.kts +++ b/radar-jersey/build.gradle.kts @@ -11,7 +11,7 @@ dependencies { val managementPortalVersion: String by project api("org.radarbase:radar-auth:$managementPortalVersion") - implementation("org.radarbase:kotlin-util:$managementPortalVersion") + implementation("org.radarbase:radar-kotlin:$managementPortalVersion") api("org.radarbase:managementportal-client:$managementPortalVersion") val javaJwtVersion: String by project From 7a54ae7f9259a5c14d12d60475637d79ccf85e76 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 16 Mar 2023 13:51:46 +0100 Subject: [PATCH 08/36] Switch to radar-commons-kotlin / MP 2 --- gradle.properties | 3 ++- radar-jersey-hibernate/build.gradle.kts | 4 ++-- radar-jersey/build.gradle.kts | 4 +++- .../jersey/service/managementportal/MPClientFactory.kt | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/gradle.properties b/gradle.properties index 87d0310..6cde7f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,8 @@ hamcrestVersion=2.2 mockitoKotlinVersion=4.1.0 hk2Version=3.0.3 -managementPortalVersion=0.10.1-SNAPSHOT +managementPortalVersion=2.0.1-SNAPSHOT +radarCommonsVersion=0.16.0-SNAPSHOT javaJwtVersion=4.3.0 jakartaWsRsVersion=3.1.0 jakartaAnnotationVersion=2.1.1 diff --git a/radar-jersey-hibernate/build.gradle.kts b/radar-jersey-hibernate/build.gradle.kts index 3b62658..80faad6 100644 --- a/radar-jersey-hibernate/build.gradle.kts +++ b/radar-jersey-hibernate/build.gradle.kts @@ -14,8 +14,8 @@ dependencies { api("org.hibernate:hibernate-core:$hibernateVersion") runtimeOnly("org.hibernate:hibernate-hikaricp:$hibernateVersion") - val managementPortalVersion: String by project - implementation("org.radarbase:radar-kotlin:$managementPortalVersion") + val radarCommonsVersion: String by project + implementation("org.radarbase:radar-commons-kotlin:$radarCommonsVersion") val jakartaValidationVersion: String by project runtimeOnly("jakarta.validation:jakarta.validation-api:$jakartaValidationVersion") diff --git a/radar-jersey/build.gradle.kts b/radar-jersey/build.gradle.kts index 5964db1..990c5ae 100644 --- a/radar-jersey/build.gradle.kts +++ b/radar-jersey/build.gradle.kts @@ -11,9 +11,11 @@ dependencies { val managementPortalVersion: String by project api("org.radarbase:radar-auth:$managementPortalVersion") - implementation("org.radarbase:radar-kotlin:$managementPortalVersion") api("org.radarbase:managementportal-client:$managementPortalVersion") + val radarCommonsVersion: String by project + implementation("org.radarbase:radar-commons-kotlin:$radarCommonsVersion") + val javaJwtVersion: String by project implementation("com.auth0:java-jwt:$javaJwtVersion") 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 be83969..162f209 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 @@ -2,8 +2,8 @@ package org.radarbase.jersey.service.managementportal import jakarta.ws.rs.core.Context import org.radarbase.jersey.auth.AuthConfig -import org.radarbase.management.auth.ClientCredentialsConfig -import org.radarbase.management.auth.clientCredentials +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 From 8c9d484015572748cb08b54eb7d60d6d43b62b79 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 16 Mar 2023 15:33:49 +0100 Subject: [PATCH 09/36] Bump dev version --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index b3b3a0a..cbf9330 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ plugins { allprojects { group = "org.radarbase" - version = "0.10.0" + version = "0.10.1-SNAPSHOT" } subprojects { From 97f5b9ee4d495e80d5e7f95ee7efa2cb7d975b71 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 16 Mar 2023 15:37:06 +0100 Subject: [PATCH 10/36] Use latest release assets upload --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7db5901..f78d25c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: # 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/*' repo-token: ${{ secrets.GITHUB_TOKEN }} From 7630aaf18653408cc309098905daddbeee755c95 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 23 Mar 2023 10:04:01 +0100 Subject: [PATCH 11/36] Applied radar-commons plugins --- .editorconfig | 26 ++ build.gradle.kts | 239 +++--------------- buildSrc/build.gradle.kts | 7 + buildSrc/src/main/kotlin/Versions.kt | 38 +++ gradle.properties | 35 --- gradle/wrapper/gradle-wrapper.properties | 2 +- radar-jersey-hibernate/build.gradle.kts | 65 ++--- .../jersey/hibernate/DatabaseHealthMetrics.kt | 2 +- .../jersey/hibernate/HibernateRepository.kt | 4 +- .../hibernate/RadarEntityManagerFactory.kt | 2 +- .../RadarEntityManagerFactoryFactory.kt | 3 +- .../hibernate/config/CloseableTransaction.kt | 1 - .../jersey/hibernate/config/DatabaseConfig.kt | 3 +- .../config/HibernateResourceEnhancer.kt | 18 +- .../hibernate/config/RadarPersistenceInfo.kt | 12 +- .../hibernate/DatabaseHealthMetricsTest.kt | 25 +- .../jersey/hibernate/HibernateTest.kt | 42 +-- .../hibernate/db/ProjectRepositoryImpl.kt | 34 +-- .../hibernate/mock/MockProjectService.kt | 2 +- .../hibernate/mock/MockResourceEnhancer.kt | 14 +- .../mock/resource/ProjectResource.kt | 5 +- radar-jersey/build.gradle.kts | 95 +++---- .../org/radarbase/jersey/GrizzlyServer.kt | 8 +- .../org/radarbase/jersey/auth/AuthConfig.kt | 63 ++--- .../org/radarbase/jersey/auth/AuthService.kt | 35 +-- .../radarbase/jersey/auth/AuthValidator.kt | 5 +- .../radarbase/jersey/auth/Authenticated.kt | 8 +- .../radarbase/jersey/auth/NeedsPermission.kt | 2 +- .../auth/disabled/DisabledAuthValidator.kt | 2 +- .../auth/filter/AuthenticationFilter.kt | 10 +- .../jersey/auth/filter/PermissionFilter.kt | 4 +- .../jersey/auth/jwt/EcdsaJwtTokenValidator.kt | 23 +- .../jersey/auth/jwt/RadarTokenFactory.kt | 4 +- .../jersey/auth/jwt/TokenValidatorFactory.kt | 6 +- .../ManagementPortalTokenValidator.kt | 2 +- .../org/radarbase/jersey/cache/Cache.kt | 2 +- .../jersey/cache/CacheControlFilter.kt | 4 +- .../org/radarbase/jersey/cache/NoCache.kt | 2 +- .../radarbase/jersey/config/ConfigLoader.kt | 4 +- .../radarbase/jersey/enhancer/Enhancers.kt | 9 +- .../jersey/enhancer/HealthResourceEnhancer.kt | 2 +- .../jersey/enhancer/MapperResourceEnhancer.kt | 14 +- .../enhancer/RadarJerseyResourceEnhancer.kt | 2 +- .../exception/ExceptionResourceEnhancer.kt | 2 +- .../exception/HttpApplicationException.kt | 4 +- .../exception/HttpBadGatewayException.kt | 4 +- .../exception/HttpBadRequestException.kt | 2 +- .../jersey/exception/HttpConflictException.kt | 2 +- .../exception/HttpGatewayTimeoutException.kt | 4 +- .../exception/HttpInternalServerException.kt | 2 +- .../exception/HttpInvalidContentException.kt | 4 +- .../jersey/exception/HttpNotFoundException.kt | 2 +- .../exception/HttpRequestEntityTooLarge.kt | 4 +- .../HttpServerUnavailableException.kt | 2 +- .../exception/HttpUnauthorizedException.kt | 4 +- .../mapper/DefaultJsonExceptionRenderer.kt | 2 +- .../mapper/DefaultTextExceptionRenderer.kt | 2 +- .../mapper/HtmlTemplateExceptionRenderer.kt | 28 +- .../org/radarbase/jersey/filter/Filters.kt | 2 + .../jersey/filter/ResponseLoggerFilter.kt | 1 + .../jersey/resource/HealthResource.kt | 1 - .../jersey/service/ImmediateHealthService.kt | 11 +- .../managementportal/MPClientFactory.kt | 2 +- .../managementportal/MPProjectService.kt | 20 +- .../managementportal/ProjectServiceWrapper.kt | 2 +- .../managementportal/RadarProjectService.kt | 1 - .../org/radarbase/jersey/auth/OAuthHelper.kt | 44 ++-- .../auth/RadarJerseyResourceEnhancerTest.kt | 112 ++++---- ...sabledAuthorizationResourceEnhancerTest.kt | 9 +- .../doc/config/SwaggerResourceEnhancerTest.kt | 4 +- .../enhancer/MapperResourceEnhancerTest.kt | 14 +- ...MockDisabledAuthResourceEnhancerFactory.kt | 8 +- .../jersey/mock/MockResourceEnhancer.kt | 6 +- .../mock/MockResourceEnhancerFactory.kt | 2 +- .../MockSwaggerResourceEnhancerFactory.kt | 23 +- .../jersey/mock/resource/MockResource.kt | 8 +- .../radarbase/jersey/util/OkHttpExtensions.kt | 2 +- settings.gradle.kts | 25 +- 78 files changed, 564 insertions(+), 686 deletions(-) create mode 100644 .editorconfig create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/Versions.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ce7dcfe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# 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 + +[*.{kt,kts}] +ktlint_standard_no-wildcard-imports = disabled + +[*.md] +trim_trailing_whitespace = false + +[*.{json,yaml,yml}] +indent_style = space +indent_size = 2 diff --git a/build.gradle.kts b/build.gradle.kts index 329c117..a7e7ca7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,223 +1,56 @@ -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.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.2.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.11.0-SNAPSHOT" +radarRootProject { + projectVersion.set("0.11.0-SNAPSHOT") } subprojects { - apply(plugin = "kotlin") - apply(plugin = "maven-publish") - apply(plugin = "signing") - apply(plugin = "org.jetbrains.dokka") - - val myProject = this - - val githubRepoName = "RADAR-base/radar-jersey" - val githubUrl = "https://github.com/$githubRepoName.git" - val githubIssueUrl = "https://github.com/$githubRepoName/issues" - - extra.apply { - set("githubRepoName", githubRepoName) - set("githubUrl", githubUrl) - set("githubIssueUrl", githubIssueUrl) - } - - repositories { - mavenCentral() - mavenLocal() - maven(url = "https://oss.sonatype.org/content/repositories/snapshots") + apply(plugin = "org.radarbase.radar-kotlin") + apply(plugin = "org.radarbase.radar-publishing") + + 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") + } + developer { + id.set("nivemaham") + name.set("Nivethika Mahasivam") + email.set("nivethika@thehyve.nl") + organization.set("The Hyve") + } + } } 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 log4j2Version = Versions.log4j2 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(myProject.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) - } - - tasks.withType { - options.release.set(11) - } - - tasks.withType { - kotlinOptions { - jvmTarget = "11" - apiVersion = "1.7" - languageVersion = "1.7" + tasks.withType { + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = FULL } + useJUnitPlatform() + systemProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager") } - - 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 myProject.name, - "Implementation-Version" to myProject.version - ) - } - } - - 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(myProject.name) - description.set(myProject.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) - } - } - } - } - } - - signing { - useGpgCmd() - isRequired = true - sign(tasks["sourcesJar"], tasks["dokkaJar"]) - sign(publishing.publications["mavenJar"]) - } - - tasks.withType().configureEach { - onlyIf { gradle.taskGraph.hasTask("${project.path}:publish") } - } - } -} - -val stableVersionRegex = "[0-9,.v-]+(-r)?".toRegex() - -fun isNonStable(version: String): Boolean { - val stableKeyword = listOf("RELEASE", "FINAL", "GA", "-CE") - .any { version.toUpperCase().contains(it) } - return !stableKeyword && !stableVersionRegex.matches(version) -} - -tasks.withType { - rejectVersionIf { - isNonStable(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.1" } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..876c922 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt new file mode 100644 index 0000000..a76f117 --- /dev/null +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -0,0 +1,38 @@ +object Versions { + const val radarCommons = "0.16.0-SNAPSHOT" + + const val kotlin = "1.8.10" + const val dokka = "1.7.20" + const val jsoup = "1.15.4" + + const val jersey = "3.1.1" + const val grizzly = "4.0.0" + const val okhttp = "4.10.0" + const val junit = "5.9.2" + const val hamcrest = "2.2" + const val mockitoKotlin = "4.1.0" + + const val hk2 = "3.0.3" + const val managementPortal = "2.0.1-SNAPSHOT" + const val javaJwt = "4.3.0" + const val jakartaWsRs = "3.1.0" + const val jakartaAnnotation = "2.1.1" + const val jackson = "2.14.2" + const val slf4j = "2.0.6" + const val log4j2 = "2.20.0" + const val jakartaXmlBind = "4.0.0" + const val jakartaJaxbCore = "4.0.2" + const val jakartaJaxbRuntime = "4.0.2" + const val jakartaValidation = "3.0.2" + const val hibernateValidator = "8.0.0.Final" + const val glassfishJakartaEl = "4.0.2" + const val jakartaActivation = "2.1.1" + const val swagger = "2.2.8" + const val mustache = "0.9.10" + + const val hibernate = "6.1.7.Final" + const val liquibase = "4.19.0" + const val postgres = "42.5.4" + const val h2 = "2.1.214" + +} diff --git a/gradle.properties b/gradle.properties index 6cde7f9..94c9d0e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,38 +2,3 @@ org.gradle.jvmargs=-Xmx2000m org.gradle.vfs.watch=true kotlin.code.style=official - -kotlinVersion=1.8.10 -dokkaVersion=1.7.20 -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.1-SNAPSHOT -radarCommonsVersion=0.16.0-SNAPSHOT -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.19.0 -postgresVersion=42.5.4 -h2Version=2.1.214 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fc10b60..bdc9a83 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/radar-jersey-hibernate/build.gradle.kts b/radar-jersey-hibernate/build.gradle.kts index 80faad6..1619403 100644 --- a/radar-jersey-hibernate/build.gradle.kts +++ b/radar-jersey-hibernate/build.gradle.kts @@ -5,46 +5,31 @@ 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-hikaricp:$hibernateVersion") - - val radarCommonsVersion: String by project - implementation("org.radarbase:radar-commons-kotlin:$radarCommonsVersion") - - 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.slf4j:slf4j-api:${Versions.slf4j}") + + 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 b569e81..d51b08a 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 @@ -18,7 +18,7 @@ class DatabaseHealthMetrics( @Context private val entityManager: Provider, @Context dbConfig: DatabaseConfig, @Context private val requestScope: RequestScope, -): Metric(name = "db") { +) : Metric(name = "db") { private val cachedStatus = CachedValue( CacheConfig( refreshDuration = dbConfig.healthCheckValiditySeconds.seconds, 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 75231d7..d557e08 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 @@ -44,7 +44,7 @@ open class HibernateRepository( } finally { storedTransaction.set(null) } - } + }, ) } catch (ex: Throwable) { continuation.resumeWithException(ex) @@ -71,7 +71,7 @@ open class HibernateRepository( suspendTransaction.abort() throw ex } - } + }, ) companion object { 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..edbd95f 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 @@ -15,7 +15,7 @@ import java.util.* * 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 6368bde..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,7 +1,6 @@ package org.radarbase.jersey.hibernate.config import jakarta.persistence.EntityTransaction -import java.io.Closeable interface CloseableTransaction { val transaction: EntityTransaction 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 2c5595e..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 9135255..caa7b2d 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 @@ -11,8 +11,8 @@ import java.util.* 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") @@ -25,10 +25,10 @@ class RadarPersistenceInfo( "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 + "hibernate.dialect" to config.dialect, ) .filter { (_, v) -> v != null } - .forEach { (k, v) -> put (k, v) } + .forEach { (k, v) -> put(k, v) } putAll(config.properties) } @@ -77,8 +77,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 17b8097..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,6 +1,5 @@ package org.radarbase.jersey.hibernate -import okhttp3.ConnectionPool import okhttp3.OkHttpClient import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo @@ -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) @@ -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,7 +67,6 @@ 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") @@ -93,7 +93,6 @@ internal class DatabaseHealthMetricsTest { .readTimeout(30, TimeUnit.SECONDS) .build() - client.call { url("http://localhost:9091/health") }.use { response -> 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 0f0cbcd..d2f1111 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 @@ -31,12 +31,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) @@ -52,7 +53,6 @@ internal class HibernateTest { server.shutdown() } - @Test fun testBasicGet() { client.call { @@ -63,7 +63,6 @@ internal class HibernateTest { } } - @Test fun testMissingProject() { client.call { @@ -92,18 +91,22 @@ internal class HibernateTest { fun testOverload(): Unit = runBlocking { client = OkHttpClient.Builder() .connectionPool(ConnectionPool(64, 30, TimeUnit.MINUTES)) - .dispatcher(Dispatcher().apply { - maxRequestsPerHost = 64 - }) + .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() - }) + val call = client.newCall( + Request.Builder().run { + post("test".toRequestBody(JSON_TYPE)) + url("http://localhost:9091/projects/query") + build() + }, + ) continuation.invokeOnCancellation { call.cancel() } @@ -131,14 +134,15 @@ internal class HibernateTest { 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.call { url("http://localhost:9091/projects") }.use { response -> @@ -177,5 +181,5 @@ internal inline fun OkHttpClient.call(builder: Request.Builder.() -> Unit): Resp Request.Builder().run { builder() build() - } + }, ).execute() 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 94c3092..daa712a 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 @@ -9,10 +9,10 @@ import org.radarbase.jersey.hibernate.HibernateRepository class ProjectRepositoryImpl( @Context em: Provider, @Context requestScope: RequestScope, -): ProjectRepository, HibernateRepository(em, requestScope) { +) : ProjectRepository, HibernateRepository(em, requestScope) { override suspend fun list(): List = transact { createQuery("SELECT p FROM Project p", ProjectDao::class.java) - .resultList + .resultList } override suspend fun create(name: String, description: String?, organization: String): ProjectDao = transact { @@ -26,28 +26,28 @@ class ProjectRepositoryImpl( 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 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 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 253dc00..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,7 +15,7 @@ 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 suspend fun ensureOrganization(organizationId: String) { if (projects.list().none { it.organization == organizationId }) { 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 16d9a24..559d856 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 @@ -4,20 +4,17 @@ import jakarta.ws.rs.* import jakarta.ws.rs.container.AsyncResponse import jakarta.ws.rs.container.Suspended import jakarta.ws.rs.core.Context -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import org.radarbase.jersey.coroutines.runAsCoroutine import org.radarbase.jersey.exception.HttpNotFoundException import org.radarbase.jersey.hibernate.db.ProjectRepository -import java.util.concurrent.atomic.AtomicInteger 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, ) { @POST @Path("query") diff --git a/radar-jersey/build.gradle.kts b/radar-jersey/build.gradle.kts index 990c5ae..62937bd 100644 --- a/radar-jersey/build.gradle.kts +++ b/radar-jersey/build.gradle.kts @@ -5,78 +5,57 @@ 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 radarCommonsVersion: String by project - implementation("org.radarbase:radar-commons-kotlin:$radarCommonsVersion") - - 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") - 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") + implementation("com.github.spullara.mustache.java:compiler:${Versions.mustache}") - val slf4jVersion: String by project - implementation("org.slf4j:slf4j-api:$slf4jVersion") + implementation("org.slf4j:slf4j-api:${Versions.slf4j}") - 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 okhttpVersion: String by project - testImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") - - 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.junit.jupiter:junit-jupiter:${Versions.junit}") + 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/AuthConfig.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt index fa217d2..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 @@ -15,49 +15,50 @@ 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, - val jwksUrls: List = emptyList(), + /** 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, ) { /** 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 index 0e13a77..d2c2289 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -66,7 +66,7 @@ class AuthService( suspend fun hasPermission( permission: Permission, - entity: EntityDetails + entity: EntityDetails, ) = oracle.hasPermission(token, permission, entity) /** @@ -133,7 +133,7 @@ class AuthService( } else if (org != organization) { throw HttpNotFoundException( "organization_not_found", - "Organization $organization not found for project $project." + "Organization $organization not found for project $project.", ) } val subject = subject @@ -162,7 +162,7 @@ class AuthService( wwwAuthenticateHeader = HttpUnauthorizedException.wwwAuthenticateHeader( error = "insufficient_scope", errorDescription = message, - scope = permission.toString() + scope = permission.toString(), ), ) } @@ -235,27 +235,28 @@ class AuthService( 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 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("logPermission") - || startsWith("checkPermission") - || startsWith("invoke") - || startsWith("internal") + get() = startsWith("logPermission") || + startsWith("checkPermission") || + startsWith("invoke") || + startsWith("internal") private val Class<*>.isAuthClass: Boolean - get() = isInstance(AuthService::class.java) - || isAnonymousClass - || isLocalClass - || simpleName == "ReflectionHelper" + get() = isInstance(AuthService::class.java) || + isAnonymousClass || + isLocalClass || + 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 9de3a93..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 @@ -22,8 +22,9 @@ interface AuthValidator { 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 6de7704..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 @@ -17,7 +17,7 @@ import org.radarbase.auth.authorization.Permission @Target( AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, - AnnotationTarget.PROPERTY_SETTER + AnnotationTarget.PROPERTY_SETTER, ) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented 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 d88fdc3..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 @@ -10,7 +10,7 @@ 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): RadarToken = DataRadarToken( 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 05f2093..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) 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 47adffe..b054935 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 @@ -41,5 +41,7 @@ class PermissionFilter( private fun String.fetchPathParam(): String? = if (isNotEmpty()) { uriInfo.pathParameters[this]?.firstOrNull() - } else null + } else { + null + } } 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 c7bd16d..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 @@ -11,26 +11,13 @@ package org.radarbase.jersey.auth.jwt import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context -import org.radarbase.auth.authentication.StaticTokenVerifierLoader import org.radarbase.auth.authentication.TokenValidator import org.radarbase.auth.authorization.AuthorityReference import org.radarbase.auth.authorization.RoleAuthority -import org.radarbase.auth.exception.TokenValidationException -import org.radarbase.auth.jwks.ECPEMCertificateParser -import org.radarbase.auth.jwks.JwksTokenVerifierLoader.Companion.toTokenVerifier -import org.radarbase.auth.jwks.RSAPEMCertificateParser -import org.radarbase.auth.jwks.toAlgorithm import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.auth.AuthValidator import org.slf4j.Logger 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 kotlin.io.path.inputStream class EcdsaJwtTokenValidator constructor( @Context private val tokenValidator: TokenValidator, @@ -40,10 +27,12 @@ class EcdsaJwtTokenValidator constructor( return try { val radarToken = tokenValidator.validateBlocking(token) - return radarToken.copyWithRoles(buildSet { - addAll(radarToken.roles) - add(AuthorityReference(RoleAuthority.PARTICIPANT, project)) - }) + return radarToken.copyWithRoles( + buildSet { + addAll(radarToken.roles) + add(AuthorityReference(RoleAuthority.PARTICIPANT, project)) + }, + ) } catch (ex: Throwable) { logger.warn("JWT verification exception", ex) null 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 index ded9449..2efe162 100644 --- 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 @@ -17,8 +17,8 @@ import java.util.function.Supplier /** Generates radar tokens from the security context. */ class RadarTokenFactory( - @Context private val context: ContainerRequestContext + @Context private val context: ContainerRequestContext, ) : Supplier { override fun get() = (context.securityContext as? RadarSecurityContext)?.token - ?: throw IllegalStateException("Created null wrapper") + ?: throw IllegalStateException("Created null wrapper") } 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 index 98e37ec..78dad48 100644 --- 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 @@ -59,7 +59,7 @@ class TokenValidatorFactory( val pkcs12Store = KeyStore.getInstance("pkcs12") pkcs12Store.load( Paths.get(config.jwtKeystorePath).inputStream(), - config.jwtKeystorePassword?.toCharArray() + config.jwtKeystorePassword?.toCharArray(), ) when (val publicKey = pkcs12Store.getCertificate(config.jwtKeystoreAlias).publicKey) { is ECPublicKey -> publicKey.toAlgorithm() @@ -68,7 +68,7 @@ class TokenValidatorFactory( } } catch (ex: Exception) { throw IllegalStateException("Failed to initialize JWT ECDSA public key", ex) - } + }, ) } } @@ -81,7 +81,7 @@ class TokenValidatorFactory( withIssuer(it) } } - } + }, ) } 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 49e800c..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 @@ -17,7 +17,7 @@ import org.radarbase.jersey.auth.AuthValidator /** Creates a TokenValidator based on the current management portal configuration. */ class ManagementPortalTokenValidator( - @Context private val tokenValidator: TokenValidator + @Context private val tokenValidator: TokenValidator, ) : AuthValidator { override fun verify(token: String, request: ContainerRequestContext): RadarToken? = tokenValidator.validateBlocking(token) 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 0b510e3..ad97ca0 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 @@ -148,6 +148,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/enhancer/Enhancers.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/Enhancers.kt index 514a8d4..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 @@ -12,23 +12,30 @@ object Enhancers { /** Adds authorization framework, configuration and utilities. */ fun radar( config: AuthConfig, - includeMapper: Boolean = true + includeMapper: Boolean = true, ) = 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() + /** 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/RadarJerseyResourceEnhancer.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/enhancer/RadarJerseyResourceEnhancer.kt index ce73053..97dbb44 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 @@ -31,7 +31,7 @@ import org.radarbase.jersey.auth.jwt.RadarTokenFactory class RadarJerseyResourceEnhancer( private val config: AuthConfig, includeMapper: Boolean = true, -): JerseyResourceEnhancer { +) : JerseyResourceEnhancer { /** * Utilities. Set to `null` to avoid injection. Modify utility mapper or client to inject * a different mapper or client. 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..7fface0 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 @@ -7,7 +7,7 @@ import org.radarbase.jersey.enhancer.JerseyResourceEnhancer import org.radarbase.jersey.exception.mapper.* /** 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 768b6e8..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 @@ -17,8 +17,8 @@ open class HttpApplicationException( 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) + 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) 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 index a1033c6..1c5349e 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/exception/HttpServerUnavailableException.kt @@ -9,5 +9,5 @@ class HttpServerUnavailableException( status = Response.Status.SERVICE_UNAVAILABLE, code = "timeout", detailedMessage = message, - additionalHeaders = additionalHeaders + 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/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 febec2a..6cd16c8 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 @@ -11,7 +11,6 @@ import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.MediaType.APPLICATION_JSON import org.radarbase.jersey.coroutines.runAsCoroutine import org.radarbase.jersey.service.HealthService -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Path("/health") 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 c20c736..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 @@ -8,16 +8,17 @@ 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 suspend fun computeStatus(): HealthService.Status = if (allMetrics.forkAny { - val status = it.computeStatus() - status == HealthService.Status.DOWN - }) { + val status = it.computeStatus() + status == HealthService.Status.DOWN + } + ) { HealthService.Status.DOWN } else { HealthService.Status.UP 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 162f209..15a428b 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 @@ -27,7 +27,7 @@ class MPClientFactory( clientCredentials( authConfig = authConfig, - targetHost = URL(url).host + targetHost = URL(url).host, ) } } 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 8c0958c..19abf41 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 @@ -70,15 +70,15 @@ class MPProjectService( override suspend fun userProjects(permission: Permission): List { return projects.get().values - .filter { - authService.get().hasPermission( - permission, - EntityDetails( - organization = it.organization?.id, - project = it.id - ) - ) - } + .filter { + authService.get().hasPermission( + permission, + EntityDetails( + organization = it.organization?.id, + project = it.id, + ), + ) + } } override suspend fun ensureProject(projectId: String) { @@ -93,7 +93,7 @@ class MPProjectService( override suspend fun projectSubjects(projectId: String): List = projectUserCache(projectId).get().values.toList() override suspend fun subjectByExternalId(projectId: String, externalUserId: String): MPSubject? = projectUserCache(projectId) - .findValue { it.externalId == externalUserId } + .findValue { it.externalId == externalUserId } override suspend fun ensureSubject(projectId: String, userId: String) { ensureProject(projectId) 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 a9243b6..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,7 +22,7 @@ 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 suspend fun ensureOrganization(organizationId: String) = radarProjectService.get().ensureOrganization(organizationId) 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 6860d65..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 @@ -23,7 +23,6 @@ import org.radarbase.jersey.service.ProjectService import org.radarbase.management.client.MPProject import org.radarbase.management.client.MPSubject - interface RadarProjectService : ProjectService { override suspend fun ensureProject(projectId: String) { project(projectId) 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 d4bf8a8..e96a697 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 @@ -7,7 +7,6 @@ import org.radarbase.auth.authentication.StaticTokenVerifierLoader import org.radarbase.auth.authentication.TokenValidator import org.radarbase.auth.authorization.Permission import org.radarbase.auth.jwks.JwksTokenVerifierLoader.Companion.toTokenVerifier -import java.net.URI import java.security.KeyStore import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPublicKey @@ -30,8 +29,10 @@ 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 @@ -39,7 +40,7 @@ class OAuthHelper { validEcToken = createValidToken(ecdsa) tokenValidator = TokenValidator( - listOf(StaticTokenVerifierLoader(listOf(ecdsa.toTokenVerifier("res_ManagementPortal")))) + listOf(StaticTokenVerifierLoader(listOf(ecdsa.toTokenVerifier("res_ManagementPortal")))), ) } @@ -47,21 +48,21 @@ class OAuthHelper { 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 { @@ -78,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 beb36c0..6320039 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 @@ -80,16 +80,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"}""")) @@ -98,11 +99,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"}""")) @@ -111,23 +113,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)) @@ -137,10 +140,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)) @@ -150,11 +154,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)) @@ -164,10 +169,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)) @@ -177,11 +183,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)) @@ -191,11 +198,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)) @@ -207,14 +215,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)) @@ -232,7 +240,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() @@ -246,7 +254,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() @@ -260,7 +268,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..8a8f79c 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 @@ -21,7 +21,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 +37,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/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..c00aa25 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 @@ -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 a1f540d..f9b954b 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 @@ -53,9 +53,11 @@ 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, 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 index 0493513..43e8903 100644 --- a/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt +++ b/radar-jersey/src/test/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt @@ -6,7 +6,7 @@ import okhttp3.Response inline fun OkHttpClient.request(builder: Request.Builder.() -> Unit, callback: (Response) -> T): T = newCall( - Request.Builder().apply(builder).build() + Request.Builder().apply(builder).build(), ).execute().use(callback) /** diff --git a/settings.gradle.kts b/settings.gradle.kts index cac26e1..bb59d92 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,19 +1,34 @@ +import java.util.* + rootProject.name = "radar-jersey" include("radar-jersey") include("radar-jersey-hibernate") pluginManagement { - val kotlinVersion: String by settings - val dokkaVersion: String by settings - repositories { gradlePluginPortal() mavenCentral() + maven(url = "https://maven.pkg.github.com/radar-base/radar-commons") { + credentials { + username = System.getenv("GITHUB_ACTOR") + ?: extra.properties["gpr.user"] as? String + ?: extra.properties["public.gpr.user"] as? String + password = System.getenv("GITHUB_TOKEN") + ?: extra.properties["gpr.token"] as? String + ?: (extra.properties["public.gpr.token"] as? String)?.let { + Base64.getDecoder().decode(it).decodeToString() + } + } + } } plugins { - kotlin("jvm") version kotlinVersion - id("org.jetbrains.dokka") version dokkaVersion + val radarCommonsVersion = "0.16.0-SNAPSHOT" + id("org.radarbase.radar-root-project") version radarCommonsVersion + id("org.radarbase.radar-dependency-management") version radarCommonsVersion + id("org.radarbase.radar-kotlin") version radarCommonsVersion + id("org.radarbase.radar-publishing") version radarCommonsVersion + } } From 9924c61af8c50e14619e30fe634292531404a0b6 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Thu, 23 Mar 2023 11:12:29 +0100 Subject: [PATCH 12/36] Move more config to radar-commons --- build.gradle.kts | 24 ++++++------------------ gradle.properties | 3 +++ settings.gradle.kts | 4 +--- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a7e7ca7..3719273 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL import org.radarbase.gradle.plugin.radarPublishing import org.radarbase.gradle.plugin.radarRootProject +import org.radarbase.gradle.plugin.radarKotlin plugins { id("org.radarbase.radar-root-project") version Versions.radarCommons @@ -17,6 +18,11 @@ subprojects { apply(plugin = "org.radarbase.radar-kotlin") apply(plugin = "org.radarbase.radar-publishing") + radarKotlin { + log4j2Version.set(Versions.log4j2) + slf4jVersion.set(Versions.slf4j) + } + radarPublishing { val githubRepoName = "RADAR-base/radar-jersey" githubUrl.set("https://github.com/$githubRepoName.git") @@ -35,22 +41,4 @@ subprojects { } } } - - dependencies { - val log4j2Version = Versions.log4j2 - 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") - } - - tasks.withType { - testLogging { - events("passed", "skipped", "failed") - showStandardStreams = true - exceptionFormat = FULL - } - useJUnitPlatform() - systemProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager") - } } diff --git a/gradle.properties b/gradle.properties index 94c9d0e..0ec765f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,6 @@ org.gradle.jvmargs=-Xmx2000m org.gradle.vfs.watch=true kotlin.code.style=official + +public.gpr.user=radar-public +public.gpr.token=Z2hwX0h0d0FHSmJzeEpjenBlUVIycVhWb0RpNGdZdHZnZzJTMFVJZA== diff --git a/settings.gradle.kts b/settings.gradle.kts index bb59d92..04a9031 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,3 @@ -import java.util.* - rootProject.name = "radar-jersey" include("radar-jersey") @@ -17,7 +15,7 @@ pluginManagement { password = System.getenv("GITHUB_TOKEN") ?: extra.properties["gpr.token"] as? String ?: (extra.properties["public.gpr.token"] as? String)?.let { - Base64.getDecoder().decode(it).decodeToString() + java.util.Base64.getDecoder().decode(it).decodeToString() } } } From e791056160b50d570e1c14c9d169d3d3225a3aaf Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 28 Mar 2023 13:21:02 +0200 Subject: [PATCH 13/36] Added AysncCoroutineService to handle response scope --- build.gradle.kts | 3 +- gradle.properties | 2 - .../jersey/hibernate/DatabaseHealthMetrics.kt | 6 +- .../jersey/hibernate/HibernateRepository.kt | 87 +++++++------- .../jersey/hibernate/HibernateTest.kt | 91 ++++++++++++++ .../hibernate/db/ProjectRepositoryImpl.kt | 6 +- .../mock/resource/ProjectResource.kt | 27 +++-- .../org/radarbase/jersey/auth/AuthService.kt | 65 ++++++---- .../auth/filter/AuthorizationFeature.kt | 2 + .../jersey/auth/filter/PermissionFilter.kt | 2 + .../auth/filter/RadarSecurityContext.kt | 8 ++ .../jersey/auth/jwt/RadarTokenFactory.kt | 5 +- .../jersey/auth/jwt/TokenValidatorFactory.kt | 2 +- .../coroutines/CoroutineRequestContext.kt | 10 ++ .../coroutines/CoroutineResponseWrapper.kt | 58 +++++++++ .../radarbase/jersey/coroutines/Coroutines.kt | 50 -------- .../enhancer/RadarJerseyResourceEnhancer.kt | 10 +- .../jersey/resource/HealthResource.kt | 5 +- .../jersey/service/AsyncCoroutineService.kt | 30 +++++ .../service/ScopedAsyncCoroutineService.kt | 113 ++++++++++++++++++ settings.gradle.kts | 9 -- 21 files changed, 441 insertions(+), 150 deletions(-) create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestContext.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt delete mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/service/AsyncCoroutineService.kt create mode 100644 radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3719273..2bb8cc0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ -import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL +import org.radarbase.gradle.plugin.radarKotlin import org.radarbase.gradle.plugin.radarPublishing import org.radarbase.gradle.plugin.radarRootProject -import org.radarbase.gradle.plugin.radarKotlin plugins { id("org.radarbase.radar-root-project") version Versions.radarCommons diff --git a/gradle.properties b/gradle.properties index 0ec765f..3187fda 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,4 @@ org.gradle.jvmargs=-Xmx2000m - -org.gradle.vfs.watch=true kotlin.code.style=official public.gpr.user=radar-public 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 d51b08a..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 @@ -5,9 +5,9 @@ import jakarta.persistence.EntityManager import jakarta.ws.rs.core.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.glassfish.jersey.process.internal.RequestScope 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.kotlin.coroutines.CacheConfig @@ -17,7 +17,7 @@ import kotlin.time.Duration.Companion.seconds class DatabaseHealthMetrics( @Context private val entityManager: Provider, @Context dbConfig: DatabaseConfig, - @Context private val requestScope: RequestScope, + @Context private val asyncService: AsyncCoroutineService, ) : Metric(name = "db") { private val cachedStatus = CachedValue( CacheConfig( @@ -34,7 +34,7 @@ class DatabaseHealthMetrics( private suspend fun testConnection(): HealthService.Status = withContext(Dispatchers.IO) { try { - requestScope.runInScope { + asyncService.runInRequestScope { entityManager.get().useConnection { it.close() } } HealthService.Status.UP 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 d557e08..becd34b 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 @@ -4,19 +4,18 @@ import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.persistence.EntityTransaction import kotlinx.coroutines.* -import org.glassfish.jersey.process.internal.RequestScope 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.Callable import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException open class HibernateRepository( private val entityManagerProvider: Provider, - private val requestScope: RequestScope, + private val asyncService: AsyncCoroutineService, ) { @Suppress("MemberVisibilityCanBePrivate") protected val entityManager: EntityManager @@ -26,53 +25,51 @@ open class HibernateRepository( * Run a transaction and commit it. If an exception occurs, the transaction is rolled back. */ suspend fun transact(transactionOperation: EntityManager.() -> T): T = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { continuation -> - val storedTransaction = AtomicReference(null) - continuation.invokeOnCancellation { storedTransaction.get()?.cancel() } - try { - continuation.resume( - createTransaction { transaction -> - storedTransaction.set(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 - } finally { - storedTransaction.set(null) - } - }, - ) - } catch (ex: Throwable) { - continuation.resumeWithException(ex) - } - } + 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. */ - fun createTransaction( - transactionOperation: EntityManager.(CloseableTransaction) -> T, - ): T = requestScope.runInScope( - Callable { - 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) - try { - suspendTransaction.begin() - em.transactionOperation(suspendTransaction) - } catch (ex: Exception) { - logger.error("Rolling back operation", ex) - suspendTransaction.abort() - throw ex - } - }, - ) + 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 { + suspendTransaction.begin() + continuation.resume(em.block(suspendTransaction)) + } catch (ex: Exception) { + logger.error("Rolling back operation", ex) + suspendTransaction.abort() + continuation.resumeWithException(ex) + } finally { + storedTransaction.set(null) + } + } companion object { private val logger = LoggerFactory.getLogger(HibernateRepository::class.java) 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 d2f1111..149f595 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,7 +1,9 @@ package org.radarbase.jersey.hibernate +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody @@ -23,6 +25,7 @@ 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 @@ -86,6 +89,67 @@ internal class HibernateTest { } } + @Test + fun testTime(): Unit = runBlocking { + client = OkHttpClient.Builder() + .connectionPool(ConnectionPool(64, 30, TimeUnit.MINUTES)) + .dispatcher( + Dispatcher().apply { + maxRequestsPerHost = 64 + }, + ) + .build() + + val size = 2000 + + var startTime = System.nanoTime() + + client.makeCalls(size) { + url("http://localhost:9091/projects/empty") + } + + var newTime = System.nanoTime() + var diff = (newTime - startTime).nanoseconds + println("first: $diff") + startTime = newTime + + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-suspend") + } + + newTime = System.nanoTime() + diff = (newTime - startTime).nanoseconds + println("first: $diff") + startTime = newTime + + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-blocking") + } + + newTime = System.nanoTime() + diff = (newTime - startTime).nanoseconds + println("first: $diff") + + startTime = newTime + + client.makeCalls(size) { + url("http://localhost:9091/projects/empty") + } + + newTime = System.nanoTime() + diff = (newTime - startTime).nanoseconds + println("first: $diff") + startTime = newTime + + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-suspend") + } + + newTime = System.nanoTime() + diff = (newTime - startTime).nanoseconds + println("first: $diff") + } + @Test @Timeout(2) fun testOverload(): Unit = runBlocking { @@ -177,6 +241,33 @@ internal class HibernateTest { } } +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() 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 daa712a..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 @@ -3,13 +3,13 @@ package org.radarbase.jersey.hibernate.db import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.ws.rs.core.Context -import org.glassfish.jersey.process.internal.RequestScope import org.radarbase.jersey.hibernate.HibernateRepository +import org.radarbase.jersey.service.AsyncCoroutineService class ProjectRepositoryImpl( @Context em: Provider, - @Context requestScope: RequestScope, -) : ProjectRepository, HibernateRepository(em, requestScope) { + @Context asyncCoroutineService: AsyncCoroutineService, +) : ProjectRepository, HibernateRepository(em, asyncCoroutineService) { override suspend fun list(): List = transact { createQuery("SELECT p FROM Project p", ProjectDao::class.java) .resultList 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 559d856..fc48e76 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 @@ -5,9 +5,9 @@ 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.coroutines.runAsCoroutine import org.radarbase.jersey.exception.HttpNotFoundException import org.radarbase.jersey.hibernate.db.ProjectRepository +import org.radarbase.jersey.service.AsyncCoroutineService import kotlin.time.Duration.Companion.seconds @Path("projects") @@ -15,25 +15,38 @@ import kotlin.time.Duration.Companion.seconds @Produces("application/json") class ProjectResource( @Context private val projects: ProjectRepository, + @Context private val asyncService: AsyncCoroutineService, ) { @POST @Path("query") - fun query(@Suspended asyncResponse: AsyncResponse) = asyncResponse.runAsCoroutine { + fun query(@Suspended asyncResponse: AsyncResponse) = asyncService.runAsCoroutine(asyncResponse) { delay(1.seconds) "{\"result\": 1}" } @GET - fun projects(@Suspended asyncResponse: AsyncResponse) = asyncResponse.runAsCoroutine { + 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 + @Path("empty-blocking") + fun emptyBlocking() = asyncService.runBlocking { listOf() } + @GET @Path("{id}") fun project( @PathParam("id") id: Long, @Suspended asyncResponse: AsyncResponse, - ) = asyncResponse.runAsCoroutine { + ) = asyncService.runAsCoroutine(asyncResponse) { projects.get(id) ?: throw HttpNotFoundException("project_not_found", "Project with ID $id does not exist") } @@ -44,7 +57,7 @@ class ProjectResource( @PathParam("id") id: Long, values: Map, @Suspended asyncResponse: AsyncResponse, - ) = asyncResponse.runAsCoroutine { + ) = asyncService.runAsCoroutine(asyncResponse) { projects.update(id, values["description"], values.getValue("organization")) ?: throw HttpNotFoundException("project_not_found", "Project with ID $id does not exist") } @@ -53,7 +66,7 @@ class ProjectResource( fun createProject( values: Map, @Suspended asyncResponse: AsyncResponse, - ) = asyncResponse.runAsCoroutine { + ) = asyncService.runAsCoroutine(asyncResponse) { projects.create(values.getValue("name"), values["description"], values.getValue("organization")) } @@ -62,5 +75,5 @@ class ProjectResource( fun deleteProject( @PathParam("id") id: Long, @Suspended asyncResponse: AsyncResponse, - ) = asyncResponse.runAsCoroutine { projects.delete(id) } + ) = asyncService.runAsCoroutine(asyncResponse) { projects.delete(id) } } 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 index d2c2289..cf8e03d 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -2,12 +2,13 @@ package org.radarbase.jersey.auth import jakarta.inject.Provider import jakarta.ws.rs.core.Context -import kotlinx.coroutines.runBlocking import org.radarbase.auth.authorization.* +import org.radarbase.auth.token.DataRadarToken.Companion.copy 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 @@ -15,27 +16,30 @@ class AuthService( @Context private val oracle: AuthorizationOracle, @Context private val tokenProvider: Provider, @Context private val projectService: ProjectService, + @Context private val asyncService: AsyncCoroutineService, ) { - val token: RadarToken - get() = try { - tokenProvider.get() + suspend fun requestScopedToken(): RadarToken = asyncService.runInRequestScope { + try { + tokenProvider.get().copy() } 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, location: String? = null) { + fun checkScope(permission: Permission, token: RadarToken, location: String? = null) { if (!oracle.hasScope(token, permission)) { throw forbiddenException( permission = permission, + token = token, location = location, ) } - logAuthorized(permission, location) + logAuthorized(permission, token, location) } /** @@ -46,20 +50,22 @@ class AuthService( */ 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, location) + logAuthorized(permission, token, location) } else { - checkPermissionBlocking(permission, entity, location, permission.entity) + checkPermissionBlocking(permission, entity, token, location, permission.entity) } return entity } @@ -67,7 +73,8 @@ class AuthService( suspend fun hasPermission( permission: Permission, entity: EntityDetails, - ) = oracle.hasPermission(token, permission, entity) + token: RadarToken? = null, + ) = oracle.hasPermission(token ?: requestScopedToken(), permission, entity) /** * Check whether [token] has permission [permission], regarding given [entity]. @@ -78,13 +85,14 @@ class AuthService( fun checkPermissionBlocking( permission: Permission, entity: EntityDetails, + token: RadarToken? = null, location: String? = null, scope: Permission.Entity = permission.entity, - ) = runBlocking { - checkPermission(permission, entity, location, scope) + ) = asyncService.runBlocking { + checkPermission(permission, entity, token, location, scope) } - fun activeParticipantProject(): String? = token.roles + suspend fun activeParticipantProject(token: RadarToken? = null): String? = (token ?: requestScopedToken()).roles .firstOrNull { it.role == RoleAuthority.PARTICIPANT } ?.referent @@ -97,13 +105,15 @@ class AuthService( 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( - token, + actualToken, permission, entity, scope, @@ -111,6 +121,7 @@ class AuthService( ) { throw forbiddenException( permission = permission, + token = actualToken, location = location, entity, ) @@ -118,6 +129,7 @@ class AuthService( logAuthorized( permission = permission, + token = actualToken, location = location, entity = entity, ) @@ -145,14 +157,22 @@ class AuthService( } } + 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, ) @@ -167,15 +187,23 @@ class AuthService( ) } + 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, location, entity) + ) = logPermission(true, permission, token, location, entity) private fun logPermission( isAuthorized: Boolean, permission: Permission, + token: RadarToken, location: String? = null, entity: EntityDetails? = null, ): String { @@ -216,13 +244,8 @@ class AuthService( return message } - fun referentsByScope(permission: Permission): AuthorityReferenceSet { - val token = try { - tokenProvider.get() - } catch (ex: Throwable) { - return AuthorityReferenceSet() - } - return oracle.referentsByScope(token, permission) + suspend fun referentsByScope(permission: Permission): AuthorityReferenceSet { + return oracle.referentsByScope(requestScopedToken(), permission) } fun mayBeGranted(role: RoleAuthority, permission: Permission): Boolean = with(oracle) { 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 b054935..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 @@ -16,6 +16,7 @@ import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.UriInfo import org.radarbase.jersey.auth.AuthService import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.auth.filter.RadarSecurityContext.Companion.radarSecurityContext /** * Check that the token has given permissions. @@ -31,6 +32,7 @@ class PermissionFilter( authService.checkScopeAndPermission( permission = annotation.permission, + token = requestContext.radarSecurityContext.token, location = "${requestContext.method} ${requestContext.uriInfo.path}", ) { organization = annotation.organizationPathParam.fetchPathParam() 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 c362f49..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,6 +9,7 @@ package org.radarbase.jersey.auth.filter +import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.SecurityContext import org.radarbase.auth.authorization.AuthorityReference import org.radarbase.auth.token.RadarToken @@ -60,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/RadarTokenFactory.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt index 2efe162..c4a4303 100644 --- 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 @@ -12,13 +12,12 @@ package org.radarbase.jersey.auth.jwt import jakarta.ws.rs.container.ContainerRequestContext import jakarta.ws.rs.core.Context import org.radarbase.auth.token.RadarToken -import org.radarbase.jersey.auth.filter.RadarSecurityContext +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: ContainerRequestContext, ) : Supplier { - override fun get() = (context.securityContext as? RadarSecurityContext)?.token - ?: throw IllegalStateException("Created null wrapper") + 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 index 78dad48..24ae050 100644 --- 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 @@ -89,7 +89,7 @@ class TokenValidatorFactory( throw TokenValidationException("No verification algorithms given") } - logger.info("Verifying JWTs with ${algorithms.size} algorithms") + logger.info("Verifying JWTs with ${tokenVerifierLoaders.size} token verifiers") return TokenValidator( verifierLoaders = tokenVerifierLoaders, 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..1318b11 --- /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/CoroutineResponseWrapper.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt new file mode 100644 index 0000000..2b10243 --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt @@ -0,0 +1,58 @@ +package org.radarbase.jersey.coroutines + +import kotlinx.coroutines.* +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 CoroutineResponseWrapper( + private val timeout: Duration? = 30.seconds, + requestScope: RequestScope? = null, + location: String? = null, +) { + private val job = Job() + + val coroutineContext: CoroutineContext + + private val requestContext = try { + requestScope?.createContext() + } catch (ex: Throwable) { + logger.debug("Cannot create request scope: {}", ex.toString()) + null + } + + init { + var context = job + + CoroutineName("Request coroutine ${location ?: ""}#${Thread.currentThread().id}") + + Dispatchers.Default + + if (requestContext != null) { + context += CoroutineRequestContext(requestContext) + } + coroutineContext = context + } + + fun cancel() { + 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(CoroutineResponseWrapper::class.java) + } +} diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt deleted file mode 100644 index c9703d0..0000000 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/Coroutines.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.radarbase.jersey.coroutines - -import jakarta.ws.rs.container.AsyncResponse -import jakarta.ws.rs.container.ConnectionCallback -import kotlinx.coroutines.* -import org.radarbase.jersey.exception.HttpServerUnavailableException -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -/** - * 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. - */ -fun AsyncResponse.runAsCoroutine( - timeout: Duration? = 30.seconds, - block: suspend () -> T, -) { - val job = Job() - - val emit: (T) -> Unit = { value -> - resume(value) - job.cancel() - } - val cancel: (Throwable) -> Unit = { ex -> - resume(ex) - job.cancel() - } - - register(ConnectionCallback { job.cancel() }) - - CoroutineScope(job + Dispatchers.Default).launch { - if (timeout != null) { - launch { - delay(timeout) - cancel(HttpServerUnavailableException()) - } - } - try { - emit(block()) - } catch (ex: CancellationException) { - // do nothing - job.cancel(ex) - } catch (ex: Throwable) { - cancel(ex) - } - } -} 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 97dbb44..9e6786f 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 @@ -20,6 +20,8 @@ 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.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 @@ -53,10 +55,14 @@ 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(RadarTokenFactory::class.java) - .proxy(true) - .proxyForSameScope(true) + .proxy(false) + .proxyForSameScope(false) .to(RadarToken::class.java) .`in`(RequestScoped::class.java) 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 6cd16c8..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 @@ -9,7 +9,7 @@ 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.coroutines.runAsCoroutine +import org.radarbase.jersey.service.AsyncCoroutineService import org.radarbase.jersey.service.HealthService import kotlin.time.Duration.Companion.seconds @@ -17,11 +17,12 @@ import kotlin.time.Duration.Companion.seconds @Resource @Singleton class HealthResource( + @Context private val asyncService: AsyncCoroutineService, @Context private val healthService: HealthService, ) { @GET @Produces(APPLICATION_JSON) - fun healthStatus(@Suspended asyncResponse: AsyncResponse) = asyncResponse.runAsCoroutine(5.seconds) { + 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..864cf3d --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/AsyncCoroutineService.kt @@ -0,0 +1,30 @@ +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. + */ + fun runAsCoroutine( + asyncResponse: AsyncResponse, + timeout: Duration = 30.seconds, + block: suspend () -> T, + ) + + fun runBlocking( + timeout: Duration = 30.seconds, + block: suspend () -> T, + ): T + + suspend fun runInRequestScope(block: () -> T): T + suspend fun suspendInRequestScope(block: (CancellableContinuation) -> Unit): T +} 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..fa4a21d --- /dev/null +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt @@ -0,0 +1,113 @@ +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.* +import org.glassfish.jersey.process.internal.RequestScope +import org.radarbase.jersey.coroutines.CoroutineRequestContext +import org.radarbase.jersey.coroutines.CoroutineResponseWrapper +import org.radarbase.kotlin.coroutines.consumeFirst +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class ScopedAsyncCoroutineService( + @Context private val requestScope: Provider, + @Context private val requestContext: Provider, + @Context private val uriInfo: Provider, +) : 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. + */ + override fun runAsCoroutine( + asyncResponse: AsyncResponse, + timeout: Duration, + block: suspend () -> T, + ) { + with( + CoroutineResponseWrapper( + timeout, + requestScope.get(), + "${requestContext.get().method} ${uriInfo.get().path}", + ), + ) { + asyncResponse.register(ConnectionCallback { cancel() }) + + CoroutineScope(coroutineContext).launch { + timeoutHandler { + asyncResponse.resume(it) + this@with.cancel() + } + try { + asyncResponse.resume(block()) + } catch (ex: CancellationException) { + // do nothing + } catch (ex: Throwable) { + asyncResponse.resume(ex) + } finally { + this@with.cancel() + } + } + } + } + + override fun runBlocking( + timeout: Duration, + block: suspend () -> T, + ): T { + return with( + CoroutineResponseWrapper( + timeout, + requestScope.get(), + "${requestContext.get().method} ${uriInfo.get().path}", + ), + ) { + 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 { + this@with.cancel() + } + } + } + + 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/settings.gradle.kts b/settings.gradle.kts index 04a9031..568c5ec 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,13 +20,4 @@ pluginManagement { } } } - - plugins { - val radarCommonsVersion = "0.16.0-SNAPSHOT" - id("org.radarbase.radar-root-project") version radarCommonsVersion - id("org.radarbase.radar-dependency-management") version radarCommonsVersion - id("org.radarbase.radar-kotlin") version radarCommonsVersion - id("org.radarbase.radar-publishing") version radarCommonsVersion - - } } From b9939dc42e70bbcbd26a02f6c788f3e7f046f0ac Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 28 Mar 2023 14:51:25 +0200 Subject: [PATCH 14/36] Simplified build --- build.gradle.kts | 3 + buildSrc/build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Versions.kt | 13 ++-- radar-jersey-hibernate/build.gradle.kts | 2 - .../jersey/hibernate/HibernateTest.kt | 66 ++++++++----------- radar-jersey/build.gradle.kts | 3 - .../coroutines/CoroutineRequestContext.kt | 2 +- .../service/ScopedAsyncCoroutineService.kt | 1 - 8 files changed, 38 insertions(+), 54 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2bb8cc0..0dddefd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,8 +18,11 @@ subprojects { 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 { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 876c922..5843a81 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - `kotlin-dsl` + kotlin("jvm") version "1.8.10" } repositories { diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index a76f117..d5387b6 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,10 +1,9 @@ object Versions { const val radarCommons = "0.16.0-SNAPSHOT" - const val kotlin = "1.8.10" - const val dokka = "1.7.20" - const val jsoup = "1.15.4" + val kotlin = KotlinVersion.CURRENT.toString() + const val java: Int = 17 const val jersey = "3.1.1" const val grizzly = "4.0.0" const val okhttp = "4.10.0" @@ -18,7 +17,7 @@ object Versions { const val jakartaWsRs = "3.1.0" const val jakartaAnnotation = "2.1.1" const val jackson = "2.14.2" - const val slf4j = "2.0.6" + const val slf4j = "2.0.7" const val log4j2 = "2.20.0" const val jakartaXmlBind = "4.0.0" const val jakartaJaxbCore = "4.0.2" @@ -27,12 +26,12 @@ object Versions { const val hibernateValidator = "8.0.0.Final" const val glassfishJakartaEl = "4.0.2" const val jakartaActivation = "2.1.1" - const val swagger = "2.2.8" + const val swagger = "2.2.9" const val mustache = "0.9.10" const val hibernate = "6.1.7.Final" - const val liquibase = "4.19.0" - const val postgres = "42.5.4" + const val liquibase = "4.20.0" + const val postgres = "42.6.0" const val h2 = "2.1.214" } diff --git a/radar-jersey-hibernate/build.gradle.kts b/radar-jersey-hibernate/build.gradle.kts index 1619403..befaf10 100644 --- a/radar-jersey-hibernate/build.gradle.kts +++ b/radar-jersey-hibernate/build.gradle.kts @@ -19,8 +19,6 @@ dependencies { runtimeOnly("org.glassfish:jakarta.el:${Versions.glassfishJakartaEl}") - implementation("org.slf4j:slf4j-api:${Versions.slf4j}") - implementation("org.liquibase:liquibase-core:${Versions.liquibase}") runtimeOnly("org.postgresql:postgresql:${Versions.postgres}") 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 149f595..4ec31a0 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 @@ -3,7 +3,6 @@ package org.radarbase.jersey.hibernate import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody @@ -100,54 +99,44 @@ internal class HibernateTest { ) .build() - val size = 2000 + val size = 20 - var startTime = System.nanoTime() - - client.makeCalls(size) { - url("http://localhost:9091/projects/empty") + time("blocking 1") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty") + } } - var newTime = System.nanoTime() - var diff = (newTime - startTime).nanoseconds - println("first: $diff") - startTime = newTime - - client.makeCalls(size) { - url("http://localhost:9091/projects/empty-suspend") + time("async 1") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-suspend") + } } - newTime = System.nanoTime() - diff = (newTime - startTime).nanoseconds - println("first: $diff") - startTime = newTime - - client.makeCalls(size) { - url("http://localhost:9091/projects/empty-blocking") + time("blocking coroutine") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-blocking") + } } - newTime = System.nanoTime() - diff = (newTime - startTime).nanoseconds - println("first: $diff") - - startTime = newTime - - client.makeCalls(size) { - url("http://localhost:9091/projects/empty") + time("blocking 2") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty") + } } - newTime = System.nanoTime() - diff = (newTime - startTime).nanoseconds - println("first: $diff") - startTime = newTime - - client.makeCalls(size) { - url("http://localhost:9091/projects/empty-suspend") + time("async 2") { + client.makeCalls(size) { + url("http://localhost:9091/projects/empty-suspend") + } } + } - newTime = System.nanoTime() - diff = (newTime - startTime).nanoseconds - println("first: $diff") + suspend fun time(label: String, block: suspend () -> Unit) { + val startTime = System.nanoTime() + block() + val diff = (System.nanoTime() - startTime).nanoseconds + println("$label: $diff") } @Test @@ -267,7 +256,6 @@ internal suspend fun OkHttpClient.makeCalls(size: Int, requestBuilder: Request.B } } - internal inline fun OkHttpClient.call(builder: Request.Builder.() -> Unit): Response = newCall( Request.Builder().run { builder() diff --git a/radar-jersey/build.gradle.kts b/radar-jersey/build.gradle.kts index 62937bd..85cbbc2 100644 --- a/radar-jersey/build.gradle.kts +++ b/radar-jersey/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { // exception template rendering implementation("com.github.spullara.mustache.java:compiler:${Versions.mustache}") - implementation("org.slf4j:slf4j-api:${Versions.slf4j}") - implementation("io.swagger.core.v3:swagger-jaxrs2-jakarta:${Versions.swagger}") { exclude(group = "com.fasterxml.jackson.jaxrs", module = "jackson-jaxrs-json-provider") } @@ -52,7 +50,6 @@ dependencies { testRuntimeOnly("org.glassfish.grizzly:grizzly-http-server:${Versions.grizzly}") testRuntimeOnly("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:${Versions.jersey}") - testImplementation("org.junit.jupiter:junit-jupiter:${Versions.junit}") testImplementation("org.hamcrest:hamcrest:${Versions.hamcrest}") testImplementation("org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlin}") 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 index 1318b11..2a13b85 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestContext.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestContext.kt @@ -4,7 +4,7 @@ import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext open class CoroutineRequestContext( - val requestContext: org.glassfish.jersey.process.internal.RequestContext + 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/service/ScopedAsyncCoroutineService.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt index fa4a21d..a679063 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt @@ -12,7 +12,6 @@ import org.radarbase.jersey.coroutines.CoroutineRequestContext import org.radarbase.jersey.coroutines.CoroutineResponseWrapper import org.radarbase.kotlin.coroutines.consumeFirst import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds class ScopedAsyncCoroutineService( @Context private val requestScope: Provider, From 34af404d33459a2b94bf0408ead999ff99080250 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 28 Mar 2023 15:39:14 +0200 Subject: [PATCH 15/36] Misc fixes --- .../kotlin/org/radarbase/jersey/auth/AuthService.kt | 10 +++++----- .../org/radarbase/jersey/auth/jwt/RadarTokenFactory.kt | 4 ++-- .../jersey/coroutines/CoroutineResponseWrapper.kt | 2 +- .../jersey/enhancer/RadarJerseyResourceEnhancer.kt | 3 --- 4 files changed, 8 insertions(+), 11 deletions(-) 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 index cf8e03d..b2dd1af 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -231,11 +231,11 @@ class AuthService( append(' ') buildList(6) { - entity.organization?.let { add("organization: $it") } - entity.project?.let { add("project: $it") } - entity.subject?.let { add("subject: $it") } - entity.source?.let { add("source: $it") } - entity.user?.let { add("user: $it") } + 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 = "}") } } 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 index c4a4303..4ee9858 100644 --- 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 @@ -9,15 +9,15 @@ package org.radarbase.jersey.auth.jwt -import jakarta.ws.rs.container.ContainerRequestContext 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: ContainerRequestContext, + @Context private val context: ContainerRequest, ) : Supplier { override fun get(): RadarToken = context.radarSecurityContext.token } diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt index 2b10243..a63d3be 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt @@ -18,7 +18,7 @@ class CoroutineResponseWrapper( val coroutineContext: CoroutineContext private val requestContext = try { - requestScope?.createContext() + requestScope?.suspendCurrent() } catch (ex: Throwable) { logger.debug("Cannot create request scope: {}", ex.toString()) null 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 9e6786f..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 @@ -28,7 +28,6 @@ import org.radarbase.jersey.service.ScopedAsyncCoroutineService * 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, @@ -61,8 +60,6 @@ class RadarJerseyResourceEnhancer( // Bind factories. bindFactory(RadarTokenFactory::class.java) - .proxy(false) - .proxyForSameScope(false) .to(RadarToken::class.java) .`in`(RequestScoped::class.java) From a330f8c1519bc7082bff35defc8fe0d929cfbd6a Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 10 May 2023 15:35:45 +0200 Subject: [PATCH 16/36] Update dependencies and fix deprecations --- build.gradle.kts | 3 ++- buildSrc/build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Versions.kt | 23 +++++++++--------- gradle/wrapper/gradle-wrapper.jar | Bin 61608 -> 62076 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 7 +++--- .../hibernate/DatabaseInitialization.kt | 19 +++++++++------ .../coroutines/CoroutineResponseWrapper.kt | 10 +++++--- 8 files changed, 37 insertions(+), 29 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0dddefd..93de5e9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,8 @@ plugins { } radarRootProject { - projectVersion.set("0.11.0-SNAPSHOT") + projectVersion.set(Versions.project) + gradleVersion.set(Versions.wrapper) } subprojects { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 5843a81..ef98af2 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("jvm") version "1.8.10" + kotlin("jvm") version "1.8.21" } repositories { diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index d5387b6..2710340 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,22 +1,22 @@ object Versions { - const val radarCommons = "0.16.0-SNAPSHOT" - - val kotlin = KotlinVersion.CURRENT.toString() + const val project = "0.11.0-SNAPSHOT" + const val kotlin = "1.8.21" const val java: Int = 17 const val jersey = "3.1.1" const val grizzly = "4.0.0" - const val okhttp = "4.10.0" - const val junit = "5.9.2" + const val okhttp = "4.11.0" + const val junit = "5.9.3" const val hamcrest = "2.2" const val mockitoKotlin = "4.1.0" - const val hk2 = "3.0.3" + const val hk2 = "3.0.4" const val managementPortal = "2.0.1-SNAPSHOT" - const val javaJwt = "4.3.0" + const val radarCommons = "1.0.0" + const val javaJwt = "4.4.0" const val jakartaWsRs = "3.1.0" const val jakartaAnnotation = "2.1.1" - const val jackson = "2.14.2" + const val jackson = "2.15.0" const val slf4j = "2.0.7" const val log4j2 = "2.20.0" const val jakartaXmlBind = "4.0.0" @@ -25,13 +25,14 @@ object Versions { const val jakartaValidation = "3.0.2" const val hibernateValidator = "8.0.0.Final" const val glassfishJakartaEl = "4.0.2" - const val jakartaActivation = "2.1.1" + const val jakartaActivation = "2.1.2" const val swagger = "2.2.9" const val mustache = "0.9.10" - const val hibernate = "6.1.7.Final" - const val liquibase = "4.20.0" + const val hibernate = "6.2.2.Final" + const val liquibase = "4.21.1" const val postgres = "42.6.0" const val h2 = "2.1.214" + const val wrapper = "8.1.1" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7710deaf9f98673a68957ea02138b60d0a..c1962a79e29d3e0ab67b14947c167a862655af9b 100644 GIT binary patch delta 8979 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 F{TFmrHLU;u delta 8469 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 + addArgumentValue(UpdateCommandStep.CONTEXTS_ARG, context) + } + addArgumentValue(DATABASE_ARG, database) + execute() + } } override fun onRequest(requestEvent: RequestEvent?): RequestEventListener? = null diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt index a63d3be..dc6e3be 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt @@ -25,10 +25,7 @@ class CoroutineResponseWrapper( } init { - var context = job + - CoroutineName("Request coroutine ${location ?: ""}#${Thread.currentThread().id}") + - Dispatchers.Default - + var context = job + contextName(location) + Dispatchers.Default if (requestContext != null) { context += CoroutineRequestContext(requestContext) } @@ -54,5 +51,10 @@ class CoroutineResponseWrapper( companion object { private val logger = LoggerFactory.getLogger(CoroutineResponseWrapper::class.java) + + @Suppress("DEPRECATION", "KotlinRedundantDiagnosticSuppress") + private fun contextName(location: String?) = CoroutineName( + "Request coroutine ${location ?: ""}#${Thread.currentThread().id}", + ) } } From 82bf6937a27139f1a1341b8ea2561bc8edb03ca1 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 15 May 2023 11:26:25 +0200 Subject: [PATCH 17/36] Build with JVM 20 --- buildSrc/build.gradle.kts | 14 ++++++++++++++ .../service/managementportal/MPClientFactory.kt | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index ef98af2..eefe754 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,3 +1,6 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { kotlin("jvm") version "1.8.21" } @@ -5,3 +8,14 @@ plugins { repositories { mavenCentral() } + +tasks.withType { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +tasks.withType { + sourceCompatibility = "11" + targetCompatibility = "11" +} 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 15a428b..073bae5 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 @@ -7,7 +7,7 @@ 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.URL +import java.net.URI import java.util.function.Supplier class MPClientFactory( @@ -27,7 +27,7 @@ class MPClientFactory( clientCredentials( authConfig = authConfig, - targetHost = URL(url).host, + targetHost = URI.create(url).host, ) } } From e0a617937c91db780859766a899ca510e48952a7 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 15 May 2023 13:52:29 +0200 Subject: [PATCH 18/36] Fixed Ktlint check --- .../main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 7e3cea0..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) - } + }, ) } From b407e0a6a15da3749e64075ba394197359d09eef Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 15 May 2023 15:45:11 +0200 Subject: [PATCH 19/36] Fix snaphshot check --- .github/workflows/publish_snapshots.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 From bb45ed107bfa2e74e5954c656442947462dc2a46 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 10:38:13 +0200 Subject: [PATCH 20/36] Avoid leaking credentials --- ...eResponseWrapper.kt => CoroutineRequestWrapper.kt} | 11 ++++++++--- .../jersey/service/ScopedAsyncCoroutineService.kt | 6 +++--- .../service/managementportal/MPClientFactory.kt | 8 ++++++-- 3 files changed, 17 insertions(+), 8 deletions(-) rename radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/{CoroutineResponseWrapper.kt => CoroutineRequestWrapper.kt} (87%) diff --git a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt similarity index 87% rename from radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt rename to radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt index dc6e3be..21ea79d 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineResponseWrapper.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt @@ -8,7 +8,7 @@ import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -class CoroutineResponseWrapper( +class CoroutineRequestWrapper( private val timeout: Duration? = 30.seconds, requestScope: RequestScope? = null, location: String? = null, @@ -18,7 +18,12 @@ class CoroutineResponseWrapper( val coroutineContext: CoroutineContext private val requestContext = try { - requestScope?.suspendCurrent() + if (requestScope != null) { + requestScope.suspendCurrent() + ?: requestScope.createContext() + } else { + null + } } catch (ex: Throwable) { logger.debug("Cannot create request scope: {}", ex.toString()) null @@ -50,7 +55,7 @@ class CoroutineResponseWrapper( } companion object { - private val logger = LoggerFactory.getLogger(CoroutineResponseWrapper::class.java) + private val logger = LoggerFactory.getLogger(CoroutineRequestWrapper::class.java) @Suppress("DEPRECATION", "KotlinRedundantDiagnosticSuppress") private fun contextName(location: String?) = CoroutineName( 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 index a679063..95523f3 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt @@ -9,7 +9,7 @@ import jakarta.ws.rs.core.UriInfo import kotlinx.coroutines.* import org.glassfish.jersey.process.internal.RequestScope import org.radarbase.jersey.coroutines.CoroutineRequestContext -import org.radarbase.jersey.coroutines.CoroutineResponseWrapper +import org.radarbase.jersey.coroutines.CoroutineRequestWrapper import org.radarbase.kotlin.coroutines.consumeFirst import kotlin.time.Duration @@ -32,7 +32,7 @@ class ScopedAsyncCoroutineService( block: suspend () -> T, ) { with( - CoroutineResponseWrapper( + CoroutineRequestWrapper( timeout, requestScope.get(), "${requestContext.get().method} ${uriInfo.get().path}", @@ -63,7 +63,7 @@ class ScopedAsyncCoroutineService( block: suspend () -> T, ): T { return with( - CoroutineResponseWrapper( + CoroutineRequestWrapper( timeout, requestScope.get(), "${requestContext.get().method} ${uriInfo.get().path}", 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 073bae5..f24ea13 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 @@ -23,11 +23,15 @@ class MPClientFactory( clientSecret = authConfig.managementPortal.clientSecret, ).copyWithEnv() - logger.info("Configuring MPClient with {}", authConfig) + logger.info( + "Configuring MPClient with URL {} and client ID {}", + authConfig.tokenUrl, + authConfig.clientId + ) clientCredentials( authConfig = authConfig, - targetHost = URI.create(url).host, + targetHost = URI.create(url!!).host, ) } } From d6704da25c0c17aac5e72c8a281016a142f2b2c2 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 12:18:50 +0200 Subject: [PATCH 21/36] Add AuthService.withPermission function --- .../org/radarbase/jersey/auth/AuthService.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) 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 index b2dd1af..8cab27a 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -2,6 +2,8 @@ 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.* import org.radarbase.auth.token.DataRadarToken.Companion.copy import org.radarbase.auth.token.RadarToken @@ -18,7 +20,7 @@ class AuthService( @Context private val projectService: ProjectService, @Context private val asyncService: AsyncCoroutineService, ) { - suspend fun requestScopedToken(): RadarToken = asyncService.runInRequestScope { + private suspend fun requestScopedToken(): RadarToken = asyncService.runInRequestScope { try { tokenProvider.get().copy() } catch (ex: Throwable) { @@ -135,6 +137,33 @@ class AuthService( ) } + /** + * 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 From d679c18600e76c4519f1a581648cde08f8202d9a Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 13:12:54 +0200 Subject: [PATCH 22/36] Ktlint fix --- .../jersey/service/managementportal/MPClientFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f24ea13..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 @@ -26,7 +26,7 @@ class MPClientFactory( logger.info( "Configuring MPClient with URL {} and client ID {}", authConfig.tokenUrl, - authConfig.clientId + authConfig.clientId, ) clientCredentials( From 3b8066b9870006b21deb0eec978318363d7ecfbd Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 22 May 2023 13:17:03 +0200 Subject: [PATCH 23/36] Remove layer of indirection for authService --- .../jersey/service/managementportal/MPProjectService.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 19abf41..9eec8e1 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 @@ -69,9 +69,11 @@ class MPProjectService( } override suspend fun userProjects(permission: Permission): List { - return projects.get().values + val authService = authService.get() + return projects.get() + .values .filter { - authService.get().hasPermission( + authService.hasPermission( permission, EntityDetails( organization = it.organization?.id, From 35b9f5ab95c33e918fa5bf7fa135a1604b15a96d Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 23 May 2023 17:03:24 +0200 Subject: [PATCH 24/36] Documented AsyncCoroutineService --- .../org/radarbase/jersey/auth/AuthService.kt | 7 ++++-- .../jersey/service/AsyncCoroutineService.kt | 22 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) 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 index 8cab27a..27680ce 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -300,15 +300,18 @@ class AuthService( get() = methodName.isAuthMethodName || declaringClass.isAuthClass private val String.isAuthMethodName: Boolean - get() = startsWith("logPermission") || + get() = + startsWith("logAuthorized") || + startsWith("logPermission") || startsWith("checkPermission") || startsWith("invoke") || startsWith("internal") private val Class<*>.isAuthClass: Boolean - get() = isInstance(AuthService::class.java) || + get() = this == AuthService::class.java || isAnonymousClass || isLocalClass || + name.contains("AuthService") || simpleName == "ReflectionHelper" } } 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 index 864cf3d..0040efb 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/AsyncCoroutineService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/AsyncCoroutineService.kt @@ -12,7 +12,8 @@ interface AsyncCoroutineService { * [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. + * 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, @@ -20,11 +21,30 @@ interface AsyncCoroutineService { 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 } From 8b8fe22e62a62ba1e1ea07f90fac5f47fae7c83a Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 23 May 2023 17:03:57 +0200 Subject: [PATCH 25/36] Remove duplicate docs --- .../jersey/service/ScopedAsyncCoroutineService.kt | 7 ------- 1 file changed, 7 deletions(-) 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 index 95523f3..8900e09 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt @@ -19,13 +19,6 @@ class ScopedAsyncCoroutineService( @Context private val uriInfo: Provider, ) : 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. - */ override fun runAsCoroutine( asyncResponse: AsyncResponse, timeout: Duration, From d45ed983c84bab6ec720da39731bf864b0e62d19 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 23 May 2023 17:13:29 +0200 Subject: [PATCH 26/36] Readability improvements --- .../coroutines/CoroutineRequestWrapper.kt | 2 +- .../service/ScopedAsyncCoroutineService.kt | 65 +++++++++---------- 2 files changed, 31 insertions(+), 36 deletions(-) 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 index 21ea79d..bf21645 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt @@ -37,7 +37,7 @@ class CoroutineRequestWrapper( coroutineContext = context } - fun cancel() { + fun cancelRequest() { try { requestContext?.release() } catch (ex: Throwable) { 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 index 8900e09..e3ca004 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt @@ -24,28 +24,22 @@ class ScopedAsyncCoroutineService( timeout: Duration, block: suspend () -> T, ) { - with( - CoroutineRequestWrapper( - timeout, - requestScope.get(), - "${requestContext.get().method} ${uriInfo.get().path}", - ), - ) { - asyncResponse.register(ConnectionCallback { cancel() }) + withWrapper(timeout) { + asyncResponse.register(ConnectionCallback { cancelRequest() }) CoroutineScope(coroutineContext).launch { timeoutHandler { asyncResponse.resume(it) - this@with.cancel() + cancelRequest() } try { asyncResponse.resume(block()) } catch (ex: CancellationException) { - // do nothing + // do nothing, cancel request in finally } catch (ex: Throwable) { asyncResponse.resume(ex) } finally { - this@with.cancel() + cancelRequest() } } } @@ -54,34 +48,35 @@ class ScopedAsyncCoroutineService( override fun runBlocking( timeout: Duration, block: suspend () -> T, - ): T { - return with( - CoroutineRequestWrapper( - timeout, - requestScope.get(), - "${requestContext.get().method} ${uriInfo.get().path}", - ), - ) { - 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 { - this@with.cancel() + ): 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.""" From 7fcf4d751e40591794f87d796be3cdf720c3f854 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 24 May 2023 08:46:50 +0200 Subject: [PATCH 27/36] AuthService fixes --- .../main/kotlin/org/radarbase/jersey/auth/AuthService.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index 27680ce..d82fca5 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -5,7 +5,7 @@ import jakarta.ws.rs.core.Context import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.radarbase.auth.authorization.* -import org.radarbase.auth.token.DataRadarToken.Companion.copy +import org.radarbase.auth.token.DataRadarToken import org.radarbase.auth.token.RadarToken import org.radarbase.jersey.exception.HttpForbiddenException import org.radarbase.jersey.exception.HttpNotFoundException @@ -20,9 +20,9 @@ class AuthService( @Context private val projectService: ProjectService, @Context private val asyncService: AsyncCoroutineService, ) { - private suspend fun requestScopedToken(): RadarToken = asyncService.runInRequestScope { + suspend fun requestScopedToken(): RadarToken = asyncService.runInRequestScope { try { - tokenProvider.get().copy() + DataRadarToken(tokenProvider.get()) } catch (ex: Throwable) { throw HttpForbiddenException("unauthorized", "User without authentication does not have permission.") } @@ -300,8 +300,7 @@ class AuthService( get() = methodName.isAuthMethodName || declaringClass.isAuthClass private val String.isAuthMethodName: Boolean - get() = - startsWith("logAuthorized") || + get() = startsWith("logAuthorized") || startsWith("logPermission") || startsWith("checkPermission") || startsWith("invoke") || From 066b0881787080ba2da40328c52959cba1a8eaa1 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 24 May 2023 09:48:52 +0200 Subject: [PATCH 28/36] Added missing authservice function --- .../kotlin/org/radarbase/jersey/auth/AuthService.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index d82fca5..3c8d3bd 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -277,10 +277,20 @@ class AuthService( return oracle.referentsByScope(requestScopedToken(), permission) } - fun mayBeGranted(role: RoleAuthority, permission: Permission): Boolean = with(oracle) { + 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) From 181ec8eb98009d51cc62f18565f509a327f7c1e9 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Tue, 4 Jul 2023 14:52:28 +0200 Subject: [PATCH 29/36] Bump dependencies and collect licenses --- .github/workflows/release.yml | 4 ++-- buildSrc/src/main/kotlin/Versions.kt | 24 +++++++++++------------ gradle/wrapper/gradle-wrapper.jar | Bin 62076 -> 63375 bytes gradle/wrapper/gradle-wrapper.properties | 3 ++- gradlew | 5 ++++- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f78d25c..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.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/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 2710340..da844ee 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,38 +1,38 @@ object Versions { const val project = "0.11.0-SNAPSHOT" - const val kotlin = "1.8.21" + const val kotlin = "1.8.22" const val java: Int = 17 - const val jersey = "3.1.1" + const val jersey = "3.1.2" const val grizzly = "4.0.0" const val okhttp = "4.11.0" const val junit = "5.9.3" const val hamcrest = "2.2" - const val mockitoKotlin = "4.1.0" + const val mockitoKotlin = "5.0.0" const val hk2 = "3.0.4" const val managementPortal = "2.0.1-SNAPSHOT" - const val radarCommons = "1.0.0" + const val radarCommons = "1.0.1-SNAPSHOT" const val javaJwt = "4.4.0" const val jakartaWsRs = "3.1.0" const val jakartaAnnotation = "2.1.1" - const val jackson = "2.15.0" + const val jackson = "2.15.2" const val slf4j = "2.0.7" const val log4j2 = "2.20.0" const val jakartaXmlBind = "4.0.0" - const val jakartaJaxbCore = "4.0.2" - const val jakartaJaxbRuntime = "4.0.2" + const val jakartaJaxbCore = "4.0.3" + const val jakartaJaxbRuntime = "4.0.3" const val jakartaValidation = "3.0.2" - const val hibernateValidator = "8.0.0.Final" + const val hibernateValidator = "8.0.1.Final" const val glassfishJakartaEl = "4.0.2" const val jakartaActivation = "2.1.2" - const val swagger = "2.2.9" + const val swagger = "2.2.14" const val mustache = "0.9.10" - const val hibernate = "6.2.2.Final" - const val liquibase = "4.21.1" + const val hibernate = "6.2.6.Final" + const val liquibase = "4.23.0" const val postgres = "42.6.0" const val h2 = "2.1.214" - const val wrapper = "8.1.1" + const val wrapper = "8.2" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index c1962a79e29d3e0ab67b14947c167a862655af9b..033e24c4cdf41af1ab109bc7f253b2b887023340 100644 GIT binary patch delta 16170 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 delta 14892 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37aef8d..62f495d 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.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index aeb74cb..fcb6fca 100755 --- a/gradlew +++ b/gradlew @@ -130,10 +130,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. From 33eea444e62eb3311e7ae0dd2ff45d11099fbabf Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 23 Aug 2023 14:18:20 +0200 Subject: [PATCH 30/36] Default to "main" organization for non-supported MP --- .../managementportal/MPProjectService.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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 9eec8e1..ff7ae32 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 @@ -30,6 +30,7 @@ import org.radarbase.management.client.MPOrganization import org.radarbase.management.client.MPProject import org.radarbase.management.client.MPSubject import org.slf4j.LoggerFactory +import java.io.IOException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import kotlin.time.Duration.Companion.minutes @@ -56,13 +57,29 @@ class MPProjectService( ) 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: IOException) { + if (ex.message?.contains("404 Not Found") == true) { + 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) } } @@ -133,5 +150,6 @@ class MPProjectService( companion object { private val RETRY_INTERVAL = 1.minutes private val logger = LoggerFactory.getLogger(MPProjectService::class.java) + private val defaultOrganization = MPOrganization("main") } } From 915fecf49ee6a53407a5defd573206edb848b060 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Wed, 23 Aug 2023 15:19:27 +0200 Subject: [PATCH 31/36] Updated MP client implementation --- .../jersey/service/managementportal/MPProjectService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 ff7ae32..bf9478f 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,6 +16,7 @@ package org.radarbase.jersey.service.managementportal +import io.ktor.http.* import jakarta.inject.Provider import jakarta.ws.rs.core.Context import org.radarbase.auth.authorization.EntityDetails @@ -25,12 +26,12 @@ import org.radarbase.jersey.auth.AuthService import org.radarbase.jersey.exception.HttpNotFoundException 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.io.IOException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import kotlin.time.Duration.Companion.minutes @@ -61,8 +62,8 @@ class MPProjectService( mpClient.requestOrganizations() .associateBy { it.id } .also { logger.debug("Fetched organizations {}", it) } - } catch (ex: IOException) { - if (ex.message?.contains("404 Not Found") == true) { + } 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 { From 689225633cd6333284975c7a379a1911dabab289 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 18 Sep 2023 14:19:16 +0200 Subject: [PATCH 32/36] Updated dependencies --- buildSrc/build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Versions.kt | 24 +++++++++++------------ gradle.properties | 3 --- gradle/wrapper/gradle-wrapper.jar | Bin 63375 -> 63721 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 3 ++- settings.gradle.kts | 12 ------------ 7 files changed, 16 insertions(+), 30 deletions(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index eefe754..da83967 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -2,7 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") version "1.8.21" + kotlin("jvm") version "1.9.10" } repositories { diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index da844ee..90be070 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,38 +1,38 @@ object Versions { const val project = "0.11.0-SNAPSHOT" - const val kotlin = "1.8.22" + const val kotlin = "1.9.10" const val java: Int = 17 - const val jersey = "3.1.2" + const val jersey = "3.1.3" const val grizzly = "4.0.0" const val okhttp = "4.11.0" - const val junit = "5.9.3" + const val junit = "5.10.0" const val hamcrest = "2.2" - const val mockitoKotlin = "5.0.0" + const val mockitoKotlin = "5.1.0" const val hk2 = "3.0.4" const val managementPortal = "2.0.1-SNAPSHOT" - const val radarCommons = "1.0.1-SNAPSHOT" + const val radarCommons = "1.1.0" const val javaJwt = "4.4.0" const val jakartaWsRs = "3.1.0" const val jakartaAnnotation = "2.1.1" const val jackson = "2.15.2" - const val slf4j = "2.0.7" + const val slf4j = "2.0.9" const val log4j2 = "2.20.0" - const val jakartaXmlBind = "4.0.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.14" + const val swagger = "2.2.16" const val mustache = "0.9.10" - const val hibernate = "6.2.6.Final" - const val liquibase = "4.23.0" + const val hibernate = "6.3.0.Final" + const val liquibase = "4.23.2" const val postgres = "42.6.0" - const val h2 = "2.1.214" + const val h2 = "2.2.224" - const val wrapper = "8.2" + const val wrapper = "8.3" } diff --git a/gradle.properties b/gradle.properties index 3187fda..545f5db 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,2 @@ org.gradle.jvmargs=-Xmx2000m kotlin.code.style=official - -public.gpr.user=radar-public -public.gpr.token=Z2hwX0h0d0FHSmJzeEpjenBlUVIycVhWb0RpNGdZdHZnZzJTMFVJZA== diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4cdf41af1ab109bc7f253b2b887023340..7f93135c49b765f8051ef9d0a6055ff8e46073d8 100644 GIT binary patch delta 28216 zcmZ6yQ*@x+6TO*^ZQHip9ox2TJ8x{;wr$&H$LgqKv*-KI%$l`+bAK-CVxOv0&)z5g z2JHL}tl@+Jd?b>@B>9{`5um}}z@(_WbP841wh56Q*(#D!%+_WFn zxTW!hkY%qR9|LgnC$UfeVp69yjV8RF>YD%YeVEatr**mzN7 z%~mf;`MId9ttnTP(NBpBu_T!aR9RPfUey|B+hCTWWUp*Wy%dWP;fVVjO?KDc*VJ^iSto8gEBp#a5qRnMR zR-GrMr4};1AUK^Wl4El^I$-(Vox98wN~VNm(oL!Se73~FCH0%|9`4hgXt)VkY;&YA zxyNzaSx28JDZ@IjQQ-r%=U60hdM!;;Y1B&M`-jR5wo|dL0PfRJBs={0-i#sk@ffUT z&!L4AR}OfxIMF;CysW-jf@GxJRaJf6F$^KwJk-s_L0t?_fJ4k67RHAk3M+heW>EqQ>mh(Ebmt5gvhew5D{oe# zo`>K30R3ukH;X#Wq!&s zh<7!d$VmuwoQfFr&7EXB^fHQhPSUeX-@m@70<^Z-3rtpi;hOA_$6iw7N*XT>pwkm9^O|F` zV$|!O7HK<&%rdLqo6c5A>AL}T)rY)mCX9IQZdUUafh2CzC~-ixktzMIU(ZZ}?tK;b zJk9Wwx!+Ej!fTgInh8by&<<;Q+>(gN(w-wO{3c($ua2PiC10N6MH6zHuCrIMQL^<_ zJbok&IZ1f&2hF8#E}+@2;m7z@mRJbXJZAMDrA>>?YCn~dS;HOKzymOhHng2>Vqt^| zqR71FIPY1`Y_tsTs>9k)&f%JOVl9oUZ$3ufI0`kM#_d@%1~~NYRSbgq>`8HS@YCTP zN1lIW7odKxwcu71yGi#68$K_+c ziEt@@hyTm6*U^3V^=kEYm`?AR*^&DQz$%CV6-c-87CA>z6cAI!Vqdi|Jtw*PVTC)3 zlYI4yE!rS)gHla|DYjQ~Vea(In8~mqeIn7W;5?2$4lJ;wAqMcLS|AcWwN%&FK2(WL zCB@UE7+TPVkEN#q8zY_zi3x8BE+TsYo3s#nfJ3DnuABb|!28j#;A;27g+x)xLTX7; zFdUA=o26z`apjP!WJaK>P+gP2ijuSvm!WBq{8a4#OJrB?Ug=K7+zHCo#~{om5nhEs z9#&+qk>(sVESM`sJSaE)ybL7yTB^J;zDIu1m$&l!OE#yxvjF6c{p&|oM!+4^|7sVv zEAcZqfZP}eW}<;f4=Lg1u0_*M-Zd@kKx|7%JfW;#kT}yRVY^C5IX^Mr^9vW0=G!6T zF&u}?lsA7r)qVcE`SrY(kG$-uK` zy|vn}D^GBxhP+f%Y;>yBFh0^0Q5|u_)gQylO808C5xO_%+ih8?+Yv@4|M?vYB7is!1y@n%8fZ?IL%a@%Qe;9q@IC)BmfjA?Nu*COkU$PP%XoE%%B7dd0rf;*AuGIs%d zOMi)Jd9Gk%3W)sXCM{Upg&JbSh^G5j%l!y8;nw*n+WIK}OM-wt=d*R0>_L9r1Z`Z+ zc;l>^^y#C*RBicDoGdG^c-*Zr{)PYO-TL>cc2ra#H9P@ml{LnWdB+Cg@@z`F$Cg+) zG%M(!=}+i3o``uvsP4UI;}edQyyqZbhpD_!BTz{O#yrq`+%` zc`uT~qNjFFBRixfq)^)E7CBxi+tN7qW>|BPwlr(li({kN6O$wSLd~@Z?I;>xiv*V4 zNVM-0H#h?4NaQa%3c&yC zig%>pq3m7pKFUN(2zW>A1lJ+WSZAKAGYMiK8&pp)v01^a<6B_rE*}s1p0O(4zakbSt3e((EqbeC`uF1H|A;Kp%N@+b0~5;x6Sji?IUl||MmI_F~I2l;HWrhBF@A~cyW>#?3TOhsOX~T z(J+~?l^huJf-@6)ffBq5{}E(V#{dT0S-bwmxJdBun@ag@6#pTiE9Ezrr2eTc4o@dX z7^#jNNu1QkkCv-BX}AEd5UzX2tqN~X2OVPl&L0Ji(PJ5Iy^nx?^D%V!wnX-q2I;-) z60eT5kXD5n4_=;$XA%1n?+VR-OduZ$j7f}>l5G`pHDp*bY%p$(?FY8OO;Quk$1iAZ zsH$={((`g1fW)?#-qm}Z7ooqMF{7%3NJzC`sqBIK+w16yQ{=>80lt}l2ilW=>G0*7 zeU>_{?`68NS8DJ>H1#HgY!!{EG)+Cvvb{7~_tlQnzU!^l+JP7RmY4hKA zbNYsg5Imd)jj?9-HRiDIvpga&yhaS2y6}aAS?|gA9y$}Z2w%N?Hi;14$6Qt9Fc(zl zSClM66;E1hxh^>PDv1XMq3yzJ#jIQ2n+?hwjw)8hFcXDQ$PiWf{s&^_>jbGGeg0{e zx4b5kIhB2gIgyS27y+;DfV`%)h1F!WTP!76o?^QsSBR~nBXnz|IYr*$k${m-u>9Mj z>09A!u0*q9wSQ>0WDmmm6hKju+`dxYkybvA=1jG|1`G$ikS^okbnAN=Wz*xojmwWtY zZq{@FnLJg|h&Ci78w-ZXi=9I>WkRlD1d>c0=b9iXFguf*jq8UF(aM^HPO6~l!aXXi zc4bhK;mEsobxUit``hThf!0qvU3#~h%+C7bA-UJ%beFlm%?79KFM=Q2ALm>*ejo)1 zN33ZFKX8=zsg25G0Ab*X= zdcI5{@`irEC^Vn3q59Jucz{N6{KZY%y!;&|6(=B*Qp4*X@6+qsstjw|K^Wnh^m zw8Uv>6;*bKq>4?Gx3QFDLt`0UxmmN7Xiq<$s>g!~1}N!FL8j3aRyuwusB^Rr5ctV|o-cP?J#Un1>4_;4aB&7@B;k zdZy2^x1cZ-*IQTd25OC9?`_p0K$U0DHZIt8<7E+h=)E^Rp0gzu`UVffNxwLzG zX*D_UAl34>+%*J+r|O0;FZ>F4(Wc?6+cR=BtS-N0cj2Yp2q1d6l?d$Iytr<#v-_FO z?eHZv2-Ip;7yMv=O)FL_oCZRJQZX}2v%EkS681es?4j-kL}8;X|j8CJgydxjyLn~K)YXxg3=u&4MoB$FGPl~zhg3Z zt9ULN>|(KD1PZU)Y&rZfmS<5B={#}jsn5pr0NC%Kj3BZIDQ?<^F6!SqVMmILZ*Rg9 zh;>0;5a)j%SOPWU-3a2Uio^ISC|#-S@d({=CDa}9snC0(l2PSpUg_lNxPwJt^@lHE zzsH2EZ{#WTf~S~FR+S{&bn+>G!R`)dK>!wpyCXVYKkn$H26^H}y?Pi92!6C`>d|xr z04#wV>t1@WEpp8Z4ox^;Kfbf?SOf8A+gRb-FV zo*K})Vl88rX(Cy{n7WTpuH!!Cg7%u|7ebCsC3o@cBYL-WRS+Ei#Eqz-Kus=L zHm{IVReCv-q^w<(1uL|t!n?OI9^C>u04UcQmT0+f^tju& z)>4-ifqvfZeaFYITS2-g=cs6(oOxE+d0EAHd3=(PzjT#uzKm@ zgrDe|sc}|ch_f*s3u~u-E>%w54`pHmYs8;Y6D8+zZv{~2!v$2Rn;zl9<~J?1z{;(A z@UoM9-m`u#g!u`Iq<$7d5R2hKH24np5$k`9nQM%%90Hu&6MGS8YIgT?UIB{>&e~~QN=3Dxs}jp=o+ZtT+@i3B z08fM@&s=^0OlDN8C7NrIV)tHN@k(btrvS=hU;f^XtyY9ut0iGguY>N^z5G-_QRcbC zY1in&LcJK1Gy{kQR-+*eQxf|JW=##h%gG)PkfBE#!`!l9VMx=a#}oEB`ankvFMAzGI$+YZtR5 z1#tsKLDn{?6SAY-0$IOK4t{yC)-@xeTjmW*n{|re;5Zj0I?(*cntWv<9!m=Xzc)thU&Kd>|ZN$$^G_#)x z2%^6f(ME|_JBHgD=EEJIc0R()U=&0+!(7cWHJKxMo1=D#X9X^ zrn{#b5-y<<3@jpQxz(mDBys9EFS5&gC%No+d9<9`I(p|yOCN8U|MWIe?<88JU1}F$ z65mW}YpxpK(06$&)134EYp_b9?A<36n^XgK?+NsqIxAAw_@(Tp-w?v6(>YT23bWyZ zk~QuSf%CmhEgzU-si-Le?l zi<Y8De#UBk7GH}6lp7u4ZWWW(HWvk6HGK98r>$Lhc4g>ap&DIbg26pN+IKTkJ zj5m%j@9m+o$P$$I!#9sR5R0^V@L^NNGv^d6!c6ZN5bxwax7k%OpKLd_i@oS9R%8#E zOguV^hwbW1dDkx{my`)5g+*i`=fWpHXS6_nmBZR1B?{kB6?K=0PvDypQp`g_ZXmio zBbJ}pvNMlcCGE?=PM>)|nvl5CgjfTi#%PTW40+-&gMw{NEtnF+S~(9qEfgfDG^6G4 z%$l!(mS|w3m6R10{XU%-Ur0t>CjI)`_R)dXqz;6O(d3<7PL>M_R%b8%6DaTC^J;#i1tIdy>{u!xr>XSQX51%i%eA(F-EG&?U3Y(n$kgTebw z*5Ia#73$3pSKF2>3>E&PR7fw#DEU;bDP7H_=iDgSbb#c^bgLQP$1EJqp!V1){_wra zF59?uP;Z@lTi7ryb657UZjutvVVOkT6$~??*6|%Rc<>G0dh(q_OVcx$60m@FQA&sL zfT*O1>pj?j0>2}h+`SRQ%DG!)|FBZo@t$e_g0-S3r>OdqMG>pIeoj+aK^9mNx16!O z7_Y)>4;X8X_QdIEDmGS_z)Zut1ZLLs+{!kZ!>rS_()wo@HKglQ?U-lq6Q26_Rs?#N z)9_e6|54ab35x_OYoog1O$J@^GOgyFR-BQ#au9KSFL3Ku3489qnI6QaKc`JoyDPg^ zDi3~ zFkumPkT5n=3>cI$4y%}(Ae_H+!eb+hL;0W01;%>Oq(0LM7ssp8>O+%V zmDC^L*Fu(}l%Hx*h_ZlbpuhcNVU~)(u3aW~F4l`abNHXu3G!^0jg}1t0wVPvqviVl z*4n&FOdwTl$9Y*C{d+BqOpJPzJ5pqch&V)B+BgSX+A^mM=Ffbslck)9h)zaqElW|< zaiVEi?-|}Ls9(^o<1${kiaD?DOCUBc1Hqg$t(*zUGLFyu_2$jzb$j*Rzwak55Sb3D zBQOlKj)KDu?6F4rqoOEyb=8zc+9NUu8(MTSv6hmf)&w1EUDX6k zGk)E41#Er(#H*^f+!#Vwq1tp~5Jy;xy)BC*M!Oj+eyvuV*3I>G#x6sjNiwB|OZN8e zVIIX=qcZHZj-ZHpGn!_dijxQ5_EF#^i>2B)OK;Sy-yZo$XVzt_j9q-YZSzV?Evk`6 zC$NlaWbZuB)tebCI0f&_rmIw7^GY_1hNtO%zBgBo2-wfycBB z*db(hOg4Om(MRI;=R3R|BOH9z#LTn%#zCSy?Qf!75wuqvVD=eiaCi7r+H5i;9$?zr zyrOR5UhmUEienla;e|Z~zNvROs1xkD`qDKJW_?BGV+Sla;(8$2nW%OS%ret|12;a; z`E{Z#hS)NP5PF$|Ib`}Rv&68%SpPEY{~l=$!$)u*edKO&Lc}y!b&0L0^rp4s%dR#p z&Rb0lAa!89w%6_piY4(I@-_px7>I)K?vD>PO6o&HRX)65xFFC@m1IrI+!QDQ%A{a# zmbl4N{^INwcVhl<1YIW2ERZ#wL3d6g*(vTMETNjPZ5Dw40)3-NdH2n?7Nh+W=A#IV zR8ny_^+GY|#y{SwBT2Yu;d*mFqm>x@DMuwPv#=^Z3b7?G!HP{rQWuX(0hQs6<0%Tf zH6%>VCi5&)-@gLCq!dOCUITlfZFq@J2-eBXEpGiaPsz|N(}t+~!V!agF$|5<%u)YX z0`N<4D`wP>I_3S1LL%z=*o`9$hB_7V#%Yq4Q~rTp<&_YN{g|gU9i(1B_d7l}iL6Zj z-<#a0p5CAQ&F2b+?uXUv#vk+p0=i(Xqbm7R;1_TukEVny;PKIT)s&(PE~Qc3$Q8 z{{+A?Mw{8ajV#H_*i98t&3Qtt5V(x0G8PMp$VJ5>HqoymH+V3RRQXLKocae7bawv$ z`JLyE?M8K>eOH`+aFX=tS_INlAhueE#lj|qEp*GvJLZt|wee$As&+4;0i-1=(S<8g$m3Xb=#BWA0>4=j}1$3D)zaX}Q=oUvOk^ z*G8i{bP{R$f13(&Bv@%4!0}n~d|tu=4$8T7p~mgvKI_8zACF<}1^ z2T!5zg82qwbK-BTWdGH#74|81kL~SQYYrjQ$I2ygzB)uvzS!zyH@kIbvnHcMZ&U$h zq+N1$CZR5Y2qw(GxEM~)!j$edV-jfeN`L)8uvMwk7gw&i;sjR=9}`q>qB;toio7ZJ z;57Za)8J~a)%KinL+9}ShCi>x8hLFcKK94Ew2zwm>sf=WmwJu5!=CvcEMU%wSWcDY{lffr`Ln!Vqu*WB* zm|=gzA%I%wGdVshI$arMJQ*i1FBvfIIxcK?A|vEFs}|1mtY0ERL%Sg*HC&n?!hgiIDq|(#Y)g^T%xRON`#>J+>-SyaWjZJ#@}e8@R;yVcl)vqza?DVx4(E%~O$55{&N zT{2{U;6Y@lG5sg#RM|zLWsf&$9N)6ORZp{rCCAYJIlkI}9_WLpLn|}+b}1IN-Cuz7 ze(Ao9VI*_Wa7V>iyWl>Pe`x1A-zQc2*tLF-w`QUfmv(O5PK<=ZoWR-;gMko_-RA9F z6ERTL6?g*aZkeyS!)4qACG4KV$_#|Ti@ba6!rT1w3amqq9yP}9m1hV$-~9)!hdS<@ zeIWE`dsZg*#2YN;?ZJx;d6rtWudEpbNy9qH+7#Idck6NN2)~$>A|)8W{w5ATfDn^p zrkpo-Ft13BWQ#RlSm97m=}<_U{m?I7ZT*b?p5Yw^?qD%r;u96}`y1p5q8s>CBzb0< z9Yw8l1oLhiP|iF7m3ShOabR`)#w_g%KJ80S+Jee;g`Bi2w;d&Ef5hpPGr?ej?@?in z$+JzNK!N1SYh~M5&#c*Vac+leQN%Wfdw|hY*?CB1`S8dmVer9}RbmWlg`?mWRg-)| zAhh`uWNth_@elmkDC-$xJD&5Fhd<&ky!b?%N*@sfd@>i!!MR{oSpex+KiL0j*K?W) z4*WmucKqiVu>OCKD~>A^AXP=rVaX8PU!DdX&Lx0#=hJwC6B}=J2PcLSRZe!oJZN+D zTED*HJ8`{wvt0(%3_rZIe(CyVblz{zJ}bPW#u_=_wNkl;x&mu{Bw+ zHKu~yN`slvxNvTQ*SQpvx0vKA-Z*$O8ob_+^?LI4!Dz=#ReaG6;8M1N06Fv%b87jH z+)BJ$Uvk0^nbuW}2^EFv;ilA8Z5+$!?0#CEOOec?WMsi3H}Hlh*N`96xq^?}t+n!= zvyd6n;GI!|mX|la=NIbK({<)6IljR};&OBfmBiH;49R6^dP0gKS*D$lF;sKX_VfeVlea2Qyc&L^)p8C zgNS|b8Uo9DzwhC(vVPW3+dGS&-V{dt%WY%BfrEklVMAnbNYKb3bJMd0*y6d!?+lJ` zZ20^QvpPDgXOo5xG0%*-xUUNIri#IvhXS?mk7k1lbRY)+rUasnarW-lk0U%jNLzn% z*QBY5#(V`3Ta6#dsRh_*sT-8!c6F@mZp|t0h!2+tSx*_}41whAjUG@QLb94;Um2bR zcsW%39m?x5CVdXHTRF<&FlIt3f?4Q&hBmTeSu~6a=TZjeQb#O#BW9`C{gGR?TnUF< zTbe9(bsJ;20&PefJqcfM|Erf9&5@pDUhxo^UOWRhF8l2>sOE9;N>BvkXI|V`R1gqa zS`ZM*|5rzl$puo-fR&-nYU+0!!};VqQ#KkEiYba##FZyZV8)16E(G(4`~bK6JzDMuJ)vrJ`JvjUZ&7PE{@R+(v8qop6hX>Zql zN%WhroL_|=H{CBeF7pD@9`kmBgA zeSC`r*~jk4O$2q93WFvgdwft4XhI2j7TuV-`o^qUMpO?bfG(NxfR#+oagb#A@0IM6RYV$cSzvH=jYYHm^E2ky!Yg z;J3EoqNPuCR(a%Uq|t({W+_um%W5&6`ka8$ilj^S($F0X*Vm{fSHpKo8vbXdxw|S+ zBS&wt3{IF`-5HYW62(IfGenbS{{~z9#gEESBE;;kL~OnuV&cw?83V=C?1Kgq#=Cv) zTMbbRFu}Knl4TFi9pC?AHX~h74l`fcBbZ53h?^aTWn3f}zwsx~tsCk6f;P zu&HY5B_812M#a5$B4Eq&;Fc3U=^1^{Zm|c?xncA)Q&yq?<->-oJKf*)Qs*obH+2x(FnH|-x(lQb`R5Gdl?o!$nCx`d<3|6ed7R3raL>;n7=qV4|byO!fh5x{2#Vtq7Z0D+qio4lT zZtn~8C9PmHYw1`~*xzKHu02^SWG?I?(k(4=fz*>Ymd$>U+QAU-qN zClRs5z}Z&%9MUWZW$JT{S8Z=+bI??tHG;snJWo$H^+& zUNV$D&)zckKt*O$0hwAu9522A{34ez&5Mr61!_7-37jyZwKz=e@8~y6NCZ?yv?h&~ z;O7*xraDDhV79j90vUoLd#^G$lBk}3FThNgTWpDQR?JTc6#pY5h07ZBUGbebfCf-#PPfMIelyFl*xiiV+z<%58 zfOFgaKz_9w>IJpXJB^zPK(;wy4FhM`q_)Gn9%l^f|G9BR7HnlACCTXo0aGm@s(30Aqqu%!C zu=BD^+qu+L+c{O&Zjz&EHp#|}udvwCzlK|grM+h)>GIfH?2$nRuus5)iTBo*tJd;` z@@O=aib<`dV=~$<|Dn-@tb-aWUX-?7l0vx3#Sm0TnaVQcw?p5q>0G^SK6y2Tyq9*B zwoT%p?VP@CIl0rZo^&%IkhWbd`t+=mui19oeJ`-4sAZ@;IyTSt*+pu-^;o^%@oZ3D-?IU6-_yavDEcK3xqhA;t&txcIA7Lpf(m5p5b3-cSM zzxkM?Qw~IiFzp6T+m(ed>g}kuEngzy=hEN3UpC{@K}NvgBg0F6ZR*|S63w4@H`|EK zbobi^WwJmyPCJYTDC2KQ?v?X+C}X?7;%-zFLrHq~1tdQkfZMvyg(L}Ynk-&SdM{Oo zHXCPKXKu1Sf|^#-cH6dNiF<4hb}gvkqnP!Ky?Si=w?^qdiJMBR2~_A`$u$B?Q4B@q zGQ=ZYEhcDODOH(TqCDcy3YqxXhe*yqVFiKZ#Ut09D$Lg_V>Iplw)Y7(A)%k&BnThg0n6dv?&X8j#*hafajC7Z=HEJI3)^OAw&F;{~^Y zq+Vq4H6h1GTCfRJ^synHxe^VI{T@^Iu2ABOU_8+7()wBYX`?a>!zPl~Tp~lmT4s6m zS!=UZUxBD}oob`p+w^oP9mTLo_hGr>Uz|4j733cYy!S58UucX(*8P{4tNEJ_3_d#e zpWr}m=kE^>#sn6+=ifksiN)<2pn;d}9h0&rm{2^(h}v^2Q)YM@*U`ghE`TAuOPBQi zq%LMOyUVSGoFiUN;N@;slp~cvl5BE+05_i7K8~rPRyxLbVb~SuvZXpbD>_75_3J}Z z&AlK5SZF_DbJ*;_sH5Nep`U?H0l9kh1r4|~wZW8G33FSfb2v8v8-$UIzYI=alOa#J zbTtOz=ol7sN#XXeuJ(#tH{ zRjBq2r!@tEi){HTj3x|iFJbo%iruQ=6v&DAkW12o60mUVsbkJG>Mv&<^p>0~hUX># z!kuy60#ZSSeQB|ewqlJ&a^CyNOn7uNUAzu0Y_`V@>%6kf&60I;Q+P>~ za$iUy6P8UTgB3d|UA2|qH~S%r6K5;ySM`(U^#9oR(OU`$1E8oXf2a2*JEGYGVf&cR zE{=3SPw~Uo*83OYx2N9vSGO9UYfG2by&tlbXZYzuw{Ld1?lZSu6INZ4eFxt2&;!16 z-dfJy(XuJrOaPqP#$evbf(g~NNq6k}7nEe7>8x3`<%4wDb?_p@jS3A3;jC*LCi4=B zG_+zb)E)9Ek@?=}^T+2-yq+o$BkZylg!hJibRn)U!Zj0?BrvfV?>nfk>BCadh8K({ zEp5gWwj#F^U)ZD3;am5GO}RnhP^BNZPXS-=oc^}0hutWW_t*&s+s*6@73OZD8f;9U z*RDgj-%t-nbu}PW^4KZm>x?y~>gAiq7(+3rjvBKJej@m?(5Z)QaP9<9!$}=zw1myy z-p#s2{t*b3wMe!KGUpXr?%IY?j(X}8py|4sH$0R_Px3~s^dRlWOFoZMF(8MFtm3!c z5}fy!oh(F=pw-G7iPGllNl(x-vy>(i>a4B76GKVarn-lpUDbuYT-&^oU z<}-6qO-a1cx`Q=MP{1M?p2x4yMm|oGQ)($ zjq!wIrfG%WBmT3@uV+b(@t%$P$%MDJy9XOvVI7{0y{}ffn!r-)wxvA^yBAucD|OHE z^iOEy{v4n4m4(L9hbsypf5Zny((kaUAa&`^u$d0+Os)e^>ePMVF!DUO>e{F z{k2%oVQ}-q5mBQMmP7il&BS_>#}GAlIvArt-u!m_gEPh#dwz96gJI>v)R|(rTa>$eL1bgJ0%k?(9B22W?pKIl4Jg~Nmz z8XfqPUPnT9wp!Nqmb86!!hdVpKB-0UHT*rKhH%la=coFZ>F{!;XHQfGIH?e!(trd$ zwK=?;#WRz|F?d9Q(VxHOfByE$c7|tgKw*aiM9kOz^Sk3Q4GIo7)h9X;$EC54iar3|MN{zd%afpw5w%VeU+5Z*&v( zKE!zed9qHQM$jCr+<}>6q5nQTb$>FO1JsWkt5jE_o$e8};a8nInzIdBDwkPYPi~&D zb9&lML^jKp)Uxs`N@~}Qe2E%U3EJ&ds=2dR)%w>xJLAAKw)S4I)d?*9t>BldVm(hr zHR6$#P82}d=O^m>p+P^;Z$$Dv@de}zwJWQK_m2~;;EXewN z2BCeYmQUDbO6su=>uX{KCD>T}=}zlLHDd0__&?%N{o+`F`0^fR(AxJDCl~jGIWo5? ze92r^DAe+qtH;u*_Tx-r{9p|tatXyj5CQ-jtv}#{8rF@SjhqVc>F_6Tn;)6n6;$h- z!|HU6)_V=hwlrtS^(|8?`{(DuyjF&bw*h+-8<6B?hBGh~)ALVWFB9_&XFy|NEfg6E za^1eeIe&B{NbUpKA9L34MqcDR$)dFb-zL!U7GR$=SeScuUh_wxNT5}3cJ58l=%(Jn z-rBT1vgO;*7kA3uv^QekntXOnkEGkMKlz|;(`f3Ax>`-)&$!~SZEx&dOAWrVttb0> zvh6QTyeIZQpZoy+5ARAwxW-LZwLnh(Ws2M^qDz2=prk!IDD)pE#rcnu3ML!b;3r2q zPyu%TrK*wr+n989;<2WqNl8l!+5!Ydn8t9?g0eEu*>hHIoqY7B4jVl>?P1=lZ{f(3 zUROu{DYF_s*brO70dS zl0ut8DZ&a*m8HIdNVI6zag_0dRG4GdN&r-y+~Kf@-G?xRJYR;}4ujJ~cK7+rrH`iB z+Zs$!hH{L%GNzokv_7&_%*4aK2a-c0>Z0_fTCz=IdPTm(ev}Hb|MI`7MpKu#>%!RT zGOb|#BLw-?X-BAK+N*UEkaITY(bk1srnEBHN0d z&I;Z)o}v&~(i-WU9lx}pR*>9uyWHiNhLN6Wk&Qv1>PNJpjA)e1IPF>^==Mq{^kq)jyWrOeTwu>=5YaU_P0AsAr8k=$ zH$EAcZu%hpV9l3Kf0$tpiao4EAV5HB;F9kOag&*Iox6mQH(o|Qbrtr2AA=h~9xwSdLLZ%y*>x!`>`{N{p@S5P zO)8giI0iU=Oie+P8D8e6NmW%{UFw%@Qyq!zl-88UPM^)ixCT*b61_Yg&otyQbkyZ` z<)vuFZK)-yHFTcERO+0cZH}mAK1xdXZAtpoqGGh_0~wK@t$pEYQVz z#6e%6dbg5tl^B8egc=QYo2%R$ZK;BpY%?jY;B`jo`@Htl71vD`;QGcra7=JLLD``7 zte&w}^+yPSTz6>$Tb>f5-JmxIet}50g;DX~f@4&m`K&J%uezgHpazF@813MF=I0K# zwZMQ!N2TFM6P*dqG#jfk&690L3;!75jc%<~g_ims{lPl536&Iqfu>X&EiHF52AM2&|KTUo zuzLyuZ<989r#NL(!cnRx*~oRM&HFnJ9Y%*pISgAxDl;6m%KUcK3v^mXJL#;YWMFz1 z-`HX8`;%UP`^3V=%imqqkg&mmVR@}`RZXLxbeteKFT=5O@;SA>m3s8t+soac=O-qe zyFbg)Fuv6(F6q;awd0e-F@5raumN$c;zC%~n0Ve2NbLtK-K;fG>U34lK6M^kmF2G& zk)+CXHCGJV+R`TaJTDUII#W!$1n|UPNV-@O7D~Fz@>`R_ReWW7RxOA$q>%^ycxMJ{ zLya|cLJt1{jB}#Dmv>5Amjm9yYkc2}!AC;SsYi8?8D_P_j=IC8pE1`VHx7x9&Y7UbCs-fNix$IE)f& z%*I|(DN7W-`;E?;@=zqLbyD}lxSixcliB3HZ@vw-QAo^%`||vsb3-uf$oM7rKjjQ! z%UMFO54nTku*E^iB#-cWEu6NC;DLCj&j^^$5UEdT{OFEj3#K6C$*Tbr{HF)c_Jna} z{{fb&LgA&I(B&i1y_gF?-bpC5s_4bR_7$qQg+$?(H#-03hJ+SCJJDreP^ThC9v|+Y zL7xYW4J)3$g8cX4O`&Md0LpRdCtisn(qdhtr4P#I6Y3L;<-h;i^-Lak#BEluXaz-J zc-7zd!~p@3=L7*EPB!wwOlGV`0-!u~Rxt!mt@yS4aoUc^r&NVy@#p^{^N@45iQwB( zZD`3;6K~D8{Yr}=r($U~Lm#3IRmQc{BCvuBEn#r4$Sj4B{;$qbpT%CTt*?1Mg=ux+ zrF!2xpO+n{>&$;VFHxtvZ%ZbkEvkIeGNZaw@!nqSo|U;=XTDv*uP0PJ!0}7sgW`((})@6D|;$_@JOtNV?UQinTx ztIFKH;{TG~f)b}LZiwDij1ISs;XQmOizh}ZyF2<>!valh>%$~o`Bbj+=@OcRe!LQ{ zao&|tAHAxRSQBKF@f~w801}d?7t+nstsoQ9eJEkygv|7-@#Z^fF4NPknecHhp?`k5 zb9s$SLH7Lm-P65OFu(odEmY4VQJ>T)l6R%p zt7oi3TAoe`M*3QKk1rjtA%oHKnr=3A%1$+qP}nwvCBx=fw7jZDW#& zHL<8*T@Mb*)MG`MPC(T3( zzWE>nM5Vr;lnDjO5Q!V*&kXVrCqE7v;q5S=3hb2ym<356yjKczdIU~QCf=dndN0Ul zTn`g{G({HN-fBP9_`GollfMB3&UPEdUwMBXobdq$wlQy{_|puf6l?z9-dn{(MMl1t>#!4^PHQI=tS9oW1h>2^zPK8$$1QZm<7w zE?^uWHKk+7gOix!LS-B<7_sJ{s6SifWWT<))*iUNGBVA0Y+tq6nOp_-sp<0A3YmXcOt$_R|N!Dpy$8Tl&!JK4!$X+Rv=N{;O^eH`e(TxB0T7Ey@=`!}*?MXO7ij4(cC6BffqHIw#0fzIOcp zV`&|l+1VBo`6B{`Y|~4?83OWVI;{pV;K?wFp@Qr)Mha=Q!eF_ zql$279;UB4mF6P7ZNmc!=#00h?5aI=EvV{n17v0aBLaDVu*>qsO@+yA%^diVx&fq4 z7FFVyGA`vw%gSl5@Rvh;zEI)J_a=lF#uF~|yq=!~_RQ1eNsLpOjr%J+0w!WZ99?@4 zRUo^DPwc~EF;uMpWNl-dUky+-v_$;?m-4`M-_WSJ)?lG_M=unHpaddzRwf#jB1Y76 zf$zMl4c#)w#Ak2lVN*P$?3KALZ$?1Imtup;J;nQn3XY2iH&0m|CFME;;kiwRk*Rtu zPO&R99xaa>T^kK#KVOF667{h4L_q#cy}v4Kd6|7KxUzEc#-0a2y6G%wRB{W| z`DMLFX{dseQ=02*$FgEh#o(Z)UxEMJH%(N|#@#7h1MhVWz! z{ak$Kg90_`mq?;TKB(JFo*Z#$4kW?A0?a>S^Zik)5Ek3_o6@QDV_B@xFPRT>Jt63v z#9*dw|5?~c!ahmoHNIN773Vb~_Ku~%)0N8Z&BzD9FA1>Brd@}NkugZ^Ep`{cznY+$ z%EeAZ>SM&HKFWE0nVt#zSvHl4eXf82F<4#qsB0T3HHd`}!U}NYxALu%XNax>dRi$j z{|rT36BA4}F(ZL$iro%h;c1YX8l9FH6nc^r12c`qJ%bLnaQsx{ZWpa`^}g>isl1g zP;_fFXphQc!Tu8|CcfULKs347U5jEwryPV$y6>RAWB!^Y*dSMqYd@EW@B$aGT*!T* z7)o@o9rOW4_gb+5X+JxI=#ip8R_%S80k8SW9|BX0Mk*I;Z_PwZG813N- zHbUGm(7C8w1NSZB>kG+un`?ctG9ygwtgW54XTnhFBL4U#jCfH>FWd+*Qgu^+7Ik`5 zH1QILxLZ)j5e7Q;VdYBF*Rx{qU8d`d>l(GiZTz^$7uC5Zk7)~QM@48k?bGbhx!Whj zKJ3;gX>!o-MLwe0$Fb?Lu1j{6whN`00%o$kFu(4pi|3MJH=%HHO{~#P#T-(&aKnB< zrWIM8a72XR#v_^?G2|m!*Zo2UjG#qm^|705mj1S=uE!hzZy^)UAq$JKXw8kJm&{tz zaL`*wXiZ^5nV2iL6B5rU`XpiMuGt&rm|MGXvhXSAAm7iJp5*!2}6rEiTKfDF#SJm5pZi6uDl)Hw5wqjheZIM&S6Yz`R}%7Pi*j?SUB zs%f-Hp1u=x_H%~_4bsYG3gw3hLaoJ9sl65Rqt|G0z~{0c7Ya7Hj)iF&%+V}E@Ovc& z_(zJjEXC(pGj9X)~rpsbY+w;T?^&b)D_ zFclEt83QqG>rmA%@%183yfvlyKede_-+60fa`U6VWQiAddCu=K zg=SoKEkpTaxPFCzm76Z34$J^fZF%CR`aK$?0hF~|*Vgc3FI$v$(7z?p zjen`&!$VhVlseS9!#Q4^+DO&?iWTQ}&cJSoF{GgGs@eEUBv@=xb8WQ}>49g;>degb zw7AjB=EG}|c9ECb75z!runjX|SA#HEZL0igt2;BJ6PfQu?};YuCVFY$vM>OmX4;3j zkRf~tyldY*9Z*>hPQS!Nkkj)$X67qBs%?d0ZJ`o&5xQ&Ip%I0p$9+ok zr%pnEbk9MC_?PBU*PllR0WlI^9H2GWl2{lKeZ**|GWD{3kW+@xc=#;2Sp#xy1P7vBw!rp(x~(G;ODqCAiC(A7kY4-Js!=t_6!t zM96+;YwCG1RIG^KMD%_P6>fyooYx0_;7EHu-h|01zGQZ*C5%@bEiK&`L-Xtx!52|L zF9|Dcq@KE2v^>mPgRP>SJ4q34r1!~6E^*6NUjWK?L?FU-?bTV*J#SgtTyQJxV!z1^ z=?XgjzKPxAViu9bAr2*wRlJ;#^YWN?#`&Z#8t2olG~PMbB-D%wbX0Db7z$(cd5y#* z5y$+XPQ;wE_zEA$gNs)OFI9}H@oq|wSCM|yuBcAS$@GFg!oFP4i?{R$B_554HjJ*B z`2}!rV1sMJ@Y?I^dx=l?(`g#kXS;oJCQb~eEHBR{(8@e&nLY-A((cE(t1rrN zm=HWf>#8(*IWUp_N9j`|0@bN8lUZ9!S)kkuPNgd77RF}m0X{~h(q%F)^)XTYK{Wbx z{sV2-kN0$ZY0_*+Bm zl55$t3`?zTVI6BOy!lNbCNf%F#1}l=rl#DkEB`ZX5aTuW5kqw?D>{lZu6ygiqcwOQ zE*m0Db$-;-gOaWjN3%|7W4z7St3)gRjJ;R%`|+j6ib@s7r8%ZldCrI4#7pf@Rw)47 z8{70U)E#Da@X43CV=VeHq{-AZJwBdyM;)bbJUr6f?=dGjYMk7M4iWmS&Zh@uvLMA9tsyBdMlkQwrm41CFa)p9eB3-#H z?h|txb4$vWJ=rVsY^`8jMNk|KN)5;df-$-K`q!goZx|i9J?CN`4r;JSge$Ae7h(9R zlVZ&42`HCDYrtdu2tD*2UemJ+#jvA4fe}QYGHA~1l^`!^sRTj&{ z|#4F)+%Y6_z=e+^ss17tLZ!#Uutbq1{W-^8m+Nb>uV^=CsAFgo5(M;_!O1Hm{atl3I-N>kDXv{2KE1 zyAW1C=G~lKv1yFNjiCj(+q+|WL8X73=45tc3tY`Xvw#^Dk$b)rur@!2bgC;KD3J^ID zG~T7G7$BLYNn3~GxC1O)uQapRl|&obXFf@n#34FXK-e?XkK$h!#djuE7S>mqPLtqZ z*Dmz;%#o4C!DH<)*(bKOTZs=pOs4~D+Y`{fUKw=;L!C->h6;hKZIK9yM>hSUTaapOtgn6Y zUr0)4q#usk#t%=<%^F;wPxlY+buu5jBcWQq)KJCZk+Ew1LgyHdNmCIsy|Slj+Ll;v z$qGn#>hLoFfGI-Jj-qY4^BMhb>AhLeqxh6`iNLq|7dc*K8((y8r zs^(cPW>x_Qp$MoVOKg_Pv)vj>DIHufIf=X{$8Y}*$`<09GZ6$|!Kp2v(4xSYhKx>k z1Kx}l&j;00Y(HAvwt2MF+`LzX$d8mDwg>OEuP8-| zZoYLdOg>C{VX1q;?bD+pT*Oa^+7;&pgKuuqQ8y_myutFC(np zj48I}aRV+jtfk$>O&3vZ9r23NJt_94rxRKrfv2d-eZ2ZzvHqB5O^kL{+q^G{t_6#% zeo-?5JTLm*j%T85U`#eo28rUOtyub~pa*!`jWxH8epQ`8QuMKglT3nQ`ivlJN8LHM z0W;&Vk=CzB1?rtgSM3YK(9*_9@p4GP9kM1Ig@8h{cwc?nwS?-hLKtog7T6;FpeaE@ zQ9*pu9uPR1aJY0*kNOaNh-)FlE54^ksVD%|!l5I@lo3S~JjiLN4APbO_Oi2u>V@w0 zGg#%-BZv=lSm z06?zxL%4AzSn$W(_mk~HvJoAz7aEu@4A(d5iXTCQ4d@@!t02~*Vp(xcc}D|Z;FEZb zq-Vwzu$<;{JkR4pAWe()hw~vekzhM%!};?P)%?0jiZ5U;_{6%9O%E8BzIvIS2%1L{ zATR#R#w-##M&&!kRp9fQqQHeAk{do8rvpg#fD{>rwKJ2h_aY>|A?+Pw@)3fx zWc#`Mg2si`URmQGksFEXPe`*ol*orX)+V8Eno)m1=Va#vx7FIxMYq1TDO53r>kN=3 zB&WSS7*$Wug8E9~ybpoQWFjs!X9{Olhm*_>&eVhwVU+M_i^FHQyj)gVC%*PwUsm7h zlmE3icMMXez8aj4Uej}~;Sqt@QQu~b#!z76`J6S6q@|$3GEXPt%6}?7CJ<)n=-;UMiS0-)lp@hEd;A=(J>5nrC$F0wycd;J*UVVf+A4*rv?bhOr%L zx;&>^tM|H0S~kC`Qi%o1269k4BKv*-~Ovy@|sg~O>oTk7AdWR-jt>XAVaV1yM({;bW7~c4Fx<=L8(lPu0K`~^k zP(3R=N~7&YS@x?+39JUR3>~cprCU|AtQ=7L=Uk&FX%^O%8w@X~b=TX}duLQd5U^U;)cl4m3@{4 zkuz^_&g;|WWbSz;$6`lEQ3?Bz=-P0o>#b4!6Ea81u;%&C=+H-xZcdLrnj$VCSk+xI zPSr_Dm2!N8>0RJ1GoPATro2z`?cJHW-1q#+a|$oP40?d@Yzcik*ofkOUQ5$NJ*=%P zK%WKheP-Edk(O^0<~z~wQC1O2=t>mQc9PqeUFsv0O||`4?d)NsIzM9|Lcm@*C8QFD zE92qZMf&fw8GdUs$+8k07WdKqdEtIseNX}Dh44zc9v|oqA8gEP$LwJ%@WjSbsay5W%R?173^hLb2{`BOgV(k75`JR|e7U4|~L+mJ71xtz^|yj6N3 zKI$4hwADr`Esk*A&YWlEeUo;}ilTI?=CdCD*^Eq5eIrC|OIEpl!tk~mRqq?W1MxO= zT-SX&)w2eJ!3|hzPbJY>KKw9{-f#}zvA{2mr@0p4ZU9kAxWU&av&W7Lk z_y=En#~H{N@J2F5+Q;kt6uv?=KD_!dfHU;N=P4q}DaKnU%qg5T%qjAkQ0s#UdD~oi z+v*e&l{w-X91DOmAWzy&Fp#M8XOzqc^|~+4C}|Q{ZG&sO)v95L4j{4MRAgnd_{o8( z-nScjhYn;{uaSpWzpGhv>!?}|AAUYRmjq4DI=fZm)l6?uvkfM&E^`6R!!=}Q)cuxz z*i;8|(kUS9WkdIE_3JM>T-U~0hO8LYI&GankCIhh_zv~DwoiRY#PXWkzcKUI7#8DHu=(ozVr z=i}8TB-1-B#+IwiN|`2CULcZHNEJh!Ju)!txHW4UwLFzOjmgXu8GlAhb?%d2;qM;! z{SG;0IKL+=EXzp;g$%oGs+yXZa;cPYG;AE4^C(}*i+&5W%m=tj*1=`Q_IQ~KOXM@g zh&9LGHrv+&B?vkfs<2e`@VvAz7E|RXO7+wfrX^O4dFgivBT9voC_V{AsK%{$Slj0|Cp3j9aSbF58I#jRL*ABYnEJ*gK!3GYv6?2a4$L2mDIA>!D9y1ZJ z-PdVox@E$9YidVU#Rhl+>2}e*B?fo}$o4d0ZQc|HGzBPkWvApaN6_7Wdv#`9yLD5E zO67O<8PVA2Gh$0Q-XFOrD0#mN-^5gfp(E=wIt^n8BLF~l6w?9XHP`_tf^L>!) zC8B){UAkss?o2A?W8PT70{V?9-w<=qw)(aq@A**Z4|vkFhC3JTIVOs2!;L;z>oV zX9Utkz}N*H?VA-lpVN+$(7a=ka>8)N28yoeqX^Jt(*Tv$C;ml6yfDN2fFfU@Gxp`% zI#1$T0o5T_QmvaZ7R=7+`{`=iWO%z~d;APB{;n2wbB*LrGOys(Wey+;gYSGuV{Ml! zOS(gc;f)sI_l~A^$CI{pPQDG#xyhhD?6mj}PS2lU{5SKCYtI)SzBK6$gc(lY4IHUf z4jlmd%bR1Z`=_zAfIWtN9>H{_MfB-JA%VDWDA%mnEu^A%iC3A4WCNRt2Qb_sFERIt z*$DB83-;me{`VINKS+nrz2>o$x5BRwN1sB>k1B3x;z#EaXgX=`sck5KW$&^ofFul= zLP+n4I8an1-wbrefi8w>5*)A=MravTd$w0s91g#l`tsvc7N#2a>uGtC(QO zpoDD%&4$RrxXaq`#@G!K6{{p}%VN%h3t2~et-S%oxO6M#g0Q@Rg$%zu0>mf(L7oBt zDGRK}O@s$pPMtdEg1lVqsvt(5c{{ge#li!Y!necl%bBlHAO$b_V!Isit|JI(LdaQF zA|6RB3A`QrBfUY4sQFt7V(&M_0SRD4S&C}S!Hfv?Pq0h#djQIg2M`y_ zQesg4c^DMN5E4np@bI=_ev8xDcE^0w(o0q~a6xOzL%X3TBh} zam(7^Km>WD7mJiolv}c4n|=B<@qj#rjssux2^-!ddxx>66mt#klHjU*pI>|rPLVTk-OVxlPO=%sq@V`D4YP(Rq&x0 z0v%Zd_r^7*rMT}X76=opBG0m^rpSjFMFiPh%iAJzi4`{p!!SD}T6tzEC(f)`1)*hx z0{~Q1m-yW|{h`o1fezEX8EP^JnrAq%8}9kmtf)9H%U;DT&W2nva}6ma#j@7KLGi~& zkY2g|{Nf$u#ZRGOe9vi6|1qNYMG$|Y@DV7~hNl$|>_SI`|;@ZpB z)Yq&{gsAUtY}=1LkG+5RdmpzRFU*w%pHPB0#j2vTquLh}wdH6AY9zY##9$KuGAPd2 z>PF;yErH!iLuZr(Blr}lyYXmPJ5f>GvN}=Z78E|*fUT*5lI|O#kM3}tf0 zbFRIHCg)nrXojcfY8D%Gt0b7kl~&4IO2Jkg)F}{@@LMJWp0wcSHqquOz>Mir%-6Fu zv0k?=kb`ZNd?zN^`HwZl8uy%L)X5&kz=Nlx*CXONUVMaK=L=K`lh%cbpO?3vU$b5F zoIa@9#GHDysjaP^Nc@G%$P${vJ1?J)AuDx@xO~z&W@~AA+f6owoVl;7K@Q5?QXM|J z19}9Sa;3v!L`rdhL)S$kU@>JJC#LFDc1?q`9>3J80gt`S4l2N7zc8pJ{&^=u?3}M~ zgsnNg&p*#MmqCBEj&gZxYAMrJB8|0`bFOYQbtuWqy4y4Aysad|Oxlwt=p8a4U0Q*% zwLw~z_f@XVR(5)W%ETf#ZL7!*4~=B5)mEFygD|R!mKsdRO|7I4z-^Epdl*qY)MjV1 zI0qdc7Bn2MXvC|RJeTJE{mkH9FD0{@EsZ^_7KvINcah2o^@bAFxV-YfUOx5-4$@7G zlQCdT=QHhwWvG&+G2Pl9%u=N2Ntcl>P5 z1E`>-CJ6Uhhf{6~(1G4nkAsboN{d8d6Z=LAxnwLy3K=j3{)f!x$_6g{C)RqEa`G%Z zjsJ|P>TQE{u2b$Y>7ZqyHk<20t>nUK- z;wQ_VP1v@I)07Hw6gH=O|UjlM7b=-Xxv+vWN0S)A15A(e4L z_mkd8P+uzT0d@#3xZC|+lK#pgpQ{&fcTb=;ab0*KkttdhZ%LHMdsMi>W-UHw?=ifz z`=bmu=$2YtS;?~DOdT?oawEzParzc-al;4VdURsa#cOzhGaJSStoA#`Z2Q_%m4!$g zb@;Ev7|Md;E>E0+gHha*PmF=m+LUF{A22 z2L&?6;rw+Q=e7Mzgn$XYa;=0v1(k*)@S21}q_}PSC|Ub69NJfhb%696>^IGkZ5}7I zOtc#>+&_K7l5g@O-)~Ce{_N1ADo<)yfiZ@WsnVoF7O0RF_GlyPL89lbOpWgdJrw5g zo~Gh00!BDFiI!6GM~ufBSKv{{zN6pnq2+Ph+q{D10x#So?Nm)=;oH~lLZ;57mVmMN z&-%7yUTb=4y$g2E7d)Gw5N2(fi*a`3(a;yUM16lmRy~`#^@Xw zW#jp)D3~YC2dZlI`~ z7qW~=huPW8cIp`zV@I|bI;XKs6lz&QYnfvcK6Iet}7TPqK4(mv?v3g~ndHVx`L*`GOOUA9Oi*X1kLkkytv zDE;V6{}`x$P}AGq(Sx?>nQU<^^k}o|0i>)5)_X*)^wfLMgZcL?2=sB+axUb_n?t^b z5e}iqUY2W8%h^CJ<%h8N!$}SniMU|(s?*@k6m!7ev_n1`ysU*N;*>YoI}JoZ8b%26 z_Q6JBHBfSZ{}I%2g|iq09rwb6kBAjd)*aJLEiknx@+TZlPk_S<)(o4E@vZed1=xN{ zwdPaOFD;576X;htV>?`<9{SV7!hspd^u;O_vn{!z1*_c2YH$KMrEi?wCK<3IiAa>N zmL+PkhB4W7%v8Zz1f~j^Vy&hMx5^n?Y_#>7t=5_g6}w`}GRGyh6PptQtq6 ze;~To_HiD(!7&W!F|?vN2+BGPx!Mmv*_U&yg{azxN87nTx9%DlMDDleJM+O-5gyM4 zQ`6}3u8@lHMdGCZiagMci%bx{S`q;Ivt7(Eb*WWDiz{GDGiMAWlB3Xw06$RDh~1Q= z5Efz{my%J~We_=4Iw;_Z-P? zo|y&16$jm$bNsStJM~WhXRID6Hcyb8?Lt-a;u`(tqyjUCEjvq<)V(6}+~D zbGD8iwr$_&i=cIW`#$~Cc;FSDJF$Z+&eUy>NJ?*WsI!rdyp8)Q`L| z(x0O&O04-Jl)Qscb{B>nVK99nYYS+FOA~WS`4^)c7inYX;212%OaKtOC}k(r(cn4> z`X;bBhNsFHxPVnFo7zSTSG;%ca3-W^x4z-Vy)SZe1;$PHZ>fdJe-W{)5zkD#j( z%mO6tB9NArhn#?xUVyZ!-WmVaEsdOB0<&OD6Usv_;%In>nZDFks552Ek(d}_Qa|UH zbF_iFQHLSnbH3+@Tt-A*eZ1V0n{%$F80B6h=5I>jlVV~wK$s{V12rkNw&R)a1#pR8 z%lZM1e$k7^5dmKS%i;3HBurkNuEj!D@;&CUK^gkDUT@ec^1#6Zyl>C@fe`<e1f=9shLYzW(7eF^jtF~B`agPh%;%V3GeZCCm^+68dYofH{?!QsCVe``MgKo1 z6~R9uO#ckuDe)J`c|l6>ALX6R&%3hw%r*)C145Gi3$l_T`g=$JNb&pwl#%-cl6|W3 zKmo^oqX4ll@xX8mfusgBK>bTPFe-~rlMJZx1px?si~=0~^vYQScP}l$h-`tfR~BG5 zcEGP!0$`-}z{@L1FungY1i(N$T%heW3c)`Fsefj*bOt&)i2(DDP=L=aCm z0p|lTfdsAue@M&@Z zzuwY;^@IZZL&$-DK25I7&t5{H%$*1rRo1782`spi17j=%vKBA{@$TusZi<1T4_H8h zdm@7WN4Wt3A^Yz|eYT~+>m{Ec0$|fU8<k~{XdsT@Xx;Se`3gMKYLNpE|Wq{rB@`RXuCYxyBgl z><%p92CU(j0Q~gDra$G3KpD{EZeUQZBHl%z6J<&bf!0?3ajZ)Xo&2Z2)ZjvNlVVH4 zA0mH9Yd}0y*7T$NE-Th$&M|mRwGA8f``7f$FQ+~pJ~qF=udjOyVWM<$c2Z3xvHCE| z5%Q766A7Vf7kKAwtZWh({9$|~Zb@?QJLQltDf|SUF>KpeEnC5j=>;HZCC;ASZX)X! zs@%!SMp$1fgc(SkVTOiMiZ|4 z5jHQL1+#xl5IU+B z6H#S>cAV^J_19u!WRL+*$Hm3M`|;R)I!_uSJe_tz@%^bS4mz=?gzMzk;X=)s-(-V7 zgWfrw!_gx8LZKe}!1UA%TGK6FM0d?AwuQAa`q74=`3%MDSPTHc^1m(4I;=!W$vnt> zGJ$M{zf#m1X1TIh#>;4V%x}Yg@JglLQHu9GyiGW~6BgmI6L%XOo~(_08hU^g6Yf;N2|X_dj6K;D8&9t0{p%lPCJP$?BYe>z z<1D`Nuc^95(GVaDu0E$TYJN(8ja~T|>j{(z#UUiQa=ITnO_b>ibW5=1gUXPo` zzh2wLK<+&!nXf!ZeQW3M3sX`n5edG}g`Cs%`H#TGI_u*IId`T7r6kYg7O&+?xNxB% z3|OhB{Xiu@EM04RbY9LFTuvw^xuP`l+7dE9{UMA2T@_%D1ZUXe-m9%HN-y#a8lM6F@&_ZPxMV8lEOia670ShaHsp1a=mL+Ti*p9DT48nWVl*TWE>a#m&x|)f^OFr zqqreScC}o{i3#;wiWm(oU1I(8GmCl7lDJ3kdbX~({nYHiDXRBlkJphO51Ku?iX87JRU^YGBHCrydn4*4YhczR9Nz7~sIA+IgYF`h~6ZAji%Tqp2MsCx0_bE0> zvAv4JkHR4*i7a}jx$w{JH)_`MXZ$QnDs*aj%5c~kXmYKIF#2B2+ZL^8xI_&q66kt0v7lFvQ^T~kcQUa)|oFNh>dGRbZWn$ zHInpr6%DTg;ZpvN{LXgN(|_~#Y4!D*&ghxhQSi&hDu@LY$guGhJ3~XMS3_7<|$Hyir zfk89c-k5)AK^H!bo(gmfL@_cJswK3D?3rNFO5%YHm3FvJ$uH>QN5g`$L{?v zyHIrfHD55Fs0Z1uDN$ebaA0XZj{_|;FQh;}uIlWrvSbbB~ zi`G}R8oRPpx3wypk7s!0rc%?Oy{V+vJTszq#@TL3@6!W8s%N<RpP?gS`!f@4AxMZbGib$tfc2}#W%7sVn z%2FP2F<^k8QX+Dt+zQ8&+sF*RG80m(>-iPsup%FyfCIVHdJ%)@(9|lBQ=ul$<-S!3NM zK43(ntb$6&5dkru$Qci9-SHmWAUA6I)sGQr2-3-@l~1)1w=4*e@ zAq$TupiyE-lvZP#ZCEe0%=Xy9`0qBaT;B*`tD>X=`{&RCWkHqZnnOfPE%T1Nk4L+P z`%hyPV(c4;K~AVU9DB3pEytRk;H72V2Egx_{gD@y_9Qi1Bh6apGUQ?ZPM#q3x{%Q; zykDqC#_k)=JLCO3rfWo|hE%k78M#%T9vyWwM>Ft6oB?WhtEF4PPiR(_{)^1N(c2X1 z>&E70n2$XV)5@MO!2X9w`dBwPUK!icIQ3>kbCIqrYXp*Wqs>1i=f}mGYcbj}G{7Dy zAg7V&k6-ZDh@3M~pcpY(oOHk08b%aT^!jadPefl$)N95VB{%6Agsj_EE7Vn zsn&8&A}v&jjcV?O&XqXA&QVH31xWAhO}I+q2RD--2RF|uKa|id&JbL0ka&F#F?Szu z$9K{~#q+cdoZye+XW&1LoU_((8(Hl(HU>T07)k{78Al8~kjOrCkiQ+lAFLqGL#q{n zi0Ah}E<#v2V-@Ak{UMu-oVWQBP5y@X-v)5&aEmGj3IYjo0}cWrnPP%LkP;*dnF2<` z1bk{&=v6{g6+x5A_L~f#7qE<&?*?Bkok&k} zcN7pXYom~I`P@#n-EMetKLhWM>4I==aWXgNj76Ae_*bUM(D--_*i|@HSX3;exk~6l zDaDGkdCjHUdV-C$&!x3`2=gDqc>f4Q0<5p`>nC$0TB`Yn=B(aS0TFSS&k|ez!Y`(U z^P(LKO8D%3sL1NP|Ik2IUv-JL;$Odqz#6*qbF@T8BjKAo6WE|Vg>{4N{A1ASQ{Hl; zzJRwB;$Ot(8=YejI&K@@DI_4dXwFj2vF%YI7Vt8<$oe5)Z&zYZoDh$Vy=vb51Gwo2 zMx`20<#u)-<0XVD<}GC%&=SOM^()^!u6piF5=`EW7T{wHc-(!M*ADQ2Y)gFU@vmcT zGfn4|3RVNBnzw_}l_glVD^HK4aQHf%jc^AOBu=qwFIu>1Z5EL}!S_Aj3DuAMr^zv` z1iaqEj;VJ1-emAPVOJh%m(cJzfZ-(BpEydBZQ@2K&}p)SC8_Z^OJQQ2e`>xsSvEmk zHkEJUUlbQiUu%5G&UuXQ>YUpql2PnF#iYGV}A1iLX0^|}&^0i>drOvAE76fd%*kVw zX-Nv3lNzX}%wvC0EWp_QG8V^)z9ywPRUfT72mduX7%+yjjsvbPF5x_gvH}h!wf{?H zTt^`APUsf@8xl#Xr@hKo4wrX7#c0>hV{d2oX7~O2;_Dg7N)Tcp!Ubo#K|vC|KfS>~ zlBUHKD7ySZGA9-Sl^dBm!%J+!3@SFnh_i0i9t%tE!+{>G^8;>p<}oOicjMzsT6(f# z%o^M;vqMXgj4<^M?<2h(pgLsy$m1f6{(~gHsTFLR#QRt}DCx4}W*yxxkCg8vSu!g->6+C0q;cyzN>^2A?5w~WyH6<7?cq0019=-7~0nNf2?ZnPI7UBUo2X#NKq9DZi(W3B0P-)!sXICls6_)zo zdgYO=8L#aSg}Ql*DAfF?rZyNI#O-7{C7UQLxf!q0o^ip-{+8LR_Lwg{>3;K7W`QvP zgPmJCJG#T{+n&M2|JcN9xm8Dlvo`lL{=tOt)`I6cA~rvkM0lP)?fi}>SE(}9)R%j* zX&c=8!E%I%3$F2xav7H+p#FZrNNqcKs3`20eHOu!u&p$gL9pIM`B1lgSz(+tPJo8m zD$ES&*vqw}12^}MeSElOx4;`=hCYfmU?^mk(+uVA75dj)NmaN1((uNaoafgHPAMzX zF|`|mmvTE7RA~{s-@ZJcD3edKh}a}L#D1=>F1x-WgK^r$K*0|N z*z{tJ!f7BpB&|baka7eZm+?xG7iR4y>Ow?a3w%pK=C{_To@#Bi$N5TFDPNUMXI1sp zn#Qd9^5mAhmKvuI*Ud)h_+)ecfz#z~AOzDv(7VrAlWq-I4slDNx=)5CCS9Wt{yCBny z#;S_r&)WnQg3xfsUaI)dGj? z@H{H^c92>dNv;UtL-{EKhd(w!gZZy%5psUBWx;jsoARh25EB%%i^2 z#nnCv!IaG$oSkbGH|VDX4{#jRnt3a;KfD&2S0%29zZZqg8Im%|b2-HvilV!uq*!g@ zEODVd^d_Cx+-!_EYd_pz0sCA}xQ=AKtnRHY`%f5s4I|`SSO&s%0xOw|sblvzuelZm zj1`{OTQ%0GT|00`-uyNUXyrRkuF^fDs*5GP2^K>09B>(<+prqh;-vSVHIpOk0WilS zoTlcky}U}?24E$^xGVU9$%!({Irkz+OOYZ<n%HBptG>=$c;rjV14YBBe%*DsL+45wzFIEma4SXR|AGy;;9Yxzy;w2NYTu2WO#| zr3o^ruf%=Q1I5!8d)R3ei^+X4OFzp|aK&_5OyKve53x(Em$69~A;js0j?Z2w;$nz@ z9AKnIWhm1in)P{O02~L?;o>q~>+0TP?`Z^tX{yfDZ7A%x1uH@WNXFt@~{mW}CUBduKaZ{-&j7k9XW?KXp7 zTRIf~@YmhgSmTZ-A7b@Ctga|3$2R$EmA{_*ZjhMP3I*Qj>84xlJCMN>&zaw8nd1C|}Y!i{;(DhwG3aHmzL9Q^pd&Pf2(VbirC@PKuF~A+EXi8f`@g1z~b&+`y zTx?ZOpZpM8-u1JNQWmjN6Ji-eUMD)JsEKes4PS514ecrLC_3hs{e-dwu!pR}Vkmzb zNj#h*(|y10A85Yy<*aH+QtueV27Md3+?^zTkp1uAtQPojP?B=ZDgziOEgPece_P@0 ztYP5L{;Zc5--K%lhK9B+dODXSr=^TCteKyw+BR z?GaB1ROf)&i^1mg8Rp^D5G0&K)O54bMG$PtxpZ@bd1u{p_;1RxhLzfe-B4>PApzxw z7iKx%w-W`e4f5+8%Z0N{F=T{&$!C{>N9W>l*A_8Cj2h2Kd;>t@`C#CN9_96%h1f>=)L6v09Cmluf&8dZe&(31MBhp=EM;G&&IS)pT+P^yaLR3Aj7SFg zx6$|yDI-ot=psOl3FFqwfMRk_{z)di_ut5VCA+7a(i{D^xb$IBWNI4EvG`!W zbux^*!(}@jXAZAIa}b@PM7#Mv^apggmNQ8&u7g;GMUXJU#gTuSE3L1E3&R7eaqT31}tObr!fms}D< zk8B0U_2_g5)>upemHAbOdX5?WR+HmA*Zu6)RiR9Zh@a0(uFJ24r-=IR1&OB?(``L` z@JLi4`-Ar>7LXRJl`2gzXB*ZWbYkd$h;X`}3Rj)XQ zAMd!IFC-9F_!K5Znz?|XJXZNnIR}kx3v8skhevzA_~LZGh2x}x!ScF0-K#-7rCU~~ zmYIHe&CZ-Exm?`2YK>)&WjCL$(JZrVIi5zn@8d7RcFqd}TY%~W7h#Ns?6Gs@ObmCZ z;Fl9|Rw|lO9y2;_(GTWdB-PSCnQLXpy5TGv>Y;Jex}kyl`H(r)Uls+8EaV&95fd3j z*tv!O_!o9%;*ebo2O8#kq}#+LVlT0%i4b2&(V?b2Z^aRPNIQPYp<8vtqU2ja1vsb= zzQi)C{9ByrBXPP%tQ4roSxQEk;(sHI5*XnOPY(U*XX;~RP@Oo`gg%`gbwl4^N2R4*d7&#i6agknUz&v6k!GgWH z#7<@l1&9y|V+#C17Pa5pKVFd^d(wuW$VtO!Fh3nI=XNb{@)-E}?-edcB9+3NnXE9s z|Bac>R51iZV+d516jOp;M%s-pj*3*1+h1cu4aJUh4ab*L9@u*1!byg(ND!gsgMu8c zt+K)6tNq)z-?#Y8a1XDU+vRw5RyTPyLGyAWpFq;>ca#%v;F&GeRs9}6O{`_Vwu>a6FN={o#)u-E1Wi~x4(^x zS$?FDBxdkT*p!D=V=jmArQd{~{fL;J@g^O57uL~-;~~21%pc4!0Wn|@r4I165%mUs z>51VcB?A2xi+Q45;z^#se4f}Qy6{=0bUHn;oY5v5@%G!i`#5eBlR1*3Dg9*OTv6+M%@_3bKR*{SqOA z6bcYxUBkjcnpuGT;bg;feCxZuO(01$N_A@_4UVed4?;A>-OT{qB2y@1Wo2pA_iAam zB?JIpkj#-*0oXy6DVb|YqAHoCasp02i1Q!JX0uoMg(q7lv z?a%#xop0B(_4HQ7{#h7B^dtCU*Ze;4pFO&*!^~QF`K6DtUm?q&-BC^2z ze^wj%m!;=c=`<#-s76bOc46s+sxUMSN#cJRWmV=%;;935PE*Ha@(#nDQE&H_>vz`jQ?qT6W;0)JIz|F->;Oo;DS&&4{skDh?BqJ6A1VS^f`po2UVT4bo z!rDqhLE(S)S-Sz>wy`qoC;?>a`4yl8KkTv9n%9Qp#qiy^;X%!&`kXzqiPFb#=%|YD zd=*5}9f1BjZwoqL%R!@em~200;Q=Q$`$9Kx6-C4t#j*DKm7)1KMqr#ZC*A?|Nx8$X zX_IXqDm}lyOEp}?P7;M9mu3ZNq>-6mzikFv=WG_;&V4MVDvjcuaA5R_Gzvhz^b3^c ze!7H*$$=jjdMxgE3dNa@S;Xd&Pm<^bm_J3Ewq?u{F3c4m6PutNr z@~LsvkBst-*nC_D%xr=cFb_PLZFtMaI#q4drjJ;xUNOx)|5jR{aG`IBgk;50Tf-#K(u+^81DSJcS8sk~@+(8yQjpemR)cu*+-Q7S%l@hIHA(s{@i zkO*&Bo;tH^q@sak>IV|~J9%+y9>?Dl4ENkgdPCffYP0zF9b$R1gs1LH z8|FqP4c@D4dhByM*WA@%S`%efa`^?bi#PCKx&7A3@igY<{F@9-lIdO$7FuxGaX+v= z&^jV%erq`k4V~Q45jQP&D0=?7r$J{C-3<$~g0#*imBs!>{9j&c;K%SGQf9?v0sjt# zlW}C1&_#@C%iw4{shhFnc-!2h(X*D5~|36vc)0+fY`^!yhGrvESYUjKft@ z7CvAd=Ou3$X3UHvvP(==D~Hwz4c6?g^v1QMs5l`BOL|DR*N;&UW*p1)=#lhzQl;BP zcEWd`f}CPSy8723iY6$}sAZuDHRTt_PPtq5j7_)qFC53UM7SdpVy4kPAd72$$q)7j z{iqgScZ1?`1?z#|>7tlZP>5{h3reBEZ!jFU^NfExxh5vXr|O&U($DDwgaUdG~qA36Crxh1TwmnUc-TN(rA6x3tl6m2jvIo0qAJM^V}!ymq( zmSkl*O2jY$^5W1pzsuNntU-NI~R50T|8fP2Ajab$pD~S3AE0CTF%M zXCXw12dJkfNH;^NQHF3aIb=a`!G}o|lXJ``n9(dLMYk(LJSs=mYC}9|YRlSeAvl6m z&h0K#?W)@ZYx^{fwx0dvv}zqNbl&)$=j1JuW1>FIu6dq+-T0sA0VjN3hJs&@CLnCb zmG~`(fYSM$)xVdRcwhg5eK7(@|ANE%7wMDRJ@yZSVIkK$O2M_lLo@;&?xKA)f?*eS ztZ`?4tas-Sq+rS-vq*Cv3cYb^7n_4M7EOM`#g%R?0ax_!x?(xkUek&slXDjRxY%1+ zLW`s%!^w5?)OeehAiim91z30V1F-s76FRe1!0eaqzFLABdZ-%4-rYHi$fQkePG-z7 zYZMax`bd4Ts^YSFQ~V~YL`r40{4$G{;<^gOGKNJVr35eL60B-XvF@z8Y!qcFZ#r#+ z(LRUboh5A#tJsxmgqCI1lf1!PvQCv&<>Y3kHcfLct5gc@YHqb>?n&CK>?4FB zpi{AnWusba#^5t;if^Tqz5plN+{&t$QfjDErp_ldZsA&Y{$DY!MZtqdr*Qg(DxHU+ zj)=)As!ru}xNDNu`RWm^0wX3i$9@Bj0V?c>sii!#rGykeHq82X@u2fX^2FbGVRqyM zaSk1Z%ocKFHoGAfHhj3T(2ShVC~zO(>HN{d4*ZZ2u|1MZZ}{nGN|@bJ^5QVKqjHjB z`z|D9h67rX7rq_?eFf5t#nEA2Q%bLv=3I3Lm8 z&7q&p!#5v@05MdH!5P{)O}4ley=Gm&W3I^_9)bb0lMXdp#&Ed}am2%l3@g#L2HBo9 z3*!cpY9Xa_i1T$YQ&CCFTeJpjEg91CpOOREvL@FF8rJ&zR7?P8LjOy-l+IoQKqTq_FWW(XbgJ_0ZuCP62qIg+oW1|m7OUL-dQIV_$HNpdQde1nsndQV+ znjniOCzZjU6Ze6`)NwB2=;O&;<`O95OY&6?QJ~((jcY9W#d% z*OFqT{zZR{d_Wr%nWUq}r#7HlHE9uYEM_Q3PNjG*haxIY8f3b<-xrpp%N>-Y_HvF{ zj4{)nUO3i(mXoCL$@U5~FHL6DjddH$$|8G+0HwjbUL-Fd4aFU0 ziiglWQ!?t3s^a6tUhqUkVT_fAbdQf0&zZGmwYpTH(3e`VZ`4o3pOiy$^kFVLnswyr z{)w6aC7Qdv;t+AD@~>~k5ssC_t%{>YQ-b%97L$O&eCRG{!+sxdr;Kq+9xlPjBViAB zi?l{-+spym0#|$6T4YHse^NUoH+RcjaUKH3SDPV)xbW9(mMUaYD8c>K%cK*3aMd%% zEhbA-n{(>?_=CQTNPJ9rPUlokwh=w1U|w`PmmOQ`zXTw?kz1C@A}EN4O?#%i0uoiL@5-dMp6++qi)*2x@sOkrM`Rh1x73yb75TNx&OFSFA;} zY1&L|5QjfYWQY)#Adv-5a8NT8al8HtS4~?~7uYWlEW;_aqBI-P(dl`eeIQUoxXYB2 zXicO==u>FnxyIR3xuY}2Vo*^3&A`IDhv?KqF|e9I+?4Td`McVZJ*w3ZqaklvV=v~z zawv$mxPdIN}_w>feJLX(DN#CZMmuH&z`TbHfQVz~E4L({LU`o-XRU2xGm>4+jiun0!`525&!$i#1e6tE`U>|E>#Q!GltK=N2&G)8yz@^T_@#$Gap^J z))%Z+Er_uIJ+qGw(05Y0A8{?7J@nX5REm49-<|2qfz|HOuV%S%EN*gCNOT;i8}>_@ zECBJ}gfKCKFK^@5o6xjp>?5#sAki^x#_X4hMv4>NTcnO(35K5d?3(b;QQH$s+Em&S z9q~=cC#8JMoNFZ2e&rQ-cCXhQpQ^~&zpfOcUa4aJb`xZ@XI1IoL;KR(MAnXq6%O^K zCZIBUZ#nka+Wg3I@9mI>4qs;$%hL$kL3jX%&r0I>kzY1{9ja4|@eVT2?+B;pu)`m| z49Mr!aAB2->>Ec;w#AXz^iYcw+taq3icH@#D-FZ)DFG3eS|PDa`u(?6{|K}+BPX8E zJt_@1#}Gy(BKS#^mMTIe8DicgLQxTXRr1-WV^VfDBa?OJxO@j^<^d#J*zNoyy8)o4 zu<$7;0ZdFH{wp6EyfpuWls(mq;^9Gba`KEom8l;IyJkA^_}K&pgJ#;X{G2Ov26TBp zi^3LF?d?yJ^&!m2Wv30!KjoqxI$Z5GznYL-x^WE5+?s=j+>%{&uAhx_SnhKzNQK0> zAF$jntxxcF?H|Fa4F#}e_JWjRy(IwC%4iJ(ay47~Xe|?U&85D{g@wCGlA6!2cAkaR zitFt~@B23`{BBxqeGs(m9me_;<*;_8cg&xZp`Un zb?)-YhBc9J;5g*+1;WDHl+D8YLT)OSWP9U1pk^Ut-_k9otE;<0HO|#4t{JfHf)Lci zg~jCS{QGd7o5LMvid6wuM`dh5?J}J7EHfq0bT>v;Y3Es3d^)T*%S~46)jLcF!y(I=8sLBBro3@_^ROR znNEG5Oa*t2ptmX&X%mq(xe_2?H#a<6B~~~uj9C_`2%+lrmV|R=2au>d>DrEE7Y!a+ zwITjvF=-2(5@Qc3-??l;_VL~`cM!%Iu04peeAeCLpvPruH*x^3ZX4{RB0qbJZld$9 z_eDT>K6A#r%SWzaD7@q<*w)hdx!-USsQw^}vAKxkKXjVU#_CAj76XwU)%3BONvWPf z6EBZ>A+;4A0oP_NVWoz>8W~(!IGjxx>%U|E@;cWk+~XyUDSXz7PFQoA4OVRa>ME}U zzc~t98#!%Z{GFe)j0oWWVQ(oW48kj~sLJT2_rQz%Bd7U|`Q^>h{?=Z_>GZ2h>^=b7 z##`^?!LyG+nA7hUqaXmH<-)X$0QJWQR_DDY&Fi+Z8NzZfe6u4(V7P4D;01Tf&Zlut z0d~|*P){O9P2Uw+7pW(qJkz^IVwxV(%)SU5Y;`NtkNex>$-w^R_{MQtYH))6-AbJ$ z!(P94!sax5SNVgy36Vt08D#7SeD&4nZNz~pPY{X+MP%YQUKlWa!W)(pvU4AOehim4 zTtVxVHNO+O*nO;$&(~i7W#&m%k7b6pvgG2i~R=eKMD`7b=rRn9~%59w<@$%1*SWpP^%?bXerpY2DO%${w?JteBWwJAWm! zsPH?1#!p%Jyb>tc4c#`BFQ!xc7R*Sjm?~a*@-byt^m&Y$+MWgW1){mZ+ql zu4lNAAi=>n#(FLgN6C0BP;Wh~?h$lCn(`#uJ5i{TQ*my_WvqA8`ip)b!^J#^y!s4;QX4`F0C=38UMSYx?fI~1`WNa;ZTj)?O{ z$k^8^@kfe#fy#CUon?hDil$fDZ1GDHtHiC^vA?`{+iZ>oakvyd0X1IXnzbv!pL{NX< z1VREE_pLFd&{eHR>&g=iKD>p{e@pB;DTt9U6h=6&{1?zNcHz_6-XA#72^Ouk3XcNqusnb+X1vcB3r_o zPuU|6Z8U*HYS5a~UJY*UQ0+2Z#~e>SqFQ4yIj|;maD_Th1bC5{nIQ!9ruS*x=SfUb zkqYh4!oBhZg&v9UsA+fQg;3M~V@1o8WCA!8-xdgcBFJn{XqP+dQKpaVv*?gt028Jz~~escDay5(iNj7EK{TDK}}3Ln6}LdGz9nst;&Z z8-i|mgbQNSK{0Qhcz~9RaYxQ{u~a&B8UJ~ViuB+8a6>xazZONYMc=|ow7c5{WBB$* z?C|Fi{6uD)(0pX`ulor3IDVol7R%*ql?5m&r6eLK&cs*cq^mGGFeWtc#SKbx8jI3v zusce~TFpzFCP?(H8QQ^lTG_uz*Ma5=rwL88YVdyo9hp+`r+Jwudt9H!`Bf?S9I_R=WQDAvmUl!Uj+lTT(osusoB^`0q@)cgNtk3Az1c zF1{rgTdT)0xH;7MNFtNM<{iHSTf7rHIDa@8j$tKank45JHUyFgUMjak zwT?Y{7@hu{+{=9oMgKFvR{WBSS``<#eq#MN;^JaRuZWRC8Ozz1`J_1fgxcwrHoM-;t$w!alwNy;C;jw&xSD|h`-QZg4!8}tg z!;hR;EI=t*SG2r2>4;0Qty3g3AQ(#(Ch6SK+TXwSglJX_A85<$CEYF-{~J}fg-=d3t?1>syx z*JaKOOqHjX`w=yrJgt#EQuJJNPQBF>ND<@zM+rMl=)wIJ4uE?`vgzz^qI|>Cz4g)` z?Yy{!x$+A0`J!1op)P*Xo`Nf0w9I97oI`BBm(FF4R4bp^AE9ZE=~I7A=T~bvyw!!8 zR8eOZrXmuNmje>d2uSM3sBW+(1=%~oC_@3GceKojdL~jU6I@Q0^9+J zG0ksA?7y(Sf&Rle*05Y0pME8SEKD7?Ag2CaC=x>WI>(Nt{DIVuStyi1PzJCYMIZOc zL(Fb^vn1zRB+N;o#la`owLp~7L{iOW*PS6cgH(suEB!W?wp@EAs_t6*_Qoqyzi_$n zH2eC4ckMQ<=H7@aPglaZCpi0h3%^`CIKGW*^3Q+vu>IB~$2s1UDGy4`I0kxXFp}8m z)dK&SsZc2a&QgHh|0}_lVWqDflPY7N&_J{>Opx|r+sQ-QimF!Gltzr7v8E4Nc(Uc9 zK5Fg5kte^{9yqa%vFU{sk&`<%oy>FwoUmF2e!RUQ4AAD8CymyGiekdd=&;@x58gxR zl-w;O7lkH=vJMZpRhIY+Ceo*8!&m-umST=oFGX#=1_I?yy?QVbEo*S!_^n+TYW>UP zvkW#(yfqO#w(RWs(4gz>%>T$(glY2M?%EMbi1w!v6kEjD7ye!v^sPV)qs)L6`yHmI z%UXk8?e`Jn$NFeEEv)XVI-s#-r(9#JB`c7II<{5iq+GGQ+C&%;Ve;Zi&(YwNozGnNhTF68iv*ywu?MfEka)$l4-o|Y+giU^}duk$J zF_l23z)m(iVmuLE?UU^&>Cv{Z$|Ka6AsGXU>kn(kCxz}#a*UMrml?O+Zg`}Hoq@|8 zb~U`x_p>XuB$MP*Su2%)_M-yk>EqRElrhK;?_s>N*F>3~RaH;q zcC(Z2Pa`b>(;O7Px&xWAdl~*a!{}+h}?f?I`{dSoLG}zJ@&U&C5hyQ+!CgKci@w=rDi34W*_KhSFE{EihuCUZmrLL z3iTwj++&Y|u!W^ijqnt~xup9e!JtiyT3|ZEwbQskrgVq_pk6Y3&`)SSktHm%$#6Gl8Gf78(nthd*4k-&5>K*Q4EiE zg?5_%o!VE4da~^E%+U3LEX>N2-%kC_^}5s7+s(5O2>yVV$41ODJS5I9lUw*u5{!4| z8e{SBkY-p(jTMv3B)1-b&nSkx-b^0Hih0mDc@P2vEK_wcGzOk=bzg^nynC89Zyau> zh)qs5Jh%mRQWw%W9ElaSOye@RG8st=V}`l`eFk>LXt@@1n#KL1D2srZfu_Oav?@?R zDN`}zt{C(plghz2u>TB}ozbK&YwESkETMa?DUsoGvkTfl<`9{Te_nas+F2n>3&LlS4mc*htNr~^i3~3NqE(TVVVfM1Ma~_eIeSfFI75Re}2Y>+Ed$P+^xA^Gg+Ft$#wX3Hkrd7!P4by#ru$l zx!y9v(;b!j7?Aa>R~$Wc`v^V%B|dv<{}3SD90(xX9D+d**}gy%*}a5y3XNL93a;Nm z^r_#bMbzH`aS=`~YQ}zxF%LXjTvo@fYnzlb-m$qmox1(X`8D$019ch?j0SDubT}r;*iBQI06^U{F&3CK{LGBnYm)$vpw{KW)X zh{u*qaQsH^__HiJtx`y9A6hc_(d(r9@Eg;GamFzyECdv|dqT2*P;@y&2}ehjiIoQHVMj zIk`8W>2#Ll$?}S6{$5Wluq{2qN($m{pw(O(ey*;;-6NgrHpiJqR9cR`-m9`*sW(g0 zFuu+>E-Bo#rT41T5q`>oJQ3bI@j}S?n=j!6NNsI++L&v@k~yMg_V33l^g<&lRPt4c zZWi^zh_$~jUp_y*-}$Q!2p)cp6=`PxWM^Z!!kCPBF1tOn0^dlkr!0%973tzODptsopDYsZBgHB^b?5fHv-QMi-E zUzqWi^JdEo?r0*+Ed18m;)l-fq?~)A3=DdX-yyXvj?;%E2Ts}a&RUC1x`|bWBTuLR z#iGRJgqf9!5*txdox~+6K{u7ycs3>2r&ohjGy;9W>pU^=D;#Y@+BwMegFS#aZwwhS zX#_`qfLRq=1oGr`Rd#8ME#ihHo`@wlpE=4X$_ynV z5aR!@y&?d$x-kCgtE)mMv-gxKQ06294T#d@<`z<@;$o=enc(u;@Y)v1J>hGm6vTlWQSZDb6svJn(mC?gX z;w3=TxqoA%nPI%!&~T{X?jWB)&$L{Ok2GhW_=%i=e-?7*_OOA;P?=Axom$X}PtAm%p+#-3jIjU6cwsCMQ6dub!A6gc1fypG0~DjtnRGdiTc?-Y$UvhS^NsKCFPs z$@me^WvK|^;%h;MXVe?gPF0N z?fU{H?>qkc4G#1Fsp>3%;)u3&4THP8LvVL@_uvxTo!}N2+xjoqEAu|GaRZ3S*u)8K`bnzKOgKa862W#|sM2Q0hn3Uq(C z7{7lVSDFZyOBmrQpvLD}g@x<*x%3?Zc1S4cT+GIe95=G~>l5Aqy2cQ$p0HF=_n#97vv{Xsl z_2dJ(%qCcxw3dRGAGwYO--`BYey*EqI45c$>gz+W3huI!;iiUn#%7$aLb*9v3G&xolLap0>4GK z@j$GN*WvycKkw6JW7nLG9*(YC!9V3pH6s3o+0WsC5syk!7ej!bs5H$TI*cO+opCL; zzCse^fGk@H7edh&Ga)+vWG(O;l5oTHd+;~O%yOp$DNMvEe)n{GqlsZF*}3*idhI@H z^AH)%brK|*YW%HJHIqwy_XQc)pFl2+798xPHadUXWnG?ika7k;D=7gqlcwA_ub1@r zdFXP{&kVdn6=Yb6V?(mKIn=oDDt!3wukB|!QTpk+m>RSWW8jL$coczP|1B{yHrNKF z^^gU8&4Gg*t3q46&q?UAOD5l8gRk0fT)6u}1;K|=$TaGkADb4W%%Fm#B!JSe*6@0m zpd!Oa6M~gx^ccA}6$wB_EC)_P?#Fajk@;0(*ySY??B_9LxE-b&ZYfw;fGNaEZ?W9Z z@cIeS2-4sy<~}w%Lbfxy?1aFx_`y|x*|`v7T6qp9jju@|DVb(7?CH!eG*5Gy&l+8h zRbM^8F!tpT5oH7_gW>9GoIpm};Yf!1O{25~qK{^yWgpO~+jaA%S(nwyE0EdwL!30c zKldt?xJ0aM&=1ycCR-5a38i5O*0PK$+gT3P>!y1@WKHxy>~~O27sP(<)ig}wRNBRr z%aKHq$VG*rl$FywL80@QG^{g$)G(eHOk>J}B_@)*1Pdw21lI-z;E;-&jIZWa_0rpSSA7mp= zY4%6fSDnyAb5@>5=Tji(VLG&@QJBH2*IT9d#Z0;Q1}$-PDQPDU=b^MOJ-_5unLk?& zJZi>Qg3o#87MvE77KLnnubDpISzVT$FGU~oW?sqGR>)#s1~C4_i_tCZz~R{`G{gU{ zE$-s^yxBhQl6sEv)_Qo3lC-ZDfTii0Zc2yEfn()i7M1a+7BB|f{1XW1VWwf3P^+de z<&}b!6y9Xr(kUtJ5k~uysJ}ev!@ZJgTX43?N(3|OzqhI_ zsE`L~Z(%4Bo2itEVg!ZfoN{oLg?~rEvg_D~ERcyBo#J#Sl8d<@Xys_0V6>-ceP)`5dl2>|jwH~b+=fqshaPwn^QIdTGV^Ti z8BzI7>A~8Nw6PZUN=A6is)VG6;#e}?*nJ}5PPBsTSPCo{pUH1sUePRlAORuxUGTL; zKEk~Tq9QxSdq&rcb2q7smlm$PdEqm_b)ERpIu%W>VLYrJ7aua2XM*1h2BvVi7cSXjq-L*w5-) zq9A6ft4bIGNCMU02vz_tSz-F^eHzfm>oq1zs4eB@ z@mighTiklDogFW5lyrl{W9cm1P0|dWwlOGh#Ja$N$km}-j? zY``YYW?#ckjy5RzMFrfp_H13V40I@GOpetB-1a9QVGpY6k-=rTjyBAN>)HrTAXhx? zjs+{5lV)GZRr2S&0QY?3JgpBZBe52ll7*daQZZ++teaus3k5iw5W=xmxQO%El^)7a`2Q7ALgm-8h!U^Y(ne^KbVI#U}z#)(&OI zJDMZDDt*AHcv3>&{(4=K_-i*KDFP6MMhTKL1F6)&UtMqCUz!7YI1}H)F1sD+?HsvM zwnbTk?(?UESMwaPnd@-|!F3FkpxHG`X_-S6%)#&Q8Y130A{gi2agh>GlFZi|_=nIj zwOXpd3C|nC_-6?4odNmsLdj^GmJ30Dm3 zp^Rl(mgvZ7rg?OPuqj8wp}kBq5<%s(y*A39AfzGg1#VM{I=3eH zr#^4k3i-u(AteXe|4|m>-P1 zBXT7m&IZ-{Z`Ubnyz&hjqacZm48@VyU>ux?>kb!B8u`*$ z6tcI(Z7o)f{5l1?jg>WYf1To^3 z-<_=Hk8jxi0(ZX&7?QJDyYNQ#(tSnb(7qlF+`@y0 zGG6G;Wc?tFFKF@juW~+#NK9N0>>e|@;?1~G6^qJ%ucLp^)ph}|*{{=dgk_%K=1}uw z1yk2-(#`kOv*gNxB5=4sc1PG1MXV;pYlZU0#XlnFvM&dZmD^_C%RR9Rwzz!R@(o#^ z=+} zr7EYu@;hHinSeF0V{y^VS_`oB3u!ar0?;%DO@ZA~5#pvo<3+5q7lQov3dG(!cl(yT?b(xcB+F_-Ld` zm66hh_Bn0T?$LPQU z{0+si%bDJMog9=Z86uvtvJ#wP9>-<@Hv-={&B;l}tM8!u__j-Xf#2KA)XS_#9;<=1OL|`w zg{mpfY;ju3s^xvMcEcN6EJj35M--uDj)8VE zyH~>{jkyBn+K>r{rG;rBb1SYHD*{O|i>(6MIJi^k!p#!|E5f^#*dRw;?j7LyG*I&~ zC!S!yeWH7M1JHiqalYa&v7bn@H|TP{rCu&~7tP3qkg?Y)*Zm4k%i<|wqoC_Yfl(4WW|6uE z1IoaVykI1l6mgiCB;j-@SYWd^ILaF8@*D1UUPx>^3V$OR|F)Ub9mQ@0TKKHO3SztkrL_O9a;xo~2 zlCE0m`)9ZXfw}{QXWHLn<&o^T$s&mTEI9mcC9^#kg6rhIpwb#~8{qp}-QHG}Mw5ni zIZ|iJGmHHg-XrGK2bsQLw&}_*syR+Ee7^<@-EtE&tjmfTcE}xt56B4WX_1~RfCnQ$3*fB;!?xeos|dU_fV?S1>I_e5iuA8g zp@Hcs)BHLeXt!xJHCZ;RJCKc4`R(*$NjQnCq4O-XuE^}^bxi(QRYrclRHsz3puDKu zen8iKi?)cpKXIuDpE2-LNycrIr8<0Co1($PtV3So;5T?5W3tjsBaVtM&lDXWi<;=xuTdL#5h;7fAWS}>n zliW&C-J|?)fwu(b5K7nAgCl2JIri-qLuphbM=~#o^*Un*u z4?aO(8`voaX8h1Vz?(8-Db{BR2FG9^)695+rSPsSI+Fd}nO}~4!7{v;?j0}}tyjn$ zxz;m=LNVt%%eS^*N#m{d(KI#P_voO;g3;Uq`GV@jC%)` z{s5K^NVk%P&ogIrM{Y~TGjp@_#6s0;*<0-|?NaSPNd#d4>P2()x)kY>pJGSo_ntZx zC;?TOy^^8@I4P?_Rmwb0H_U0f6#5hQjxRZ6HW>hyYJ49a9*kN>mX2d`!{0s~Rv9&p zU+JDV*$ipn)K9ARQ|X1!V7_D~2P8KS?ym->l`-%x>@Ip{UxE^~Bt992U6)9E8*J!5 zA&+|jtFqLhzVLP$Y}L4ar-VQ&8RxK$x>0fEC++wSY5bB|{3k-)MMhe)W>7}Uq%aGy z4YsBwaQ{XE-xPzn_kqJG$+ht*gCA;S4B;T7GC2v#A?-#fLtVF4@oSfgmTc9WU_9}~ z$E1k>@D)v@&GjGJCH6gfj|qwuw+v4&%Ir0AAoqA&@S0?kY;rWcGp{_oSEH0dj_@G8 zhvsXwo#9Vj(7Nh*1Mp-yB42@A)2S{z5Hc_I>ISQ|^73E#Ii zDV+JdPl>)k39i$JNrAf_uRm@H1l<_1v%D1^XGS!xYk3<xs<)1$j0{6LQ zVMvWe#~e27`Wg6h506iG<%}!Z=5gnvVS2d3(pQ-dzhqUrlYoOq0Uzw!Cl&^LJgawM zMi}_*ZQxwho1t$?%Y8L8zvbH*;(Gg(`0H)L9PT!drU=SMrv!D81RxJJY8U}%*5trkJ(cV#X{ zR0s%~zpsi&$8do_qIn!)b7rcs9hf2cx_Yc3gnFhCTzP~PzGA7CC>$oiJDFUF2|2xt0UNN=D}EKk*CbYB`l@Q|utEPBoL zH8<&klmS{1(FXF)r$GI|)+w&C{+GM1+_MjVu z5ZQN#0Q~-hrKk6geOFA>>V%fk2yx4j#~5L29^D9O%i|s>IhYM_%AUD#wKd>omKUVV+)3u}*B-W$n09lTz9b+CG_3LKuZe5%M{7}00v zmW6EEE)TqCH{@j2YsB44u7*G46BTrGGIQwet}L<{4ohw@VfbEbWQE2XTTw=;sfZYM zSb_g+N$nh02^-hpVkmZ*Qt@@c781^U^;_#?I4%(8@y9Jd`YcDC+j52F0NdPXA{D!I ztes^veALZ(+PS(SWw$rQ30s4uagJNEMiZOL!>C1jG7;YLnk!PrTCKiCv6|hoIAJ_8ic?D`fKpOrtVOfH zB+W^({5z{CP3#z+U}mZkT4w-~6-&8Z9SPW&Y52j!2QOCr+dA(zdhf7NvB6J(er#Ul zh<)PW-g5wVH;!l?yJOC*BUSAsCC+n81K}14rp#4KXzjKL0l}=yy8No$*L-};fC-VFURL?clu+XR7EJEll&uXnW1^x;X#RVt`pGOIrWl)r(CzIRGxcu?=y!2HJ;XZd9~s6t$n<} zpTb`#`<(nv8LMggUEB9VZH%Y^eHZBxgW;aIhhUO8*0VVSuPWPu3-|pLdbIEvL_m1Y zl=X!c9xuD%#?Rf)v+F&~Q-v=mYD8}QzF6r4B+6X)wET)4N`q1wMrydoTD`!a{S7xs zG~1J$?YF#u-TUa+8^xbk1?HV)J@%4FE;^t6vP5|X4Vi6p5F4bo0QE7pDgwHfQ^EDI zoejKcw!T7FR^#95IeP347u%2o^joH>1BdZanlo`wmqP{jHtbf~$F)0H(`@6%;x-sz z_FO)(WD0J#;|K}3o8sk26Bh#grrA5yad0zD*5t{$(kFZdWv?iR9bi_;p# zUURB8U3pfDyE{eJ)?Kg^;I^nV?`xVb7lPTUf~&7wr1@9m`WVu1;=nlV!gC&>K+ZsO z_Sj8b~rcPhN}w>rfhab6|WO%{Og{!~n->G3Tr2}7_s zyIQH2U@5UL^Xud#e3$Ht_kmpT0j_T&wD%A9<{pTXq-Sk)knt<(~InierO=! z2p`()B!L$UCcaa=5mbrcsL4Vs7M`-q7^R%epvuJ^1oYi+z~zsU_uv zU!W}l-V*VwsYk8mmq(M+mjQ9C5px7Q_>qC%Xe&o8gF29C4+twG?0)iPx;!JYZny5D zL9~mY-*1Xq$lSoG2et3{#84@DQUsoADj1^$F8bd*V83}|Ct%1x_|>0cgQUpt+^+Zy z^eJBPFfh_HPz?oz1SU1`anCg=B|?*(DX{-QFrP#XfA-)1bf9rFO3xu-xjUz6cjMM} z0wM`z#ayC-exoCqHg`8kC+>eS$Pw7m7+yq+?nfM8st$qy_9DR_v{Q~TzI-N$ zP_qtp(mHb8?P_-M!H%TL(?XclnIIAq_vPiE6VWSN%Al-LTYKNK(xX(;d$~^zR7)St zXG`s7UlcBu-W}Vhl&}3c2RJ%o!`~j+FZ_SJ0Dt&xJgkd6?}ng3+Tcb@btw$yLU!p( zKpIhPH)Fm6`Dny@4S)LNMlQl#!eTh5e8zT8{us-vs2gZbxlU@8~ zLS%I3$0H|3uRN*fL`UA{G8AOawo5XhsAH@?Ywqr^)eq0vTGxkt)w?A~-3&9g`;bK#`3Z}oCI2V%~u zFJfM*I$obtt5n76{CiwK+A7eEB$bxi+KePI0~GY{ELJp=_erUf)L`D-s~nu8TH4WF z!+tT>0}WZWl8H^-b;iVQI_{vR*HIyLZe=^*3hUpU=)Op$e;})AWNvA#w0;m{nwegh zCvuCbxNmBb^=ukkfxRxmAumA|E+H%}Erros!LU|ho}SCy)0iu1)E8`q4l}f~xAVoC zEmq?yrj2OEfb=-)V4vYKqq_=S;c}v**I#T}1d@JY&W$a|$O0Ej?+tW_d)`+{?xT+9 z*E$j7*0u29y}Cv^M$8o;GgGk{SCZ0B;&XtE$Z@2yJKp1B z7-L*%jVdg(HbvH|amZ@UHk6@QWiXmd$Bq=+@!Z`@4X;tEk1p#$-ZlT3WJlLxlv0@O zUh#K>x|WFkj6s75ZaC|3N*+_Fklbp+0S;)Q*i(IpW|vr|d#DpvvEeBW%o-yoE=Kd+ zG~QnG>yWT*nfE+0$G!n57ulC*tXmn{F&y-5MB zSk5qX!e#K&lJTOd#PbFhE7`MfEB%ZI+_{*k9z&MnFoq16zIzF zOGLGQy6=pTy^0JrJAvV0+Lh4lF!1B@;>FerM>sm(6%>K!;0_1NwyXvFxgEr6Y7@iG zkH|5;*ldf}(D8j6cgFql*t~}Cle)TFxH7Uh9lM2@>;$5%>`tjyNZOzTo3C_^QFfmm zsTF~#RCPhX@!*ZR{1kzyHYegpHIX~yy{*qq`n?CbciClsXJxoIH5+MMR zIoEfXA!Dk|Dn1;wJmL%l0;+tKT&XMlE~!5=`;^JKzy}Ii6QrPJtyhyIYh~@#`^BQu zg1eXA6j&+DI-KJqCEQ+@)+4=erSjzVx>$!P zmmu=QyfY|7tcyQ1Wa)^0qh#@=pXO~lM4#?7ymc*HHN0gg1PU6sXB?{F{fZ>tDCI)C z4zr7MADYos=+X77kKlU1oR6l=g4CKte=b#ElHKZeT~3lB?)`o-C`a){PK( z9=)f${WLYSlnz52WHUn84}xC{p`N8XM^fnK)Sc47j|Ybfg(WvSFy+`6O*N<~P}OCz z5vql7vwT8P0phdPxrY%F9txWi;hY!3h-@1ms}`gL;$dDEYS1C^=18y^01@}@cE??W z3^qO!#tfk4#~vc8*9gTi($t6YZ<*krfy%-CjWlZJH)$(fjLhqejz+`#hSE{`JW-X7 z`>xsT{ptp`H`>cx`Y}4zH~l=d0f;CdUB??jN26J6;DXXNKkdg~ww7mvg7$Yg&GQ<% ze)k{3i2AAc60B&A-|y)Fiyto;>(TA&mjrB1w+Vj}|(ZfOGKn(V>no5cP;4~?a|MM9qai$5$YH}In)H_N|kJ%wEE zdx$Z6Fc7ko*OZyo|CG!w&B?BIv=@OJI>X*t!GUulJ9dnILly;;_GbzLJoz@!^eyTP z3FJ6(Fmdx-3yB*J!WKSFbNv27JBI|e?BPdEz|QNBeLkBXBJuZxY^0Y|Imm3u@`1iG z`~1gsxuzr*Sya zJh;m-lFd&fn=g^uzqV+wix*k~8f!T zn3ir71+XJq3a*|ATML^!$z&d9uh&(qV~yQRUJXAQSBDwbpX|E&S8!O65W-Z+>9)&z zGMbzw&w;!+q_q|G&ugeXvj@*#c7abnsgu&v1r4nWX-*X5c47i`^q;+i-j&%PL5+I^ zjT(Ca(EpQqY5vF(`frjLkz+&XzZp03j;)~oqr4A7IQb0oR}&o+aAHOLSLF3Qz~=T{ ztx)Jax6J=;#X-v)pe;Ho5FsZKNaPfq_&;)*74P8SJ1G3W)O%SRw8#yDJf{bNPHBk$ z(LVeKTI2f*y`7R1|DzoD4|FQ{7s3_B0Og;f6aUqZdmpmpJz9hFAMi-{9b^Sfp5YSz z73g}0yx*aJ=d~mD4yh9VRYZCR+TODbaQxHDtmNM-OgN_?{*Oe?uXo7)eK|_>ABaxo zFLZIvLj3>ra^Bag{(;Qo-yurSrwcX!i~(rtf)Z5wZem)zo4NoVYmnfj6#&r|Bw!~9 zV!K8M_3j~qo-a`WzwAJWS3&?3d(h<-5yX8zN~@GT(#HRJE;r&|R8PTpVB zD4!67cZ3cKy(0uH7l88bxQPD=xcT2f-^=2lfkM#boeF@j93*xxO8k%K_&?n5ig%6} z)Oybbz#aNK%-cN=p#R5TlXUF;SNMUB_@C9pf0~z${1?RfJMp;(LcsYH=<>k;@HP+n syvPdje?%w#=c($S<~7S8@>K@hkBTtwU;THn!}mQ03j*TT&VOqE4-{M+YybcN diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 62f495d..ac72c34 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca..0adc8e1 100755 --- a/gradlew +++ b/gradlew @@ -83,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# 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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 568c5ec..b29b10d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,17 +7,5 @@ pluginManagement { repositories { gradlePluginPortal() mavenCentral() - maven(url = "https://maven.pkg.github.com/radar-base/radar-commons") { - credentials { - username = System.getenv("GITHUB_ACTOR") - ?: extra.properties["gpr.user"] as? String - ?: extra.properties["public.gpr.user"] as? String - password = System.getenv("GITHUB_TOKEN") - ?: extra.properties["gpr.token"] as? String - ?: (extra.properties["public.gpr.token"] as? String)?.let { - java.util.Base64.getDecoder().decode(it).decodeToString() - } - } - } } } From 1d6ec5a9cf02dc9a2a836074b8053767806ce56f Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 2 Oct 2023 11:43:06 +0200 Subject: [PATCH 33/36] Updated dependencies and style --- .editorconfig | 4 ---- buildSrc/src/main/kotlin/Versions.kt | 4 ++-- .../jersey/hibernate/HibernateRepository.kt | 7 +++++-- .../hibernate/RadarEntityManagerFactoryFactory.kt | 2 +- .../hibernate/config/RadarPersistenceInfo.kt | 3 ++- .../radarbase/jersey/hibernate/HibernateTest.kt | 14 ++++++++++++-- .../radarbase/jersey/hibernate/db/ProjectDao.kt | 8 +++++++- .../hibernate/mock/resource/ProjectResource.kt | 8 +++++++- .../org/radarbase/jersey/auth/AuthService.kt | 6 +++++- .../auth/disabled/DisabledAuthorizationOracle.kt | 6 +++++- .../jersey/auth/jwt/TokenValidatorFactory.kt | 6 +++++- .../jersey/coroutines/CoroutineRequestWrapper.kt | 7 ++++++- .../jersey/exception/ExceptionResourceEnhancer.kt | 10 +++++++++- .../jersey/service/ScopedAsyncCoroutineService.kt | 8 +++++++- .../service/managementportal/MPProjectService.kt | 2 +- .../org/radarbase/jersey/auth/OAuthHelper.kt | 2 +- .../jersey/auth/RadarJerseyResourceEnhancerTest.kt | 6 +++++- .../doc/config/SwaggerResourceEnhancerTest.kt | 4 +++- .../mock/MockSwaggerResourceEnhancerFactory.kt | 2 +- .../radarbase/jersey/mock/resource/MockResource.kt | 8 +++++++- 20 files changed, 91 insertions(+), 26 deletions(-) diff --git a/.editorconfig b/.editorconfig index ce7dcfe..d340b76 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,12 +15,8 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.{kt,kts}] -ktlint_standard_no-wildcard-imports = disabled - [*.md] trim_trailing_whitespace = false [*.{json,yaml,yml}] -indent_style = space indent_size = 2 diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 90be070..4fc094a 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -12,7 +12,7 @@ object Versions { const val hk2 = "3.0.4" const val managementPortal = "2.0.1-SNAPSHOT" - const val radarCommons = "1.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" @@ -29,7 +29,7 @@ object Versions { const val swagger = "2.2.16" const val mustache = "0.9.10" - const val hibernate = "6.3.0.Final" + const val hibernate = "6.3.1.Final" const val liquibase = "4.23.2" const val postgres = "42.6.0" const val h2 = "2.2.224" 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 becd34b..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,7 +3,8 @@ package org.radarbase.jersey.hibernate import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.persistence.EntityTransaction -import kotlinx.coroutines.* +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 @@ -24,7 +25,9 @@ open class HibernateRepository( /** * Run a transaction and commit it. If an exception occurs, the transaction is rolled back. */ - suspend fun transact(transactionOperation: EntityManager.() -> T): T = withContext(Dispatchers.IO) { + suspend fun transact( + transactionOperation: EntityManager.() -> T, + ): T = withContext(Dispatchers.IO) { createTransaction( block = { transaction -> try { 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 edbd95f..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,7 +8,7 @@ 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, 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 caa7b2d..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,7 +7,8 @@ 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( 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 4ec31a0..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 @@ -3,13 +3,23 @@ package org.radarbase.jersey.hibernate import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine -import okhttp3.* +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.Companion.toRequestBody +import okhttp3.Response import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.`is` -import org.junit.jupiter.api.* +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 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/mock/resource/ProjectResource.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/resource/ProjectResource.kt index fc48e76..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,6 +1,12 @@ 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 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 index 3c8d3bd..982b4cc 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/auth/AuthService.kt @@ -4,7 +4,11 @@ import jakarta.inject.Provider import jakarta.ws.rs.core.Context import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import org.radarbase.auth.authorization.* +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 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 index a68e4a5..2279c6f 100644 --- 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 @@ -1,6 +1,10 @@ package org.radarbase.jersey.auth.disabled -import org.radarbase.auth.authorization.* +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 { 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 index 24ae050..3933aac 100644 --- 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 @@ -14,8 +14,12 @@ 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.* +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 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 index bf21645..5e4591e 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/coroutines/CoroutineRequestWrapper.kt @@ -1,6 +1,11 @@ package org.radarbase.jersey.coroutines -import kotlinx.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 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 7fface0..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,7 +4,15 @@ 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 { 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 index e3ca004..c197160 100644 --- a/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt +++ b/radar-jersey/src/main/kotlin/org/radarbase/jersey/service/ScopedAsyncCoroutineService.kt @@ -6,7 +6,13 @@ 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.* +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 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 bf9478f..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,7 +16,7 @@ package org.radarbase.jersey.service.managementportal -import io.ktor.http.* +import io.ktor.http.HttpStatusCode import jakarta.inject.Provider import jakarta.ws.rs.core.Context import org.radarbase.auth.authorization.EntityDetails 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 e96a697..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 @@ -12,7 +12,7 @@ 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. 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 6320039..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 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 8a8f79c..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 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 c00aa25..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 { 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 f9b954b..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,7 +13,13 @@ 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 From dce04df8393bc75316090e58d5e7b6c9a14b48e1 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 16 Oct 2023 13:23:27 +0200 Subject: [PATCH 34/36] Bumped dependencies --- buildSrc/src/main/kotlin/Versions.kt | 13 +++++++------ gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 14 +++++++------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 4fc094a..16a1199 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,3 +1,4 @@ +@Suppress("ConstPropertyName") object Versions { const val project = "0.11.0-SNAPSHOT" const val kotlin = "1.9.10" @@ -11,12 +12,12 @@ object Versions { const val mockitoKotlin = "5.1.0" const val hk2 = "3.0.4" - const val managementPortal = "2.0.1-SNAPSHOT" + 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.2" + const val jackson = "2.15.3" const val slf4j = "2.0.9" const val log4j2 = "2.20.0" const val jakartaXmlBind = "4.0.1" @@ -26,13 +27,13 @@ object Versions { const val hibernateValidator = "8.0.1.Final" const val glassfishJakartaEl = "4.0.2" const val jakartaActivation = "2.1.2" - const val swagger = "2.2.16" - const val mustache = "0.9.10" + const val swagger = "2.2.17" + const val mustache = "0.9.11" const val hibernate = "6.3.1.Final" - const val liquibase = "4.23.2" + const val liquibase = "4.24.0" const val postgres = "42.6.0" const val h2 = "2.2.224" - const val wrapper = "8.3" + const val wrapper = "8.4" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ac72c34..3fa8f86 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 0adc8e1..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -145,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 @@ -153,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 @@ -202,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $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. +# 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" \ From 85e0df1c1ac3b0b903e11c123a3fb0080401c173 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 16 Oct 2023 13:24:07 +0200 Subject: [PATCH 35/36] Bump release version --- buildSrc/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 16a1199..3faa5ee 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,6 +1,6 @@ @Suppress("ConstPropertyName") object Versions { - const val project = "0.11.0-SNAPSHOT" + const val project = "0.11.0" const val kotlin = "1.9.10" const val java: Int = 17 From 36fd3d85706b7bc8905fb51a61fffc9025f30e96 Mon Sep 17 00:00:00 2001 From: Joris Borgdorff Date: Mon, 16 Oct 2023 13:36:45 +0200 Subject: [PATCH 36/36] Update README --- README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 9 deletions(-) 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) } } ```