The JAX-RS Base Server module provides a bunch of utility code for quickly building new JAX-RS servers. It provides a lot of building blocks around the following:
- Error Handling and Reporting via RFC 7807 Problem Responses
- JWT Authentication
- Cross-Origin Resource Sharing (CORS)
- Server configuration initialization
- Application stubs, servers and entrypoints
To create a new JAX-RS server add a Dependency as detailed later and then create a new class extending
AbstractApplication
. This class should override getClasses()
, ensuring that it calls the super-class method and
appends any application specific classes to it e.g.
@ApplicationPath("/")
public class SearchApplication extends AbstractApplication {
@Override
public Set<Class<?>> getClasses() {
// Get the super-class set of classes
// i.e. all the common functionality the base application class provides
Set<Class<?>> classes = super.getClasses();
// Add any application specific functionality
// Parameter Converters
classes.add(SearchParamConvertersProvider.class);
// Resources i.e. actual API paths
classes.add(DocumentsResource.class);
return classes;
}
@Override
protected Class<? extends AbstractHealthResource> getHealthResourceClass() {
// Health Resource, or null if you prefer not to derive from our AbstractHealthResource
return HealthResource.class;
}
@Override
protected boolean isAuthEnabled() {
return true;
}
}
If you are implementing a /healthz
following the libraries provided pattern you can return that
class in your overridden getHealthResourceClass()
method. If you prefer to not provide such an endpoint, or implement
it by other means then you can return null
here.
As seen in the above example another key consideration is whether you want authentication enabled which you can do by
overriding the isAuthEnabled()
method from the base class. You may wish to calculate this value based on
environment/system property variables if you want authentication to be configurable as on/off in your application.
To create a runnable entrypoint for our application we can extend AbstractAppEntrypoint
to construct our Server
instance and implement a main()
method on it e.g.
public class SearchApiEntrypoint extends AbstractAppEntrypoint {
public static void main(String[] args) {
SearchApiEntrypoint entrypoint = new SearchApiEntrypoint();
// true causes the JVM to start the server and block until the process is interrupted
entrypoint.run(true);
}
@Override
protected ServerBuilder buildServer() {
// In this method we build our server definition
return ServerBuilder.create()
.localhost()
.port(8181)
.application(SearchApplication.class)
.withAutoConfigInitialisation()
.displayName("Search REST API");
}
}
Here we see the ServerBuilder
API used to build the definition of our actual server.
The ServerBuilder
API is used to build a definition for a JAX-RS server you want to run. When you call build()
on it
you get a Server
instance that can be used to start/stop the server as desired. At a bare minimum, you MUST
configure the Application class, Display name and Port number for the server e.g.
Server server
= ServerBuilder.create()
.application(YourApplication.class)
.displayName("Example Application")
.port(10001)
.build();
Would create a server that runs YourApplication
on localhost:10001
with a display name of Example Application
, the
display name is primarily used for logging though also necessary for the application context used to deploy the JAX-RS
application to the server.
By default, your application will be hosted as the root context of the server, if you want to host it under a different
context then you can do so via the contextPath()
method. For example .contextPath("/api")
would host your
application under /api
on the server. Note that for inspection purposes the getBaseUri()
method on the built
Server
instance will give you the Base URI against which you can formulate your requests including the context path
(if any), this is particularly useful for unit and integration testing.
The hostname for the server is configured via the hostname("some-host")
or localhost()
methods, some-host
can be a
hostname or an IP address. So you could use .hostname("0.0.0.0")
to bind the server to all network interfaces on your
host.
The withAutoConfigInitialisation()
method registers our
ServiceLoadedServletContextInitialiser
context listener, this automatically detects, loads and runs initialisers that conform to our
ServiceConfigInit
interface. This module already registers listeners for JWT
Authentication and User Attribute ABAC automatically. Alternatively if
you have existing listeners that do not conform to our interface, but implement the base ServletContextListener
interface then you can use these as well via withListener(YourListener.class)
.
The withVersionInfo()
method ensures that a libraries version
information is exposed via the applications /version-info
endpoint. If this is not called then only libraries who are creating Open Telemetry meters via
TelicentMetrics.getMeter()
will be included, and only
if they have done so at the point where a user retrieves /version-info
. Calling this method explicitly on your
ServerBuilder
ensures that the libraries of relevance to your server application report their version information.
Once you have built a Server
instance via ServerBuilder
you can then start and stop the server via
its start()
and stop()
methods, generally you should only start and stop a server once. If you want to terminate a
server immediately you can do so via the shutdownNow()
method.
A Server
is an AutoCloseable
so for unit or integration tests you can use it with a try-with-resources
block e.g.
try (Server server = ServerBuilder.create()
.port(1234)
.applicationClass(MyApp.class)
.displayName("Test")
.build()) {
server.start();
// Test some server behaviour...
server.shutdownNow();
}
Then if anything goes wrong while the test is running you are guaranteed to clean it up.
The
ServerConfigInit
interface is a small extension to the ServletContextListener
interface that adds a name and priority to the interface.
The intent of this is to allow listeners to be automatically discovered (via ServiceLoader
) and applied in a
well-defined order without having to manually define the listeners. By using a ServiceLoader
driven mechanism we need
only define a single explicit listener and provided suitable META-INF/services
files are present on the Classpath all
our listeners will be discovered and loaded.
When you use a ServerBuilder
you can ensure this listener is registered via the
withAutoConfigInitialisation()
method.
The getName()
value is used for logging purposes so should be something meaningful to a developer or system
administrator reviewing the logs.
The priority()
value of these instances allows the initialisation order to be automatically controlled. A higher
value is considered higher priority and will be initialised first. When the server is stopped the initialisers are
destroyed in reverse priority order i.e. lowest priority will be destroyed first.
The base server module includes a servlet context listener that configures JWT Authentication using the support from our
jwt-servlet-auth module. This listener looks for an environment variable named JWKS_URL
and uses its value to
configure a suitable JwtVerifier
for your application. This will also configure an authentication engine that
sends challenge responses using RFC 7807 Problem Responses for alignment with the error handling
provided by this module.
The JWKS_URL
environment variable is used to configure the location of the public keys at server startup. This may
either be a remote URL or a local file, for a local file the URL must be of the form file:///path/to/jwks.json
.
Alternatively it may be one of several special values:
disabled
- Disables authentication entirely. This just means that no verifier will be registered, if your application does not also disable authentication in its application class definition the relevant request filter will still be installed and result in requests being rejected as unauthenticated.aws:<region>
- Enables AWS authentication where<region>
is the AWS region in which the server is being deployed. This will configure authentication to use the custom AWS headers and resolve public keys from AWS ELB.
Since 0.16.0
the following additional environment variables may be used for further configuration:
Environment Variable | Example Value | Notes |
---|---|---|
JWT_HEADERS_NAMES |
Authorization,X-Custom |
Specifies a comma separated list of HTTP Header names that will be searched to find a suitable JWT |
JWT_HEADERS_PREFIXES |
Bearer, |
Specifies a comma separated list of prefixes that are present in the HTTP Headers and will be stripped as part of extracting the token |
JWT_USERNAME_CLAIMS |
email,username |
Specifies a comma separated list of claim names that will be used in extracting the username of the authenticated user from the verified JWT |
In the example values shown in this table we allow the token to be presented in either the Authorization
or the
X-Custom
header. For the Authorization
header it is expected to have a prefix of Bearer
present, e.g. Bearer <jwt>
, while the X-Custom
header is expected to have no prefix and just contain the token, e.g. <jwt>
.
Assuming that we successfully verify a JWT then we will search first the email
, and then the username
claim to find
the username for the user. Note that regardless of the claims configured here if none are present the authentication
library falls back to using the JWT standard sub
(subject) claim to detect a user identity.
The User Attributes Service is part of Telicent CORE and provides the ability to look-up the attributes for an authenticated user in order to then enforce Attribute Based Access Control (ABAC) upon a users requests. Telicent Access is the default implementation of this service for CORE.
To use a User Attributes service you must set the USER_ATTRIBUTES_URL
environment variable to the URL of the
attributes service. For example if you had deployed Access entirely locally it would be
https://localhost:8091/users/lookup/{user}
. Note that your URL must include {user}
as the placeholder where the
username should be substituted in order to derive a URL that looks up the users attributes.
Alternatively it may be the special value disabled
which disables user attributes lookup, internally this configures
the user attributes store to be an empty store so all users have no attributes.
NB: disabled
MUST only be used for local development and testing.
The module provides a number of ExceptionMapper
instances that are automatically registered when you derive from
AbstractApplication
per Creating an Application. These handle the following errors,
turning them into RFC 7807 Problem responses:
- Constraint Violations i.e. when a request parameter does not pass Bean validation rules declared on its method parameters
- Method Not Allowed i.e. when a user attempts to use an HTTP Verb on a request path that does not accept that verb.
For example if they attempted a
DELETE
on a request that only permitsGET
. - Not Found i.e. 404 errors when a user provides a bad URI, or the URI does not identify a resource that exists.
- Parameter Conversion Exceptions i.e. when a user provides a value to a request parameter that cannot be converted into the relevant type.
Additionally, it also installs a generic fallback mapper that will turn any other errors into problem responses. This ensures consistent error handling behaviour of our application servers.
Problem responses are based upon RFC 7807 and by default are a JSON structure and will be serialized as such if an
endpoint method that produces one as a response declares application/json
or application/problem+json
in its
@Produces
annotation. From 0.25.0 onwards we also support serializing a subset of the problem into text/plain
responses. If an endpoint method does not declare one of the supported media types, or supports other media types, then
your application may need to provide its own additional MessageBodyWriter<Problem>
for those media types.
There are a few other minor utilities provided by this library.
When building your server you can use the withCors()
method to provide/manipulate a
CorsConfigurationBuilder
object that allows you to set up the necessary CORS configuration for your application. By
default, CORS is enabled for all servers unless withoutCors()
is explicitly called on the ServerBuilder
.
The default CORS configuration allows all standard HTTP methods, the Accept
, Authorization
, Content-Disposition
,
Content-Type
and Request-ID
headers to be included in pre-flight requests and the Request-ID
header to be obtained
from pre-flight responses.
NB: Remember that a number of HTTP Headers are implicitly permitted by the CORS Request safe-list and the CORS Response safe-list.
If your application requires custom CORS settings then use the withCors()
method to configure CORS as desired e.g.
Server server
= ServerBuilder.create()
.localhost()
.port(12345)
.application(ExampleApplication.class)
.withAutoConfigInitialisation()
.withCors(c -> c.addAllowedHeaders("X-Custom")
.addExposedHeaders("X-Custom")
.addAllowedOrigins("https://your-domain.com")
.preflightMaxAge(30))
.displayName("Custom CORS API")
The RequestIdFilter
is automatically registered when you derive from AbstractApplication
per Creating an
Application. It adds a unique Request-ID
header containing a UUID to each incoming request
and copies this header to the outgoing response as well. The Request ID is also copied into the logging MDC
allowing
logging configuration to include it as part of the log format, this then allows the logs to be correlated with
individual requests. This is particularly useful when many users are making concurrent requests to the server and the
logging for those requests may be arbitrarily interleaved.
If a client provides a Request-ID
header in their original request then this is used as the prefix for creating a
unique Request ID by appending an incrementing number (seeded from the server startup time) to the client provided ID.
Thus resulting in Request IDs like test/1677839349485
, test/1677839349486
etc.
This feature is useful if clients need to make multiple requests to perform some logical operation (from the client's viewpoint) and wants to later correlate all those requests within the logs. Obviously a client needs to be careful that their supplied Request ID is sufficiently unique to start with, e.g. by generating its own UUIDs.
Note that in order to protect the server from overly long Request IDs the server imposes a maximum length of 36
characters on client provided IDs. This corresponds to the length of a standard UUID encoded as a string e.g.
d34b6a52-0511-42d8-9211-819bca4db626
. If the client provided Request ID is longer than this is will be truncated to
36 characters and then the server provided unique suffix appended as shown above.
The Paging
static class provides an applyPaging()
method that can be used to apply limit and offset based paging to
any list of results.
Note that generally it will be better to implement paging directly in your underlying APIs wherever possible, this is merely a stop-gap measure for the scenario where such capabilities are not supported by an API.
The AbstractHealthResource
provides a JAX-RS resource class for implementing the /healthz
health endpoint in your
application. You need to derive from this class and override the determineStatus()
method to provide a HealthStatus
object describing the health of your server.
From 0.22.0
onwards you can provide your class for this via overriding the abstract getHealthResourceClass()
method
in your application class. Note that if you prefer not to use our AbstractHealthResource
as a base then you should
return null
from that method, and just register your own health resource class in your applications
getClasses()
method.
The VersionInfoResource
provides a /version-info
endpoint to your server that exposes version information. You can
ensure specific libraries information is exposed there by using the withVersionInfo("library-name")
method on the
ServerBuilder
. See the Detecting Version
Information documentation for details on how the version
information is detected.
This API is provided by the jaxrs-base-server
module which can be depended on from Maven like so:
<dependency>
<groupId>io.telicent.smart-caches</groupId>
<artifactId>jaxrs-base-server</artifactId>
<version>VERSION</version>
</dependency>
Where VERSION
is the desired version, see the top level README in this repository for that
information.
The tests
classifier of this module includes some useful mock servers that can be used in setting up testing of
servers built with this framework.
For testing that services properly enable authentication and can verify user identity there is a MockKeyServer
that
can be instantiated and started like so:
MockKeyServer keyServer = new MockKeyServer(12345);
keyServer.start();
// Pick one of the available Key IDs
String keyId = keyServer.getKeyIdsAsList().get(0);
// Create a JWT signed with a key provided by the key server
String jwt = Jwts.builder()
.header()
.keyId(keyId)
.and()
.subject("user@example.org")
.expiration(Date.from(Instant.now().plus(5, ChronoUnit.MINUTES)))
.signWith(keyServer.getPrivateKey(keyId))
.compact();
// Create a verifier that can verify keys from the key server
JwtVerifier verifier
= new SignedJwtVerifier(new CachedJwksKeyLocator(URI.create(keyServer.getJwksUrl()),
Duration.ofMinutes(15)));
// Verify the signed JWT
Jws<Claims> jws = verifier.verify(jwt);
It provides a getJwksUrl()
method that provides a JWKS URL that can be used to configure the JWT Authentication
Filter, see JWT Authentication for configuration details.
If you want to mock an AWS deployment you can call registerAsAwsRegion("test")
and then configure the filter with the
test
region to have it resolve keys AWS style from the key server. You should remember to call
AwsElbKeyUrlRegistry.reset()
after your tests to remove the custom configuration.
Finally you should always call stop()
in your test class teardown to stop the mock key server.
There is also a MockAttributesServer
provided that allows mocking out the user attribute service, this allows for
testing services that need to retrieve user attributes and make authorization decisions based upon those.
// Create an attributes store with the attributes you want to serve for your test(s)
AttributeValueSet attributes
= AttributeValueSet.of(
List.of(
AttributeValue.of("name", ValueTerm.value("Thomas T. Test")),
AttributeValue.of("admin", ValueTerm.value(true))));
AttributesStoreLocal local = new AttributesStoreLocal();
local.put("test", expected);
// Create and start the mock server with that store
MockAttributesServer attributesServer = new MockAttributesServer(local);
attributesServer.start();
// Can then create a remote attributes store backed by it
AttributesStore remote
= new AttributesStoreRemote(attributesServer.getUserLookupUrl(),
attributesServer.getHierarchyLookupUrl());
// And lookup user attributes
AttributeValueSet userAttributes = remote.attributes("test");
// Stop the server
attributesServer.shutdown();
The server provides a getUserLookupUrl()
and a getHierarchyLookupUrl()
method that can be used to obtain the URLs to
pass into a service to configure it to use the mock server, see User Attributes for
configuration details.