Restop is an REST OPinionated Quarkus extension.
It's meant to avoid keep rewriting over and over (almost) the same code to create resource REST endpoints when wrinting Quarkus applications leveraging Hibernate with Panache.
The opinions that drives the opinionated approach are:
- a "list all" endpoint should be always paginated to provide a stable and predictable impact of each endpoint
- a "list all" endpoint should let the user to be able to filter data
- every endpoint should allow the usage of DTOs to not expose "internal" entities
- it relies on "active record pattern" for Hibernate with Panache Quarkus extension
- multiple inheritance of behavior leveraging Java interfaces default methods
These are opinions (and as such are debatable) so this is not supposed to be the solution for everything but a solution that works on quite a lot of use cases (more below).
JitPack can be used to add restop dependency:
-
add the JitPack repository
<repositories> <repository> <id>jitpack.io</id> <url>https://jitpack.io</url> </repository> </repositories>
-
add the dependency
<dependency> <groupId>com.github.mrizzi</groupId> <artifactId>restop</artifactId> <version>master-SNAPSHOT</version> </dependency>
Let's start with the Fruit entity referenced in many Quarkus guides.
It must be a PanacheEntity like:
@Entity
public class Fruit extends PanacheEntity {
@Column(length = 40, unique = true)
public String name;
public String description;
public Fruit() {
}
public Fruit(String name, String description) {
this.name = name;
this.description = description;
}
}Now let's move to leverage Restop to create the "read" endpoints:
@Path("fruit")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FruitResource implements ReadableById<Fruit>, ReadablePaginatedByRange<Fruit> {
@Override
public Class<Fruit> getType() {return Fruit.class;}
}done!
The /fruit endpoint will be able to provide responses to calls:
- read one:
GETrequest to/fruit/{id}endpoint with a singleFruitresult - read many "first page":
GETrequest to/fruitendpoint with an ordered (by ID) list ofFruitresults using default (and opinionated) values forlimit(i.e.25) andoffset(i.e.0) parameters - read many "n-th page":
GETrequest to/fruit?limit=10&offset=20endpoint with an ordered (by ID) list of (up to 10)Fruitresults starting for the 20th element - read many with sorting:
GETrequest to/fruit?sort_by=name:Ascendingendpoint with an ordered by Fruit'snamefield ascending list of (up to 25)Fruitresults starting for first element - read many with "equals" filter:
GETrequest to/fruit?name=Bananaendpoint with an ordered (by ID) list of (up to 25)Fruitresults whosenamefield value isBanana - read many with "in" filter:
GETrequest to/fruit?name=Banana&name=Apple&name=Kiwiendpoint with an ordered (by ID) list of (up to 25)Fruitresults whosenamefield value isBananaorAppleorKiwi
Obviously the "query" parameters for the "read many" operations work together so they can be combined in the request.
Here is an example of a "paginated" response (for the GET request to /fruit?sort_by=name:Ascending endpoint):
{
"data": [
{
"id": 2,
"description": "Winter fruit",
"name": "Apple"
},
{
"id": 3,
"description": "Tropical fruit",
"name": "Banana"
},
{
"id": 1,
"description": "Sweet fruit available on mid-spring.",
"name": "Cherry"
}
],
"links": {
"first": "/fruits?limit=25&offset=0&sort_by=name:Ascending",
"last": "/fruits?limit=25&offset=0&sort_by=name:Ascending"
},
"meta": {
"count": 3,
"limit": 25,
"offset": 0,
"sortBy": "name:Ascending"
}
}where:
datacontains the response data arraylinksset of links to easily move to other set of results consistently with the request'slimitandoffsetfirstlink to the first pageprevlink to the previous page (if available)nextlink to the next page (if available)lastlink to the last page
metacontains metadata about the resourcecountis the total number of entities corresponding to the filterslimitis the limit applied to the data retrieved (useful in case of default values)offsetis the offset applied to the data retrieved (useful in case of default values)sortis the sorting applied to the data retrieved (useful in case of default values)
To recap what has been done so far:
- created
Fruit extends PanacheEntityentity (something that should have been done anyway) - created
FruitResource implements ReadableById<Fruit>, ReadablePaginatedByRange<Fruit>providing thegetType()method implementation (requested from Restop) - got for free all the "read" endpoints about with pagination, sorting, filtering and links
How to add the endpoint to create a Fruit entity?
Following the "multiple inheritance of behavior" approach, it's a matter of adding to FruitResource class that it implements the Creatable<E extends PanacheEntity> interface.
The class will look like:
@Path("fruit")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FruitResource implements ReadableById<Fruit>, ReadablePaginatedByRange<Fruit>,
Creatable<Fruit> {
@Override
public Class<Fruit> getType() {return Fruit.class;}
}Now a POST request to /fruit endpoint will create a new Fruit resource.
For adding the deletion feature to an endpoint the approach will be the same as above.
Change the FruitResource class to implement the Deletable<E extends PanacheEntity> interface.
With this further change, the FruitResource class will be:
@Path("fruit")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FruitResource implements ReadableById<Fruit>, ReadablePaginatedByRange<Fruit>,
Creatable<Fruit>, Deletable<Fruit> {
@Override
public Class<Fruit> getType() {return Fruit.class;}
}Now a request DELETE request to the /fruit/{id} endpoint will delete the Fruit with provided id.
No interface available for adding the update feature yet.
Let me clarify the update use case.
The "sample" implementation of the update endpoint is something like:
@PUT
@Path("{id}")
@Transactional
public Fruit update(@PathParam Long id, Fruit fruit) {
if (fruit.name == null) {
throw new WebApplicationException("Fruit Name was not set on request.", 422);
}
Fruit entity = Fruit.findById(id);
if (entity == null) {
throw new WebApplicationException("Fruit with id of " + id + " does not exist.", 404);
}
entity.name = fruit.name;
return entity;
}as you can see, in this case there a need for a "knowledge" about the bean to move the information from the fruit input bean to the entity bean to get persisted into the database.
The interfaces introduces so far are not taking into account any kind of knowledge about the bean and to keep this approach, there's no Updatable interface.
But, no worries, this takes us to the next set of features: DTO.
The last paragraph about being able to reflect changes from an input bean into the persisted bean is close to the DTO (data transfer object) approach.
More generally speaking, when working with REST endpoints is common to have the need to use DTO for the endpoints avoiding to use the entities bean in the REST APIs.
restop provides a way to create quickly and easily REST endpoints with DTO.
The main change to be introduced to use DTO is the need to implement the getMapper() method from the WithDtoWebMethod interface.
The aim of this method is to provide the implementation of a mapper that takes case of "translating" values from DTO to Entity beans.
DTO, as everything in restop, are opinionated as well and they must (right now, maybe this will change in the future) accomplish one requirement:
DTO's fields must be a subset of Entity's fields.
Let's see how it works starting from where we left, the update method.
To add the endpoint for updating entities the FruitResource class must implement also the UpdatableWithDto<E extends PanacheEntity, D> interface.
In this example, the declaration will use UpdatableWithDto<Fruit, Fruit> since our DTO corresponds with the entity: this is an edge case obviously but for the sake of the example it makes sense.
So FruitResource becomes:
@Path("fruit")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FruitResource implements ReadableById<Fruit>, ReadablePaginatedByRange<Fruit>,
Creatable<Fruit>, Deletable<Fruit>, UpdatableWithDto<Fruit, Fruit>{
@Override
public Class<Fruit> getType() {return Fruit.class;}
@Override
public Mapper<Fruit, Fruit> getMapper() {
return new Mapper<Fruit, Fruit>() {
@Override
public Fruit map(Fruit source, Fruit target) {
if (target == null) target = new Fruit();
target.name = source.name;
target.description = source.description;
return target;
}
};
}}So, besides adding the interface, also the implementation for the getMapper() method has been added.
The implementation of the method is basic but it does what we expect: it copies values from DTO (a.k.a. source) into entity (a.k.a. target).
Now a PUT request to the /fruit/{id} endpoint will update the Fruit with provided id using the values in the DTO.
It's also clear this is an edge case of having DTO because the entity and the DTO are the same (compliant with the "subset" requirement): in the next paragraph we will see how to use a "traditional" (and obviously opinionated) DTO.
When dealing with read operations, it happens that some entity bean's fields are not meant to be sent out to the client. This can happen for different reasons: some fields are just internal fields (e.g. audit fields) or maybe you want to create a response with just the field shown in the UI in order to maximize the perfomances reading from the DB only the needed fields and so keeping the response payload as small as possible.
Going back to our example, let's say (and really just for the sake of the explanation) we just want to send out the name of the Fruit entities and not their id and description.
The DTO will look like:
@RegisterForReflection
public class FruitDto {
public String name;
public FruitDto(String name) {
this.name = name;
}
}The annotation @RegisterForReflection is mandatory to register manually the projection class for reflection, if you plan to deploy your application as a native executable (ref. Simplified Hibernate ORM with Panache)
For the example to use the "DTO-ed" operations, we can create a new resource class FruitWithDtoResource like this:
@Path("fruits-dto")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FruitWithDtoResource implements ReadableByIdWithDto<Fruit, FruitDto>, ReadablePaginatedByRangeWithDto<Fruit, FruitDto> {
@Override
public Class<Fruit> getPanacheEntityType() {return Fruit.class;}
@Override
public Class<FruitDto> getDtoType() {return FruitDto.class;}
}so, comparing quickly with the above FruitResource class:
ReadableById<Fruit>has been replaced byReadableByIdWithDto<Fruit, FruitDto>ReadablePaginatedByRange<Fruit>has been replaced byReadablePaginatedByRangeWithDto<Fruit, FruitDto>getDtoType()method has been added and implemented
With just this code, we have all the same read endpoint described in the above Reads endpoint paragraph.
As above, we can add a method to create an entity using a DTO implementing the CreatableWithDto<E extends PanacheEntity, D> interface and hence providing the requested implementation of the getMapper() method.
The FruitWithDtoResource will become:
@Path("fruits-dto")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FruitWithDtoResource implements ReadableByIdWithDto<Fruit, FruitDto>, ReadablePaginatedByRangeWithDto<Fruit, FruitDto>,
CreatableWithDto<Fruit, FruitDto> {
@Override
public Class<Fruit> getPanacheEntityType() {return Fruit.class;}
@Override
public Class<FruitDto> getDtoType() {return FruitDto.class;}
@Override
public Mapper<Fruit, FruitDto> getMapper() {
return new Mapper<Fruit, FruitDto>() {
@Override
public Fruit map(FruitDto source, Fruit target) {
if (target == null) target = new Fruit();
target.name = source.name;
return target;
}
};
}
}FruitDto needs a change as well to annotate the only available constructor (with one input parameter) with @JsonbCreator annotation since otherwise during deserialization the no-arg constructor is searched and since it's not available, the create endpoints will fail.
If you're wondering why not just adding the no-arg constructor, the reason is that to use the same bean in the read operations as a projection, the bean must have just one single constructor will all the fields to have Hibernate to create the right select statement for the "projected" query (ref. Simplified Hibernate ORM with Panache).
So FruitDto is:
@RegisterForReflection
public class FruitDto {
public String name;
@JsonbCreator
public FruitDto(String name) {
this.name = name;
}
}Let's say that in this case using the FruitDto doesn't make a lot of sense because it will add Fruits without description but this example is provided just to show how to use DTO and then it's left to the user when it makes sense to use Creatable or CreatableWithDto interfaces.
There's nothing about this since there's no DTO involved in deleting an entity: make a DELETE request to the /fruit/{id} endpoint will delete the Fruit with provided id.
So use Deletable<E extends PanacheEntity> interface and let the FruitWithDtoResource become:
@Path("fruits-dto")
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class FruitWithDtoResource implements ReadableByIdWithDto<Fruit, FruitDto>, ReadablePaginatedByRangeWithDto<Fruit, FruitDto>,
CreatableWithDto<Fruit, FruitDto>, Deletable<Fruit> {
@Override
public Class<Fruit> getPanacheEntityType() {return Fruit.class;}
@Override
public Class<FruitDto> getDtoType() {return FruitDto.class;}
@Override
public Mapper<Fruit, FruitDto> getMapper() {
return new Mapper<Fruit, FruitDto>() {
@Override
public Fruit map(FruitDto source, Fruit target) {
if (target == null) target = new Fruit();
target.name = source.name;
return target;
}
};
}
}