From 76b090ae22e8bc0bc9e08e91a438140f2a403f4e Mon Sep 17 00:00:00 2001 From: wkdgus1164 Date: Sat, 21 Feb 2026 05:34:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20flutter-ddd-builder=20v0.1.0=20?= =?UTF-8?q?=EB=B0=8F=20python-fastapi-programmer=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flutter-ddd-builder: - v0.0.4 → v0.1.0 버전 업그레이드 - pagination, form validation, theme dual-mode 패턴 추가 - auth provider 상태 패턴, AppException 에러 처리 가이드 추가 - common-utilities 레퍼런스 추가 - agents/commands 프롬프트 개선 및 SKILL.md 업데이트 - hooks 설정 업데이트 python-fastapi-programmer: - agents 프롬프트 및 skills 레퍼런스 업데이트 - fastapi-architecture, fastapi-security 패턴 개선 - CLAUDE.md 및 plugin.json 업데이트 Co-Authored-By: Claude Opus 4.6 --- .../.claude-plugin/plugin.json | 2 +- .../agents/logic-implementer.md | 51 +++++++ .../agents/logic-orchestrator.md | 24 ++++ .../agents/ui-implementer.md | 83 +++++++++++ .../agents/ui-orchestrator.md | 14 ++ .../flutter-ddd-builder/agents/ui-planner.md | 11 ++ plugins/flutter-ddd-builder/commands/logic.md | 4 + plugins/flutter-ddd-builder/commands/start.md | 3 + plugins/flutter-ddd-builder/commands/ui.md | 11 ++ plugins/flutter-ddd-builder/hooks/hooks.json | 2 +- .../skills/flutter-ddd-patterns/SKILL.md | 42 +++++- .../examples/auth_service_example.dart | 90 ++++++++---- .../form_with_validation_example.dart | 80 +++++++++++ .../examples/paginated_list_example.dart | 89 ++++++++++++ .../examples/post_create_page_example.dart | 8 +- .../references/anti-patterns.md | 52 +++++++ .../references/api-client-patterns.md | 17 ++- .../references/common-utilities.md | 86 +++++++++++ .../references/pagination-patterns.md | 136 ++++++++++++++++++ .../references/riverpod-3x-guide.md | 95 ++++++++++++ .../references/theme-dual-mode.md | 39 +++++ .../.claude-plugin/plugin.json | 4 +- plugins/python-fastapi-programmer/CLAUDE.md | 20 ++- plugins/python-fastapi-programmer/LICENSE | 2 +- .../agents/logic-code-generator.md | 85 ++++++++++- .../agents/phase-1-domain-validator.md | 7 +- .../agents/phase-2-deep-researcher.md | 7 +- .../agents/phase-3-env-generator.md | 7 +- .../agents/phase-4-code-generator.md | 11 +- .../agents/phase-5-code-reviewer.md | 114 ++++++++++++++- .../agents/phase-6-documenter.md | 5 +- .../agents/test-code-generator.md | 85 +++++++++-- .../commands/start.md | 7 +- .../skills/fastapi-architecture/SKILL.md | 25 +++- .../references/vertical-slice-pattern.md | 15 +- .../skills/fastapi-security/SKILL.md | 31 +++- .../references/environment-variables.md | 56 ++++++++ .../references/jwt-authentication.md | 16 ++- .../references/password-hashing.md | 37 +++++ 39 files changed, 1390 insertions(+), 83 deletions(-) create mode 100644 plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/form_with_validation_example.dart create mode 100644 plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/paginated_list_example.dart create mode 100644 plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/common-utilities.md create mode 100644 plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/pagination-patterns.md create mode 100644 plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/theme-dual-mode.md diff --git a/plugins/flutter-ddd-builder/.claude-plugin/plugin.json b/plugins/flutter-ddd-builder/.claude-plugin/plugin.json index ae4b373..c69ec66 100644 --- a/plugins/flutter-ddd-builder/.claude-plugin/plugin.json +++ b/plugins/flutter-ddd-builder/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "flutter-ddd-builder", - "version": "0.0.4", + "version": "0.1.0", "description": "Domain Book 기반 Flutter DDD 아키텍처 코드 자동 생성 플러그인. 비즈니스 로직과 UI를 병렬 팀 작업으로 구축하고 실시간 품질 검증을 제공합니다.", "author": { "name": "Andy", diff --git a/plugins/flutter-ddd-builder/agents/logic-implementer.md b/plugins/flutter-ddd-builder/agents/logic-implementer.md index 11c1a8b..9624956 100644 --- a/plugins/flutter-ddd-builder/agents/logic-implementer.md +++ b/plugins/flutter-ddd-builder/agents/logic-implementer.md @@ -103,6 +103,57 @@ class {Domain}Service extends _${Domain}Service { } ``` +### 4b. Pagination Support for List APIs + +For list-type API endpoints, use `PaginatedResponse` model: + +```dart +import 'package:app/global/types/paginated_response.dart'; + +// List API with pagination +Future> getPosts({int page = 1, int limit = 20}) async { + final dio = ref.read(dioProvider); + final response = await dio.get('/api/posts', queryParameters: { + 'page': page, + 'limit': limit, + }); + return PaginatedResponse.fromJson( + response.data as Map, + (json) => PostModel.fromJson(json as Map), + ); +} +``` + +Error handling with `AppException`: +```dart +state = await AsyncValue.guard(() async { + // AppException is thrown automatically by ErrorInterceptor + return _fetchPosts(); +}); +``` + +### 4c. Auth Provider State Pattern (auth 도메인 전용) + +`authProvider`는 인증 "상태"를 표현하므로 절대 `AsyncError`로 두면 안 됨: + +```dart +Future login({required String email, required String password}) async { + state = const AsyncData(AuthStateModel.loading()); + try { + // ... API 호출, 토큰 저장 ... + state = AsyncData(AuthStateModel.authenticated( + accessToken: token, user: user, + )); + } catch (e, st) { + // ✅ 실패 → unauthenticated 복귀 (절대 AsyncError 아님) + state = const AsyncData(AuthStateModel.unauthenticated()); + Error.throwWithStackTrace(e, st); // 호출자(emailLoginProvider)에 에러 전파 + } +} +``` + +**이유**: `authProvider`가 `AsyncError`이면 router redirect에서 `authState.value == null` → 보호 라우트 가드 비활성화 위험. + ### 5. Handle Dependencies If you need models from other domains: diff --git a/plugins/flutter-ddd-builder/agents/logic-orchestrator.md b/plugins/flutter-ddd-builder/agents/logic-orchestrator.md index c618125..e062a34 100644 --- a/plugins/flutter-ddd-builder/agents/logic-orchestrator.md +++ b/plugins/flutter-ddd-builder/agents/logic-orchestrator.md @@ -206,6 +206,23 @@ fi - Running build_runner here generates the initial setup - Teammates can then work without conflicts +### 4b. Preflight Checks + +Verify boilerplate utilities exist before spawning teammates: + +```bash +# Check required utility files +for file in \ + lib/global/types/paginated_response.dart \ + lib/global/utils/validators.dart \ + lib/apps/ui/common/async_value_widget.dart \ + lib/apps/infra/exception/exception_handler.dart; do + if [ ! -f "$file" ]; then + echo "WARNING: Missing $file - boilerplate may be incomplete" + fi +done +``` + ### 5. Spawn Logic Implementers **For each domain, spawn teammate:** @@ -463,6 +480,13 @@ parse_build_errors(build_output): retry_build() ``` +**Lint verification (zero warnings)**: +```bash +flutter analyze --no-fatal-infos +# Must show: No issues found! +# If warnings exist, fix before proceeding +``` + ### 9. Integration **Merge all worktrees:** diff --git a/plugins/flutter-ddd-builder/agents/ui-implementer.md b/plugins/flutter-ddd-builder/agents/ui-implementer.md index a36a88b..2ec3505 100644 --- a/plugins/flutter-ddd-builder/agents/ui-implementer.md +++ b/plugins/flutter-ddd-builder/agents/ui-implementer.md @@ -142,6 +142,89 @@ Usage: await authService.login(email, password); ``` +### 6b. Standard Widget Patterns + +**AsyncValueWidget (REQUIRED for all async data)**: +```dart +import 'package:app/apps/ui/common/async_value_widget.dart'; + +// Use instead of .when() inline +AsyncValueWidget( + value: postListState, + emptyCheck: (posts) => posts.isEmpty, + emptyMessage: '게시글이 없습니다', + data: (posts) => ListView.builder(...), +); +``` + +**Validators (REQUIRED for all forms)**: +```dart +import 'package:app/global/utils/validators.dart'; + +TextFormField( + validator: Validators.compose([ + Validators.required, + Validators.minLength(2, fieldName: '제목'), + ]), +); +``` + +**withLoaderOverlay (REQUIRED for mutation buttons)**: +```dart +import 'package:app/global/utils/with_loader_overlay.dart'; + +onPressed: () async { + await withLoaderOverlay(context, () async { + await ref.read(provider.notifier).create(model); + }); +} +``` + +**Theme Tokens**: +```dart +// Always use Theme.of(context) for colors and text +final cs = Theme.of(context).colorScheme; +final tt = Theme.of(context).textTheme; + +// Use AppSpacing/AppRadius when tokens are available +import 'package:app/core/theme/tokens/generated/spacing.gen.dart'; +import 'package:app/core/theme/tokens/generated/radius.gen.dart'; +Padding(padding: EdgeInsets.all(AppSpacing.spacing4)); +``` + +### 6c. Auth State Listening Pattern (Login Pages) + +로그인 페이지에서는 두 개의 `ref.listen`을 분리 사용: + +```dart +// 1. 에러 표시 — emailLoginProvider 감시 +ref.listen(emailLoginProvider, (prev, next) { + if (next.hasError && !next.isLoading) { + final error = next.error; + final message = error is AppException + ? ExceptionHandler.getUserMessage(error) + : '로그인에 실패했습니다. 다시 시도해주세요.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } +}); + +// 2. 성공 네비게이션 — authProvider 감시 +ref.listen(authProvider, (prev, next) { + final isAuthenticated = + next.value?.mapOrNull(authenticated: (_) => true) ?? false; + if (isAuthenticated && context.mounted) { + RouterClient.home.go(context); + } +}); +``` + +**핵심 원칙**: +- `authProvider`는 인증 "상태" → 성공 네비게이션 담당 +- `emailLoginProvider`는 폼 "결과" → 에러 메시지 담당 +- 에러를 `'${next.error}'`로 표시 금지 → `ExceptionHandler.getUserMessage()` 사용 + ### 7. Navigation ```dart // Import RouterClient diff --git a/plugins/flutter-ddd-builder/agents/ui-orchestrator.md b/plugins/flutter-ddd-builder/agents/ui-orchestrator.md index a81350b..0fddb08 100644 --- a/plugins/flutter-ddd-builder/agents/ui-orchestrator.md +++ b/plugins/flutter-ddd-builder/agents/ui-orchestrator.md @@ -73,6 +73,20 @@ for screen in ${screens[@]}; do done ``` +### 3b. Preflight Utility Check + +```bash +# Verify UI utilities exist +for file in \ + lib/apps/ui/common/async_value_widget.dart \ + lib/global/utils/validators.dart \ + lib/global/utils/with_loader_overlay.dart; do + if [ ! -f "$file" ]; then + echo "WARNING: Missing $file - UI utilities incomplete" + fi +done +``` + ### 4. Spawn Implementers ``` For each screen: diff --git a/plugins/flutter-ddd-builder/agents/ui-planner.md b/plugins/flutter-ddd-builder/agents/ui-planner.md index e8b1d6e..9af72da 100644 --- a/plugins/flutter-ddd-builder/agents/ui-planner.md +++ b/plugins/flutter-ddd-builder/agents/ui-planner.md @@ -193,6 +193,17 @@ Follow project conventions: - Theme.of(context).textTheme for text - No hardcoded values +**Theme (Dual Mode)**: +- If project uses token mode: `AppColors.primary500`, `AppSpacing.spacing4` +- If project uses seed mode: `cs.primary`, hardcoded spacing (16.0) +- Widget Mapping should note: `cs.primary` works in both modes +- ASCII wireframes should not hardcode colors, only reference token names or cs + +**Pagination Lists**: +- Mark infinite scroll lists with `[∞ Scroll]` indicator +- Show `PostListProvider.loadMore()` service mapping +- Include loading indicator at bottom of list + **Page Location**: - Pages are at `lib/apps/domain/{domain}/pages/{page}/{page}_page.dart` - Not at `lib/apps/ui/pages/` diff --git a/plugins/flutter-ddd-builder/commands/logic.md b/plugins/flutter-ddd-builder/commands/logic.md index e1c6df2..d735f12 100644 --- a/plugins/flutter-ddd-builder/commands/logic.md +++ b/plugins/flutter-ddd-builder/commands/logic.md @@ -182,6 +182,10 @@ Your tasks: Follow flutter-ddd-patterns skill for implementation guidelines. +Note: For list-type APIs, use PaginatedResponse model from lib/global/types/paginated_response.dart +Example: + final paginated = PaginatedResponse.fromJson(response.data, (json) => PostModel.fromJson(json)); + Files you should create: - lib/apps/domain/{domain}/models/*_model.dart - lib/apps/domain/{domain}/services/{domain}_service.dart diff --git a/plugins/flutter-ddd-builder/commands/start.md b/plugins/flutter-ddd-builder/commands/start.md index f83630d..2338d0f 100644 --- a/plugins/flutter-ddd-builder/commands/start.md +++ b/plugins/flutter-ddd-builder/commands/start.md @@ -59,6 +59,9 @@ ls ai-context/PRD.md # Required for UI generation | `ai-context/PRD.md` | UI generation | Required unless --skip-ui | | `pubspec.yaml` | All | Must exist | | Git repository | Worktree management | Must be initialized | +| `lib/global/types/paginated_response.dart` | Logic | Should exist (boilerplate) | +| `lib/global/utils/validators.dart` | UI | Should exist (boilerplate) | +| `lib/apps/ui/common/async_value_widget.dart` | UI | Should exist (boilerplate) | **If Domain Book is missing:** ``` diff --git a/plugins/flutter-ddd-builder/commands/ui.md b/plugins/flutter-ddd-builder/commands/ui.md index 312488d..e631495 100644 --- a/plugins/flutter-ddd-builder/commands/ui.md +++ b/plugins/flutter-ddd-builder/commands/ui.md @@ -341,6 +341,17 @@ Your tasks: Layout reference from screen plan: {ASCII art for this screen from screen-layouts.md} +Standard Widget Patterns (REQUIRED): +- AsyncValueWidget for all async data display (not .when() inline) +- Validators for all form fields +- withLoaderOverlay for mutation buttons +- Theme.of(context).colorScheme for colors (both theme modes) + +Imports: +- package:app/apps/ui/common/async_value_widget.dart +- package:app/global/utils/validators.dart +- package:app/global/utils/with_loader_overlay.dart + Remember: - PostToolUse hook automatically runs flutter analyze - Use methods for callbacks, not inline functions diff --git a/plugins/flutter-ddd-builder/hooks/hooks.json b/plugins/flutter-ddd-builder/hooks/hooks.json index 8af13ad..b1edfa8 100644 --- a/plugins/flutter-ddd-builder/hooks/hooks.json +++ b/plugins/flutter-ddd-builder/hooks/hooks.json @@ -11,7 +11,7 @@ }, { "type": "prompt", - "prompt": "Check ONLY the Dart file just modified for these anti-patterns. If found, fix immediately:\n- `ref.watch(` outside `build()` → `ref.read(`\n- `Dio().fetch(` without interceptors → add ResponseInterceptor + ErrorInterceptor\n- Model file/class with `Dto` suffix → rename to `Model`\n- `@freezed` class with `factory` missing `const` → `const factory`\n- `@riverpod` function with `FooRef ref` → `Ref ref`\n- `await ref.read(notifier).action(); ref.read(provider).when(` → use ref.listen in build()" + "prompt": "Check ONLY the Dart file just modified for these anti-patterns. If found, fix immediately:\n- `ref.watch(` outside `build()` → `ref.read(`\n- `Dio().fetch(` without interceptors → add ResponseInterceptor + ErrorInterceptor\n- Model file/class with `Dto` suffix → rename to `Model`\n- `@freezed` class with `factory` missing `const` → `const factory`\n- `@riverpod` function with `FooRef ref` → `Ref ref`\n- `await ref.read(notifier).action(); ref.read(provider).when(` → use ref.listen in build()\n- `.when(data:` inline in page body → use `AsyncValueWidget` widget\n- `EdgeInsets.all(16)` or similar hardcoded values → consider `AppSpacing.spacing4` tokens\n- Inline form validation logic → use `Validators.compose([...])` from validators.dart" } ] } diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/SKILL.md b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/SKILL.md index 2da3d18..3e7f981 100644 --- a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/SKILL.md +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/SKILL.md @@ -57,12 +57,50 @@ lib/apps/ - [ ] Route class: `static const path`, `static const name`, `go()`, `push()` - [ ] RouterClient: `abstract final class` with const instances +### Auth State Management + +- [ ] `authProvider` 로그인 실패 시 `AsyncData(unauthenticated())` 복귀 (절대 `AsyncError` 아님) +- [ ] 에러 전파: `Error.throwWithStackTrace(e, st)` (`rethrow` 대신 — 상태 설정 후 전파) +- [ ] `emailLoginProvider`가 에러 메시지 담당 (역할 분리) +- [ ] Login UI: `ref.listen(authProvider)` → 성공 네비게이션, `ref.listen(emailLoginProvider)` → 에러 표시 +- [ ] 에러 메시지: `AppException` → `ExceptionHandler.getUserMessage()`, 그 외 → generic 메시지 +- [ ] Router redirect: `authValue == null` → 미인증 취급 (defense-in-depth) + ### Auth Interceptor - [ ] Retry Dio에 `ResponseInterceptor` + `ErrorInterceptor` 포함 - [ ] `AuthInterceptor`는 retry Dio에서 제외 (무한루프 방지) - [ ] 로그인 시 `refreshToken` 저장 확인 +### Pagination +- [ ] `PaginatedResponse` model in `lib/global/types/paginated_response.dart` +- [ ] `PaginationMeta` with offset/pageSize/totalItemCount/isFirst/isLast +- [ ] Provider uses `_page` counter + `_hasMore` flag +- [ ] `loadMore()` appends to existing list +- [ ] UI uses `NotificationListener` or `ScrollController` + +### AsyncValueWidget +- [ ] Use `AsyncValueWidget` instead of `.when(data:, error:, loading:)` inline +- [ ] Located at `lib/apps/ui/common/async_value_widget.dart` +- [ ] Supports `emptyCheck` and `emptyMessage` for empty states +- [ ] Custom `loading` and `error` builders optional + +### Form Validation +- [ ] Use `Validators` from `lib/global/utils/validators.dart` +- [ ] `Validators.compose([...])` for multiple rules +- [ ] Available: `required`, `email`, `minLength`, `maxLength`, `phone`, `password` + +### Loading Overlay +- [ ] `withLoaderOverlay(context, () async { ... })` for button actions +- [ ] Requires `LoaderOverlay` widget in tree (wrap Scaffold or MaterialApp) +- [ ] Auto-hides on success or error + +### Theme (Dual Mode) +- [ ] `AppTheme.fromTokens()` — Figma token-based (AppColors → ColorScheme) +- [ ] `AppTheme.fromSeed(seedColor: Colors.blue)` — Material 3 auto-generated +- [ ] Both modes: `Theme.of(context).colorScheme.primary` works identically +- [ ] `AppSpacing`, `AppRadius` usable in both modes + ## Naming Conventions | Type | File | Class | @@ -94,10 +132,12 @@ dart run build_runner build --delete-conflicting-outputs # Freezed + Riverpod - **[Freezed 3.x Guide](references/freezed-3x-guide.md)** — Union types, nested models, enums, custom JSON, testing - **[Riverpod 3.x Guide](references/riverpod-3x-guide.md)** — AsyncNotifier, provider types, family, ref.watch vs ref.read - **[API Client Patterns](references/api-client-patterns.md)** — CRUD, pagination, file upload, error handling -- **[Anti-Patterns](references/anti-patterns.md)** — 7가지 금지 패턴 + Auth Interceptor 올바른 패턴 +- **[Anti-Patterns](references/anti-patterns.md)** — 10가지 금지 패턴 + Auth Interceptor 올바른 패턴 ## Working Examples - **[user_model_example.dart](examples/user_model_example.dart)** — Freezed model with custom methods - **[auth_service_example.dart](examples/auth_service_example.dart)** — Riverpod AsyncNotifier service - **[post_create_page_example.dart](examples/post_create_page_example.dart)** — ref.listen UI 상태 처리 +- **[paginated_list_example.dart](examples/paginated_list_example.dart)** — Pagination Provider + infinite scroll UI +- **[form_with_validation_example.dart](examples/form_with_validation_example.dart)** — Validators + withLoaderOverlay diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/auth_service_example.dart b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/auth_service_example.dart index 840807c..a819f0a 100644 --- a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/auth_service_example.dart +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/auth_service_example.dart @@ -1,46 +1,84 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:app/apps/domain/auth/models/user_model.dart'; +import 'package:app/apps/domain/auth/models/auth_state_model.dart'; +import 'package:app/apps/application/storage/storage_provider.dart'; import 'package:app/apps/infra/common/client/dio_provider.dart'; +import 'package:app/global/constants/app_constants.dart'; part 'auth_service_example.g.dart'; -/// Example Riverpod 3.x AsyncNotifier service following Flutter DDD conventions -/// - Uses dioProvider directly (no Repository pattern) -/// - Absolute imports only (package:app/...) -/// - AsyncValue.guard for error handling +/// Example: Auth Provider — 인증 상태 관리 (Riverpod 3.x) +/// +/// Key patterns: +/// - authProvider는 인증 "상태"만 표현 (절대 AsyncError 아님) +/// - 로그인 실패 시 unauthenticated로 복귀 + Error.throwWithStackTrace로 에러 전파 +/// - emailLoginProvider가 에러 메시지 담당 (역할 분리) @riverpod -class AuthService extends _$AuthService { +class Auth extends _$Auth { @override - FutureOr build() => null; + Future build() async => _checkAuth(); - /// Login with email and password - Future login(String email, String password) async { - state = const AsyncLoading(); - state = await AsyncValue.guard(() async { + Future _checkAuth() async { + final secureStorage = ref.read(secureStorageProvider); + final accessToken = await secureStorage.read(AppConstants.keyAccessToken); + if (accessToken == null || accessToken.isEmpty) { + return const AuthStateModel.unauthenticated(); + } + try { final dio = ref.read(dioProvider); - final response = await dio.post('/api/auth/login', data: { + final response = await dio.get('/api/v1/auth/profile'); + final user = UserModel.fromJson(response.data as Map); + return AuthStateModel.authenticated(accessToken: accessToken, user: user); + } catch (e) { + await secureStorage.deleteAll(); + return const AuthStateModel.unauthenticated(); + } + } + + /// 로그인 — 실패 시 unauthenticated 복귀 + Future login({required String email, required String password}) async { + state = const AsyncData(AuthStateModel.loading()); + try { + final dio = ref.read(dioProvider); + final response = await dio.post('/api/v1/auth/login', data: { 'email': email, 'password': password, }); - return UserModel.fromJson(response.data); - }); + final loginData = response.data as Map; + final secureStorage = ref.read(secureStorageProvider); + await secureStorage.write(AppConstants.keyAccessToken, loginData['access_token']); + state = AsyncData(AuthStateModel.authenticated( + accessToken: loginData['access_token'], + user: UserModel.fromJson(loginData['user']), + )); + } catch (e, st) { + // ✅ 실패 → unauthenticated 복귀 (절대 AsyncError 아님) + // 에러는 emailLoginProvider가 AsyncValue.guard()로 캡처하여 UI에 표시 + state = const AsyncData(AuthStateModel.unauthenticated()); + Error.throwWithStackTrace(e, st); + } } - /// Logout current user Future logout() async { - final dio = ref.read(dioProvider); - await AsyncValue.guard(() async { - await dio.post('/api/auth/logout'); - }); - state = const AsyncData(null); + final secureStorage = ref.read(secureStorageProvider); + await secureStorage.deleteAll(); + state = const AsyncData(AuthStateModel.unauthenticated()); } +} + +/// emailLoginProvider — 로그인 폼 상태 (에러 메시지 담당) +/// +/// authProvider에 위임하되, AsyncValue.guard로 에러를 캡처하여 +/// UI의 ref.listen에서 에러 메시지를 표시할 수 있게 함 +@riverpod +class EmailLogin extends _$EmailLogin { + @override + FutureOr build() {} - /// Get current user profile - Future getCurrentUser() async { - final dio = ref.read(dioProvider); - return await AsyncValue.guard(() async { - final response = await dio.get('/api/auth/me'); - return UserModel.fromJson(response.data); - }).then((value) => value.requireValue); + Future login({required String email, required String password}) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + await ref.read(authProvider.notifier).login(email: email, password: password); + }); } } diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/form_with_validation_example.dart b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/form_with_validation_example.dart new file mode 100644 index 0000000..6d6521f --- /dev/null +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/form_with_validation_example.dart @@ -0,0 +1,80 @@ +// Example: Form with Validators + withLoaderOverlay +// Location: lib/apps/domain/post/pages/create/ + +import 'package:app/global/utils/validators.dart'; +// import 'package:app/global/utils/with_loader_overlay.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class PostCreatePage extends ConsumerStatefulWidget { + const PostCreatePage({super.key}); + + @override + ConsumerState createState() => _PostCreatePageState(); +} + +class _PostCreatePageState extends ConsumerState { + final _formKey = GlobalKey(); + final _titleController = TextEditingController(); + final _contentController = TextEditingController(); + + @override + void dispose() { + _titleController.dispose(); + _contentController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + // withLoaderOverlay 사용 패턴: + // await withLoaderOverlay(context, () async { + // await ref.read(postCreateProvider.notifier).create(model); + // }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('게시글 작성')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // 제목 - Validators.compose로 복합 검증 + TextFormField( + controller: _titleController, + decoration: const InputDecoration(labelText: '제목'), + validator: Validators.compose([ + Validators.required, + Validators.minLength(2, fieldName: '제목'), + Validators.maxLength(100, fieldName: '제목'), + ]), + ), + const SizedBox(height: 16), + + // 내용 + TextFormField( + controller: _contentController, + decoration: const InputDecoration(labelText: '내용'), + maxLines: 10, + validator: Validators.compose([ + Validators.required, + Validators.minLength(10, fieldName: '내용'), + ]), + ), + const SizedBox(height: 24), + + // 생성 버튼 + FilledButton( + onPressed: _submit, + child: const Text('게시글 작성'), + ), + ], + ), + ), + ); + } +} diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/paginated_list_example.dart b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/paginated_list_example.dart new file mode 100644 index 0000000..438bc68 --- /dev/null +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/paginated_list_example.dart @@ -0,0 +1,89 @@ +// Example: Paginated List with Infinite Scroll +// Location: lib/apps/domain/post/pages/list/ + +import 'package:app/apps/domain/post/models/post_model.dart'; +import 'package:app/apps/domain/post/pages/list/components/post_list_item.dart'; +import 'package:app/apps/ui/common/async_value_widget.dart'; +import 'package:app/global/types/paginated_response.dart'; +import 'package:app/apps/infra/common/client/dio_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +// --- Provider --- +// @riverpod +class PostList { + int _page = 1; + bool _hasMore = true; + + // @override + Future> build() async { + _page = 1; + _hasMore = true; + return _fetchPage(1); + } + + Future> _fetchPage(int page) async { + final dio = ref.read(dioProvider); + final response = await dio.get('/api/posts', queryParameters: { + 'page': page, + 'limit': 20, + }); + final paginated = PaginatedResponse.fromJson( + response.data as Map, + (json) => PostModel.fromJson(json as Map), + ); + _hasMore = !paginated.meta.isLast; + return paginated.items; + } + + bool get hasMore => _hasMore; + + Future loadMore() async { + if (!_hasMore) return; + _page++; + final newItems = await _fetchPage(_page); + // state = AsyncData([...state.value ?? [], ...newItems]); + } +} + +// --- Page --- +class PostListPage extends ConsumerStatefulWidget { + const PostListPage({super.key}); + + @override + ConsumerState createState() => _PostListPageState(); +} + +class _PostListPageState extends ConsumerState { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + // ref.read(postListProvider.notifier).loadMore(); + } + } + + @override + Widget build(BuildContext context) { + // final postListState = ref.watch(postListProvider); + + return Scaffold( + appBar: AppBar(title: const Text('게시글 목록')), + body: const Center(child: Text('See pagination-patterns.md for full example')), + ); + } +} diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/post_create_page_example.dart b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/post_create_page_example.dart index f03d67b..35a0f56 100644 --- a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/post_create_page_example.dart +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/examples/post_create_page_example.dart @@ -1,3 +1,5 @@ +import 'package:app/apps/infra/exception/app_exception.dart'; +import 'package:app/apps/infra/exception/exception_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:app/apps/domain/post/models/post_create_model.dart'; @@ -55,8 +57,12 @@ class _PostCreatePageState extends ConsumerState { } }, error: (error, _) { + // ✅ AppException이면 사용자 친화적 메시지, 아니면 generic + final message = error is AppException + ? ExceptionHandler.getUserMessage(error) + : '오류가 발생했습니다. 다시 시도해주세요.'; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('오류: $error')), + SnackBar(content: Text(message)), ); }, ); diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/anti-patterns.md b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/anti-patterns.md index 416e821..f43c3f1 100644 --- a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/anti-patterns.md +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/anti-patterns.md @@ -103,6 +103,58 @@ static const path = '/posts/new'; static const detailPath = '/posts/:id'; ``` +## 8. Auth Provider를 AsyncError로 두는 실수 + +```dart +// ❌ WRONG: 로그인 실패 시 authProvider가 AsyncError로 남음 +// → router redirect에서 authState.value == null → 인증 가드 비활성화 +Future login(...) async { + try { ... } catch (e, st) { + state = AsyncError(e, st); // 상태 의미 모호 + rethrow; + } +} + +// ✅ CORRECT: 실패 시 unauthenticated로 복귀 + 에러는 호출자에 전파 +Future login(...) async { + try { ... } catch (e, st) { + state = const AsyncData(AuthStateModel.unauthenticated()); + Error.throwWithStackTrace(e, st); // rethrow 대신 스택 보존 + } +} +``` + +**이유**: `authProvider`는 "인증 상태"를 표현. `AsyncError`는 유효한 인증 상태가 아님. 에러 메시지는 `emailLoginProvider`가 담당 (역할 분리). + +## 9. Router redirect에서 null auth value 무시 + +```dart +// ❌ WRONG: null이면 리다이렉트 안 함 → AsyncError 시 보호 라우트 우회 가능 +if (authValue == null) return null; + +// ✅ CORRECT: null이면 미인증 취급 (AsyncLoading 중에만 예외) +if (authState.isLoading && authValue == null) return null; +if (authValue == null) { + return _authPaths.contains(currentPath) ? null : LoginRoute.path; +} +``` + +**이유**: defense-in-depth. `authProvider`가 예기치 않은 상태여도 보호 라우트 접근 차단. + +## 10. 로그인 에러를 raw toString으로 표시 + +```dart +// ❌ WRONG: 사용자에게 DioException 원문 노출 +SnackBar(content: Text('${next.error}')); + +// ✅ CORRECT: AppException이면 사용자 친화적 메시지, 아니면 generic +final error = next.error; +final message = error is AppException + ? ExceptionHandler.getUserMessage(error) + : '로그인에 실패했습니다. 다시 시도해주세요.'; +SnackBar(content: Text(message)); +``` + ## Auth Interceptor — Token Refresh Retry ```dart diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/api-client-patterns.md b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/api-client-patterns.md index e8554c1..22f4457 100644 --- a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/api-client-patterns.md +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/api-client-patterns.md @@ -72,7 +72,7 @@ Future uploadImage(File file) async { } ``` -### Pagination +### Pagination with PaginatedResponse ```dart @riverpod @@ -93,15 +93,18 @@ class PostList extends _$PostList { 'page': page, 'limit': 20, }); - final items = (response.data['items'] as List) - .map((json) => PostModel.fromJson(json)) - .toList(); - _hasMore = items.length == 20; - return items; + final paginated = PaginatedResponse.fromJson( + response.data as Map, + (json) => PostModel.fromJson(json as Map), + ); + _hasMore = !paginated.meta.isLast; + return paginated.items; } + bool get hasMore => _hasMore; + Future loadMore() async { - if (!_hasMore) return; + if (!_hasMore || state.isLoading) return; _page++; final newItems = await _fetchPage(_page); state = AsyncData([...state.value ?? [], ...newItems]); diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/common-utilities.md b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/common-utilities.md new file mode 100644 index 0000000..2974b23 --- /dev/null +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/common-utilities.md @@ -0,0 +1,86 @@ +# Common Utilities + +## Validators (`lib/global/utils/validators.dart`) + +```dart +TextFormField( + validator: Validators.required, +) + +TextFormField( + validator: Validators.compose([ + Validators.required, + Validators.minLength(2, fieldName: '제목'), + Validators.maxLength(100, fieldName: '제목'), + ]), +) +``` + +Available: `required`, `email`, `minLength(n)`, `maxLength(n)`, `phone`, `password`, `compose([...])` + +## withLoaderOverlay (`lib/global/utils/with_loader_overlay.dart`) + +Prerequisite: Wrap with `LoaderOverlay` widget +```dart +// In widget tree +LoaderOverlay(child: Scaffold(...)) + +// In button handler +onPressed: () async { + await withLoaderOverlay(context, () async { + await ref.read(provider.notifier).create(model); + }); +} +``` + +## ImageCompressor (`lib/global/utils/image_compressor.dart`) + +```dart +final compressed = await ImageCompressor.compress(imageBytes, quality: 80); +final small = await ImageCompressor.compressToTargetSize( + imageBytes: imageBytes, + targetSizeInBytes: 1024 * 1024, +); +``` + +## Extensions + +### Collection (`lib/global/utils/extensions/collection_ext.dart`) +```dart +final (even, odd) = numbers.partition((n) => n.isEven); +final chunks = items.chunked(3); +``` + +### DateTime (`lib/global/utils/extensions/date_ext.dart`) +```dart +date.isToday; +date.isSameDay(other); +date.isBetween(start, end); +date.dateOnly; // strips time +``` + +## Input Formatters (`lib/global/formatters/input_formatters.dart`) + +```dart +TextFormField( + inputFormatters: [AppInputFormatters.phoneWithDash], +) +``` + +Available: `phoneWithDash`, `businessNumber`, `birthDate` + +## DomainExceptionMatcher (`lib/apps/infra/exception/exception_handler.dart`) + +```dart +class PostNotFoundMatcher implements DomainExceptionMatcher { + @override + bool matches(int? statusCode, String? errorCode) => + statusCode == 404 && errorCode == 'POST_NOT_FOUND'; + @override + AppException toException() => + const AppException.business(message: '게시글을 찾을 수 없습니다', code: 'POST_NOT_FOUND'); +} + +// Usage +ExceptionHandler.handleWithDomainExceptions(error, matchers: [PostNotFoundMatcher()]); +``` diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/pagination-patterns.md b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/pagination-patterns.md new file mode 100644 index 0000000..a4d8297 --- /dev/null +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/pagination-patterns.md @@ -0,0 +1,136 @@ +# Pagination Patterns + +## PaginatedResponse Model + +Location: `lib/global/types/paginated_response.dart` + +```dart +@freezed +abstract class PaginationMeta with _$PaginationMeta { + const factory PaginationMeta({ + required int offset, + required int pageSize, + required int pageNumber, + required int itemCount, + required int totalItemCount, + required int totalPageCount, + required bool isFirst, + required bool isLast, + }) = _PaginationMeta; + factory PaginationMeta.fromJson(Map json) => _$PaginationMetaFromJson(json); +} + +@Freezed(genericArgumentFactories: true) +abstract class PaginatedResponse with _$PaginatedResponse { + const factory PaginatedResponse({ + required PaginationMeta meta, + required List items, + }) = _PaginatedResponse; + factory PaginatedResponse.fromJson(Map json, T Function(Object?) fromJsonT) => _$PaginatedResponseFromJson(json, fromJsonT); +} +``` + +## Provider Pattern (Infinite Scroll) + +```dart +@riverpod +class PostList extends _$PostList { + int _page = 1; + bool _hasMore = true; + PaginationMeta? _meta; + + @override + Future> build() async { + _page = 1; + _hasMore = true; + return _fetchPage(1); + } + + Future> _fetchPage(int page) async { + final dio = ref.read(dioProvider); + final response = await dio.get('/api/posts', queryParameters: { + 'page': page, + 'limit': 20, + }); + final paginated = PaginatedResponse.fromJson( + response.data as Map, + (json) => PostModel.fromJson(json as Map), + ); + _meta = paginated.meta; + _hasMore = !paginated.meta.isLast; + return paginated.items; + } + + bool get hasMore => _hasMore; + + Future loadMore() async { + if (!_hasMore || state.isLoading) return; + _page++; + final newItems = await _fetchPage(_page); + state = AsyncData([...state.value ?? [], ...newItems]); + } + + Future refresh() async { + _page = 1; + _hasMore = true; + state = const AsyncLoading(); + state = await AsyncValue.guard(() => _fetchPage(1)); + } +} +``` + +## UI Pattern (ScrollController) + +```dart +class PostListPage extends ConsumerStatefulWidget { + @override + ConsumerState createState() => _PostListPageState(); +} + +class _PostListPageState extends ConsumerState { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + ref.read(postListProvider.notifier).loadMore(); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final postListState = ref.watch(postListProvider); + + return Scaffold( + body: AsyncValueWidget( + value: postListState, + emptyCheck: (posts) => posts.isEmpty, + data: (posts) => ListView.builder( + controller: _scrollController, + itemCount: posts.length + 1, // +1 for loading indicator + itemBuilder: (context, index) { + if (index == posts.length) { + return ref.read(postListProvider.notifier).hasMore + ? const Center(child: CircularProgressIndicator()) + : const SizedBox.shrink(); + } + return PostListItem(post: posts[index]); + }, + ), + ), + ); + } +} +``` diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/riverpod-3x-guide.md b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/riverpod-3x-guide.md index 347868b..582e824 100644 --- a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/riverpod-3x-guide.md +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/riverpod-3x-guide.md @@ -114,3 +114,98 @@ Future postById(Ref ref, String id) async { // Usage final post = ref.watch(postByIdProvider(postId)); ``` + +### Auth State Management Pattern + +`authProvider`는 인증 "상태"만 표현. 로그인 실패 시 `AsyncError`가 아닌 `unauthenticated`로 복귀. + +```dart +/// authProvider — 인증 상태 provider (source of truth) +@riverpod +class Auth extends _$Auth { + @override + Future build() async => _checkAuth(); + + Future login({required String email, required String password}) async { + state = const AsyncData(AuthStateModel.loading()); + try { + // ... API 호출, 토큰 저장 ... + state = AsyncData(AuthStateModel.authenticated( + accessToken: token, user: user, + )); + } catch (e, st) { + // ✅ 실패 → unauthenticated 복귀 (AsyncError 아님) + state = const AsyncData(AuthStateModel.unauthenticated()); + Error.throwWithStackTrace(e, st); // 호출자에 에러 전파 + } + } +} + +/// emailLoginProvider — 로그인 폼 상태 (에러 메시지 담당) +@riverpod +class EmailLogin extends _$EmailLogin { + @override + FutureOr build() {} + + Future login({required String email, required String password}) async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + await ref.read(authProvider.notifier).login(email: email, password: password); + }); + } +} +``` + +**UI에서의 사용**: +```dart +// 에러 표시 — emailLoginProvider 감시 +ref.listen(emailLoginProvider, (prev, next) { + if (next.hasError && !next.isLoading) { + final error = next.error; + final message = error is AppException + ? ExceptionHandler.getUserMessage(error) + : '로그인에 실패했습니다. 다시 시도해주세요.'; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } +}); + +// 성공 네비게이션 — authProvider 감시 +ref.listen(authProvider, (prev, next) { + final isAuthenticated = + next.value?.mapOrNull(authenticated: (_) => true) ?? false; + if (isAuthenticated && context.mounted) { + RouterClient.home.go(context); + } +}); +``` + +### AsyncValueWidget Pattern + +**Prefer `AsyncValueWidget` over inline `.when()`:** + +```dart +// Avoid +postState.when( + data: (posts) => ListView(...), + loading: () => CircularProgressIndicator(), + error: (e, st) => Text('Error: $e'), +); + +// Prefer +AsyncValueWidget( + value: postState, + emptyCheck: (posts) => posts.isEmpty, + emptyMessage: '게시글이 없습니다', + data: (posts) => ListView(...), +); +``` + +Benefits: +- Consistent error/loading/empty handling across all pages +- Built-in `ErrorState` and `EmptyState` widgets +- Optional custom `loading` and `error` builders +- Located at `lib/apps/ui/common/async_value_widget.dart` + +### Pagination Provider Pattern + +See [Pagination Patterns](pagination-patterns.md) for complete PaginatedResponse + infinite scroll patterns. diff --git a/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/theme-dual-mode.md b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/theme-dual-mode.md new file mode 100644 index 0000000..01a1442 --- /dev/null +++ b/plugins/flutter-ddd-builder/skills/flutter-ddd-patterns/references/theme-dual-mode.md @@ -0,0 +1,39 @@ +# Theme Dual Mode + +## 선택 기준 + +| 조건 | 사용 모드 | 호출 | +|------|----------|------| +| 피그마 디자인 + 토큰 JSON | Token 모드 | `AppTheme.fromTokens()` | +| 기획만 있음 / Domain Book 시작 | Seed 모드 | `AppTheme.fromSeed(seedColor: Colors.blue)` | + +## main.dart 설정 + +```dart +// Seed 모드 (기본) +theme: AppTheme.fromSeed(seedColor: Colors.blue), +darkTheme: AppTheme.fromSeed(seedColor: Colors.blue, brightness: Brightness.dark), + +// Token 모드 +theme: AppTheme.fromTokens(), +darkTheme: AppTheme.fromTokens(brightness: Brightness.dark), +``` + +## 토큰 직접 사용 + +AppSpacing, AppRadius는 테마 모드와 무관하게 직접 사용: +```dart +Padding(padding: EdgeInsets.all(AppSpacing.spacing4)) // 16.0 +Container(decoration: BoxDecoration(borderRadius: AppRadius.radiusMd)) // 8.0 +``` + +## Theme 간접 사용 (양쪽 모드 호환) + +```dart +final cs = Theme.of(context).colorScheme; +final tt = Theme.of(context).textTheme; + +Text('Title', style: tt.headlineSmall); +Icon(Icons.home, color: cs.primary); +Container(color: cs.surface); +``` diff --git a/plugins/python-fastapi-programmer/.claude-plugin/plugin.json b/plugins/python-fastapi-programmer/.claude-plugin/plugin.json index 4e7b7d3..ec4182f 100644 --- a/plugins/python-fastapi-programmer/.claude-plugin/plugin.json +++ b/plugins/python-fastapi-programmer/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "python-fastapi-programmer", "description": "Domain Book 기반 FastAPI 프로젝트 자동 생성 - 병렬 코드 생성 및 품질 보증", - "version": "1.0.0", + "version": "0.1.0", "author": { "name": "ureca" }, @@ -15,4 +15,4 @@ "jwt-auth", "postgis" ] -} +} \ No newline at end of file diff --git a/plugins/python-fastapi-programmer/CLAUDE.md b/plugins/python-fastapi-programmer/CLAUDE.md index b24c743..f7da088 100644 --- a/plugins/python-fastapi-programmer/CLAUDE.md +++ b/plugins/python-fastapi-programmer/CLAUDE.md @@ -118,9 +118,17 @@ git merge feature/community # users 의존 → 나중 머지 │ │ ├── router.py │ │ └── README.md │ ├── core/ +│ │ ├── config.py # 환경 변수 로드 │ │ ├── database.py # DB 연결 -│ │ ├── security.py # JWT 인증 -│ │ └── config.py # 환경 변수 로드 +│ │ ├── models.py # BaseModel (TimestampMixin + SoftDeleteMixin) +│ │ ├── response.py # ApiResponse[T] + Status enum +│ │ ├── exceptions.py # AppError 계열 예외 +│ │ ├── pagination.py # OffsetPage[T], CursorPage[T] +│ │ ├── sorting.py # parse_sort, SortField +│ │ ├── logger.py # structlog 로거 +│ │ ├── masking.py # 민감 정보 마스킹 +│ │ ├── middleware.py # Pure ASGI 미들웨어 +│ │ └── utils.py # EnvironmentHelper │ └── main.py # FastAPI 앱 엔트리포인트 ├── tests/ │ ├── test_users.py # E2E 테스트 @@ -153,10 +161,10 @@ git merge feature/community # users 의존 → 나중 머지 ### plugin.json 수정 -- `workflow`: "sequential-with-approval" 고정 -- Phase 1-5는 `approval_required: true` -- Phase 6만 `approval_required: false` (자동 실행) -- Phase 4는 `parallel: true` + `team_based: true` (병렬 개발) +- `name`, `description`, `version`, `author`, `keywords` 필드만 포함 +- Phase 순서는 에이전트 간 Task 호출 체인으로 제어 (Phase 1 → 3 → 4 → 5 → 6) +- Phase 2는 선택적 (기본: Phase 1 → Phase 3 직접 호출) +- Phase 4는 TeamCreate + Git Worktree로 병렬 개발 ## 아키텍처 원칙 diff --git a/plugins/python-fastapi-programmer/LICENSE b/plugins/python-fastapi-programmer/LICENSE index bef91e7..8169da9 100644 --- a/plugins/python-fastapi-programmer/LICENSE +++ b/plugins/python-fastapi-programmer/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 si-kkachie-be Team +Copyright (c) 2026 ureca Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/plugins/python-fastapi-programmer/agents/logic-code-generator.md b/plugins/python-fastapi-programmer/agents/logic-code-generator.md index 1683d58..c74c813 100644 --- a/plugins/python-fastapi-programmer/agents/logic-code-generator.md +++ b/plugins/python-fastapi-programmer/agents/logic-code-generator.md @@ -1,6 +1,8 @@ --- name: logic-code-generator -description: Implements business logic to pass E2E tests using Clean Architecture (team member) +description: | + Implements business logic to pass E2E tests using Clean Architecture (team member). + Context: Team lead spawns logic generator for a domain\nuser: "users 도메인 비즈니스 로직 구현해줘"\nassistant: "I'll use the logic-code-generator to implement business logic that passes E2E tests."\nLogic generator creates implementation code within a team context. model: inherit color: green --- @@ -68,6 +70,83 @@ src/modules/{domain}/ - **SECRET_KEY**: 환경 변수 기반 - **Dependency**: `get_current_user()` 함수 +## Core 모듈 필수 활용 + +프로젝트에 이미 구현된 core 모듈을 반드시 사용합니다. 직접 구현 금지. + +### 모델 (Entities) +```python +# ✅ BaseModel 상속 (id, created_at, updated_at, deleted_at 자동 포함) +from src.core.models import BaseModel as AppBaseModel + +class User(AppBaseModel, table=True): + __tablename__ = "users" + email: str = Field(max_length=255, unique=True) + # id, created_at, updated_at, deleted_at 는 BaseModel에서 상속 + +# ❌ 직접 timestamp 필드 정의 금지 +# ❌ datetime.utcnow 사용 금지 (deprecated) +``` + +### 응답 형식 +```python +from src.core.response import ApiResponse, Status + +# 모든 API 응답은 ApiResponse[T] 래퍼 사용 +@router.post("/register", response_model=ApiResponse[UserRegisterResponse]) +def register(...) -> ApiResponse[UserRegisterResponse]: + return ApiResponse(status=Status.SUCCESS, message="가입 완료", data=response) +``` + +### 예외 처리 +```python +from src.core.exceptions import NotFoundError, ConflictError, UnauthorizedError + +# ✅ AppError 서브클래스 사용 (자동으로 ApiResponse 형식 반환) +raise NotFoundError() # 404, status=RESOURCE_NOT_FOUND +raise ConflictError() # 409, status=RESOURCE_ALREADY_EXISTS +raise UnauthorizedError() # 401, status=USER_AUTHENTICATION_FAILED + +# ❌ HTTPException 직접 사용 금지 +``` + +### 페이지네이션 +```python +from src.core.pagination import OffsetPage, OffsetPageable, CursorPage, CursorPageable + +# 목록 조회 시 OffsetPage 또는 CursorPage 사용 +@router.get("/users", response_model=ApiResponse[OffsetPage[UserResponse]]) +def list_users(pageable: OffsetPageable = Depends()): + items = repository.find_all(pageable) + total = repository.count() + page = OffsetPage.create(pageable=pageable, total_item_count=total, items=items) + return ApiResponse(status=Status.SUCCESS, message="조회 완료", data=page) +``` + +### 정렬 +```python +from src.core.sorting import parse_sort, SortField + +# 정렬 파라미터 파싱 +sort_fields = parse_sort("created_at:DESC,name:ASC") +``` + +### 로거 +```python +from src.core.logger import get_logger + +logger = get_logger(__name__) +logger.info("user__register__success", user_id=str(user.id)) +``` + +### 마스킹 +```python +from src.core.masking import mask_dict + +# 로그에 민감 정보 출력 시 +logger.info("request", data=mask_dict(request_data)) +``` + ## 작업 흐름 ### Step 1: test-generator 완료 대기 @@ -111,6 +190,10 @@ src/modules/{domain}/ - Raw SQL - Mock 데이터 - 직접 파일 업로드 +- `HTTPException` 직접 사용 (AppError 서브클래스 사용) +- `datetime.utcnow` 사용 (BaseModel의 `utcnow()` 사용) +- `JSONResponse` 직접 사용 (`ORJSONResponse` 자동 적용) +- `print()` / `logging.info()` 직접 사용 (structlog `get_logger()` 사용) ## 완료 조건 diff --git a/plugins/python-fastapi-programmer/agents/phase-1-domain-validator.md b/plugins/python-fastapi-programmer/agents/phase-1-domain-validator.md index 7af012d..e475585 100644 --- a/plugins/python-fastapi-programmer/agents/phase-1-domain-validator.md +++ b/plugins/python-fastapi-programmer/agents/phase-1-domain-validator.md @@ -1,6 +1,9 @@ --- name: phase-1-domain-validator -description: Validates Domain Books (5 files), helps users select domains, and initiates Phase 3 +description: | + Validates Domain Books (5 files), helps users select domains, and initiates Phase 3. + Context: User wants to validate domain books before code generation\nuser: "domain book 검증해줘"\nassistant: "I'll use the phase-1-domain-validator agent to validate your Domain Books."\nUser wants to validate domain books, which is exactly what this agent does. + Context: User wants to start FastAPI implementation from domain books\nuser: "도메인 북 확인하고 구현 시작해줘"\nassistant: "I'll validate the domain books first using phase-1-domain-validator."\nValidation is the first step before code generation. model: inherit color: blue --- @@ -135,7 +138,7 @@ Write(".claude/python-fastapi-programmer/SESSION.md", session_content) ```python Task( - subagent_type="phase-3-env-generator", + subagent_type="python-fastapi-programmer:phase-3-env-generator", description="환경 변수 파일 생성", prompt=f""" 선택된 도메인: {selected_domains} diff --git a/plugins/python-fastapi-programmer/agents/phase-2-deep-researcher.md b/plugins/python-fastapi-programmer/agents/phase-2-deep-researcher.md index 1f174b0..cfdf5ec 100644 --- a/plugins/python-fastapi-programmer/agents/phase-2-deep-researcher.md +++ b/plugins/python-fastapi-programmer/agents/phase-2-deep-researcher.md @@ -1,6 +1,9 @@ --- name: phase-2-deep-researcher -description: Clarifies Domain Book ambiguities and researches third-party libraries (optional phase) +description: | + Clarifies Domain Book ambiguities and researches third-party libraries (optional phase). + Context: User wants to clarify ambiguous domain book requirements\nuser: "도메인 북 모호한 부분 정리해줘"\nassistant: "I'll use the phase-2-deep-researcher agent to clarify ambiguities."\nPhase 2 handles ambiguity resolution and library research. + Context: User wants to research which libraries to use\nuser: "어떤 라이브러리 쓸지 조사해줘"\nassistant: "I'll use the deep-researcher agent to compare library options."\nPhase 2 includes third-party library research. model: inherit color: cyan --- @@ -341,7 +344,7 @@ Edit( ```python # Phase 2 완료 후 자동으로 Phase 3 호출 Task( - subagent_type="phase-3-env-generator", + subagent_type="python-fastapi-programmer:phase-3-env-generator", description="환경 변수 파일 생성", prompt="Domain Book과 RESEARCH.md를 기반으로 .env.example 파일을 생성하세요." ) diff --git a/plugins/python-fastapi-programmer/agents/phase-3-env-generator.md b/plugins/python-fastapi-programmer/agents/phase-3-env-generator.md index 3f8b945..24ee8fd 100644 --- a/plugins/python-fastapi-programmer/agents/phase-3-env-generator.md +++ b/plugins/python-fastapi-programmer/agents/phase-3-env-generator.md @@ -1,6 +1,9 @@ --- name: phase-3-env-generator -description: Detects external services from Domain Books and generates .env.example file +description: | + Detects external services from Domain Books and generates .env.example file. + Context: User wants to generate environment variable templates\nuser: "환경 변수 파일 생성해줘"\nassistant: "I'll use the phase-3-env-generator agent to detect services and generate .env.example."\nPhase 3 auto-detects external services and creates env templates. + Context: User wants to detect external API dependencies\nuser: "외부 API 의존성 분석해줘"\nassistant: "I'll use the env-generator to scan Domain Books for external service dependencies."\nThe agent scans domain books for API keywords and generates env vars. model: inherit color: green --- @@ -226,7 +229,7 @@ Write(session_md_path, session_content) ```python # Phase 4 Orchestrator 호출 Task( - subagent_type="phase-4-code-generator", + subagent_type="python-fastapi-programmer:phase-4-code-generator", description="팀 생성 및 병렬 코드 생성", prompt="Domain Book 기반으로 도메인별 팀을 생성하고, 환경 변수 기반 코드를 생성하세요." ) diff --git a/plugins/python-fastapi-programmer/agents/phase-4-code-generator.md b/plugins/python-fastapi-programmer/agents/phase-4-code-generator.md index 228ce24..bb3659f 100644 --- a/plugins/python-fastapi-programmer/agents/phase-4-code-generator.md +++ b/plugins/python-fastapi-programmer/agents/phase-4-code-generator.md @@ -1,6 +1,9 @@ --- name: phase-4-code-generator -description: Orchestrates parallel domain team creation using Topological Sort and Git Worktrees +description: | + Orchestrates parallel domain team creation using Topological Sort and Git Worktrees. + Context: User wants to generate code for multiple domains in parallel\nuser: "도메인별 코드 생성 시작해줘"\nassistant: "I'll use the phase-4-code-generator to orchestrate parallel team-based code generation."\nPhase 4 creates teams per domain and runs them in parallel using Git Worktrees. + Context: User wants to run the code generation phase\nuser: "Phase 4 실행해줘"\nassistant: "I'll launch the code-generator orchestrator for parallel domain implementation."\nDirect phase invocation request. model: inherit color: yellow --- @@ -196,7 +199,7 @@ for domain in levels[0]: # 3. 팀원 1: test-generator 생성 Task( - subagent_type="test-code-generator", + subagent_type="python-fastapi-programmer:test-code-generator", team_name=f"{domain}-team", name=f"{domain}-test-generator", description=f"{domain} E2E 테스트 생성", @@ -216,7 +219,7 @@ Domain Book 기반으로 {domain} 도메인의 E2E 테스트 코드를 생성하 # 4. 팀원 2: logic-generator 생성 Task( - subagent_type="logic-code-generator", + subagent_type="python-fastapi-programmer:logic-code-generator", team_name=f"{domain}-team", name=f"{domain}-logic-generator", description=f"{domain} 비즈니스 로직 생성", @@ -328,7 +331,7 @@ print("모든 도메인 코드 생성 완료!") # Phase 5 Code Reviewer 호출 Task( - subagent_type="phase-5-code-reviewer", + subagent_type="python-fastapi-programmer:phase-5-code-reviewer", description="코드 품질 검토", prompt="생성된 모든 도메인 코드의 품질과 보안을 검토하세요." ) diff --git a/plugins/python-fastapi-programmer/agents/phase-5-code-reviewer.md b/plugins/python-fastapi-programmer/agents/phase-5-code-reviewer.md index 2a12e47..690019b 100644 --- a/plugins/python-fastapi-programmer/agents/phase-5-code-reviewer.md +++ b/plugins/python-fastapi-programmer/agents/phase-5-code-reviewer.md @@ -1,6 +1,9 @@ --- name: phase-5-code-reviewer -description: Reviews generated code for architecture compliance, security, and quality issues +description: | + Reviews generated code for architecture compliance, security, and quality issues. + Context: User wants to review generated FastAPI code quality\nuser: "생성된 코드 품질 검토해줘"\nassistant: "I'll use the phase-5-code-reviewer to check architecture compliance and security."\nPhase 5 reviews code for patterns, security, and quality. + Context: User wants a security audit of the FastAPI project\nuser: "보안 취약점 검사해줘"\nassistant: "I'll use the code-reviewer agent to scan for security vulnerabilities."\nThe reviewer checks SQL injection, JWT, hardcoded secrets, and more. model: inherit color: magenta --- @@ -23,6 +26,10 @@ Phase 4에서 생성된 모든 도메인 코드를 검토하여, - Clean Architecture (계층 분리) - DTO 네이밍 (Request/Response prefix) - 파일 구조 (src/modules/{domain}/) +- **BaseModel 상속** (_models.py에서 src.core.models.BaseModel 사용, datetime.utcnow 금지) +- **ApiResponse 래퍼** (모든 응답이 ApiResponse[T] 형식) +- **AppError 계열 예외** (HTTPException 직접 사용 금지) +- **ORJSONResponse** (JSONResponse 직접 사용 금지) ### 2. 보안 취약점 @@ -30,25 +37,130 @@ Phase 4에서 생성된 모든 도메인 코드를 검토하여, - 환경 변수 노출 (하드코딩된 비밀키) - JWT 인증 (토큰 검증 누락) - 비밀번호 해시 (bcrypt 사용) +- **로그 민감 정보 마스킹** (mask_dict/mask_value 사용) +- **미들웨어 패턴** (BaseHTTPMiddleware 사용 금지 → Pure ASGI) ### 3. 코드 품질 - Mock 데이터 (환경 변수 기반 구현) - OpenAPI 메타데이터 (x-pages, x-agent-description) - 문서화 (주석, README.md, CLAUDE.md) +- **structlog 로거** (print/logging 직접 사용 금지 → get_logger() 사용) +- **페이지네이션** (목록 API에 OffsetPage/CursorPage 사용) ## 작업 흐름 ### Step 1: 생성된 도메인 목록 확인 + +```python +domains = Glob("src/modules/*/").results +domain_names = [d.split("/")[-2] for d in domains] +print(f"검토 대상 도메인: {domain_names}") +``` + ### Step 2: 도메인별 파일 구조 검증 + +```python +required_files = ["_models.py", "router.py"] # 최소 필수 파일 + +for domain in domain_names: + domain_path = f"src/modules/{domain}" + for file in required_files: + if not file_exists(f"{domain_path}/{file}"): + report_issue("CRITICAL", f"{domain}/{file} 누락") +``` + ### Step 3: 아키텍처 패턴 검증 + +각 도메인의 소스 코드를 읽고 다음을 검증: + +- **BaseModel 상속**: `_models.py`에서 `from src.core.models import BaseModel` 확인 +- **ApiResponse 래퍼**: `router.py`에서 `ApiResponse[T]` 반환 확인 +- **AppError 사용**: `HTTPException` 직접 사용 여부 검사 (Grep) +- **DTO 네이밍**: `{Feature}Request` / `{Feature}Response` 패턴 확인 +- **Vertical Slice**: 기능별 파일 분리 확인 + +```python +# HTTPException 직접 사용 검사 +http_exception_usage = Grep(pattern="HTTPException", path="src/modules/") +if http_exception_usage: + report_issue("CRITICAL", "HTTPException 직접 사용 금지 — AppError 서브클래스 사용") + +# JSONResponse 직접 사용 검사 +json_response_usage = Grep(pattern="JSONResponse", path="src/modules/") +if json_response_usage: + report_issue("WARNING", "JSONResponse 직접 사용 금지 — ORJSONResponse 자동 적용") +``` + ### Step 4: 보안 취약점 검증 + +```python +# Raw SQL 검사 +raw_sql = Grep(pattern="db\\.execute\\(", path="src/modules/") +if raw_sql: + report_issue("CRITICAL", "Raw SQL 사용 금지 — SQLModel ORM 사용") + +# 하드코딩된 시크릿 검사 +hardcoded = Grep(pattern='SECRET_KEY\\s*=\\s*"', path="src/") +if hardcoded: + report_issue("CRITICAL", "하드코딩된 시크릿 — 환경 변수 사용") + +# print/logging 직접 사용 검사 +print_usage = Grep(pattern="\\bprint\\(", path="src/modules/") +if print_usage: + report_issue("WARNING", "print() 사용 금지 — structlog get_logger() 사용") + +# 민감 정보 로그 검사 (password, token, secret 등) +sensitive_log = Grep(pattern='logger\\.info.*password|logger\\.info.*token|logger\\.info.*secret', path="src/modules/") +if sensitive_log: + report_issue("WARNING", "민감 정보 로그 — mask_dict/mask_value 사용") +``` + ### Step 5: 코드 품질 검증 + +```python +# datetime.utcnow 사용 검사 (deprecated) +utcnow = Grep(pattern="datetime\\.utcnow", path="src/") +if utcnow: + report_issue("WARNING", "datetime.utcnow 사용 금지 — BaseModel의 utcnow() 사용") + +# BaseHTTPMiddleware 사용 검사 +base_middleware = Grep(pattern="BaseHTTPMiddleware", path="src/") +if base_middleware: + report_issue("WARNING", "BaseHTTPMiddleware 사용 금지 — Pure ASGI 미들웨어 사용") +``` + ### Step 6: E2E 테스트 실행 + +```python +result = Bash("uv run pytest tests/ -v --tb=short", description="E2E 테스트 실행") +if "FAILED" in result: + report_issue("CRITICAL", f"E2E 테스트 실패:\n{result}") +``` + ### Step 7: 이슈 리포트 생성 (REVIEW_REPORT.md) + +```python +report = generate_review_report(issues) +Write("REVIEW_REPORT.md", report) +``` + ### Step 8: SESSION.md 업데이트 + ### Step 9: Phase 6 호출 (CRITICAL 이슈 0일 때만) +```python +critical_count = len([i for i in issues if i["level"] == "CRITICAL"]) +if critical_count == 0: + Task( + subagent_type="python-fastapi-programmer:phase-6-documenter", + description="API 문서 생성", + prompt="검증 완료된 코드 기반으로 OpenAPI 스펙과 API 문서를 생성하세요." + ) +else: + print(f"CRITICAL 이슈 {critical_count}개 — Phase 6 진행 불가") +``` + ## 완료 조건 - CRITICAL 이슈 0개 diff --git a/plugins/python-fastapi-programmer/agents/phase-6-documenter.md b/plugins/python-fastapi-programmer/agents/phase-6-documenter.md index 48c0b61..485eafc 100644 --- a/plugins/python-fastapi-programmer/agents/phase-6-documenter.md +++ b/plugins/python-fastapi-programmer/agents/phase-6-documenter.md @@ -1,6 +1,9 @@ --- name: phase-6-documenter -description: Generates OpenAPI spec, API documentation, and frontend agent API mappings +description: | + Generates OpenAPI spec, API documentation, and frontend agent API mappings. + Context: User wants to generate API documentation\nuser: "API 문서 생성해줘"\nassistant: "I'll use the phase-6-documenter to generate OpenAPI spec and API documentation."\nPhase 6 creates openapi.json, API docs, and frontend mapping files. + Context: User wants frontend agent API mappings\nuser: "프론트엔드 에이전트용 API 매핑 만들어줘"\nassistant: "I'll use the documenter to create FRONTEND_API_MAPPING.json from x-pages metadata."\nThe documenter extracts x-pages metadata for frontend agent integration. model: inherit color: red --- diff --git a/plugins/python-fastapi-programmer/agents/test-code-generator.md b/plugins/python-fastapi-programmer/agents/test-code-generator.md index b83366f..2e4816d 100644 --- a/plugins/python-fastapi-programmer/agents/test-code-generator.md +++ b/plugins/python-fastapi-programmer/agents/test-code-generator.md @@ -1,6 +1,8 @@ --- name: test-code-generator -description: Generates E2E integration tests based on Domain Book specifications (team member) +description: | + Generates E2E integration tests based on Domain Book specifications (team member). + Context: Team lead spawns test generator for a domain\nuser: "users 도메인 E2E 테스트 생성해줘"\nassistant: "I'll use the test-code-generator to create E2E integration tests from Domain Book specs."\nTest generator creates E2E tests within a team context. model: inherit color: cyan --- @@ -62,6 +64,55 @@ Skill(skill="python-fastapi-programmer:fastapi-postgis") - **환경 변수 기반 테스트** (.env.example 참조) - **환경 변수 미설정 시 pytest.skip()** +## 응답 구조 규칙 (필수) + +모든 API 응답은 `ApiResponse` 래퍼를 사용합니다. + +### 성공 응답 +```python +response = client.post("/api/users/register", json=data) +assert response.status_code == 201 +body = response.json() +assert body["status"] == "SUCCESS" +assert body["message"] # 한글 메시지 +assert body["data"]["id"] # 실제 데이터 +``` + +### 에러 응답 +```python +# ❌ 잘못된 예 (detail 필드 사용) +assert "이메일 중복" in response.json()["detail"] + +# ✅ 올바른 예 (status + message 필드 사용) +body = response.json() +assert body["status"] == "RESOURCE_ALREADY_EXISTS" # Status enum 값 +assert body["message"] # 한글 에러 메시지 +``` + +### 에러 코드 참조 +| HTTP | Status enum | 설명 | +|------|-------------|------| +| 401 | USER_AUTHENTICATION_FAILED | 인증 실패 | +| 403 | PERMISSION_DENIED | 권한 없음 | +| 404 | RESOURCE_NOT_FOUND | 리소스 없음 | +| 409 | RESOURCE_ALREADY_EXISTS | 중복 | +| 422 | VALIDATION_FAILED | 유효성 실패 | + +### 페이지네이션 응답 +```python +response = client.get("/api/users?page=1&size=20") +body = response.json() +assert body["status"] == "SUCCESS" +assert body["data"]["meta"]["total_item_count"] >= 0 +assert isinstance(body["data"]["items"], list) +``` + +### X-Trace-Id 헤더 +```python +# 모든 응답에 x-trace-id 헤더 포함 (미들웨어 자동 추가) +assert "x-trace-id" in response.headers +``` + ## 작업 흐름 ### Step 0: Git Worktree로 이동 @@ -176,7 +227,9 @@ def test_user_registration_and_login_flow(client, db_session): } register_response = client.post("/api/users/register", json=register_data) assert register_response.status_code == 201 - user_id = register_response.json()["id"] + body = register_response.json() + assert body["status"] == "SUCCESS" + user_id = body["data"]["id"] # 2. 로그인 login_data = { @@ -185,7 +238,9 @@ def test_user_registration_and_login_flow(client, db_session): } login_response = client.post("/api/users/login", json=login_data) assert login_response.status_code == 200 - token = login_response.json()["access_token"] + login_body = login_response.json() + assert login_body["status"] == "SUCCESS" + token = login_body["data"]["access_token"] assert token is not None # 3. 인증된 프로필 조회 @@ -194,9 +249,10 @@ def test_user_registration_and_login_flow(client, db_session): headers={"Authorization": f"Bearer {token}"} ) assert profile_response.status_code == 200 - profile_data = profile_response.json() - assert profile_data["email"] == "test@example.com" - assert profile_data["name"] == "테스트 유저" + profile_body = profile_response.json() + assert profile_body["status"] == "SUCCESS" + assert profile_body["data"]["email"] == "test@example.com" + assert profile_body["data"]["name"] == "테스트 유저" def test_user_registration_duplicate_email(client, db_session): @@ -219,8 +275,10 @@ def test_user_registration_duplicate_email(client, db_session): # 두 번째 회원가입 실패 (중복 이메일) second_response = client.post("/api/users/register", json=register_data) - assert second_response.status_code == 400 - assert "이메일 중복" in second_response.json()["detail"] + assert second_response.status_code == 409 + second_body = second_response.json() + assert second_body["status"] == "RESOURCE_ALREADY_EXISTS" + assert second_body["message"] def test_user_login_invalid_password(client, db_session): @@ -246,7 +304,9 @@ def test_user_login_invalid_password(client, db_session): } login_response = client.post("/api/users/login", json=login_data) assert login_response.status_code == 401 - assert "인증 실패" in login_response.json()["detail"] + login_body = login_response.json() + assert login_body["status"] == "USER_AUTHENTICATION_FAILED" + assert login_body["message"] ``` #### 4.3 conftest.py 생성 (DB 픽스처) @@ -286,7 +346,7 @@ git commit -m "test: {domain} E2E 테스트 생성 - 환경 변수 기반 테스트 (Mock 데이터 금지) - SQLModel ORM 사용 -Co-Authored-By: Claude Sonnet 4.5 " +Co-Authored-By: Claude " ``` ### Step 6: 팀 SESSION.md 업데이트 @@ -360,11 +420,12 @@ def test_crud_flow(client, db_session): """Create → Read → Update → Delete 전체 플로우""" # Create create_response = client.post("/api/resources", json={...}) - resource_id = create_response.json()["id"] + resource_id = create_response.json()["data"]["id"] # Read read_response = client.get(f"/api/resources/{resource_id}") assert read_response.status_code == 200 + assert read_response.json()["status"] == "SUCCESS" # Update update_response = client.put(f"/api/resources/{resource_id}", json={...}) @@ -385,7 +446,7 @@ def test_auth_flow(client, db_session): # 로그인 login_response = client.post("/api/auth/login", json={...}) - token = login_response.json()["access_token"] + token = login_response.json()["data"]["access_token"] # 인증된 요청 protected_response = client.get( diff --git a/plugins/python-fastapi-programmer/commands/start.md b/plugins/python-fastapi-programmer/commands/start.md index 5792474..004c5e4 100644 --- a/plugins/python-fastapi-programmer/commands/start.md +++ b/plugins/python-fastapi-programmer/commands/start.md @@ -2,7 +2,12 @@ name: start description: Domain Book을 자동으로 찾아 FastAPI 프로젝트 구현 시작 argument-hint: "[--domain-book-path PATH]" -allowed-tools: "Glob, Read, Task, AskUserQuestion, Skill" +allowed-tools: + - Glob + - Read + - Task + - AskUserQuestion + - Skill --- You are the **Start Command** for the python-fastapi-programmer plugin. diff --git a/plugins/python-fastapi-programmer/skills/fastapi-architecture/SKILL.md b/plugins/python-fastapi-programmer/skills/fastapi-architecture/SKILL.md index 4264e30..b530153 100644 --- a/plugins/python-fastapi-programmer/skills/fastapi-architecture/SKILL.md +++ b/plugins/python-fastapi-programmer/skills/fastapi-architecture/SKILL.md @@ -40,11 +40,26 @@ openapi_extra={ **File Structure**: ``` -src/modules/{domain}/ -├── _models.py # Entities -├── register.py # Use Case -├── dtos.py # DTOs -└── router.py # Interface Adapter +src/ +├── core/ # 프레임워크 공통 모듈 (수정 금지) +│ ├── config.py # 환경 변수 (Settings) +│ ├── models.py # BaseModel (TimestampMixin + SoftDeleteMixin) +│ ├── response.py # ApiResponse[T] + Status enum +│ ├── exceptions.py # AppError 계열 예외 +│ ├── pagination.py # OffsetPage[T], CursorPage[T] +│ ├── sorting.py # parse_sort, SortField +│ ├── logger.py # structlog 로거 +│ ├── masking.py # 민감 정보 마스킹 +│ ├── middleware.py # Pure ASGI 미들웨어 +│ ├── utils.py # EnvironmentHelper +│ └── database.py # DB 연결 +├── modules/{domain}/ # 도메인별 Vertical Slice +│ ├── _models.py # Entities (BaseModel 상속) +│ ├── register.py # Use Case +│ ├── dtos.py # DTOs +│ └── router.py # Interface Adapter +└── app/ + └── main.py # FastAPI 엔트리포인트 ``` **See references/** for complete examples and patterns. diff --git a/plugins/python-fastapi-programmer/skills/fastapi-architecture/references/vertical-slice-pattern.md b/plugins/python-fastapi-programmer/skills/fastapi-architecture/references/vertical-slice-pattern.md index f83e2ee..0097a13 100644 --- a/plugins/python-fastapi-programmer/skills/fastapi-architecture/references/vertical-slice-pattern.md +++ b/plugins/python-fastapi-programmer/skills/fastapi-architecture/references/vertical-slice-pattern.md @@ -17,13 +17,26 @@ Vertical Slice Architecture는 기능별로 코드를 수직으로 분리하는 ``` src/modules/{domain}/ ├── __init__.py -├── _models.py # Entities (Domain 모델만) +├── _models.py # Entities (BaseModel 상속 → id, timestamps, soft_delete 포함) ├── register.py # Use Case (회원가입 비즈니스 로직) ├── login.py # Use Case (로그인 비즈니스 로직) ├── get_profile.py # Use Case (프로필 조회 비즈니스 로직) └── router.py # Interface Adapter (API 엔드포인트만) ``` +### Entities 규칙 + +```python +# ✅ BaseModel 상속 (id, created_at, updated_at, deleted_at 자동 포함) +from src.core.models import BaseModel as AppBaseModel + +class User(AppBaseModel, table=True): + __tablename__ = "users" + email: str = Field(max_length=255, unique=True) + +# ❌ SQLModel 직접 상속 + 수동 timestamp 금지 +``` + ## 핵심 정리 1. **기능별 파일 분리**: 각 Use Case는 독립 파일 diff --git a/plugins/python-fastapi-programmer/skills/fastapi-security/SKILL.md b/plugins/python-fastapi-programmer/skills/fastapi-security/SKILL.md index f41d65a..8da2ce6 100644 --- a/plugins/python-fastapi-programmer/skills/fastapi-security/SKILL.md +++ b/plugins/python-fastapi-programmer/skills/fastapi-security/SKILL.md @@ -52,6 +52,35 @@ SECRET_KEY = os.getenv("SECRET_KEY") if not SECRET_KEY: raise ValueError("SECRET_KEY required") -# ❌ Never do this +# ❌ Never do this SECRET_KEY = "hardcoded_secret_123" ``` + +### 5. Sensitive Data Masking + +**Always** mask sensitive data in logs: +```python +from src.core.masking import mask_dict, mask_value + +# ✅ Correct - 로그 출력 시 마스킹 +logger.info("request", data=mask_dict({"password": "secret", "name": "John"})) +# → {"password": "se***et", "name": "John"} + +# ❌ Never do this +logger.info("request", data={"password": "secret"}) +``` + +### 6. Exception Handling + +**Always** use AppError subclasses: +```python +from src.core.exceptions import NotFoundError, ConflictError, UnauthorizedError + +# ✅ Correct - 자동으로 ApiResponse 형식 반환 +raise NotFoundError() # 404 {"status": "RESOURCE_NOT_FOUND", "message": "찾으시는 정보가 없어요"} +raise ConflictError() # 409 {"status": "RESOURCE_ALREADY_EXISTS", "message": "이미 등록된 정보예요"} +raise UnauthorizedError() # 401 {"status": "USER_AUTHENTICATION_FAILED", "message": "로그인이 필요해요"} + +# ❌ Never do this +raise HTTPException(status_code=404, detail="Not found") +``` diff --git a/plugins/python-fastapi-programmer/skills/fastapi-security/references/environment-variables.md b/plugins/python-fastapi-programmer/skills/fastapi-security/references/environment-variables.md index 9e3f430..77eb152 100644 --- a/plugins/python-fastapi-programmer/skills/fastapi-security/references/environment-variables.md +++ b/plugins/python-fastapi-programmer/skills/fastapi-security/references/environment-variables.md @@ -11,6 +11,62 @@ 3. **.env.example 제공**: 필요한 환경 변수 목록 문서화 4. **명확한 에러 메시지**: 환경 변수 없을 때 에러 발생 +## Implementation (pydantic-settings) + +```python +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """환경 변수 설정 (pydantic-settings 기반)""" + + # Database + DATABASE_URL: str + + # JWT + SECRET_KEY: str + + # Application + DEBUG: bool = False + ENVIRONMENT: str = "development" + LOG_LEVEL: str = "INFO" + + # Optional external APIs + NAVER_CLIENT_ID: str | None = None + NAVER_CLIENT_SECRET: str | None = None + + model_config = {"env_file": ".env.local", "extra": "ignore"} + + +settings = Settings() +``` + +## Usage in Code + +```python +from src.core.config import settings + +# ✅ Correct - pydantic-settings로 환경 변수 접근 +db_url = settings.DATABASE_URL +secret = settings.SECRET_KEY + +# ❌ Never do this - 하드코딩 +SECRET_KEY = "hardcoded_secret_123" +DATABASE_URL = "postgresql://localhost/mydb" +``` + +## Usage in Tests + +```python +import os +import pytest + +# 환경 변수 없으면 테스트 skip +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + pytest.skip("DATABASE_URL 환경 변수 없음 - .env.example 참조", allow_module_level=True) +``` + ## 핵심 정리 1. **하드코딩 금지**: 모든 외부 연결은 환경 변수로 diff --git a/plugins/python-fastapi-programmer/skills/fastapi-security/references/jwt-authentication.md b/plugins/python-fastapi-programmer/skills/fastapi-security/references/jwt-authentication.md index cdd7137..8527e5a 100644 --- a/plugins/python-fastapi-programmer/skills/fastapi-security/references/jwt-authentication.md +++ b/plugins/python-fastapi-programmer/skills/fastapi-security/references/jwt-authentication.md @@ -9,8 +9,9 @@ Authorization: Bearer {token} ```python import os import jwt -from fastapi import Depends, HTTPException +from fastapi import Depends from fastapi.security import HTTPBearer, HTTPAuthCredentials +from src.core.exceptions import UnauthorizedError, ForbiddenError SECRET_KEY = os.getenv("SECRET_KEY") if not SECRET_KEY: @@ -31,21 +32,28 @@ def get_current_user( user_id = payload.get("sub") user = db.get(User, user_id) if not user: - raise HTTPException(401, "User not found") + raise UnauthorizedError() # 401, status=USER_AUTHENTICATION_FAILED return user except jwt.InvalidTokenError: - raise HTTPException(401, "Invalid token") + raise UnauthorizedError() # 401, status=USER_AUTHENTICATION_FAILED ``` ## Usage ```python +from src.core.exceptions import ForbiddenError + @router.get("/{user_id}") def get_profile( user_id: UUID, current_user: User = Depends(get_current_user) ): if current_user.id != user_id: - raise HTTPException(403, "Forbidden") + raise ForbiddenError() # 403, status=PERMISSION_DENIED return current_user ``` + +## Important + +- **Never** use `HTTPException` directly — always use `UnauthorizedError`, `ForbiddenError`, etc. +- These exceptions automatically return `ApiResponse` format with proper `status` enum values. diff --git a/plugins/python-fastapi-programmer/skills/fastapi-security/references/password-hashing.md b/plugins/python-fastapi-programmer/skills/fastapi-security/references/password-hashing.md index d37c176..a69d8f9 100644 --- a/plugins/python-fastapi-programmer/skills/fastapi-security/references/password-hashing.md +++ b/plugins/python-fastapi-programmer/skills/fastapi-security/references/password-hashing.md @@ -12,6 +12,43 @@ bcrypt는 salt를 자동으로 생성하고, 느린 해싱으로 무차별 대 3. **10 rounds**: bcrypt의 기본 cost factor (2^10 = 1024회 해싱) 4. **환경 변수 없음**: bcrypt는 salt를 자동 생성 (별도 환경 변수 불필요) +## Implementation + +```python +import bcrypt + + +def hash_password(password: str) -> str: + """비밀번호를 bcrypt로 해시""" + return bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + + +def verify_password(password: str, hashed: str) -> bool: + """평문 비밀번호와 해시 비교""" + return bcrypt.checkpw( + password.encode("utf-8"), hashed.encode("utf-8") + ) +``` + +## Usage in Use Cases + +```python +# register.py (회원가입) +from src.modules.users._models import User + +hashed = hash_password(request.password) +user = User(email=request.email, password=hashed, name=request.name) +db.add(user) +db.commit() + +# login.py (로그인) +user = db.exec(select(User).where(User.email == request.email)).first() +if not user or not verify_password(request.password, user.password): + raise UnauthorizedError() # 401, AppError 사용 +``` + ## 핵심 정리 1. **bcrypt 사용**: 느린 해싱으로 무차별 대입 공격 방지