Building RESTful API with Micronaut Data JPA¶
In this section, we are building a RESTful API backend application with Micronaut Data JPA. If you have some experience with Spring Boot and Spring Data JPA, it is easy to update yourself to use Micronaut to archive the same purpose.
Preparing Project Skeleton¶
Follow Generating Project Skeleton guide and generate a new project skeleton.
Generating Project¶
Open your browser and navigate to Micronaut Launch, fill the following fields in the Micronaut Launch page, leave other as it is.
- Java version: 17
- Language: Java
- Build tool: Gradle
- Test framework: Junit
- Included Features: lombok, data hibernate jpa, assertj, postgres, testcontainers etc.
Import the generated project into your IDE.
Configuring Database¶
In this project, we are using Postgres as a database. You can download a copy of Postgres and install it in your local system, then create a new database to serve the application.
Open src/main/resources/application.yml, there is a default data source is configured by default.
Change the properties according to your environment.
datasources:
default:
url: jdbc:postgresql://localhost:5432/blogdb
driverClassName: org.postgresql.Driver
username: user
password: password
schema-generate: CREATE_DROP
dialect: POSTGRES
jpa.default.properties.hibernate.hbm2ddl.auto: update
Alternatively, you can serve a Postgres database in Docker quickly.
Serving Postgres Database in Docker¶
Create a docker-compose.yml file, and define a postgres service as the following.
version: '3.7' # specify docker-compose version
services:
postgres:
image: postgres
ports:
- "5432:5432"
restart: always
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: blogdb
POSTGRES_USER: user
volumes:
- ./data:/var/lib/postgresql
- ./pg-initdb.d:/docker-entrypoint-initdb.d
Then start the Postgres database instance in the Docker container.
docker compose up postgres
Wait for a while, it will prepare a Postgres database from Docker image and start it.
Data Accessing using Micronaut Data JPA¶
When generating the project, we have add a data-jpa feature into the project dependencies, which enables Micronaut Data JPA support.
Similar to Spring Data architecture, Micronaut Data also provides a common abstraction for the basic data operations, For example, there is a GenericRepository
interface to indicate it is a Repository
for JPA entities, and its sub interfaces, such as CrudRepository
and PagableRepsoitory
includes more operations, such as save, retrieve, update, delete, and bulk updates, and return pageable results for large amount of results.
Micronaut Data JPA has similar APIs with Spring Data JPA, it also contains a pragmatic criteria builder to execute query via custom Specificaiton
.
Currently, Micronaut Data project only supports relational databases, it includes 3 modules: Data JPA, Data JDBC, Data R2DBC, read the official documentation for more details.
In this post, we focus on the Micronaut Data JPA.
Next, we will create a JPA entity and create a Repository for the entity, then create a Controller to produce RESTful API endpoints for it.
Creating JPA Entity¶
I have used a simple blog application in the past years to demonstrate different frameworks. In this post, I will reuse the blog application concept.
It includes two JPA entities, Post
and Comment
, it is a one-to-many relation.
Firstly let's have a look at the Post
entity class.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "posts")
public class Post implements Serializable {
@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")
UUID id;
String title;
String content;
@Builder.Default
Status status = Status.DRAFT;
@Builder.Default
LocalDateTime createdAt = LocalDateTime.now();
@OneToMany(cascade = {CascadeType.ALL}, orphanRemoval = true, mappedBy = "post")
@Builder.Default
@OrderColumn(name = "comment_idx")
List<Comment> comments = new ArrayList<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Post post = (Post) o;
return getTitle().equals(post.getTitle());
}
@Override
public int hashCode() {
return Objects.hash(getTitle());
}
@Override
public String toString() {
return "Post{" +
"id=" + id +
", title='" + title + '\'' +
", content='" + content + '\'' +
", status=" + status +
", createdAt=" + createdAt +
'}';
}
}
A JPA entity should be annotated with an @Entity
annotation, optionally adding a @Table
to specify the table metadata.
An entity should include a none-arguments constructor.
An entity should have an identifier field with an @Id
annotation. To assign a value to the id field automatically, you can select a strategy type by specifying the strategy
attribute of the @GgeneratedValue
annotation, it could be AUTO
, IDENTITY
, SEQUENCE
and TABLE
, else you can define your own generator by setting the value of generator
attribute. In the above Post
entity, we use the Hibernate built-in uuid2
strategy to generate a UUID value and assign it to the id field before persisting.
With the annotations from the Lombok project, eg. @Getter
, @Setter
, @NoArgsConstructor
, @AllArgsConstructor
and @Builder
, it helps you to erase the tedious methods for getting and setting the Java Bean properties, and keep your codes clean. When building the project, Lombok annotation processor will participate into the compiling progress and generate getters and setters, varied constructors, and a builder class used to create an entity.
Use IDE to generate equals
and hasCode
according to the business requirements.
Be careful of using Lombok @Data to generate all facilities, especially in the entity in an inheritance structure or containing custom
equals
andhasCode
to identify an entity.
Similar to the Post
entity, create another entity named Comment
.
// Comment entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "comments")
public class Comment implements Serializable {
@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")
private UUID id;
@ManyToOne
@JoinColumn(name = "post_id")
private Post post;
private String content;
@Builder.Default
@Column(name = "created_at")
private LocalDateTime createdAt = LocalDateTime.now();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Comment comment = (Comment) o;
return getContent().equals(comment.getContent());
}
@Override
public int hashCode() {
return Objects.hash(getContent());
}
@Override
public String toString() {
return "Comment{" +
"id=" + id +
", content='" + content + '\'' +
", createdAt=" + createdAt +
'}';
}
}
The Post
and Comment
association is a simple bi-direction one-to-many relation.
On the one side, aka inPost
entity, a @OneToMany
annotation is added to the comments which is a List
, the cascade
attribute defines the behiavor to process the many side when performing a persist, merge, delete operation on the one side, here we use ALL
to setup all cascade rules will be applied. The orphanRemoval=true
setting tells the persistence context to clear the Comment
orphans when deleting a Post
. The @OrderColumn
will persist the inserted position of comments.
On the many side aka in the Comment
entity, a @ManyToOne
annotation is added on the post
field. The @JoinColumn
set the column which stores the foreign key constraints by the Post
id.
Besides one-to-many relation (@OneToMany
and @ManyToOne
), JPA specification includes two other relations, aka one-to-one (@OneToOne
) and many-to-many (@ManyToMany
).
We've just demonstrated a simple entity association case here, it is a bi-direction one-to-many relation. Please note, one-to-one, one-to-many, and many-to-many can be set as single direction, and you can use a secondary table as connecting table in the one-to-one and one-to-many relations.
We can not cover every details of JPA specifiction here. If you are new to JPA, Java persistence with Hibernate is a good book to start your JPA journey.
Creating Repository¶
Create a Repository
for the Post
entity.
@Repository
public interface PostRepository extends JpaRepository<Post, UUID>{
}
The JpaRepository
overrides some existing methods in the parent CrudRepository
and PageableRepository
, and adds some JPA-specific methods, such as flush
used to flush the persistence context by force.
Note, in Micronaut Data, a Repository bean must be annotated with a @Repository
annotation. In a multi-datasource environment, you can specify the datasource identifier name to ensure this repository uses the certain DataSource to connect to the database.
For example, @Repository("orders")
to connect the orders datasource defined in the application.yml configuration.
datasources:
orders:
....
Similarly, create a Repository
for the Comment
entity.
@Repository
public interface CommentRepository extends JpaRepository<Comment, UUID> {
List<Comment> findByPost(Post post);
}
The findByPost
is used to filter comments by a specific post argument. Similar to Spring Data JPA, Micronaut Data support fluent query methods derived from property expression.
Similar to Spring Data, Micronaut Data provides pagination for a long query result, the findAll
accepts a Pageable
parameter and returns a Page
result.
Micronaut Data also includes a Specification
to adopt JPA Criteria APIs for the complex type-safe query.
Query by Specification¶
Change PostRepository
, add JpaSpecificationExecutor<Post>
to extends list.
@Repository
public interface PostRepository extends JpaRepository<Post, UUID>, JpaSpecificationExecutor<Post> {
}
The JpaSpecificationExecutor
provides extra methods to accept a Specification
as parameters.
Create a specific PostSpecifications
to group all specifications for querying posts. Currently, only add one for query by keyword and status.
public class PostSpecifications {
private PostSpecifications(){
// forbid to instantiate
}
public static Specification<Post> filterByKeywordAndStatus(
final String keyword,
final Status status
) {
return (Root<Post> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.hasText(keyword)) {
predicates.add(
cb.or(
cb.like(root.get(Post_.title), "%" + keyword + "%"),
cb.like(root.get(Post_.content), "%" + keyword + "%")
)
);
}
if (status != null) {
predicates.add(cb.equal(root.get(Post_.status), status));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
The filterByKeywordAndStatus
specification provides optional keyword and status to filter the posts. The Post_
is a metadata class generated by Hibernate metadata generating tools.
Add the following annotationProcessor
in the project dependencies.
annotationProcessor('org.hibernate:hibernate-jpamodelgen:5.6.5.Final')
For those who are familiar with JPA EntityManager
and prefer to use literal query string to handle complex queries, In Micronaut Data, it is easy to use them in the Repository directly.
Custom Query with EntityManager¶
Change the Repository interface to an abstract class, and inject an EntityManager
, then you can use it freely in your custom methods.
@Repository()
@RequiredArgsConstructor
public abstract class PostRepository implements JpaRepository<Post, UUID>, JpaSpecificationExecutor<Post> {
private final EntityManager entityManager;
public List<Post> findAllByTitleContains(String title) {
return entityManager.createQuery("FROM Post AS p WHERE p.title like :title", Post.class)
.setParameter("title", "%" + title + "%")
.getResultList();
}
}
In the above findAllByTitleContains
method, it uses EntityManager
to execute a custom literal query.
In contrast, to use EntityManager
in your custom queries, in Spring Data JPA, you need to create a PostRepositoryCustom
interface and a PostRepositoryImpl
implementation class. Micronaut Data simplifies the work.
Initializing Sample Data¶
Add a DataInitializer
bean to initialize some sample data.
@Singleton
@RequiredArgsConstructor
@Slf4j
public class DataInitializer implements ApplicationEventListener<ApplicationStartupEvent> {
private final PostRepository posts;
private final TransactionOperations<?> tx;
@Override
public void onApplicationEvent(ApplicationStartupEvent event) {
log.info("initializing sample data...");
var data = List.of(Post.builder().title("Getting started wit Micronaut").content("test").build(),
Post.builder().title("Getting started wit Micronaut: part 2").content("test").build());
tx.executeWrite(status -> {
this.posts.deleteAll();
this.posts.saveAll(data);
return null;
});
tx.executeRead(status -> {
this.posts.findAll().forEach(p -> log.info("saved post: {}", p));
return null;
});
log.info("data initialization is done...");
}
}
In the above codes, use TransactionOperations
to wrap a series of operations into a transaction to ensure it happens before the successor operations.
Exposing RESTful API¶
Following the REST conventions and Richardson Mature Model, we design a series of HTTP API endpoints that satisfies the Richardson Mature Model Level 2.
URI | HTTP Method | Description |
---|---|---|
/posts | GET | Get all posts |
/posts/{id} | GET | Get a single Post, if not found return 404 status code. |
/posts | POST | Create a new Post, if successful, return 201 and add newly-created Post URI to the response Location header |
/posts/{id} | PUT | Update the existing Post, return 204. If not found return 404 |
/posts/{id} | DELETE | Delete the existing Post, return 204. If not found return 404 |
/posts/{id}/comments | GET | Get all comments of the specified Post. |
... |
Similar to Spring WebMVC, Micronaut uses a Controller
to expose Restful APIs.
Creating Controller¶
Create a controller to produce RESTful API.
@Controller("/posts")
@RequiredArgsConstructor(onConstructor_ = {@Inject})
@Validated
public class PostController {
private final PostRepository posts;
private final CommentRepository comments;
@Get(uri = "/", produces = MediaType.APPLICATION_JSON)
public HttpResponse<List<PostSummaryDto>> getAll() {
var body = posts.findAll()
.stream()
.map(p -> new PostSummaryDto(p.getId(), p.getTitle(), p.getCreatedAt()))
.toList();
return ok(body);
}
@Get(uri = "/{id}", produces = MediaType.APPLICATION_JSON)
public HttpResponse<?> getById(@PathVariable UUID id) {
return posts.findById(id)
.map(p -> ok(new PostDetailsDto(p.getId(), p.getTitle(), p.getContent(), p.getStatus(), p.getCreatedAt())))
//.orElseThrow(() -> new PostNotFoundException(id));
.orElseGet(HttpResponse::notFound);
}
}
Usually, a controller is annotated with @Controller
, you can set a base uri
that applies to all methods.
Note, There is no
@RestController
in Micronaut.
The @Get
, @Post
,@Put
, @Delete
annotations are used to handle varied HTTP methods, it is similar to Spring's @GetMapping
, @PostMapping
, etc.
You can set media types using consumes or produces attributes in these annotations to limit the request and response content type, or use extra standalone annotations @Consumes
and @Produces
on the methods.
In the PostController
, we have two methods. The getAll
method serves the /posts endpoint, and the getById(id)
serves the /posts/{id} endpoint.
Testing Endpoints using cURL¶
Startup the application via Gradle command.
./gradlew run
Do not forget to start up Postgres database firstly.
Open a terminal, use curl
command to test the /posts
endpoint.
curl http://localhost:8080/posts
[ {
"id" : "b6fb90ab-2719-498e-a5fd-93d0c7669fdf",
"title" : "Getting started wit Micronaut",
"createdAt" : "2021-10-14T22:00:28.80933"
}, {
"id" : "8c6147ea-8de4-473f-b97d-e211c8e43bac",
"title" : "Getting started wit Micronaut: part 2",
"createdAt" : "2021-10-14T22:00:28.80933"
} ]
curl http://localhost:8080/posts/b6fb90ab-2719-498e-a5fd-93d0c7669fdf
{
"id" : "b6fb90ab-2719-498e-a5fd-93d0c7669fdf",
"title" : "Getting started wit Micronaut",
"content": "test",
"createdAt" : "2021-10-14T22:00:28.80933"
}
Micronaut CLI provides commands to generate skeleton for controller, repository, bean, test, etc. Run
mn --help
in the project root folder to get all available commands.
Handling Exception¶
In the above PostController
, if there is no posts found for the given post id, it returns a 404 HTTP status directly. In a real-world application, we can use a custom exception to envelope the exception case.
Like Spring WebMVC, Micronaut provides similar exception handling mechanism.
For example, create an PostNotFoundException
to stand for the case if the post was not found by a specified id.
Create a PostNotFoundException
class.
public class PostNotFoundException extends RuntimeException
public PostNotFoundException(UUID id) {
super("Post[id=" + id + "] was not found");
}
}
In the PostController
, throw this exception in the Optional.orElseThrow
block.
@Get(uri = "/{id}", produces = MediaType.APPLICATION_JSON)
public HttpResponse<?> getById(@PathVariable UUID id) {
return posts.findById(id)
.map(p -> ok(new PostDetailsDto(p.getId(), p.getTitle(), p.getContent(), p.getStatus(), p.getCreatedAt())))
.orElseThrow(() -> new PostNotFoundException(id));
}
Add a PostNotFoundExceptionHandler
to handle PostNotFoundException
.
@Produces
@Singleton
@Requires(classes = { PostNotFoundException.class})
@RequiredArgsConstructor
public class PostNotFoundExceptionHandler implements ExceptionHandler<PostNotFoundException, HttpResponse<?>> {
private final ErrorResponseProcessor<?> errorResponseProcessor;
@Override
public HttpResponse<?> handle(HttpRequest request, PostNotFoundException exception) {
return errorResponseProcessor.processResponse(
ErrorContext.builder(request)
.cause(exception)
.errorMessage(exception.getMessage())
.build(),
HttpResponse.notFound()
);
}
}
Open a terminal, use curl
command to test the /posts/{id}
endpoint with a non-existing id.
# curl http://localhost:8080/posts/b6fb90ab-2719-498e-a5fd-93d0c7669fdf -v
> GET /posts/b6fb90ab-2719-498e-a5fd-93d0c7669fdf HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Content-Type: application/json
< date: Mon, 25 Oct 2021 07:02:01 GMT
< content-length: 301
< connection: keep-alive
<
{
"message" : "Not Found",
"_links" : {
"self" : {
"href" : "/posts/b6fb90ab-2719-498e-a5fd-93d0c7669fdf",
"templated" : false
}
},
"_embedded" : {
"errors" : [ {
"message" : "Post[id=b6fb90ab-2719-498e-a5fd-93d0c7669fdf] was not found"
} ]
}
}
Handling Pagination¶
Change the getAll
method of PostController
to the following.
@Get(uri = "/", produces = MediaType.APPLICATION_JSON)
@Transactional
public HttpResponse<Page<PostSummaryDto>> getAll(@QueryValue(defaultValue = "") String q,
@QueryValue(defaultValue = "") String status,
@QueryValue(defaultValue = "0") int page,
@QueryValue(defaultValue = "10") int size) {
var pageable = Pageable.from(page, size, Sort.of(Sort.Order.desc("createdAt")));
var postStatus = StringUtils.hasText(status) ? com.example.domain.Status.valueOf(status) : null;
var data = this.posts.findAll(PostSpecifications.filterByKeywordAndStatus(q, postStatus), pageable);
var body = data.map(p -> new PostSummaryDto(p.getId(), p.getTitle(), p.getCreatedAt()));
return ok(body);
}
All the query parameters are optional.
Let's use curl
to test the /posts endpoint again.
# curl http://localhost:8080/posts
{
"content" : [ {
"id" : "c9ec963d-2df5-4d65-bfbe-5a0d4cb14ca6",
"title" : "Getting started wit Micronaut",
"createdAt" : "2021-10-25T16:35:03.732951"
}, {
"id" : "0a79185c-5981-4301-86d1-c266b26b4980",
"title" : "Getting started wit Micronaut: part 2",
"createdAt" : "2021-10-25T16:35:03.732951"
} ],
"pageable" : {
"number" : 0,
"sort" : {
"orderBy" : [ {
"property" : "createdAt",
"direction" : "DESC",
"ignoreCase" : false,
"ascending" : false
} ],
"sorted" : true
},
"size" : 10,
"offset" : 0,
"sorted" : true,
"unpaged" : false
},
"totalSize" : 2,
"totalPages" : 1,
"empty" : false,
"size" : 10,
"offset" : 0,
"numberOfElements" : 2,
"pageNumber" : 0
}
The Page
JSON results look a little tedious, let's customize a Jackson JsonSerializer
to clean up the JSON data string.
Customizing JsonSerializer¶
Create a PageJsonSerializer
to process the Page
object as you expected.
@Singleton
public class PageJsonSerializer extends JsonSerializer<Page<?>> {
@Override
public void serialize(Page<?> value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeStartObject();
gen.writeNumberField("pageNumber", value.getPageNumber());
if (value.getNumberOfElements() != value.getSize()) {
//only display it in the last page when number of elements is not equal to page size.
gen.writeNumberField("numberOfElements", value.getNumberOfElements());
}
gen.writeNumberField("size", value.getSize());
gen.writeNumberField("totalPages", value.getTotalPages());
gen.writeNumberField("totalSize", value.getTotalSize());
gen.writeObjectField("content", value.getContent());
gen.writeEndObject();
}
}
Run the application, and hint /posts endpoint again.
# curl http://localhost:8080/posts
{
"pageNumber" : 0,
"numberOfElements" : 2,
"size" : 10,
"totalPages" : 1,
"totalSize" : 2,
"content" : [ {
"id" : "53fb77d5-4159-4a80-bab9-c76d9a535b36",
"title" : "Getting started wit Micronaut",
"createdAt" : "2021-10-25T16:47:05.545594"
}, {
"id" : "aa02fd49-0c24-4f12-b204-2e48213c7a1e",
"title" : "Getting started wit Micronaut: part 2",
"createdAt" : "2021-10-25T16:47:05.545594"
} ]
}
Creating Post¶
We have discussed how to query posts by keyword and get a single post by id, in this section, we are moving on to creating a new post.
According to the REST convention, we use a POST HTTP method to send a request on endpoint /posts, it accepts JSON data as the request body.
@io.micronaut.http.annotation.Post(uri = "/", consumes = MediaType.APPLICATION_JSON)
@Transactional
public HttpResponse<Void> create(@Body CreatePostCommand dto) {
var data = Post.builder().title(dto.title()).content(dto.content()).build();
var saved = this.posts.save(data);
return HttpResponse.created(URI.create("/posts/" + saved.getId()));
}
The CreatePostCommand
is a Record class.
@Introspected
public record CreatePostCommand(@NotBlank String title, @NotBlank String content) {
}
The immutable characteristic of a Record is a good match with the DTO pattern. The Introspected
annotation marks Micronaut plugin process the Bean validation at build time.
The request body is deserialized as a POJO by built-in Jackson JsonDesearilizer
s, it is annotated with a @Body
annotation to indicate which target class (CreatePostCommand
) it should be deserialized to. After the post data is saved, set the response header Location
value to the URI of the newly created post.
Run the application, try to add a post via curl
, and then access the newly created post.
# curl -X POST -v -H "Content-Type:application/json" http://localhost:8080/posts -d "{\"title\":\"test title\",\"content\":\"test content\"}"
> POST /posts HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Accept: */*
> Content-Type:application/json
> Content-Length: 47
>
* upload completely sent off: 47 out of 47 bytes
< HTTP/1.1 201 Created
< location: /posts/7db15639-62e3-4d3e-9cf4-f54413502ea6
< date: Mon, 25 Oct 2021 09:07:40 GMT
< connection: keep-alive
< transfer-encoding: chunked
<
# curl http://localhost:8080/posts/7db15639-62e3-4d3e-9cf4-f54413502ea6
{
"id" : "7db15639-62e3-4d3e-9cf4-f54413502ea6",
"title" : "test title",
"content" : "test content",
"status" : "DRAFT",
"createdAt" : "2021-10-25T17:07:40.87621"
}
Validating Request Body¶
Generally, in a real-world application, we have to ensure the request data satisfies requirements. Micronaut has built-in Bean Validation support.
In the above CreatPostCommand
class, add Bean Validation annotations on the fields.
@Introspected
public record CreatePostCommand(@NotBlank String title, @NotBlank String content) {
}
You have to add @Introspected
annotation to let Micronaut plugin to preprocess bean validation annotations at build time, thus Bean Validation works without any Java Reflection APIs at runtime time.
Add a @Validated
on the PostController
class to enable validation in the whole class.
The add a @Valid
on the method argument which presents the request body.
@Validated
public class PostController {
public HttpResponse<Void> create(@Body @Valid CreatePostCommand dto) {...}
//...
}
Open a terminal, try to create a Post with an empty content field.
curl -X POST -v -H "Content-Type:application/json" http://localhost:8080/posts -d "{\"title\":\"test title\",\"content\":\"\"}"
> POST /posts HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.55.1
> Accept: */*
> Content-Type:application/json
> Content-Length: 35
>
* upload completely sent off: 35 out of 35 bytes
< HTTP/1.1 400 Bad Request
< Content-Type: application/json
< date: Mon, 25 Oct 2021 09:23:22 GMT
< content-length: 237
< connection: keep-alive
<
{
"message" : "Bad Request",
"_embedded" : {
"errors" : [ {
"message" : "dto.content: must not be blank"
} ]
},
"_links" : {
"self" : {
"href" : "/posts",
"templated" : false
}
}
}
Updating Existing Post¶
Follow the REST convention, to update an existing post, send a PUT
to the /posts/{id} endpoint. If it is successful, it returns a 204 status. If the post does not exist, return a 404 status code instead.
The request body is the change data(encoded to JSON or XML,etc.) and should be validated by Bean Validator, if the validation fails, it returns 400 status and sends the validation errors to the response body. The validation exception is handled by the Micronaut exception handler automatically.
@Put(uri = "/{id}", consumes = MediaType.APPLICATION_JSON)
@Transactional
public HttpResponse<?> update(@PathVariable UUID id, @Body @Valid UpdatePostCommand dto) {
return posts.findById(id)
.map(p -> {
p.setTitle(dto.title());
p.setContent(dto.content());
this.posts.save(p);
return HttpResponse.noContent();
})
.orElseThrow(() -> new PostNotFoundException(id));
//.orElseGet(HttpResponse::notFound);
}
Similar to CreatePostCommand
, UpdatePostCommand
is a record used to transfer data from reuqest.
@Introspected
public record UpdatePostCommand(@NotBlank String title, @NotBlank String content) {
}
Deleting Post¶
According to REST convention, to delete a single post, send a DELETE
request on /posts/{id}
, if it is successful, returns a 204 status. If the id
does not exist, it returns a 404
instead.
Add the following codes to the PostController
.
@Delete(uri = "/{id}", produces = MediaType.APPLICATION_JSON)
@Transactional
public HttpResponse<?> deleteById(@PathVariable UUID id) {
return posts.findById(id)
.map(p -> {
this.posts.delete(p);
return HttpResponse.noContent();
})
.orElseThrow(() -> new PostNotFoundException(id));
//.orElseGet(HttpResponse::notFound);
}
Processing Subresources¶
In our application, the Comment
resource should be a subresource of Post
resource. When adding comments or fetching comments of a specific Post
, design the following comments APIs.
POST /posts/{id}/comments
, add aComment
resource to a specificPost
.GET /posts/{id}/comments
, get all comments of a certainPost
which id value is the path variableid
.
// nested comments endpoints
@Get(uri = "/{id}/comments", produces = MediaType.APPLICATION_JSON)
public HttpResponse<?> getCommentsByPostId(@PathVariable UUID id) {
return posts.findById(id)
.map(post -> {
var comments = this.comments.findByPost(post);
return ok(comments.stream().map(c -> new CommentDetailsDto(c.getId(), c.getContent(), c.getCreatedAt())));
})
.orElseThrow(() -> new PostNotFoundException(id));
//.orElseGet(HttpResponse::notFound);
}
@io.micronaut.http.annotation.Post(uri = "/{id}/comments", consumes = MediaType.APPLICATION_JSON)
@Transactional
public HttpResponse<?> createComment(@PathVariable UUID id, @Body @Valid CreateCommentCommand dto) {
return posts.findById(id)
.map(post -> {
var data = Comment.builder().content(dto.content()).post(post).build();
post.getComments().add(data);
var saved = this.comments.save(data);
return HttpResponse.created(URI.create("/comments/" + saved.getId()));
})
.orElseThrow(() -> new PostNotFoundException(id));
// .orElseGet(HttpResponse::notFound);
}
Example Codes¶
The example codes are hosted on my GitHub, check hantsy/micronaut-sandbox#post-service.