Spring Boot + React.js

Overview

YouTube

Part 1

React.js

project package

{
  "name": "spring-data-rest-and-reactjs",
  "version": "0.1.0",
  "description": "Demo of ReactJS + Spring Data REST",
  "repository": {
  "type": "git",
  "url": "git@github.com:spring-guides/tut-react-and-spring-data-rest.git"
  },
  "keywords": [
  "rest",
  "hateoas",
  "spring",
  "data",
  "react"
  ],
  "author": "Greg L. Turnquist",
  "license": "Apache-2.0",
  "bugs": {
  "url": "https://github.com/spring-guides/tut-react-and-spring-data-rest/issues"
  },
  "homepage": "https://github.com/spring-guides/tut-react-and-spring-data-rest",
  "dependencies": {
  "react": "^16.5.2",
  "react-dom": "^16.5.2",
  "rest": "^1.8.0"
  },
  "scripts": {
  "watch": "webpack --watch -d --output ./target/classes/static/built/bundle.js"
  },
  "devDependencies": {
  "@babel/core": "^7.1.0",
  "@babel/preset-env": "^7.1.0",
  "@babel/preset-react": "^7.0.0",
  "babel-loader": "^8.0.2",
  "webpack": "^4.19.1",
  "webpack-cli": "^3.1.0"
  }
}
const path = require('path');

module.exports = {
  entry: './src/main/js/app.js',
  devtool: 'sourcemaps',
  cache: true,
  mode: 'development',
  output: {
    path: __dirname,
    filename: './src/main/resources/static/built/bundle.js'
  },
  module: {
    rules: [
      {
        test: path.join(__dirname, '.'),
        exclude: /(node_modules)/,
        use: [{
          loader: 'babel-loader',
          options: {
            presets: ["@babel/preset-env", "@babel/preset-react"]
          }
        }]
      }
    ]
  }
};

React Basic

'use strict';

const React = require('react'); // <1>
const ReactDOM = require('react-dom'); // <2>
const client = require('./client'); // <3>

class App extends React.Component {

	constructor(props) {
		super(props);
		this.state = {employees: []};
	}

	componentDidMount() {
		client({method: 'GET', path: '/api/employees'}).done(response => {
			this.setState({employees: response.entity._embedded.employees});
		});
	}

	render() {
		return (
			<EmployeeList employees={this.state.employees}/>
		)
	}
}

class EmployeeList extends React.Component {
	render() {
		const employees = this.props.employees.map(employee =>
			<Employee key={employee._links.self.href} employee={employee}/>
		);
		return (
			<table>
				<tbody>
					<tr>
						<th>First Name</th>
						<th>Last Name</th>
						<th>Description</th>
					</tr>
					{employees}
				</tbody>
			</table>
		)
	}
}

class Employee extends React.Component {
	render() {
		return (
			<tr>
				<td>{this.props.employee.firstName}</td>
				<td>{this.props.employee.lastName}</td>
				<td>{this.props.employee.description}</td>
			</tr>
		)
	}
}

ReactDOM.render(
	<App />,
	document.getElementById('react')
)

Part 2

diff

Paging

