|
6 | 6 |
|
7 | 7 | # java-http-server
|
8 | 8 |
|
9 |
| -A wrapper around [Javalin](https://javalin.io/) enabling you to craft CRUD REST-services in no-time. |
| 9 | +A wrapper around [Javalin](https://javalin.io/) enabling you to craft database-backed CRUD REST-services in no-time. |
10 | 10 |
|
11 | 11 | 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.
|
12 | 12 |
|
13 |
| -Further it has interceptors and extensions allowing you to interact with the process of generating a response for a specific request at any level. |
| 13 | +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. |
| 14 | + |
| 15 | + |
| 16 | + |
| 17 | +## Prerequisites |
| 18 | + |
| 19 | +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. |
| 20 | +(You should use our [java-rdb-utils](https://github.com/UnterrainerInformatik/java-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). |
| 21 | + |
| 22 | +### Minimal Example |
| 23 | + |
| 24 | +```java |
| 25 | +// Get configuration containing necessary environment variables. |
| 26 | +configuration = MyProgramConfiguration.read(); |
| 27 | +// Create an EntityManagerFactory using java-rdb-utils. |
| 28 | +// Registers shutdownhook to close emf as well. |
| 29 | +EntityManagerFactory emf = |
| 30 | + dbUtils.createAutoclosingEntityManagerFactory(MyProgram.class, "my-server"); |
| 31 | + |
| 32 | +// Create a JpqlTransactionManager which will be used to maintain transactions |
| 33 | +// throughout the server. |
| 34 | +JpqlTransactionManager jpqlTransactionManager = new JpqlTransactionManager(emf); |
| 35 | + |
| 36 | +// Create the server. |
| 37 | +HttpServer server = HttpServer.builder() |
| 38 | + .applicationName("my-rest-server") |
| 39 | + .jsonMapper(jsonMapper) |
| 40 | + .orikaFactory(orikaFactory) |
| 41 | + .build(); |
| 42 | + |
| 43 | +// All handlers are added and considered in order. |
| 44 | +// After you're done adding handlers, start the server. |
| 45 | +server.start(); |
| 46 | +``` |
| 47 | + |
| 48 | +When starting this server, you'll be able to access the endpoints using Postman or a similar REST client. |
| 49 | + |
| 50 | +### Standard endpoints available |
| 51 | + |
| 52 | +* AppNameHandler |
| 53 | + Path: GET "/" |
| 54 | + Returns: The name of the server |
| 55 | +* AppVersionHandler |
| 56 | + Path: GET "/version" |
| 57 | + Returns: The version of the registered version-provider, or the http-server if none given |
| 58 | +* DateTimeHandler |
| 59 | + Path: GET "/datetime" |
| 60 | + Returns: The current date and time on the server in UTC |
| 61 | +* HealthHandler |
| 62 | + Path: GET "/health" |
| 63 | + Returns: "healthy" if the server is up and running |
| 64 | +* PostmanCollectionHandler |
| 65 | + Path: GET "/postman" |
| 66 | + Returns: The content of the file "src/main/resources/postman_collection.json", if any |
| 67 | + |
| 68 | + |
| 69 | + |
| 70 | +## Custom Handlers |
| 71 | + |
| 72 | +The reason why you're doing REST services is that you have some data to expose to your client. |
| 73 | +The next example takes such data (a user) and exposes it. |
| 74 | + |
| 75 | +First, let's create the JPA used to read and write to and from the database. |
| 76 | +It's linked to the table using JPA annotations. |
| 77 | + |
| 78 | +### User.jpa |
| 79 | + |
| 80 | +```java |
| 81 | +@Data |
| 82 | +@NoArgsConstructor |
| 83 | +@EqualsAndHashCode(callSuper = true) |
| 84 | +@SuperBuilder(toBuilder = true) |
| 85 | +@Entity |
| 86 | +@Table(name = "user") |
| 87 | +public class UserJpa extends BasicJpa { |
| 88 | + |
| 89 | + private String name; |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +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. |
| 94 | + |
| 95 | +### User.json |
| 96 | + |
| 97 | +```java |
| 98 | +@Data |
| 99 | +@NoArgsConstructor |
| 100 | +@SuperBuilder(toBuilder = true) |
| 101 | +@EqualsAndHashCode(callSuper = true) |
| 102 | +public class UserJson extends BasicJson { |
| 103 | + |
| 104 | + private String name; |
| 105 | +} |
| 106 | +``` |
| 107 | + |
| 108 | +And lastly, update the server-code so that we expose the endpoint. |
| 109 | + |
| 110 | +### Server |
| 111 | + |
| 112 | +```java |
| 113 | +// omitted for brevity... |
| 114 | +// (see first, minimal example) |
| 115 | +// Last line here is the creation of the server ending with ".build()" |
| 116 | + |
| 117 | +// Register a custom handler for the resource 'user'. |
| 118 | +server.handlerGroupFor(UserJpa.class, UserJson.class, jpqlTransactionManager) |
| 119 | + .path("users") |
| 120 | + .dao(new JpqlDao<UserJpa>(emf, UserJpa.class)) |
| 121 | + .endpoints(Endpoint.ALL) |
| 122 | + .addRoleFor(Endpoint.ALL, RoleBuilder.open()) |
| 123 | + .getListInterceptor() |
| 124 | + .query("userName = :userName[string]") |
| 125 | + .build() |
| 126 | + .add(); |
| 127 | + |
| 128 | +server.start(); |
| 129 | +``` |
| 130 | + |
| 131 | +Now you can use the resource reachable via '/users'. |
| 132 | + |
| 133 | + |
| 134 | + |
| 135 | +## URL Schema |
| 136 | + |
| 137 | +The server uses an all-plural schema, meaning that the resource name is the plural of the word for it. |
| 138 | +Additionally all resource-names are lower-case only due to restrictions within Javalin as of this version. |
| 139 | + |
| 140 | +`GET local.myserver.com/users/12` |
| 141 | + to get the user with the ID 12. |
| 142 | + Referred to as 'get-by-ID'. |
| 143 | + |
| 144 | +`GET local.myserver.com/users?size=10&offset=0` |
| 145 | + to get the list of the next 10 users starting with offset 0. |
| 146 | + Referred to as 'get-list'. The result has a global count and prev, next, first, last links. |
| 147 | + |
| 148 | +`POST local.myserver.com/users` with payload `{ "name": "testName" }` |
| 149 | + to persist the new user with name `testName`. |
| 150 | + Referred to as 'create'. |
| 151 | + |
| 152 | +`PUT local.myserver.com/users/12` with payload `{ "name": "testName1" }` |
| 153 | + to update the name of the user with the ID 12 to `testName1`. |
| 154 | + Referred to as 'full-update'. |
| 155 | + |
| 156 | +`DEL local.myserver.com/users/12` |
| 157 | + to delete the user with the ID 12. |
| 158 | + Referred to as 'delete'. |
14 | 159 |
|
15 | 160 |
|
16 | 161 |
|
17 | 162 | ## Standard Request-Response Process
|
18 | 163 |
|
| 164 | +When sending requests to the server, it will do the following in the following order to get to returning a response object. |
| 165 | + |
19 | 166 | 
|
20 | 167 |
|
21 | 168 |
|
22 | 169 |
|
23 | 170 | ## Request-Response Process with Extensions and Interceptors
|
24 | 171 |
|
25 |
| - |
| 172 | +In addition to the standard process, you may register extensions (sync and async) or any number of get-list-interceptors at your leasure. |
| 173 | + |
| 174 | + |
| 175 | + |
| 176 | +### Sync-Extensions |
| 177 | + |
| 178 | +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. |
| 179 | + |
| 180 | +If you have long-running operations, you better choose an async-extension point. |
| 181 | + |
| 182 | +#### Example |
| 183 | + |
| 184 | +```java |
| 185 | +server.handlerGroupFor(SomeSingletonJpa.class, SomeSingletonJson.class, jpqlTransactionManager) |
| 186 | + .path("cmd/somesingleton") |
| 187 | + .dao(new JpqlAsyncDao<SomeSingletonJpa>(emf, SomeSingletonJpa.class)) |
| 188 | + .endpoints(Endpoint.ALL) |
| 189 | + .addRoleFor(Endpoint.ALL, RoleBuilder.open()) |
| 190 | + .extension() |
| 191 | + .preInsertSync((ctx, em, receivedJson, resultJpa) -> { |
| 192 | + if (someSingletonDao.lockedGetNextWith(em, AsyncState.PROCESSING, AsyncState.PROCESSING) != null) |
| 193 | + throw new ConflictException( |
| 194 | + "A singleton-run is already in progress. Only a single singleton-run is allowed to be running at any given time."); |
| 195 | + resultJpa.setState(AsyncState.PROCESSING); |
| 196 | + resultJpa.setStartedOn(LocalDateTime.now(ZoneOffset.UTC)); |
| 197 | + return resultJpa; |
| 198 | + }) |
| 199 | + .extension() |
| 200 | + .add(); |
| 201 | +server.start(); |
| 202 | +``` |
| 203 | + |
| 204 | +The throwing of an HttpException here stops the request-response-process returning the error-message for that exception including the correct status-code. |
| 205 | + |
| 206 | + |
| 207 | + |
| 208 | +### Async-Extensions |
| 209 | + |
| 210 | +Run in their own context, detached from the request-response-process and therefore cannot alter or stop it. |
| 211 | + |
| 212 | +#### Example |
| 213 | + |
| 214 | +```java |
| 215 | +server.handlerGroupFor(SubscriptionJpa.class, SubscriptionJson.class, jpqlTransactionManager) |
| 216 | + .path("/subscriptions") |
| 217 | + .dao(subscriptionDao) |
| 218 | + .endpoints(Endpoint.ALL) |
| 219 | + .addRoleFor(Endpoint.ALL, RoleBuilder.open()) |
| 220 | + .extension() |
| 221 | + .postDeleteAsync(id -> { |
| 222 | + subscriptionHandler.updateSubscriptions(); |
| 223 | + }) |
| 224 | + .extension() |
| 225 | + .postInsertAsync((receivedJson, mappedJpa, createdJpa, response) -> { |
| 226 | + subscriptionHandler.updateSubscriptions(); |
| 227 | + }) |
| 228 | + .extension() |
| 229 | + .postModifyAsync((receivedId, receivedJson, readJpa, mappedJpa, persistedJpa, response) -> { |
| 230 | + subscriptionHandler.updateSubscriptions(); |
| 231 | + }) |
| 232 | + .add(); |
| 233 | +server.start(); |
| 234 | +``` |
| 235 | + |
| 236 | +Here we're running `subscriptionHandler.updateSubscriptions()` every time a subscription is changed using our CRUD endpoints. |
| 237 | + |
| 238 | +### Get-List-Interceptors |
| 239 | + |
| 240 | +These are called in order of registration BEFORE calling the standard get-list code. |
| 241 | +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. |
| 242 | + |
| 243 | +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. |
| 244 | + |
| 245 | +They come in two flavors. |
| 246 | + |
| 247 | +#### Query-Based Get-List-Interceptors |
| 248 | + |
| 249 | +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. |
| 250 | + |
| 251 | +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. |
| 252 | + |
| 253 | +##### Example 1 |
| 254 | + |
| 255 | +```java |
| 256 | +server.handlerGroupFor(SubscriptionJpa.class, SubscriptionJson.class, jpqlTransactionManager) |
| 257 | + .path("/subscriptions") |
| 258 | + .dao(subscriptionDao) |
| 259 | + .endpoints(Endpoint.ALL) |
| 260 | + .addRoleFor(Endpoint.ALL, RoleBuilder.open()) |
| 261 | + .getListInterceptor() |
| 262 | + .query("idString = :stringId[string]") |
| 263 | + .build() |
| 264 | + .add(); |
| 265 | +server.start(); |
| 266 | +``` |
| 267 | + |
| 268 | +This interceptor is used if the (mandatory) path-parameter `stringId` 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-field `idString`. So if you'd pass it the value 'test' the resulting JPQL query would look like this: |
| 269 | + |
| 270 | +```sql |
| 271 | +SELECT o from <yourObject> WHERE idString=:stringId |
| 272 | +''' with the following parameters being set for that query... |
| 273 | +setParam(stringId, "test") |
| 274 | +``` |
| 275 | +
|
| 276 | +You may specify a parameter as optional by pre-fixing the database-field name with a question-mark like so: |
| 277 | +
|
| 278 | +##### Example 2 |
| 279 | +
|
| 280 | +```java |
| 281 | +.getListInterceptor() |
| 282 | + .query("scanId = :scanId[long] AND (?name LIKE :sn[string] OR ?idString LIKE :sn[string] OR ?description LIKE :sn[string])") |
| 283 | + .build() |
| 284 | +``` |
| 285 | +
|
| 286 | +Where `scanId` is a numeric mandatory parameter and the rest is checked using the `LIKE` operator but since the parameter `sn` is optional, the usages are as well. |
| 287 | +
|
| 288 | +
|
| 289 | +
|
| 290 | +#### Explicit Get-List-Interceptors |
| 291 | +
|
| 292 | +Are registered as anonymous methods returning an `InterceptorData` object or null, if to be omitted. |
| 293 | +
|
| 294 | +Here you can do everything the integrated RQL language doesn't make up for. |
| 295 | + |
| 296 | +##### InterceptorData |
| 297 | + |
| 298 | +```java |
| 299 | +@Data |
| 300 | +@AllArgsConstructor |
| 301 | +@Builder |
| 302 | +public class InterceptorData { |
| 303 | +
|
| 304 | + private String selectClause; |
| 305 | + private String whereClause; |
| 306 | + private String joinClause; |
| 307 | + private String orderByClause; |
| 308 | + private ParamMap params; |
| 309 | + private String partOfQueryString; |
| 310 | +} |
| 311 | +``` |
| 312 | + |
| 313 | +##### Example |
| 314 | + |
| 315 | +```java |
| 316 | +server.handlerGroupFor(SubscriptionJpa.class, SubscriptionJson.class, jpqlTransactionManager) |
| 317 | + .path("/subscriptions") |
| 318 | + .dao(subscriptionDao) |
| 319 | + .endpoints(Endpoint.ALL) |
| 320 | + .addRoleFor(Endpoint.ALL, RoleBuilder.open()) |
| 321 | + .getListInterceptor(subscriptionInterceptor::select) |
| 322 | + .add(); |
| 323 | +server.start(); |
| 324 | +``` |
| 325 | + |
| 326 | +Where the method `subscriptionInterceptor.select` is some longer method resulting in an `InterceptorData` object being returned like along the lines of this: |
| 327 | + |
| 328 | +```java |
| 329 | +public InterceptorData select(final Context ctx, final HandlerUtils hu) { |
| 330 | + // (locNameLike=:string AND locale=:string) AND hasTags=:[long] AND |
| 331 | + // anyTags=:[long] AND |
| 332 | + // state=:string AND quality=:string |
| 333 | + String locNameLike = hu.getQueryParamAsString(ctx, "locNameLike", null); |
| 334 | + String hasTags = hu.getQueryParamAsString(ctx, "hasTags", null); |
| 335 | + ... |
| 336 | +``` |
| 337 | + |
| 338 | + |
| 339 | + |
0 commit comments