HelloKoding

Practical coding guides

Spring Boot CRUD Example with RESTful APIs, JPA, Hibernate, MySQL, and VueJS

This tutorial will walk you through the steps of building a full-stack CRUD web app and RESTful APIs web services example with Spring Boot, Lombok, JPA and Hibernate, MySQL, FreeMarker, VueJS and Axios

What you’ll build

CRUD with VueJS

What you’ll need

Your local computer should have JDK 8+ or OpenJDK 8+, Maven 3+, MySQL Server 5+ or Docker CE 18+

You should also walk through the following tutorials

Init project structure

You can create and init a new Spring Boot project by using Spring CLI or Spring Initializr. Learn more about using these tools here

The final project structure as below

├── src
│   └── main
│       ├── java
│       │   └── com
│       │       └── hellokoding
│       │           └── springboot
│       │               └── restful
│       │                   ├── product
│       │                   │   ├── Product.java
│       │                   │   ├── ProductAPI.java
│       │                   │   ├── ProductController.java
│       │                   │   ├── ProductRespository.java
│       │                   │   └── ProductService.java
│       │                   └── Application.java
│       └── resources
│           ├── static
│           │   ├── products.css
│           │   └── products.js
│           ├── templates
│           │   └── products.html
│           └── application.properties
├── Dockerfile
├── docker-compose.yml
└── pom.xml

Project dependencies

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.hellokoding.springboot</groupId>
    <artifactId>crud-mysql-vuejs</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.1.RELEASE</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
    </properties>

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

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

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

        <dependency>
            <groupId>javax.validation</groupId>
            <artifactId>validation-api</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Create JPA Entity

Product.java

package com.hellokoding.springboot.restful.product;

import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.math.BigDecimal;
import java.util.Date;

@Entity

@Data
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String description;

    private BigDecimal price;

    @CreationTimestamp
    private Date createdAt;

    @UpdateTimestamp
    private Date updatedAt;
}

@Data is a Lombok annotation which generates field getters and setters, toString, equals and hashCode methods for you at compile time

@Entity is a JPA annotation which specifies the class as an entity (so the class name can be used in JPQL queries) and as a table in the database (the @Entity class name will match with the underlying table name if the @Table annotation is omitted)

Learn more about JPA and Hibernate

Create Spring Data JPA Repository

ProductRespository.java

package com.hellokoding.springboot.restful.product;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRespository extends JpaRepository<Product, Long> {
}

Implement Service

ProductService.java

package com.hellokoding.springboot.restful.product;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service

@RequiredArgsConstructor
public class ProductService {
    private final ProductRespository productRespository;

    public List<Product> findAll() {
        return productRespository.findAll();
    }

    public Optional<Product> findById(Long id) {
        return productRespository.findById(id);
    }

    public Product save(Product stock) {
        return productRespository.save(stock);
    }

    public void deleteById(Long id) {
        productRespository.deleteById(id);
    }
}

RequiredArgsConstructor is a Lombok annotation which generates a constructor with required fields (final fields and @NonNull fields). For the above ProductService class, Lombok will generate

@Service
public class ProductService {
    private final ProductRespository productRespository;

    public ProductService(ProductRespository productRespository) {
        this.productRespository = productRespository;
    }

    ...
}

For classes which only have single constructor, since Spring 4.3, you no longer need to specify an explicit injection annotation such as @Autowired, Spring does that for you

If your editor has not been installed Lombok plugin, you may get a highlighted error on the productRespository field. Either compiling the project or installing the plugin will resolve the problem

Learn more about using Lombok in Java and Spring Boot

Create REST APIs

ProductAPI.java

package com.hellokoding.springboot.restful.product;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;
import java.util.Optional;


@RestController
@RequestMapping("/api/v1/products")
@Slf4j
@RequiredArgsConstructor
public class ProductAPI {
    private final ProductService productService;

    @GetMapping
    public ResponseEntity<List<Product>> findAll() {
        return ResponseEntity.ok(productService.findAll());
    }

