diff --git a/.gitignore b/.gitignore index 3952e44..16240ff 100644 --- a/.gitignore +++ b/.gitignore @@ -86,7 +86,7 @@ out/ # Gradle ###################### .gradle/ -/build/ +build/ .gradletasknamecache ###################### @@ -155,7 +155,7 @@ Desktop.ini .eslintcache # output directory -/out/ +out/ # don't ignore jars in /libs -!/libs/**/*.jar +!libs/**/*.jar diff --git a/build.gradle b/build.gradle index d3f91e4..71fa823 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,21 @@ plugins { - id 'org.jetbrains.kotlin.jvm' version "1.4.0" + id 'org.jetbrains.kotlin.jvm' id 'com.jfrog.bintray' version '1.8.5' apply false } +allprojects { + group = 'org.radarbase' + version = '0.3.0' + + ext { + jerseyVersion = "2.31" + grizzlyVersion = "2.4.4" + okhttpVersion = "4.8.1" + junitVersion = "5.6.2" + } +} + description = 'Library for Jersey authorization, exception handling and configuration with the RADAR platform' -group = 'org.radarbase' -version = '0.2.4' ext { githubRepoName = 'RADAR-base/radar-jersey' @@ -17,17 +27,13 @@ ext { managementPortalVersion = "0.5.8" jakartaWsRsVersion = "2.1.6" jakartaAnnotationVersion = "1.3.5" - jerseyVersion = "2.31" - grizzlyVersion = "2.4.4" jacksonVersion = "2.11.2" jacksonModuleVersion = "2.11.2" - okhttpVersion = "4.8.1" slf4jVersion = "1.7.30" javaxXmlBindVersion = "2.3.1" javaxJaxbCoreVersion = "2.3.0.1" javaxJaxbRuntimeVersion = "2.3.3" javaxActivation = "1.1.1" - junitVersion = "5.6.2" } repositories { @@ -45,6 +51,9 @@ dependencies { api("org.glassfish.jersey.core:jersey-server:$jerseyVersion") api("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion") + + implementation("com.squareup.okhttp3:okhttp:$okhttpVersion") implementation("org.glassfish.jersey.containers:jersey-container-grizzly2-http:$jerseyVersion") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonModuleVersion") @@ -52,11 +61,11 @@ dependencies { // exception template rendering implementation 'com.github.spullara.mustache.java:compiler:0.9.6' - implementation "org.jetbrains.kotlin:kotlin-reflect" - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + api "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" implementation "org.slf4j:slf4j-api:$slf4jVersion" - implementation("org.glassfish.jersey.inject:jersey-hk2:$jerseyVersion") + api("org.glassfish.jersey.inject:jersey-hk2:$jerseyVersion") runtimeOnly("org.glassfish.jersey.media:jersey-media-json-jackson:$jerseyVersion") @@ -74,7 +83,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") testImplementation 'org.hamcrest:hamcrest-all:1.3' - testImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") testRuntimeOnly("ch.qos.logback:logback-classic:1.2.3") } diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7c4e20a --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlinVersion=1.4.10 diff --git a/radar-jersey-hibernate/build.gradle b/radar-jersey-hibernate/build.gradle new file mode 100644 index 0000000..b75a79c --- /dev/null +++ b/radar-jersey-hibernate/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' +} + +description = 'Library for Jersey with Hibernate with the RADAR platform' + +repositories { + jcenter() + maven { url = "https://dl.bintray.com/radar-cns/org.radarcns" } +} + +ext.extra = [ + "hibernateVersion": "5.4.20.Final", + "liquibaseVersion": "3.10.2", + "postgresVersion": "42.2.16", + "h2Version": "1.4.200", +] + +dependencies { + api(project(":")) + api("org.hibernate:hibernate-core:${project.extra["hibernateVersion"]}") + runtimeOnly("org.hibernate:hibernate-c3p0:${project.extra["hibernateVersion"]}") + implementation("org.liquibase:liquibase-core:${project.extra["liquibaseVersion"]}") + + runtimeOnly("org.postgresql:postgresql:${project.extra["postgresVersion"]}") + + testRuntimeOnly("org.glassfish.grizzly:grizzly-http-server:$grizzlyVersion") + testRuntimeOnly("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:$jerseyVersion") + testImplementation("com.h2database:h2:${project.extra["h2Version"]}") + + testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") + testImplementation 'org.hamcrest:hamcrest-all:1.3' + testImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") + + testRuntimeOnly("ch.qos.logback:logback-classic:1.2.3") +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + jvmTarget = "11" + apiVersion = "1.4" + languageVersion = "1.4" + } +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + showStandardStreams = true + } +} + +apply from: "$rootDir/gradle/publishing.gradle" diff --git a/radar-jersey-hibernate/src/README.md b/radar-jersey-hibernate/src/README.md new file mode 100644 index 0000000..517ed69 --- /dev/null +++ b/radar-jersey-hibernate/src/README.md @@ -0,0 +1,20 @@ +# radar-jersey-hibernate + +Extension module of radar-jersey to use Hibernate in a Jersey project. Default database configuration is for PostgreSQL, but any JDBC driver compatible with Hibernate can be used. The module is activated by adding `HibernateResourceEnhancer` to your `EnhancerFactory` with a given database configuration. When this is done, `Provider` can be injected via the `Context` annotation. + +To make full use if the module, let any database-using classes extend `HibernateRepository`. Any database operations must be wrapped in a `transact { doSomething() }` or `createTransaction { doSomething() }.use { result -> doSomething result }`. Both closures have EntityManager as `this` object, meaning that `createQuery` and derivatives should be called without referencing an additional `EntityManager`. + +By default, liquibase is used to manage database versioning. It can be disabled by setting `DatabaseConfig.liquibase.enable` to `false`. If enabled, liquibase expects the master changelog file to reside in resources at `DatabaseConfig.liquibase.changelogs` (default `db/changelog/changes/db.changelog-master.xml`.) + +Example repository code: + +```kotlin +class ProjectRepositoryImpl( + @Context em: Provider +): ProjectRepository, HibernateRepository(em) { + fun list(): List = transact { + createQuery("SELECT p FROM Project p", ProjectDao::class.java) + .resultList + } +} +``` 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 new file mode 100644 index 0000000..c576b95 --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetrics.kt @@ -0,0 +1,42 @@ +package org.radarbase.jersey.hibernate + +import liquibase.database.DatabaseFactory +import liquibase.database.jvm.JdbcConnection +import org.radarbase.jersey.hibernate.RadarEntityManagerFactory.Companion.connection +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.CachedValue +import org.slf4j.LoggerFactory +import java.time.Duration +import javax.inject.Provider +import javax.persistence.EntityManager +import javax.ws.rs.core.Context + +class DatabaseHealthMetrics( + @Context private val entityManager: Provider, + @Context dbConfig: DatabaseConfig +): Metric(name = "db") { + private val cachedStatus = CachedValue( + Duration.ofSeconds(dbConfig.healthCheckValiditySeconds), + Duration.ofSeconds(dbConfig.healthCheckValiditySeconds)) { + testConnection() + } + + override val status: HealthService.Status + get() = cachedStatus.get { it == HealthService.Status.UP } + + override val metrics: Any + get() = mapOf("status" to status) + + private fun testConnection(): HealthService.Status = try { + entityManager.get().connection().close() + HealthService.Status.UP + } catch (ex: Throwable) { + 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 new file mode 100644 index 0000000..50d6323 --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/DatabaseInitialization.kt @@ -0,0 +1,58 @@ +package org.radarbase.jersey.hibernate + +import liquibase.Contexts +import liquibase.Liquibase +import liquibase.database.DatabaseFactory +import liquibase.database.jvm.JdbcConnection +import liquibase.resource.ClassLoaderResourceAccessor +import org.glassfish.jersey.server.monitoring.ApplicationEvent +import org.glassfish.jersey.server.monitoring.ApplicationEventListener +import org.glassfish.jersey.server.monitoring.RequestEvent +import org.glassfish.jersey.server.monitoring.RequestEventListener +import org.radarbase.jersey.hibernate.RadarEntityManagerFactory.Companion.connection +import org.radarbase.jersey.hibernate.RadarEntityManagerFactoryFactory.Companion.useEntityManager +import org.radarbase.jersey.hibernate.config.DatabaseConfig +import org.slf4j.LoggerFactory +import java.sql.Connection +import javax.persistence.EntityManagerFactory +import javax.ws.rs.core.Context +import javax.ws.rs.ext.Provider + +@Provider +class DatabaseInitialization( + @Context private val entityManagerFactory: javax.inject.Provider, + @Context private val dbConfig: DatabaseConfig, +) : ApplicationEventListener { + override fun onEvent(event: ApplicationEvent) { + logger.info("Application state: {}", event.type) + if (event.type != ApplicationEvent.Type.INITIALIZATION_APP_FINISHED) return + try { + entityManagerFactory.get().useEntityManager { + // make first connection + it.connection().use { connection -> + if (dbConfig.liquibase.enable) { + initializeLiquibase(connection) + } + } + } + } catch (ex: Throwable) { + throw IllegalStateException("Cannot initialize database.", ex) + } + } + + private fun initializeLiquibase(connection: Connection) { + logger.info("Initializing Liquibase") + val database = DatabaseFactory.getInstance() + .findCorrectDatabaseImplementation( + JdbcConnection(connection)) + Liquibase(dbConfig.liquibase.changelogs, ClassLoaderResourceAccessor(), database).use { + it.update(null as Contexts?) + } + } + + override fun onRequest(requestEvent: RequestEvent?): RequestEventListener? = null + + companion object { + private val logger = LoggerFactory.getLogger(DatabaseInitialization::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 new file mode 100644 index 0000000..c9bb1b8 --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/HibernateRepository.kt @@ -0,0 +1,60 @@ +package org.radarbase.jersey.hibernate + +import org.radarbase.jersey.exception.HttpInternalServerException +import org.radarbase.jersey.hibernate.config.CloseableTransaction +import org.slf4j.LoggerFactory +import javax.inject.Provider +import javax.persistence.EntityManager +import javax.persistence.EntityTransaction + +open class HibernateRepository( + @Suppress("MemberVisibilityCanBePrivate") + protected val entityManager: Provider +) { + /** + * Run a transaction and commit it. If an exception occurs, the transaction is rolled back. + */ + open fun transact(transactionOperation: EntityManager.() -> T) = createTransaction { + it.use { transactionOperation() } + } + + /** + * Start a transaction without committing it. If an exception occurs, the transaction is rolled back. + */ + open fun createTransaction(transactionOperation: EntityManager.(CloseableTransaction) -> T): T { + val entityManager = entityManager.get() + val currentTransaction = entityManager.transaction + ?: throw HttpInternalServerException("transaction_not_found", "Cannot find a transaction from EntityManager") + + currentTransaction.begin() + try { + return entityManager.transactionOperation(object : CloseableTransaction { + override val transaction: EntityTransaction = currentTransaction + + override fun close() { + try { + transaction.commit() + } catch (ex: Exception) { + logger.error("Rolling back operation", ex) + if (currentTransaction.isActive) { + currentTransaction.rollback() + } + throw ex + } + } + }) + } catch (ex: Exception) { + logger.error("Rolling back operation", ex) + if (currentTransaction.isActive) { + currentTransaction.rollback() + } + throw ex + } + } + + + companion object { + private val logger = LoggerFactory.getLogger(HibernateRepository::class.java) + } +} + 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 new file mode 100644 index 0000000..7fed4bd --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactory.kt @@ -0,0 +1,33 @@ +package org.radarbase.jersey.hibernate + +import org.glassfish.jersey.internal.inject.DisposableSupplier +import org.hibernate.engine.spi.SharedSessionContractImplementor +import org.hibernate.internal.SessionImpl +import org.slf4j.LoggerFactory +import javax.inject.Provider +import javax.persistence.EntityManager +import javax.persistence.EntityManagerFactory +import javax.ws.rs.core.Context + +class RadarEntityManagerFactory( + @Context private val emf: EntityManagerFactory +) : DisposableSupplier { + + override fun get(): EntityManager { + logger.debug("Creating EntityManager...") + return emf.createEntityManager() + } + + override fun dispose(instance: EntityManager?) { + instance?.let { + logger.debug("Disposing EntityManager") + it.close() + } + } + + companion object { + private val logger = LoggerFactory.getLogger(RadarEntityManagerFactory::class.java) + + fun EntityManager.connection() = unwrap(SessionImpl::class.java).connection() + } +} 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 new file mode 100644 index 0000000..99560b9 --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/RadarEntityManagerFactoryFactory.kt @@ -0,0 +1,52 @@ +package org.radarbase.jersey.hibernate + +import org.glassfish.jersey.internal.inject.DisposableSupplier +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 javax.persistence.EntityManager +import javax.persistence.EntityManagerFactory +import javax.ws.rs.core.Context + +/** + * Creates EntityManagerFactory using Hibernate. When an [EntityManagerFactory] is created, + * Liquibase is used to initialize the database, if so configured. + */ +class RadarEntityManagerFactoryFactory( + @Context config: DatabaseConfig +) : DisposableSupplier { + private val persistenceInfo = RadarPersistenceInfo(config) + private val persistenceProvider = HibernatePersistenceProvider() + + override fun get(): EntityManagerFactory { + logger.info("Initializing EntityManagerFactory with config: $persistenceInfo") + + return persistenceProvider.createContainerEntityManagerFactory(persistenceInfo, Properties()) + } + + override fun dispose(instance: EntityManagerFactory?) { + logger.info("Disposing EntityManagerFactory") + instance?.close() + } + + companion object { + private val logger = LoggerFactory.getLogger(RadarEntityManagerFactoryFactory::class.java) + + /** + * Use an EntityManager for the duration of [method]. No reference of the passed + * [EntityManager] should be returned back, either directly or indirectly. + */ + @Suppress("unused") + inline fun EntityManagerFactory.useEntityManager(method: (EntityManager) -> T): T { + val entityManager = createEntityManager() + return try { + method(entityManager) + } finally { + entityManager.close() + } + } + } +} + 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 new file mode 100644 index 0000000..aad87e7 --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/CloseableTransaction.kt @@ -0,0 +1,9 @@ +package org.radarbase.jersey.hibernate.config + +import java.io.Closeable +import javax.persistence.EntityTransaction + +interface CloseableTransaction : Closeable { + val transaction: EntityTransaction + override fun close() +} 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 new file mode 100644 index 0000000..07708cb --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/DatabaseConfig.kt @@ -0,0 +1,20 @@ +package org.radarbase.jersey.hibernate.config + + +data class DatabaseConfig( + /** Classes that can be used in Hibernate queries. */ + val managedClasses: List = emptyList(), + val driver: String? = "org.postgresql.Driver", + val url: String? = null, + val user: String? = null, + val password: String? = null, + val dialect: String = "org.hibernate.dialect.PostgreSQLDialect", + val properties: Map = emptyMap(), + val liquibase: LiquibaseConfig = LiquibaseConfig(), + val healthCheckValiditySeconds: Long = 60 +) + +data class LiquibaseConfig( + val enable: Boolean = true, + val changelogs: String = "db/changelog/changes/db.changelog-master.xml", +) 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 new file mode 100644 index 0000000..4607917 --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/HibernateResourceEnhancer.kt @@ -0,0 +1,37 @@ +package org.radarbase.jersey.hibernate.config + +import org.glassfish.jersey.internal.inject.AbstractBinder +import org.glassfish.jersey.process.internal.RequestScoped +import org.radarbase.jersey.config.JerseyResourceEnhancer +import org.radarbase.jersey.hibernate.DatabaseHealthMetrics +import org.radarbase.jersey.hibernate.DatabaseInitialization +import org.radarbase.jersey.hibernate.RadarEntityManagerFactory +import org.radarbase.jersey.hibernate.RadarEntityManagerFactoryFactory +import org.radarbase.jersey.service.HealthService +import javax.inject.Singleton +import javax.persistence.EntityManager +import javax.persistence.EntityManagerFactory + +class HibernateResourceEnhancer( + private val databaseConfig: DatabaseConfig +) : JerseyResourceEnhancer { + override val classes: Array> = arrayOf(DatabaseInitialization::class.java) + + override fun AbstractBinder.enhance() { + bind(databaseConfig) + .to(DatabaseConfig::class.java) + + bind(DatabaseHealthMetrics::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) + + bindFactory(RadarEntityManagerFactory::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 new file mode 100644 index 0000000..275713f --- /dev/null +++ b/radar-jersey-hibernate/src/main/kotlin/org/radarbase/jersey/hibernate/config/RadarPersistenceInfo.kt @@ -0,0 +1,90 @@ +package org.radarbase.jersey.hibernate.config + +import org.hibernate.jpa.HibernatePersistenceProvider +import java.net.URL +import java.util.* +import javax.persistence.SharedCacheMode +import javax.persistence.ValidationMode +import javax.persistence.spi.ClassTransformer +import javax.persistence.spi.PersistenceUnitInfo +import javax.persistence.spi.PersistenceUnitTransactionType +import javax.sql.DataSource + +class RadarPersistenceInfo( + config: DatabaseConfig +): PersistenceUnitInfo { + @Suppress("UNCHECKED_CAST") + private val properties: Properties = Properties().apply { + put("javax.persistence.schema-generation.database.action", "none") + put("org.hibernate.flushMode", "COMMIT") + put("hibernate.connection.provider_class", "org.hibernate.connection.C3P0ConnectionProvider") + put("hibernate.c3p0.max_size", "50") + put("hibernate.c3p0.min_size", "0") + put("hibernate.c3p0.acquire_increment", "1") + put("hibernate.c3p0.idle_test_period", "300") + put("hibernate.c3p0.max_statements", "0") + put("hibernate.c3p0.timeout", "100") + put("hibernate.c3p0.checkoutTimeout", "5000") + put("hibernate.c3p0.acquireRetryAttempts", "3") + put("hibernate.c3p0.breakAfterAcquireFailure", "false") + + putAll((mapOf( + "javax.persistence.jdbc.driver" to config.driver, + "javax.persistence.jdbc.url" to config.url, + "javax.persistence.jdbc.user" to config.user, + "javax.persistence.jdbc.password" to config.password, + "hibernate.dialect" to config.dialect) + + config.properties) + .filterValues { it != null } as Map) + } + + private val managedClasses = config.managedClasses + + override fun getPersistenceUnitName(): String = "org.radarbase.jersey.hibernate" + + override fun getPersistenceProviderClassName(): String = HibernatePersistenceProvider::class.java.name + + override fun getTransactionType(): PersistenceUnitTransactionType = PersistenceUnitTransactionType.RESOURCE_LOCAL + + override fun getJtaDataSource(): DataSource? = null + + override fun getNonJtaDataSource(): DataSource? = null + + override fun getMappingFileNames(): List = emptyList() + + override fun getJarFileUrls(): List = emptyList() + + override fun getPersistenceUnitRootUrl(): URL? = null + + override fun getManagedClassNames(): List = managedClasses + + override fun excludeUnlistedClasses(): Boolean = false + + override fun getSharedCacheMode(): SharedCacheMode = SharedCacheMode.UNSPECIFIED + + override fun getValidationMode(): ValidationMode = ValidationMode.AUTO + + override fun getProperties(): Properties = properties + + override fun getPersistenceXMLSchemaVersion(): String = "2.0" + + override fun getClassLoader(): ClassLoader = Thread.currentThread().contextClassLoader + + override fun addTransformer(transformer: ClassTransformer?) = Unit + + override fun getNewTempClassLoader(): ClassLoader? = null + + override fun toString() = "RadarPersistenceInfo(managedClasses=$managedClasses, properties=$properties)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RadarPersistenceInfo + + 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 new file mode 100644 index 0000000..d533fea --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/DatabaseHealthMetricsTest.kt @@ -0,0 +1,113 @@ +package org.radarbase.jersey.hibernate + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.hamcrest.MatcherAssert +import org.hamcrest.Matchers +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.radarbase.jersey.GrizzlyServer +import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.config.ConfigLoader +import org.radarbase.jersey.hibernate.config.DatabaseConfig +import org.radarbase.jersey.hibernate.db.ProjectDao +import org.radarbase.jersey.hibernate.mock.MockResourceEnhancerFactory +import java.net.URI +import java.util.concurrent.TimeUnit + +internal class DatabaseHealthMetricsTest { + @Test + fun existsTest() { + val authConfig = AuthConfig( + 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", + ) + + val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) + + val server = GrizzlyServer(URI.create("http://localhost:9091"), resources) + server.start() + + try { + val client = OkHttpClient() + + client.newCall(Request.Builder() + .url("http://localhost:9091/health") + .build()).execute().use { response -> + MatcherAssert.assertThat(response.isSuccessful, Matchers.`is`(true)) + MatcherAssert.assertThat(response.body?.string(), Matchers.equalTo("{\"status\":\"UP\",\"db\":{\"status\":\"UP\"}}")) + } + } finally { + server.shutdown() + } + } + + @Test + fun databaseDoesNotExistTest() { + val authConfig = AuthConfig( + 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", + ) + + val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) + + assertThrows { GrizzlyServer(URI.create("http://localhost:9091"), resources) } + } + + + @Test + fun databaseIsDisabledTest() { + val tcp = org.h2.tools.Server.createTcpServer("-tcpPort", "9999", "-baseDir", "build/resources/test", "-ifNotExists"); + tcp.start() + + val authConfig = AuthConfig( + jwtResourceName = "res_jerseyTest") + 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, + ) + + val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) + + val server = GrizzlyServer(URI.create("http://localhost:9091"), resources) + server.start() + + try { + val client = OkHttpClient.Builder() + .readTimeout(30, TimeUnit.SECONDS) + .build() + + + client.newCall(Request.Builder() + .url("http://localhost:9091/health") + .build()).execute().use { response -> + MatcherAssert.assertThat(response.isSuccessful, Matchers.`is`(true)) + MatcherAssert.assertThat(response.body?.string(), Matchers.equalTo("{\"status\":\"UP\",\"db\":{\"status\":\"UP\"}}")) + } + + // Disable database. Connections should now fail + tcp.stop() + Thread.sleep(1_000L) + + client.newCall(Request.Builder() + .url("http://localhost:9091/health") + .build()).execute().use { response -> + MatcherAssert.assertThat(response.isSuccessful, Matchers.`is`(true)) + MatcherAssert.assertThat(response.body?.string(), Matchers.equalTo("{\"status\":\"DOWN\",\"db\":{\"status\":\"DOWN\"}}")) + } + } finally { + server.shutdown() + } + } +} 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 new file mode 100644 index 0000000..95571b6 --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/HibernateTest.kt @@ -0,0 +1,132 @@ +package org.radarbase.jersey.hibernate + +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +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.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 java.net.URI + +internal class HibernateTest { + private lateinit var client: OkHttpClient + private lateinit var server: GrizzlyServer + + @BeforeEach + fun setUp() { + val authConfig = AuthConfig( + 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", + ) + + val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig, databaseConfig) + + server = GrizzlyServer(URI.create("http://localhost:9091"), resources) + server.start() + + client = OkHttpClient() + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + + @Test + fun testBasicGet() { + client.newCall(Request.Builder() + .url("http://localhost:9091/projects") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("[]")) + } + } + + + @Test + fun testMissingProject() { + client.newCall(Request.Builder() + .url("http://localhost:9091/projects/1") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(false)) + assertThat(response.code, `is`(404)) + } + } + + + @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\"}") + } + + }) + .url("http://localhost:9091/projects") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("{\"id\":1000,\"name\":\"a\"}")) + } + + client.newCall(Request.Builder() + .url("http://localhost:9091/projects/1000") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("{\"id\":1000,\"name\":\"a\"}")) + } + + + client.newCall(Request.Builder() + .url("http://localhost:9091/projects") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("[{\"id\":1000,\"name\":\"a\"}]")) + } + + client.newCall(Request.Builder() + .post(object : RequestBody() { + override fun contentType() = "application/json".toMediaTypeOrNull() + + override fun writeTo(sink: BufferedSink) { + sink.writeUtf8("{\"name\": \"a\",\"description\":\"d\"}") + } + }) + .url("http://localhost:9091/projects/1000") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("{\"id\":1000,\"name\":\"a\",\"description\":\"d\"}")) + } + client.newCall(Request.Builder() + .delete() + .url("http://localhost:9091/projects/1000") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + } + + client.newCall(Request.Builder() + .url("http://localhost:9091/projects") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("[]")) + } + } +} 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 new file mode 100644 index 0000000..4b2c4b2 --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectDao.kt @@ -0,0 +1,18 @@ +package org.radarbase.jersey.hibernate.db + +import javax.persistence.* + +@Entity(name = "Project") +@Table(name = "project") +class ProjectDao { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator") + @SequenceGenerator(name = "sequenceGenerator", sequenceName = "project_id_seq", initialValue = 1, allocationSize = 1) + var id: Long? = null + + @Column + var name: String = "" + + @Column + var description: String? = null +} 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 new file mode 100644 index 0000000..982f348 --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepository.kt @@ -0,0 +1,9 @@ +package org.radarbase.jersey.hibernate.db + +interface ProjectRepository { + fun list(): List + fun create(name: String, description: String?): ProjectDao + fun update(id: Long, description: String?): ProjectDao? + fun delete(id: Long) + 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 new file mode 100644 index 0000000..14cfecf --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/db/ProjectRepositoryImpl.kt @@ -0,0 +1,49 @@ +package org.radarbase.jersey.hibernate.db + +import org.radarbase.jersey.hibernate.HibernateRepository +import javax.inject.Provider +import javax.persistence.EntityManager +import javax.ws.rs.core.Context + +class ProjectRepositoryImpl( + @Context em: Provider +): ProjectRepository, HibernateRepository(em) { + override fun list(): List = transact { + createQuery("SELECT p FROM Project p", ProjectDao::class.java) + .resultList + } + + override fun create(name: String, description: String?): ProjectDao = transact { + ProjectDao().apply { + this.name = name + this.description = description + persist(this) + } + } + + override fun update(id: Long, description: 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 + merge(this) + } + } + + override 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 { + createQuery("SELECT p FROM Project p WHERE p.id = :id", ProjectDao::class.java) + .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 new file mode 100644 index 0000000..1989e83 --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockProjectService.kt @@ -0,0 +1,25 @@ +/* + * 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.hibernate.mock + +import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.hibernate.db.ProjectRepository +import org.radarbase.jersey.service.ProjectService +import javax.ws.rs.core.Context + +class MockProjectService( + @Context private val projects: ProjectRepository +) : ProjectService { + override fun ensureProject(projectId: String) { + if (projects.list().none { it.name == projectId }) { + throw HttpNotFoundException("project_not_found", "Project $projectId not found.") + } + } +} 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 new file mode 100644 index 0000000..1b74b41 --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancer.kt @@ -0,0 +1,27 @@ +package org.radarbase.jersey.hibernate.mock + +import org.glassfish.jersey.internal.inject.AbstractBinder +import org.radarbase.jersey.config.ConfigLoader +import org.radarbase.jersey.config.JerseyResourceEnhancer +import org.radarbase.jersey.hibernate.db.ProjectRepository +import org.radarbase.jersey.hibernate.db.ProjectRepositoryImpl +import org.radarbase.jersey.service.ProjectService +import javax.inject.Singleton + +class MockResourceEnhancer : JerseyResourceEnhancer { + override val classes: Array> = arrayOf( + ConfigLoader.Filters.logResponse) + + override val packages: Array = arrayOf( + "org.radarbase.jersey.hibernate.mock.resource") + + override fun AbstractBinder.enhance() { + bind(ProjectRepositoryImpl::class.java) + .to(ProjectRepository::class.java) + .`in`(Singleton::class.java) + + bind(MockProjectService::class.java) + .to(ProjectService::class.java) + .`in`(Singleton::class.java) + } +} diff --git a/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancerFactory.kt b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancerFactory.kt new file mode 100644 index 0000000..390bcc9 --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/MockResourceEnhancerFactory.kt @@ -0,0 +1,19 @@ +package org.radarbase.jersey.hibernate.mock + +import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.config.ConfigLoader +import org.radarbase.jersey.config.EnhancerFactory +import org.radarbase.jersey.config.JerseyResourceEnhancer +import org.radarbase.jersey.hibernate.config.DatabaseConfig +import org.radarbase.jersey.hibernate.config.HibernateResourceEnhancer + +class MockResourceEnhancerFactory(private val config: AuthConfig, private val databaseConfig: DatabaseConfig) : EnhancerFactory { + override fun createEnhancers(): List = listOf( + MockResourceEnhancer(), + ConfigLoader.Enhancers.radar(config), + HibernateResourceEnhancer(databaseConfig), + ConfigLoader.Enhancers.disabledAuthorization, + ConfigLoader.Enhancers.health, + ConfigLoader.Enhancers.httpException, + ConfigLoader.Enhancers.generalException) +} 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 new file mode 100644 index 0000000..21a9320 --- /dev/null +++ b/radar-jersey-hibernate/src/test/kotlin/org/radarbase/jersey/hibernate/mock/resource/ProjectResource.kt @@ -0,0 +1,34 @@ +package org.radarbase.jersey.hibernate.mock.resource + +import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.hibernate.db.ProjectDao +import org.radarbase.jersey.hibernate.db.ProjectRepository +import javax.ws.rs.* +import javax.ws.rs.core.Context + +@Path("projects") +@Consumes("application/json") +@Produces("application/json") +class ProjectResource( + @Context private val projects: ProjectRepository +) { + @GET + fun projects(): List = projects.list() + + @GET + @Path("{id}") + fun project(@PathParam("id") id: Long) = 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"]) + ?: throw HttpNotFoundException("project_not_found", "Project with ID $id does not exist") + + @POST + fun createProject(values: Map) = projects.create(values.getValue("name"), values["description"]) + + @DELETE + @Path("{id}") + fun deleteProject(@PathParam("id") id: Long) = projects.delete(id) +} diff --git a/radar-jersey-hibernate/src/test/resources/db/changelog/changes/00000000000000_initial_schema.xml b/radar-jersey-hibernate/src/test/resources/db/changelog/changes/00000000000000_initial_schema.xml new file mode 100644 index 0000000..e625417 --- /dev/null +++ b/radar-jersey-hibernate/src/test/resources/db/changelog/changes/00000000000000_initial_schema.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/radar-jersey-hibernate/src/test/resources/db/changelog/changes/db.changelog-master.xml b/radar-jersey-hibernate/src/test/resources/db/changelog/changes/db.changelog-master.xml new file mode 100644 index 0000000..8fc5036 --- /dev/null +++ b/radar-jersey-hibernate/src/test/resources/db/changelog/changes/db.changelog-master.xml @@ -0,0 +1,8 @@ + + + + diff --git a/settings.gradle b/settings.gradle index 000275e..9478191 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,13 @@ -rootProject.name = "radar-jersey" \ No newline at end of file +pluginManagement { + resolutionStrategy { + eachPlugin { + if ( requested.id.id == 'org.jetbrains.kotlin.jvm' ) { + useModule( "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" ) + } + } + } +} + +rootProject.name = "radar-jersey" + +include(":radar-jersey-hibernate") diff --git a/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt b/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt index 8512a97..5176b5f 100644 --- a/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt +++ b/src/main/kotlin/org/radarbase/jersey/GrizzlyServer.kt @@ -13,6 +13,7 @@ import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory import org.glassfish.jersey.server.ResourceConfig import org.slf4j.LoggerFactory import java.net.URI +import java.util.concurrent.TimeUnit /** * Grizzly server wrapper. @@ -69,7 +70,7 @@ class GrizzlyServer( } catch (ex: IllegalStateException) { // ignore } - server.shutdown() + server.shutdown(15, TimeUnit.SECONDS) } companion object { diff --git a/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt b/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt index 69b9765..f855cbe 100644 --- a/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt +++ b/src/main/kotlin/org/radarbase/jersey/auth/AuthConfig.kt @@ -9,13 +9,44 @@ package org.radarbase.jersey.auth +import com.fasterxml.jackson.annotation.JsonIgnore +import java.time.Duration + data class AuthConfig( - val managementPortalUrl: String? = null, + /** ManagementPortal configuration. */ + val managementPortal: MPConfig = MPConfig(), + /** OAuth 2.0 resource name. */ val jwtResourceName: String, + /** OAuth 2.0 issuer. */ val jwtIssuer: String? = null, + /** ECDSA public keys used for verifying incoming OAuth 2.0 JWT. */ val jwtECPublicKeys: List? = null, + /** RSA public keys used for verifying incoming OAuth 2.0 JWT. */ val jwtRSAPublicKeys: List? = null, + /** p12 keystore file path used for verifying incoming OAuth 2.0 JWT. */ val jwtKeystorePath: String? = null, + /** Key alias in p12 keystore for verifying incoming OAuth 2.0 JWT. */ + val jwtKeystoreAlias: String? = null, + /** Key password for the key alias in the p12 keystore. */ val jwtKeystorePassword: String? = null, - val jwtKeystoreAlias: String? = null ) + +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, +) { + /** 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) +} diff --git a/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuth.kt b/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuth.kt new file mode 100644 index 0000000..725aae6 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuth.kt @@ -0,0 +1,64 @@ +package org.radarbase.jersey.auth.disabled + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.NullNode +import org.radarbase.jersey.auth.Auth +import org.radarcns.auth.authorization.Permission +import org.radarcns.auth.token.RadarToken +import java.util.* + +/** 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(): Map> = emptyMap() + + 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 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: String?): Boolean = true + + override fun hasPermission(permission: Permission?): 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/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthValidator.kt b/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthValidator.kt new file mode 100644 index 0000000..ac9a632 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/auth/disabled/DisabledAuthValidator.kt @@ -0,0 +1,16 @@ +package org.radarbase.jersey.auth.disabled + +import org.radarbase.jersey.auth.Auth +import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.auth.AuthValidator +import javax.ws.rs.container.ContainerRequestContext +import javax.ws.rs.core.Context + +/** 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) +} diff --git a/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt b/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt index 25fa38c..a8d8d12 100644 --- a/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt +++ b/src/main/kotlin/org/radarbase/jersey/auth/filter/PermissionFilter.kt @@ -11,8 +11,8 @@ package org.radarbase.jersey.auth.filter import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.NeedsPermission -import org.radarbase.jersey.auth.ProjectService import org.radarbase.jersey.exception.HttpForbiddenException +import org.radarbase.jersey.service.ProjectService import org.radarcns.auth.authorization.Permission import javax.ws.rs.container.ContainerRequestContext import javax.ws.rs.container.ContainerRequestFilter diff --git a/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt b/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt index b17fbf4..9cf8eae 100644 --- a/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt +++ b/src/main/kotlin/org/radarbase/jersey/auth/jwt/JwtAuth.kt @@ -11,6 +11,7 @@ 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.jersey.auth.Auth import org.radarcns.auth.authorization.Permission import org.radarcns.auth.authorization.Permission.Entity @@ -44,4 +45,5 @@ class JwtAuth(project: String?, private val jwt: DecodedJWT) : Auth { 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/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt b/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt index 4f62896..5513e86 100644 --- a/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt +++ b/src/main/kotlin/org/radarbase/jersey/auth/managementportal/TokenValidatorFactory.kt @@ -23,7 +23,7 @@ class TokenValidatorFactory( TokenValidator() } catch (e: RuntimeException) { val cfg = TokenVerifierPublicKeyConfig().apply { - publicKeyEndpoints = listOf(URI("${config.managementPortalUrl}/oauth/token_key")) + publicKeyEndpoints = listOf(URI("${config.managementPortal.url}/oauth/token_key")) resourceName = config.jwtResourceName } TokenValidator(cfg) diff --git a/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt b/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt index 349cb48..9645ab0 100644 --- a/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt +++ b/src/main/kotlin/org/radarbase/jersey/config/ConfigLoader.kt @@ -107,8 +107,10 @@ object ConfigLoader { } object Enhancers { fun radar(config: AuthConfig) = RadarJerseyResourceEnhancer(config) - val managementPortal = ManagementPortalResourceEnhancer() + fun managementPortal(config: AuthConfig) = ManagementPortalResourceEnhancer(config) + val disabledAuthorization = DisabledAuthorizationResourceEnhancer() val ecdsa = EcdsaResourceEnhancer() + val health = HealthResourceEnhancer() val httpException = HttpExceptionResourceEnhancer() val generalException = GeneralExceptionResourceEnhancer() } diff --git a/src/main/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancer.kt b/src/main/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancer.kt new file mode 100644 index 0000000..f554082 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancer.kt @@ -0,0 +1,27 @@ +/* + * 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.config + +import org.glassfish.jersey.internal.inject.AbstractBinder +import org.radarbase.jersey.auth.AuthValidator +import org.radarbase.jersey.auth.disabled.DisabledAuthValidator +import javax.inject.Singleton + +/** + * Registration for authorization against a ManagementPortal. It requires managementPortalUrl and + * jwtResourceName to be set in the AuthConfig. + */ +class DisabledAuthorizationResourceEnhancer : JerseyResourceEnhancer { + override fun AbstractBinder.enhance() { + bind(DisabledAuthValidator::class.java) + .to(AuthValidator::class.java) + .`in`(Singleton::class.java) + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/config/HealthResourceEnhancer.kt b/src/main/kotlin/org/radarbase/jersey/config/HealthResourceEnhancer.kt new file mode 100644 index 0000000..949493b --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/config/HealthResourceEnhancer.kt @@ -0,0 +1,17 @@ +package org.radarbase.jersey.config + +import org.glassfish.jersey.internal.inject.AbstractBinder +import org.radarbase.jersey.resource.HealthResource +import org.radarbase.jersey.service.HealthService +import org.radarbase.jersey.service.ImmediateHealthService +import javax.inject.Singleton + +class HealthResourceEnhancer: JerseyResourceEnhancer { + override val classes: Array> = arrayOf(HealthResource::class.java) + + override fun AbstractBinder.enhance() { + bind(ImmediateHealthService::class.java) + .to(HealthService::class.java) + .`in`(Singleton::class.java) + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/config/ManagementPortalResourceEnhancer.kt b/src/main/kotlin/org/radarbase/jersey/config/ManagementPortalResourceEnhancer.kt index a840b11..1b139c0 100644 --- a/src/main/kotlin/org/radarbase/jersey/config/ManagementPortalResourceEnhancer.kt +++ b/src/main/kotlin/org/radarbase/jersey/config/ManagementPortalResourceEnhancer.kt @@ -10,9 +10,15 @@ package org.radarbase.jersey.config import org.glassfish.jersey.internal.inject.AbstractBinder +import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.auth.AuthValidator import org.radarbase.jersey.auth.managementportal.ManagementPortalTokenValidator import org.radarbase.jersey.auth.managementportal.TokenValidatorFactory +import org.radarbase.jersey.service.ProjectService +import org.radarbase.jersey.service.managementportal.MPClient +import org.radarbase.jersey.service.managementportal.MPProjectService +import org.radarbase.jersey.service.managementportal.ProjectServiceWrapper +import org.radarbase.jersey.service.managementportal.RadarProjectService import org.radarcns.auth.authentication.TokenValidator import javax.inject.Singleton @@ -20,7 +26,7 @@ import javax.inject.Singleton * Registration for authorization against a ManagementPortal. It requires managementPortalUrl and * jwtResourceName to be set in the AuthConfig. */ -class ManagementPortalResourceEnhancer : JerseyResourceEnhancer { +class ManagementPortalResourceEnhancer(private val config: AuthConfig) : JerseyResourceEnhancer { override fun AbstractBinder.enhance() { bindFactory(TokenValidatorFactory::class.java) .to(TokenValidator::class.java) @@ -29,5 +35,19 @@ class ManagementPortalResourceEnhancer : JerseyResourceEnhancer { bind(ManagementPortalTokenValidator::class.java) .to(AuthValidator::class.java) .`in`(Singleton::class.java) + + if (config.managementPortal.clientId != null) { + bind(MPClient::class.java) + .to(MPClient::class.java) + .`in`(Singleton::class.java) + + bind(ProjectServiceWrapper::class.java) + .to(ProjectService::class.java) + .`in`(Singleton::class.java) + + bind(MPProjectService::class.java) + .to(RadarProjectService::class.java) + .`in`(Singleton::class.java) + } } } diff --git a/src/main/kotlin/org/radarbase/jersey/config/RadarJerseyResourceEnhancer.kt b/src/main/kotlin/org/radarbase/jersey/config/RadarJerseyResourceEnhancer.kt index 379d1ae..848e012 100644 --- a/src/main/kotlin/org/radarbase/jersey/config/RadarJerseyResourceEnhancer.kt +++ b/src/main/kotlin/org/radarbase/jersey/config/RadarJerseyResourceEnhancer.kt @@ -9,13 +9,23 @@ package org.radarbase.jersey.config +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import okhttp3.OkHttpClient import org.glassfish.jersey.internal.inject.AbstractBinder import org.glassfish.jersey.process.internal.RequestScoped +import org.glassfish.jersey.server.ResourceConfig import org.radarbase.jersey.auth.Auth import org.radarbase.jersey.auth.AuthConfig import org.radarbase.jersey.auth.filter.AuthenticationFilter import org.radarbase.jersey.auth.filter.AuthorizationFeature import org.radarbase.jersey.auth.jwt.AuthFactory +import java.util.concurrent.TimeUnit +import javax.ws.rs.ext.ContextResolver /** * Add RADAR auth to a Jersey project. This requires a {@link ProjectService} implementation to be @@ -28,10 +38,24 @@ class RadarJerseyResourceEnhancer( AuthenticationFilter::class.java, AuthorizationFeature::class.java) + override fun ResourceConfig.enhance() { + register(ContextResolver { OBJECT_MAPPER }) + } + override fun AbstractBinder.enhance() { bind(config) .to(AuthConfig::class.java) + bind(OkHttpClient().newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build()) + .to(OkHttpClient::class.java) + + bind(OBJECT_MAPPER) + .to(ObjectMapper::class.java) + // Bind factories. bindFactory(AuthFactory::class.java) .proxy(true) @@ -39,4 +63,13 @@ class RadarJerseyResourceEnhancer( .to(Auth::class.java) .`in`(RequestScoped::class.java) } + + companion object { + private val OBJECT_MAPPER: ObjectMapper = ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .registerModule(JavaTimeModule()) + .registerModule(KotlinModule()) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } } diff --git a/src/main/kotlin/org/radarbase/jersey/resource/HealthResource.kt b/src/main/kotlin/org/radarbase/jersey/resource/HealthResource.kt new file mode 100644 index 0000000..d166753 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/resource/HealthResource.kt @@ -0,0 +1,21 @@ +package org.radarbase.jersey.resource + +import org.radarbase.jersey.service.HealthService +import javax.annotation.Resource +import javax.inject.Singleton +import javax.ws.rs.GET +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.Context +import javax.ws.rs.core.MediaType.APPLICATION_JSON + +@Path("/health") +@Resource +@Singleton +class HealthResource( + @Context private val healthService: HealthService +) { + @GET + @Produces(APPLICATION_JSON) + fun healthStatus(): Map = healthService.metrics +} diff --git a/src/main/kotlin/org/radarbase/jersey/service/HealthService.kt b/src/main/kotlin/org/radarbase/jersey/service/HealthService.kt new file mode 100644 index 0000000..77001c6 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/HealthService.kt @@ -0,0 +1,27 @@ +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 + + override fun equals(other: Any?): Boolean { + if (other === this) return true + if (other?.javaClass != javaClass) return false + other as Metric + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + } + + enum class Status { + UP, DOWN + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/service/ImmediateHealthService.kt b/src/main/kotlin/org/radarbase/jersey/service/ImmediateHealthService.kt new file mode 100644 index 0000000..3c7827e --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/ImmediateHealthService.kt @@ -0,0 +1,39 @@ +package org.radarbase.jersey.service + +import org.glassfish.hk2.api.IterableProvider +import javax.ws.rs.core.Context + +class ImmediateHealthService( + @Context healthMetrics: IterableProvider +): HealthService { + @Volatile + private var allMetrics: List = healthMetrics.toList() + + override val status: HealthService.Status + get() = if (allMetrics.any { it.status == HealthService.Status.DOWN }) { + 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 add(metric: HealthService.Metric) { + allMetrics = allMetrics + metric + } + + override fun remove(metric: HealthService.Metric) { + allMetrics = allMetrics - metric + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/auth/ProjectService.kt b/src/main/kotlin/org/radarbase/jersey/service/ProjectService.kt similarity index 93% rename from src/main/kotlin/org/radarbase/jersey/auth/ProjectService.kt rename to src/main/kotlin/org/radarbase/jersey/service/ProjectService.kt index 15df479..bddc2c9 100644 --- a/src/main/kotlin/org/radarbase/jersey/auth/ProjectService.kt +++ b/src/main/kotlin/org/radarbase/jersey/service/ProjectService.kt @@ -7,7 +7,7 @@ * See the file LICENSE in the root of this repository. */ -package org.radarbase.jersey.auth +package org.radarbase.jersey.service import org.radarbase.jersey.exception.HttpApplicationException diff --git a/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPClient.kt b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPClient.kt new file mode 100644 index 0000000..dd6087c --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPClient.kt @@ -0,0 +1,100 @@ +package org.radarbase.jersey.service.managementportal + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import okhttp3.* +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.radarbase.jersey.auth.Auth +import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.util.requestJson +import org.slf4j.LoggerFactory +import java.net.MalformedURLException +import java.time.Duration +import java.time.Instant +import javax.ws.rs.core.Context + +class MPClient( + @Context config: AuthConfig, + @Context private val objectMapper: ObjectMapper, + @Context private val auth: Auth, + @Context private val httpClient: OkHttpClient, +) { + private val clientId: String = config.managementPortal.clientId + ?: throw IllegalArgumentException("Cannot configure managementportal client without client ID") + private val clientSecret: String = config.managementPortal.clientSecret + ?: throw IllegalArgumentException("Cannot configure managementportal client without client secret") + private val baseUrl: HttpUrl = config.managementPortal.url?.toHttpUrlOrNull() + ?: throw MalformedURLException("Cannot parse base URL ${config.managementPortal.url} as an URL") + + private val projectListReader = objectMapper.readerFor(object : TypeReference>() {}) + private val userListReader = objectMapper.readerFor(object : TypeReference>() {}) + private val tokenReader = objectMapper.readerFor(RestOauth2AccessToken::class.java) + + @Volatile + private var token: RestOauth2AccessToken? = null + + private val validToken: RestOauth2AccessToken? + get() = token?.takeIf { it.isValid() } + + private fun ensureToken(): String = (validToken + ?: requestToken().also { token = it }) + .accessToken + + private fun requestToken(): RestOauth2AccessToken { + val request = Request.Builder().apply { + url(baseUrl.resolve("oauth/token")!!) + post(FormBody.Builder().apply { + add("grant_type", "client_credentials") + add("client_id", clientId) + add("client_secret", clientSecret) + }.build()) + header("Authorization", Credentials.basic(clientId, clientSecret)) + }.build() + + return httpClient.requestJson(request, tokenReader) + } + + /** Read list of projects from ManagementPortal. */ + fun readProjects(): List { + logger.debug("Requesting for projects") + val request = Request.Builder().apply { + url(baseUrl.resolve("api/projects")!!) + header("Authorization", "Bearer ${ensureToken()}") + }.build() + + return httpClient.requestJson(request, projectListReader) + } + + /** Read list of participants from ManagementPortal project. The [projectId] is the name that + * the project is identified by. */ + fun readParticipants(projectId: String): List { + val request = Request.Builder().apply { + url(baseUrl.newBuilder() + .addPathSegments("api/projects/$projectId/subjects") + .addQueryParameter("page", "0") + .addQueryParameter("size", Int.MAX_VALUE.toString()) + .build()) + header("Authorization", "Bearer ${ensureToken()}") + }.build() + + return httpClient.requestJson>(request, userListReader) + .map { it.copy(projectId = projectId) } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + data class RestOauth2AccessToken( + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String? = null, + @JsonProperty("expires_in") val expiresIn: Long = 0, + @JsonProperty("token_type") val tokenType: String? = null, + @JsonProperty("user_id") val externalUserId: String? = null) { + private val expiration: Instant = Instant.now() + Duration.ofSeconds(expiresIn) - Duration.ofMinutes(5) + fun isValid() = Instant.now() < expiration + } + + companion object { + private val logger = LoggerFactory.getLogger(MPClient::class.java) + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProject.kt b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProject.kt new file mode 100644 index 0000000..c2c7109 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProject.kt @@ -0,0 +1,18 @@ +package org.radarbase.jersey.service.managementportal + +import com.fasterxml.jackson.annotation.JsonProperty + +/** ManagementPortal Project DTO. */ +data class MPProject( + /** Project id, a name that identifies it uniquely. */ + @JsonProperty("projectName") val id: String, + /** Project name, to be shown to users. */ + @JsonProperty("humanReadableProjectName") val name: String? = null, + /** Where a project is organized. */ + val location: String? = null, + /** Organization that organizes the project. */ + val organization: String? = null, + /** Project description. */ + val description: String? = null, + /** Any other attributes. */ + val attributes: Map = emptyMap()) diff --git a/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt new file mode 100644 index 0000000..2e015b1 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPProjectService.kt @@ -0,0 +1,61 @@ +/* + * 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.service.managementportal + +import org.radarbase.jersey.auth.Auth +import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.util.CachedSet +import org.radarcns.auth.authorization.Permission +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import javax.ws.rs.core.Context + +class MPProjectService( + @Context private val config: AuthConfig, + @Context private val mpClient: MPClient, +) : RadarProjectService { + private val projects = CachedSet(config.managementPortal.syncProjectsInterval, RETRY_INTERVAL) { + mpClient.readProjects() + } + + private val participants: ConcurrentMap> = ConcurrentHashMap() + + override fun userProjects(auth: Auth, permission: Permission): List { + return projects.get() + .filter { auth.token.hasPermissionOnProject(permission, it.id) } + } + + override fun project(projectId: String): MPProject = projects.find { it.id == projectId } + ?: throw HttpNotFoundException("project_not_found", "Project $projectId not found in Management Portal.") + + override fun projectUsers(projectId: String): List { + ensureProject(projectId) + val projectParticipants = participants.computeIfAbsent(projectId) { + CachedSet(config.managementPortal.syncParticipantsInterval, RETRY_INTERVAL) { + mpClient.readParticipants(projectId) + } + } + + return projectParticipants.get().toList() + } + + companion object { + private val RETRY_INTERVAL = Duration.ofMinutes(1) + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPUser.kt b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPUser.kt new file mode 100644 index 0000000..fbe870d --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/managementportal/MPUser.kt @@ -0,0 +1,16 @@ +package org.radarbase.jersey.service.managementportal + +import com.fasterxml.jackson.annotation.JsonProperty + +/** ManagementPortal Subject DTO. */ +data class MPUser( + /** User id, a name that identifies it uniquely. */ + @JsonProperty("login") val id: String, + /** Project id that the user belongs to. */ + val projectId: String? = null, + /** ID in an external system for the user. */ + val externalId: String? = null, + /** User status in the project. */ + val status: String = "DEACTIVATED", + /** Additional attributes of the user. */ + val attributes: Map = emptyMap()) diff --git a/src/main/kotlin/org/radarbase/jersey/service/managementportal/ProjectServiceWrapper.kt b/src/main/kotlin/org/radarbase/jersey/service/managementportal/ProjectServiceWrapper.kt new file mode 100644 index 0000000..8b7b85c --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/managementportal/ProjectServiceWrapper.kt @@ -0,0 +1,32 @@ +/* + * 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.service.managementportal + +import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.service.ProjectService +import javax.inject.Provider +import javax.ws.rs.core.Context + +class ProjectServiceWrapper( + @Context private val radarProjectService: Provider +) : ProjectService { + /** + * 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) +} diff --git a/src/main/kotlin/org/radarbase/jersey/service/managementportal/RadarProjectService.kt b/src/main/kotlin/org/radarbase/jersey/service/managementportal/RadarProjectService.kt new file mode 100644 index 0000000..c4548fc --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/service/managementportal/RadarProjectService.kt @@ -0,0 +1,54 @@ +/* + * 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.service.managementportal + +import org.radarbase.jersey.auth.Auth +import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.service.ProjectService +import org.radarcns.auth.authorization.Permission +import org.radarcns.auth.authorization.Permission.PROJECT_READ + + +interface RadarProjectService : ProjectService { + override fun ensureProject(projectId: String) { + project(projectId) + } + + /** + * Ensures that [projectId] exists in ManagementPortal. + * @throws HttpNotFoundException if the project does not exist. + */ + fun project(projectId: String): MPProject + + /** + * Returns all ManagementPortal projects that the current user has access to. + */ + fun userProjects(auth: Auth, permission: Permission = PROJECT_READ): List + + /** + * Get project with [projectId] in ManagementPortal. + * @throws HttpNotFoundException if the project does not exist. + */ + fun projectUsers(projectId: String): List + + /** + * Get subject with [externalUserId] from [projectId] in ManagementPortal. + * @throws HttpNotFoundException if the project does not exist. + */ + fun userByExternalId(projectId: String, externalUserId: String): MPUser? = + projectUsers(projectId).find { it.externalId == externalUserId } +} diff --git a/src/main/kotlin/org/radarbase/jersey/util/CachedSet.kt b/src/main/kotlin/org/radarbase/jersey/util/CachedSet.kt new file mode 100644 index 0000000..e90193a --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/util/CachedSet.kt @@ -0,0 +1,88 @@ +/* + * 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 +import java.time.Instant + +/** Set of data that is cached for a duration of time. */ +class CachedSet( + /** Duration after which the cache is considered stale and should be refreshed. */ + private val refreshDuration: Duration, + /** 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. */ + private val retryDuration: Duration, + /** How to update the cache. */ + private val supplier: () -> Iterable +) { + @set:Synchronized + private var cached: Set = emptySet() + set(value) { + val now = Instant.now() + field = value + nextRefresh = now.plus(refreshDuration) + nextRetry = now.plus(retryDuration) + } + + private var nextRefresh: Instant = Instant.MIN + private var nextRetry: Instant = Instant.MIN + + @get:Synchronized + private val state: State + get() { + val now = Instant.now() + return State(cached, + now.isAfter(nextRefresh), + now.isAfter(nextRetry)) + } + + /** Force refresh of the cache. */ + private fun refresh() = supplier.invoke().toSet() + .also { cached = it } + + /** Whether the cache contains [value]. If it does not contain the value and [retryDuration] + * has passed since the last try, it will update the cache and try once more. */ + fun contains(value: T) = state.query({ it.contains(value) }, { it }) + /** + * Find a value matching [predicate]. + * If it does not contain the value and [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 [retryDuration] + * has passed since the last try, it will update the cache and try once more. + */ + fun get(): Set = state.query({ it }, { it.isNotEmpty() }) + + private inner class State(val cache: Set, val mustRefresh: Boolean, val mayRetry: Boolean) { + fun query(method: (Set) -> S, valueIsValid: (S) -> Boolean): S { + var result: S + if (mustRefresh) { + result = method(refresh()) + } else { + result = method(cache) + if (!valueIsValid(result) && mayRetry) { + result = method(refresh()) + } + } + return result + } + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt b/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt new file mode 100644 index 0000000..3c32b23 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/util/CachedValue.kt @@ -0,0 +1,98 @@ +/* + * 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 +import java.time.Instant +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantReadWriteLock + +/** Set of data that is cached for a duration of time. */ +class CachedValue( + /** Duration after which the cache is considered stale and should be refreshed. */ + private val refreshDuration: Duration, + /** 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. */ + private val retryDuration: Duration, + /** How to update the cache. */ + private val supplier: () -> T +) { + private val refreshLock = ReentrantReadWriteLock() + private val readLock = refreshLock.readLock() + private val writeLock = refreshLock.writeLock() + + var cache: T = supplier() + private set + + private var nextRefresh: Instant + private var nextRetry: Instant + + private val state: State + get() = readLock.locked { + val now = Instant.now() + return State(cache, + now.isAfter(nextRefresh), + now.isAfter(nextRetry)) + } + + init { + val now = Instant.now() + nextRefresh = now.plus(refreshDuration) + nextRetry = now.plus(retryDuration) + } + + /** + * Get the value. + * If the cache is empty and [retryDuration] + * has passed since the last try, it will update the cache and try once more. + */ + fun get(validityPredicate: (T) -> Boolean = { true }): T = state.query(validityPredicate) + + private inner class State(val cache: T, val mustRefresh: Boolean, val mayRetry: Boolean) { + fun query(valueIsValid: (T) -> Boolean): T { + return if (shouldRefresh(valueIsValid) + && writeLock.tryLock()) { + try { + supplier() + .also { + this@CachedValue.cache = it + val now = Instant.now() + nextRefresh = now.plus(refreshDuration) + nextRetry = now.plus(retryDuration) + } + } finally { + writeLock.unlock() + } + } else { + cache + } + } + + private inline fun shouldRefresh(valueIsValid: (T) -> Boolean): Boolean = mustRefresh || (!valueIsValid(cache) && mayRetry) + } + + companion object { + inline fun Lock.locked(method: () -> T): T { + lock() + return try { + method() + } finally { + unlock() + } + } + } +} diff --git a/src/main/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt b/src/main/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt new file mode 100644 index 0000000..7c9cbc6 --- /dev/null +++ b/src/main/kotlin/org/radarbase/jersey/util/OkHttpExtensions.kt @@ -0,0 +1,40 @@ +package org.radarbase.jersey.util + +import com.fasterxml.jackson.databind.ObjectReader +import okhttp3.OkHttpClient +import okhttp3.Request +import org.radarbase.jersey.exception.HttpBadGatewayException +import org.slf4j.LoggerFactory + +/** + * 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/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt b/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt index b36c52b..e4a03ed 100644 --- a/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt +++ b/src/test/kotlin/org/radarbase/jersey/auth/RadarJerseyResourceEnhancerTest.kt @@ -31,7 +31,7 @@ internal class RadarJerseyResourceEnhancerTest { @BeforeEach fun setUp() { val authConfig = AuthConfig( - managementPortalUrl = "http://localhost:8080", + managementPortal = MPConfig(url = "http://localhost:8080"), jwtResourceName = "res_ManagementPortal") val resources = ConfigLoader.loadResources(MockResourceEnhancerFactory::class.java, authConfig) diff --git a/src/test/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancerTest.kt b/src/test/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancerTest.kt new file mode 100644 index 0000000..c21e3cd --- /dev/null +++ b/src/test/kotlin/org/radarbase/jersey/config/DisabledAuthorizationResourceEnhancerTest.kt @@ -0,0 +1,89 @@ +package org.radarbase.jersey.config + +import okhttp3.OkHttpClient +import okhttp3.Request +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.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.auth.OAuthHelper.Companion.bearerHeader +import org.radarbase.jersey.auth.RadarJerseyResourceEnhancerTest +import org.radarbase.jersey.mock.MockDisabledAuthResourceEnhancerFactory +import java.net.URI + +internal class DisabledAuthorizationResourceEnhancerTest { + private lateinit var client: OkHttpClient + private lateinit var server: HttpServer + + @BeforeEach + fun setUp() { + val authConfig = AuthConfig( + jwtResourceName = "res_jerseyTest") + + val resources = ConfigLoader.loadResources(MockDisabledAuthResourceEnhancerFactory::class.java, authConfig) + + server = GrizzlyHttpServerFactory.createHttpServer(URI.create("http://localhost:9091"), resources) + server.start() + + client = OkHttpClient() + } + + @AfterEach + fun tearDown() { + server.shutdown() + } + + + @Test + fun testBasicGet() { + client.newCall(Request.Builder() + .url("http://localhost:9091/") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("{\"this\":\"that\"}")) + } + } + + + @Test + fun testAuthenticatedGet() { + client.newCall(Request.Builder() + .url("http://localhost:9091/user") + .header("Authorization", "Bearer none") + .build()).execute().use { response -> + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("{\"accessToken\":\"\"}")) + } + } + + + @Test + fun testExistingGet() { + client.newCall(Request.Builder() + .url("http://localhost:9091/projects/a/users/b") + .build()).execute().use { response -> + + assertThat(response.isSuccessful, `is`(true)) + assertThat(response.body?.string(), equalTo("{\"projectId\":\"a\",\"userId\":\"b\"}")) + } + } + + + @Test + fun testNonExistingGet() { + client.newCall(Request.Builder() + .url("http://localhost:9091/projects/c/users/b") + .header("Accept", "application/json") + .build()).execute().use { response -> + + assertThat(response.isSuccessful, `is`(false)) + assertThat(response.code, `is`(404)) + assertThat(response.body?.string(), equalTo("{\"error\":\"project_not_found\",\"error_description\":\"Project c not found.\"}")) + } + } +} diff --git a/src/test/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilterTest.kt b/src/test/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilterTest.kt index ab3f743..010d31b 100644 --- a/src/test/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilterTest.kt +++ b/src/test/kotlin/org/radarbase/jersey/filter/ResponseLoggerFilterTest.kt @@ -21,4 +21,4 @@ internal class ResponseLoggerFilterTest { assertThat(ResponseLoggerFilter.dateTimeFormatter.format(fromInstant), equalTo("2019-01-02 12:13:14")) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/org/radarbase/jersey/mock/MockDisabledAuthResourceEnhancerFactory.kt b/src/test/kotlin/org/radarbase/jersey/mock/MockDisabledAuthResourceEnhancerFactory.kt new file mode 100644 index 0000000..e8c192f --- /dev/null +++ b/src/test/kotlin/org/radarbase/jersey/mock/MockDisabledAuthResourceEnhancerFactory.kt @@ -0,0 +1,15 @@ +package org.radarbase.jersey.mock + +import org.radarbase.jersey.auth.AuthConfig +import org.radarbase.jersey.config.ConfigLoader +import org.radarbase.jersey.config.EnhancerFactory +import org.radarbase.jersey.config.JerseyResourceEnhancer + +class MockDisabledAuthResourceEnhancerFactory(private val config: AuthConfig) : EnhancerFactory { + override fun createEnhancers(): List = listOf( + MockResourceEnhancer(), + ConfigLoader.Enhancers.radar(config), + ConfigLoader.Enhancers.disabledAuthorization, + ConfigLoader.Enhancers.httpException, + ConfigLoader.Enhancers.generalException) +} diff --git a/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt b/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt index 588f3c0..9c10487 100644 --- a/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt +++ b/src/test/kotlin/org/radarbase/jersey/mock/MockProjectService.kt @@ -9,8 +9,8 @@ package org.radarbase.jersey.mock -import org.radarbase.jersey.auth.ProjectService import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.service.ProjectService class MockProjectService(private val projects: List) : ProjectService { override fun ensureProject(projectId: String) { diff --git a/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt b/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt index 375f826..abac6f1 100644 --- a/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt +++ b/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancer.kt @@ -1,10 +1,10 @@ package org.radarbase.jersey.mock import org.glassfish.jersey.internal.inject.AbstractBinder -import org.radarbase.jersey.auth.ProjectService import org.radarbase.jersey.auth.RadarJerseyResourceEnhancerTest import org.radarbase.jersey.config.ConfigLoader import org.radarbase.jersey.config.JerseyResourceEnhancer +import org.radarbase.jersey.service.ProjectService import org.radarcns.auth.authentication.TokenValidator import javax.inject.Singleton @@ -23,4 +23,4 @@ class MockResourceEnhancer : JerseyResourceEnhancer { bindFactory { RadarJerseyResourceEnhancerTest.oauthHelper.tokenValidator } .to(TokenValidator::class.java) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt b/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt index ff6f201..bb15dbe 100644 --- a/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt +++ b/src/test/kotlin/org/radarbase/jersey/mock/MockResourceEnhancerFactory.kt @@ -9,7 +9,7 @@ class MockResourceEnhancerFactory(private val config: AuthConfig) : EnhancerFact override fun createEnhancers(): List = listOf( MockResourceEnhancer(), ConfigLoader.Enhancers.radar(config), - ConfigLoader.Enhancers.managementPortal, + ConfigLoader.Enhancers.managementPortal(config), ConfigLoader.Enhancers.httpException, ConfigLoader.Enhancers.generalException) }