Projeto biblioteca fullstack Spring Boot e Vue.js

Criação de projeto de biblioteca fullstack Spring Boot e Vue.js
Voltar

Material complementar:

  • Conteúdo no YouTube: Acesse
  • Conteúdo no GitHub: Acesse
  • Curso gratuito e com certificado na Udemy (Biblioteca fullstack: Spring Boot + Vue.js + Postman + Bootstrap): Acesse em breve

Conceito:

Criar sistema web RESTful fullstack, com backend Spring Boot Kotlin (ORM hibernate JPA DAO) e frontend Vue.js TypeScript (vue router), com funções de CRUD (criar, listar, ver específico, editar e excluir) de livros.


Backend (Spring Boot):

  1. Pré-requisitos:
    • JDK 21 (e path);
    • Gradle (e path);
    • MySQL;
    • Postman web e Postman desktop agent.
  2. Criar projeto Spring Boot:
    • Em Spring Initializr, selecione as dependências:
      • Project: Gradle - Kotlin
      • Language: Kotlin
      • Project Metadata
        • Group: ubsocial.com
        • Artifact: biblioteca
        • Name: biblioteca (igual no Artifact)
        • Package name: ubsocial.com.biblioteca
        • Java: versão do JDK instalado na máquina (21)
      • Dependencies:
        • Spring Web
        • Spring Data JPA
      • Generate (projeto será gerado, abra-o no editor)
  3. Criar banco de dados MySQL: CREATE DATABASE biblioteca;
  4. 'build.gradle.kts', em 'dependencies', adicionar dependencies:
    
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    runtimeOnly("com.mysql:mysql-connector-j")
    
  5. 'src/main/resources/application.properties':
    
    spring.application.name=biblioteca
    spring.jpa.hibernate.ddl-auto=update
    spring.datasource.url=jdbc:mysql://localhost:3306/biblioteca
    spring.datasource.username=root
    spring.datasource.password=
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.jackson.serialization.fail-on-empty-beans=false
    server.error.include-message=always
    server.error.include-binding-errors=always
    spring.jpa.show-sql=true
    spring.jpa.properties.hibernate.format_sql=true
    server.port=8080
    
  6. Dentro do pacote principal do projeto (ubsocial.com.biblioteca), criar pacote "config": ficará ubsocial.com.biblioteca.config
  7. Dentro do pacote "config", criar classe WebConfig.kt para conexão com frontend Vue.js:
    
    package ubsocial.com.biblioteca.config
    import org.springframework.context.annotation.Bean
    import org.springframework.context.annotation.Configuration
    import org.springframework.web.servlet.config.annotation.CorsRegistry
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
    
    @Configuration
    class WebConfig {
    
        @Bean
        fun corsConfigurer(): WebMvcConfigurer {
            return object : WebMvcConfigurer {
                override fun addCorsMappings(registry: CorsRegistry) {
                    registry.addMapping("/**")
                        .allowedOrigins("http://localhost:8081")
                        .allowedMethods("GET","POST","PUT","DELETE","OPTIONS")
                }
            }
        }
    }
    
  8. Dentro do pacote principal do projeto (ubsocial.com.biblioteca), criar pacote "model": ficará ubsocial.com.biblioteca.model
  9. Dentro do pacote "model", criar pacote de entidades "entity": ficará model.entity
  10. Dentro do pacote "model.entity", criar Model (entidade) Livro.kt:
    
    package ubsocial.com.biblioteca.model.entity
    import com.fasterxml.jackson.annotation.JsonFormat
    import jakarta.persistence.*
    import jakarta.validation.constraints.NotBlank
    import jakarta.validation.constraints.NotNull
    import jakarta.validation.constraints.Size
    import java.time.LocalDate
    
    @Entity
    data class Livro(
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        val id: Long? = null,
    
        @field:NotBlank
        @field:Size(min = 1, max = 255, message = "Título precisa ter entre 1 e 255 caracteres")
        @Column(length = 255)
        val title: String = "",
    
        @field:NotBlank
        @field:Size(min = 1, max = 255, message = "Autor precisa ter entre 1 e 255 caracteres")
        @Column(length = 255)
        val author: String = "",
    
        @field:NotNull
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
        @Column(name = "published_date")
        val published_date: LocalDate = LocalDate.now(),
    
        @field:NotBlank
        @field:Size(min = 13, max = 13, message = "ISBN precisa ter 13 caracteres")
        @Column(unique = true)
        val isbn: String = "",
    
        val pages: Int = 0,
    
        @field:Size(min = 1, max = 255, message = "Capa/Gênero precisa ter entre 1 e 255 caracteres")
        @Column(length = 255)
        val cover: String? = null,
    
        @field:NotBlank
        @field:Size(min = 1, max = 255, message = "Idioma precisa ter entre 1 e 255 caracteres")
        @Column(length = 255)
        val language: String = ""
    )
    
  11. No pacote "model", criar pacote "repositories": ficará model.repositories
  12. No pacote "repositories", criar interface LivroRepository.kt:
    
    package ubsocial.com.biblioteca.model.repositories
    import org.springframework.data.jpa.repository.JpaRepository
    import ubsocial.com.biblioteca.model.entity.Livro
    
    interface LivroRepository : JpaRepository<Livro, Long>
    
  13. Pacote principal "ubsocial.com.biblioteca", criar pacote "controller"
  14. Pacote "controller", criar classe LivroResource.kt:
    
    package ubsocial.com.biblioteca.controller
    import jakarta.validation.Valid
    import org.springframework.http.HttpStatus
    import org.springframework.http.ResponseEntity
    import org.springframework.web.bind.annotation.*
    import ubsocial.com.biblioteca.model.entity.Livro
    import ubsocial.com.biblioteca.model.repositories.LivroRepository
    
    @RestController
    @RequestMapping("/books")
    class LivroResource(private val livroRepository: LivroRepository) {
    
        @GetMapping
        fun getAll(): List<Livro> = livroRepository.findAll()
        
        @GetMapping("/{id}")
        fun getById(@PathVariable id: Long): ResponseEntity<Livro> {
            val livro = livroRepository.findById(id)
            return if (livro.isPresent) ResponseEntity.ok(livro.get())
            else ResponseEntity.notFound().build()
        }
    
        @PostMapping
        fun create(@RequestBody @Valid livro: Livro): Livro = livroRepository.save(livro)
    
        @PutMapping("/{id}")
        fun update(@PathVariable id: Long, @RequestBody @Valid livro: Livro): ResponseEntity<Livro> {
            val optional = livroRepository.findById(id)
            if (optional.isEmpty) return ResponseEntity.notFound().build()
    
            val updated = optional.get().copy(
                title = livro.title,
                author = livro.author,
                published_date = livro.published_date,
                isbn = livro.isbn,
                pages = livro.pages,
                cover = livro.cover,
                language = livro.language
            )
            return ResponseEntity.ok(livroRepository.save(updated))
        }
    
        @DeleteMapping("/{id}")
        fun delete(@PathVariable id: Long): ResponseEntity<Void> {
            return if (livroRepository.existsById(id)) {
                livroRepository.deleteById(id)
                ResponseEntity.noContent().build()
            } else {
                ResponseEntity.notFound().build()
            }
        }
    }
    
  15. Na mesma pasta de "gradlew", executar projeto: ".\gradlew bootRun"
  16. Teste Postman (create):
    • URL: http://localhost:8080/books
    • Método: POST
    • Body raw (JSON):
      
      {
          "title": "O Senhor dos Anéis",
          "author": "J.R.R. Tolkien",
          "published_date": "1954-07-29",
          "isbn": "9780261102385",
          "pages": 1178,
          "cover": "Ficção",
          "language": "Português"
      }
      
    • Enviar (Send)
  17. Teste Postman (get):
  18. Teste Postman (get específico):
  19. Teste Postman (update):
    • URL: http://localhost:8080/books/1
    • Método: PUT
    • Body raw (JSON):
      
      {
          "title": "O Senhor dos Anéis - Edição Atualizada",
          "author": "J.R.R. Tolkien",
          "published_date": "1954-07-29",
          "isbn": "9780261102385",
          "pages": 1178,
          "cover": "Ficção",
          "language": "Português"
      }
      
    • Enviar (Send)
  20. Teste Postman (delete):

