oh-my-customcode 0.10.0 → 0.10.1
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/dist/cli/index.js +106 -17
- package/dist/index.js +106 -17
- package/package.json +1 -1
- package/templates/.claude/skills/springboot-best-practices/SKILL.md +7 -152
- package/templates/.claude/skills/springboot-best-practices/examples/config-properties-example.java +22 -0
- package/templates/.claude/skills/springboot-best-practices/examples/controller-example.java +28 -0
- package/templates/.claude/skills/springboot-best-practices/examples/controller-test-example.java +33 -0
- package/templates/.claude/skills/springboot-best-practices/examples/entity-example.java +22 -0
- package/templates/.claude/skills/springboot-best-practices/examples/exception-handler-example.java +30 -0
- package/templates/.claude/skills/springboot-best-practices/examples/repository-example.java +17 -0
- package/templates/.claude/skills/springboot-best-practices/examples/repository-test-example.java +23 -0
- package/templates/.claude/skills/springboot-best-practices/examples/security-config-example.java +27 -0
- package/templates/.claude/skills/springboot-best-practices/examples/service-example.java +33 -0
package/dist/cli/index.js
CHANGED
|
@@ -12301,8 +12301,38 @@ var $visitAsync = visit.visitAsync;
|
|
|
12301
12301
|
import { join as join2 } from "node:path";
|
|
12302
12302
|
|
|
12303
12303
|
// src/utils/fs.ts
|
|
12304
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
12304
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
12305
12305
|
import { fileURLToPath } from "node:url";
|
|
12306
|
+
function validatePreserveFilePath(filePath, projectRoot) {
|
|
12307
|
+
if (!filePath || filePath.trim() === "") {
|
|
12308
|
+
return {
|
|
12309
|
+
valid: false,
|
|
12310
|
+
reason: "Path cannot be empty"
|
|
12311
|
+
};
|
|
12312
|
+
}
|
|
12313
|
+
if (isAbsolute(filePath)) {
|
|
12314
|
+
return {
|
|
12315
|
+
valid: false,
|
|
12316
|
+
reason: "Absolute paths are not allowed"
|
|
12317
|
+
};
|
|
12318
|
+
}
|
|
12319
|
+
const normalizedPath = normalize(filePath);
|
|
12320
|
+
if (normalizedPath.startsWith("..")) {
|
|
12321
|
+
return {
|
|
12322
|
+
valid: false,
|
|
12323
|
+
reason: "Path cannot traverse outside project root"
|
|
12324
|
+
};
|
|
12325
|
+
}
|
|
12326
|
+
const resolvedPath = resolve(projectRoot, normalizedPath);
|
|
12327
|
+
const relativePath = relative(projectRoot, resolvedPath);
|
|
12328
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
12329
|
+
return {
|
|
12330
|
+
valid: false,
|
|
12331
|
+
reason: "Resolved path escapes project root"
|
|
12332
|
+
};
|
|
12333
|
+
}
|
|
12334
|
+
return { valid: true };
|
|
12335
|
+
}
|
|
12306
12336
|
async function fileExists(path) {
|
|
12307
12337
|
const fs = await import("node:fs/promises");
|
|
12308
12338
|
try {
|
|
@@ -12691,7 +12721,7 @@ async function loadConfig(targetDir) {
|
|
|
12691
12721
|
if (await fileExists(configPath)) {
|
|
12692
12722
|
try {
|
|
12693
12723
|
const config = await readJsonFile(configPath);
|
|
12694
|
-
const merged = mergeConfig(getDefaultConfig(), config);
|
|
12724
|
+
const merged = mergeConfig(getDefaultConfig(), config, targetDir);
|
|
12695
12725
|
if (merged.configVersion < CURRENT_CONFIG_VERSION) {
|
|
12696
12726
|
const migrated = migrateConfig(merged);
|
|
12697
12727
|
await saveConfig(targetDir, migrated);
|
|
@@ -12720,7 +12750,30 @@ function deduplicateCustomComponents(components) {
|
|
|
12720
12750
|
}
|
|
12721
12751
|
return [...seen.values()];
|
|
12722
12752
|
}
|
|
12723
|
-
function mergeConfig(defaults, overrides) {
|
|
12753
|
+
function mergeConfig(defaults, overrides, targetDir) {
|
|
12754
|
+
let mergedPreserveFiles;
|
|
12755
|
+
if (overrides.preserveFiles) {
|
|
12756
|
+
const allFiles = [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])];
|
|
12757
|
+
if (targetDir) {
|
|
12758
|
+
const validatedFiles = [];
|
|
12759
|
+
for (const filePath of allFiles) {
|
|
12760
|
+
const validation = validatePreserveFilePath(filePath, targetDir);
|
|
12761
|
+
if (validation.valid) {
|
|
12762
|
+
validatedFiles.push(filePath);
|
|
12763
|
+
} else {
|
|
12764
|
+
warn("config.invalid_preserve_path", {
|
|
12765
|
+
path: filePath,
|
|
12766
|
+
reason: validation.reason ?? "Invalid path"
|
|
12767
|
+
});
|
|
12768
|
+
}
|
|
12769
|
+
}
|
|
12770
|
+
mergedPreserveFiles = validatedFiles;
|
|
12771
|
+
} else {
|
|
12772
|
+
mergedPreserveFiles = allFiles;
|
|
12773
|
+
}
|
|
12774
|
+
} else {
|
|
12775
|
+
mergedPreserveFiles = defaults.preserveFiles;
|
|
12776
|
+
}
|
|
12724
12777
|
return {
|
|
12725
12778
|
...defaults,
|
|
12726
12779
|
...overrides,
|
|
@@ -12734,7 +12787,7 @@ function mergeConfig(defaults, overrides) {
|
|
|
12734
12787
|
...defaults.agents,
|
|
12735
12788
|
...overrides.agents
|
|
12736
12789
|
},
|
|
12737
|
-
preserveFiles:
|
|
12790
|
+
preserveFiles: mergedPreserveFiles,
|
|
12738
12791
|
customComponents: overrides.customComponents ? deduplicateCustomComponents([
|
|
12739
12792
|
...defaults.customComponents || [],
|
|
12740
12793
|
...overrides.customComponents
|
|
@@ -14362,27 +14415,63 @@ function resolveConfigPreserveFiles(options, config) {
|
|
|
14362
14415
|
if (options.forceOverwriteAll) {
|
|
14363
14416
|
return [];
|
|
14364
14417
|
}
|
|
14365
|
-
|
|
14418
|
+
const preserveFiles = config.preserveFiles || [];
|
|
14419
|
+
const validatedPaths = [];
|
|
14420
|
+
for (const filePath of preserveFiles) {
|
|
14421
|
+
const validation = validatePreserveFilePath(filePath, options.targetDir);
|
|
14422
|
+
if (validation.valid) {
|
|
14423
|
+
validatedPaths.push(filePath);
|
|
14424
|
+
} else {
|
|
14425
|
+
warn("preserve_files.invalid_path", {
|
|
14426
|
+
path: filePath,
|
|
14427
|
+
reason: validation.reason ?? "Invalid path"
|
|
14428
|
+
});
|
|
14429
|
+
}
|
|
14430
|
+
}
|
|
14431
|
+
return validatedPaths;
|
|
14366
14432
|
}
|
|
14367
|
-
function resolveCustomizations(customizations, configPreserveFiles) {
|
|
14368
|
-
|
|
14369
|
-
|
|
14433
|
+
function resolveCustomizations(customizations, configPreserveFiles, targetDir) {
|
|
14434
|
+
const validatedManifestFiles = [];
|
|
14435
|
+
if (customizations && customizations.preserveFiles.length > 0) {
|
|
14436
|
+
for (const filePath of customizations.preserveFiles) {
|
|
14437
|
+
const validation = validatePreserveFilePath(filePath, targetDir);
|
|
14438
|
+
if (validation.valid) {
|
|
14439
|
+
validatedManifestFiles.push(filePath);
|
|
14440
|
+
} else {
|
|
14441
|
+
warn("preserve_files.invalid_path", {
|
|
14442
|
+
path: filePath,
|
|
14443
|
+
reason: validation.reason ?? "Invalid path",
|
|
14444
|
+
source: "manifest"
|
|
14445
|
+
});
|
|
14446
|
+
}
|
|
14447
|
+
}
|
|
14370
14448
|
}
|
|
14371
|
-
if (
|
|
14372
|
-
customizations.
|
|
14373
|
-
|
|
14374
|
-
|
|
14375
|
-
|
|
14449
|
+
if (validatedManifestFiles.length === 0 && configPreserveFiles.length === 0) {
|
|
14450
|
+
return customizations && customizations.modifiedFiles.length > 0 ? customizations : null;
|
|
14451
|
+
}
|
|
14452
|
+
if (validatedManifestFiles.length > 0 && configPreserveFiles.length > 0) {
|
|
14453
|
+
const merged = customizations || {
|
|
14454
|
+
modifiedFiles: [],
|
|
14455
|
+
preserveFiles: [],
|
|
14456
|
+
customComponents: [],
|
|
14457
|
+
lastUpdated: new Date().toISOString()
|
|
14458
|
+
};
|
|
14459
|
+
merged.preserveFiles = [...new Set([...validatedManifestFiles, ...configPreserveFiles])];
|
|
14460
|
+
return merged;
|
|
14376
14461
|
}
|
|
14377
14462
|
if (configPreserveFiles.length > 0) {
|
|
14378
14463
|
return {
|
|
14379
|
-
modifiedFiles: [],
|
|
14464
|
+
modifiedFiles: customizations?.modifiedFiles || [],
|
|
14380
14465
|
preserveFiles: configPreserveFiles,
|
|
14381
|
-
customComponents: [],
|
|
14466
|
+
customComponents: customizations?.customComponents || [],
|
|
14382
14467
|
lastUpdated: new Date().toISOString()
|
|
14383
14468
|
};
|
|
14384
14469
|
}
|
|
14385
|
-
|
|
14470
|
+
if (customizations) {
|
|
14471
|
+
customizations.preserveFiles = validatedManifestFiles;
|
|
14472
|
+
return customizations;
|
|
14473
|
+
}
|
|
14474
|
+
return null;
|
|
14386
14475
|
}
|
|
14387
14476
|
async function updateEntryDoc(targetDir, provider, config, options) {
|
|
14388
14477
|
const layout = getProviderLayout(provider);
|
|
@@ -14437,7 +14526,7 @@ async function update(options) {
|
|
|
14437
14526
|
await handleBackupIfRequested(options.targetDir, provider, !!options.backup, result);
|
|
14438
14527
|
const manifestCustomizations = await resolveManifestCustomizations(options, options.targetDir);
|
|
14439
14528
|
const configPreserveFiles = resolveConfigPreserveFiles(options, config);
|
|
14440
|
-
const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles);
|
|
14529
|
+
const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles, options.targetDir);
|
|
14441
14530
|
const components = options.components || getAllUpdateComponents();
|
|
14442
14531
|
await updateAllComponents(options.targetDir, provider, components, updateCheck, customizations, options, result, config);
|
|
14443
14532
|
if (!options.components || options.components.length === 0) {
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,38 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
|
5
5
|
import { join as join2 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// src/utils/fs.ts
|
|
8
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
function validatePreserveFilePath(filePath, projectRoot) {
|
|
11
|
+
if (!filePath || filePath.trim() === "") {
|
|
12
|
+
return {
|
|
13
|
+
valid: false,
|
|
14
|
+
reason: "Path cannot be empty"
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
if (isAbsolute(filePath)) {
|
|
18
|
+
return {
|
|
19
|
+
valid: false,
|
|
20
|
+
reason: "Absolute paths are not allowed"
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const normalizedPath = normalize(filePath);
|
|
24
|
+
if (normalizedPath.startsWith("..")) {
|
|
25
|
+
return {
|
|
26
|
+
valid: false,
|
|
27
|
+
reason: "Path cannot traverse outside project root"
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const resolvedPath = resolve(projectRoot, normalizedPath);
|
|
31
|
+
const relativePath = relative(projectRoot, resolvedPath);
|
|
32
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
33
|
+
return {
|
|
34
|
+
valid: false,
|
|
35
|
+
reason: "Resolved path escapes project root"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { valid: true };
|
|
39
|
+
}
|
|
10
40
|
async function fileExists(path) {
|
|
11
41
|
const fs = await import("node:fs/promises");
|
|
12
42
|
try {
|
|
@@ -386,7 +416,7 @@ async function loadConfig(targetDir) {
|
|
|
386
416
|
if (await fileExists(configPath)) {
|
|
387
417
|
try {
|
|
388
418
|
const config = await readJsonFile(configPath);
|
|
389
|
-
const merged = mergeConfig(getDefaultConfig(), config);
|
|
419
|
+
const merged = mergeConfig(getDefaultConfig(), config, targetDir);
|
|
390
420
|
if (merged.configVersion < CURRENT_CONFIG_VERSION) {
|
|
391
421
|
const migrated = migrateConfig(merged);
|
|
392
422
|
await saveConfig(targetDir, migrated);
|
|
@@ -415,7 +445,30 @@ function deduplicateCustomComponents(components) {
|
|
|
415
445
|
}
|
|
416
446
|
return [...seen.values()];
|
|
417
447
|
}
|
|
418
|
-
function mergeConfig(defaults, overrides) {
|
|
448
|
+
function mergeConfig(defaults, overrides, targetDir) {
|
|
449
|
+
let mergedPreserveFiles;
|
|
450
|
+
if (overrides.preserveFiles) {
|
|
451
|
+
const allFiles = [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])];
|
|
452
|
+
if (targetDir) {
|
|
453
|
+
const validatedFiles = [];
|
|
454
|
+
for (const filePath of allFiles) {
|
|
455
|
+
const validation = validatePreserveFilePath(filePath, targetDir);
|
|
456
|
+
if (validation.valid) {
|
|
457
|
+
validatedFiles.push(filePath);
|
|
458
|
+
} else {
|
|
459
|
+
warn("config.invalid_preserve_path", {
|
|
460
|
+
path: filePath,
|
|
461
|
+
reason: validation.reason ?? "Invalid path"
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
mergedPreserveFiles = validatedFiles;
|
|
466
|
+
} else {
|
|
467
|
+
mergedPreserveFiles = allFiles;
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
mergedPreserveFiles = defaults.preserveFiles;
|
|
471
|
+
}
|
|
419
472
|
return {
|
|
420
473
|
...defaults,
|
|
421
474
|
...overrides,
|
|
@@ -429,7 +482,7 @@ function mergeConfig(defaults, overrides) {
|
|
|
429
482
|
...defaults.agents,
|
|
430
483
|
...overrides.agents
|
|
431
484
|
},
|
|
432
|
-
preserveFiles:
|
|
485
|
+
preserveFiles: mergedPreserveFiles,
|
|
433
486
|
customComponents: overrides.customComponents ? deduplicateCustomComponents([
|
|
434
487
|
...defaults.customComponents || [],
|
|
435
488
|
...overrides.customComponents
|
|
@@ -1075,27 +1128,63 @@ function resolveConfigPreserveFiles(options, config) {
|
|
|
1075
1128
|
if (options.forceOverwriteAll) {
|
|
1076
1129
|
return [];
|
|
1077
1130
|
}
|
|
1078
|
-
|
|
1131
|
+
const preserveFiles = config.preserveFiles || [];
|
|
1132
|
+
const validatedPaths = [];
|
|
1133
|
+
for (const filePath of preserveFiles) {
|
|
1134
|
+
const validation = validatePreserveFilePath(filePath, options.targetDir);
|
|
1135
|
+
if (validation.valid) {
|
|
1136
|
+
validatedPaths.push(filePath);
|
|
1137
|
+
} else {
|
|
1138
|
+
warn("preserve_files.invalid_path", {
|
|
1139
|
+
path: filePath,
|
|
1140
|
+
reason: validation.reason ?? "Invalid path"
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return validatedPaths;
|
|
1079
1145
|
}
|
|
1080
|
-
function resolveCustomizations(customizations, configPreserveFiles) {
|
|
1081
|
-
|
|
1082
|
-
|
|
1146
|
+
function resolveCustomizations(customizations, configPreserveFiles, targetDir) {
|
|
1147
|
+
const validatedManifestFiles = [];
|
|
1148
|
+
if (customizations && customizations.preserveFiles.length > 0) {
|
|
1149
|
+
for (const filePath of customizations.preserveFiles) {
|
|
1150
|
+
const validation = validatePreserveFilePath(filePath, targetDir);
|
|
1151
|
+
if (validation.valid) {
|
|
1152
|
+
validatedManifestFiles.push(filePath);
|
|
1153
|
+
} else {
|
|
1154
|
+
warn("preserve_files.invalid_path", {
|
|
1155
|
+
path: filePath,
|
|
1156
|
+
reason: validation.reason ?? "Invalid path",
|
|
1157
|
+
source: "manifest"
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1083
1161
|
}
|
|
1084
|
-
if (
|
|
1085
|
-
customizations.
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1162
|
+
if (validatedManifestFiles.length === 0 && configPreserveFiles.length === 0) {
|
|
1163
|
+
return customizations && customizations.modifiedFiles.length > 0 ? customizations : null;
|
|
1164
|
+
}
|
|
1165
|
+
if (validatedManifestFiles.length > 0 && configPreserveFiles.length > 0) {
|
|
1166
|
+
const merged = customizations || {
|
|
1167
|
+
modifiedFiles: [],
|
|
1168
|
+
preserveFiles: [],
|
|
1169
|
+
customComponents: [],
|
|
1170
|
+
lastUpdated: new Date().toISOString()
|
|
1171
|
+
};
|
|
1172
|
+
merged.preserveFiles = [...new Set([...validatedManifestFiles, ...configPreserveFiles])];
|
|
1173
|
+
return merged;
|
|
1089
1174
|
}
|
|
1090
1175
|
if (configPreserveFiles.length > 0) {
|
|
1091
1176
|
return {
|
|
1092
|
-
modifiedFiles: [],
|
|
1177
|
+
modifiedFiles: customizations?.modifiedFiles || [],
|
|
1093
1178
|
preserveFiles: configPreserveFiles,
|
|
1094
|
-
customComponents: [],
|
|
1179
|
+
customComponents: customizations?.customComponents || [],
|
|
1095
1180
|
lastUpdated: new Date().toISOString()
|
|
1096
1181
|
};
|
|
1097
1182
|
}
|
|
1098
|
-
|
|
1183
|
+
if (customizations) {
|
|
1184
|
+
customizations.preserveFiles = validatedManifestFiles;
|
|
1185
|
+
return customizations;
|
|
1186
|
+
}
|
|
1187
|
+
return null;
|
|
1099
1188
|
}
|
|
1100
1189
|
async function updateEntryDoc(targetDir, provider, config, options) {
|
|
1101
1190
|
const layout = getProviderLayout(provider);
|
|
@@ -1150,7 +1239,7 @@ async function update(options) {
|
|
|
1150
1239
|
await handleBackupIfRequested(options.targetDir, provider, !!options.backup, result);
|
|
1151
1240
|
const manifestCustomizations = await resolveManifestCustomizations(options, options.targetDir);
|
|
1152
1241
|
const configPreserveFiles = resolveConfigPreserveFiles(options, config);
|
|
1153
|
-
const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles);
|
|
1242
|
+
const customizations = resolveCustomizations(manifestCustomizations, configPreserveFiles, options.targetDir);
|
|
1154
1243
|
const components = options.components || getAllUpdateComponents();
|
|
1155
1244
|
await updateAllComponents(options.targetDir, provider, components, updateCheck, customizations, options, result, config);
|
|
1156
1245
|
if (!options.components || options.components.length === 0) {
|
package/package.json
CHANGED
|
@@ -25,104 +25,22 @@ public class UserService {
|
|
|
25
25
|
### 3. REST API Design
|
|
26
26
|
@RestController + @RequestMapping. Use @Validated for input, ResponseEntity for responses, proper HTTP status codes.
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
@RestController
|
|
30
|
-
@RequestMapping("/api/v1/users")
|
|
31
|
-
@RequiredArgsConstructor
|
|
32
|
-
public class UserController {
|
|
33
|
-
private final UserService userService;
|
|
34
|
-
|
|
35
|
-
@GetMapping("/{id}")
|
|
36
|
-
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
|
|
37
|
-
return ResponseEntity.ok(userService.findById(id));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
@PostMapping
|
|
41
|
-
@ResponseStatus(HttpStatus.CREATED)
|
|
42
|
-
public UserResponse createUser(@Valid @RequestBody UserRequest request) {
|
|
43
|
-
return userService.create(request);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
```
|
|
28
|
+
See `examples/controller-example.java` for reference implementation.
|
|
47
29
|
|
|
48
30
|
### 4. Service Layer
|
|
49
31
|
Business logic in services. @Transactional boundaries at service level. Interface + implementation pattern.
|
|
50
32
|
|
|
51
|
-
|
|
52
|
-
@Service
|
|
53
|
-
@Transactional(readOnly = true)
|
|
54
|
-
@RequiredArgsConstructor
|
|
55
|
-
public class UserServiceImpl implements UserService {
|
|
56
|
-
private final UserRepository userRepository;
|
|
57
|
-
|
|
58
|
-
@Override
|
|
59
|
-
public UserResponse findById(Long id) {
|
|
60
|
-
User user = userRepository.findById(id)
|
|
61
|
-
.orElseThrow(() -> new UserNotFoundException(id));
|
|
62
|
-
return userMapper.toResponse(user);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
@Override
|
|
66
|
-
@Transactional
|
|
67
|
-
public UserResponse create(UserRequest request) {
|
|
68
|
-
User user = userMapper.toEntity(request);
|
|
69
|
-
return userMapper.toResponse(userRepository.save(user));
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
```
|
|
33
|
+
See `examples/service-example.java` for reference implementation.
|
|
73
34
|
|
|
74
35
|
### 5. Data Access
|
|
75
36
|
Spring Data JPA. @Query or method naming for custom queries. @Entity with proper JPA annotations.
|
|
76
37
|
|
|
77
|
-
|
|
78
|
-
public interface UserRepository extends JpaRepository<User, Long> {
|
|
79
|
-
Optional<User> findByEmail(String email);
|
|
80
|
-
|
|
81
|
-
@Query("SELECT u FROM User u WHERE u.status = :status")
|
|
82
|
-
List<User> findByStatus(@Param("status") UserStatus status);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
@Entity
|
|
86
|
-
@Table(name = "users")
|
|
87
|
-
@Getter
|
|
88
|
-
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
89
|
-
public class User {
|
|
90
|
-
@Id
|
|
91
|
-
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
92
|
-
private Long id;
|
|
93
|
-
|
|
94
|
-
@Column(nullable = false, unique = true)
|
|
95
|
-
private String email;
|
|
96
|
-
|
|
97
|
-
@Enumerated(EnumType.STRING)
|
|
98
|
-
private UserStatus status;
|
|
99
|
-
}
|
|
100
|
-
```
|
|
38
|
+
See `examples/repository-example.java` and `examples/entity-example.java` for reference implementations.
|
|
101
39
|
|
|
102
40
|
### 6. Exception Handling
|
|
103
41
|
@RestControllerAdvice for global handling. Domain-specific exceptions with proper HTTP status mapping.
|
|
104
42
|
|
|
105
|
-
|
|
106
|
-
@RestControllerAdvice
|
|
107
|
-
public class GlobalExceptionHandler {
|
|
108
|
-
@ExceptionHandler(UserNotFoundException.class)
|
|
109
|
-
@ResponseStatus(HttpStatus.NOT_FOUND)
|
|
110
|
-
public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
|
|
111
|
-
return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
@ExceptionHandler(MethodArgumentNotValidException.class)
|
|
115
|
-
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
|
116
|
-
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
|
|
117
|
-
List<String> errors = ex.getBindingResult()
|
|
118
|
-
.getFieldErrors()
|
|
119
|
-
.stream()
|
|
120
|
-
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
|
121
|
-
.toList();
|
|
122
|
-
return new ErrorResponse("VALIDATION_ERROR", errors);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
```
|
|
43
|
+
See `examples/exception-handler-example.java` for reference implementation.
|
|
126
44
|
|
|
127
45
|
### 7. Configuration
|
|
128
46
|
Profile-based: application-{profile}.yml. @ConfigurationProperties for type-safe config. Externalize sensitive values.
|
|
@@ -138,80 +56,17 @@ spring:
|
|
|
138
56
|
password: ${DATABASE_PASSWORD}
|
|
139
57
|
```
|
|
140
58
|
|
|
141
|
-
|
|
142
|
-
@Configuration
|
|
143
|
-
@ConfigurationProperties(prefix = "app")
|
|
144
|
-
@Validated
|
|
145
|
-
public class AppProperties {
|
|
146
|
-
@NotBlank
|
|
147
|
-
private String name;
|
|
148
|
-
|
|
149
|
-
@Min(1)
|
|
150
|
-
private int maxConnections;
|
|
151
|
-
}
|
|
152
|
-
```
|
|
59
|
+
See `examples/config-properties-example.java` for type-safe configuration properties.
|
|
153
60
|
|
|
154
61
|
### 8. Security
|
|
155
62
|
Spring Security with SecurityFilterChain. Externalize secrets. Proper authentication/authorization patterns.
|
|
156
63
|
|
|
157
|
-
|
|
158
|
-
@Configuration
|
|
159
|
-
@EnableWebSecurity
|
|
160
|
-
@RequiredArgsConstructor
|
|
161
|
-
public class SecurityConfig {
|
|
162
|
-
@Bean
|
|
163
|
-
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
164
|
-
return http
|
|
165
|
-
.csrf(csrf -> csrf.disable())
|
|
166
|
-
.sessionManagement(session ->
|
|
167
|
-
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
168
|
-
.authorizeHttpRequests(auth -> auth
|
|
169
|
-
.requestMatchers("/api/v1/auth/**").permitAll()
|
|
170
|
-
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
|
171
|
-
.anyRequest().authenticated())
|
|
172
|
-
.build();
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
```
|
|
64
|
+
See `examples/security-config-example.java` for reference implementation.
|
|
176
65
|
|
|
177
66
|
### 9. Testing
|
|
178
67
|
@WebMvcTest (controller), @DataJpaTest (repository), @SpringBootTest (integration), @MockBean for mocking.
|
|
179
68
|
|
|
180
|
-
|
|
181
|
-
// Controller test
|
|
182
|
-
@WebMvcTest(UserController.class)
|
|
183
|
-
class UserControllerTest {
|
|
184
|
-
@Autowired
|
|
185
|
-
private MockMvc mockMvc;
|
|
186
|
-
|
|
187
|
-
@MockBean
|
|
188
|
-
private UserService userService;
|
|
189
|
-
|
|
190
|
-
@Test
|
|
191
|
-
void getUser_shouldReturnUser() throws Exception {
|
|
192
|
-
given(userService.findById(1L))
|
|
193
|
-
.willReturn(new UserResponse(1L, "test@example.com"));
|
|
194
|
-
|
|
195
|
-
mockMvc.perform(get("/api/v1/users/1"))
|
|
196
|
-
.andExpect(status().isOk())
|
|
197
|
-
.andExpect(jsonPath("$.email").value("test@example.com"));
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Repository test
|
|
202
|
-
@DataJpaTest
|
|
203
|
-
class UserRepositoryTest {
|
|
204
|
-
@Autowired
|
|
205
|
-
private UserRepository userRepository;
|
|
206
|
-
|
|
207
|
-
@Test
|
|
208
|
-
void findByEmail_shouldReturnUser() {
|
|
209
|
-
User user = userRepository.save(new User("test@example.com"));
|
|
210
|
-
Optional<User> found = userRepository.findByEmail("test@example.com");
|
|
211
|
-
assertThat(found).isPresent();
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
```
|
|
69
|
+
See `examples/controller-test-example.java` and `examples/repository-test-example.java` for reference implementations.
|
|
215
70
|
|
|
216
71
|
## Application
|
|
217
72
|
|
package/templates/.claude/skills/springboot-best-practices/examples/config-properties-example.java
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package com.example.demo.config;
|
|
2
|
+
|
|
3
|
+
import jakarta.validation.constraints.Min;
|
|
4
|
+
import jakarta.validation.constraints.NotBlank;
|
|
5
|
+
import lombok.Getter;
|
|
6
|
+
import lombok.Setter;
|
|
7
|
+
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
8
|
+
import org.springframework.context.annotation.Configuration;
|
|
9
|
+
import org.springframework.validation.annotation.Validated;
|
|
10
|
+
|
|
11
|
+
@Configuration
|
|
12
|
+
@ConfigurationProperties(prefix = "app")
|
|
13
|
+
@Validated
|
|
14
|
+
@Getter
|
|
15
|
+
@Setter
|
|
16
|
+
public class AppProperties {
|
|
17
|
+
@NotBlank
|
|
18
|
+
private String name;
|
|
19
|
+
|
|
20
|
+
@Min(1)
|
|
21
|
+
private int maxConnections;
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package com.example.demo.controller;
|
|
2
|
+
|
|
3
|
+
import com.example.demo.dto.UserRequest;
|
|
4
|
+
import com.example.demo.dto.UserResponse;
|
|
5
|
+
import com.example.demo.service.UserService;
|
|
6
|
+
import jakarta.validation.Valid;
|
|
7
|
+
import lombok.RequiredArgsConstructor;
|
|
8
|
+
import org.springframework.http.HttpStatus;
|
|
9
|
+
import org.springframework.http.ResponseEntity;
|
|
10
|
+
import org.springframework.web.bind.annotation.*;
|
|
11
|
+
|
|
12
|
+
@RestController
|
|
13
|
+
@RequestMapping("/api/v1/users")
|
|
14
|
+
@RequiredArgsConstructor
|
|
15
|
+
public class UserController {
|
|
16
|
+
private final UserService userService;
|
|
17
|
+
|
|
18
|
+
@GetMapping("/{id}")
|
|
19
|
+
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
|
|
20
|
+
return ResponseEntity.ok(userService.findById(id));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@PostMapping
|
|
24
|
+
@ResponseStatus(HttpStatus.CREATED)
|
|
25
|
+
public UserResponse createUser(@Valid @RequestBody UserRequest request) {
|
|
26
|
+
return userService.create(request);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/templates/.claude/skills/springboot-best-practices/examples/controller-test-example.java
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.example.demo.controller;
|
|
2
|
+
|
|
3
|
+
import com.example.demo.dto.UserResponse;
|
|
4
|
+
import com.example.demo.service.UserService;
|
|
5
|
+
import org.junit.jupiter.api.Test;
|
|
6
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
7
|
+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
|
8
|
+
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
9
|
+
import org.springframework.test.web.servlet.MockMvc;
|
|
10
|
+
|
|
11
|
+
import static org.mockito.BDDMockito.given;
|
|
12
|
+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
13
|
+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
14
|
+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
15
|
+
|
|
16
|
+
@WebMvcTest(UserController.class)
|
|
17
|
+
class UserControllerTest {
|
|
18
|
+
@Autowired
|
|
19
|
+
private MockMvc mockMvc;
|
|
20
|
+
|
|
21
|
+
@MockBean
|
|
22
|
+
private UserService userService;
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
void getUser_shouldReturnUser() throws Exception {
|
|
26
|
+
given(userService.findById(1L))
|
|
27
|
+
.willReturn(new UserResponse(1L, "test@example.com"));
|
|
28
|
+
|
|
29
|
+
mockMvc.perform(get("/api/v1/users/1"))
|
|
30
|
+
.andExpect(status().isOk())
|
|
31
|
+
.andExpect(jsonPath("$.email").value("test@example.com"));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package com.example.demo.entity;
|
|
2
|
+
|
|
3
|
+
import jakarta.persistence.*;
|
|
4
|
+
import lombok.AccessLevel;
|
|
5
|
+
import lombok.Getter;
|
|
6
|
+
import lombok.NoArgsConstructor;
|
|
7
|
+
|
|
8
|
+
@Entity
|
|
9
|
+
@Table(name = "users")
|
|
10
|
+
@Getter
|
|
11
|
+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
12
|
+
public class User {
|
|
13
|
+
@Id
|
|
14
|
+
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
15
|
+
private Long id;
|
|
16
|
+
|
|
17
|
+
@Column(nullable = false, unique = true)
|
|
18
|
+
private String email;
|
|
19
|
+
|
|
20
|
+
@Enumerated(EnumType.STRING)
|
|
21
|
+
private UserStatus status;
|
|
22
|
+
}
|
package/templates/.claude/skills/springboot-best-practices/examples/exception-handler-example.java
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package com.example.demo.exception;
|
|
2
|
+
|
|
3
|
+
import com.example.demo.dto.ErrorResponse;
|
|
4
|
+
import org.springframework.http.HttpStatus;
|
|
5
|
+
import org.springframework.web.bind.MethodArgumentNotValidException;
|
|
6
|
+
import org.springframework.web.bind.annotation.ExceptionHandler;
|
|
7
|
+
import org.springframework.web.bind.annotation.ResponseStatus;
|
|
8
|
+
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|
9
|
+
|
|
10
|
+
import java.util.List;
|
|
11
|
+
|
|
12
|
+
@RestControllerAdvice
|
|
13
|
+
public class GlobalExceptionHandler {
|
|
14
|
+
@ExceptionHandler(UserNotFoundException.class)
|
|
15
|
+
@ResponseStatus(HttpStatus.NOT_FOUND)
|
|
16
|
+
public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
|
|
17
|
+
return new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@ExceptionHandler(MethodArgumentNotValidException.class)
|
|
21
|
+
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
|
22
|
+
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
|
|
23
|
+
List<String> errors = ex.getBindingResult()
|
|
24
|
+
.getFieldErrors()
|
|
25
|
+
.stream()
|
|
26
|
+
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
|
27
|
+
.toList();
|
|
28
|
+
return new ErrorResponse("VALIDATION_ERROR", errors);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.example.demo.repository;
|
|
2
|
+
|
|
3
|
+
import com.example.demo.entity.User;
|
|
4
|
+
import com.example.demo.entity.UserStatus;
|
|
5
|
+
import org.springframework.data.jpa.repository.JpaRepository;
|
|
6
|
+
import org.springframework.data.jpa.repository.Query;
|
|
7
|
+
import org.springframework.data.repository.query.Param;
|
|
8
|
+
|
|
9
|
+
import java.util.List;
|
|
10
|
+
import java.util.Optional;
|
|
11
|
+
|
|
12
|
+
public interface UserRepository extends JpaRepository<User, Long> {
|
|
13
|
+
Optional<User> findByEmail(String email);
|
|
14
|
+
|
|
15
|
+
@Query("SELECT u FROM User u WHERE u.status = :status")
|
|
16
|
+
List<User> findByStatus(@Param("status") UserStatus status);
|
|
17
|
+
}
|
package/templates/.claude/skills/springboot-best-practices/examples/repository-test-example.java
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package com.example.demo.repository;
|
|
2
|
+
|
|
3
|
+
import com.example.demo.entity.User;
|
|
4
|
+
import org.junit.jupiter.api.Test;
|
|
5
|
+
import org.springframework.beans.factory.annotation.Autowired;
|
|
6
|
+
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
|
7
|
+
|
|
8
|
+
import java.util.Optional;
|
|
9
|
+
|
|
10
|
+
import static org.assertj.core.api.Assertions.assertThat;
|
|
11
|
+
|
|
12
|
+
@DataJpaTest
|
|
13
|
+
class UserRepositoryTest {
|
|
14
|
+
@Autowired
|
|
15
|
+
private UserRepository userRepository;
|
|
16
|
+
|
|
17
|
+
@Test
|
|
18
|
+
void findByEmail_shouldReturnUser() {
|
|
19
|
+
User user = userRepository.save(new User("test@example.com"));
|
|
20
|
+
Optional<User> found = userRepository.findByEmail("test@example.com");
|
|
21
|
+
assertThat(found).isPresent();
|
|
22
|
+
}
|
|
23
|
+
}
|
package/templates/.claude/skills/springboot-best-practices/examples/security-config-example.java
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package com.example.demo.config;
|
|
2
|
+
|
|
3
|
+
import lombok.RequiredArgsConstructor;
|
|
4
|
+
import org.springframework.context.annotation.Bean;
|
|
5
|
+
import org.springframework.context.annotation.Configuration;
|
|
6
|
+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
7
|
+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
|
8
|
+
import org.springframework.security.config.http.SessionCreationPolicy;
|
|
9
|
+
import org.springframework.security.web.SecurityFilterChain;
|
|
10
|
+
|
|
11
|
+
@Configuration
|
|
12
|
+
@EnableWebSecurity
|
|
13
|
+
@RequiredArgsConstructor
|
|
14
|
+
public class SecurityConfig {
|
|
15
|
+
@Bean
|
|
16
|
+
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
17
|
+
return http
|
|
18
|
+
.csrf(csrf -> csrf.disable())
|
|
19
|
+
.sessionManagement(session ->
|
|
20
|
+
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
21
|
+
.authorizeHttpRequests(auth -> auth
|
|
22
|
+
.requestMatchers("/api/v1/auth/**").permitAll()
|
|
23
|
+
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
|
24
|
+
.anyRequest().authenticated())
|
|
25
|
+
.build();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.example.demo.service;
|
|
2
|
+
|
|
3
|
+
import com.example.demo.dto.UserRequest;
|
|
4
|
+
import com.example.demo.dto.UserResponse;
|
|
5
|
+
import com.example.demo.entity.User;
|
|
6
|
+
import com.example.demo.exception.UserNotFoundException;
|
|
7
|
+
import com.example.demo.mapper.UserMapper;
|
|
8
|
+
import com.example.demo.repository.UserRepository;
|
|
9
|
+
import lombok.RequiredArgsConstructor;
|
|
10
|
+
import org.springframework.stereotype.Service;
|
|
11
|
+
import org.springframework.transaction.annotation.Transactional;
|
|
12
|
+
|
|
13
|
+
@Service
|
|
14
|
+
@Transactional(readOnly = true)
|
|
15
|
+
@RequiredArgsConstructor
|
|
16
|
+
public class UserServiceImpl implements UserService {
|
|
17
|
+
private final UserRepository userRepository;
|
|
18
|
+
private final UserMapper userMapper;
|
|
19
|
+
|
|
20
|
+
@Override
|
|
21
|
+
public UserResponse findById(Long id) {
|
|
22
|
+
User user = userRepository.findById(id)
|
|
23
|
+
.orElseThrow(() -> new UserNotFoundException(id));
|
|
24
|
+
return userMapper.toResponse(user);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Override
|
|
28
|
+
@Transactional
|
|
29
|
+
public UserResponse create(UserRequest request) {
|
|
30
|
+
User user = userMapper.toEntity(request);
|
|
31
|
+
return userMapper.toResponse(userRepository.save(user));
|
|
32
|
+
}
|
|
33
|
+
}
|