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担当

ログイン状態保持

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"
}

作成手順:

  1. AWS Consoleへログイン

  2. Secrets Manager

  3. 「新しいシークレットを保存」

  4. 「その他のシークレット」

  5. JSON入力

  6. 名前:

motion-analysis/prod/app-secrets

4.2 Cognito User Pool作成

作成手順

  1. Cognito

  2. 「User Pool作成」

  3. サインイン方式:

email
  1. MFA:

必須
  1. MFAタイプ:

Authenticator App
  1. 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 の APITestCaseforce_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.yamlactions.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

本質

👉 レビュー前に品質を揃える


一番効果がある順番

  1. Ruff

  2. pytest

  3. mypy

  4. pre-commit


次にやるとさらに効果大

  • Djangoのフォルダ構成整理

  • service層の分離

  • OpenAPI + 型自動生成


 

完成版 eslint.config.js

完成版 eslint.config.js

// eslint.config.js
// eslint.config.js

const js = require('@eslint/js');
const globals = require('globals');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const eslintConfigPrettier = require('eslint-config-prettier');
const importPlugin = require('eslint-plugin-import');

module.exports = [
  {
    ignores: ['dist/**', 'coverage/**', 'node_modules/**', '.angular/**', '.cypress/**']
  },

  // --------------------------------------
  // TypeScript / Angular source files
  // --------------------------------------
  {
    files: ['**/*.ts'],
    ...js.configs.recommended,
    ...tseslint.configs.recommended[0],
    languageOptions: {
      ...tseslint.configs.recommended[0].languageOptions,
      parserOptions: {
        projectService: true,
        tsconfigRootDir: __dirname
      },
      globals: {
        ...globals.browser,
        ...globals.node
      }
    },
    plugins: {
      ...tseslint.configs.recommended[0].plugins,
      '@angular-eslint': angular.tsPlugin,
      import: importPlugin
    },
    processor: angular.processInlineTemplates,
    rules: {
      ...tseslint.configs.recommended[0].rules,
      ...angular.configs.tsRecommended[1].rules,

      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      '@typescript-eslint/consistent-type-imports': 'off',
      '@typescript-eslint/no-empty-function': 'warn',
      '@typescript-eslint/no-inferrable-types': 'off',

      'no-console': ['warn', { allow: ['warn', 'error'] }],
      eqeqeq: ['error', 'always'],
      curly: ['error', 'all'],
      'no-var': 'error',
      'prefer-const': 'error',

      'import/no-duplicates': 'error',
      'import/first': 'error',
      'import/newline-after-import': 'error',
      'import/order': [
        'warn',
        {
          groups: [
            'builtin',
            'external',
            'internal',
            ['parent', 'sibling', 'index'],
            'object',
            'type'
          ],
          'newlines-between': 'always',
          alphabetize: {
            order: 'asc',
            caseInsensitive: true
          }
        }
      ],

      '@angular-eslint/component-class-suffix': 'error',
      '@angular-eslint/directive-class-suffix': 'error',
      '@angular-eslint/component-selector': [
        'error',
        {
          type: 'element',
          prefix: 'app',
          style: 'kebab-case'
        }
      ],
      '@angular-eslint/directive-selector': [
        'error',
        {
          type: 'attribute',
          prefix: 'app',
          style: 'camelCase'
        }
      ],
      '@angular-eslint/no-empty-lifecycle-method': 'error',
      '@angular-eslint/use-lifecycle-interface': 'error',
      '@angular-eslint/prefer-on-push-component-change-detection': 'warn',

      'max-lines-per-function': [
        'warn',
        {
          max: 80,
          skipBlankLines: true,
          skipComments: true
        }
      ],
      complexity: ['warn', 10],
      'max-depth': ['warn', 3]
    }
  },

  // --------------------------------------
  // Angular HTML templates
  // --------------------------------------
  {
    files: ['**/*.html'],
    plugins: {
      '@angular-eslint/template': angular.templatePlugin
    },
    languageOptions: {
      parser: angular.templateParser
    },
    rules: {
      ...angular.configs.templateRecommended[1].rules,

      '@angular-eslint/template/banana-in-box': 'error',
      '@angular-eslint/template/eqeqeq': 'error',
      '@angular-eslint/template/no-negated-async': 'error',
      '@angular-eslint/template/click-events-have-key-events': 'warn',
      '@angular-eslint/template/interactive-supports-focus': 'warn'
    }
  },

  eslintConfigPrettier
];
 

これを入れると何が変わるか

自動化されるレビュー(かなり減る)

  • 未使用変数

  • 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 完全自動レビュー構成(コピペ可)

も出せます