用FastAPI和Vue.js开发一个单页应用程序

3,377 阅读22分钟

下面将逐步介绍如何使用FastAPI、Vue、Docker和Postgres构建和容器化一个基本的CRUD应用。我们将从后端开始,开发一个由Python、FastAPI和Docker驱动的RESTful API,然后转向前端。我们还将连接基于令牌的认证。

最终的应用程序

主要的依赖性。

  • Vue v2.6.11
  • Vue CLI v4.5.13
  • Node v16.6.1
  • npm v7.20.3
  • FastAPI v0.68.0
  • Python v3.9

这是一个中级教程,重点是用FastAPI和Vue分别开发后端和前端应用程序。除了应用程序本身,你将添加认证并将它们整合在一起。假设你有使用FastAPI、Vue和Docker的经验。有关学习前面提到的工具和技术的推荐资源,请参阅FastAPI和Vue部分。

目标

在本教程结束时,你将能够。

  1. 解释什么是FastAPI
  2. 解释什么是Vue,以及它与React和Angular等其他UI库和前端框架的比较
  3. 用FastAPI开发一个RESTful API
  4. 使用Vue CLI搭建Vue项目的支架
  5. 在浏览器中创建和渲染Vue组件
  6. 用Vue组件创建一个单页应用程序(SPA)
  7. 将Vue应用程序连接到FastAPI后端
  8. 用Bootstrap设计Vue组件的样式
  9. 使用Vue Router来创建路由和渲染组件
  10. 用基于令牌的认证管理用户授权

让我们快速了解一下每个框架。

什么是FastAPI?

FastAPI是一个现代的、包含电池的Python网络框架,非常适合于构建RESTful API。它可以处理同步和异步请求,并内置了对数据验证、JSON序列化、认证和授权以及OpenAPI文档的支持。

亮点。

  1. 受到Flask的很大启发,它有一种轻量级的微框架感觉,支持类似Flask的路由装饰器。
  2. 它利用Python类型提示进行参数声明,从而实现数据验证(通过pydantic)和OpenAPI/Swagger文档。
  3. 构建在Starlette之上,它支持异步API的开发。
  4. 它很快速。由于异步比传统的同步线程模型更有效,它可以在性能方面与Node和Go竞争。
  5. 因为它基于并完全兼容OpenAPI和JSON Schema,它支持许多强大的工具,如Swagger UI。
  6. 它有惊人的文档

**第一次使用FastAPI?**请查看以下资源。

什么是Vue?

Vue是一个开源的JavaScript框架,用于构建用户界面。它采用了React和Angular的一些最佳实践。也就是说,与React和Angular相比,它更加平易近人,所以初学者可以快速上手。它也同样强大,所以它提供了你创建现代前端应用程序所需的所有功能。

关于Vue的更多信息,以及使用它与React和Angular的优点和缺点,请查阅以下文章。

第一次使用Vue?

我们在建造什么?

我们的目标是设计一个后端RESTful API,由Python和FastAPI驱动,用于两种资源--用户和笔记。该API本身应该遵循RESTful设计原则,使用基本的HTTP动词。GET、POST、PUT和DELETE。

我们还将用Vue建立一个前端应用程序,与后端API进行交互。

核心功能。

  1. 经过认证的用户将能够查看、添加、更新和删除笔记。
  2. 已认证的用户还可以查看他们的用户信息并删除自己

本教程主要是处理快乐的路径。处理不愉快/异常的路径对读者来说是一个单独的练习。检查你的理解,为前端和后端添加适当的错误处理。

FastAPI设置

首先创建一个名为 "fastapi-vue "的新项目文件夹,并添加以下文件和文件夹。

fastapi-vue
├── docker-compose.yml
└── services
    └── backend
        ├── Dockerfile
        ├── requirements.txt
        └── src
            └── main.py

接下来,在services/backend/Dockerfile中添加以下代码。

FROM python:3.9-buster

RUN mkdir app
WORKDIR /app

ENV PATH="${PATH}:/root/.local/bin"
ENV PYTHONPATH=.

COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

COPY src/ .

services/backend/requirements.txt文件中添加以下依赖项。

fastapi==0.68.0
uvicorn==0.14.0

像这样更新docker-compose.yml

在我们构建镜像之前,让我们给services/backend/src/main.py添加一个测试路由,这样我们就可以快速测试应用程序是否构建成功。

from fastapi import FastAPI


app = FastAPI()


@app.get("/")
def home():
    return "Hello, World!"

在你的终端中建立镜像。

$ docker-compose up -d --build

一旦完成,在你选择的浏览器中导航到http://127.0.0.1:5000/。你应该看到。

你可以在http://localhost:5000/docs 查看 Swagger UI。

接下来,添加CORSMiddleware

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware  # NEW


app = FastAPI()

# NEW
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8080"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/")
def home():
    return "Hello, World!"

CORSMiddleware 如果需要进行跨源请求 -- 例如,来自不同协议、IP地址、域名或端口的请求 -- 你需要启用跨源资源共享(CORS)。这是必要的,因为前端将运行在 。http://localhost:8080

Vue设置

为了开始使用我们的前端,我们将使用Vue CLI搭建一个项目的支架。

确保你使用的是4.5.13版本的Vue CLI。

接下来,从 "fastapi-vue/services "文件夹中,搭建出一个新的Vue项目。

选择Default ([Vue 2] babel, eslint)

脚手架搭好后,添加路由器(对历史模式说是),并安装所需的依赖。

我们很快就会讨论每一个依赖项。

要在本地提供Vue应用程序,请运行。

导航到http://localhost:8080/,查看你的应用程序。

关闭服务器。

接下来,在services/frontend/src/main.js中连接Axios和Bootstrap的依赖项。

import 'bootstrap/dist/css/bootstrap.css';
import axios from 'axios';
import Vue from 'vue';

import App from './App.vue';
import router from './router';


axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://localhost:5000/';  // the FastAPI backend

Vue.config.productionTip = false;

new Vue({
  router,
  render: h => h(App)
}).$mount('#app');

在 "services/frontend "中添加一个Docker文件

FROM node:lts-alpine