public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {
}
$ curl "localhost:8080/api/employees?size=2"
{
  "_links" : {
  "first" : {
    "href" : "http://localhost:8080/api/employees?page=0&size=2"
  },
  "self" : {
    "href" : "http://localhost:8080/api/employees"
  },
  "next" : {
    "href" : "http://localhost:8080/api/employees?page=1&size=2"
  },
  "last" : {
    "href" : "http://localhost:8080/api/employees?page=2&size=2"
  }
  },
  "_embedded" : {
  "employees" : [ {
    "firstName" : "Frodo",
    "lastName" : "Baggins",
    "description" : "ring bearer",
    "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/employees/1"
    }
    }
  }, {
    "firstName" : "Bilbo",
    "lastName" : "Baggins",
    "description" : "burglar",
    "_links" : {
    "self" : {
      "href" : "http://localhost:8080/api/employees/2"
    }
    }
  } ]
  },
  "page" : {
  "size" : 2,
  "totalElements" : 6,
  "totalPages" : 3,
  "number" : 0
  }
}
$ curl "http://localhost:8080/api/employees?page=1&size=2"
{
  "_links" : {
  "first" : {
    "href" : "http://localhost:8080/api/employees?page=0&size=2"
  },
  "prev" : {
    "href" : "http://localhost:8080/api/employees?page=0&size=2"
  },
  "self" : {
    "href" : "http://localhost:8080/api/employees"
  },
  "next" : {
    "href" : "http://localhost:8080/api/employees?page=2&size=2"
  },
  "last" : {
    "href" : "http://localhost:8080/api/employees?page=2&size=2"
  }
  },
...
{
  "title" : "Employee",
  "properties" : {
  "firstName" : {
    "title" : "First name",
    "readOnly" : false,
    "type" : "string"
  },
  "lastName" : {
    "title" : "Last name",
    "readOnly" : false,
    "type" : "string"
  },
  "description" : {
    "title" : "Description",
    "readOnly" : false,
    "type" : "string"
  }
  },
  "definitions" : { },
  "type" : "object",
  "$schema" : "https://json-schema.org/draft-04/schema#"
}

Delete

class Employee extends React.Component {

	constructor(props) {
		super(props);
		this.handleDelete = this.handleDelete.bind(this);
	}

	handleDelete() {
		this.props.onDelete(this.props.employee);
	}

	render() {
		return (
			<tr>
				<td>{this.props.employee.firstName}</td>
				<td>{this.props.employee.lastName}</td>
				<td>{this.props.employee.description}</td>
				<td>
					<button onClick={this.handleDelete}>Delete</button>
				</td>
			</tr>
		)
	}
}
onDelete(employee) {
	client({method: 'DELETE', path: employee._links.self.href}).done(response => {
		this.loadFromServer(this.state.pageSize);
	});
}

Page Size

<input ref="pageSize" defaultValue={this.props.pageSize} onInput={this.handleInput}/>
handleInput(e) {
	e.preventDefault();
	const pageSize = ReactDOM.findDOMNode(this.refs.pageSize).value;
	if (/^[0-9]+$/.test(pageSize)) {
		this.props.updatePageSize(pageSize);
	} else {
		ReactDOM.findDOMNode(this.refs.pageSize).value =
			pageSize.substring(0, pageSize.length - 1);
	}
}
updatePageSize(pageSize) {
	if (pageSize !== this.state.pageSize) {
		this.loadFromServer(pageSize);
	}
}

Part 3

diff

Version

	private @Version @JsonIgnore Long version;

Etag

onUpdate(employee, updatedEmployee) {
	client({
		method: 'PUT',
		path: employee.entity._links.self.href,
		entity: updatedEmployee,
		headers: {
			'Content-Type': 'application/json',
			'If-Match': employee.headers.Etag
		}
	}).done(response => {
		this.loadFromServer(this.state.pageSize);
	}, response => {
		if (response.status.code === 412) {
			alert('DENIED: Unable to update ' +
				employee.entity._links.self.href + '. Your copy is stale.');
		}
	});
}

Part 4

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

diff

@Component
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

	static final String MESSAGE_PREFIX = "/topic";

	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/payroll").withSockJS();
	}

	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
		registry.enableSimpleBroker(MESSAGE_PREFIX);
		registry.setApplicationDestinationPrefixes("/app");
	}
}
  1. @EnableWebSocketMessageBroker WebSocket ์‹œ์ž‘.
  2. WebSocketMessageBrokerConfigurer ๊ธฐ๋ณธ ์„ค์ •.
  3. MESSAGE_PREFIX ๋ชจ๋“  ๋ฉ”์‹œ์ง€ ๊ฒฝ๋กœ์˜ ์ „์น˜์‚ฌ.
  4. registerStompEndpoints() /payroll ์—”๋“œํฌ์ธํŠธ ์„ค์ •.
  5. configureMessageBroker() ๋ฉ”์‹œ์ง€ ๋ธŒ๋กœ์ปค ์„ค์ •.

