.NET WebAPI With Vue.js and Docker - Todolist


  1. 說明
  2. WebAPI
    1. Models
    2. Controllers
    3. 啟用 CORS
  3. Vue

實作使用 .NET WebAPI 作為後端,Vue.js 作為前端,並且使用 Docker 進行容器化的部署方式,設計一個簡單的 Todolist 應用程式。

logo

說明

建立 ASP.NET Core WebAPI 專案

項目 名稱
解決方案 Todolist
WebAPI 專案 TodolistAPI
Vue 專案 TodolistVue

先進行 git 初始化:

git init

WebAPI

Models

使用 cli 安裝 Entity Framework Core:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer   
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design

接著加入 TodoItem Model:

TodoItem.cs

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

public class TodoItem
{
    [Key]
    public int TodoItemID { get; set; }

    [Required]
    [MaxLength(255)]
    public string? Title { get; set; }

    [MaxLength]
    public string? Description { get; set; }

    public DateTime? DueDate { get; set; }

    [ForeignKey("Category")]
    public int? CategoryID { get; set; }

    public Category? Category { get; set; }
}

Category.cs

using System.ComponentModel.DataAnnotations;

public class Category
{
    [Key]
    public int CategoryID { get; set; }

    [Required]
    [MaxLength(255)]
    public string? CategoryName { get; set; }
}

最後加上 DBContext,來連結 Models 與 Database。

TodoContext.cs

using Microsoft.EntityFrameworkCore;

namespace TodolistService.Models;

public class TodoContext : DbContext
{
    public TodoContext(DbContextOptions<TodoContext> options) : base(options) { }

    public DbSet<Category> Categories { get; set; }
    public DbSet<TodoItem> TodoItems { get; set; }
}

連線字串設定在 appsettings.json,簡化使用 LocalDB 以及 Integrated Security 的連線方式來示範,實際專案可以用明確的 User ID 與 Password 以及搭配正式的 SQL Server 來使用。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=TodoListDB;Integrated Security=true;TrustServerCertificate=true"
  },
  "AllowedHosts": "*"
}

接著進行 Migration (如果尚未安裝 dotnet-ef 必須先進行安裝):

dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate
dotnet ef database update

完成 Datbase Update 後,會發現 Entity Framework Core 已經自動在 LocalDB 中建立了 TodoListDB Database,並且建立了 Categories 與 TodoItems Table。

Controllers

接著建立 TodoItemsController 來處理 TodoItem 的 CRUD。

透過 Scaffold-DbContext 來建立 Controller,只進行以下的修改,加入 Categories 以及回應 TodiItems 使用 Eager Loading:

// GET: api/Categories
[HttpGet("/api/Categories")]
public async Task<ActionResult<IEnumerable<Category>>> GetCategories()
{
    return await _context.Categories.ToListAsync();
}

// GET: api/TodoItems
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
{
  if (_context.TodoItems == null)
  {
      return NotFound();
  }
    return await _context.TodoItems.Include(i => i.Category).ToListAsync();
}

完成後啟用網站,有看到 Swagger 表示已經可以進行 CRUD 了,

使用 POST 輸入一筆資料,同時建立 TodoItem 以及 Category (能夠這麼方便全是因為 Model Binding 的幫忙 😎):

{
  "title": "Buy Milk",
  "description": "🥛",
  "dueDate": "2023-09-11T12:00:00.939Z",
  "category": {
    "categoryName": "Life"
  }
}

啟用 CORS

在 Startup.cs 中加入 CORS Policy:

var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";

builder.Services.AddCors(options =>
{
    options.AddPolicy(name: MyAllowSpecificOrigins,
                      policy =>
                      {
                          policy.AllowAnyHeader()
                                .AllowAnyMethod()
                                .AllowAnyOrigin();
                      });
});

app.UseCors(MyAllowSpecificOrigins);

Vue

首先加入專案,類型選擇「獨立 JavaScript Vue 專案」。

