Skip to content

Jersey integration for ManagementPortal authentication

Notifications You must be signed in to change notification settings

RADAR-base/radar-jersey

Repository files navigation

radar-jersey

Library to facilitate using with a Jersey-based REST API. This includes OAuth 2.0 integration, exception handling and resource configuration.

Usage

Add this library to your project using the following Gradle configuration:

repositories {
    mavenCentral()
}

dependencies {
    api("org.radarbase:radar-jersey:0.11.0")
}

Any path or resource that should be authenticated against the ManagementPortal, should be annotated with @Authenticated. Specific authorization can be checked by adding a @NeedsPermission annotation. An Auth object can be injected to get app-specific information. For reliable injection, constructor or method injection, not class parameter injection. Examples:

@Path("/projects")
@Authenticated
class Users(
    @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(
        @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,
        @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,
        @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)
    }
}

These APIs are activated by adding an EnhancerFactory implementation to your resource definition:

class MyEnhancerFactory(private val config: MyConfigClass): EnhancerFactory {
    override fun createEnhancers(): List<JerseyResourceEnhancer> {
        val authConfig = AuthConfig(
            managementPortal = MPConfig(
                url = "https://...",
            ),
            jwtResourceName = "res_MyResource",
        )
        return listOf(
            // My own resource configuration
            MyResourceEnhancer(),
            // RADAR OAuth2 enhancement
            Enhancers.radar(authConfig),
            // Use ManagementPortal OAuth implementation
            Enhancers.managementPortal(authConfig),
            // Error handling
            Enhancers.exception,
        )
    }

    class MyResourceEnhancer: JerseyResourceEnhancer {
        // only classes used directly by Jersey, cannot inject them in user code
        override val classes: Array<Class<*>> = arrayOf(
            Filters.logResponse,
            Filters.cors,
            Filters.cache,
        )

        // only classes used directly by Jersey, cannot inject them in user code
        override val packages = arrayOf(
            "com.example.app.resources",
        )

        override fun AbstractBinder.enhance() {
            bind(config)
                .to(MyConfigClass::class.java)
            bind(MyService::class.java)
                .to(MyServiceInterface::class.java)
                .`in`(Singleton::class.java)
            bindFactory(OtherServiceFactory::class.java)
                .to(OtherServiceInterface::class.java)
                .`in`(Singleton::class.java)
        }
    }
}

Ensure that a class implementing org.radarbase.jersey.auth.ProjectService is added to the binder. This is done automatically if you configure a MPConfig.clientId and MPConfig.clientSecret. Then the projects will be fetched from ManagementPortal.

The following variables will be fetched from environment variables if set:
MANAGEMENT_PORTAL_CLIENT_ID sets AuthConfig.managementPortal.clientId
MANAGEMENT_PORTAL_CLIENT_SECRET sets AuthConfig.managementPortal.clientSecret
AUTH_KEYSTORE_PASSWORD sets AuthConfig.jwtKeystorePassword
DATABASE_URL sets DatabaseConfig.url
DATABASE_USER sets DatabaseConfig.user
DATABASE_PASSWORD sets DatabaseConfig.password

This factory can then be specified in your main method, by adding it to your MyConfigClass definition:

fun main(args: Array<String>) {
    val config: MyConfigClass = ConfigLoader.loadConfig("my-config-name.yml", args)
    val resources = ConfigLoader.loadResources(config.resourceConfig, config)
    val server = GrizzlyServer(config.baseUri, resources, config.isJmxEnabled)
    // Listen until JVM shutdown
    server.listen()
}

Error handling

Errors are handled by adding the ConfigLoader.Enhancers.httpException enhancer. This adds error handling for org.radarbase.jersey.exception.HttpApplicationException exceptions and its subclasses can be used and extended to serve detailed error messages with customized logging and HTML templating. They can be thrown from any resource.

To serve custom HTML error messages for error codes 400 to 599, add a Mustache template to the classpath in directory org/radarbase/jersey/exception/mapper/<code>.html. You can use special cases 4xx.html and 5xx.html as a catch-all template. The templates can use variables status for the HTTP status code, code for shorthand code for the specific error, and an optional detailedMessage for a human-readable message.

Any other uncaught exceptions can be handled by adding the ConfigLoader.Enhancers.generalException.

Logging

To enable logging with radar-jersey, please set the following configurations. For new projects, the default should be Log4j 2. A configuration file is included in the classpath. First include the following dependencies:

dependencies {
    // To enable logging either use log4j
    val log4j2Version: String by project
    runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl:$log4j2Version")
    runtimeOnly("org.apache.logging.log4j:log4j-core:$log4j2Version")
    runtimeOnly("org.apache.logging.log4j:log4j-jul:$log4j2Version")
}

Then before any other command is made, set:

// Initialize logging with log4j2
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager")

Execute this statement before ANY logging or logging initialization code has been called, for example in the init of a companion object of the main class. Alternatively, set it as a Java system property in the command line, i.e. -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager.

If Logback is used instead, import the following dependencies to gradle:

dependencies {
    val logbackVersion: String by project
    runtimeOnly("ch.qos.logback:logback-classic:$logbackVersion")
    val slf4jVersion: String by project
    implementation("org.slf4j:jul-to-slf4j:$slf4jVersion")
}

Then before any logging code has been called, set:

SLF4JBridgeHandler.removeHandlersForRootLogger()
SLF4JBridgeHandler.install()

Health

A /health endpoint can be added with ConfigLoader.Enhancers.health. It has the response structure {"status":"UP","myhealth:{"status":"UP","numberOfSomething":5}}. It reports main status DOWN if any metric status is DOWN, and UP otherwise. A health metric can be added by binding a HealthService.Metric named to your metric name, e.g.:

bind(MyMetric::class.java)
    .named("mymetric")
    .to(HealthService.Metric::class.java)

The implementation may optionally return health status UP or DOWN and may in addition expose custom metrics that should be serializable by Jackson. The status is not automatically shown in the response. It is only shown if it is added as part of the metrics property implementation of HealthService.Metrics.

Caching

Client side caching is enabled by the Filters.cache filter. When this is enabled, resource methods and classes can be annotated with a org.radarbase.jersey.cache.Cache or NoCache annotation. The fields of this annotation correspond to the Cache-Control headers.

OpenAPI / Swagger

To automatically create a OpenAPI / Swagger endpoint for your API, add the Enhancers.openapi resource enhancer. Provide it with a general description of your API as specified by an OpenAPI object.