WORKDIR /app

ENV PATH /app/node_modules/.bin:$PATH

RUN npm install @vue/[email protected] -g

COPY package.json .
COPY package-lock.json .
RUN npm install

CMD ["npm", "run", "serve"]

docker-compose.yml中添加一个frontend 服务。

构建新的镜像并启动容器。

$ docker-compose up -d --build

确保http://localhost:8080/仍然工作。

接下来,像这样更新services/frontend/src/components/HelloWorld.vue


  
    {{ msg }}
  



Axios是一个HTTP客户端,用于向后端发送AJAX请求。在上面的组件中,我们从后台的响应中更新了msg 的值。

最后,在services/frontend/src/App.vue中,删除导航以及相关的样式。

现在你应该在浏览器中看到Hello, World! ,在http://localhost:8080/。

你的整个项目结构现在应该是这样的。

├── docker-compose.yml
└── services
    ├── backend
    │   ├── Dockerfile
    │   ├── requirements.txt
    │   └── src
    │       └── main.py
    └── frontend
        ├── .gitignore
        ├── Dockerfile
        ├── README.md
        ├── babel.config.js
        ├── package-lock.json
        ├── package.json
        ├── public
        │   ├── favicon.ico
        │   └── index.html
        └── src
            ├── App.vue
            ├── assets
            │   └── logo.png
            ├── components
            │   └── HelloWorld.vue
            ├── main.js
            ├── router
            │   └── index.js
            └── views
                ├── About.vue
                └── Home.vue

模型和迁移

我们将使用Tortoise作为我们的ORM(对象关系映射器),使用Aerich来管理数据库迁移。

更新后端依赖性。

aerich==0.5.5
asyncpg==0.23.0
fastapi==0.68.0
tortoise-orm==0.17.6
uvicorn==0.14.0

首先,让我们在docker-compose.yml中为Postgres添加一个新服务。

注意到db 中的环境变量以及backend 服务中新的DATABASE_URL 环境变量。

接下来,在 "services/backend/src "文件夹中创建一个名为 "数据库 "的文件夹,并在其中创建一个名为models.py的新文件。

from tortoise import fields, models


class Users(models.Model):
    id = fields.IntField(pk=True)
    username = fields.CharField(max_length=20, unique=True)
    full_name = fields.CharField(max_length=50, null=True)
    password = fields.CharField(max_length=128, null=True)
    created_at = fields.DatetimeField(auto_now_add=True)
    modified_at = fields.DatetimeField(auto_now=True)


class Notes(models.Model):
    id = fields.IntField(pk=True)
    title = fields.CharField(max_length=225)
    content = fields.TextField()
    author = fields.ForeignKeyField("models.Users", related_name="note")
    created_at = fields.DatetimeField(auto_now_add=True)
    modified_at = fields.DatetimeField(auto_now=True)

    def __str__(self):
        return f"{self.title}, {self.author_id} on {self.created_at}"

UsersNotes 类将在我们的数据库中创建两个新表。请注意,author 列与用户有关,创建一个一对多的关系(一个用户可以有很多笔记)。

在 "services/backend/src/database "文件夹中创建一个config.py文件。

import os


TORTOISE_ORM = {
    "connections": {"default": os.environ.get("DATABASE_URL")},
    "apps": {
        "models": {
            "models": [
                "src.database.models", "aerich.models"
            ],
            "default_connection": "default"
        }
    }
}

在这里,我们为Tortoise和Aerich都指定了配置。

简单的说,我们。

  1. 通过DATABASE_URL 环境变量定义了数据库连接
  2. 注册了我们的模型,src.database.models (用户和注释)和aerich.models (迁移元数据)

在 "services/backend/src/database "中也添加了一个register.py文件。

from typing import Optional

from tortoise import Tortoise


def register_tortoise(
    app,
    config: Optional[dict] = None,
    generate_schemas: bool = False,
) -> None:
    @app.on_event("startup")
    async def init_orm():
        await Tortoise.init(config=config)
        if generate_schemas:
            await Tortoise.generate_schemas()

    @app.on_event("shutdown")
    async def close_orm():
        await Tortoise.close_connections()

register_tortoise 是一个函数,将用于用Tortoise配置我们的应用程序和模型。它接收我们的应用程序,一个config dict,和一个 boolean。generate_schema

这个函数将在main.py中被调用,并带有我们的config dict。

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from src.database.register import register_tortoise  # NEW
from src.database.config import TORTOISE_ORM         # NEW


app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# NEW
register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)


@app.get("/")
def home():
    return "Hello, World!"

构建新的镜像并启动容器。

$ docker-compose up -d --build

容器启动和运行后,运行。

$ docker-compose exec backend aerich init -t src.database.config.TORTOISE_ORM
Success create migrate location ./migrations
Success generate config file aerich.ini

$ docker-compose exec backend aerich init-db
Success create app migrate location migrations/models
Success generate schema for app "models"

第一条命令告诉Aerich,用于初始化模型和数据库之间连接的config dict在哪里。这创建了一个services/backend/aerich.ini 配置文件和一个 "services/backend/migrations "文件夹。

接下来,我们在 "services/backend/migrations/models "中为我们的三个模型--用户、笔记和aerich--生成一个迁移文件。这些文件也被应用到数据库中。

让我们把aerich.ini文件和 "migrations "文件夹复制到容器中。要做到这一点,请像这样更新Dockerfile

FROM python:3.9-buster

RUN mkdir app
WORKDIR /app

ENV PATH="${PATH}:/root/.local/bin"
ENV PYTHONPATH=.

COPY requirements.txt .
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

# for migrations
COPY migrations .
COPY aerich.ini .

COPY src/ .

更新。

$ docker-compose up -d --build

现在,当你对模型进行修改时,你可以运行以下命令来更新数据库。

$ docker-compose exec backend aerich migrate
$ docker-compose exec backend aerich upgrade

CRUD动作

现在让我们来连接基本的CRUD动作:创建、读取、更新和删除。

首先,由于我们需要定义用于序列化和反序列化数据的模式,在 "services/backend/src "中创建两个文件夹,称为 "crud "和 "schemas"。

为了确保我们的序列化程序能够读取我们的模型之间的关系,我们需要在main.py文件中初始化这些模型。

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from tortoise import Tortoise  # NEW

from src.database.register import register_tortoise
from src.database.config import TORTOISE_ORM


# enable schemas to read relationship between models
Tortoise.init_models(["src.database.models"], "models")  # NEW

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8080"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)


@app.get("/")
def home():
    return "Hello, World!"

现在,对任何对象进行的查询都可以从相关的表中获得数据。

接下来,在 "schemas "文件夹中,添加两个名为users.pynotes.py 的文件。

services/backend/src/schemas/users.py

from tortoise.contrib.pydantic import pydantic_model_creator

from src.database.models import Users


UserInSchema = pydantic_model_creator(
    Users, name="UserIn", exclude_readonly=True
)
UserOutSchema = pydantic_model_creator(
    Users, name="UserOut", exclude=["password", "created_at", "modified_at"]
)
UserDatabaseSchema = pydantic_model_creator(
    Users, name="User", exclude=["created_at", "modified_at"]
)

pydantic_model_creator是一个Tortoise助手,它允许我们从Tortoise模型中创建pydantic模型,我们将用它来创建和检索数据库记录。它接收了Users 模型和一个name 。你也可以exclude 特定的列。

模式。

  1. UserInSchema 是用于创建新的用户。
  2. UserOutSchema 是用于检索用户信息,以便我们的应用程序之外使用,用于返回给最终用户。
  3. UserDatabaseSchema 是用于检索用户信息,以便我们的应用程序使用,用于验证用户。

services/backend/src/schemas/notes.py:

from typing import Optional

from pydantic import BaseModel
from tortoise.contrib.pydantic import pydantic_model_creator

from src.database.models import Notes


NoteInSchema = pydantic_model_creator(
    Notes, name="NoteIn", exclude=["author_id"], exclude_readonly=True)
NoteOutSchema = pydantic_model_creator(
    Notes, name="Note", exclude =[
      "modified_at", "author.password", "author.created_at", "author.modified_at"
    ]
)


class UpdateNote(BaseModel):
    title: Optional[str]
    content: Optional[str]

模式。

  1. NoteInSchema 用于创建新的注释。
  2. NoteOutSchema 是用于检索笔记。
  3. UpdateNote 是用来更新笔记的。

接下来,将users.pynotes.py文件添加到 "services/backend/src/crud "文件夹中。

services/backend/src/crud/users.py

from fastapi import HTTPException
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist, IntegrityError

from src.database.models import Users
from src.schemas.users import UserOutSchema


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


async def create_user(user) -> UserOutSchema:
    user.password = pwd_context.encrypt(user.password)

    try:
        user_obj = await Users.create(**user.dict(exclude_unset=True))
    except IntegrityError:
        raise HTTPException(status_code=401, detail=f"Sorry, that username already exists.")

    return await UserOutSchema.from_tortoise_orm(user_obj)


async def delete_user(user_id, current_user):
    try:
        db_user = await UserOutSchema.from_queryset_single(Users.get(id=user_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"User {user_id} not found")

    if db_user.id == current_user.id:
        deleted_count = await Users.filter(id=user_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"User {user_id} not found")
        return f"Deleted user {user_id}"

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

在这里,我们定义了用于创建和删除用户的辅助函数。

  1. create_user 接收一个用户,加密 ,然后将该用户添加到数据库中。user.password
  2. delete_user 从数据库中删除一个用户。它还通过确保请求是由当前认证的用户发起的来保护用户。

services/backend/requirements.txt中添加所需的依赖项。

aerich==0.5.5
asyncpg==0.23.0
bcrypt==3.2.0
fastapi==0.68.0
passlib==1.7.4
tortoise-orm==0.17.6
uvicorn==0.14.0

services/backend/src/crud/notes.py

from fastapi import HTTPException
from tortoise.exceptions import DoesNotExist

from src.database.models import Notes
from src.schemas.notes import NoteOutSchema


async def get_notes():
    return await NoteOutSchema.from_queryset(Notes.all())


async def get_note(note_id) -> NoteOutSchema:
    return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))


async def create_note(note, current_user) -> NoteOutSchema:
    note_dict = note.dict(exclude_unset=True)
    note_dict["author_id"] = current_user.id
    note_obj = await Notes.create(**note_dict)
    return await NoteOutSchema.from_tortoise_orm(note_obj)


async def update_note(note_id, note, current_user) -> NoteOutSchema:
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        await Notes.filter(id=note_id).update(**note.dict(exclude_unset=True))
        return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))

    raise HTTPException(status_code=403, detail=f"Not authorized to update")


