FastAPIをサーバとして、VueでSPAアプリの雛形を試作してみました。全プログラムを掲載します。Vueプログラムの割合が圧倒的に多いですが、本記事はあくまでFastAPIに注目したものです。
FastAPIのプログラムについては、過去記事のものをほぼそのまま流用しました。
【過去記事】
FastAPI OAuth2パスワード認証 - Qiita
FastAPI OAuth2 クライアント - Qiita
ログイン機能をOAuth2認証で行っていますが、Vue側で得られたtokenをlocalstorageに保存して各コンポーネントで共有するようにしています。Vueについては以下の記事を参考にさせていただきました。
【Vue + vue-cli】vue-routerの基本的な使い方
【Vue.js】npmでvueのプロジェクトをHello Worldするまで
1. ディレクトリ構成
ポイントはFastAPIプロジェクトディレクトリのサブディレクトリにVueのプロジェクトを作成することです。FastAPIのメインプログラムからVueプロジェクトをマウントします。詳細は後ほどプログラムの説明の時に示します。
fastapi_vue # FastAPIプロジェクトトップ
├── main.py # FastAPIメインプログラム
├── vue # Vue用のサブディレクトリ
│ ├── vuegui # Vueプロジェクトトップ
│ ├── package.json
│ ├── public
│ ├── src # Vueのプログラムディレクトリ
│ ├── components
│ ├── pages
│ ├── router
│ ├── App.vue
│ ├── main.js
│ ├── ...
│ └── ...
│ ├── dist # FastAPIメインのマウント先
│ ├── node_modules
│ ├── ...
│ ├── ...
│ └── ...
2. FastAPIプログラム
本プログラムは「FastAPI OAuth2パスワード認証 - Qiita」のものとほぼ同じですが、一点変更点があります。Vueのデプロイとも関係のあるものですが、重要なポイントですので以下で述べておきます。
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from fastapi.staticfiles import StaticFiles
# to get a string like this run:
# openssl rand -hex 32
SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "johndoe@example.com",
"hashed_password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW",
"disabled": False,
}
}
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
class User(BaseModel):
username: str
email: Optional[str] = None
full_name: Optional[str] = None
disabled: Optional[bool] = None
class UserInDB(User):
hashed_password: str
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
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)
def get_user(db, username: str):
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
def authenticate_user(fake_db, username: str, password: str):
user = get_user(fake_db, username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
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(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
print(f'### token = {token}, payload = {payload}')
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = get_user(fake_users_db, username=token_data.username)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@app.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(fake_users_db, form_data.username, form_data.password)
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
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/users/me/", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user
@app.get("/users/me/items/")
async def read_own_items(current_user: User = Depends(get_current_active_user)):
return [{"item_id": "Foo", "owner": current_user.username}]
# must mount on root(/) after all path operations, otherwise they will be overrided !
app.mount("/", StaticFiles(directory="vue/vuequi/dist", html=True), name="html")
Vueのアプリは、「npm run build」コマンドで、distディレクトリにdeployされますが、アプリのプログラムは全てルートディレクトリ(/)を参照しています。それなのでFastAPIでapp.mountするときは(/)にマウントする必要があります。
# must mount on root(/) after all path operations, otherwise they will be overrided !
app.mount("/", StaticFiles(directory="vue/vuequi/dist", html=True), name="html")
しかしプログラムの最初の位置で、FastAPIで(/)にapp.mountすると、FastAPIのパスの定義が全て上書きされてしまうので、このapp.mount文は最後に置く必要があります。
結構、ここの部分がキモのような気がします。私はマル一日悩みました。わかってみれば当然なのですが。
3. Vueプログラム
最初にlocalStorageの初期化を行っておきます。
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
localStorage.setItem('token', '');
const app = createApp(App)
app.use(router).mount('#app')
isLogin変数で、現在ログイン状態であるか否かを判断して、画面を切り替えています。
changeLogin関数は、LoginコンポーネントおよびLogoutコンポーネントの、子コンポーネントからのemitを受けて、isLoginを更新するものです。
<template>
<div id="app">
<Header v-bind:isLogin="isLogin" />
<router-view v-on:change-login="changeLogin" />
<Footer v-bind:isLogin="isLogin" />
</div>
</template>
<script>
import Header from './components/Header.vue'
import Footer from './components/Footer.vue'
export default {
components: {
Header,
Footer
},
data () {
return {
isLogin: false
}
},
methods : {
changeLogin: function(val) {
this.isLogin = val
}
}
}
</script>
<style>
body {
margin: 0;
}
#app {
font-family: "Avenir", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
</style>
SPAのルーティングの定義です。
import { createRouter, createWebHistory } from 'vue-router'
import Top from "@/pages/Top";
import Login from "@/pages/Login";
import Logout from "@/pages/Logout";
import Profile from "@/pages/Profile";
const routes = [
{
path: "/",
name: "Top",
component: Top
},
{
path: "/Login",
name: "Login",
component: Login
},
{
path: "/Logout",
name: "Logout",
component: Logout
},
{
path: "/Profile",
name: "Profile",
component: Profile
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
ヘッダーとフッターが続きますが、ほぼ同じものです。router-linkでルータのリンクがあります。
<template>
<header>
<div class="row" v-if="isLogin">
{{title}}
<router-link to="/">Top</router-link>
<router-link to="/Profile">Profile</router-link>
<router-link to="/Logout">Logout</router-link>
</div>
<div class="row" v-else>
{{title}}
<router-link to="/">Top</router-link>
<router-link to="/Login">Login</router-link>
</div>
</header>
</template>
<script>
export default {
props : ['isLogin'],
data () {
return {
title: "ヘッダー"
}
}
}
</script>
<style scoped>
header {
padding: 1.8em 5%;
background: #778899;
}
header .row {
text-align: right;
}
header a {
color: #fff;
text-decoration: none;
margin-left: 2em;
}
header a:hover {
text-decoration: underline;
}
h1 {
margin: 0;
color: #fff;
}
</style>
<template>
<header>
<div class="row" v-if="isLogin">
{{title}}
<router-link to="/">Top</router-link>
<router-link to="/Profile">Profile</router-link>
<router-link to="/Logout">Logout</router-link>
</div>
<div class="row" v-else>
{{title}}
<router-link to="/">Top</router-link>
<router-link to="/Login">Login</router-link>
</div>
</header>
</template>
<script>
export default {
props : ['isLogin'],
data () {
return {
title: "フッター"
}
}
}
</script>
<style scoped>
header {
padding: 1.8em 5%;
background: #778899;
}
header .row {
text-align: right;
}
header a {
color: #fff;
text-decoration: none;
margin-left: 2em;
}
header a:hover {
text-decoration: underline;
}
h1 {
margin: 0;
color: #fff;
}
</style>
トップページです
<template>
<main>
<h2>{{title}}</h2>
</main>
</template>
<script>
export default {
data () {
return {
title: "TOPページです。"
}
}
}
</script>
<style scoped>
main {
height: calc(100vh - 152px);
padding: 3% 0;
box-sizing: border-box;
justify-content: center;
align-items: center;
}
h2 {
margin: 0;
}
</style>
ログインコンポーネントです。axiosでログイン情報を送るときのヘッダーに注意してください。過去記事で述べていますが、application/x-www-form-urlencodedで送る必要があります。
成功したらlocalStorageにtokenを保存し、App.vueにchange-loginをemitします。
<template>
<main>
<h2>{{title}}</h2>
<div>
<label for="username">ユーザ名</label>
<input name="username" v-model="username">
<br />
<label for="password">パスワード</label>
<input name ="password" v-model="password">
</div>
<br />
<button v-on:click="doLogin">Login</button>
<br />
<p>Current token = {{ token }}</p>
</main>
</template>
<script>
// import { inject } from 'vue'
import axios from 'axios'
export default {
data () {
return {
title: "Loginページです。",
token: ""
}
},
methods: {
doLogin: function() {
console.log("doLogin:: username=", this.username, " passowrd=", this.password)
const params = new URLSearchParams();
params.append('username', this.username); // 渡したいデータ分だけappendする
params.append('password', this.password);
let config = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
};
axios.post('/token', params, config)
.then((response) => {
console.log("response.data = ", response.data)
this.token = response.data.access_token
localStorage.setItem('token', response.data.access_token);
this.$emit('change-login', true)
})
.catch((err) => {
console.log("Error = ", err)
})
}
}
}
</script>
<style scoped>
main {
height: calc(100vh - 152px);
padding: 3% 0;
box-sizing: border-box;
justify-content: center;
align-items: center;
}
h2 {
margin: 0;
}
</style>
ログアウトコンポーネントです。成功したらlocalStorageをクリアーし、App.vueにchange-loginをemitします。
<template>
<main>
<h2>{{title}}</h2>
<button v-on:click="doLogout">Logout</button>
</main>
</template>
<script>
export default {
data () {
return {
title: "Logoutページです。"
}
},
methods: {
doLogout: function() {
localStorage.setItem('token', '');
this.$emit('change-login', false)
}
}
}
</script>
<style scoped>
main {
height: calc(100vh - 152px);
padding: 3% 0;
box-sizing: border-box;
justify-content: center;
align-items: center;
}
h2 {
margin: 0;
}
</style>
プロファイルコンポーネントです。ログイン状態のときに有効です。
localStorageからtokenを取得し、FastAPIサーバにget要求を出します。
<template>
<main>
<h2>{{title}}</h2>
<button v-on:click="getProfile">Profile</button>
<button v-on:click="getItems">Items</button>
<div>{{profile}}</div>
</main>
</template>
<script>
import axios from 'axios'
export default {
data () {
return {
title: "Profileページです。",
profile: ''
}
},
methods: {
getProfile: function() {
this.profile = ''
this.items = ''
const token = localStorage.getItem('token');
const config = {
headers: {
Authorization: `Bearer ${token}`
}
};
axios.get('/users/me/', config)
.then((response) => {
console.log("response.data = ", response.data)
this.profile = JSON.stringify(response.data)
})
.catch((err) => {
console.log("Error = ", err)
this.profile = JSON.stringify(err)
})
},
getItems: function() {
this.profile = ''
this.items = ''
const token = localStorage.getItem('token');
const config = {
headers: {
Authorization: `Bearer ${token}`
}
};
axios.get('/users/me/items/', config)
.then((response) => {
console.log("response.data = ", response.data)
this.profile = JSON.stringify(response.data)
})
.catch((err) => {
console.log("Error = ", err)
this.profile = JSON.stringify(err)
})
}
}
}
</script>
<style scoped>
main {
height: calc(100vh - 152px);
padding: 3% 0;
box-sizing: border-box;
justify-content: center;
align-items: center;
}
h2 {
margin: 0;
}
</style>
4. 画面
ちょっと動きを確認します、
ログインしてみます。username=johndoe, passowrd=secret
tokenの値が表示され、ヘッダーとフッターのメニューが、リアクティブに変更されます。
プロフィール画面でprofileボタンをクリックすると、値がFastAPIから取得され表示されます。
大体のような動きになります。
今回は以上です。