Frontend (Vue.js):

  1. Pré-requisitos:
    • Instalar nodejs (npm);
    • Instalar Vue: npm install -g @vue/cli vue-router@4
  2. Comandos para criar projeto Vue.js:
    
    vue create biblioteca-frontend
    -  Escolher features manualmente: Choose Vue version, Babel, Linter / Formatter e TypeScript
    – Use class-style component syntax? No
    – Use Babel alongside TypeScript? Yes
    – Pick a linter / formatter config: Prettier
    – Pick additional lint features: Lint on save
    – Where do you prefer placing config files? In dedicated config files
    – Save this for future project? No
    
    cd biblioteca-frontend
    npm i bootstrap jquery popper.js axios
    npm install vue-router@4
    npm run serve
    
  3. src/main.ts:
    
    import { createApp } from "vue";
    import App from "@/App.vue";
    import router from "@/router";
    import "bootstrap";
    import "bootstrap/dist/css/bootstrap.min.css";
    createApp(App).use(router).mount("#app");
    
  4. src/http-common.ts:
    
    import axios, { AxiosInstance } from "axios";
    
    const apiClient: AxiosInstance = axios.create({
        baseURL: "http://localhost:8080",
        headers: {
            "Content-type": "application/json",
        },
    });
    
    export default apiClient;
    
  5. src/shims-vue.d.ts:
    
    declare module "*.vue" {
        import { DefineComponent } from "vue";
        const component: DefineComponent<{}, {}, any>;
        export default component;
    }
    
  6. .eslintrc.js:
    
    module.exports = {
        root: true,
        env: {
            node: true,
        },
        extends: [
            "plugin:vue/vue3-essential",
            "eslint:recommended",
            "@vue/typescript/recommended",
            //"plugin:prettier/recommended",
        ],
        parserOptions: {
            ecmaVersion: 2020,
        },
        rules: {
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/ban-types': 'off',
            '@typescript-eslint/no-unused-vars': 'warn',
        },
    };
    
  7. vue.config.js:
    
    module.exports = {
        devServer: {
            port: 8081,
        },
    };
    
  8. src/router.ts:
    
    import { createWebHistory, createRouter } from "vue-router";
    import { RouteRecordRaw } from "vue-router";
    
    const routes: Array<RouteRecordRaw> = [
        {
            path: "/",
            alias: "/books",
            name: "books",
            component: () => import("./components/BooksList.vue"),
        },
        {
            path: "/books/:id",
            name: "book-details",
            component: () => import("./components/BookDetails.vue"),
        },
        {
            path: "/add",
            name: "add",
            component: () => import("./components/AddBook.vue"),
        },
    ];
    
    const router = createRouter({
        history: createWebHistory(),
        routes,
    });
    
    export default router;
    
  9. src/components/AddBook.vue:
    
    <template>
        <h3>Novo livro:</h3>
        <form @submit.prevent="saveBook">
            <div class="form-group">
                <input type="text" class="form-control" v-model="book.title" placeholder="Título" required id="title"
                    name="title" />
            </div>
            <div class="form-group">
                <input type="text" class="form-control" v-model="book.author" placeholder="Autor(a)" required id="author"
                    name="author" />
            </div>
            <div class="form-group">
                <label for="published_date">Publicação:</label>
                <input type="date" class="form-control" v-model="book.published_date" required id="published_date"
                    name="published_date" :max="new Date().toISOString().split('T')[0]" />
            </div>
            <div class="form-group">
                <input type="text" class="form-control" v-model="book.isbn" placeholder="ISBN" required id="isbn"
                    name="isbn" pattern="^\d{13}$" />
            </div>
            <div class="form-group">
                <input type="number" class="form-control" v-model="book.pages" placeholder="Páginas" required id="pages"
                    name="pages" min="1" />
            </div>
            <div class="form-group">
                <input type="text" name="cover" class="form-control" v-model="book.cover" placeholder="Capa" required
                    id="cover" />
            </div>
            <div class="form-group">
                <input type="text" class="form-control" v-model="book.language" placeholder="Idioma" required id="language"
                    name="language" />
            </div>
            <button type="button" class="btn btn-secondary" @click="$router.push('/books')">Cancelar</button>
            <button type="submit" class="btn btn-success">Adicionar</button>
        </form>
    </template>
    
    <script lang="ts">
    import { defineComponent } from 'vue';
    import BookDataService from '@/services/BookDataService';
    import Book from '@/types/Book';
    import ResponseData from '@/types/ResponseData';
    
    export default defineComponent({
        name: 'add-book',
        data() {
            return {
                book: {
                    title: '',
                    author: '',
                    published_date: new Date(),
                    isbn: '',
                    pages: 0,
                    cover: '',
                    language: ''
                } as Book,
            };
        },
        methods: {
            saveBook() {
                let data = {
                    title: this.book.title,
                    author: this.book.author,
                    published_date: this.book.published_date,
                    isbn: this.book.isbn,
                    pages: this.book.pages,
                    cover: this.book.cover,
                    language: this.book.language,
                };
    
                BookDataService.create(data)
                    .then((response: ResponseData) => {
                        this.book.id = response.data.id;
                        this.$router.push({ name: "books" });
                    })
                    .catch((e: Error) => {
                        console.error(e);
                    });
            },
        },
    });
    </script>
    
    <style></style>
    
  10. src/components/BookDetails.vue:
    
    <template>
        <div v-if="currentBook.id">
            <h4>Livro {{ currentBook.id }} - {{ currentBook.title }}</h4>
            <form @submit.prevent="updateBook">
                <div class="form-group">
                    <input type="text" class="form-control" v-model="currentBook.title" placeholder="Título" required
                        id="title" name="title" />
                </div>
                <div class="form-group">
                    <input type="text" class="form-control" v-model="currentBook.author" placeholder="Autor(a)" required
                        id="author" name="author" />
                </div>
                <div class="form-group">
                    <label for="published_date">Publicação:</label>
                    <input type="date" class="form-control" v-model="currentBook.published_date" required
                        id="published_date" name="published_date" :max="new Date().toISOString().split('T')[0]" />
                </div>
                <div class="form-group">
                    <input type="text" class="form-control" v-model="currentBook.isbn" placeholder="ISBN" required id="isbn"
                        name="isbn" pattern="^\d{13}$" />
                </div>
                <div class="form-group">
                    <input type="number" class="form-control" v-model="currentBook.pages" placeholder="Páginas" required
                        id="pages" name="pages" min="1" />
                </div>
                <div class="form-group">
                    <input type="text" name="cover" class="form-control" v-model="currentBook.cover" placeholder="Capa"
                        required id="cover" />
                </div>
                <div class="form-group">
                    <input type="text" class="form-control" v-model="currentBook.language" placeholder="Idioma" required
                        id="language" name="language" />
                </div>
                <button type="button" class="btn btn-secondary" @click="$router.push('/books')">Cancelar</button>
                <button type="submit" class="btn btn-warning">Confirmar</button>
            </form>
            <p>{{ message }}</p>
        </div>
    </template>
    
    <script lang="ts">
    import { defineComponent } from 'vue';
    import BookDataService from '@/services/BookDataService';
    import Book from '@/types/Book';
    import ResponseData from '@/types/ResponseData';
    
    export default defineComponent({
        name: 'bookDetails',
        data() {
            return {
                currentBook: {} as Book,
                message: "",
            };
        },
        methods: {
            getBook(id: any) {
                BookDataService.get(id)
                    .then((response: ResponseData) => {
                        this.currentBook = response.data;
                    })
                    .catch((e: Error) => {
                        console.error(e);
                    });
            },
            updateBook() {
                BookDataService.update(this.currentBook.id, this.currentBook)
                    .then((response: ResponseData) => {
                        this.$router.push({ name: "books" });
                    })
                    .catch((e: Error) => {
                        console.error(e);
                        this.message = "Erro ao atualizar o livro: " + e.message;
                    });
            },
            deleteBook(id: number) {
                BookDataService.delete(this.currentBook.id)
                    .then((response: ResponseData) => {
                        console.log(response.data);
                        this.$router.push({ name: "books" });
                    })
                    .catch((e: Error) => {
                        console.error(e);
                    });
            },
        },
        mounted() {
            this.message = "";
            this.getBook(this.$route.params.id);
        },
    });
    </script>
    
    <style></style>
    
  11. src/components/BooksList.vue:
    
    <template>
        <h4>Lista de livros:</h4>
    
        <input type="text" class="form-control mb-3" placeholder="Pesquisar" v-model="searchTitle" />
    
        <ul>
            <li :class="{ active: index === currentIndex }" v-for="(book, index) in filteredBooks" :key="book.id">
                {{ book.title }}
                <button class="btn btn-primary btn-sm" @click="setActiveBook(book, index)">Ver</button>
                <router-link :to="'/books/' + book.id" class="btn btn-warning btn-sm">Editar</router-link>
                <button class="btn btn-danger btn-sm" @click="deleteBook(book.id)">Excluir</button>
            </li>
        </ul>
    
        <div v-if="currentBook.id">
            <h4>Livro {{ currentBook.id }}</h4>
            <p>Título: {{ currentBook.title }}</p>
            <p>Autor(a): {{ currentBook.author }}</p>
            <p>Publicação: {{ currentBook.published_date }}</p>
            <p>ISBN: {{ currentBook.isbn }}</p>
            <p>Páginas: {{ currentBook.pages }}</p>
            <p>Capa: {{ currentBook.cover }}</p>
            <p>Idioma: {{ currentBook.language }}</p>
            <router-link :to="'/books/' + currentBook.id" class="btn btn-warning btn-sm">Editar</router-link>
            <button @click="deleteBook(currentBook.id)" class="btn btn-danger btn-sm">Excluir</button>
        </div>
    </template>
    
    <script lang="ts">
    import { defineComponent } from 'vue';
    import BookDataService from '@/services/BookDataService';
    import Book from '@/types/Book';
    import ResponseData from '@/types/ResponseData';
    
    export default defineComponent({
        name: 'books-list',
        data() {
            return {
                books: [] as Book[],
                currentBook: {} as Book,
                currentIndex: -1,
                searchTitle: "",
            };
        },
        computed: {
            filteredBooks(): Book[] {
                return this.books.filter(book =>
                    book.title.toLowerCase().includes(this.searchTitle.toLowerCase())
                );
            },
        },
        methods: {
            retrieveBooks() {
                BookDataService.getAll()
                    .then((response: ResponseData) => {
                        this.books = response.data;
                    })
                    .catch((e: Error) => {
                        console.error(e);
                    });
            },
            refreshList() {
                this.retrieveBooks();
                this.currentBook = {} as Book;
                this.currentIndex = -1;
            },
            setActiveBook(book: Book, index = -1) {
                this.currentBook = book;
                this.currentIndex = index;
            },
            deleteBook(id: number) {
                BookDataService.delete(id)
                    .then((response: ResponseData) => {
                        this.refreshList();
                    })
                    .catch((e: Error) => {
                        console.error(e);
                    });
            },
        },
        mounted() {
            this.retrieveBooks();
        },
    });
    </script>
    
    <style></style>
    
  12. src/services/BookDataService.ts:
    
    import http from "@/http-common";
    import Book from "@/types/Book";
    import ResponseData from "@/types/ResponseData";
    
    class BookDataService {
        getAll(): Promise<ResponseData> {
            return http.get("/books");
        }
    
        get(id: number): Promise<ResponseData> {
            return http.get(`/books/${id}`);
        }
    
        create(data: Partial<Book>): Promise<ResponseData> {
            return http.post("/books", data);
        }
    
        update(id: number, data: Partial<Book>): Promise<ResponseData> {
            return http.put(`/books/${id}`, data);
        }
    
        delete(id: number): Promise<ResponseData> {
            return http.delete(`/books/${id}`);
        }
    }
    
    export default new BookDataService();
    
  13. src/types/Book.ts:
    
    export default interface Book {
        id: number;
        title: string;
        author: string;
        published_date: Date;
        isbn: string;
        pages: number;
        cover: string;
        language: string;
    }
    
  14. src/types/ResponseData.ts:
    
    export default interface ResponseData<T = any> {
        data: T;
    }
    
  15. src/App.vue:
    
    <template>
        <div id="app">
            <h1>Livraria UB Social</h1>
            <router-link to="/" class="btn btn-primary">Home</router-link>
            <router-link to="/add" class="btn btn-success">Adicionar livro</router-link>
            <router-view />
        </div>
    </template>
    
    <script lang="ts">
    import { defineComponent } from 'vue';
    
    export default defineComponent({
        name: 'App',
    });
    </script>
    
    <style></style>
    

Integração do sistema:

Integrar (juntar) backend Spring com frontend Vue.js, para tornar sistema unificado.

  1. Pasta do projeto backend Spring Boot, executar servidor Gradle: .\gradlew bootRun
  2. Pasta do projeto frontend Vue.js, executar servidor Vue.js: npm run serve

Elaborado por Mateus Schwede
ubsocial.github.io