    @PostMapping
    public ResponseEntity create(@Valid @RequestBody Product product) {
        return ResponseEntity.ok(productService.save(product));
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> findById(@PathVariable Long id) {
        Optional<Product> stock = productService.findById(id);
        if (!stock.isPresent()) {
            log.error("Id " + id + " is not existed");
            ResponseEntity.badRequest().build();
        }

        return ResponseEntity.ok(stock.get());
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(@PathVariable Long id, @Valid @RequestBody Product product) {
        if (!productService.findById(id).isPresent()) {
            log.error("Id " + id + " is not existed");
            ResponseEntity.badRequest().build();
        }

        return ResponseEntity.ok(productService.save(product));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity delete(@PathVariable Long id) {
        if (!productService.findById(id).isPresent()) {
            log.error("Id " + id + " is not existed");
            ResponseEntity.badRequest().build();
        }

        productService.deleteById(id);

        return ResponseEntity.ok().build();
    }
}

Create Web Controller

ProductController.java

package com.hellokoding.springboot.restful.product;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class ProductController {
    @GetMapping("/")
    public String list(){
        return "products";
    }
}

Create FreeMarker View Template

products.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta name="description" content="">
  <meta name="author" content="">
  <title>Full stack CRUD Example with Spring Boot, MySQL and VueJS</title>
  <link href="https://unpkg.com/bootstrap@3.4.0/dist/css/bootstrap.min.css" rel="stylesheet">
  <link href="/products.css" rel="stylesheet"/>
</head>
<body>
  <div class="container">
    <h1>Product CRUD</h1>
    <main id="app">
      <router-view></router-view>
    </main>
  </div>

  <template id="product">
    <div>
      <h2>{{ product.name }}</h2>
      <b>Description: </b>
      <div>{{ product.description }}</div>
      <b>Price:</b>
      <div>{{ product.price }}<span class="glyphicon glyphicon-euro"></span></div>
      <br/>
      <span class="glyphicon glyphicon-arrow-left" aria-hidden="true"></span>
      <a>
        <router-link to="/">Back to product list</router-link>
      </a>
    </div>
  </template>

  <template id="product-list">
    <div>
      <div class="actions">
        <a class="btn btn-default">
          <router-link :to="{path: '/add-product'}">
            <span class="glyphicon glyphicon-plus"></span>
            Add product
          </router-link>
        </a>
      </div>
      <div class="filters row">
        <div class="form-group col-sm-3">
          <input placeholder="Search" v-model="searchKey" class="form-control" id="search-element" requred/>
        </div>
      </div>
      <table class="table">
        <thead>
        <tr>
          <th>Name</th>
          <th>Description</th>
          <th>Price</th>
          <th class="col-sm-2">Actions</th>
        </tr>
        </thead>
        <tbody>
        <tr v-for="product in filteredProducts">
          <!-- tr v-for="product in products" -->
          <!-- tr v-for="product in products | filterBy searchKey in 'name'" -->
          <td>
            <a>
              <router-link :to="{name: 'product', params: {product_id: product.id}}">{{ product.name }}</router-link>
            </a>
          </td>
          <td>{{ product.description }}</td>
          <td>
            {{ product.price }}
            <span class="glyphicon glyphicon-euro" aria-hidden="true"></span>
          </td>
          <td>
            <a class="btn btn-warning btn-xs">
              <router-link :to="{name: 'product-edit', params: {product_id: product.id}}">Edit</router-link>
            </a>
            <a class="btn btn-danger btn-xs">
              <router-link :to="{name: 'product-delete', params: {product_id: product.id}}">Delete</router-link>
            </a>
          </td>
        </tr>
        </tbody>
      </table>
    </div>
  </template>


  <template id="add-product">
    <div>
      <h2>Add new product</h2>
      <form @submit="createProduct">
        <div class="form-group">
          <label for="add-name">Name</label>
          <input class="form-control" id="add-name" v-model="product.name" required/>
        </div>
        <div class="form-group">
          <label for="add-description">Description</label>
          <textarea class="form-control" id="add-description" rows="10" v-model="product.description"></textarea>
        </div>
        <div class="form-group">
          <label for="add-price">Price, <span class="glyphicon glyphicon-euro"></span></label>
          <input type="number" class="form-control" id="add-price" v-model="product.price"/>
        </div>
        <button type="submit" class="btn btn-primary">Create</button>
        <a class="btn btn-default">
          <router-link to="/">Cancel</router-link>
        </a>
      </form>
    </div>
  </template>

  <template id="product-edit">
    <div>
      <h2>Edit product</h2>
      <form @submit="updateProduct">
        <div class="form-group">
          <label for="edit-name">Name</label>
          <input class="form-control" id="edit-name" v-model="product.name" required/>
        </div>
        <div class="form-group">
          <label for="edit-description">Description</label>
          <textarea class="form-control" id="edit-description" rows="3" v-model="product.description"></textarea>
        </div>
        <div class="form-group">
          <label for="edit-price">Price, <span class="glyphicon glyphicon-euro"></span></label>
          <input type="number" class="form-control" id="edit-price" v-model="product.price"/>
        </div>
        <button type="submit" class="btn btn-primary">Save</button>
        <a class="btn btn-default">
          <router-link to="/">Cancel</router-link>
        </a>
      </form>
    </div>
  </template>

  <template id="product-delete">
    <div>
      <h2>Delete product {{ product.name }}</h2>
      <form @submit="deleteProduct">
        <p>The action cannot be undone.</p>
        <button type="submit" class="btn btn-danger">Delete</button>
        <a class="btn btn-default">
          <router-link to="/">Cancel</router-link>
        </a>
      </form>
    </div>
  </template>

  <script src="https://unpkg.com/vue@2.5.22/dist/vue.js"></script>
  <script src="https://unpkg.com/vue-router@3.0.2/dist/vue-router.js"></script>
  <script src="https://unpkg.com/axios@0.18.0/dist/axios.min.js"></script>
  <script src="/products.js"></script>

</body>
</html>

Static Files

products.js

var products = [];

function findProduct (productId) {
  return products[findProductKey(productId)];
}

function findProductKey (productId) {
  for (var key = 0; key < products.length; key++) {
    if (products[key].id == productId) {
      return key;
    }
  }
}

var productService = {
  findAll(fn) {
    axios
      .get('/api/v1/products')
      .then(response => fn(response))
      .catch(error => console.log(error))
  },

  findById(id, fn) {
    axios
      .get('/api/v1/products/' + id)
      .then(response => fn(response))
      .catch(error => console.log(error))
  },

  create(product, fn) {
    axios
      .post('/api/v1/products', product)
      .then(response => fn(response))
      .catch(error => console.log(error))
  },

  update(id, product, fn) {
    axios
      .put('/api/v1/products/' + id, product)
      .then(response => fn(response))
      .catch(error => console.log(error))
  },

  deleteProduct(id, fn) {
    axios
      .delete('/api/v1/products/' + id)
      .then(response => fn(response))
      .catch(error => console.log(error))
  }
}

var List = Vue.extend({
  template: '#product-list',
  data: function () {
    return {products: [], searchKey: ''};
  },
  computed: {
    filteredProducts() {
      return this.products.filter((product) => {
      	return product.name.indexOf(this.searchKey) > -1
      	  || product.description.indexOf(this.searchKey) > -1
      	  || product.price.toString().indexOf(this.searchKey) > -1
      })
    }
  },
  mounted() {
    productService.findAll(r => {this.products = r.data; products = r.data})
  }
});

var Product = Vue.extend({
  template: '#product',
  data: function () {
    return {product: findProduct(this.$route.params.product_id)};
  }
});

var ProductEdit = Vue.extend({
  template: '#product-edit',
  data: function () {
    return {product: findProduct(this.$route.params.product_id)};
  },
  methods: {
    updateProduct: function () {
      productService.update(this.product.id, this.product, r => router.push('/'))
    }
  }
});

var ProductDelete = Vue.extend({
  template: '#product-delete',
  data: function () {
    return {product: findProduct(this.$route.params.product_id)};
  },
  methods: {
    deleteProduct: function () {
      productService.deleteProduct(this.product.id, r => router.push('/'))
    }
  }
});

var AddProduct = Vue.extend({
  template: '#add-product',
  data() {
    return {
      product: {name: '', description: '', price: 0}
    }
  },
  methods: {
    createProduct() {
      productService.create(this.product, r => router.push('/'))
    }
  }
});

var router = new VueRouter({
	routes: [
		{path: '/', component: List},
		{path: '/product/:product_id', component: Product, name: 'product'},
		{path: '/add-product', component: AddProduct},
		{path: '/product/:product_id/edit', component: ProductEdit, name: 'product-edit'},
		{path: '/product/:product_id/delete', component: ProductDelete, name: 'product-delete'}
	]
});

new Vue({
  router
}).$mount('#app')

products.css

.actions {
  margin-bottom: 20px;
  margin-top: 20px;
}

Application Configurations

Application.java

package com.hellokoding.springboot.restful;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

application.properties

spring.datasource.url=jdbc:mysql://hk-mysql:3306/test?useSSL=false
spring.datasource.username=root
spring.datasource.password=hellokoding
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

spring.jpa.hibernate.ddl-auto=create
spring.jpa.database-platform=org.hibernate.dialect.MySQL57Dialect
spring.jpa.generate-ddl=true
spring.jpa.show-sql=true

spring.freemarker.suffix=.html

hk-mysql refers to Docker Compose service defined in the below docker-compose.yml file

spring.jpa.hibernate.ddl-auto=create allows JPA/Hibernate auto create database and table schema for you

In practice, you may like to disable the DDL Auto feature by using spring.jpa.hibernate.ddl-auto=validate or spring.jpa.hibernate.ddl-auto=none (default). Check out this example as one of the approaches Spring Boot Flyway Example of Database Evolution

Run with Docker

Prepare Dockerfile for Java/Spring Boot application and docker-compose.yml for MySQL Server

Dockerfile

FROM maven:3.5-jdk-8

docker-compose.yml

version: '3'
services:
  hk-mysql:
    container_name: hk-mysql
    image: mysql/mysql-server:5.7
    environment:
      MYSQL_DATABASE: test
      MYSQL_ROOT_PASSWORD: hellokoding
      MYSQL_ROOT_HOST: '%'
    ports:
    - "3306:3306"
    restart: always

  crud-mysql-vuejs:
    build: .
    volumes:
    - .:/app
    - ~/.m2:/root/.m2
    working_dir: /app
    ports:
    - 8080:8080
    command: mvn clean spring-boot:run
    depends_on:
    - hk-mysql

Type the below command at the project root directory, make sure your local Docker is running

docker-compose up

Run with your local MySQL Server

You can run the app with your local MySQL Server by updating hk-mysql on application.properties to localhost and type the below command at the project root directory

mvn clean spring-boot:run

Test the app

Access to localhost:8080 and start playing around with the app

Source code

https://github.com/hellokoding/hellokoding-courses/tree/master/springboot-examples/springboot-crud-mysql-vuejs

References

Follow HelloKoding