@malamute/ai-rules 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +270 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/.claude/rules/conventions/documentation.md +324 -0
- package/configs/_shared/.claude/rules/conventions/git.md +265 -0
- package/configs/_shared/.claude/rules/{performance.md → conventions/performance.md} +1 -1
- package/configs/_shared/.claude/rules/conventions/principles.md +334 -0
- package/configs/_shared/.claude/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/.claude/rules/devops/docker.md +275 -0
- package/configs/_shared/.claude/rules/devops/nx.md +194 -0
- package/configs/_shared/.claude/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/.claude/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/.claude/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/.claude/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/.claude/rules/lang/python/async.md +337 -0
- package/configs/_shared/.claude/rules/lang/python/celery.md +476 -0
- package/configs/_shared/.claude/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/.claude/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/.claude/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/.claude/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/.claude/rules/lang/python/python.md +172 -0
- package/configs/_shared/.claude/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/.claude/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/.claude/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/.claude/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/.claude/rules/quality/error-handling.md +48 -0
- package/configs/_shared/.claude/rules/quality/logging.md +45 -0
- package/configs/_shared/.claude/rules/quality/observability.md +240 -0
- package/configs/_shared/.claude/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/.claude/rules/security/secrets-management.md +222 -0
- package/configs/_shared/.claude/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/.claude/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/.claude/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/.claude/{commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/.claude/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/.claude/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/.claude/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/.claude/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/.claude/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/angular/.claude/rules/{components.md → core/components.md} +69 -15
- package/configs/angular/.claude/rules/core/resource.md +285 -0
- package/configs/angular/.claude/rules/core/signals.md +323 -0
- package/configs/angular/.claude/rules/http.md +338 -0
- package/configs/angular/.claude/rules/routing.md +291 -0
- package/configs/angular/.claude/rules/ssr.md +312 -0
- package/configs/angular/.claude/rules/state/signal-store.md +408 -0
- package/configs/angular/.claude/rules/{state.md → state/state.md} +2 -2
- package/configs/angular/.claude/rules/testing.md +7 -7
- package/configs/angular/.claude/rules/ui/aria.md +422 -0
- package/configs/angular/.claude/rules/ui/forms.md +424 -0
- package/configs/angular/.claude/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/.claude/settings.json +1 -0
- package/configs/angular/.claude/skills/ngrx-slice/SKILL.md +362 -0
- package/configs/angular/.claude/skills/signal-store/SKILL.md +445 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/dotnet/.claude/rules/background-services.md +552 -0
- package/configs/dotnet/.claude/rules/configuration.md +426 -0
- package/configs/dotnet/.claude/rules/ddd.md +447 -0
- package/configs/dotnet/.claude/rules/dependency-injection.md +343 -0
- package/configs/dotnet/.claude/rules/mediatr.md +320 -0
- package/configs/dotnet/.claude/rules/middleware.md +489 -0
- package/configs/dotnet/.claude/rules/result-pattern.md +363 -0
- package/configs/dotnet/.claude/rules/validation.md +388 -0
- package/configs/dotnet/.claude/settings.json +21 -3
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/fastapi/.claude/rules/background-tasks.md +254 -0
- package/configs/fastapi/.claude/rules/dependencies.md +170 -0
- package/configs/{python → fastapi}/.claude/rules/fastapi.md +61 -1
- package/configs/fastapi/.claude/rules/lifespan.md +274 -0
- package/configs/fastapi/.claude/rules/middleware.md +229 -0
- package/configs/fastapi/.claude/rules/pydantic.md +433 -0
- package/configs/fastapi/.claude/rules/responses.md +251 -0
- package/configs/fastapi/.claude/rules/routers.md +202 -0
- package/configs/fastapi/.claude/rules/security.md +222 -0
- package/configs/fastapi/.claude/rules/testing.md +251 -0
- package/configs/fastapi/.claude/rules/websockets.md +298 -0
- package/configs/fastapi/.claude/settings.json +33 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/flask/.claude/rules/blueprints.md +208 -0
- package/configs/flask/.claude/rules/cli.md +285 -0
- package/configs/flask/.claude/rules/configuration.md +281 -0
- package/configs/flask/.claude/rules/context.md +238 -0
- package/configs/flask/.claude/rules/error-handlers.md +278 -0
- package/configs/flask/.claude/rules/extensions.md +278 -0
- package/configs/flask/.claude/rules/flask.md +171 -0
- package/configs/flask/.claude/rules/marshmallow.md +206 -0
- package/configs/flask/.claude/rules/security.md +267 -0
- package/configs/flask/.claude/rules/testing.md +284 -0
- package/configs/flask/.claude/settings.json +33 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/nestjs/.claude/rules/common-patterns.md +300 -0
- package/configs/nestjs/.claude/rules/filters.md +376 -0
- package/configs/nestjs/.claude/rules/interceptors.md +317 -0
- package/configs/nestjs/.claude/rules/middleware.md +321 -0
- package/configs/nestjs/.claude/rules/modules.md +26 -0
- package/configs/nestjs/.claude/rules/pipes.md +351 -0
- package/configs/nestjs/.claude/rules/websockets.md +451 -0
- package/configs/nestjs/.claude/settings.json +16 -2
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nextjs/.claude/rules/api-routes.md +358 -0
- package/configs/nextjs/.claude/rules/authentication.md +355 -0
- package/configs/nextjs/.claude/rules/components.md +52 -0
- package/configs/nextjs/.claude/rules/data-fetching.md +249 -0
- package/configs/nextjs/.claude/rules/database.md +400 -0
- package/configs/nextjs/.claude/rules/middleware.md +303 -0
- package/configs/nextjs/.claude/rules/routing.md +324 -0
- package/configs/nextjs/.claude/rules/seo.md +350 -0
- package/configs/nextjs/.claude/rules/server-actions.md +353 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +6 -6
- package/configs/nextjs/.claude/settings.json +5 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/package.json +23 -9
- package/src/cli.js +220 -0
- package/src/config.js +29 -0
- package/src/index.js +13 -0
- package/src/installer.js +361 -0
- package/src/merge.js +116 -0
- package/src/tech-config.json +29 -0
- package/src/utils.js +96 -0
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/.claude/rules/{accessibility.md → domain/frontend/accessibility.md} +0 -0
- /package/configs/_shared/.claude/rules/{security.md → security/security.md} +0 -0
- /package/configs/_shared/.claude/skills/{debug → dev/debug}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{learning → dev/learning}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{spec → dev/spec}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{review → git/review}/SKILL.md +0 -0
package/src/merge.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { log, backupFile } = require('./utils');
|
|
4
|
+
const { VERSION } = require('./config');
|
|
5
|
+
|
|
6
|
+
function mergeClaudeMd(targetPath, sourcePath, isFirst, options = {}) {
|
|
7
|
+
const { dryRun = false, backup = false, targetDir } = options;
|
|
8
|
+
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
9
|
+
const exists = fs.existsSync(targetPath);
|
|
10
|
+
|
|
11
|
+
if (dryRun) {
|
|
12
|
+
return { type: exists ? 'merge' : 'create', path: 'CLAUDE.md' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (exists && backup && isFirst) {
|
|
16
|
+
backupFile(targetPath, targetDir);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (isFirst) {
|
|
20
|
+
fs.writeFileSync(targetPath, content);
|
|
21
|
+
} else {
|
|
22
|
+
const existing = fs.readFileSync(targetPath, 'utf8');
|
|
23
|
+
fs.writeFileSync(targetPath, `${existing}\n\n---\n\n${content}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { type: exists ? 'merge' : 'create', path: 'CLAUDE.md' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mergeSettingsJson(targetPath, sourcePath, options = {}) {
|
|
30
|
+
const { dryRun = false, backup = false, targetDir } = options;
|
|
31
|
+
const exists = fs.existsSync(targetPath);
|
|
32
|
+
|
|
33
|
+
if (dryRun) {
|
|
34
|
+
return { type: exists ? 'merge' : 'create', path: '.claude/settings.json' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!exists) {
|
|
38
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
39
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
40
|
+
return { type: 'create', path: '.claude/settings.json' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (backup) {
|
|
44
|
+
backupFile(targetPath, targetDir);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const existing = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
49
|
+
const incoming = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
|
|
50
|
+
|
|
51
|
+
const merged = {
|
|
52
|
+
...existing,
|
|
53
|
+
permissions: {
|
|
54
|
+
allow: [
|
|
55
|
+
...new Set([
|
|
56
|
+
...(existing.permissions?.allow || []),
|
|
57
|
+
...(incoming.permissions?.allow || []),
|
|
58
|
+
]),
|
|
59
|
+
],
|
|
60
|
+
deny: [
|
|
61
|
+
...new Set([
|
|
62
|
+
...(existing.permissions?.deny || []),
|
|
63
|
+
...(incoming.permissions?.deny || []),
|
|
64
|
+
]),
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (incoming.env) {
|
|
70
|
+
merged.env = { ...(existing.env || {}), ...incoming.env };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fs.writeFileSync(targetPath, JSON.stringify(merged, null, 2) + '\n');
|
|
74
|
+
return { type: 'merge', path: '.claude/settings.json' };
|
|
75
|
+
} catch (_e) {
|
|
76
|
+
log.warning('Could not merge settings.json, overwriting');
|
|
77
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
78
|
+
return { type: 'overwrite', path: '.claude/settings.json' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getManifestPath(targetDir) {
|
|
83
|
+
return path.join(targetDir, '.claude', '.ai-rules.json');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readManifest(targetDir) {
|
|
87
|
+
const manifestPath = getManifestPath(targetDir);
|
|
88
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeManifest(targetDir, data, dryRun = false) {
|
|
98
|
+
if (dryRun) return;
|
|
99
|
+
|
|
100
|
+
const manifestPath = getManifestPath(targetDir);
|
|
101
|
+
const manifest = {
|
|
102
|
+
version: VERSION,
|
|
103
|
+
installedAt: new Date().toISOString(),
|
|
104
|
+
...data,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
108
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
mergeClaudeMd,
|
|
113
|
+
mergeSettingsJson,
|
|
114
|
+
readManifest,
|
|
115
|
+
writeManifest,
|
|
116
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"technologies": {
|
|
3
|
+
"angular": {
|
|
4
|
+
"includeRules": ["lang/typescript", "domain/frontend"]
|
|
5
|
+
},
|
|
6
|
+
"nextjs": {
|
|
7
|
+
"includeRules": ["lang/typescript", "domain/frontend"]
|
|
8
|
+
},
|
|
9
|
+
"nestjs": {
|
|
10
|
+
"includeRules": ["lang/typescript", "domain/backend"]
|
|
11
|
+
},
|
|
12
|
+
"dotnet": {
|
|
13
|
+
"includeRules": ["lang/csharp", "domain/backend"]
|
|
14
|
+
},
|
|
15
|
+
"fastapi": {
|
|
16
|
+
"includeRules": ["lang/python", "domain/backend"]
|
|
17
|
+
},
|
|
18
|
+
"flask": {
|
|
19
|
+
"includeRules": ["lang/python", "domain/backend"]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"ruleCategories": {
|
|
23
|
+
"lang/typescript": "TypeScript best practices",
|
|
24
|
+
"lang/python": "Python best practices",
|
|
25
|
+
"lang/csharp": "C# language best practices",
|
|
26
|
+
"domain/frontend": "Accessibility, UI patterns",
|
|
27
|
+
"domain/backend": "API design, server patterns"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const colors = {
|
|
5
|
+
red: (text) => `\x1b[31m${text}\x1b[0m`,
|
|
6
|
+
green: (text) => `\x1b[32m${text}\x1b[0m`,
|
|
7
|
+
blue: (text) => `\x1b[34m${text}\x1b[0m`,
|
|
8
|
+
yellow: (text) => `\x1b[33m${text}\x1b[0m`,
|
|
9
|
+
cyan: (text) => `\x1b[36m${text}\x1b[0m`,
|
|
10
|
+
dim: (text) => `\x1b[2m${text}\x1b[0m`,
|
|
11
|
+
bold: (text) => `\x1b[1m${text}\x1b[0m`,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const log = {
|
|
15
|
+
info: (msg) => console.log(`${colors.blue('ℹ')} ${msg}`),
|
|
16
|
+
success: (msg) => console.log(`${colors.green('✓')} ${msg}`),
|
|
17
|
+
warning: (msg) => console.log(`${colors.yellow('⚠')} ${msg}`),
|
|
18
|
+
error: (msg) => console.log(`${colors.red('✗')} ${msg}`),
|
|
19
|
+
dry: (msg) => console.log(`${colors.cyan('○')} ${msg}`),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function getFilesRecursive(dir, baseDir = dir) {
|
|
23
|
+
const files = [];
|
|
24
|
+
if (!fs.existsSync(dir)) return files;
|
|
25
|
+
|
|
26
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = path.join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
files.push(...getFilesRecursive(fullPath, baseDir));
|
|
31
|
+
} else {
|
|
32
|
+
files.push(path.relative(baseDir, fullPath));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return files;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function copyDirRecursive(src, dest, options = {}) {
|
|
39
|
+
const { dryRun = false, backup = false, targetDir = dest } = options;
|
|
40
|
+
const operations = [];
|
|
41
|
+
|
|
42
|
+
if (!fs.existsSync(src)) return operations;
|
|
43
|
+
|
|
44
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
45
|
+
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const srcPath = path.join(src, entry.name);
|
|
48
|
+
const destPath = path.join(dest, entry.name);
|
|
49
|
+
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
operations.push(...copyDirRecursive(srcPath, destPath, options));
|
|
52
|
+
} else {
|
|
53
|
+
const exists = fs.existsSync(destPath);
|
|
54
|
+
const relativePath = path.relative(targetDir, destPath);
|
|
55
|
+
|
|
56
|
+
if (dryRun) {
|
|
57
|
+
operations.push({
|
|
58
|
+
type: exists ? 'overwrite' : 'create',
|
|
59
|
+
path: relativePath,
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
if (exists && backup) {
|
|
63
|
+
backupFile(destPath, targetDir);
|
|
64
|
+
}
|
|
65
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
66
|
+
fs.copyFileSync(srcPath, destPath);
|
|
67
|
+
operations.push({
|
|
68
|
+
type: exists ? 'overwrite' : 'create',
|
|
69
|
+
path: relativePath,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return operations;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function backupFile(filePath, targetDir) {
|
|
79
|
+
const backupDir = path.join(targetDir, '.claude', 'backups');
|
|
80
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
81
|
+
const relativePath = path.relative(targetDir, filePath);
|
|
82
|
+
const backupPath = path.join(backupDir, `${relativePath}.${timestamp}`);
|
|
83
|
+
|
|
84
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
85
|
+
fs.copyFileSync(filePath, backupPath);
|
|
86
|
+
|
|
87
|
+
return backupPath;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
colors,
|
|
92
|
+
log,
|
|
93
|
+
getFilesRecursive,
|
|
94
|
+
copyDirRecursive,
|
|
95
|
+
backupFile,
|
|
96
|
+
};
|
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
paths:
|
|
3
|
-
- "**/*.py"
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Flask Rules
|
|
7
|
-
|
|
8
|
-
## Application Factory
|
|
9
|
-
|
|
10
|
-
```python
|
|
11
|
-
from flask import Flask
|
|
12
|
-
from flask_sqlalchemy import SQLAlchemy
|
|
13
|
-
from flask_migrate import Migrate
|
|
14
|
-
|
|
15
|
-
db = SQLAlchemy()
|
|
16
|
-
migrate = Migrate()
|
|
17
|
-
|
|
18
|
-
def create_app(config_name: str = "development") -> Flask:
|
|
19
|
-
app = Flask(__name__)
|
|
20
|
-
app.config.from_object(config[config_name])
|
|
21
|
-
|
|
22
|
-
# Initialize extensions
|
|
23
|
-
db.init_app(app)
|
|
24
|
-
migrate.init_app(app, db)
|
|
25
|
-
|
|
26
|
-
# Register blueprints
|
|
27
|
-
from app.users import bp as users_bp
|
|
28
|
-
from app.auth import bp as auth_bp
|
|
29
|
-
|
|
30
|
-
app.register_blueprint(users_bp, url_prefix="/api/v1/users")
|
|
31
|
-
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
|
|
32
|
-
|
|
33
|
-
# Register error handlers
|
|
34
|
-
register_error_handlers(app)
|
|
35
|
-
|
|
36
|
-
return app
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
## Blueprints
|
|
40
|
-
|
|
41
|
-
```python
|
|
42
|
-
from flask import Blueprint, request, jsonify
|
|
43
|
-
from http import HTTPStatus
|
|
44
|
-
|
|
45
|
-
bp = Blueprint("users", __name__)
|
|
46
|
-
|
|
47
|
-
@bp.get("/")
|
|
48
|
-
def list_users():
|
|
49
|
-
"""List all users with pagination."""
|
|
50
|
-
page = request.args.get("page", 1, type=int)
|
|
51
|
-
per_page = request.args.get("per_page", 20, type=int)
|
|
52
|
-
|
|
53
|
-
pagination = User.query.paginate(page=page, per_page=per_page)
|
|
54
|
-
|
|
55
|
-
return jsonify({
|
|
56
|
-
"items": [user.to_dict() for user in pagination.items],
|
|
57
|
-
"total": pagination.total,
|
|
58
|
-
"page": pagination.page,
|
|
59
|
-
"pages": pagination.pages,
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@bp.get("/<int:user_id>")
|
|
64
|
-
def get_user(user_id: int):
|
|
65
|
-
"""Get a user by ID."""
|
|
66
|
-
user = db.get_or_404(User, user_id)
|
|
67
|
-
return jsonify(user.to_dict())
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@bp.post("/")
|
|
71
|
-
def create_user():
|
|
72
|
-
"""Create a new user."""
|
|
73
|
-
data = request.get_json()
|
|
74
|
-
|
|
75
|
-
# Validation
|
|
76
|
-
errors = validate_user_data(data)
|
|
77
|
-
if errors:
|
|
78
|
-
return jsonify({"errors": errors}), HTTPStatus.BAD_REQUEST
|
|
79
|
-
|
|
80
|
-
user = User(
|
|
81
|
-
email=data["email"],
|
|
82
|
-
name=data["name"],
|
|
83
|
-
)
|
|
84
|
-
user.set_password(data["password"])
|
|
85
|
-
|
|
86
|
-
db.session.add(user)
|
|
87
|
-
db.session.commit()
|
|
88
|
-
|
|
89
|
-
return jsonify(user.to_dict()), HTTPStatus.CREATED
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@bp.put("/<int:user_id>")
|
|
93
|
-
def update_user(user_id: int):
|
|
94
|
-
"""Update a user."""
|
|
95
|
-
user = db.get_or_404(User, user_id)
|
|
96
|
-
data = request.get_json()
|
|
97
|
-
|
|
98
|
-
if "email" in data:
|
|
99
|
-
user.email = data["email"]
|
|
100
|
-
if "name" in data:
|
|
101
|
-
user.name = data["name"]
|
|
102
|
-
|
|
103
|
-
db.session.commit()
|
|
104
|
-
return jsonify(user.to_dict())
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@bp.delete("/<int:user_id>")
|
|
108
|
-
def delete_user(user_id: int):
|
|
109
|
-
"""Delete a user."""
|
|
110
|
-
user = db.get_or_404(User, user_id)
|
|
111
|
-
db.session.delete(user)
|
|
112
|
-
db.session.commit()
|
|
113
|
-
return "", HTTPStatus.NO_CONTENT
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
## Models with Flask-SQLAlchemy
|
|
117
|
-
|
|
118
|
-
```python
|
|
119
|
-
from datetime import datetime
|
|
120
|
-
from werkzeug.security import generate_password_hash, check_password_hash
|
|
121
|
-
from app import db
|
|
122
|
-
|
|
123
|
-
class TimestampMixin:
|
|
124
|
-
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
125
|
-
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
126
|
-
|
|
127
|
-
class User(TimestampMixin, db.Model):
|
|
128
|
-
__tablename__ = "users"
|
|
129
|
-
|
|
130
|
-
id = db.Column(db.Integer, primary_key=True)
|
|
131
|
-
email = db.Column(db.String(256), unique=True, nullable=False, index=True)
|
|
132
|
-
password_hash = db.Column(db.String(256), nullable=False)
|
|
133
|
-
name = db.Column(db.String(100))
|
|
134
|
-
role = db.Column(db.String(50), default="user")
|
|
135
|
-
|
|
136
|
-
# Relationships
|
|
137
|
-
posts = db.relationship("Post", back_populates="author", lazy="dynamic")
|
|
138
|
-
|
|
139
|
-
def set_password(self, password: str) -> None:
|
|
140
|
-
self.password_hash = generate_password_hash(password)
|
|
141
|
-
|
|
142
|
-
def check_password(self, password: str) -> bool:
|
|
143
|
-
return check_password_hash(self.password_hash, password)
|
|
144
|
-
|
|
145
|
-
def to_dict(self) -> dict:
|
|
146
|
-
return {
|
|
147
|
-
"id": self.id,
|
|
148
|
-
"email": self.email,
|
|
149
|
-
"name": self.name,
|
|
150
|
-
"role": self.role,
|
|
151
|
-
"created_at": self.created_at.isoformat(),
|
|
152
|
-
}
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
## Error Handling
|
|
156
|
-
|
|
157
|
-
```python
|
|
158
|
-
from flask import jsonify
|
|
159
|
-
from werkzeug.exceptions import HTTPException
|
|
160
|
-
|
|
161
|
-
def register_error_handlers(app: Flask) -> None:
|
|
162
|
-
|
|
163
|
-
@app.errorhandler(HTTPException)
|
|
164
|
-
def handle_http_exception(error: HTTPException):
|
|
165
|
-
return jsonify({
|
|
166
|
-
"error": error.name,
|
|
167
|
-
"message": error.description,
|
|
168
|
-
}), error.code
|
|
169
|
-
|
|
170
|
-
@app.errorhandler(ValidationError)
|
|
171
|
-
def handle_validation_error(error: ValidationError):
|
|
172
|
-
return jsonify({
|
|
173
|
-
"error": "Validation Error",
|
|
174
|
-
"message": str(error),
|
|
175
|
-
"details": error.errors,
|
|
176
|
-
}), HTTPStatus.BAD_REQUEST
|
|
177
|
-
|
|
178
|
-
@app.errorhandler(Exception)
|
|
179
|
-
def handle_generic_exception(error: Exception):
|
|
180
|
-
app.logger.exception("Unhandled exception")
|
|
181
|
-
return jsonify({
|
|
182
|
-
"error": "Internal Server Error",
|
|
183
|
-
"message": "An unexpected error occurred",
|
|
184
|
-
}), HTTPStatus.INTERNAL_SERVER_ERROR
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
## Authentication with Flask-JWT-Extended
|
|
188
|
-
|
|
189
|
-
```python
|
|
190
|
-
from flask_jwt_extended import (
|
|
191
|
-
JWTManager,
|
|
192
|
-
create_access_token,
|
|
193
|
-
create_refresh_token,
|
|
194
|
-
jwt_required,
|
|
195
|
-
get_jwt_identity,
|
|
196
|
-
current_user,
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
jwt = JWTManager()
|
|
200
|
-
|
|
201
|
-
@jwt.user_identity_loader
|
|
202
|
-
def user_identity_lookup(user: User) -> int:
|
|
203
|
-
return user.id
|
|
204
|
-
|
|
205
|
-
@jwt.user_lookup_loader
|
|
206
|
-
def user_lookup_callback(_jwt_header, jwt_data) -> User | None:
|
|
207
|
-
identity = jwt_data["sub"]
|
|
208
|
-
return User.query.get(identity)
|
|
209
|
-
|
|
210
|
-
# Auth blueprint
|
|
211
|
-
auth_bp = Blueprint("auth", __name__)
|
|
212
|
-
|
|
213
|
-
@auth_bp.post("/login")
|
|
214
|
-
def login():
|
|
215
|
-
data = request.get_json()
|
|
216
|
-
user = User.query.filter_by(email=data["email"]).first()
|
|
217
|
-
|
|
218
|
-
if not user or not user.check_password(data["password"]):
|
|
219
|
-
return jsonify({"error": "Invalid credentials"}), HTTPStatus.UNAUTHORIZED
|
|
220
|
-
|
|
221
|
-
return jsonify({
|
|
222
|
-
"access_token": create_access_token(identity=user),
|
|
223
|
-
"refresh_token": create_refresh_token(identity=user),
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
@auth_bp.post("/refresh")
|
|
227
|
-
@jwt_required(refresh=True)
|
|
228
|
-
def refresh():
|
|
229
|
-
user = current_user
|
|
230
|
-
return jsonify({
|
|
231
|
-
"access_token": create_access_token(identity=user),
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
# Protected route
|
|
235
|
-
@bp.get("/me")
|
|
236
|
-
@jwt_required()
|
|
237
|
-
def get_current_user():
|
|
238
|
-
return jsonify(current_user.to_dict())
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
## Request Validation with Marshmallow
|
|
242
|
-
|
|
243
|
-
```python
|
|
244
|
-
from marshmallow import Schema, fields, validate, ValidationError, post_load
|
|
245
|
-
|
|
246
|
-
class UserCreateSchema(Schema):
|
|
247
|
-
email = fields.Email(required=True)
|
|
248
|
-
password = fields.Str(required=True, validate=validate.Length(min=8))
|
|
249
|
-
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
|
|
250
|
-
|
|
251
|
-
class UserUpdateSchema(Schema):
|
|
252
|
-
email = fields.Email()
|
|
253
|
-
name = fields.Str(validate=validate.Length(max=100))
|
|
254
|
-
|
|
255
|
-
class UserResponseSchema(Schema):
|
|
256
|
-
id = fields.Int(dump_only=True)
|
|
257
|
-
email = fields.Email()
|
|
258
|
-
name = fields.Str()
|
|
259
|
-
created_at = fields.DateTime(dump_only=True)
|
|
260
|
-
|
|
261
|
-
# Usage in route
|
|
262
|
-
@bp.post("/")
|
|
263
|
-
def create_user():
|
|
264
|
-
schema = UserCreateSchema()
|
|
265
|
-
try:
|
|
266
|
-
data = schema.load(request.get_json())
|
|
267
|
-
except ValidationError as err:
|
|
268
|
-
return jsonify({"errors": err.messages}), HTTPStatus.BAD_REQUEST
|
|
269
|
-
|
|
270
|
-
# Create user...
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
## Configuration
|
|
274
|
-
|
|
275
|
-
```python
|
|
276
|
-
import os
|
|
277
|
-
|
|
278
|
-
class Config:
|
|
279
|
-
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
|
|
280
|
-
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
281
|
-
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "jwt-secret")
|
|
282
|
-
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
|
283
|
-
|
|
284
|
-
class DevelopmentConfig(Config):
|
|
285
|
-
DEBUG = True
|
|
286
|
-
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
|
287
|
-
"DATABASE_URL", "sqlite:///dev.db"
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
class ProductionConfig(Config):
|
|
291
|
-
DEBUG = False
|
|
292
|
-
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
|
|
293
|
-
|
|
294
|
-
class TestingConfig(Config):
|
|
295
|
-
TESTING = True
|
|
296
|
-
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
|
297
|
-
|
|
298
|
-
config = {
|
|
299
|
-
"development": DevelopmentConfig,
|
|
300
|
-
"production": ProductionConfig,
|
|
301
|
-
"testing": TestingConfig,
|
|
302
|
-
}
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
## CLI Commands
|
|
306
|
-
|
|
307
|
-
```python
|
|
308
|
-
import click
|
|
309
|
-
from flask.cli import with_appcontext
|
|
310
|
-
|
|
311
|
-
@app.cli.command("seed-db")
|
|
312
|
-
@with_appcontext
|
|
313
|
-
def seed_db():
|
|
314
|
-
"""Seed the database with initial data."""
|
|
315
|
-
admin = User(email="admin@example.com", name="Admin", role="admin")
|
|
316
|
-
admin.set_password("admin123")
|
|
317
|
-
db.session.add(admin)
|
|
318
|
-
db.session.commit()
|
|
319
|
-
click.echo("Database seeded!")
|
|
320
|
-
|
|
321
|
-
@app.cli.command("create-admin")
|
|
322
|
-
@click.argument("email")
|
|
323
|
-
@click.password_option()
|
|
324
|
-
@with_appcontext
|
|
325
|
-
def create_admin(email: str, password: str):
|
|
326
|
-
"""Create an admin user."""
|
|
327
|
-
user = User(email=email, role="admin")
|
|
328
|
-
user.set_password(password)
|
|
329
|
-
db.session.add(user)
|
|
330
|
-
db.session.commit()
|
|
331
|
-
click.echo(f"Admin {email} created!")
|
|
332
|
-
```
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(python *)",
|
|
5
|
-
"Bash(uvicorn *)",
|
|
6
|
-
"Bash(fastapi *)",
|
|
7
|
-
"Bash(flask *)",
|
|
8
|
-
"Bash(pytest *)",
|
|
9
|
-
"Bash(ruff *)",
|
|
10
|
-
"Bash(mypy *)",
|
|
11
|
-
"Bash(alembic *)",
|
|
12
|
-
"Bash(uv *)",
|
|
13
|
-
"Bash(poetry *)",
|
|
14
|
-
"Bash(pip *)"
|
|
15
|
-
],
|
|
16
|
-
"deny": []
|
|
17
|
-
}
|
|
18
|
-
}
|