async def delete_note(note_id, current_user):
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        deleted_count = await Notes.filter(id=note_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
        return f"Deleted note {note_id}"

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

在这里,我们创建了辅助函数来实现笔记资源的所有CRUD操作。请注意update_notedelete_note 辅助函数。我们添加了一个检查,以确保请求是来自笔记作者。

你的文件夹结构现在应该是这样的。

├── docker-compose.yml
└── services
    ├── backend
    │   ├── Dockerfile
    │   ├── aerich.ini
    │   ├── migrations
    │   │   └── models
    │   │       └── 1_20210811013714_None.sql
    │   ├── requirements.txt
    │   └── src
    │       ├── crud
    │       │   ├── notes.py
    │       │   └── users.py
    │       ├── database
    │       │   ├── config.py
    │       │   ├── models.py
    │       │   └── register.py
    │       ├── main.py
    │       └── schemas
    │           ├── notes.py
    │           └── users.py
    └── frontend
        ├── .gitignore
        ├── Dockerfile
        ├── README.md
        ├── babel.config.js
        ├── package-lock.json
        ├── package.json
        ├── public
        │   ├── favicon.ico
        │   └── index.html
        └── src
            ├── App.vue
            ├── assets
            │   └── logo.png
            ├── components
            │   └── HelloWorld.vue
            ├── main.js
            ├── router
            │   └── index.js
            └── views
                ├── About.vue
                └── Home.vue

这是一个很好的时间来停止,回顾你到目前为止所完成的工作,并连接pytest来测试CRUD帮助器。需要帮助吗?回顾一下用FastAPI和Pytest开发和测试异步API

在我们添加路由处理程序之前,让我们连接认证以保护特定的路由。

首先,我们需要在 "services/backend/src/schemas "文件夹下一个名为token.py的新文件中创建一些pydantic模型。

from typing import Optional

from pydantic import BaseModel


class TokenData(BaseModel):
    username: Optional[str] = None


class Status(BaseModel):
    message: str

我们定义了两个模式。

  1. TokenData 是为了确保来自token的用户名是一个字符串。
  2. Status 是用于向终端用户发送状态信息。

在 "services/backend/src "文件夹下创建另一个名为 "auth "的文件夹。然后,在其中添加两个新的文件,叫做jwthandler.pyusers.py

services/backend/src/auth/jwthandler.py

import os
from datetime import datetime, timedelta
from typing import Optional

from fastapi import Depends, HTTPException, Request
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
from fastapi.security import OAuth2
from fastapi.security.utils import get_authorization_scheme_param
from jose import JWTError, jwt
from tortoise.exceptions import DoesNotExist

from src.schemas.token import TokenData
from src.schemas.users import UserOutSchema
from src.database.models import Users


SECRET_KEY = os.environ.get("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30


class OAuth2PasswordBearerCookie(OAuth2):
    def __init__(
        self,
        token_url: str,
        scheme_name: str = None,
        scopes: dict = None,
        auto_error: bool = True,
    ):
        if not scopes:
            scopes = {}
        flows = OAuthFlowsModel(password={"tokenUrl": token_url, "scopes": scopes})
        super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)

    async def __call__(self, request: Request) -> Optional[str]:
        authorization: str = request.cookies.get("Authorization")
        scheme, param = get_authorization_scheme_param(authorization)

        if not authorization or scheme.lower() != "bearer":
            if self.auto_error:
                raise HTTPException(
                    status_code=401,
                    detail="Not authenticated",
                    headers={"WWW-Authenticate": "Bearer"},
                )
            else:
                return None

        return param


security = OAuth2PasswordBearerCookie(token_url="/login")


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()

    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)

    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

    return encoded_jwt


async def get_current_user(token: str = Depends(security)):
    credentials_exception = HTTPException(
        status_code=401,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = TokenData(username=username)
    except JWTError:
        raise credentials_exception

    try:
        user = await UserOutSchema.from_queryset_single(
            Users.get(username=token_data.username)
        )
    except DoesNotExist:
        raise credentials_exception

    return user

注意事项。

  1. OAuth2PasswordBearerCookie 是一个继承自 的类,用于读取受保护路由的请求头中发送的cookie。它确保cookie的存在,然后从cookie中返回token。OAuth2
  2. create_access_token 函数接收用户的用户名,用过期时间编码,并从中生成一个令牌。
  3. get_current_user 解码该令牌并验证用户。

python-jose用于对JWT令牌进行编码和解码。在需求文件中添加该包。

aerich==0.5.5
asyncpg==0.23.0
bcrypt==3.2.0
fastapi==0.68.0
passlib==1.7.4
python-jose==3.3.0
tortoise-orm==0.17.6
uvicorn==0.14.0

docker-compose.yml中添加SECRET_KEY 环境变量。

services/backend/src/auth/users.py

from fastapi import HTTPException, Depends, status
from fastapi.security import OAuth2PasswordRequestForm
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist

from src.database.models import Users
from src.schemas.users import UserDatabaseSchema


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password):
    return pwd_context.hash(password)


async def get_user(username: str):
    return await UserDatabaseSchema.from_queryset_single(Users.get(username=username))


async def validate_user(user: OAuth2PasswordRequestForm = Depends()):
    try:
        db_user = await get_user(user.username)
    except DoesNotExist:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
        )

    if not verify_password(user.password, db_user.password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
        )

    return db_user

注意。

  • validate_user 是用来在用户登录时验证他们。如果用户名或密码不正确,它会向用户抛出一个 错误。401_UNAUTHORIZED

最后,让我们更新CRUD帮助器,以便它们使用Status pydantic模型。

class Status(BaseModel):
    message: str

services/backend/src/crud/users.py

from fastapi import HTTPException
from passlib.context import CryptContext
from tortoise.exceptions import DoesNotExist, IntegrityError

from src.database.models import Users
from src.schemas.token import Status  # NEW
from src.schemas.users import UserOutSchema


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


async def create_user(user) -> UserOutSchema:
    user.password = pwd_context.encrypt(user.password)

    try:
        user_obj = await Users.create(**user.dict(exclude_unset=True))
    except IntegrityError:
        raise HTTPException(status_code=401, detail=f"Sorry, that username already exists.")

    return await UserOutSchema.from_tortoise_orm(user_obj)


async def delete_user(user_id, current_user) -> Status:  # UPDATED
    try:
        db_user = await UserOutSchema.from_queryset_single(Users.get(id=user_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"User {user_id} not found")

    if db_user.id == current_user.id:
        deleted_count = await Users.filter(id=user_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"User {user_id} not found")
        return Status(message=f"Deleted user {user_id}")  # UPDATED

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

*services/backend/src/crud/notes.*py:

from fastapi import HTTPException
from tortoise.exceptions import DoesNotExist

from src.database.models import Notes
from src.schemas.notes import NoteOutSchema
from src.schemas.token import Status  # NEW


async def get_notes():
    return await NoteOutSchema.from_queryset(Notes.all())


async def get_note(note_id) -> NoteOutSchema:
    return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))


async def create_note(note, current_user) -> NoteOutSchema:
    note_dict = note.dict(exclude_unset=True)
    note_dict["author_id"] = current_user.id
    note_obj = await Notes.create(**note_dict)
    return await NoteOutSchema.from_tortoise_orm(note_obj)


async def update_note(note_id, note, current_user) -> NoteOutSchema:
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        await Notes.filter(id=note_id).update(**note.dict(exclude_unset=True))
        return await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))

    raise HTTPException(status_code=403, detail=f"Not authorized to update")


