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 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: overrides.preserveFiles ? [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])] : defaults.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
- return config.preserveFiles || [];
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
- if (!customizations && configPreserveFiles.length === 0) {
14369
- return null;
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 (customizations && configPreserveFiles.length > 0) {
14372
- customizations.preserveFiles = [
14373
- ...new Set([...customizations.preserveFiles, ...configPreserveFiles])
14374
- ];
14375
- return customizations;
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
- return customizations;
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: overrides.preserveFiles ? [...new Set([...defaults.preserveFiles || [], ...overrides.preserveFiles])] : defaults.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
- return config.preserveFiles || [];
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
- if (!customizations && configPreserveFiles.length === 0) {
1082
- return null;
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 (customizations && configPreserveFiles.length > 0) {
1085
- customizations.preserveFiles = [
1086
- ...new Set([...customizations.preserveFiles, ...configPreserveFiles])
1087
- ];
1088
- return customizations;
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
- return customizations;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-customcode",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Batteries-included agent harness for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- ```java
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
- ```java
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
- ```java
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
- ```java
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
- ```java
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
- ```java
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
- ```java
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
 
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }