6
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPIでつくるVue SPAアプリの雛形

Last updated at Posted at 2021-12-14

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のデプロイとも関係のあるものですが、重要なポイントですので以下で述べておきます。

main.py
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の初期化を行っておきます。

src/main.js
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を更新するものです。

src/App.vue
<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のルーティングの定義です。

src/router/index.js
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でルータのリンクがあります。

src/components/Header.vue
<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>
src/components/Footer.vue
<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>

トップページです

src/pages/Top.vue
<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します。

src/pages/Login.vue
<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します。

src/pages/Logout.vue
<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要求を出します。

src/pages/Profile.vue
<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. 画面

ちょっと動きを確認します、

トップページです。
image.png

ログインページです。
image.png

ログインしてみます。username=johndoe, passowrd=secret
tokenの値が表示され、ヘッダーとフッターのメニューが、リアクティブに変更されます。

image.png

プロフィール画面でprofileボタンをクリックすると、値がFastAPIから取得され表示されます。
image.png

大体のような動きになります。

今回は以上です。

6
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?