首先調整 index.html 加入對 Bootstrap 及 Google Icon 的引用:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vite App</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">

    <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
          rel="stylesheet">
</head>
<body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
</body>
</html>

src/App.vue

Template 的部分:

<template>
    <div class="container">
        <div class="row text-center my-4">
            <img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
            <h1>Vue.js Todolist</h1>
        </div>
        <div class="row">
            <div class="col-md-4 p-3">
                <Form :postDataObj="postDataObj"
                      :editMode="editMode"
                      :categories="categories"
                      @init-post="initPostData"
                      @load-todo="loadTodos" />
            </div>
            <div class="col-md-8">
                <Table :todos="todos" @edit-todo="editTodoItem" @add-todo="addTodoItem" @delete-todo="deleteTodoItem" />
            </div>
        </div>
    </div>
</template>

Script 與 Style 的部分:

<script>
    import Form from './components/Form.vue';
    import Table from './components/Table.vue';

    export default {
        components: {
            Form,
            Table,
        },
        data() {
            return {
                postDataObj: {
                    title: '',
                    description: '',
                    dueDate: this.getDatetimeNow(),
                },
                editMode: false,
                todos: [],
                categories: [],
            };
        },
        mounted() {
            this.loadTodos();
            this.loadCategories();
            this.initPostData();
        },
        methods: {
            getDatetimeNow() {
                return new Date().toISOString().slice(0, 16);
            },
            initPostData() {
                this.postDataObj = {
                    title: '',
                    description: '',
                    dueDate: this.getDatetimeNow(),
                };
                document.getElementById('title').focus();
            },
            loadTodos() {
                fetch(`${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`, { method: 'GET' })
                    .then(response => response.json())
                    .then(data => {
                        this.todos = data;
                    })
                    .catch(error => {
                        console.error('Error fetching data:', error);
                    });
            },
            loadCategories() {
                fetch(`${import.meta.env.VITE_API_BASE_URL}/api/Categories/`)
                    .then(response => response.json())
                    .then(data => {
                        this.categories = data.map(item => item);
                    })
                    .catch(error => {
                        console.error('Error fetching data:', error);
                    });
            },
            editTodoItem(todo) {
                this.postDataObj.title = todo.title;
                this.postDataObj.description = todo.description;
                this.postDataObj.dueDate = todo.dueDate;
                this.postDataObj.categoryId = todo.category ? todo.category.categoryID : null;
                this.postDataObj.TodoItemID = todo.todoItemID;

                // Change the button label and action
                this.editMode = true;
            },
            addTodoItem() {
                this.editMode = false;
                this.initPostData();
            },
            deleteTodoItem(todo) {
                const confirmDelete = confirm("Are you sure you want to delete this item?");
                if (confirmDelete) {
                    fetch(`${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${todo.todoItemID}`, {
                        method: 'DELETE'
                    })
                        .then(response => {
                            if (response.ok) {
                                this.loadTodos();
                                this.initPostData();
                                console.log('Successfully deleted todo item.');
                            } else {
                                console.error('Failed to delete todo item.');
                            }
                        })
                        .catch(error => {
                            console.error('Error deleting todo item:', error);
                        });
                }
            },
            postData() {
                const requestOptions = {
                    method: this.editMode ? 'PUT' : 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(this.postDataObj)
                };

                const url = this.editMode
                    ? `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${this.postDataObj.TodoItemID}`
                    : `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`;

                if (requestOptions.method == 'PUT') {
                    fetch(url, requestOptions)
                        .then(response => {
                            this.loadTodos();
                            console.log('Success fetching data:', response);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                }
                else {
                    fetch(url, requestOptions)
                        .then(response => response.json())
                        .then(data => {
                            this.initPostData();
                            this.loadTodos();
                            console.log('Success fetching data:', data);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                }
            }
        }
    };
</script>

<style>
    @import url('https://fonts.googleapis.com/css2?family=Gochi+Hand&display=swap');
    body {
        font-family: 'Gochi Hand', cursive;
        font-size: 1.2em;
    }
</style>

src/components/form.vue

Template 的部分:

<template>
    <form @submit.prevent="postData">
        <div class="mb-3 row">
            <label for="title" class="col-md-4 col-form-label">Title</label>
            <div class="col-md-8">
                <input type="text" id="title" class="form-control" v-model="postDataObj.title" required>
            </div>
        </div>
        <div class="mb-3 row">
            <label for="description" class="col-md-4 col-form-label">Description</label>
            <div class="col-md-8">
                <input type="text" id="description" class="form-control" v-model="postDataObj.description" required>
            </div>
        </div>
        <div class="mb-3 row">
            <label for="dueDate" class="col-md-4 col-form-label">Due Date</label>
            <div class="col-md-8">
                <input type="datetime-local" id="dueDate" class="form-control" v-model="postDataObj.dueDate" required>
            </div>
        </div>
        <div class="mb-3 row">
            <label for="category" class="col-md-4 col-form-label">Category</label>
            <div class="col-md-8">
                <select id="category" class="form-select" v-model="postDataObj.categoryId" required>
                    <option v-for="category in categories" :key="category.categoryID" :value="category.categoryID">{{ category.categoryName }}</option>
                </select>
            </div>
        </div>
        <div class="mb-3">
            <div v-if="editMode == true">
                <button type="submit" class="btn btn-success">Edit</button>
            </div>
            <div v-else>
                <button type="submit" class="btn btn-primary">Submit</button>
            </div>
        </div>
    </form>
</template>

Script 的部分:

<script>
    export default {
        props: {
            postDataObj: Object,
            editMode: Boolean,
            categories: Array,
        },
        methods: {
            postData() {
                const requestOptions = {
                    method: this.editMode ? 'PUT' : 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(this.postDataObj)
                };

                const url = this.editMode
                    ? `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/${this.postDataObj.TodoItemID}`
                    : `${import.meta.env.VITE_API_BASE_URL}/api/TodoItems/`;

                if (requestOptions.method == 'PUT') {
                    fetch(url, requestOptions)
                        .then(response => {
                            this.$emit('load-todo');
                            console.log('Success fetching data:', response);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                } else {
                    fetch(url, requestOptions)
                        .then(response => response.json())
                        .then(data => {
                            this.$emit('init-post');
                            this.$emit('load-todo');
                            console.log('Success fetching data:', data);
                        })
                        .catch(error => {
                            console.error('Error fetching data:', error);
                        });
                }
            },
        },
    };
</script>

src/components/table.vue

template 的部分:

<template>
    <table class="table">
        <thead>
            <tr>
                <th>ID</th>
                <th>Title</th>
                <th>Description</th>
                <th>Due Date</th>
                <th>Category</th>
                <th class="text-success">
                    <span class="material-icons fs-3" style="cursor: pointer;" @click="addTodoItem()">add</span>
                </th>
            </tr>
        </thead>
        <tbody>
            <tr v-for="todo in todos" :key="todo.todoItemID" @click="editTodoItem(todo)">
                <td>{{ todo.todoItemID }}</td>
                <td>{{ todo.title }}</td>
                <td>{{ todo.description }}</td>
                <td>{{ todo.dueDate }}</td>
                <td>{{ todo.category ? todo.category.categoryName : 'N/A' }}</td>
                <td class="text-danger">
                    <button @click="deleteTodoItem(todo)" class="btn btn-danger">Delete</button>
                </td>
            </tr>
        </tbody>
    </table>
</template>

Script 的部分:

<script>
    export default {
        props: {
            todos: Array,
        },
        methods: {
            editTodoItem(todo) {
                this.$emit('edit-todo', todo);
            },
            addTodoItem() {
                this.$emit('add-todo');
            },
            deleteTodoItem(todo) {
                this.$emit('delete-todo', todo);
            },
        },
    };
</script>

並且在 vue.js 專案加入 .env

.env

VITE_API_BASE_URL="https://localhost:44396"