A few days ago, I created a post to describe Reactive programming supports in Spring 5 and its subprojects, all codes of this article are updated the latest Spring 5 RELEASE, check spring-reactive-sample under my Github account.
In this post, I will create a simple blog system, which includes the following features:
- A user can sign in and sign out.
- An authenticated user can create a post.
- An authenticated user can update a post.
- Only the user who has ADMIN role can delete a post.
- All users(including anonymous users) can view post list and post details.
- An authenticated user can add his comments to a certain post.
The backend will be built with the latest Spring 5 reactive stack, including:
- Spring Boot 2.0, at the moment the latest version is 2.0.0.M7
- Spring Data MongoDB supports reactive operations for MongoDB
- Spring Session adds reactive support for
WebSession
- Spring Security 5 aligns with Spring 5 reactive stack
The frontend UI is an Angular based SPA and it will be generated by Angular CLI.
Get the source codes from my Github account.
Make sure you have already installed the following software.
- Oracle Java 8 SDK
- Apache Maven
- Gradle
- Your favorite IDE, including :
- NetBeans IDE
- Eclipse IDE (or Eclipse based IDE, Spring ToolSuite is highly recommended)
- Intellij IDEA
The quickest way to start a Spring Boot based is using the Spring initializr.
Open browser, and go to http://start.spring.io.
Choose the following stack:
- Use the default Maven as building tool, and Java as programming language
- Set Spring Boot version to 2.0.0.M7
- In the search dependencies box, search reactive, select Reactive Web, Reactive MongoDB in the search results, and then add Security, Session, Lombok in the same way etc.
Hint Generate Project button or press ALT+ENTER keys to download the generated codes.
Extract files into your local system, and import it into your favorite IDE.
Let's start with cooking the Post
APIs, the expected APIs are listed below.
URI | request | response | description |
---|---|---|---|
/posts | GET | [{id:'1', title:'title'}, {id:'2', title:'title 2'}] | Get all posts |
/posts | POST {title:'title',content:'content'} | {id:'1', title:'title',content:'content'} | Create a new post |
/posts/{id} | GET | {id:'1', title:'title',content:'content'} | Get a post by id |
/posts/{id} | PUT {title:'title',content:'content'} | {id:'1', title:'title',content:'content'} | Update specific post by id |
/posts/{id} | DELETE | no content | Delete a post by id |
Create a Post
POJO class. Add Spring Data MongoDB specific @Document
annotation on this class to indicate it is a MongoDB document.
@Document
@Data
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
class Post implements Serializable {
@Id
private String id;
@NotBlank
private String title;
@NotBlank
private String content;
}
To remove getters and setters, toString
, equals
, hashCode
methods from Post
and make the source code looks clearly, let's use Lombok specific annotations to generate these required facilities at compile time via annotation processor tooling support.
Create a PostRepository
interface for Post
document. Make sure it is extended from ReactiveMongoRepository
, which is the reactive variant of MongoRepository
interface and it is ready for reactive operations.
interface PostRepository extends ReactiveMongoRepository<Post, String> {
}
Let's create a PostController
class to expose RESTful APIs.
@RestController()
@RequestMapping(value = "/posts")
class PostController {
private final PostRepository posts;
public PostController(PostRepository posts) {
this.posts = posts;
}
@GetMapping("")
public Flux<Post> all() {
return this.posts.findAll();
}
@PostMapping("")
public Mono<Post> create(@RequestBody Post post) {
return this.posts.save(post);
}
@GetMapping("/{id}")
public Mono<Post> get(@PathVariable("id") String id) {
return this.posts.findById(id);
}
@PutMapping("/{id}")
public Mono<Post> update(@PathVariable("id") String id, @RequestBody Post post) {
return this.posts.findById(id)
.map(p -> {
p.setTitle(post.getTitle());
p.setContent(post.getContent());
return p;
})
.flatMap(p -> this.posts.save(p));
}
@DeleteMapping("/{id}")
@ResponseStatus(NO_CONTENT)
public Mono<Void> delete(@PathVariable("id") String id) {
return this.posts.deleteById(id);
}
}
It is very similar with traditional Servlet based @RestController
, the difference is here we use Reactor specific Mono
and Flux
as return result type.
NOTE: Spring 5 also provides functional programming experience, check spring-reactive-sample for more details.
Create a CommandLineRunner
component to insert some dummy data when the application is started.
@Component
@Slf4j
class DataInitializer implements CommandLineRunner {
private final PostRepository posts;
public DataInitializer(PostRepository posts) {
this.posts = posts;
}
@Override
public void run(String[] args) {
log.info("start data initialization ...");
this.posts
.deleteAll()
.thenMany(
Flux
.just("Post one", "Post two")
.flatMap(
title -> this.posts.save(Post.builder().title(title).content("content of " + title).build())
)
)
.log()
.subscribe(
null,
null,
() -> log.info("done initialization...")
);
}
}
Now we are almost ready to start up the application. But before this you have to make sure there is a running MongoDB instance.
I have prepared docker-compose.yml in the root folder, utilize it, we can bootstrap a MongoDB instance quickly in Docker container.
Open your terminal, and switch to the root folder of this project, run the following command to start a MongoDB service.
docker-compose up mongodb
NOTE: You can also install a MongoDB server in your local system instead.
Now try to execute mvn spring-boot:run
to start up the application,or click Run action in the project context menu in your IDE.
When the application is started, open another terminal window, use curl
or httpie
to test the APIs.
curl http://localhost:8080/posts
You should see some result like following.
curl http://localhost:8080/posts
[{"id":"5a584469a5f5c7261cb548e2","title":"Post two","content":"content of Post two"},{"id":"5a584469a5f5c7261cb548e1","title":"Post one","content":"content of Post one"}]
Great! it works.
Next let's add access control rules to protect the Post
APIs.
Like traditional Servlet based MVC, when spring-security-web
is existed in the classpath of a webflux application, Spring Boot will configure security automatically.
To customize security configuration, create a standalone @Configuration
class.
@Configuration
class SecurityConfig {
@Bean
SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception {
//@formatter:off
return http
.csrf().disable()
.and()
.authorizeExchange()
.pathMatchers(HttpMethod.GET, "/posts/**").permitAll()
.pathMatchers(HttpMethod.DELETE, "/posts/**").hasRole("ADMIN")
.pathMatchers("/posts/**").authenticated()
.pathMatchers("/user").authenticated()
.pathMatchers("/users/{user}/**").access(this::currentUserMatchesPath)
.anyExchange().permitAll()
.and()
.build();
//@formatter:on
}
private Mono<AuthorizationDecision> currentUserMatchesPath(Mono<Authentication> authentication, AuthorizationContext context) {
return authentication
.map(a -> context.getVariables().get("user").equals(a.getName()))
.map(AuthorizationDecision::new);
}
@Bean
public ReactiveUserDetailsService userDetailsService(UserRepository users) {
return (username) -> users.findByUsername(username)
.map(u -> User.withUsername(u.getUsername())
.password(u.getPassword())
.authorities(u.getRoles().toArray(new String[0]))
.accountExpired(!u.isActive())
.credentialsExpired(!u.isActive())
.disabled(!u.isActive())
.accountLocked(!u.isActive())
.build()
);
}
}
In this configuration, we defined a SecurityWebFilterChain
bean to change default security path matching rules as expected. And we have to declare a ReactiveUserDetailsService
bean to customize the UserDetailsService
, eg. fetching users from our MongoDB.
Create the required User
document and UserRepository
interface.
@Data
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document
class User {
@Id
private String id;
private String username;
@JsonIgnore
private String password;
@Email
private String email;
@Builder.Default()
private boolean active = true;
@Builder.Default()
private List<String> roles = new ArrayList<>();
}
In UserRepository
, add a new findByUsername
method to query user by username, it returns Mono<User>
.
public interface UserRepository extends ReactiveMongoRepository<User, String> {
Mono<User> findByUsername(String username);
}
Now let's handle authentication.
In the real world, in order to protect REST APIs, token based authentication is mostly used.
Spring Session provides a simple strategy to expose the session id in HTTP response headers and check validation of session id in HTTP request headers, combined with Spring Security, if the token(session id) is invalid, return a 401 to the client.
Currently, Spring Session provides reactive support for Redis and MongoDB. In this project, we use MongoDB as an example.
Add spring-session-data-mongodb
into the project classpath.
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-mongodb</artifactId>
</dependency>
To force HTTP BASIC authentication to manage sessions by MongoDB, add the following configuration in SecurityConfig
.
http
.csrf().disable()
.httpBasic().securityContextRepository(new WebSessionServerSecurityContextRepository())
.and()
...
Declare WebSessionIdResolver
bean to resolve session id by HTTP header instead of cookie.
@Configuration
@EnableMongoWebSession
class SessionConfig {
@Bean
public WebSessionIdResolver webSessionIdResolver() {
HeaderWebSessionIdResolver resolver = new HeaderWebSessionIdResolver();
resolver.setHeaderName("X-AUTH-TOKEN");
return resolver;
}
}
Expose current user by REST APIs.
@GetMapping("/user")
public Mono<Map> current(@AuthenticationPrincipal Mono<Principal> principal) {
return principal
.map( user -> {
Map<String, Object> map = new HashMap<>();
map.put("name", user.getName());
map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
.getAuthorities()));
return map;
});
}
When the user is authenticated, the user info can be fetched from an injected Principal
.
Add the initial users in the DataInitializer
bean.
this.users
.deleteAll()
.thenMany(
Flux
.just("user", "admin")
.flatMap(
username -> {
List<String> roles = "user".equals(username)
? Arrays.asList("ROLE_USER")
: Arrays.asList("ROLE_USER", "ROLE_ADMIN");
User user = User.builder()
.roles(roles)
.username(username)
.password(passwordEncoder.encode("password"))
.email(username + "@example.com")
.build();
return this.users.save(user);
}
)
)
.log()
.subscribe(
null,
null,
() -> log.info("done users initialization...")
);
Restart the application and have a try.
curl -v http://localhost:8080/auth/user -u user:password
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
* Server auth using Basic with user 'user'
> GET /auth/user HTTP/1.1
> Host: localhost:8080
> Authorization: Basic dXNlcjpwYXNzd29yZA==
> User-Agent: curl/7.57.0
> Accept: */*
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/json;charset=UTF-8
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
< X-AUTH-TOKEN: 39af0166-0f5f-4a1a-a955-21340b0b31b1
<
{"roles":["ROLE_USER"],"name":"user"}* Connection #0 to host localhost left intact
As you see, there is a X-AUTH-TOKEN
header in the response headers when the authentication is successful.
Try to add X-AUTH-TOKEN
to HTTP request headers and access the protected APIs.
curl -v http://localhost:8080/auth/user -H "X-AUTH-TOKEN: 39af0166-0f5f-4a1a-a955-21340b0b31b1"
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /auth/user HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.57.0
> Accept: */*
> X-AUTH-TOKEN: 39af0166-0f5f-4a1a-a955-21340b0b31b1
>
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/json;charset=UTF-8
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
<
{"roles":["ROLE_USER"],"name":"user"}* Connection #0 to host localhost left intact
Yeah, it works as expected. You can try more example on other protected path, such as creating and updating posts.
For traditional Servlet stack, we can use @ExceptionHandler
to handle the exceptions and convert them into HTTP friendly messages.
In a webflux based application, we can declare a WebExceptionHanlder
bean to archive this purpose.
For example, if there is no posts found by id, throw a PostNotFoundException
, and finally convert it to a 404 error to HTTP client.
Declare PostNotFoundExcpetion
as a RuntimeException
.
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(String id) {
super("Post:" + id +" is not found.");
}
}
Throw an PostNotFoundException
when it is not found, eg. the get
method in PostController
.
@GetMapping("/{id}")
public Mono<Post> get(@PathVariable("id") String id) {
return this.posts.findById(id).switchIfEmpty(Mono.error(new PostNotFoundException(id)));
}
Declare a WebExceptionHanlder
bean to handle PostNotFoundException
.
@Component
@Order(-2)
@Slf4j
public class RestExceptionHandler implements WebExceptionHandler {
private ObjectMapper objectMapper;
public RestExceptionHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
if (ex instanceof PostNotFoundException) {
exchange.getResponse().setStatusCode(HttpStatus.NOT_FOUND);
// marks the response as complete and forbids writing to it
return exchange.getResponse().setComplete();
}
return Mono.error(ex);
}
}
For the bean validations, we can convert the WebExchangeBindException
to a UnprocessableEntity
error code to the client, please check the complete codes of RestExceptionHandler
for more details.
In the backend codes, I have added some features mentioned at the beginning of this post, such as comment endpoints, and also tried to add pagination, and data auditing feature(when it is ready), check the source codes and research it yourself.
Next let's try to build a simple Angular frontend application to shake hands with our backend APIs.
Make sure you have already installed the following software.
- The latest NodeJS, I used NodeJS 9.4.0 at the moment.
- Your favorite code editors, VS Code, Atom Editor, Intellij WebStorm etc.
Install Angular CLI globally.
npm install -g @angular/cli
Make sure it is installed correctly.
> ng -v
_ _ ____ _ ___
/ \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
/ △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
/ ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
|___/
Angular CLI: 1.6.3
Node: 9.4.0
OS: win32 x64
Angular:
Open your terminal, execute the following command to generate an Angular project.
ng new client
Install Angular Material, Angular FlexLayout from Angular team.
npm install --save @angular/material @angular/cdk @angular/flex-layout
Some Material modules depend on @angular/animations
. Import BrowserAnimationsModule
in AppModule
.
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
@NgModule({
...
imports: [BrowserAnimationsModule],
...
})
export class AppModule { }
Open polyfills.ts, uncomment the following line,
import 'web-animations-js';
Then install web-animations polyfill.
npm install --save web-animations-js
To enable gesture in some modules of Angular Material, install hammerjs
.
npm install --save hammerjs
Import it in polyfills.ts.
import 'hammerjs';
Follow the official Angular Style Guide, use ng
command to generate expected modules and components. No worries at this moment, you can enrich them later.
ng g module home --routing=true
ng g module auth --routing=true
ng g module post --routing=true
ng g module user --routing=true
ng g module core
ng g module shared
ng g c home --module home
ng g c post/post-list --module post
ng g c post/post-form --module post
ng g c post/new-post --module post
ng g c post/edit-post --module post
ng g c post/post-details --module post
ng g c user/profile --module user
ng g c auth/signin --module auth
To simplify the coding work, I just posted my former Angular 2.x work to this project, more about the Angular development steps, check the wiki pages.
Angular 4.x introduced a new HttpClientModule
(located in @angular/common/http
) instead of the original HttpModule
( located in @angular/http
). I updated the original codes to use HttpClientModule
to interact with REST APIs in this project.
Let's have a look at the refreshed PostService
. Here we use the new HttpClient
to replace the legacy Http
, the usage of HttpClient
is similar with Http
, the most difference is its methods return an Observable<T>
by default, there are varied usage of the methods in HttpClient
, check the official docs about HttpClient for more details.
const apiUrl = environment.baseApiUrl + '/posts';
@Injectable()
export class PostService {
constructor(private http: HttpClient) { }
getPosts(term?: any): Observable<any> {
const params: HttpParams = new HttpParams();
Object.keys(term).map(key => {
if (term[key]) { params.set(key, term[key]); }
});
return this.http.get(`${apiUrl}`, { params });
}
getPost(id: string): Observable<any> {
return this.http.get(`${apiUrl}/${id}`);
}
savePost(data: Post) {
console.log('saving post:' + data);
return this.http.post(`${apiUrl}`, data);
}
updatePost(id: string, data: Post) {
console.log('updating post:' + data);
return this.http.put(`${apiUrl}/${id}`, data);
}
deletePost(id: string) {
console.log('delete post by id:' + id);
return this.http.delete(`${apiUrl}/${id}`);
}
saveComment(id: string, data: Comment) {
return this.http.post(`${apiUrl}/${id}/comments`, data);
}
getCommentsOfPost(id: string): Observable<any> {
return this.http.get(`${apiUrl}/${id}/comments`);
}
}
Another awesome feature of the HttpClientModule
is it added the long-awaited HttpInterceptor
officially. We do not need @covalent/http
to get interceptor support now.
The following is an example of HttpInterceptor
to handle authentication globally.
const TOKEN_HEADER_KEY = 'X-AUTH-TOKEN';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private token: TokenStorage, private router: Router) { }
intercept(req: HttpRequest<any>, next: HttpHandler):
Observable<HttpSentEvent | HttpHeaderResponse | HttpProgressEvent | HttpResponse<any> | HttpUserEvent<any>> {
if (this.token.get()) {
console.log('set token in header ::' + this.token.get());
req.headers.set(TOKEN_HEADER_KEY, this.token.get());
}
return next.handle(req).do(
(event: HttpEvent<any>) => {
if (event instanceof HttpResponse) {
const token = event.headers.get(TOKEN_HEADER_KEY);
if (token) {
console.log('saving token ::' + token);
this.token.save(token);
}
}
},
(err: any) => {
if (err instanceof HttpErrorResponse) {
console.log(err);
console.log('req url :: ' + req.url);
if (!req.url.endsWith('/auth/user') && err.status === 401) {
this.router.navigate(['', 'auth', 'signin']);
}
}
}
);
}
}
Compare to the HTTP interceptor provided in @covalent/http
, the official HttpInterceptor
is more flexible, and it adopts the nodejs expressjs middleware concept.
Check the complete source codes from my github, and follow the README to play the codes in your local system.