Angular + Django(BFF) MFA認証 実装手順書
1. 目的
本手順書では、
Angular
+
Django(BFF)
+
AWS Cognito
+
MFA(TOTP)
を使用した安全な認証機能を実装する。
本システムでは、
AngularはJWTを保持しない
構成を採用する。
JWTはDjango(BFF)のみが扱う。
2. システム構成
@startuml
actor User
rectangle "Browser" {
component "Angular" as Angular
}
rectangle "Django BFF" {
component "Session認証"
}
rectangle "Cognito" {
component "MFA"
}
User --> Angular
Angular --> "Django BFF" : /auth/login
"Django BFF" --> Cognito
Cognito --> "Django BFF" : callback
"Django BFF" --> Angular : Session Cookie
Angular --> "Django BFF" : Cookie付きAPI
@enduml
```plantuml
@startuml
title BFF方式 ログイン全体シーケンス
actor User as user
participant "Browser\nAngular" as angular
participant "Django BFF\n/auth/login" as login
participant "Django Session" as session
participant "Cognito Hosted UI\nMFA" as cognito
participant "Django BFF\n/auth/callback" as callback
participant "Cognito Token Endpoint\n/oauth2/token" as token
database "RDS MySQL\nUser / Role" as db
user -> angular : /analysis/123 へアクセス
angular -> login : GET /api/me
login --> angular : 401 Unauthorized
angular -> login : GET /auth/login?next=/analysis/123
login -> login : state生成
login -> login : PKCE生成
login -> session : state / next / code_verifier 保存
login --> angular : 302 Redirect to Cognito
angular -> cognito : Hosted UI表示
user -> cognito : email / password 入力
user -> cognito : TOTP MFA入力
cognito --> callback : GET /auth/callback?code=xxx&state=yyy
callback -> session : state / code_verifier / next 取得
callback -> callback : state検証
callback -> token : POST /oauth2/token\ncode + code_verifier
token --> callback : id_token / access_token / refresh_token
callback -> callback : id_token検証\nissuer / audience / exp / token_use
callback -> db : Cognito sub / email でユーザー確認
db --> callback : AppUser / 権限情報
callback -> session : Django login()\nsessionid発行
callback --> angular : 302 Redirect to /analysis/123\nSet-Cookie: sessionid; HttpOnly; Secure; SameSite=Lax
angular -> login : GET /api/me\nCookie付き
login --> angular : 200 OK\nログインユーザー情報
angular -> angular : /analysis/123 表示
@enduml
```
3. なぜBFF方式なのか
NG方式
AngularがJWTを保持
問題:
XSS時にJWT漏洩リスク
OK方式
DjangoだけがJWTを保持
Angularは:
Session Cookieのみ
を使用する。
4. Angular実装
4.1 Angularの役割
Angularの役割:
画面表示
ログイン状態確認
未ログイン時のリダイレクト
Angularは:
JWTを扱わない
4.2 認証Service作成
作成ファイル
src/app/core/auth/auth.service.ts
auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface MeResponse {
authenticated: boolean;
username: string;
email: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor(
private http: HttpClient
) {}
me(): Observable<MeResponse> {
return this.http.get<MeResponse>(
'/api/me',
{
withCredentials: true,
}
);
}
login(returnUrl: string): void {
window.location.href =
`/auth/login?next=${encodeURIComponent(returnUrl)}`;
}
logout(): Observable<{ logout_url: string }> {
return this.http.post<{ logout_url: string }>(
'/auth/logout',
{},
{
withCredentials: true,
}
);
}
}
4.3 withCredentials の意味
重要:
withCredentials: true
意味:
Cookieを自動送信する
これがないと:
sessionid Cookie
が送信されない。
4.4 Route Guard作成
作成ファイル
src/app/core/auth/auth.guard.ts
auth.guard.ts
import { inject } from '@angular/core';
import {
CanActivateFn,
Router,
} from '@angular/router';
import {
catchError,
map,
of,
} from 'rxjs';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.me().pipe(
map(() => true),
catchError(() => {
const returnUrl = router.url;
authService.login(returnUrl);
return of(false);
})
);
};
4.5 Guardの役割
ログイン済み:
画面表示
未ログイン:
Cognitoログインへ移動
4.6 Router設定
app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard';
export const routes: Routes = [
{
path: 'analysis/:id',
canActivate: [authGuard],
loadComponent: () =>
import('./pages/analysis/analysis.component')
.then(m => m.AnalysisComponent),
},
];
4.7 ログアウトボタン
logout-button.component.ts
import { Component } from '@angular/core';
import { AuthService } from '../auth/auth.service';
@Component({
selector: 'app-logout-button',
template: `
<button
type="button"
(click)="logout()"
>
ログアウト
</button>
`,
})
export class LogoutButtonComponent {
constructor(
private authService: AuthService
) {}
logout(): void {
this.authService
.logout()
.subscribe((res) => {
window.location.href =
res.logout_url;
});
}
}
4.8 Angular側でやってはいけないこと
禁止:
JWT保存
localStorage保存
sessionStorage保存
Token decode
Token検証
全部Django側で行う。
5. Django(BFF)実装
5.1 Djangoの役割
Django(BFF)の役割:
Cognitoログイン
Token交換
JWT検証
Session作成
Cookie発行
認可
5.2 必要ライブラリ
pip install \
requests \
PyJWT \
cryptography \
boto3
5.3 settings.py
本番設定
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "Lax"
SECURE_SSL_REDIRECT = True
5.4 各設定の意味
| 設定 | 意味 |
|---|---|
| HttpOnly | JSからCookie参照禁止 |
| Secure | HTTPSのみ |
| SameSite | CSRF軽減 |
| SSL_REDIRECT | HTTP禁止 |
5.5 ログイン開始View
auth_views.py
import secrets
from django.http import HttpResponseRedirect
from core.cognito import (
create_pkce_pair,
build_authorize_url,
)
def auth_login(request):
next_url = request.GET.get(
"next",
"/"
)
state = secrets.token_urlsafe(32)
code_verifier, code_challenge = (
create_pkce_pair()
)
request.session["oauth_state"] = state
request.session["oauth_next"] = next_url
request.session["pkce_code_verifier"] = (
code_verifier
)
authorize_url = build_authorize_url(
state=state,
code_challenge=code_challenge,
)
return HttpResponseRedirect(
authorize_url
)
5.6 この処理でやっていること
1. state作成
2. PKCE生成
3. session保存
4. Cognitoへredirect
5.7 callback View
def auth_callback(request):
code = request.GET.get("code")
state = request.GET.get("state")
expected_state = request.session.get(
"oauth_state"
)
if state != expected_state:
return JsonResponse(
{"detail": "Invalid state"},
status=400,
)
# token交換
# JWT検証
# login()
return redirect("/")
5.8 callback の役割
Cognitoログイン完了処理
画面ではない。
5.9 Django login()
from django.contrib.auth import login
実行すると:
sessionid Cookie
が自動発行される。
5.10 /api/me
@login_required
def me(request):
return JsonResponse({
"authenticated": True,
"username": request.user.username,
})
役割:
ログイン確認API
5.11 ログアウト
from django.contrib.auth import logout
def auth_logout(request):
logout(request)
return JsonResponse({
"logout_url": build_logout_url()
})
6. CSRF対策
Cookie認証では:
CSRF対策必須
6.1 csrf endpoint
from django.views.decorators.csrf import ensure_csrf_cookie
@ensure_csrf_cookie
def csrf(request):
return JsonResponse({
"detail": "ok"
})
6.2 Angular側
POST前に:
/api/csrf
を呼ぶ。
7. 直リンク対応
例:
/analysis/123
へ直接アクセス。
未ログイン時:
/auth/login?next=/analysis/123
へ移動。
ログイン後:
/analysis/123
へ戻る。
8. callback URLについて
Callback URLは:
固定
にする。
例:
https://motion.example.com/auth/callback
9. state の役割
state は:
元画面URL保持
+
CSRF対策
10. CloudWatchログ
記録する内容:
ログイン成功
ログイン失敗
MFA失敗
JWT検証失敗
権限拒否
管理者操作
11. 管理者API
admin_required
if not request.user.is_staff:
return 403
12. セキュリティチェック
| 項目 | 必須 |
|---|---|
| MFA | ○ |
| HTTPS | ○ |
| HttpOnly Cookie | ○ |
| Secure Cookie | ○ |
| CSRF対策 | ○ |
| JWT検証 | ○ |
| SSH禁止 | ○ |
| Secrets Manager | ○ |
13. やってはいけないこと
禁止:
AngularでJWT保持
localStorage保存
Implicit Flow
Client SecretをGit管理
Public RDS
SSH開放
14. 新入社員向け理解ポイント
Angular
画面担当
Django
認証担当
Cognito
MFA担当
Cookie
ログイン状態保持
15. 最終構成
Angular
= UIのみ
Django(BFF)
= 認証/認可
Cognito
= MFA
Browser
= HttpOnly Cookieのみ保持
この構成が、
社外公開・機密データ・管理者操作ありシステムでは
最も安全性が高い。
動作分析システム MFA導入手順書
BFF方式:SAM + Cognito + Angular + Django
1. 採用方針
本システムは以下の条件に該当する。
-
社外公開
-
機密データあり
-
管理者操作あり
そのため、AngularでJWTを直接扱う方式ではなく、BFF方式を採用する。
BFF方式では、Angularはアクセストークン、IDトークン、リフレッシュトークンを保持しない。
認証処理とトークン管理はDjangoが担当する。
2. 全体構成
@startuml
title 動作分析システム BFF認証構成
actor User
rectangle "Browser" {
component "Angular Frontend\nTokenを保持しない" as Angular
}
rectangle "AWS" {
component "ALB\nHTTPS/WAF" as ALB
rectangle "EC2 / Docker" {
component "Frontend Container\nAngular" as FE
component "Django BFF/API Container" as BFF
}
component "Cognito Hosted UI\nMFA(TOTP)" as Cognito
database "RDS MySQL" as RDS
component "Secrets Manager" as Secrets
component "CloudWatch Logs" as Logs
}
User --> Angular
Angular --> BFF : /auth/login
BFF --> Cognito : authorize redirect
Cognito --> BFF : /auth/callback?code=xxx
BFF --> Cognito : token exchange
BFF --> Angular : HttpOnly Secure Session Cookie
Angular --> BFF : Cookie付きAPI通信
BFF --> RDS
BFF --> Secrets
BFF --> Logs
@enduml
3. 認証フロー
@startuml
title BFFログインフロー
actor User
participant "Angular" as FE
participant "Django BFF" as BFF
participant "Cognito" as Cognito
participant "RDS" as RDS
User -> FE : /analysis/123 へ直リンクアクセス
FE -> BFF : GET /api/me
BFF -> FE : 401 Not Authenticated
FE -> BFF : GET /auth/login?next=/analysis/123
BFF -> Cognito : /oauth2/authorize へリダイレクト
User -> Cognito : ID/PW + MFA
Cognito -> BFF : /auth/callback?code=xxx&state=yyy
BFF -> Cognito : codeをtokenに交換
Cognito -> BFF : id_token / access_token / refresh_token
BFF -> RDS : ユーザー確認・権限取得
BFF -> FE : sessionid HttpOnly Cookie
FE -> BFF : GET /api/me Cookie付き
BFF -> FE : ログインユーザー情報
FE -> FE : /analysis/123 へ遷移
@enduml
4. URL設計
Angular側
| 用途 | URL |
|---|---|
| 通常画面 | / |
| 分析画面 | /analysis/:id |
| 管理画面 | /admin |
| 未ログイン時 | /auth/login へ遷移 |
Django BFF側
| 用途 | URL |
|---|---|
| ログイン開始 | /auth/login |
| Cognito戻り先 | /auth/callback |
| ログアウト | /auth/logout |
| ログイン確認 | /api/me |
| CSRF取得 | /api/csrf |
Cognito Callback URL
BFF方式ではCallback URLはAngularではなくDjangoにする。
https://motion.example.com/auth/callback
5. SAMでCognitoを作成
template.yaml
Parameters:
EnvName:
Type: String
Default: dev
AppBaseUrl:
Type: String
Default: http://localhost:8000
Resources:
MotionAnalysisUserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: !Sub motion-analysis-${EnvName}-user-pool
UsernameAttributes:
- email
AutoVerifiedAttributes:
- email
MfaConfiguration: "ON"
EnabledMfas:
- SOFTWARE_TOKEN_MFA
AccountRecoverySetting:
RecoveryMechanisms:
- Name: verified_email
Priority: 1
Policies:
PasswordPolicy:
MinimumLength: 12
RequireLowercase: true
RequireUppercase: true
RequireNumbers: true
RequireSymbols: true
UserPoolAddOns:
AdvancedSecurityMode: ENFORCED
MotionAnalysisUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: !Sub motion-analysis-${EnvName}-bff-client
UserPoolId: !Ref MotionAnalysisUserPool
GenerateSecret: true
SupportedIdentityProviders:
- COGNITO
AllowedOAuthFlowsUserPoolClient: true
AllowedOAuthFlows:
- code
AllowedOAuthScopes:
- openid
- email
- profile
CallbackURLs:
- !Sub ${AppBaseUrl}/auth/callback
LogoutURLs:
- !Ref AppBaseUrl
PreventUserExistenceErrors: ENABLED
AccessTokenValidity: 30
IdTokenValidity: 30
RefreshTokenValidity: 1
TokenValidityUnits:
AccessToken: minutes
IdToken: minutes
RefreshToken: days
MotionAnalysisUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain: !Sub motion-analysis-auth-${EnvName}
UserPoolId: !Ref MotionAnalysisUserPool
Outputs:
CognitoUserPoolId:
Value: !Ref MotionAnalysisUserPool
CognitoUserPoolClientId:
Value: !Ref MotionAnalysisUserPoolClient
CognitoDomain:
Value: !Sub https://${MotionAnalysisUserPoolDomain}.auth.${AWS::Region}.amazoncognito.com
CognitoIssuer:
Value: !Sub https://cognito-idp.${AWS::Region}.amazonaws.com/${MotionAnalysisUserPool}
6. SAMデプロイ
開発環境
sam validate
sam build
sam deploy --guided
入力例:
Stack Name: motion-analysis-auth-dev
Region: ap-northeast-1
EnvName: dev
AppBaseUrl: http://localhost:8000
本番環境
sam deploy --config-env prod
本番の AppBaseUrl は以下のようにする。
https://motion.example.com
7. Cognito Client Secretの保存
BFF方式ではDjangoがCognitoと通信するため、Cognito App Client Secretを使える。
Client SecretはGitに保存しない。
Secrets Managerへ保存する。
保存例
{
"COGNITO_CLIENT_SECRET": "xxxxxxxx",
"DJANGO_SECRET_KEY": "xxxxxxxx",
"DB_HOST": "xxxxx",
"DB_NAME": "motion_analysis_db",
"DB_USER": "admin",
"DB_PASSWORD": "xxxxxxxx"
}
Secret名:
motion-analysis/prod/app-secrets
8. Django設定
install
pip install requests PyJWT cryptography boto3 django-cors-headers
settings.py
import os
COGNITO_REGION = os.environ["COGNITO_REGION"]
COGNITO_USER_POOL_ID = os.environ["COGNITO_USER_POOL_ID"]
COGNITO_CLIENT_ID = os.environ["COGNITO_CLIENT_ID"]
COGNITO_DOMAIN = os.environ["COGNITO_DOMAIN"]
APP_BASE_URL = os.environ["APP_BASE_URL"]
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "Lax"
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
CSRF_TRUSTED_ORIGINS = [
"https://motion.example.com",
]
ALLOWED_HOSTS = [
"motion.example.com",
]
開発環境では以下だけ一時的に緩める。
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SECURE_SSL_REDIRECT = False
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
CSRF_TRUSTED_ORIGINS = ["http://localhost:4200", "http://localhost:8000"]
9. Django Secrets取得
core/secrets.py
import json
import os
import boto3
def get_app_secrets() -> dict:
secret_name = os.environ["APP_SECRET_NAME"]
client = boto3.client("secretsmanager")
response = client.get_secret_value(
SecretId=secret_name
)
return json.loads(response["SecretString"])
10. Django BFF認証実装
core/cognito.py
import base64
import hashlib
import os
import secrets
import urllib.parse
import requests
from django.conf import settings
def create_pkce_pair() -> tuple[str, str]:
code_verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(
code_verifier.encode("ascii")
).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return code_verifier, code_challenge
def build_authorize_url(
state: str,
code_challenge: str,
) -> str:
params = {
"client_id": settings.COGNITO_CLIENT_ID,
"response_type": "code",
"scope": "openid email profile",
"redirect_uri": f"{settings.APP_BASE_URL}/auth/callback",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
return (
f"{settings.COGNITO_DOMAIN}/oauth2/authorize?"
+ urllib.parse.urlencode(params)
)
def exchange_code_for_tokens(
code: str,
code_verifier: str,
client_secret: str,
) -> dict:
token_url = f"{settings.COGNITO_DOMAIN}/oauth2/token"
auth_value = base64.b64encode(
f"{settings.COGNITO_CLIENT_ID}:{client_secret}".encode("utf-8")
).decode("utf-8")
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {auth_value}",
}
data = {
"grant_type": "authorization_code",
"client_id": settings.COGNITO_CLIENT_ID,
"code": code,
"redirect_uri": f"{settings.APP_BASE_URL}/auth/callback",
"code_verifier": code_verifier,
}
response = requests.post(
token_url,
headers=headers,
data=data,
timeout=10,
)
response.raise_for_status()
return response.json()
def build_logout_url() -> str:
params = {
"client_id": settings.COGNITO_CLIENT_ID,
"logout_uri": settings.APP_BASE_URL,
}
return (
f"{settings.COGNITO_DOMAIN}/logout?"
+ urllib.parse.urlencode(params)
)
11. JWT検証
core/jwt_verify.py
import time
import requests
import jwt
from django.conf import settings
_jwks_cache = None
_jwks_expired_at = 0
def get_jwks() -> dict:
global _jwks_cache
global _jwks_expired_at
now = int(time.time())
if _jwks_cache and now < _jwks_expired_at:
return _jwks_cache
url = (
f"https://cognito-idp.{settings.COGNITO_REGION}.amazonaws.com/"
f"{settings.COGNITO_USER_POOL_ID}/.well-known/jwks.json"
)
response = requests.get(url, timeout=5)
response.raise_for_status()
_jwks_cache = response.json()
_jwks_expired_at = now + 3600
return _jwks_cache
def verify_id_token(id_token: str) -> dict:
headers = jwt.get_unverified_header(id_token)
kid = headers["kid"]
jwks = get_jwks()
key = next(
item for item in jwks["keys"]
if item["kid"] == kid
)
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
issuer = (
f"https://cognito-idp.{settings.COGNITO_REGION}.amazonaws.com/"
f"{settings.COGNITO_USER_POOL_ID}"
)
payload = jwt.decode(
id_token,
public_key,
algorithms=["RS256"],
audience=settings.COGNITO_CLIENT_ID,
issuer=issuer,
)
if payload.get("token_use") != "id":
raise ValueError("Invalid token_use")
return payload
12. Django View実装
auth_views.py
import secrets
from django.conf import settings
from django.contrib.auth import login, logout
from django.contrib.auth.models import User
from django.http import HttpRequest, HttpResponseRedirect, JsonResponse
from django.views.decorators.http import require_GET, require_POST
from django.views.decorators.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required
from core.cognito import (
create_pkce_pair,
build_authorize_url,
exchange_code_for_tokens,
build_logout_url,
)
from core.jwt_verify import verify_id_token
from core.secrets import get_app_secrets
@require_GET
def auth_login(request: HttpRequest):
next_url = request.GET.get("next", "/")
state = secrets.token_urlsafe(32)
code_verifier, code_challenge = create_pkce_pair()
request.session["oauth_state"] = state
request.session["oauth_next"] = next_url
request.session["pkce_code_verifier"] = code_verifier
authorize_url = build_authorize_url(
state=state,
code_challenge=code_challenge,
)
return HttpResponseRedirect(authorize_url)
@require_GET
def auth_callback(request: HttpRequest):
code = request.GET.get("code")
state = request.GET.get("state")
expected_state = request.session.get("oauth_state")
code_verifier = request.session.get("pkce_code_verifier")
next_url = request.session.get("oauth_next", "/")
if not code or not state:
return JsonResponse({"detail": "Invalid callback"}, status=400)
if state != expected_state:
return JsonResponse({"detail": "Invalid state"}, status=400)
app_secrets = get_app_secrets()
tokens = exchange_code_for_tokens(
code=code,
code_verifier=code_verifier,
client_secret=app_secrets["COGNITO_CLIENT_SECRET"],
)
id_token = tokens["id_token"]
claims = verify_id_token(id_token)
email = claims.get("email")
sub = claims.get("sub")
if not email or not sub:
return JsonResponse({"detail": "Invalid user claims"}, status=401)
user, _ = User.objects.get_or_create(
username=sub,
defaults={
"email": email,
},
)
login(request, user)
request.session["cognito_sub"] = sub
request.session["email"] = email
request.session.pop("oauth_state", None)
request.session.pop("oauth_next", None)
request.session.pop("pkce_code_verifier", None)
return HttpResponseRedirect(next_url)
@require_POST
@login_required
def auth_logout(request: HttpRequest):
logout(request)
return JsonResponse({
"logout_url": build_logout_url()
})
@require_GET
@login_required
def me(request: HttpRequest):
return JsonResponse({
"authenticated": True,
"username": request.user.username,
"email": request.user.email,
})
@ensure_csrf_cookie
@require_GET
def csrf(request: HttpRequest):
return JsonResponse({"detail": "csrf cookie set"})
13. urls.py
from django.urls import path
from . import auth_views
urlpatterns = [
path("auth/login", auth_views.auth_login),
path("auth/callback", auth_views.auth_callback),
path("auth/logout", auth_views.auth_logout),
path("api/me", auth_views.me),
path("api/csrf", auth_views.csrf),
]
14. Angular実装
BFF方式ではAngularにOAuthライブラリは不要。
Angularは以下だけ行う。
-
/api/meでログイン確認 -
未ログインなら
/auth/login?next=現在URLへ遷移 -
API通信はCookie付きで行う
auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface MeResponse {
authenticated: boolean;
username: string;
email: string;
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor(private http: HttpClient) {}
me(): Observable<MeResponse> {
return this.http.get<MeResponse>('/api/me', {
withCredentials: true,
});
}
login(returnUrl: string): void {
window.location.href =
`/auth/login?next=${encodeURIComponent(returnUrl)}`;
}
logout(): Observable<{ logout_url: string }> {
return this.http.post<{ logout_url: string }>(
'/auth/logout',
{},
{
withCredentials: true,
}
);
}
csrf(): Observable<{ detail: string }> {
return this.http.get<{ detail: string }>('/api/csrf', {
withCredentials: true,
});
}
}
auth.guard.ts
import { inject } from '@angular/core';
import {
CanActivateFn,
Router,
} from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
return authService.me().pipe(
map(() => true),
catchError(() => {
const returnUrl = router.url;
authService.login(returnUrl);
return of(false);
})
);
};
logout.component.ts
import { Component } from '@angular/core';
import { AuthService } from './auth.service';
@Component({
selector: 'app-logout-button',
template: `
<button type="button" (click)="logout()">
ログアウト
</button>
`,
})
export class LogoutButtonComponent {
constructor(private authService: AuthService) {}
logout(): void {
this.authService.logout().subscribe((res) => {
window.location.href = res.logout_url;
});
}
}
15. CSRF対策
Cookie認証ではCSRF対策が必須。
DjangoではCSRF Middlewareを有効にする。
AngularからPOST/PUT/DELETEする前に /api/csrf を呼び、CSRF Cookieを発行する。
本番では以下を設定する。
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_HTTPONLY = True
16. 管理者操作の追加対策
管理者APIには追加チェックを入れる。
permissions.py
from django.http import JsonResponse
def admin_required(view_func):
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
return JsonResponse({"detail": "Unauthorized"}, status=401)
if not request.user.is_staff:
return JsonResponse({"detail": "Forbidden"}, status=403)
return view_func(request, *args, **kwargs)
return wrapper
管理者操作では以下をログに残す。
-
実行ユーザー
-
対象データ
-
操作内容
-
実行日時
-
IPアドレス
-
User-Agent
17. セキュリティチェックリスト
[ ] AngularはJWTを保持しない
[ ] Cognito Callback URLはDjangoの /auth/callback
[ ] Cognito App Client SecretはSecrets Manager保存
[ ] GenerateSecret=true
[ ] MFAはTOTP必須
[ ] OAuth Flowはcodeのみ
[ ] Implicit Flowは無効
[ ] sessionidはHttpOnly
[ ] CookieはSecure
[ ] SameSite=LaxまたはStrict
[ ] CSRF対策あり
[ ] RDSはPrivate Subnet
[ ] EC2はALB経由のみ
[ ] SSH 22番は閉鎖
[ ] 管理者操作ログあり
18. やってはいけないこと
[NG] AngularでJWTを保存する
[NG] localStorageにTokenを保存する
[NG] Cognito Client SecretをGitに保存する
[NG] Callback URLをAngularにする
[NG] Implicit Flowを有効化する
[NG] CookieをHttpOnlyなしで使う
[NG] CSRF対策なしでCookie認証する
[NG] 管理者APIをログなしで実行する
19. 導入順序
Phase 1:Cognito作成
SAMでUser Pool / App Client / Domainを作成
Phase 2:Django BFF実装
/auth/login
/auth/callback
/auth/logout
/api/me
/api/csrf
Phase 3:Angular修正
OAuthライブラリは使わない
/api/meで認証確認
未ログインなら/auth/loginへ移動
Phase 4:Redmine認証との並行稼働
一部ユーザーだけCognitoへ移行
Phase 5:全ユーザー移行
Redmine認証停止
Cognito MFAを必須化
Phase 6:本番セキュリティ確認
Cookie
CSRF
CloudWatch Logs
管理者操作ログ
WAF
Security Group
RDS Private化
20. 結論
社外公開・機密データあり・管理者操作ありの動作分析システムでは、BFF方式が最も安全性を高めやすい。
最終構成は以下とする。
Angular
= トークンを持たない
Django BFF
= Cognito連携、トークン交換、セッション管理、認可
Cognito
= MFA、認証、トークン発行
Browser
= HttpOnly Secure Cookieのみ保持
BFF方式では、ブラウザ上のトークン窃取リスクを下げられる一方で、Cookie認証になるためCSRF対策が必須です。Cognitoの/oauth2/authorizeと/oauth2/tokenをDjango側で扱い、Angularは通常のCookie付きAPI通信だけに寄せるのがポイントです。
動作分析システム MFA導入手順書(Cognito + Angular + Django)
動作分析システム MFA導入手順書(Cognito + Angular + Django)
1. 目的
本手順書では、既存のRedmine認証を廃止し、
AWS Cognito を使用した多要素認証(MFA)へ移行する。
対象システム:
-
フロントエンド:Angular
-
API:Django REST Framework
-
DB:Amazon RDS MySQL
-
実行基盤:EC2 + Docker
-
コンテナ管理:ECR
導入後の認証方式:
-
Cognito Hosted UI
-
MFA(TOTP)
-
JWT認証
-
Bearer Token認証
2. システム構成
@startuml
actor User
rectangle "Browser" {
component "Angular" as FE
}
rectangle "AWS" {
component "ALB" as ALB
component "Cognito\nMFA(TOTP)" as Cognito
rectangle "EC2" {
component "Angular Container" as FEC
component "Django API Container" as API
}
database "RDS MySQL" as RDS
component "Secrets Manager" as Secrets
component "SSM Parameter Store" as SSM
component "CloudWatch Logs" as Logs
}
User --> FE
FE --> Cognito : Login
Cognito --> FE : JWT
FE --> API : Bearer JWT
API --> Cognito : JWT Verify
API --> RDS
API --> Secrets
API --> Logs
@enduml
3. セキュリティ設計
3.1 実施するセキュリティ対策
| 項目 | 内容 |
|---|---|
| MFA | TOTPを必須化 |
| HTTPS | ALBでTLS終端 |
| JWT検証 | issuer / audience / exp検証 |
| DB保護 | RDSをPrivate Subnet配置 |
| SSH禁止 | Session Manager利用 |
| 秘密情報 | Secrets Manager利用 |
| 監査ログ | CloudWatch / CloudTrail |
| Docker | ECR Image Scan有効化 |
4. AWS事前準備
4.1 Secrets Manager作成
保存する情報:
{
"DJANGO_SECRET_KEY": "xxxxxxxx",
"DB_HOST": "xxxxx",
"DB_NAME": "motion_analysis_db",
"DB_USER": "admin",
"DB_PASSWORD": "xxxxxxxx"
}
作成手順:
-
AWS Consoleへログイン
-
Secrets Manager
-
「新しいシークレットを保存」
-
「その他のシークレット」
-
JSON入力
-
名前:
motion-analysis/prod/app-secrets
4.2 Cognito User Pool作成
作成手順
-
Cognito
-
「User Pool作成」
-
サインイン方式:
email
-
MFA:
必須
-
MFAタイプ:
Authenticator App
-
App Client作成
許可するFlow:
Authorization Code Grant
PKCE:
有効
注意
以下は禁止:
Implicit Flow
理由:
トークン漏洩リスクが高いため
5. Angular実装
5.1 ライブラリ導入
npm install angular-oauth2-oidc
5.2 auth.config.ts
import { AuthConfig } from 'angular-oauth2-oidc';
export const authConfig: AuthConfig = {
issuer:
'https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxx',
redirectUri: window.location.origin + '/auth/callback',
clientId: 'xxxxxxxx',
responseType: 'code',
scope: 'openid email profile',
strictDiscoveryDocumentValidation: false,
};
5.3 auth.service.ts
import { Injectable } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
import { authConfig } from './auth.config';
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor(private oauthService: OAuthService) {
this.oauthService.configure(authConfig);
this.oauthService.loadDiscoveryDocumentAndTryLogin();
}
login(): void {
this.oauthService.initCodeFlow();
}
logout(): void {
this.oauthService.logOut();
}
getToken(): string {
return this.oauthService.getAccessToken();
}
}
5.4 Token保存ルール
禁止:
localStorageへ長期保存
推奨:
sessionStorage
理由:
XSS被害時の漏洩リスク低減
6. Django実装
6.1 ライブラリ
pip install PyJWT cryptography requests djangorestframework boto3
6.2 環境変数
APP_SECRET_NAME=motion-analysis/prod/app-secrets
COGNITO_REGION=ap-northeast-1
COGNITO_USER_POOL_ID=xxxx
COGNITO_APP_CLIENT_ID=xxxx
6.3 Secrets取得
import json
import boto3
import os
def get_secret():
client = boto3.client("secretsmanager")
response = client.get_secret_value(
SecretId=os.environ["APP_SECRET_NAME"]
)
return json.loads(response["SecretString"])
SECRET = get_secret()
6.4 JWT検証
payload = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=settings.COGNITO_APP_CLIENT_ID,
issuer=issuer,
)
必須検証:
| 項目 | 必須 |
|---|---|
| signature | ○ |
| issuer | ○ |
| audience | ○ |
| exp | ○ |
| token_use | ○ |
7. Dockerセキュリティ
7.1 Dockerfile
禁止:
FROM python:latest
推奨:
FROM python:3.12-slim
理由:
latestは予期しない更新が入るため
7.2 rootユーザー禁止
RUN useradd -m appuser
USER appuser
8. EC2セキュリティ
SSH禁止
SecurityGroup:
22番ポート閉鎖
接続方法:
SSM Session Manager
9. RDSセキュリティ
禁止:
Public Access = true
必須:
Private Subnet
10. CloudWatchログ
出力対象:
-
ログイン成功
-
ログイン失敗
-
MFA失敗
-
権限拒否
-
JWT検証失敗
11. Redmine移行
移行方針
Redmine認証は段階的に停止
手順
Step1
Redmineユーザー一覧取得
Step2
Cognitoへユーザー登録
Step3
初回ログイン時にMFA登録
Step4
JWT認証へ切替
Step5
Redmine認証停止
12. 動作確認
確認項目
| 項目 | OK条件 |
|---|---|
| ログイン | MFA成功 |
| JWT | APIアクセス成功 |
| 無効JWT | 401 |
| 権限不足 | 403 |
| DB接続 | 正常 |
| CloudWatch | ログ出力あり |
13. 障害対応
Cognito障害
対応:
管理者緊急アカウントを用意
JWTエラー
確認:
issuer
audience
token_use
14. 本番リリースチェック
| 項目 | 状態 |
|---|---|
| MFA必須化 | □ |
| HTTPS | □ |
| SSH閉鎖 | □ |
| Secrets Manager利用 | □ |
| JWT検証 | □ |
| CloudTrail有効 | □ |
| WAF有効 | □ |
| ECR Scan有効 | □ |
15. やってはいけないこと
禁止事項:
Client SecretをGitへPush
JWTをlocalStorageへ保存
latestタグ利用
Public RDS
SSH開放
秘密情報をコード直書き
Django REST Framework の API テスト実装例
force_authenticate() を使用した認証付きテストの書き方
Django REST Framework(DRF)で API テストを実装する際、
self.client.login()
を使用しているのに 401 Unauthorized になって困るケースがあります。
本記事では、DRF の APITestCase と force_authenticate() を使用して、認証付き API を安全にテストする実装例をまとめます。
前提
今回のサンプルでは以下を実装しています。
-
Tweet 一覧取得 API
-
Tweet 詳細取得 API
-
Tweet 作成 API
-
ログイン API
-
ユーザー登録 API
また、以下の観点でテストを行います。
| テスト内容 | 説明 |
|---|---|
| 一覧取得 | 投稿一覧を取得できる |
| 詳細取得 | 指定 Tweet を取得できる |
| 未ログイン投稿 | 401 になる |
| ログイン済投稿 | 201 になる |
API テストコード
test.py
from rest_framework.test import APITestCase
from rest_framework import status
from rest_framework.response import Response
from django.contrib.auth.models import User
from tweet_api_project.models import Tweet
class TweetsViewSetTestCase(APITestCase):
@classmethod
def setUpTestData(cls):
cls.test_user1 = User.objects.create_user(
username='test_user1',
email='test1@example.com',
password='password123'
)
cls.test_user2 = User.objects.create_user(
username='test_user2',
email='test2@example.com',
password='password123'
)
for i in range(10):
Tweet.objects.create(
text=f'test_text_{i + 1}',
user=cls.test_user1
)
def login(self):
self.client.force_authenticate(
user=self.test_user1
)
def test_get_tweets(self):
response: Response = self.client.get(
'/api/v1/tweets/'
)
self.assertEqual(
response.status_code,
status.HTTP_200_OK
)
self.assertEqual(
len(response.data),
10
)
def test_get_tweet_detail(self):
response: Response = self.client.get(
'/api/v1/tweets/1'
)
self.assertEqual(
response.status_code,
status.HTTP_200_OK
)
self.assertEqual(
response.data['text'],
'test_text_1'
)
def test_create_tweet_no_login(self):
response: Response = self.client.post(
'/api/v1/tweets/',
{'text': 'unauthorized_tweet'}
)
self.assertEqual(
response.status_code,
status.HTTP_401_UNAUTHORIZED
)
self.assertEqual(
Tweet.objects.count(),
10
)
def test_create_tweet_login(self):
self.login()
response: Response = self.client.post(
'/api/v1/tweets/',
{'text': 'authorized_tweet'}
)
self.assertEqual(
response.status_code,
status.HTTP_201_CREATED
)
self.assertEqual(
Tweet.objects.count(),
11
)
API 実装例
views.py
from django.contrib.auth import login
from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework import status
from tweet_api_project.models import Tweet
from . import serializers
from .custom_permission import TweetPermission
class LoginView(APIView):
serializer_class = serializers.LoginSerializer
permission_classes = [AllowAny]
def post(self, request: Request):
serializer = self.serializer_class(
data=request.data,
context={'request': request}
)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
login(request, user)
return Response(
{'message': 'login success'},
status=status.HTTP_202_ACCEPTED
)
class UserRegisterView(APIView):
serializer_class = serializers.UserRegisterSerializer
permission_classes = [AllowAny]
def post(self, request: Request):
serializer = self.serializer_class(
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(
serializer.data,
status=status.HTTP_201_CREATED
)
class TweetsView(APIView):
serializer_class = serializers.TweetSerializer
def get(self, request: Request):
tweets = Tweet.objects.all().order_by(
'-created_at'
)
serializer = self.serializer_class(
tweets,
many=True
)
return Response(serializer.data)
def post(self, request: Request):
serializer = self.serializer_class(
data=request.data,
context={'user': request.user}
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(
serializer.data,
status=status.HTTP_201_CREATED
)
class TweetView(APIView):
serializer_class = serializers.TweetSerializer
permission_classes = [TweetPermission]
def get(self, request: Request, tweet_id):
model = get_object_or_404(
Tweet,
pk=tweet_id
)
self.check_object_permissions(
request,
model
)
serializer = self.serializer_class(
model,
many=False
)
return Response(
serializer.data,
status=status.HTTP_200_OK
)
def put(self, request: Request, tweet_id):
model = get_object_or_404(
Tweet,
pk=tweet_id
)
self.check_object_permissions(
request,
model
)
serializer = self.serializer_class(
model,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(
serializer.data,
status=status.HTTP_200_OK
)
def delete(self, request: Request, tweet_id):
model = get_object_or_404(
Tweet,
pk=tweet_id
)
self.check_object_permissions(
request,
model
)
model.delete()
return Response(
status=status.HTTP_204_NO_CONTENT
)
なぜ force_authenticate() を使うのか
DRF の API テストでは、通常の Django ログインとは認証処理が異なる場合があります。
例えば JWT 認証や Token 認証を利用している場合:
self.client.login()
だけでは認証済みにならないケースがあります。
そのため DRF では:
self.client.force_authenticate(user=user)
を使用することで、認証済み状態を簡単に再現できます。
setUpTestData() を使うメリット
今回のテストでは:
@classmethod
def setUpTestData(cls):
を使用しています。
これは各テスト実行前に毎回データを作るのではなく、テストクラス単位で一度だけデータを生成する仕組みです。
メリット:
-
テスト高速化
-
DBアクセス削減
-
テストコード簡潔化
特に大量データを扱うテストでは効果が大きいです。
テスト観点として重要なポイント
API テストでは「成功ケース」だけでなく「失敗ケース」を確認することが重要です。
今回の例では:
| ケース | 期待値 |
|---|---|
| 未ログイン投稿 | 401 |
| ログイン済投稿 | 201 |
を明確に分けています。
このようなテストを追加しておくことで、認証不備の事故を防ぎやすくなります。
まとめ
DRF の API テストでは以下が重要です。
-
APITestCaseを使う -
force_authenticate()で認証状態を再現する -
setUpTestData()でテスト高速化する -
成功系と失敗系を両方テストする
特に認証付き API のテストでは:
self.client.force_authenticate(user=user)
を知っているだけで、テスト実装がかなり安定します。
関連キーワード
-
Django REST Framework
-
DRF テスト
-
APITestCase
-
force_authenticate
-
Django API テスト
-
Python Web API
-
REST API テスト
-
Django 認証テスト
【実務で使える】Angular+Django開発を最速化する「screen.yaml起点」の自動生成戦略(完全テンプレ付き)
【実務で使える】Angular+NgRx+Django開発を最速化する「screen.yaml起点」の自動生成戦略
はじめに
Webシステム開発では、以下のような課題がよく発生します。
-
画面とAPIの仕様がズレる
-
画面間のデータのつながりが不明確
-
UIレビューで何度も手戻りが発生する
-
ComponentにAPI呼び出しや状態管理が集中する
-
NgRxのAction / Effect / Reducer / Selectorを毎回手書きするのが大変
-
画面上のアクションが増えるほど実装パターンがバラつく
これらの問題は、設計の分断と定型コードの手書きによって発生します。
本記事では、
screen.yaml を起点に、Angular+NgRx構成の一覧画面・Service・Storeを自動生成する手法
を、実務レベルでそのまま使える形で解説します。
本記事の前提
本記事のテンプレートは、まず 一覧画面用 を想定しています。
具体的には、以下のような画面です。
初期表示で検索APIを呼ぶ
一覧データを表示する
検索・詳細遷移・削除など複数アクションを持つ
loading / error / empty を出し分ける
Componentから直接APIを呼ばない
本記事のゴール
screen.yamlを書く
↓
Angular Component が生成される
↓
NgRx Action / Effect / Reducer / Selector が生成される
↓
Service経由でAPIを呼び出す
↓
画面・API・状態管理・画面操作のズレを防ぐ
NgRx構成の全体像
今回の構成では、Componentから直接APIを呼び出しません。
Component
↓ dispatch
Action
↓
Effect
↓
Service
↓
API
↓
Effect
↓ dispatch success / failure
Reducer
↓
Selector
↓
Component
複数アクションの考え方
画面上の操作は、すべて同じ扱いにしない方がよいです。
判断基準は以下です。
APIを呼ぶ操作
→ NgRx Action / Effect / Service
画面遷移だけの操作
→ Router
画面内だけの表示切り替え
→ Componentローカル状態
複数画面で共有する状態
→ NgRx Store
アクション種別
本記事では、screen.yaml の actions.kind で処理を分けます。
kind: api
→ API呼び出しあり
kind: navigation
→ 画面遷移のみ
kind: local
→ Component内処理のみ
全体構成
tools/generator/
├─ generate.js
├─ package.json
├─ screens/
│ └─ user-list.yaml
└─ templates/
├─ model.ts.hbs
├─ service.ts.hbs
├─ component.ts.hbs
├─ component.html.hbs
├─ component.scss.hbs
├─ actions.ts.hbs
├─ effects.ts.hbs
├─ reducer.ts.hbs
└─ selectors.ts.hbs
package.json
{
"name": "screen-generator",
"version": "1.0.0",
"type": "commonjs",
"private": true,
"scripts": {
"generate": "node generate.js"
},
"dependencies": {
"handlebars": "^4.7.8",
"js-yaml": "^4.1.0"
}
}
Angular側で必要なNgRxパッケージ
Angularプロジェクト側では、NgRxを利用します。
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools
screen.yaml(画面仕様)
screenId: user-list
screenName: ユーザー一覧
className: UserList
route: /users
outputDir: ../../src/app/generated/user-list
api:
search:
method: GET
path: /api/users/
responseType: UserListItem[]
delete:
method: DELETE
path: /api/users/{userId}/
responseType: void
model:
name: UserListItem
fields:
- name: userId
label: ユーザーID
type: string
display: true
- name: userName
label: ユーザー名
type: string
display: true
- name: departmentName
label: 部署名
type: string
display: true
actions:
- name: search
label: 検索
type: button
kind: api
method: GET
result: list
successMessage: 検索しました。
failureMessage: データの取得に失敗しました。
- name: detail
label: 詳細
type: link
kind: navigation
route: /users/{userId}
payload:
- userId
- name: delete
label: 削除
type: button
kind: api
method: DELETE
confirm: true
confirmMessage: 削除してもよろしいですか?
result: remove
successMessage: 削除しました。
failureMessage: 削除に失敗しました。
payload:
- userId
Generator本体
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const Handlebars = require('handlebars');
const screenFile = process.argv[2];
if (!screenFile) {
console.error('Usage: node generate.js screens/user-list.yaml');
process.exit(1);
}
const rootDir = __dirname;
const screenPath = path.resolve(rootDir, screenFile);
const screen = yaml.load(fs.readFileSync(screenPath, 'utf8'));
const outputDir = path.resolve(rootDir, screen.outputDir);
const storeDir = path.resolve(outputDir, 'store');
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(storeDir, { recursive: true });
const kebabToCamel = (value) =>
value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
const kebabToPascal = (value) =>
kebabToCamel(value).replace(/^./, (char) => char.toUpperCase());
const capitalize = (value) =>
value.replace(/^./, (char) => char.toUpperCase());
const isApiAction = (action) => action.kind === 'api';
const isNavigationAction = (action) => action.kind === 'navigation';
const isLocalAction = (action) => action.kind === 'local';
const buildActionEventName = (action) => capitalize(action.name);
const buildActionSuccessEventName = (action) => `${capitalize(action.name)} Success`;
const buildActionFailureEventName = (action) => `${capitalize(action.name)} Failure`;
const buildActionMethodName = (action) => kebabToCamel(action.name);
const buildActionSuccessMethodName = (action) => `${kebabToCamel(action.name)}Success`;
const buildActionFailureMethodName = (action) => `${kebabToCamel(action.name)}Failure`;
const buildPayloadType = (payload) => {
if (!payload || payload.length === 0) {
return '';
}
const fields = payload.map((name) => `${name}: string`).join('; ');
return `{ ${fields} }`;
};
Handlebars.registerHelper('eq', (a, b) => a === b);
Handlebars.registerHelper('not', (value) => !value);
Handlebars.registerHelper('and', (a, b) => a && b);
Handlebars.registerHelper('or', (a, b) => a || b);
Handlebars.registerHelper('pascal', kebabToPascal);
Handlebars.registerHelper('camel', kebabToCamel);
Handlebars.registerHelper('capitalize', capitalize);
Handlebars.registerHelper('isApiAction', isApiAction);
Handlebars.registerHelper('isNavigationAction', isNavigationAction);
Handlebars.registerHelper('isLocalAction', isLocalAction);
Handlebars.registerHelper('actionEventName', buildActionEventName);
Handlebars.registerHelper('actionSuccessEventName', buildActionSuccessEventName);
Handlebars.registerHelper('actionFailureEventName', buildActionFailureEventName);
Handlebars.registerHelper('actionMethodName', buildActionMethodName);
Handlebars.registerHelper('actionSuccessMethodName', buildActionSuccessMethodName);
Handlebars.registerHelper('actionFailureMethodName', buildActionFailureMethodName);
Handlebars.registerHelper('payloadType', buildPayloadType);
const apiActions = screen.actions.filter(isApiAction);
const navigationActions = screen.actions.filter(isNavigationAction);
const localActions = screen.actions.filter(isLocalAction);
const context = {
...screen,
componentSelector: `app-${screen.screenId}`,
componentClassName: `${screen.className}Component`,
serviceClassName: `${screen.className}Service`,
actionsClassName: `${screen.className}Actions`,
effectsClassName: `${screen.className}Effects`,
stateInterfaceName: `${screen.className}State`,
reducerName: `${kebabToCamel(screen.screenId)}Reducer`,
featureKeyName: `${kebabToCamel(screen.screenId)}FeatureKey`,
fileBaseName: screen.screenId,
displayFields: screen.model.fields.filter((field) => field.display),
primaryKey: screen.model.fields[0].name,
apiActions,
navigationActions,
localActions,
hasNavigationActions: navigationActions.length > 0,
hasApiActions: apiActions.length > 0,
};
const templates = [
{
template: 'model.ts.hbs',
output: `${screen.screenId}.model.ts`,
dir: outputDir,
},
{
template: 'service.ts.hbs',
output: `${screen.screenId}.service.ts`,
dir: outputDir,
},
{
template: 'component.ts.hbs',
output: `${screen.screenId}.component.ts`,
dir: outputDir,
},
{
template: 'component.html.hbs',
output: `${screen.screenId}.component.html`,
dir: outputDir,
},
{
template: 'component.scss.hbs',
output: `${screen.screenId}.component.scss`,
dir: outputDir,
},
{
template: 'actions.ts.hbs',
output: `${screen.screenId}.actions.ts`,
dir: storeDir,
},
{
template: 'effects.ts.hbs',
output: `${screen.screenId}.effects.ts`,
dir: storeDir,
},
{
template: 'reducer.ts.hbs',
output: `${screen.screenId}.reducer.ts`,
dir: storeDir,
},
{
template: 'selectors.ts.hbs',
output: `${screen.screenId}.selectors.ts`,
dir: storeDir,
},
];
for (const item of templates) {
const templatePath = path.resolve(rootDir, 'templates', item.template);
const outputPath = path.resolve(item.dir, item.output);
const templateSource = fs.readFileSync(templatePath, 'utf8');
const template = Handlebars.compile(templateSource);
const result = template(context);
fs.writeFileSync(outputPath, result, 'utf8');
console.log(`generated: ${outputPath}`);
}
テンプレート(Model)
export interface {{model.name}} {
{{#each model.fields}}
{{name}}: {{type}};
{{/each}}
}
テンプレート(Service)
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { {{model.name}} } from './{{fileBaseName}}.model';
@Injectable({
providedIn: 'root',
})
export class {{serviceClassName}} {
constructor(private readonly http: HttpClient) {}
{{#each apiActions}}
{{actionMethodName this}}({{#if payload}}{{#each payload}}{{this}}: string{{#unless @last}}, {{/unless}}{{/each}}{{/if}}): Observable<{{lookup ../api name "responseType"}}> {
{{#if payload}}
const path = {{#each payload}}{{#if @first}}{{/if}}{{/each}}`{{lookup ../api name "path"}}`;
return this.http.{{camel method}}<{{lookup ../api name "responseType"}}>(path);
{{else}}
return this.http.{{camel method}}<{{lookup ../api name "responseType"}}>('{{lookup ../api name "path"}}');
{{/if}}
}
{{/each}}
}
補足:Serviceテンプレートについて
上記の lookup を使うため、Generatorに以下のHelperを追加してください。
Handlebars.registerHelper('lookup', (object, key, property) => {
if (!object || !object[key]) {
return '';
}
return object[key][property];
});
また、DELETEなどで void を扱う場合、生成後のAngularコードでは以下のようになります。
delete(userId: string): Observable<void> {
const path = `/api/users/${userId}/`;
return this.http.delete<void>(path);
}
テンプレート(Component)
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
{{#if hasNavigationActions}}
import { Router } from '@angular/router';
{{/if}}
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { {{model.name}} } from './{{fileBaseName}}.model';
import { {{actionsClassName}} } from './store/{{fileBaseName}}.actions';
import {
select{{className}}Items,
select{{className}}Loading,
select{{className}}Error,
} from './store/{{fileBaseName}}.selectors';
@Component({
selector: '{{componentSelector}}',
standalone: true,
imports: [CommonModule],
templateUrl: './{{fileBaseName}}.component.html',
styleUrls: ['./{{fileBaseName}}.component.scss'],
})
export class {{componentClassName}} implements OnInit {
readonly items$: Observable<{{model.name}}[]> = this.store.select(select{{className}}Items);
readonly loading$: Observable<boolean> = this.store.select(select{{className}}Loading);
readonly error$: Observable<string | null> = this.store.select(select{{className}}Error);
constructor(
private readonly store: Store{{#if hasNavigationActions}},
private readonly router: Router{{/if}}
) {}
ngOnInit(): void {
{{#each apiActions}}
{{#if (eq name "search")}}
this.{{actionMethodName this}}();
{{/if}}
{{/each}}
}
{{#each actions}}
{{#if (isApiAction this)}}
{{actionMethodName this}}({{#if payload}}{{#each payload}}{{this}}: string{{#unless @last}}, {{/unless}}{{/each}}{{/if}}): void {
{{#if confirm}}
if (!window.confirm('{{confirmMessage}}')) {
return;
}
{{/if}}
this.store.dispatch(
{{../actionsClassName}}.{{actionMethodName this}}({{#if payload}}{ {{#each payload}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} }{{/if}})
);
}
{{/if}}
{{#if (isNavigationAction this)}}
{{actionMethodName this}}({{#if payload}}{{#each payload}}{{this}}: string{{#unless @last}}, {{/unless}}{{/each}}{{/if}}): void {
this.router.navigate([
{{#if payload}}
'{{route}}'.replace('{{"{"}}{{payload.[0]}}{{"}"}}', {{payload.[0]}})
{{else}}
'{{route}}'
{{/if}}
]);
}
{{/if}}
{{#if (isLocalAction this)}}
{{actionMethodName this}}(): void {
// TODO: 画面内だけで完結する処理を実装してください。
}
{{/if}}
{{/each}}
}
テンプレート(Component HTML)
<section class="screen" data-testid="{{screenId}}-screen">
<h1 data-testid="{{screenId}}-title">{{screenName}}</h1>
<div class="actions">
{{#each actions}}
{{#if (eq type "button")}}
{{#if (not payload)}}
<button
type="button"
data-testid="{{../screenId}}-{{name}}-button"
(click)="{{actionMethodName this}}()"
>
{{label}}
</button>
{{/if}}
{{/if}}
{{/each}}
</div>
@if (loading$ | async) {
<p data-testid="{{screenId}}-loading">読み込み中...</p>
}
@if (error$ | async; as error) {
<p class="error" data-testid="{{screenId}}-error">
{{ '{{ error }}' }}
</p>
}
@if (items$ | async; as items) {
@if (!(loading$ | async) && !((error$ | async)) && items.length === 0) {
<p data-testid="{{screenId}}-empty">データがありません。</p>
}
@if (!(loading$ | async) && !((error$ | async)) && items.length > 0) {
<table data-testid="{{screenId}}-table">
<thead>
<tr>
{{#each displayFields}}
<th>{{label}}</th>
{{/each}}
<th>操作</th>
</tr>
</thead>
<tbody>
@for (item of items; track item.{{primaryKey}}) {
<tr data-testid="{{screenId}}-row">
{{#each displayFields}}
<td data-testid="{{../screenId}}-{{name}}">
{{ '{{ item.' }}{{name}}{{ ' }}' }}
</td>
{{/each}}
<td>
{{#each actions}}
{{#if payload}}
{{#if (eq type "button")}}
<button
type="button"
data-testid="{{../screenId}}-{{name}}-button"
(click)="{{actionMethodName this}}({{#each payload}}item.{{this}}{{#unless @last}}, {{/unless}}{{/each}})"
>
{{label}}
</button>
{{/if}}
{{#if (eq type "link")}}
<button
type="button"
class="link-button"
data-testid="{{../screenId}}-{{name}}-link"
(click)="{{actionMethodName this}}({{#each payload}}item.{{this}}{{#unless @last}}, {{/unless}}{{/each}})"
>
{{label}}
</button>
{{/if}}
{{/if}}
{{/each}}
</td>
</tr>
}
</tbody>
</table>
}
}
</section>
テンプレート(Component SCSS)
.screen {
padding: 16px;
}
.actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.error {
color: #b00020;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 8px;
border: 1px solid #ddd;
text-align: left;
}
.link-button {
padding: 0;
border: none;
background: transparent;
color: #0645ad;
text-decoration: underline;
cursor: pointer;
}
テンプレート(Actions)
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { {{model.name}} } from '../{{fileBaseName}}.model';
export const {{actionsClassName}} = createActionGroup({
source: '{{screenName}}',
events: {
{{#each apiActions}}
{{#if payload}}
'{{actionEventName this}}': props<{{payloadType payload}}>(),
{{else}}
'{{actionEventName this}}': emptyProps(),
{{/if}}
{{#if (eq result "list")}}
'{{actionSuccessEventName this}}': props<{ items: {{../model.name}}[] }>(),
{{else}}
{{#if payload}}
'{{actionSuccessEventName this}}': props<{{payloadType payload}}>(),
{{else}}
'{{actionSuccessEventName this}}': emptyProps(),
{{/if}}
{{/if}}
'{{actionFailureEventName this}}': props<{ error: string }>(),
{{/each}}
},
});
テンプレート(Effects)
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { {{serviceClassName}} } from '../{{fileBaseName}}.service';
import { {{actionsClassName}} } from './{{fileBaseName}}.actions';
@Injectable()
export class {{effectsClassName}} {
{{#each apiActions}}
readonly {{actionMethodName this}}$ = createEffect(() =>
this.actions$.pipe(
ofType({{../actionsClassName}}.{{actionMethodName this}}),
switchMap((action) =>
this.service.{{actionMethodName this}}({{#if payload}}{{#each payload}}action.{{this}}{{#unless @last}}, {{/unless}}{{/each}}{{/if}}).pipe(
{{#if (eq result "list")}}
map((items) => {{../actionsClassName}}.{{actionSuccessMethodName this}}({ items })),
{{else}}
{{#if payload}}
map(() =>
{{../actionsClassName}}.{{actionSuccessMethodName this}}({
{{#each payload}}
{{this}}: action.{{this}},
{{/each}}
})
),
{{else}}
map(() => {{../actionsClassName}}.{{actionSuccessMethodName this}}()),
{{/if}}
{{/if}}
catchError(() =>
of(
{{../actionsClassName}}.{{actionFailureMethodName this}}({
error: '{{failureMessage}}',
})
)
)
)
)
)
);
{{/each}}
constructor(
private readonly actions$: Actions,
private readonly service: {{serviceClassName}}
) {}
}
テンプレート(Reducer)
import { createReducer, on } from '@ngrx/store';
import { {{model.name}} } from '../{{fileBaseName}}.model';
import { {{actionsClassName}} } from './{{fileBaseName}}.actions';
export const {{featureKeyName}} = '{{camel screenId}}';
export interface {{stateInterfaceName}} {
items: {{model.name}}[];
loading: boolean;
error: string | null;
}
export const initial{{stateInterfaceName}}: {{stateInterfaceName}} = {
items: [],
loading: false,
error: null,
};
export const {{reducerName}} = createReducer(
initial{{stateInterfaceName}},
{{#each apiActions}}
on({{../actionsClassName}}.{{actionMethodName this}}, (state): {{../stateInterfaceName}} => ({
...state,
loading: true,
error: null,
})),
{{#if (eq result "list")}}
on({{../actionsClassName}}.{{actionSuccessMethodName this}}, (state, { items }): {{../stateInterfaceName}} => ({
...state,
items,
loading: false,
error: null,
})),
{{else}}
{{#if (eq result "remove")}}
on({{../actionsClassName}}.{{actionSuccessMethodName this}}, (state, { {{payload.[0]}} }): {{../stateInterfaceName}} => ({
...state,
items: state.items.filter((item) => item.{{../primaryKey}} !== {{payload.[0]}}),
loading: false,
error: null,
})),
{{else}}
on({{../actionsClassName}}.{{actionSuccessMethodName this}}, (state): {{../stateInterfaceName}} => ({
...state,
loading: false,
error: null,
})),
{{/if}}
{{/if}}
on({{../actionsClassName}}.{{actionFailureMethodName this}}, (state, { error }): {{../stateInterfaceName}} => ({
...state,
loading: false,
error,
})){{#unless @last}},{{/unless}}
{{/each}}
);
テンプレート(Selectors)
import { createFeatureSelector, createSelector } from '@ngrx/store';
import {
{{featureKeyName}},
{{stateInterfaceName}},
} from './{{fileBaseName}}.reducer';
export const select{{className}}State =
createFeatureSelector<{{stateInterfaceName}}>({{featureKeyName}});
export const select{{className}}Items = createSelector(
select{{className}}State,
(state) => state.items
);
export const select{{className}}Loading = createSelector(
select{{className}}State,
(state) => state.loading
);
export const select{{className}}Error = createSelector(
select{{className}}State,
(state) => state.error
);
実行方法
cd tools/generator
npm install
npm run generate -- screens/user-list.yaml
生成結果
src/app/generated/user-list/
├─ user-list.model.ts
├─ user-list.service.ts
├─ user-list.component.ts
├─ user-list.component.html
├─ user-list.component.scss
└─ store/
├─ user-list.actions.ts
├─ user-list.effects.ts
├─ user-list.reducer.ts
└─ user-list.selectors.ts
生成後のコード例
上記テンプレートを使うと、以下のようなコードが生成されます。
user-list.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { UserListItem } from './user-list.model';
@Injectable({
providedIn: 'root',
})
export class UserListService {
constructor(private readonly http: HttpClient) {}
search(): Observable<UserListItem[]> {
return this.http.get<UserListItem[]>('/api/users/');
}
delete(userId: string): Observable<void> {
const path = `/api/users/${userId}/`;
return this.http.delete<void>(path);
}
}
user-list.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { UserListItem } from './user-list.model';
import { UserListActions } from './store/user-list.actions';
import {
selectUserListItems,
selectUserListLoading,
selectUserListError,
} from './store/user-list.selectors';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule],
templateUrl: './user-list.component.html',
styleUrls: ['./user-list.component.scss'],
})
export class UserListComponent implements OnInit {
readonly items$: Observable<UserListItem[]> = this.store.select(selectUserListItems);
readonly loading$: Observable<boolean> = this.store.select(selectUserListLoading);
readonly error$: Observable<string | null> = this.store.select(selectUserListError);
constructor(
private readonly store: Store,
private readonly router: Router
) {}
ngOnInit(): void {
this.search();
}
search(): void {
this.store.dispatch(UserListActions.search());
}
detail(userId: string): void {
this.router.navigate([
'/users/{userId}'.replace('{userId}', userId)
]);
}
delete(userId: string): void {
if (!window.confirm('削除してもよろしいですか?')) {
return;
}
this.store.dispatch(
UserListActions.delete({ userId })
);
}
}
user-list.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { UserListItem } from '../user-list.model';
export const UserListActions = createActionGroup({
source: 'ユーザー一覧',
events: {
Search: emptyProps(),
'Search Success': props<{ items: UserListItem[] }>(),
'Search Failure': props<{ error: string }>(),
Delete: props<{ userId: string }>(),
'Delete Success': props<{ userId: string }>(),
'Delete Failure': props<{ error: string }>(),
},
});
user-list.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { UserListService } from '../user-list.service';
import { UserListActions } from './user-list.actions';
@Injectable()
export class UserListEffects {
readonly search$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListActions.search),
switchMap(() =>
this.service.search().pipe(
map((items) => UserListActions.searchSuccess({ items })),
catchError(() =>
of(
UserListActions.searchFailure({
error: 'データの取得に失敗しました。',
})
)
)
)
)
)
);
readonly delete$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListActions.delete),
switchMap((action) =>
this.service.delete(action.userId).pipe(
map(() =>
UserListActions.deleteSuccess({
userId: action.userId,
})
),
catchError(() =>
of(
UserListActions.deleteFailure({
error: '削除に失敗しました。',
})
)
)
)
)
)
);
constructor(
private readonly actions$: Actions,
private readonly service: UserListService
) {}
}
user-list.reducer.ts
import { createReducer, on } from '@ngrx/store';
import { UserListItem } from '../user-list.model';
import { UserListActions } from './user-list.actions';
export const userListFeatureKey = 'userList';
export interface UserListState {
items: UserListItem[];
loading: boolean;
error: string | null;
}
export const initialUserListState: UserListState = {
items: [],
loading: false,
error: null,
};
export const userListReducer = createReducer(
initialUserListState,
on(UserListActions.search, (state): UserListState => ({
...state,
loading: true,
error: null,
})),
on(UserListActions.searchSuccess, (state, { items }): UserListState => ({
...state,
items,
loading: false,
error: null,
})),
on(UserListActions.searchFailure, (state, { error }): UserListState => ({
...state,
loading: false,
error,
})),
on(UserListActions.delete, (state): UserListState => ({
...state,
loading: true,
error: null,
})),
on(UserListActions.deleteSuccess, (state, { userId }): UserListState => ({
...state,
items: state.items.filter((item) => item.userId !== userId),
loading: false,
error: null,
})),
on(UserListActions.deleteFailure, (state, { error }): UserListState => ({
...state,
loading: false,
error,
}))
);
user-list.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import {
userListFeatureKey,
UserListState,
} from './user-list.reducer';
export const selectUserListState =
createFeatureSelector<UserListState>(userListFeatureKey);
export const selectUserListItems = createSelector(
selectUserListState,
(state) => state.items
);
export const selectUserListLoading = createSelector(
selectUserListState,
(state) => state.loading
);
export const selectUserListError = createSelector(
selectUserListState,
(state) => state.error
);
Angularルーティング例
import { Routes } from '@angular/router';
import { UserListComponent } from './generated/user-list/user-list.component';
export const routes: Routes = [
{
path: 'users',
component: UserListComponent,
},
];
NgRx Store / Effects の登録例
Standalone構成の場合、app.config.ts などで登録します。
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { routes } from './app.routes';
import {
userListFeatureKey,
userListReducer,
} from './generated/user-list/store/user-list.reducer';
import { UserListEffects } from './generated/user-list/store/user-list.effects';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideRouter(routes),
provideStore({
[userListFeatureKey]: userListReducer,
}),
provideEffects([UserListEffects]),
provideStoreDevtools({
maxAge: 25,
}),
],
};
NgRx構成で生成される処理の流れ
1. UserListComponent が ngOnInit で search() を呼ぶ
2. search() が UserListActions.search() を dispatch
3. UserListEffects が search Action を検知
4. UserListService.search() を呼び出す
5. API通信に成功したら searchSuccess を dispatch
6. 失敗したら searchFailure を dispatch
7. Reducer が state を更新
8. Selector 経由で Component に反映
削除の場合は以下です。
1. 削除ボタンを押す
2. confirmで確認する
3. UserListActions.delete({ userId }) を dispatch
4. UserListEffects が delete Action を検知
5. UserListService.delete(userId) を呼び出す
6. 成功したら deleteSuccess({ userId }) を dispatch
7. Reducer が一覧から対象行を削除する
8. Selector 経由で Component に反映
詳細遷移の場合は以下です。
1. 詳細ボタンを押す
2. Component内で Router.navigate を呼ぶ
3. APIは呼ばない
4. Storeも更新しない
運用ルール
generated は手修正しない
手書きコードは features に置く
画面仕様の変更は screen.yaml を先に直す
ComponentからServiceを直接呼ばない
API呼び出しは Effect から行う
画面表示は Selector 経由の Observable を使う
画面遷移だけの操作は Router を使う
画面内だけの表示切り替えは Component ローカル状態にする
まとめ
✔ screen.yamlで画面仕様を統一
✔ 一覧画面テンプレートとして使う
✔ 複数アクションは actions 配列で管理する
✔ API操作は Action / Effect / Service に流す
✔ 画面遷移は Router に任せる
✔ ComponentはActionをdispatchするだけにする
✔ Reducerで状態を更新する
✔ Selectorで画面に状態を渡す
✔ UI・API・状態管理・操作パターンのズレを防止する
最後に
この手法の本質は「コード生成」ではありません。
設計を構造化し、実装パターンを統一すること
NgRxはファイル数が増えやすい一方で、構成を統一できれば保守性が大きく上がります。
そのため、screen.yaml を起点にAction / Effect / Reducer / Selectorまで生成する方式は、チーム開発と相性が良いです。
【完全ローカル】Django開発のコード品質を一気に上げる最小構成(Ruff / mypy / pytest / pre-commit)
Django REST API を開発していると、こんな問題が起きがちです。
-
コードレビューに時間がかかる
-
フォーマットや命名の指摘が多い
-
バグがレビュー後に発覚する
この記事では、コードを外部にアップロードせずに、ローカルだけで品質を底上げする方法を紹介します。
この記事のゴール
以下のツールを組み合わせて、
👉 レビュー前に品質を揃える仕組みを作ります
-
Ruff(Lint + フォーマット)
-
mypy(型チェック)
-
pytest / pytest-django(テスト)
-
Bandit(セキュリティチェック)
-
pip-audit(依存脆弱性チェック)
-
pre-commit(コミット前チェック)
なぜこの構成なのか
レビュー時間を削減するには、これが本質です👇
👉 人が指摘している内容をツールにやらせる
自動化できるもの
-
import順
-
フォーマット
-
未使用変数
-
型ミス
-
簡単なバグ
-
セキュリティ問題
人が見るべきもの
-
設計
-
ビジネスロジック
-
認証・認可
-
DB設計
インストール
pip install ruff mypy pytest pytest-django bandit pip-audit pre-commit
設定ファイル
pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"
exclude = [
".git",
".venv",
"venv",
"migrations",
"static",
"media"
]
[tool.ruff.lint]
select = [
"E",
"F",
"I",
"B",
"UP",
"SIM",
"DJ",
"S"
]
ignore = [
"E501",
"S101"
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
ignore_missing_imports = true
exclude = "(migrations|venv|\\.venv)"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "project.settings"
python_files = ["test_*.py", "*_test.py", "tests.py"]
addopts = "-q"
この設定のポイント
Ruff
-
I→ import整理 -
B→ バグ検出 -
DJ→ Django特化ルール -
S→ セキュリティチェック
👉 レビュー指摘の7割を自動化
mypy
disallow_untyped_defs = true
👉 型なし関数を禁止(新規コード品質アップ)
ignore_missing_imports = true
👉 Django / boto3 で詰まらないようにする
.pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: ruff-check
name: ruff-check
entry: python -m ruff check .
language: system
pass_filenames: false
- id: ruff-format
name: ruff-format
entry: python -m ruff format . --check
language: system
pass_filenames: false
- id: mypy
name: mypy
entry: python -m mypy .
language: system
pass_filenames: false
- id: pytest
name: pytest
entry: python -m pytest
language: system
pass_filenames: false
- id: bandit
name: bandit
entry: python -m bandit -r .
language: system
pass_filenames: false
pre-commit導入
pre-commit install
開発フロー
コミット時に自動実行される
ruff check
ruff format
mypy
pytest
bandit
👉 通らないとコミットできない
手動チェック
python -m ruff check .
python -m ruff format .
python -m mypy .
python -m pytest
python -m bandit -r .
pip-audit
導入効果
Before
-
レビュー時間:30〜60分
-
指摘内容:フォーマット、変数名、import
After
-
レビュー時間:10〜20分
-
指摘内容:設計・ロジックのみ
セキュリティ面
この構成はすべて
👉 ローカル実行
です。
つまり
-
コードは外部に送信されない
-
情報漏洩リスクなし
-
社内規約に対応可能
運用ルール(重要)
実装者
-
PR前にすべて通す
-
エラーは必ず修正
-
警告は増やさない
レビュアー
-
Lint指摘は禁止
-
設計だけ見る
まとめ
最短導入
pip install ruff mypy pytest pytest-django bandit pip-audit pre-commit
pre-commit install
本質
👉 レビュー前に品質を揃える
一番効果がある順番
-
Ruff
-
pytest
-
mypy
-
pre-commit
次にやるとさらに効果大
-
Djangoのフォルダ構成整理
-
service層の分離
-
OpenAPI + 型自動生成
完成版 eslint.config.js
完成版 eslint.config.js
// eslint.config.js
これを入れると何が変わるか
自動化されるレビュー(かなり減る)
-
未使用変数
-
anyの乱用(警告)
-
空のngOnInit
-
コンポーネント命名ミス
-
==ミス -
テンプレートの危険記法
-
無駄に長い関数
レビューが楽になるポイント
人間が見るべきポイントがここに集中します👇
-
状態管理(NgRx / Service設計)
-
API設計
-
UX
-
コンポーネント分割
-
ビジネスロジック
最初に確認するコマンド
npm run lint
よくあるミス
❌ エラー: angular is not defined
👉 原因
const angular = require('angular-eslint');
が抜けている
❌ HTMLがLintされない
👉 これが必要
processor: angular.processInlineTemplates
最短導入チェック
npm run lint
npm run format:check
重要(運用)
このルールは「最初から完璧」を目指していません。
推奨運用
-
error → 必ず修正
-
warn → 徐々に改善
-
既存コード → 無理に全部直さない
-
新規コード → ルールを守る
次にやるとさらに効く
ここまでできたら👇
-
pre-commitでlint強制
-
GitHub Actionsでlint必須
-
reviewdogでPRコメント自動化
まとめ
この設定を入れると
👉 レビューの細かい指摘がほぼ消えます
結果として
👉 レビュー時間が体感で30〜60%減ります
必要なら次👇
👉 Angular専用「絶対NGルール」だけの軽量版
👉 CI + reviewdog 完全自動レビュー構成(コピペ可)
も出せます