Python Lab #2 ― FastAPI with React
フロントエンドとして FastAPI で React 画面を返却するサーバーを用意し、バックエンドとして FastAPI で Web API を動作させるサーバーを用意し、ログイン画面から Web API を呼び出すというものを開発してみました。
UI 部分には MUI(Material UI)を採用しました。そのうち、サーバーサイドペイジネーションのデータテーブルにもトライしたいです。
画面ショット
[ログイン画面(React、MUI)]
[ログイン後のホーム画面(React、MUI)]
とりあえず、MySQL の表 “product_list” の検索結果を表示している。そのうち、サーバーサイドペイジネーションのデータテーブルを実装してみたい。
動機
- 過去に Flask、React-Bootstrap、axios の基礎知識を習得したことがある。
- FastAPI は、Flask より性能が良いとされている。なので、FastAPI の基礎知識を習得してみたくなった。JWT(Java Web Token)も含めて。
- MUI(Material UI)は、Google の Material Design に基づいた UI ライブラリとのこと。なので、MUI の基礎知識を習得してみたくなった。
- 最新のブラウザーの JavaScript に Fetch API が標準で実装されている。なので、Fetch API の基礎知識を習得してみたくなった。
- ORM(Object-Relational Mapping)は、MyBatis の基礎知識しか習得したことがない。MyBatis は、「SQL 文とオブジェクトをマッピング」する ORM である。なので、「表とオブジェクトをマッピング」する ORM を知るために SQLAlchemy の基礎知識を習得してみたくなった。
体得したこと
JWT を安全な場所に保存したほうがよい
Local Storage に保存するのは、悪意のサイトから Local Storage を読み出される可能性があるため、あまり安全ではないとされている。
Cookie に 保存するのが、比較的に安全であるとされている。
以下を考慮する。
- レスポンスヘッダーに “Set-Cookie: ...; Secure; HttpOnly;” を指定する。
- サーバーセッションに実際の情報を保存する。← Local Storage の Cookie のいずれに保存するとしても
なので、今回は、Cookie に保存することにしてみた。実装が楽かな?と思ったので。
フロントエンドとバックエンドの二つのサーバーを立てて、バックエンド側で Cookie を設定できるようにするためには様々な設定をする
Cookie での実装は楽だろうと思いきや、かなりハマってしまった(8 時間くらい...)。ただ、Cookie の知識を深めることができてよかった。
以下のように、セキュリティ向けの設定値を変更する必要がある。
- パックエンドで、特定のフロントエンドのサイトのページからアクセスされることを許可する。
→ “app.add_middleware()” に “allow_origins = [...]” を設定する。これにより、悪意のサイトからアクセスされてもアクセス拒否することができる。今回の Web API はあらゆるサイトに公開するものではないので、この設定ができる。 - バックエンドで、フロントエンドのサイトの Cookie に保存することを許可する。
→ レスポンスヘッダーに “Set-Cookie: ...; Secure; HttpOnly; SameSite=none” を指定する。 - フロントエンドで、異なるサイトの資格情報(Cookie など)を送信することを許可する。
→ JavaScript の Fetch API のオプションに “credentials: 'include'” を指定する。
JWT のプリミティブそのものでは Java(Tomcat)のようなセッションの仕組みを提供しているわけではない
セションの情報をデータベース等に保持する作り込みが必要とのこと。
JWT には単に規則的な文字列を振っているだけなので、ログアウトで Cookie 中の JWT を消す作りにした(レスポンスヘッダーに “Set-Cookie: access_token=;” で access_token を消すようにした)としても、次のリクエストのリクエストヘッダー “Cookie: accss_token=” で前回にログインしたときの access_token の文字列を指定しなおせば、ログイン状態を継続した状態にできてしまう。また、タイムアウトが発生すれば(ログインしてから ACCESS_TOKEN_EXPIRE_MINUTES の時間を超えれば)、セションが無効とみなされる。
今後、研究したいこと
- セッションの仕組みを実装する。セッションの情報をデータベースに保持したり破棄したり。
- MUI のサーバーサイドペイジネーションのデータテーブルを実装する。
- 見た目の向上。CSS の仕組みを研究するところから。
このページの読者の前提
初級を抜け出した人向けです。
- Web や HTTP に関する基礎知識を持っていること。
- 自分で MySQL、Node.js、Python、React の環境を構築し、ソースコードを取り込んで、プログラムの実行ができること。
- 自分でざっと Python、React のソースコードを読んで理解できること。
フォルダー・ファイル構成
- フォルダー・ファイル
D:\Developments\PyCharmProjects\lab-fast_api │ LICENSE │ README.md │ requirements.txt ├─ backend │ │ backend_main.py → 後述 │ ├─ action │ │ authenticate_user_action.py → 後述 │ │ manipulate_products_action.py │ ├─ database │ │ connect_database.py → 後述 │ │ create_database.py → 後述 │ ├─ mapper │ │ products_mapper.py │ │ users_mapper.py → 後述 │ ├─ response │ │ list_response.py │ │ status_response.py │ └─ templates │ │ index.html │ └─ static └─ frontend │ frontend_main.py → 後述 │ package-lock.json │ package.json │ README.md │ tsconfig.json ├─ build │ │ ・・・ │ └─ static │ ・・・ ├─ node_modules │ ・・・ ├─ public │ favicon.ico │ index.html │ logo192.png │ logo512.png │ manifest.json │ robots.txt └─ src │ index.css │ index.tsx → 後述 └─ pages │ Home.tsx → 後述 │ Login.tsx → 後述 │ Settings.tsx │ Top.tsx └─ components Footer.tsx Header.tsx Logout.tsx
環境の構築からプログラムの実行まで
事前
- Python をインストールする。
- MySQL をインストールする。
- Node.js をインストール、セットアップする。
- Pycharm などで “D:\Developments\PyCharmProjects” 配下に Python プロジェクト “lab-fast_api” を作成する。
- Pycharm などで requirements.txt に記載された Python パッケージをインストールする。
バックエンド
- “D:\Developments\PyCharmProjects\lab-fast_api\backend” 配下の各種フォルダーに各種ソースコードを置く。
- データベースを作成する。
コマンド・プロンプト
C:\Users\xxxxx> D: D:\> cd D:\Developments\PyCharmProjects\lab-fast_api\backend D:\Developments\PyCharmProjects\lab-fast_api\backend> python.exe .\database\create_database.py D:\Developments\PyCharmProjects\lab-fast_api\backend> exit
- Pycharm などで backend_main.py を実行する。
フロントエンド
- React プロジェクトを作成する。
コマンド・プロンプト
C:\Users\xxxxx> D: D:\> cd D:\Developments\PyCharmProjects\lab-fast_api D:\Developments\PyCharmProjects\lab-fast_api> npx create-react-app frontend --template typescript D:\Developments\PyCharmProjects\lab-fast_api> cd frontend D:\Developments\PyCharmProjects\lab-fast_api\frontend> npm install react-router-dom D:\Developments\PyCharmProjects\lab-fast_api\frontend> npm install @mui/material @emotion/react @emotion/styled D:\Developments\PyCharmProjects\lab-fast_api\frontend> npm run build D:\Developments\PyCharmProjects\lab-fast_api\frontend> exit
- “D:\Developments\PyCharmProjects\lab-fast_api\frontend” 配下の各種フォルダーに各種ソースコードを置く。
- Pycharm などで frontend_main.py を実行する。
- ブラウザーで “http://localhost:3000” をアクセスする。
ソースコードの簡単な説明
バックエンド
-
SQLAlchemy データベース定義
database\create_database.py・・・ # Import Libraries import sys from sqlalchemy import create_engine, text from sqlalchemy_utils import create_database, drop_database from sqlalchemy.orm import sessionmaker, scoped_session from backend.mapper.products_mapper import product_list_base from backend.mapper.users_mapper import user_list_base # Display print('lab-FastAPI - DDL/DML') print('root password is "********"') password = input('Enter password: ') # Constants DIALECT = 'mysql' DRIVER = 'mysqlconnector' USER = 'root' HOST = 'localhost' PORT = '3306' DATABASE = 'lab_fast' DATABASE = f'{DIALECT}+{DRIVER}://{USER}:{password}@{HOST}:{PORT}/{DATABASE}?charset=utf-8' CMD_DDLS = [ text('DROP ROLE IF EXISTS lab_fast_role'), text('CREATE ROLE lab_fast_role'), text('DROP USER IF EXISTS \'lab_fast_user\'@\'localhost\''), text('CREATE USER \'lab_fast_user\'@\'localhost\' IDENTIFIED BY \'Asdf1234\' DEFAULT ROLE lab_fast_role'), text('GRANT SELECT, INSERT, UPDATE, DELETE ON lab_fast.product_list TO lab_fast_role'), text('GRANT SELECT, INSERT, UPDATE, DELETE ON lab_fast.user_list TO lab_fast_role'), ] CMD_DMLS = [ text(''' INSERT INTO LAB_FAST.PRODUCT_LIST ( NAME, REMARK ) VALUES ( 'Apple', 'Made in Japan.' ), ( 'Orange', 'Made in America.' ) '''), text(''' INSERT INTO LAB_FAST.USER_LIST ( NAME, PASSWORD, MAIL, REMARK ) VALUES ( 'root', HEX(AES_ENCRYPT('Asdf1234', 'Asdf1234Asdf1234')), 'root@xxxx.com', 'Administrator' ), ( 'power-user', HEX(AES_ENCRYPT('Asdf1234', 'Asdf1234Asdf1234')), 'power.user@xxxx.com', 'Power User' ) '''), ] # Main def main() -> None: # Engine engine = create_engine(DATABASE, echo=True) session = scoped_session(sessionmaker(autocommit=True, autoflush=True, bind=engine)) # Drop Database try: drop_database(engine.url) except Exception: # noqa pass # Create Database create_database(engine.url) conn = engine.connect() product_list_base.metadata.create_all(bind=engine) product_list_base.query = session.query_property() user_list_base.metadata.create_all(bind=engine) user_list_base.query = session.query_property() # Grant for cmd in CMD_DDLS: _ = conn.execute(cmd) conn.commit() # Insert Test Data for cmd in CMD_DMLS: _ = conn.execute(cmd) conn.commit() sys.exit(0) # Goto Main if __name__ == '__main__': main()
説明
SQLAlchemy の表削除関数 “drop_database()”、表作成関数 “create_database()” を呼び出して、表を作成する。
また、SQLAlchemy の SQL 文直接実行関数 “execute()” を呼び出して、GRANT と INSERT を行う。 -
FastAPI Web API バックエンドサーバー
backend_main.py・・・ # When wrote this source code, referred this page 'https://fastapi.tiangolo.com/ja/tutorial/security/oauth2-jwt/' # Import Libraries # import os # import sys import uvicorn import secrets from jose import JWTError, jwt from typing import Optional from pydantic import BaseModel from datetime import datetime, timedelta from fastapi import FastAPI, Depends, HTTPException, Request, Response, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from action.authenticate_user_action import get_user_and_password, get_user from action.manipulate_products_action import read_products from backend.response.status_response import JsonStatusResponse # Constants SECRET_KEY = secrets.token_hex(32) # example: 'c13b4b4808d0eed9323e44897315b4fea0c023313585afd9c0d6896e769490fe' ALGORITHM = 'HS256' ACCESS_TOKEN_EXPIRE_MINUTES = 30 ORIGINS = [ 'http://localhost:3000', ] # Token class Token(BaseModel): access_token: str token_type: str # TokenData class TokenData(BaseModel): username: Optional[str] = None # User class User(BaseModel): username: str # FastAPI Settings app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app.mount(path='/static', app=StaticFiles(directory='.\\templates\\static'), name='static') app.add_middleware( CORSMiddleware, # noqa allow_origins=ORIGINS, allow_credentials=True, allow_methods=['*'], allow_headers=['*'], ) templates = Jinja2Templates(directory='.\\templates') # OAuth2 oauth2_scheme = OAuth2PasswordBearer(tokenUrl='token') # Create JWT at /token Endpoint Request 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 # Check JWT Is Valid or Not at Some Endpoint Request async def get_current_user(request: Request): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Could not validate credentials', headers={'WWW-Authenticate': 'Bearer'}, ) token = '' # read from request header of 'Cookie: access_token={access_token}' cookie = request.cookies.get('access_token') if cookie is not None and cookie != '': token = cookie # read from request header of 'Authorization: "Bearer {access_token}"' authorization = request.headers.get('Authorization') if authorization is not None and authorization != '': scheme, _, token = authorization.partition(' ') try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get('username') if username is None: raise credentials_exception token_data = TokenData(username=username) except JWTError: raise credentials_exception user = get_user(username=token_data.username) if user is None: raise credentials_exception return user # / (Return index.html) @app.get("/", response_class=HTMLResponse) async def root(request: Request): return templates.TemplateResponse('index.html', {'request': request, 'message': 'hello world'}) # /auth (Return the JWT on Response Header/Body) @app.post('/auth', response_model=Token) async def login_for_access_token( response: Response, form_data: OAuth2PasswordRequestForm = Depends()): user = get_user_and_password(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={'username': user.username}, expires_delta=access_token_expires ) # write to response header of 'Set-Cookie: access_token={access_token}' response.set_cookie(key='access_token', value=f'{access_token}', httponly=True, secure=True, samesite='none') # return to value of JSON return {'access_token': access_token, 'token_type': 'bearer'} # /unauth (No Error Process) @app.post('/unauth') async def unauth(response: Response): # write to response header of 'Set-Cookie: access_token=""' response.delete_cookie(key="access_token", httponly=True, secure=True, samesite='none') return JsonStatusResponse() # /is_valid (Need the JWT on Request Header) @app.post('/is_valid') async def is_valid(current_user: User = Depends(get_current_user)): # noqa return JsonStatusResponse() # /products (Need the JWT on Request Header) @app.get('/products') async def get_products(current_user: User = Depends(get_current_user)): # noqa return read_products() # Web API Backend Web Server if __name__ == '__main__': # sys.stdout = open(os.devnull, 'w') # Discard stdout # sys.stderr = open(os.devnull, 'w') # Discard stderr uvicorn.run(app, host='0.0.0.0', port=3001, server_header=False)
ログイン画面向けのエンドポイント “http://localhost:3001/auth” において、ユーザーとパスワードが正しいか否かを判定する。
参考にした FastAPI ホームページのソースコード “https://fastapi.tiangolo.com/ja/tutorial/security/oauth2-jwt/” では、レスポンスボディに JWT の JSON を返却するものの、その後に JWT をどこに保存するかまでは言及していない。今回、レスポンスヘッダーに “Set-Cookie: access_token={jwt}” を返却してブラウザーの Cookie にも保存するようにしてみた。
ホーム画面向けのエンドポイント “http://localhost:3001/products” において、リクエストヘッダーの “Cookie: access_token={jwt}” または “Authorization: Bearer {jwt}” から JWT を受け取って JWT の文字列を検証する。 -
Web API - 認証
action\authenticate_user_action.py・・・ # Import Libraries import binascii from Crypto.Cipher import AES from backend.mapper import users_mapper as model from backend.database import connect_database as connect # Crypto Key CRYPTO_KEY = 'Asdf1234Asdf1234' # Strip Padding String From ECB Decrypto String def unpad(s): return s[:-ord(s[len(s) - 1:])] # Decrypto def decrypto(password_aes) -> str: password_bin = binascii.unhexlify(password_aes) decipher = AES.new(CRYPTO_KEY.encode('utf-8'), AES.MODE_ECB) dec = decipher.decrypt(password_bin) return unpad(dec).decode('utf-8') # Get User and Verify Password def get_user_and_password(username: str, password: str) -> []: user_list = connect.session.query( model.UserList.name.label("username"), model.UserList.password).filter(model.UserList.name == username).first() password_aes = user_list[1] # column name "password" password_dec = decrypto(password_aes) if password != password_dec: return False return user_list # Get User def get_user(username: str) -> []: user_list = connect.session.query( model.UserList.name.label("username")).filter(model.UserList.name == username).first() return user_list
-
Web API - データベース
database\connect_database.py・・・ # Import Libraries from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, scoped_session # Constants DIALECT = 'mysql' DRIVER = 'mysqlconnector' USER = 'lab_fast_user' PASSWORD = 'Asdf1234' HOST = 'localhost' PORT = '3306' DB = 'lab_fast' DATABASE = f'{DIALECT}+{DRIVER}://{USER}:{PASSWORD}@{HOST}:{PORT}/{DB}?charset=utf-8' # Engine engine = create_engine(DATABASE, echo=True) # True: SQL statement log session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine) session = scoped_session(session_local)
-
Web API - マッパー
mapper\users_mapper.py・・・ # Import Libraries from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.sql.functions import current_timestamp from sqlalchemy.ext.declarative import declarative_base # Base user_list_base = declarative_base() # USER_LIST class UserList(user_list_base): __tablename__ = 'user_list' id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(32), nullable=False, unique=True) password = Column(String(416), nullable=False) mail = Column(String(64), nullable=False, unique=True) remark = Column(String(768), nullable=False) created_at = Column(DateTime, server_default=current_timestamp()) updated_at = Column(DateTime, server_default=current_timestamp())
フロントエンド
- FastAPI React フロントエンドサーバー
frontend_main.py
・・・ # Import Libraries # import os # import sys import uvicorn from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.exceptions import HTTPException from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles # FastAPI Settings app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app.mount(path='/static', app=StaticFiles(directory='.\\build\\static'), name='static') templates = Jinja2Templates(directory='.\\build') # / (Return index.html) @app.get("/", response_class=HTMLResponse) async def root(request: Request): return templates.TemplateResponse('index.html', {'request': request}) # When Manipulate [Rewind] [Forward] [Refresh] Button [URL] Textbox at Browser @app.exception_handler(404) async def not_found(request: Request, ex: HTTPException): # noqa return templates.TemplateResponse('index.html', {'request': request}) # React Frontend Web Server if __name__ == '__main__': # sys.stdout = open(os.devnull, 'w') # Discard stdout # sys.stderr = open(os.devnull, 'w') # Discard stderr uvicorn.run(app, host='0.0.0.0', port=3000, server_header=False)
- React index.html 画面
src\index.tsx
import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import Top from './pages/Top'; import Login from './pages/Login'; import Home from './pages/Home'; import Settings from './pages/Settings'; import {BrowserRouter, Route, Routes} from 'react-router-dom'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( <React.StrictMode> <BrowserRouter> <Routes> <Route path='/' element={<Top />} /> <Route path='/login' element={<Login />} /> <Route path='/home' element={<Home />} /> <Route path='/settings' element={<Settings />} /> <Route path='*' element={<h3>Not Found.</h3>} /> </Routes> </BrowserRouter> </React.StrictMode> );
また、ブラウザーで [戻る] ボタン、[進む] ボタン、[更新] ボタンを押下したり [URL] テキストボックスに “http://localhost:3000/login” 等を指定したりして 404(Not Found)になったときに index.html を表示することで、React ルーターに相応の処理を行わせる。 - React ログイン画面
src\pages\Login.tsx
import {Grid, Box, Button, TextField} from '@mui/material'; import CssBaseline from '@mui/material/CssBaseline'; import {useNavigate} from "react-router-dom"; import {useState, useEffect, useRef} from 'react'; import Header from './components/Header'; import Footer from './components/Footer'; import Logout from './components/Logout'; const styles = { marginLeft: 1, marginRight: 0, }; function Login() { const [message, setMessage] = useState(''); const [userName, setUserName] = useState(''); const [password, setPassword] = useState(''); const navigate = useNavigate(); const loaded = useRef(false); useEffect(() => { if (loaded.current) { return; } loaded.current = true; Logout(); }, []); const login = async () => { if (! userName) { setMessage('User Name is required.'); return; } if (! password) { setMessage('Password is required.'); return; } const headers = new Headers(); const formData = new FormData(); formData.append('username', userName); formData.append('password', password); const request = new Request('http://localhost:3001/auth', { method: 'POST', headers: headers, body: formData, credentials: 'include', }); let error = false; await fetch(request) .then (response => { if (! response.ok) { error = true; } return response.json(); }) .then (data => { if (error) { setMessage('User Name or Password is invalid.'); return; } const token = data.access_token; navigate('/home'); }) .catch (error => { setMessage('Network trouble has occurred.'); return; }); } return ( <> <CssBaseline /> <Header logoutButton={false} /> <Box component="form" noValidate autoComplete="off" display='flex' flexDirection='column' justifyContent='space-between' sx={{'& .MuiTextField-root': {m: 1, width: '30ch'}}} > <Grid sx={styles}> <h3>Login</h3> </Grid> <Grid sx={styles}> {message} </Grid> <Grid item xs={16}> <TextField id="userName" label="User Name" variant="outlined" onChange={(e) => setUserName(e.target.value)} /> </Grid> <Grid item xs={16}> <TextField id="password" label="Password" type="password" autoComplete="" onChange={(e) => setPassword(e.target.value)} /> </Grid> <Grid sx={styles}> <Button id="login" variant="contained" color="primary" onClick={login} sx={{textTransform: "none"}}>Login</Button> </Grid> </Box> <Footer /> </> ); } export default Login;
- React ホーム画面
src\pages\Home.tsx
import {Grid, Box, Tabs, Tab} from '@mui/material'; import CssBaseline from '@mui/material/CssBaseline'; import {useNavigate} from "react-router-dom"; import {useState, useEffect, useRef} from 'react'; import Header from './components/Header'; import Footer from './components/Footer'; const styles = { marginLeft: 1, marginRight: 0, }; function Home() { const [message, setMessage] = useState(''); const emptyData: {name: string, remark: string}[] = []; const [data, setData] = useState(emptyData); const navigate = useNavigate(); const getProducts: () => void = async () => { const headers = new Headers(); const request = new Request('http://localhost:3001/products', { method: 'GET', headers: headers, credentials: 'include', }); let error = false await fetch(request) .then (response => { if (! response.ok) { error = true; return { response: { status: 'action-ng', message: 'Session timeout has occurred.', }, }; } return response.json(); }) .then (data => { if (error) { setMessage(data.response.message); return; } setData(data.response.list); }) .catch (error => { setMessage('Network trouble has occurred.'); return; }); } const loaded = useRef(false); useEffect(() => { if (loaded.current) { return; } loaded.current = true; getProducts(); }, []); function gotoHome() { navigate('/home'); return; }; function gotoSettings() { navigate('/settings'); return; }; return ( <> <CssBaseline /> <Header /> <Box component="form" noValidate autoComplete="off" display='flex' flexDirection='column' justifyContent='space-between' sx={{'& .MuiTextField-root': {m: 1, width: '30ch'}}} > <Grid sx={styles}> <Tabs value="Home"> <Tab id="home" label="Home" value="Home" style={{fontSize: 18, fontWeight: 'bold'}} sx={{textTransform: "none"}} onClick={gotoHome} /> <Tab id="settings" label="Settings" value="Settings" sx={{textTransform: "none"}} onClick={gotoSettings} /> </Tabs> </Grid> <Grid sx={styles}> {message} </Grid> {message === '' && ( <Grid sx={styles}> <br /> {data.map(row => ( <> {row.name}, {row.remark}<br /> </> ))} </Grid> )} </Box> <Footer /> </> ); } export default Home;
全ソースコードの置き場所
参考
書籍
React、FastAPI
FastAPI で JWT
Fetch API と Axios
Fetch API の credentials: 'include' 指定での資格情報の送信