async def delete_note(note_id, current_user) -> Status:  # UPDATED
    try:
        db_note = await NoteOutSchema.from_queryset_single(Notes.get(id=note_id))
    except DoesNotExist:
        raise HTTPException(status_code=404, detail=f"Note {note_id} not found")

    if db_note.author.id == current_user.id:
        deleted_count = await Notes.filter(id=note_id).delete()
        if not deleted_count:
            raise HTTPException(status_code=404, detail=f"Note {note_id} not found")
        return Status(message=f"Deleted note {note_id}")  # UPDATED

    raise HTTPException(status_code=403, detail=f"Not authorized to delete")

路由

有了pydantic模型、CRUD助手和JWT认证,我们现在就可以用路由处理程序把一切都粘在一起。

在我们的 "src "文件夹中创建一个 "routes "文件夹,并添加两个文件:users.pynotes.py

users*.py*。

from datetime import timedelta

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordRequestForm

from tortoise.contrib.fastapi import HTTPNotFoundError

import src.crud.users as crud
from src.auth.users import validate_user
from src.schemas.token import Status
from src.schemas.users import UserInSchema, UserOutSchema

from src.auth.jwthandler import (
    create_access_token,
    get_current_user,
    ACCESS_TOKEN_EXPIRE_MINUTES,
)


router = APIRouter()


@router.post("/register", response_model=UserOutSchema)
async def create_user(user: UserInSchema) -> UserOutSchema:
    return await crud.create_user(user)


@router.post("/login")
async def login(user: OAuth2PasswordRequestForm = Depends()):
    user = await validate_user(user)

    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    token = jsonable_encoder(access_token)
    content = {"message": "You've successfully logged in. Welcome back!"}
    response = JSONResponse(content=content)
    response.set_cookie(
        "Authorization",
        value=f"Bearer {token}",
        httponly=True,
        max_age=1800,
        expires=1800,
        samesite="Lax",
        secure=False,
    )

    return response


@router.get(
    "/users/whoami", response_model=UserOutSchema, dependencies=[Depends(get_current_user)]
)
async def read_users_me(current_user: UserOutSchema = Depends(get_current_user)):
    return current_user


@router.delete(
    "/user/{user_id}",
    response_model=Status,
    responses={404: {"model": HTTPNotFoundError}},
    dependencies=[Depends(get_current_user)],
)
async def delete_user(
    user_id: int, current_user: UserOutSchema = Depends(get_current_user)
) -> Status:
    return await crud.delete_user(user_id, current_user)

这里发生了什么?

  1. get_current_user 是附在 和 ,以保护路由。除非用户以 的身份登录,否则他们将无法访问它们。read_users_me delete_user current_user
  2. /register 利用 帮助器来创建一个新的用户并将其添加到数据库中。crud.create_user
  3. /login 通过来自 的包含用户名和密码的表单数据接收一个用户。然后,它用用户调用 函数,如果 ,则抛出一个异常。一个访问令牌从 函数中生成,然后作为一个cookie附在响应头中。OAuth2PasswordRequestForm validate_user None create_access_token
  4. /users/whoami get_current_user ,并将结果作为一个响应发送回来。
  5. /user/{user_id} 是一个动态路由,接收 ,并将其与 的结果一起发送到 帮助器。user_id current_user crud.delete_user

OAuth2PasswordRequestForm 需要Python-Multipart。把它添加到services/backend/requirements.txt

aerich==0.5.5
asyncpg==0.23.0
bcrypt==3.2.0
fastapi==0.68.0
passlib==1.7.4
python-jose==3.3.0
python-multipart==0.0.5
tortoise-orm==0.17.6
uvicorn==0.14.0

在用户成功认证后,通过Set-Cookie在响应头中发回一个cookie。当用户提出后续请求时,它会被附加到请求头中。

请注意。

response.set_cookie(
    "Authorization",
    value=f"Bearer {token}",
    httponly=True,
    max_age=1800,
    expires=1800,
    samesite="Lax",
    secure=False,
)

注意。

  1. 该cookie的名称是Authorization ,其值是Bearer {token} ,其中token 是实际的令牌。它在1800秒(30分钟)后过期。
  2. 为了安全起见,httponly被设置为True ,这样客户端的脚本就无法访问cookie。这有助于防止跨网站脚本(XSS)攻击。
  3. samesite设置为Lax ,浏览器只在一些HTTP请求中发送cookie。这有助于防止跨网站请求伪造(CSRF)攻击。
  4. 最后,secure 被设置为False ,因为我们将在本地测试,没有HTTPS。确保在生产中把它设置为True

notes.py

from typing import List

from fastapi import APIRouter, Depends, HTTPException
from tortoise.contrib.fastapi import HTTPNotFoundError
from tortoise.exceptions import DoesNotExist

import src.crud.notes as crud
from src.auth.jwthandler import get_current_user
from src.schemas.notes import NoteOutSchema, NoteInSchema, UpdateNote
from src.schemas.token import Status
from src.schemas.users import UserOutSchema


router = APIRouter()


@router.get(
    "/notes",
    response_model=List[NoteOutSchema],
    dependencies=[Depends(get_current_user)],
)
async def get_notes():
    return await crud.get_notes()


@router.get(
    "/note/{note_id}",
    response_model=NoteOutSchema,
    dependencies=[Depends(get_current_user)],
)
async def get_note(note_id: int) -> NoteOutSchema:
    try:
        return await crud.get_note(note_id)
    except DoesNotExist:
        raise HTTPException(
            status_code=404,
            detail="Note does not exist",
        )


@router.post(
    "/notes", response_model=NoteOutSchema, dependencies=[Depends(get_current_user)]
)
async def create_note(
    note: NoteInSchema, current_user: UserOutSchema = Depends(get_current_user)
) -> NoteOutSchema:
    return await crud.create_note(note, current_user)


@router.patch(
    "/note/{note_id}",
    dependencies=[Depends(get_current_user)],
    response_model=NoteOutSchema,
    responses={404: {"model": HTTPNotFoundError}},
)
async def update_note(
    note_id: int,
    note: UpdateNote,
    current_user: UserOutSchema = Depends(get_current_user),
) -> NoteOutSchema:
    return await crud.update_note(note_id, note, current_user)


