- Notifications
You must be signed in to change notification settings - Fork0
A wrapper around Javalin that helps develop secured, database-backed RESTful services with minimal overhead and maximum flexibility.
License
UnterrainerInformatik/java-http-server
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
A wrapper aroundJavalin enabling you to craft database-backed CRUD REST-services in no-time.
This library reduces boilerplate code when setting up a REST-server connected to a relational database (MariaDB adapter exists) providing extensive builders to generate standard CRUD REST-endpoints.
Further it has interceptors and extensions allowing you to interact with the process of generating a response for a specific request at any level. The sync-extensions may abort the standard-process at any point or simply alter the DTOs passed.
A relational database (MariaDB) that holds your entities (we use Liquibase for git-supported, structured, versioned DDL-manipulation) and a JPA persistenceUnit for that database.(You should use ourjava-rdb-utils project for this, since it deals with max-accuracy timestamps and LocalDateTime vs. UTC as well as reducing boilerplate code on Liquibase-startup and shutdown).
Of course you can use your own. This example is only given as a reference and quick-start support.
<?xml version="1.0" encoding="UTF-8"?><persistenceversion="3.0"xmlns="https://jakarta.ee/xml/ns/persistence"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd"> <persistence-unitname="my-persistence-unit"transaction-type="RESOURCE_LOCAL"><!-- Hibernate specific--><provider>org.hibernate.jpa.HibernatePersistenceProvider</provider><class>info.unterrainer.commons.rdbutils.entities.BasicJpa</class><class>info.unterrainer.commons.rdbutils.converters.LocalDateTimeConverter</class><properties><!-- Hibernate-specific / MariaDB-JDBC-driver specific--><propertyname="jakarta.persistence.jdbc.driver"value="org.mariadb.jdbc.Driver" /><propertyname="jakarta.persistence.lock.timeout"value="10000" /><propertyname="jakarta.persistence.query.timeout"value="60000" /><propertyname="hibernate.connection.driver_class"value="org.mariadb.jdbc.Driver" /><propertyname="hibernate.dialect"value="org.hibernate.dialect.MariaDBDialect" /><propertyname="hibernate.temp.use_jdbc_metadata_defaults"value="false" /><propertyname="hibernate.jdbc.time_zone"value="UTC"/><propertyname="hibernate.show_sql"value="false" /><propertyname="hibernate.format_sql"value="false" /><propertyname="hibernate.c3p0.min_size"value="5" /><propertyname="hibernate.c3p0.max_size"value="200" /><!-- Seconds a Connection can remain pooled but unused before being discarded. Zero means idle connections never expire.--><propertyname="hibernate.c3p0.timeout"value="300" /><!-- The size of c3p0’s global PreparedStatement cache over all connections. Zero means statement caching is turned off.--><propertyname="hibernate.c3p0.max_statements"value="500" /><!-- If this is a number greater than 0, c3p0 will test all idle, pooled but unchecked-out connections, every this number of seconds.--><propertyname="hibernate.c3p0.idle_test_period"value="3000" /><!-- Is set to true, the connection is tested with a simple query before being returned to a user--><propertyname="hibernate.c3p0.testConnectionOnCheckout"value="true" /><propertyname="hibernate.c3p0.statementCacheNumDeferredCloseThreads"value="1" /></properties></persistence-unit></persistence>
// To get the necessary configuration values, we use environment variables.// You can see the necessary fields in the java-rdb-utils project (configuration).// Those currently are://// DB_SERVER the IP or URI of your server// DB_PORT// DB_NAME// DB_USER// DB_PASSWORD// Create an EntityManagerFactory using java-rdb-utils.// Registers shutdownhook to close emf as well.// This method gets the connection data from the environment variables// as stated above.// "my-server" is the persistence-unit-nameEntityManagerFactoryemf =dbUtils.createAutoclosingEntityManagerFactory(MyProgram.class,"my-persistence-unit");// Create the server.HttpServerserver =HttpServer.builder() .applicationName("my-rest-server") .jsonMapper(jsonMapper) .objectMapper(objectMapper) .build();// All handlers are added and considered in order.// After you're done adding handlers, start the server.server.start();
When starting this server, you'll be able to access the endpoints using Postman or a similar REST client.
- AppNameHandlerPath: GET "/"Returns: The name of the server
- AppVersionHandlerPath: GET "/version"Returns: The version of the registered version-provider, or the http-server if none given
- DateTimeHandlerPath: GET "/datetime"Returns: The current date and time on the server in UTC
- HealthHandlerPath: GET "/health"Returns: "healthy" if the server is up and running
- PostmanCollectionHandlerPath: GET "/postman"Returns: The content of the file "src/main/resources/postman_collection.json", if any
The reason why you're doing REST services is that you have some data to expose to your client.The next example takes such data (a user) and exposes it.
First, let's create the JPA used to read and write to and from the database.It's linked to the table using JPA annotations.
@Data@NoArgsConstructor@EqualsAndHashCode(callSuper =true)@SuperBuilder(toBuilder =true)@Entity@Table(name ="user")publicclassUserJpaextendsBasicJpa {privateStringname;}
Then let's create the JSON object. That's the Data-Transfer-Object being sent to and from the server via HTTP. The server does all of the mapping by itself.
@Data@NoArgsConstructor@SuperBuilder(toBuilder =true)@EqualsAndHashCode(callSuper =true)publicclassUserJsonextendsBasicJson {privateStringname;}
And lastly, update the server-code so that we expose the endpoint.
// omitted for brevity...// (see first, minimal example)// Last line here is the creation of the server ending with ".build()"// Register a custom handler for the resource 'user'.server.handlerGroupFor(UserJpa.class,UserJson.class,newJpqlDao<UserJpa>(emf,UserJpa.class)) .path("users") .endpoints(Endpoint.ALL) .addRoleFor(Endpoint.ALL,RoleBuilder.open()) .getListInterceptor() .query("o.userName = :userName[string]") .build() .add();server.start();
Now you can use the resource reachable via '/users'.
The server uses an all-plural schema, meaning that the resource name is the plural of the word for it.Additionally all resource-names are lower-case only due to restrictions within Javalin as of this version.
GET local.myserver.com/users/12
to get the user with the ID 12.Referred to as 'get-by-ID'.
GET local.myserver.com/users?size=10&offset=0
to get the list of the next 10 users starting with offset 0.Referred to as 'get-list'. The result has a global count and prev, next, first, last links.
POST local.myserver.com/users
with payload{ "name": "testName" }
to persist the new user with nametestName
.Referred to as 'create'.
PUT local.myserver.com/users/12
with payload{ "name": "testName1" }
to update the name of the user with the ID 12 totestName1
.Referred to as 'full-update'.
DEL local.myserver.com/users/12
to delete the user with the ID 12.Referred to as 'delete'.
When sending requests to the server, it will do the following in the following order to get to returning a response object.
In addition to the standard process, you may register extensions (sync and async) or any number of get-list-interceptors at your leasure.
Synchronous extensions run in the context of the request-response-process and therefore may alter or stop it. The backdraw is that they stall the request-response-process for as long as it takes executing them of course.
If you have long-running operations, you better choose an async-extension point.
server.handlerGroupFor(SomeSingletonJpa.class,SomeSingletonJson.class,newJpqlAsyncDao<SomeSingletonJpa>(emf,SomeSingletonJpa.class)) .path("cmd/somesingleton") .endpoints(Endpoint.ALL) .addRoleFor(Endpoint.ALL,RoleBuilder.open()) .extension() .preInsertSync((ctx,em,receivedJson,resultJpa) -> {if (someSingletonDao.lockedGetNextWith(em,AsyncState.PROCESSING,AsyncState.PROCESSING) !=null)thrownewConflictException("A singleton-run is already in progress. Only a single singleton-run is allowed to be running at any given time.");resultJpa.setState(AsyncState.PROCESSING);resultJpa.setStartedOn(LocalDateTime.now(ZoneOffset.UTC));returnresultJpa; }) .extension() .add();server.start();
The throwing of an HttpException here stops the request-response-process returning the error-message for that exception including the correct status-code.
Run in their own context, detached from the request-response-process and therefore cannot alter or stop it.
server.handlerGroupFor(SubscriptionJpa.class,SubscriptionJson.class,subscriptionDao) .path("/subscriptions") .endpoints(Endpoint.ALL) .addRoleFor(Endpoint.ALL,RoleBuilder.open()) .extension() .postDeleteAsync(id -> {subscriptionHandler.updateSubscriptions(); }) .extension() .postInsertAsync((receivedJson,mappedJpa,createdJpa,response) -> {subscriptionHandler.updateSubscriptions(); }) .extension() .postModifyAsync((receivedId,receivedJson,readJpa,mappedJpa,persistedJpa,response) -> {subscriptionHandler.updateSubscriptions(); }) .add();server.start();
Here we're runningsubscriptionHandler.updateSubscriptions()
every time a subscription is changed using our CRUD endpoints.
These are called in order of registration BEFORE calling the standard get-list code.If any single one of those completes without an exception or without returning false, then all other interceptors will be omitted as well as the standard get-list code. The result of the interceptor will be taken and the response will be built using that data.
This allows you to customize ordering, path-parameters and so on, without you having to write all the necessary code to allow for paging all by yourself over and over again.
They come in two flavors.
The server has an integrated language we called RQL (like in REST query language) that allows you to specify and combine several additional query-parameters and the way those are mapped to the database.
Be cautious when using those and be sure to have the right indexes on your database to support the queries your users are then able to build using your query parameters.
server.handlerGroupFor(SubscriptionJpa.class,SubscriptionJson.class,subscriptionDao) .path("/subscriptions") .endpoints(Endpoint.ALL) .addRoleFor(Endpoint.ALL,RoleBuilder.open()) .getListInterceptor() .query("o.idString = :stringId[string]") .build() .add();server.start();
This interceptor is used if the (mandatory) path-parameterstringId
is set to a value. It is treated as a string internally and is matched using the=
operator on a database-level to the database-fieldidString
. So if you'd pass it the value 'test' the resulting JPQL query would look like this:
SELECT ofrom<yourObject>WHERE idString=:stringId''' with the following parameters being set for that query...setParam(stringId, "test")
You may specify a parameter as optional by pre-fixing the database-field name with a question-mark like so:
.getListInterceptor() .query("o.scanId = :scanId[long] AND (?o.name LIKE :sn[string] OR ?o.idString LIKE :sn[string] OR ?o.description LIKE :sn[string])") .build()
WherescanId
is a numeric mandatory parameter and the rest is checked using theLIKE
operator but since the parametersn
is optional, the usages are as well.
"string""boolean","bool""integer","int""long","lng""float""double","dbl""datetime"
"==","=""<>","!=""<"">""<="">=""IS NULL""IS NOT NULL""LIKE" (stringonly)"NOT LIKE" (stringonly)"STARTSWITH" (stringonly)"NOT STARTSWITH" (stringonly)"ENDSWITH" (stringonly)"NOT ENDSWITH" (stringonly)
.getListInterceptor().query("o.endsOn >= :startOn[datetime] AND ?o.type = :type[~EventType]").build()
This will cast theString
in the parametertype
to a enumerationEventType
.So this works only if you save the String-representation of an enumeration in the database and not the index.
Are registered as anonymous methods returning anInterceptorData
object or null, if to be omitted.
Here you can do everything the integrated RQL language doesn't make up for.
@Data@AllArgsConstructor@BuilderpublicclassInterceptorData {privateStringselectClause;privateStringwhereClause;privateStringjoinClause;privateStringorderByClause;privateParamMapparams;privateStringpartOfQueryString;}
server.handlerGroupFor(SubscriptionJpa.class,SubscriptionJson.class,subscriptionDao) .path("/subscriptions") .endpoints(Endpoint.ALL) .addRoleFor(Endpoint.ALL,RoleBuilder.open()) .getListInterceptor(subscriptionInterceptor::select) .add();server.start();
Where the methodsubscriptionInterceptor.select
is some longer method resulting in anInterceptorData
object being returned like along the lines of this:
publicInterceptorDataselect(finalContextctx,finalHandlerUtilshu) {// (locNameLike=:string AND locale=:string) AND hasTags=:[long] AND// anyTags=:[long] AND// state=:string AND quality=:stringStringlocNameLike =hu.getQueryParamAsString(ctx,"locNameLike",null);StringhasTags =hu.getQueryParamAsString(ctx,"hasTags",null); ...
In order to keep SQL-queries somewhat consistent and because of my deep aversion of Criteria queries, I've used the following 'query language' you can get calling everyJpqlDao<JpaType>
.
// Insert...userDao.insert(entity).execute();userDao.insert(entity).entityManager(em).execute();// Full-Update...userDao.update(entity).execute();userDao.update(entity).entityManager(em).execute();// Single-Result Query...SingleQueryBuilder<NexusUserJpa,NexusUserJpa>single =userDao.select(32L);single.delete();NexusUserJpaen =single.get();// List-Result Query...JpaListQuery<NexusUserJpa>query;query =userDao.select().build();query =userDao.select().entityManager(em).build();query =userDao.select().where("o.id = :id").addParam("id",32L).build();query =userDao.select() .where("o.priority > :priority AND o.enabled = :enabled AND userId IS NOT NULL") .addParam("priority",10L) .addParam("enabled",true) .desc("o.priority") .lockPessimistic() .build();query =userDao.select("o") .join("LEFT JOIN GroupJpa g ON g.id = o.groupId") .where("g.enabled = :enabled") .addParam("enabled",true) .build();// Delete all list-results.query.delete();// Upsert first element of list-results.query.upsert(entity);// Various ways to get some or all entities from the list-results.NexusUserJpaa =query.getFirst();List<NexusUserJpa>b =query.getList();List<NexusUserJpa>c =query.getList(0,10);ListJson<NexusUserJpa>d =query.getListJson();ListJson<NexusUserJpa>e =query.getListJson(10,10);List<NexusUserJpa>f =query.getListReversed();List<NexusUserJpa>g =query.getN(22);NexusUserJpah =query.getSingle();
The system allows to save different tennants within the same tables, in order to ease development.
This is done on a per-DAO basis (per -table so to say), as there has to be a separate table holding permission-data regarding the tenant-IDs and corresponding reference-IDs.
--- Personid(Long)name(String)--- Person_Permissionid(Long)referenceId(Long)tenantId(Long)------------------------------------------- Person1Peter2Paul3Mary--- Person_Permission111221232
When a user having tenantId=1 associated will query the full list of Persons, he will inevitably receive 'Peter and Paul', whereas another user associated with tennantId=2 would receive 'Mary'.
To enable this feature, you have to generate a special DAO that is linked to the corresponding permissions-DAO by specifying the JPA-type of the permission-DAO and both the name of the reference-ID and tenant-ID field (all that is explained in the constructor of the DAO).
So you have to create the appropriate permission-table, a permission-JPA for the table.
In order to query those tables accordingly, your querying user has to be associated with a tenant-ID.
In fact there are TWO associations with multiple tenant-IDs there.Thetenant_read
set, used to determine if a user can see (and therefore modify or delete) a row, and thetenant_write
set, used to determine how many and which permission-rows there are to write when creating a new row in the main-table.
On the DAO-level (if you do DB stuff on the server) you may specify those freely using the according setters in the query-builders of the DAO. When using the DAO you will only have to specify a single set of tenant-IDs since you know how you're planning on using those yourself (if you will create a row, then the tenant-ID set is equivalent to thetenant_write
set; if you will only query, then specify a tenant-ID set equivalent to thetenant_read
set).
In KeyCloak you have to specify both sets per user and the system will pick the appropriate set when manipulating or querying the database.
This is done by settings User-Attributes.
User: Psilo / Attributes-- tenant_read:1,3-- tenant_write:1
On KeyCloak-Setup, be advised that you have to specify an Attribute-Mapper for both attributes (Clients-><ClientName>->Mappers, create with name=tenants_read/tenants_write, User Attribute=<name>, Token Claim Name=<name>, Claim JSON Type=String
).
That set up, the Attribute values will be passed on into the JWT token and parsed by Http-Server (the data will be copied to the Context.Attribute Object from where you may retrieve them at any time during a request).The system will decide automatically which set to use, so that in this example the userPsilo
will be able to see rows that have the permission for tenant-ID 1 or 3, but when creating a row, it will only write a permission for tenant-ID 1.
// Passing the TestPermissionJpa class enables the tenant-capability.// The TestPermissionJpa has a getReferenceId() and getTenantId() method// as required by the default setting.server.handlerGroupFor(TestJpa.class,TestJson.class,newJpqlDao<>(emf,TestJpa.class,TestPermissionJpa.class)).path("/tenanttests").endpoints(Endpoint.ALL).jsonMapper(mapper).addRoleFor(Endpoint.ALL,RoleBuilder.authenticated())...// The next example sets up tenant-capability using a JPA that has a// getRefId() and a getTId() method (not default).server.handlerGroupFor(TestJpa.class,TestJson.class,newJpqlDao<>(emf,TestJpa.class,TestPermissionJpa.class,"refId","tId")).path("/tenanttests")
About
A wrapper around Javalin that helps develop secured, database-backed RESTful services with minimal overhead and maximum flexibility.
Topics
Resources
License
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.
Contributors4
Uh oh!
There was an error while loading.Please reload this page.