Java Event Handler

@Component
@RepositoryEventHandler(Employee.class)
public class EventHandler {

	private final SimpMessagingTemplate websocket;

	private final EntityLinks entityLinks;

	@Autowired
	public EventHandler(SimpMessagingTemplate websocket, EntityLinks entityLinks) {
		this.websocket = websocket;
		this.entityLinks = entityLinks;
	}

	@HandleAfterCreate
	public void newEmployee(Employee employee) {
		this.websocket.convertAndSend(
				MESSAGE_PREFIX + "/newEmployee", getPath(employee));
	}

	@HandleAfterDelete
	public void deleteEmployee(Employee employee) {
		this.websocket.convertAndSend(
				MESSAGE_PREFIX + "/deleteEmployee", getPath(employee));
	}

	@HandleAfterSave
	public void updateEmployee(Employee employee) {
		this.websocket.convertAndSend(
				MESSAGE_PREFIX + "/updateEmployee", getPath(employee));
	}

	/**
	 * Take an {@link Employee} and get the URI using Spring Data REST's {@link EntityLinks}.
	 *
	 * @param employee
	 */
	private String getPath(Employee employee) {
		return this.entityLinks.linkForItemResource(employee.getClass(),
				employee.getId()).toUri().getPath();
	}

}

JavaScript WebSocket

const stompClient = require('./websocket-listener')

Part 5

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
	<groupId>org.thymeleaf.extras</groupId>
	<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

diff

@Entity
public class Manager {

	public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();

	private @Id @GeneratedValue Long id;

	private String name;

	private @JsonIgnore String password;

	private String[] roles;

	public void setPassword(String password) {
		this.password = PASSWORD_ENCODER.encode(password);
	}

	protected Manager() {}

	public Manager(String name, String password, String... roles) {

		this.name = name;
		this.setPassword(password);
		this.roles = roles;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (o == null || getClass() != o.getClass()) return false;
		Manager manager = (Manager) o;
		return Objects.equals(id, manager.id) &&
			Objects.equals(name, manager.name) &&
			Objects.equals(password, manager.password) &&
			Arrays.equals(roles, manager.roles);
	}

	@Override
	public int hashCode() {

		int result = Objects.hash(id, name, password);
		result = 31 * result + Arrays.hashCode(roles);
		return result;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getPassword() {
		return password;
	}

	public String[] getRoles() {
		return roles;
	}

	public void setRoles(String[] roles) {
		this.roles = roles;
	}

	@Override
	public String toString() {
		return "Manager{" +
			"id=" + id +
			", name='" + name + '\'' +
			", roles=" + Arrays.toString(roles) +
			'}';
	}
}

Manager

@PreAuthorize("hasRole('ROLE_MANAGER')")
public interface EmployeeRepository extends PagingAndSortingRepository<Employee, Long> {

	@Override
	@PreAuthorize("#employee?.manager == null or #employee?.manager?.name == authentication?.name")
	Employee save(@Param("employee") Employee employee);

	@Override
	@PreAuthorize("@employeeRepository.findById(#id)?.manager?.name == authentication?.name")
	void deleteById(@Param("id") Long id);

	@Override
	@PreAuthorize("#employee?.manager?.name == authentication?.name")
	void delete(@Param("employee") Employee employee);

}

Security Config

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

	@Autowired
	private SpringDataJpaUserDetailsService userDetailsService;

	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth
			.userDetailsService(this.userDetailsService)
				.passwordEncoder(Manager.PASSWORD_ENCODER);
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
				.antMatchers("/built/**", "/main.css").permitAll()
				.anyRequest().authenticated()
				.and()
			.formLogin()
				.defaultSuccessUrl("/", true)
				.permitAll()
				.and()
			.httpBasic()
				.and()
			.csrf().disable()
			.logout()
				.logoutSuccessUrl("/");
	}

}

ref

What Else?
inflearn react api server -50% ํ• ์ธ์ฟ ํฐ: 15108-f2af1e086101 buy me a coffee