@router.delete(
    "/note/{note_id}",
    response_model=Status,
    responses={404: {"model": HTTPNotFoundError}},
    dependencies=[Depends(get_current_user)],
)
async def delete_note(
    note_id: int, current_user: UserOutSchema = Depends(get_current_user)
):
    return await crud.delete_note(note_id, current_user)

请自行查阅。

最后,我们需要在main.py中连接我们的路由。

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from tortoise import Tortoise

from src.database.register import register_tortoise
from src.database.config import TORTOISE_ORM


# enable schemas to read relationship between models
Tortoise.init_models(["src.database.models"], "models")

"""
import 'from src.routes import users, notes' must be after 'Tortoise.init_models'
why?
https://stackoverflow.com/questions/65531387/tortoise-orm-for-python-no-returns-relations-of-entities-pyndantic-fastapi
"""
from src.routes import users, notes

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8080"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
app.include_router(users.router)
app.include_router(notes.router)

register_tortoise(app, config=TORTOISE_ORM, generate_schemas=False)


@app.get("/")
def home():
    return "Hello, World!"

更新图像以安装新的依赖项。

$ docker-compose up -d --build

导航到http://localhost:5000/docs,查看Swagger UI。

现在你可以手动测试每个路由。

你应该测试什么?

路由方法快乐的路径不快乐的路径(s)
/注册发布你可以注册一个新用户重复的用户名,缺少用户名或密码字段
/login发布你可以登录一个用户用户名或密码不正确
/users/whoami获取认证后返回用户信息没有Authorization cookie或无效的token
/user/{user_id}DELETE你可以在认证后删除一个用户,而且你要删除的是当前用户未找到用户,用户存在但未被授权删除
/notes获取通过认证后,你可以获得所有笔记未通过认证
/notes发布你可以在通过认证后添加一个注释未通过认证
/note/{note_id}GET你可以在通过认证且存在的情况下获得该笔记未认证,已认证但笔记不存在
/note/{note_id}DELETE你可以在认证后删除该笔记,该笔记存在,并且当前用户创建了该笔记。未通过认证,通过认证但笔记不存在,不存在但无权删除
/note/{note_id}PATCH你可以在认证后更新该笔记,该笔记存在,并且当前用户创建了该笔记。未认证,已认证但笔记不存在,不存在但无权更新

这是很繁琐的手工测试。用pytest添加自动化测试是个好主意。同样,回顾一下用FastAPI和Pytest开发和测试异步API,以获得这方面的帮助。

有了这些,让我们把注意力转移到前端。

Vuex

Vuex是Vue的状态管理模式和库。它对状态进行全局管理。在Vuex中,通过动作调用的突变被用来改变状态。

vuex-persistedstate让你把Vuex状态持久化到本地存储中,这样你就可以在页面重新加载后重新补充Vuex状态。

在 "services/frontend/src "中添加一个名为 "store "的新文件夹。在 "store "中,添加以下文件和文件夹。

services/frontend/src/store
├── index.js
└── modules
    ├── notes.js
    └── users.js

services/frontend/src/store/index.js

import createPersistedState from "vuex-persistedstate";
import Vue from 'vue';
import Vuex from 'vuex';

import notes from './modules/notes';
import users from './modules/users';


Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    notes,
    users,
  },
  plugins: [createPersistedState()]
});

在这里,我们创建了一个新的Vuex商店,有两个模块,notes.jsusers.jsCreatePersistedState ,作为一个插件添加,这样当我们重新加载浏览器时,每个模块的状态不会丢失。

services/frontend/src/store/modules/notes.js

import axios from 'axios';

const state = {
  notes: null,
  note: null
};

const getters = {
  stateNotes: state => state.notes,
  stateNote: state => state.note,
};

const actions = {
  async createNote({dispatch}, note) {
    await axios.post('notes', note);
    await dispatch('getNotes');
  },
  async getNotes({commit}) {
    let {data} = await axios.get('notes');
    commit('setNotes', data);
  },
  async viewNote({commit}, id) {
    let {data} = await axios.get(`note/${id}`);
    commit('setNote', data);
  },
  // eslint-disable-next-line no-empty-pattern
  async updateNote({}, note) {
    await axios.patch(`note/${note.id}`, note.form);
  },
  // eslint-disable-next-line no-empty-pattern
  async deleteNote({}, id) {
    await axios.delete(`note/${id}`);
  }
};

const mutations = {
  setNotes(state, notes){
    state.notes = notes;
  },
  setNote(state, note){
    state.note = note;
  },
};

export default {
  state,
  getters,
  actions,
  mutations
};

注释。

  1. state - 和 都默认为 。它们将分别被更新为一个对象和一个对象数组。note notes null
  2. getters - 检索 和 的值。state.note state.notes
  3. actions - 每个动作都会通过Axios进行HTTP调用,然后有几个动作会执行一个副作用--例如,调用相关的突变来更新状态或一个不同的动作。
  4. mutations - 这两个动作都会对状态进行改变,从而更新 和 。state.note state.notes

services/frontend/src/store/modules/users.js

import axios from 'axios';

const state = {
  user: null,
};

const getters = {
  isAuthenticated: state => !!state.user,
  stateUser: state => state.user,
};

const actions = {
  async register({dispatch}, form) {
    await axios.post('register', form);
    let UserForm = new FormData();
    UserForm.append('username', form.username);
    UserForm.append('password', form.password);
    await dispatch('logIn', UserForm);
  },
  async logIn({dispatch}, user) {
    await axios.post('login', user);
    await dispatch('viewMe');
  },
  async viewMe({commit}) {
    let {data} = await axios.get('users/whoami');
    await commit('setUser', data);
  },
  // eslint-disable-next-line no-empty-pattern
  async deleteUser({}, id) {
    await axios.delete(`user/${id}`);
  },
  async logOut({commit}) {
    let user = null;
    commit('logout', user);
  }
};

const mutations = {
  setUser(state, username) {
    state.user = username;
  },
  logout(state, user){
    state.user = user;
  },
};

export default {
  state,
  getters,
  actions,
  mutations
};

注释。

  1. isAuthenticated - 如果 不是 ,返回 ,否则返回 。state.user null true false
  2. stateUser - 返回 的值。state.user
  3. register - 向我们在后台创建的 端点发送一个POST请求,创建一个/register FormData实例,并将其分派给 动作,以记录注册用户。logIn

最后,在services/frontend/src/main.js中把商店与根实例连接起来。

import 'bootstrap/dist/css/bootstrap.css';
import axios from 'axios';
import Vue from 'vue';

import App from './App.vue';
import router from './router';
import store from './store';


axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://localhost:5000/';  // the FastAPI backend

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

组件、视图和路由

接下来,我们将开始添加组件和视图。

组件

services/frontend/src/components/NavBar.vue


  
    
      
        FastAPI + Vue
        
          
        
        
          
            
              Home
            
            
              Dashboard
            
            
              My Profile
            
            
              Log Out
            
          
          
            
              Home
            
            
              Register
            
            
              Log In
            
          
        
      
    
  





NavBar ,用于导航到应用程序中的其他页面。isLoggedIn 属性用于检查用户是否从商店登录。如果他们登录了,他们就可以访问仪表板和个人资料,包括注销链接。

logout 函数派发了logOut 动作,并将用户重定向到/login 路径。

接下来,让我们把NavBar 组件添加到主App 组件中。

服务/frontend/src/App.vue

现在你应该可以看到新的导航栏在http://localhost:8080/。

视图

首页

services/frontend/src/views/Home.vue


  
    This site is built with FastAPI and Vue.

    
      Click here to view all notes.
    
    
      Register
       or 
      Log In
    
  


在这里,根据isLoggedIn 属性的值,向终端用户显示所有笔记的链接或注册/加入的链接。

接下来,在services/frontend/src/router/index.js中把视图和我们的路由连接起来。

import Vue from 'vue';
import VueRouter from 'vue-router';

import Home from '@/views/Home.vue';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: "Home",
    component: Home,
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

导航到http://localhost:8080/。你应该看到。

注册

services/frontend/src/views/Register.vue


  
    
      
        Username:
        
      
      
        Full Name:
        
      
      
        Password:
        
      
      Submit
    
  



这个表单输入了用户名、全名和密码,所有这些都是user 对象的属性。Register 动作通过mapActions被映射(导入)到组件中。然后,this.Register 被调用并传递给user 对象。如果结果是成功的,用户就会被重定向到/dashboard

更新路由器。

import Vue from 'vue';
import VueRouter from 'vue-router';

import Home from '@/views/Home.vue';
import Register from '@/views/Register.vue';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: "Home",
    component: Home,
  },
  {
    path: '/register',
    name: 'Register',
    component: Register,
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

测试http://localhost:8080/register,确保可以注册一个新用户。

登录

services/frontend/src/views/Login.vue


  
    
      
        Username:
        
      
      
        Password:
        
      
      Submit
    
  



提交后,logIn 动作被调用。成功后,用户被重定向到/dashboard

更新路由器。

import Vue from 'vue';
import VueRouter from 'vue-router';

import Home from '@/views/Home.vue';
import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: "Home",
    component: Home,
  },
  {
    path: '/register',
    name: 'Register',
    component: Register,
  },
  {
    path: '/login',
    name: 'Login',
    component: Login,
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

测试http://localhost:8080/login,确保可以登录一个注册用户。

仪表板

services/frontend/src/views/Dashboard.vue


  
    
      Add new note
      

      
        
          Title:
          
        
        
          Content:
          
        
        Submit
      
    

    
      Notes
      

      
        
          
            
              
                Note Title: {{ note.title }}
                Author: {{ note.author.username }}
                View
              
            
          
        
      

      
        Nothing to see. Check back later.
      
    
  



仪表板显示来自API的所有笔记,也允许用户创建新的笔记。注意到了。

View

我们很快就会在这里配置路由和视图,但要注意的关键是,路由接收笔记的ID,并将用户发送到相应的路由--即note/1,note/2,note/10,note/101, 等等。

created 函数在创建组件的过程中被调用,它与组件的生命周期挂钩。在其中,我们调用了映射的getNotes 动作。

路由器。

import Vue from 'vue';
import VueRouter from 'vue-router';

import Dashboard from '@/views/Dashboard.vue';
import Home from '@/views/Home.vue';
import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: "Home",
    component: Home,
  },
  {
    path: '/register',
    name: 'Register',
    component: Register,
  },
  {
    path: '/login',
    name: 'Login',
    component: Login,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: Dashboard,
    meta: {requiresAuth: true},
  },
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

确保在你注册或登录后,你被重定向到仪表板,并且现在显示正确。

你应该也能添加一个注释。

简介

services/frontend/src/views/Profile.vue


  
    Your Profile
    
    
      Full Name: {{ user.full_name }}
      Username: {{ user.username }}
      Delete Account
    
  



删除账户 "按钮调用deleteUser ,它将user.id 发送到deleteUser 动作,将用户注销,然后将用户重定向到主页。

路由器。

import Vue from 'vue';
import VueRouter from 'vue-router';

import Dashboard from '@/views/Dashboard';
import Home from '@/views/Home.vue';
import Login from '@/views/Login';
import Profile from '@/views/Profile';
import Register from '@/views/Register';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: "Home",
    component: Home,
  },
  {
    path: '/register',
    name: 'Register',
    component: Register,
  },
  {
    path: '/login',
    name: 'Login',
    component: Login,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: Dashboard,
    meta: {requiresAuth: true},
  },
  {
    path: '/profile',
    name: 'Profile',
    component: Profile,
    meta: {requiresAuth: true},
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

确保你可以在http://localhost:8080/profile,查看你的个人资料。也测试一下删除功能。

注意

services/frontend/src/views/Note.vue


  
    Title: {{ note.title }}
    Content: {{ note.content }}
    Author: {{ note.author.username }}

    
      Edit
      Delete
    
  




这个视图加载从它的路由中作为道具传递给它的任何笔记ID的笔记细节。

在创建的生命周期钩子中,我们将props 中的id 传递给商店中的viewNote 动作。stateUserstateNote 通过mapGetters映射到组件中,分别为usernote 。删除 "按钮触发了deleteNote 方法,该方法反过来调用deleteNote 动作,并将用户重定向到/dashboard 路线。

我们使用一个if语句来显示 "编辑 "和 "删除 "按钮,只有当note.author 与登录的用户相同时才会显示。

路由器。

import Vue from 'vue';
import VueRouter from 'vue-router';

import Dashboard from '@/views/Dashboard';
import Home from '@/views/Home.vue';
import Login from '@/views/Login';
import Note from '@/views/Note';
import Profile from '@/views/Profile';
import Register from '@/views/Register';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: "Home",
    component: Home,
  },
  {
    path: '/register',
    name: 'Register',
    component: Register,
  },
  {
    path: '/login',
    name: 'Login',
    component: Login,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: Dashboard,
    meta: {requiresAuth: true},
  },
  {
    path: '/profile',
    name: 'Profile',
    component: Profile,
    meta: {requiresAuth: true},
  },
  {
    path: '/note/:id',
    name: 'Note',
    component: Note,
    meta: {requiresAuth: true},
    props: true,
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

因为,这个路由是dianmic的,我们把props 设置为true ,这样笔记ID就会作为一个道具从URL传递给视图。

在仪表板上,点击链接查看一个新笔记。确保 "编辑 "和 "删除 "按钮只有在登录的用户是笔记创建者时才会显示。

另外,确保你可以删除一个注释。

编辑笔记

services/frontend/src/views/EditNote.vue


  
    Edit note
    

    
      
        Title:
        
      
      
        Content:
        
      
      Submit
    
  



这个视图显示一个预装的表单,其中有笔记的标题和内容,供作者编辑和更新。与Note 视图类似,笔记的id 是作为一个道具从路由器对象传递给页面的。

getNote 方法被用来加载带有笔记信息的表单。它将id 传递给viewNote 动作,并使用note 的getter值来填充表单。当组件被创建时,getNote 函数被调用。

路由器。

import Vue from 'vue';
import VueRouter from 'vue-router';

import Dashboard from '@/views/Dashboard';
import EditNote from '@/views/EditNote';
import Home from '@/views/Home.vue';
import Login from '@/views/Login';
import Note from '@/views/Note';
import Profile from '@/views/Profile';
import Register from '@/views/Register';

Vue.use(VueRouter);

const routes = [
  {
    path: '/',
    name: "Home",
    component: Home,
  },
  {
    path: '/register',
    name: 'Register',
    component: Register,
  },
  {
    path: '/login',
    name: 'Login',
    component: Login,
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: Dashboard,
    meta: {requiresAuth: true},
  },
  {
    path: '/profile',
    name: 'Profile',
    component: Profile,
    meta: {requiresAuth: true},
  },
  {
    path: '/note/:id',
    name: 'Note',
    component: Note,
    meta: {requiresAuth: true},
    props: true,
  },
  {
    path: '/note/:id',
    name: 'EditNote',
    component: EditNote,
    meta: {requiresAuth: true},
    props: true,
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

export default router;

在继续前进之前,手动测试一下。

处理未经授权的用户和过期的令牌

你是否注意到,有些路由有meta: {requiresAuth: true}, ,附在它们身上?这些路由不应该被未认证的用户访问。

例如,如果你在没有认证的情况下导航到http://localhost:8080/profile,会发生什么?你应该能够查看该页面,但没有数据加载,对吗?让我们改变一下,这样用户就会被重定向到/login 路线,而不是。

因此,为了防止未经授权的访问,让我们在services/frontend/src/router/index.js中添加一个Navigation Guard

import Vue from 'vue';
import VueRouter from 'vue-router';

import store from '@/store';  // NEW

import Dashboard from '@/views/Dashboard';
import EditNote from '@/views/EditNote';
import Home from '@/views/Home.vue';
import Login from '@/views/Login';
import Note from '@/views/Note';
import Profile from '@/views/Profile';
import Register from '@/views/Register';

Vue.use(VueRouter);

const routes = [
  ...
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes,
});

// NEW
router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (store.getters.isAuthenticated) {
      next();
      return;
    }
    next('/login');
  } else {
    next();
  }
});

export default router;

登出。然后,再次测试http://localhost:8080/profile。你应该被重定向到/login 路径。

过期的令牌

记住,令牌在三十分钟后过期。

ACCESS_TOKEN_EXPIRE_MINUTES = 30

当这种情况发生时,用户应该被注销并重定向到登录页面。为了处理这个问题,让我们在services/frontend/src/main.js中添加一个Axios拦截器

import 'bootstrap/dist/css/bootstrap.css';
import axios from 'axios';
import Vue from 'vue';

import App from './App.vue';
import router from './router';
import store from './store';


axios.defaults.withCredentials = true;
axios.defaults.baseURL = 'http://localhost:5000/';  // the FastAPI backend

Vue.config.productionTip = false;

// NEW
axios.interceptors.response.use(undefined, function (error) {
  if (error) {
    const originalRequest = error.config;
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      store.dispatch('logOut');
      return router.push('/login')
    }
  }
});

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app');

如果你想测试,把ACCESS_TOKEN_EXPIRE_MINUTES = 30 改成类似ACCESS_TOKEN_EXPIRE_MINUTES = 1 。请记住,cookie本身仍然持续30分钟。过期的是token。

总结

本教程涵盖了用Vue和FastAPI设置CRUD应用程序的基本知识。除了这些应用,你还使用了Docker来简化开发,并增加了认证。

通过回顾开始时的目标并通过下面的每个挑战来检查你的理解。

你可以在GitHub的fastapi-vuerepo中找到源代码。干杯!

--

想了解更多?

  1. 测试所有的东西。停止黑客行为。开始确保你的应用程序按预期工作。需要帮助吗?请查看以下资源。
  2. 添加警报,向终端用户显示适当的成功和错误信息。查看《用Flask和Vue.js开发单页应用》中的警报组件部分,了解更多关于用Bootstrap和Vue设置警报的信息。
  3. 在后端添加一个新的端点,当用户注销时被调用,这样可以更新cookie。