autoworkflow 3.1.4 → 3.5.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/.claude/commands/analyze.md +19 -0
- package/.claude/commands/audit.md +174 -11
- package/.claude/commands/build.md +39 -0
- package/.claude/commands/commit.md +25 -0
- package/.claude/commands/fix.md +23 -0
- package/.claude/commands/plan.md +18 -0
- package/.claude/commands/suggest.md +23 -0
- package/.claude/commands/verify.md +18 -0
- package/.claude/hooks/post-bash-router.sh +20 -0
- package/.claude/hooks/post-commit.sh +140 -0
- package/.claude/hooks/pre-edit.sh +129 -0
- package/.claude/hooks/session-check.sh +79 -0
- package/.claude/settings.json +40 -6
- package/.claude/settings.local.json +3 -1
- package/.claude/skills/actix.md +337 -0
- package/.claude/skills/alembic.md +504 -0
- package/.claude/skills/angular.md +237 -0
- package/.claude/skills/api-design.md +187 -0
- package/.claude/skills/aspnet-core.md +377 -0
- package/.claude/skills/astro.md +245 -0
- package/.claude/skills/auth-clerk.md +327 -0
- package/.claude/skills/auth-firebase.md +367 -0
- package/.claude/skills/auth-nextauth.md +359 -0
- package/.claude/skills/auth-supabase.md +368 -0
- package/.claude/skills/axum.md +386 -0
- package/.claude/skills/blazor.md +456 -0
- package/.claude/skills/chi.md +348 -0
- package/.claude/skills/code-review.md +133 -0
- package/.claude/skills/csharp.md +296 -0
- package/.claude/skills/css-modules.md +325 -0
- package/.claude/skills/cypress.md +343 -0
- package/.claude/skills/debugging.md +133 -0
- package/.claude/skills/diesel.md +392 -0
- package/.claude/skills/django.md +301 -0
- package/.claude/skills/docker.md +319 -0
- package/.claude/skills/doctrine.md +473 -0
- package/.claude/skills/documentation.md +182 -0
- package/.claude/skills/dotnet.md +409 -0
- package/.claude/skills/drizzle.md +293 -0
- package/.claude/skills/echo.md +321 -0
- package/.claude/skills/eloquent.md +256 -0
- package/.claude/skills/emotion.md +426 -0
- package/.claude/skills/entity-framework.md +370 -0
- package/.claude/skills/express.md +316 -0
- package/.claude/skills/fastapi.md +329 -0
- package/.claude/skills/fastify.md +299 -0
- package/.claude/skills/fiber.md +315 -0
- package/.claude/skills/flask.md +322 -0
- package/.claude/skills/gin.md +342 -0
- package/.claude/skills/git.md +116 -0
- package/.claude/skills/github-actions.md +353 -0
- package/.claude/skills/go.md +377 -0
- package/.claude/skills/gorm.md +409 -0
- package/.claude/skills/graphql.md +478 -0
- package/.claude/skills/hibernate.md +379 -0
- package/.claude/skills/hono.md +306 -0
- package/.claude/skills/java.md +400 -0
- package/.claude/skills/jest.md +313 -0
- package/.claude/skills/jpa.md +282 -0
- package/.claude/skills/kotlin.md +347 -0
- package/.claude/skills/kubernetes.md +363 -0
- package/.claude/skills/laravel.md +414 -0
- package/.claude/skills/mcp-browser.md +320 -0
- package/.claude/skills/mcp-database.md +219 -0
- package/.claude/skills/mcp-fetch.md +241 -0
- package/.claude/skills/mcp-filesystem.md +204 -0
- package/.claude/skills/mcp-github.md +217 -0
- package/.claude/skills/mcp-memory.md +240 -0
- package/.claude/skills/mcp-search.md +218 -0
- package/.claude/skills/mcp-slack.md +262 -0
- package/.claude/skills/micronaut.md +388 -0
- package/.claude/skills/mongodb.md +319 -0
- package/.claude/skills/mongoose.md +355 -0
- package/.claude/skills/mysql.md +281 -0
- package/.claude/skills/nestjs.md +335 -0
- package/.claude/skills/nextjs-app-router.md +260 -0
- package/.claude/skills/nextjs-pages.md +172 -0
- package/.claude/skills/nuxt.md +202 -0
- package/.claude/skills/openapi.md +489 -0
- package/.claude/skills/performance.md +199 -0
- package/.claude/skills/php.md +398 -0
- package/.claude/skills/playwright.md +371 -0
- package/.claude/skills/postgresql.md +257 -0
- package/.claude/skills/prisma.md +293 -0
- package/.claude/skills/pydantic.md +304 -0
- package/.claude/skills/pytest.md +313 -0
- package/.claude/skills/python.md +272 -0
- package/.claude/skills/quarkus.md +377 -0
- package/.claude/skills/react.md +230 -0
- package/.claude/skills/redis.md +391 -0
- package/.claude/skills/refactoring.md +143 -0
- package/.claude/skills/remix.md +246 -0
- package/.claude/skills/rest-api.md +490 -0
- package/.claude/skills/rocket.md +366 -0
- package/.claude/skills/rust.md +341 -0
- package/.claude/skills/sass.md +380 -0
- package/.claude/skills/sea-orm.md +382 -0
- package/.claude/skills/security.md +167 -0
- package/.claude/skills/sequelize.md +395 -0
- package/.claude/skills/spring-boot.md +416 -0
- package/.claude/skills/sqlalchemy.md +269 -0
- package/.claude/skills/sqlx-rust.md +408 -0
- package/.claude/skills/state-jotai.md +346 -0
- package/.claude/skills/state-mobx.md +353 -0
- package/.claude/skills/state-pinia.md +431 -0
- package/.claude/skills/state-redux.md +337 -0
- package/.claude/skills/state-tanstack-query.md +434 -0
- package/.claude/skills/state-zustand.md +340 -0
- package/.claude/skills/styled-components.md +403 -0
- package/.claude/skills/svelte.md +238 -0
- package/.claude/skills/sveltekit.md +207 -0
- package/.claude/skills/symfony.md +437 -0
- package/.claude/skills/tailwind.md +279 -0
- package/.claude/skills/terraform.md +394 -0
- package/.claude/skills/testing-library.md +371 -0
- package/.claude/skills/trpc.md +426 -0
- package/.claude/skills/typeorm.md +368 -0
- package/.claude/skills/vitest.md +330 -0
- package/.claude/skills/vue.md +202 -0
- package/.claude/skills/warp.md +365 -0
- package/README.md +135 -52
- package/package.json +1 -1
- package/system/triggers.md +152 -11
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# Spring Boot Skill
|
|
2
|
+
|
|
3
|
+
## Project Structure
|
|
4
|
+
\`\`\`
|
|
5
|
+
src/main/java/com/example/
|
|
6
|
+
├── Application.java
|
|
7
|
+
├── config/
|
|
8
|
+
│ ├── SecurityConfig.java
|
|
9
|
+
│ └── WebConfig.java
|
|
10
|
+
├── controller/
|
|
11
|
+
│ └── UserController.java
|
|
12
|
+
├── service/
|
|
13
|
+
│ ├── UserService.java
|
|
14
|
+
│ └── impl/UserServiceImpl.java
|
|
15
|
+
├── repository/
|
|
16
|
+
│ └── UserRepository.java
|
|
17
|
+
├── entity/
|
|
18
|
+
│ └── User.java
|
|
19
|
+
├── dto/
|
|
20
|
+
│ ├── CreateUserRequest.java
|
|
21
|
+
│ └── UserResponse.java
|
|
22
|
+
└── exception/
|
|
23
|
+
├── GlobalExceptionHandler.java
|
|
24
|
+
└── NotFoundException.java
|
|
25
|
+
\`\`\`
|
|
26
|
+
|
|
27
|
+
## Controller Layer
|
|
28
|
+
\`\`\`java
|
|
29
|
+
@RestController
|
|
30
|
+
@RequestMapping("/api/v1/users")
|
|
31
|
+
@RequiredArgsConstructor // Lombok for constructor injection
|
|
32
|
+
public class UserController {
|
|
33
|
+
private final UserService userService;
|
|
34
|
+
|
|
35
|
+
@GetMapping
|
|
36
|
+
public ResponseEntity<Page<UserResponse>> listUsers(
|
|
37
|
+
@RequestParam(defaultValue = "0") int page,
|
|
38
|
+
@RequestParam(defaultValue = "20") int size) {
|
|
39
|
+
Page<UserResponse> users = userService.findAll(PageRequest.of(page, size));
|
|
40
|
+
return ResponseEntity.ok(users);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@GetMapping("/{id}")
|
|
44
|
+
public ResponseEntity<UserResponse> getUser(@PathVariable String id) {
|
|
45
|
+
return ResponseEntity.ok(userService.findById(id));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@PostMapping
|
|
49
|
+
public ResponseEntity<UserResponse> createUser(
|
|
50
|
+
@Valid @RequestBody CreateUserRequest request) {
|
|
51
|
+
UserResponse user = userService.create(request);
|
|
52
|
+
URI location = ServletUriComponentsBuilder
|
|
53
|
+
.fromCurrentRequest()
|
|
54
|
+
.path("/{id}")
|
|
55
|
+
.buildAndExpand(user.id())
|
|
56
|
+
.toUri();
|
|
57
|
+
return ResponseEntity.created(location).body(user);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@PutMapping("/{id}")
|
|
61
|
+
public ResponseEntity<UserResponse> updateUser(
|
|
62
|
+
@PathVariable String id,
|
|
63
|
+
@Valid @RequestBody UpdateUserRequest request) {
|
|
64
|
+
return ResponseEntity.ok(userService.update(id, request));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@DeleteMapping("/{id}")
|
|
68
|
+
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
|
|
69
|
+
userService.delete(id);
|
|
70
|
+
return ResponseEntity.noContent().build();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
\`\`\`
|
|
74
|
+
|
|
75
|
+
## Service Layer
|
|
76
|
+
\`\`\`java
|
|
77
|
+
public interface UserService {
|
|
78
|
+
Page<UserResponse> findAll(Pageable pageable);
|
|
79
|
+
UserResponse findById(String id);
|
|
80
|
+
UserResponse create(CreateUserRequest request);
|
|
81
|
+
UserResponse update(String id, UpdateUserRequest request);
|
|
82
|
+
void delete(String id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Service
|
|
86
|
+
@RequiredArgsConstructor
|
|
87
|
+
@Transactional(readOnly = true)
|
|
88
|
+
public class UserServiceImpl implements UserService {
|
|
89
|
+
private final UserRepository userRepository;
|
|
90
|
+
private final PasswordEncoder passwordEncoder;
|
|
91
|
+
|
|
92
|
+
@Override
|
|
93
|
+
public Page<UserResponse> findAll(Pageable pageable) {
|
|
94
|
+
return userRepository.findAll(pageable)
|
|
95
|
+
.map(UserResponse::from);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Override
|
|
99
|
+
public UserResponse findById(String id) {
|
|
100
|
+
return userRepository.findById(id)
|
|
101
|
+
.map(UserResponse::from)
|
|
102
|
+
.orElseThrow(() -> new NotFoundException("User not found: " + id));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@Override
|
|
106
|
+
@Transactional
|
|
107
|
+
public UserResponse create(CreateUserRequest request) {
|
|
108
|
+
if (userRepository.existsByEmail(request.email())) {
|
|
109
|
+
throw new ConflictException("Email already exists");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
User user = User.builder()
|
|
113
|
+
.email(request.email())
|
|
114
|
+
.name(request.name())
|
|
115
|
+
.passwordHash(passwordEncoder.encode(request.password()))
|
|
116
|
+
.isActive(true)
|
|
117
|
+
.build();
|
|
118
|
+
|
|
119
|
+
return UserResponse.from(userRepository.save(user));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@Override
|
|
123
|
+
@Transactional
|
|
124
|
+
public UserResponse update(String id, UpdateUserRequest request) {
|
|
125
|
+
User user = userRepository.findById(id)
|
|
126
|
+
.orElseThrow(() -> new NotFoundException("User not found: " + id));
|
|
127
|
+
|
|
128
|
+
if (request.name() != null) {
|
|
129
|
+
user.setName(request.name());
|
|
130
|
+
}
|
|
131
|
+
if (request.email() != null) {
|
|
132
|
+
user.setEmail(request.email());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return UserResponse.from(userRepository.save(user));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Override
|
|
139
|
+
@Transactional
|
|
140
|
+
public void delete(String id) {
|
|
141
|
+
if (!userRepository.existsById(id)) {
|
|
142
|
+
throw new NotFoundException("User not found: " + id);
|
|
143
|
+
}
|
|
144
|
+
userRepository.deleteById(id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
\`\`\`
|
|
148
|
+
|
|
149
|
+
## DTOs with Validation
|
|
150
|
+
\`\`\`java
|
|
151
|
+
public record CreateUserRequest(
|
|
152
|
+
@NotBlank @Email String email,
|
|
153
|
+
@NotBlank @Size(min = 2, max = 100) String name,
|
|
154
|
+
@NotBlank @Size(min = 8) String password
|
|
155
|
+
) {}
|
|
156
|
+
|
|
157
|
+
public record UpdateUserRequest(
|
|
158
|
+
@Email String email,
|
|
159
|
+
@Size(min = 2, max = 100) String name
|
|
160
|
+
) {}
|
|
161
|
+
|
|
162
|
+
public record UserResponse(
|
|
163
|
+
String id,
|
|
164
|
+
String email,
|
|
165
|
+
String name,
|
|
166
|
+
boolean isActive,
|
|
167
|
+
Instant createdAt
|
|
168
|
+
) {
|
|
169
|
+
public static UserResponse from(User user) {
|
|
170
|
+
return new UserResponse(
|
|
171
|
+
user.getId(),
|
|
172
|
+
user.getEmail(),
|
|
173
|
+
user.getName(),
|
|
174
|
+
user.isActive(),
|
|
175
|
+
user.getCreatedAt()
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
\`\`\`
|
|
180
|
+
|
|
181
|
+
## Global Exception Handling
|
|
182
|
+
\`\`\`java
|
|
183
|
+
@RestControllerAdvice
|
|
184
|
+
@Slf4j
|
|
185
|
+
public class GlobalExceptionHandler {
|
|
186
|
+
|
|
187
|
+
@ExceptionHandler(NotFoundException.class)
|
|
188
|
+
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException ex) {
|
|
189
|
+
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
|
190
|
+
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@ExceptionHandler(ConflictException.class)
|
|
194
|
+
public ResponseEntity<ErrorResponse> handleConflict(ConflictException ex) {
|
|
195
|
+
return ResponseEntity.status(HttpStatus.CONFLICT)
|
|
196
|
+
.body(new ErrorResponse("CONFLICT", ex.getMessage()));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@ExceptionHandler(MethodArgumentNotValidException.class)
|
|
200
|
+
public ResponseEntity<ValidationErrorResponse> handleValidation(
|
|
201
|
+
MethodArgumentNotValidException ex) {
|
|
202
|
+
Map<String, String> errors = ex.getBindingResult()
|
|
203
|
+
.getFieldErrors()
|
|
204
|
+
.stream()
|
|
205
|
+
.collect(Collectors.toMap(
|
|
206
|
+
FieldError::getField,
|
|
207
|
+
e -> e.getDefaultMessage() != null ? e.getDefaultMessage() : "Invalid value"
|
|
208
|
+
));
|
|
209
|
+
|
|
210
|
+
return ResponseEntity.badRequest()
|
|
211
|
+
.body(new ValidationErrorResponse("VALIDATION_ERROR", "Validation failed", errors));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@ExceptionHandler(Exception.class)
|
|
215
|
+
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
|
|
216
|
+
log.error("Unexpected error", ex);
|
|
217
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
218
|
+
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
public record ErrorResponse(String code, String message) {}
|
|
223
|
+
|
|
224
|
+
public record ValidationErrorResponse(
|
|
225
|
+
String code,
|
|
226
|
+
String message,
|
|
227
|
+
Map<String, String> errors
|
|
228
|
+
) {}
|
|
229
|
+
\`\`\`
|
|
230
|
+
|
|
231
|
+
## Configuration
|
|
232
|
+
\`\`\`java
|
|
233
|
+
// application.yml
|
|
234
|
+
spring:
|
|
235
|
+
datasource:
|
|
236
|
+
url: \${DATABASE_URL}
|
|
237
|
+
username: \${DATABASE_USER}
|
|
238
|
+
password: \${DATABASE_PASSWORD}
|
|
239
|
+
jpa:
|
|
240
|
+
hibernate:
|
|
241
|
+
ddl-auto: validate
|
|
242
|
+
open-in-view: false
|
|
243
|
+
jackson:
|
|
244
|
+
serialization:
|
|
245
|
+
write-dates-as-timestamps: false
|
|
246
|
+
|
|
247
|
+
// AppConfig.java
|
|
248
|
+
@Configuration
|
|
249
|
+
public class AppConfig {
|
|
250
|
+
@Bean
|
|
251
|
+
public PasswordEncoder passwordEncoder() {
|
|
252
|
+
return new BCryptPasswordEncoder();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
\`\`\`
|
|
256
|
+
|
|
257
|
+
## Security Configuration
|
|
258
|
+
\`\`\`java
|
|
259
|
+
@Configuration
|
|
260
|
+
@EnableWebSecurity
|
|
261
|
+
@RequiredArgsConstructor
|
|
262
|
+
public class SecurityConfig {
|
|
263
|
+
private final JwtAuthenticationFilter jwtFilter;
|
|
264
|
+
|
|
265
|
+
@Bean
|
|
266
|
+
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
267
|
+
return http
|
|
268
|
+
.csrf(csrf -> csrf.disable())
|
|
269
|
+
.sessionManagement(session ->
|
|
270
|
+
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
271
|
+
.authorizeHttpRequests(auth -> auth
|
|
272
|
+
.requestMatchers("/api/v1/auth/**").permitAll()
|
|
273
|
+
.requestMatchers("/actuator/health").permitAll()
|
|
274
|
+
.anyRequest().authenticated()
|
|
275
|
+
)
|
|
276
|
+
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
|
277
|
+
.build();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@Component
|
|
282
|
+
@RequiredArgsConstructor
|
|
283
|
+
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|
284
|
+
private final JwtService jwtService;
|
|
285
|
+
private final UserDetailsService userDetailsService;
|
|
286
|
+
|
|
287
|
+
@Override
|
|
288
|
+
protected void doFilterInternal(
|
|
289
|
+
HttpServletRequest request,
|
|
290
|
+
HttpServletResponse response,
|
|
291
|
+
FilterChain filterChain) throws ServletException, IOException {
|
|
292
|
+
|
|
293
|
+
String authHeader = request.getHeader("Authorization");
|
|
294
|
+
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|
295
|
+
filterChain.doFilter(request, response);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
String token = authHeader.substring(7);
|
|
300
|
+
String username = jwtService.extractUsername(token);
|
|
301
|
+
|
|
302
|
+
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
|
303
|
+
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
|
304
|
+
if (jwtService.isTokenValid(token, userDetails)) {
|
|
305
|
+
var authToken = new UsernamePasswordAuthenticationToken(
|
|
306
|
+
userDetails, null, userDetails.getAuthorities());
|
|
307
|
+
SecurityContextHolder.getContext().setAuthentication(authToken);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
filterChain.doFilter(request, response);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
\`\`\`
|
|
315
|
+
|
|
316
|
+
## Testing
|
|
317
|
+
\`\`\`java
|
|
318
|
+
@WebMvcTest(UserController.class)
|
|
319
|
+
class UserControllerTest {
|
|
320
|
+
@Autowired
|
|
321
|
+
private MockMvc mockMvc;
|
|
322
|
+
|
|
323
|
+
@MockBean
|
|
324
|
+
private UserService userService;
|
|
325
|
+
|
|
326
|
+
@Autowired
|
|
327
|
+
private ObjectMapper objectMapper;
|
|
328
|
+
|
|
329
|
+
@Test
|
|
330
|
+
void getUser_whenExists_returnsUser() throws Exception {
|
|
331
|
+
var response = new UserResponse("1", "test@example.com", "Test", true, Instant.now());
|
|
332
|
+
when(userService.findById("1")).thenReturn(response);
|
|
333
|
+
|
|
334
|
+
mockMvc.perform(get("/api/v1/users/1"))
|
|
335
|
+
.andExpect(status().isOk())
|
|
336
|
+
.andExpect(jsonPath("$.email").value("test@example.com"));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
@Test
|
|
340
|
+
void getUser_whenNotExists_returns404() throws Exception {
|
|
341
|
+
when(userService.findById("999"))
|
|
342
|
+
.thenThrow(new NotFoundException("User not found"));
|
|
343
|
+
|
|
344
|
+
mockMvc.perform(get("/api/v1/users/999"))
|
|
345
|
+
.andExpect(status().isNotFound());
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
@Test
|
|
349
|
+
void createUser_withValidData_returns201() throws Exception {
|
|
350
|
+
var request = new CreateUserRequest("test@example.com", "Test", "password123");
|
|
351
|
+
var response = new UserResponse("1", "test@example.com", "Test", true, Instant.now());
|
|
352
|
+
when(userService.create(any())).thenReturn(response);
|
|
353
|
+
|
|
354
|
+
mockMvc.perform(post("/api/v1/users")
|
|
355
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
356
|
+
.content(objectMapper.writeValueAsString(request)))
|
|
357
|
+
.andExpect(status().isCreated())
|
|
358
|
+
.andExpect(header().exists("Location"));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
@Test
|
|
362
|
+
void createUser_withInvalidEmail_returns400() throws Exception {
|
|
363
|
+
var request = Map.of("email", "invalid", "name", "Test", "password", "password123");
|
|
364
|
+
|
|
365
|
+
mockMvc.perform(post("/api/v1/users")
|
|
366
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
367
|
+
.content(objectMapper.writeValueAsString(request)))
|
|
368
|
+
.andExpect(status().isBadRequest())
|
|
369
|
+
.andExpect(jsonPath("$.errors.email").exists());
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@SpringBootTest
|
|
374
|
+
@AutoConfigureMockMvc
|
|
375
|
+
class UserIntegrationTest {
|
|
376
|
+
@Autowired
|
|
377
|
+
private MockMvc mockMvc;
|
|
378
|
+
|
|
379
|
+
@Autowired
|
|
380
|
+
private UserRepository userRepository;
|
|
381
|
+
|
|
382
|
+
@BeforeEach
|
|
383
|
+
void setUp() {
|
|
384
|
+
userRepository.deleteAll();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
@Test
|
|
388
|
+
void fullUserLifecycle() throws Exception {
|
|
389
|
+
// Create
|
|
390
|
+
mockMvc.perform(post("/api/v1/users")
|
|
391
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
392
|
+
.content("{\\"email\\":\\"test@example.com\\",\\"name\\":\\"Test\\",\\"password\\":\\"password123\\"}"))
|
|
393
|
+
.andExpect(status().isCreated());
|
|
394
|
+
|
|
395
|
+
// Verify in database
|
|
396
|
+
assertThat(userRepository.findByEmail("test@example.com")).isPresent();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
\`\`\`
|
|
400
|
+
|
|
401
|
+
## ✅ DO
|
|
402
|
+
- Use constructor injection (add @RequiredArgsConstructor with Lombok)
|
|
403
|
+
- Use records for DTOs
|
|
404
|
+
- Use @Transactional at service layer
|
|
405
|
+
- Use @Valid for request validation
|
|
406
|
+
- Return ResponseEntity for explicit status control
|
|
407
|
+
- Use @RestControllerAdvice for global exception handling
|
|
408
|
+
- Set \`spring.jpa.open-in-view=false\`
|
|
409
|
+
|
|
410
|
+
## ❌ DON'T
|
|
411
|
+
- Don't inject repositories directly into controllers
|
|
412
|
+
- Don't expose entities in API responses (use DTOs)
|
|
413
|
+
- Don't catch exceptions in controllers (use @RestControllerAdvice)
|
|
414
|
+
- Don't use field injection (@Autowired on fields)
|
|
415
|
+
- Don't return null - throw exceptions or use Optional
|
|
416
|
+
- Don't put business logic in controllers
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# SQLAlchemy Skill
|
|
2
|
+
|
|
3
|
+
## SQLAlchemy 2.0 Model Definition
|
|
4
|
+
\`\`\`python
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from sqlalchemy import String, ForeignKey, func
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
8
|
+
|
|
9
|
+
class Base(DeclarativeBase):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
class User(Base):
|
|
13
|
+
__tablename__ = "users"
|
|
14
|
+
|
|
15
|
+
# Mapped columns with type annotations
|
|
16
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
17
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
|
18
|
+
name: Mapped[str] = mapped_column(String(100))
|
|
19
|
+
is_active: Mapped[bool] = mapped_column(default=True)
|
|
20
|
+
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
21
|
+
|
|
22
|
+
# Relationships
|
|
23
|
+
posts: Mapped[list["Post"]] = relationship(back_populates="author", cascade="all, delete-orphan")
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"User(id={self.id}, email={self.email!r})"
|
|
27
|
+
|
|
28
|
+
class Post(Base):
|
|
29
|
+
__tablename__ = "posts"
|
|
30
|
+
|
|
31
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
32
|
+
title: Mapped[str] = mapped_column(String(200))
|
|
33
|
+
content: Mapped[str]
|
|
34
|
+
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
|
35
|
+
|
|
36
|
+
author: Mapped["User"] = relationship(back_populates="posts")
|
|
37
|
+
\`\`\`
|
|
38
|
+
|
|
39
|
+
## Async Engine & Session
|
|
40
|
+
\`\`\`python
|
|
41
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
|
42
|
+
|
|
43
|
+
# Create async engine
|
|
44
|
+
engine = create_async_engine(
|
|
45
|
+
"postgresql+asyncpg://user:pass@localhost/db",
|
|
46
|
+
echo=True, # Log SQL
|
|
47
|
+
pool_size=5,
|
|
48
|
+
max_overflow=10,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Session factory
|
|
52
|
+
async_session = async_sessionmaker(
|
|
53
|
+
engine,
|
|
54
|
+
class_=AsyncSession,
|
|
55
|
+
expire_on_commit=False,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Dependency for FastAPI
|
|
59
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
60
|
+
async with async_session() as session:
|
|
61
|
+
try:
|
|
62
|
+
yield session
|
|
63
|
+
await session.commit()
|
|
64
|
+
except Exception:
|
|
65
|
+
await session.rollback()
|
|
66
|
+
raise
|
|
67
|
+
finally:
|
|
68
|
+
await session.close()
|
|
69
|
+
\`\`\`
|
|
70
|
+
|
|
71
|
+
## Async Queries (SQLAlchemy 2.0)
|
|
72
|
+
\`\`\`python
|
|
73
|
+
from sqlalchemy import select
|
|
74
|
+
from sqlalchemy.orm import selectinload, joinedload
|
|
75
|
+
|
|
76
|
+
# Select with type hints
|
|
77
|
+
async def get_user(session: AsyncSession, user_id: int) -> User | None:
|
|
78
|
+
result = await session.execute(
|
|
79
|
+
select(User).where(User.id == user_id)
|
|
80
|
+
)
|
|
81
|
+
return result.scalar_one_or_none()
|
|
82
|
+
|
|
83
|
+
# Select all with filtering
|
|
84
|
+
async def get_active_users(session: AsyncSession) -> list[User]:
|
|
85
|
+
result = await session.execute(
|
|
86
|
+
select(User)
|
|
87
|
+
.where(User.is_active == True)
|
|
88
|
+
.order_by(User.created_at.desc())
|
|
89
|
+
)
|
|
90
|
+
return list(result.scalars().all())
|
|
91
|
+
|
|
92
|
+
# Eager loading relationships
|
|
93
|
+
async def get_user_with_posts(session: AsyncSession, user_id: int) -> User | None:
|
|
94
|
+
result = await session.execute(
|
|
95
|
+
select(User)
|
|
96
|
+
.options(selectinload(User.posts)) # Async-safe loading
|
|
97
|
+
.where(User.id == user_id)
|
|
98
|
+
)
|
|
99
|
+
return result.scalar_one_or_none()
|
|
100
|
+
|
|
101
|
+
# Pagination
|
|
102
|
+
async def get_users_paginated(
|
|
103
|
+
session: AsyncSession,
|
|
104
|
+
page: int = 1,
|
|
105
|
+
per_page: int = 10,
|
|
106
|
+
) -> list[User]:
|
|
107
|
+
result = await session.execute(
|
|
108
|
+
select(User)
|
|
109
|
+
.offset((page - 1) * per_page)
|
|
110
|
+
.limit(per_page)
|
|
111
|
+
)
|
|
112
|
+
return list(result.scalars().all())
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
## CRUD Operations
|
|
116
|
+
\`\`\`python
|
|
117
|
+
# Create
|
|
118
|
+
async def create_user(session: AsyncSession, email: str, name: str) -> User:
|
|
119
|
+
user = User(email=email, name=name)
|
|
120
|
+
session.add(user)
|
|
121
|
+
await session.flush() # Get ID without committing
|
|
122
|
+
return user
|
|
123
|
+
|
|
124
|
+
# Update
|
|
125
|
+
async def update_user(session: AsyncSession, user_id: int, **data) -> User | None:
|
|
126
|
+
user = await get_user(session, user_id)
|
|
127
|
+
if user:
|
|
128
|
+
for key, value in data.items():
|
|
129
|
+
setattr(user, key, value)
|
|
130
|
+
await session.flush()
|
|
131
|
+
return user
|
|
132
|
+
|
|
133
|
+
# Delete
|
|
134
|
+
async def delete_user(session: AsyncSession, user_id: int) -> bool:
|
|
135
|
+
user = await get_user(session, user_id)
|
|
136
|
+
if user:
|
|
137
|
+
await session.delete(user)
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
# Bulk insert
|
|
142
|
+
async def create_users_bulk(session: AsyncSession, users_data: list[dict]) -> None:
|
|
143
|
+
session.add_all([User(**data) for data in users_data])
|
|
144
|
+
await session.flush()
|
|
145
|
+
\`\`\`
|
|
146
|
+
|
|
147
|
+
## Transactions
|
|
148
|
+
\`\`\`python
|
|
149
|
+
from sqlalchemy.exc import IntegrityError
|
|
150
|
+
|
|
151
|
+
# Explicit transaction
|
|
152
|
+
async def transfer_posts(session: AsyncSession, from_id: int, to_id: int):
|
|
153
|
+
async with session.begin(): # Auto-commit or rollback
|
|
154
|
+
from_user = await get_user(session, from_id)
|
|
155
|
+
to_user = await get_user(session, to_id)
|
|
156
|
+
|
|
157
|
+
for post in from_user.posts:
|
|
158
|
+
post.author_id = to_id
|
|
159
|
+
|
|
160
|
+
# Changes committed when exiting context
|
|
161
|
+
|
|
162
|
+
# Savepoints for partial rollbacks
|
|
163
|
+
async def create_with_fallback(session: AsyncSession, data: dict):
|
|
164
|
+
try:
|
|
165
|
+
async with session.begin_nested(): # Savepoint
|
|
166
|
+
user = User(**data)
|
|
167
|
+
session.add(user)
|
|
168
|
+
await session.flush()
|
|
169
|
+
except IntegrityError:
|
|
170
|
+
# Rollback to savepoint, outer transaction continues
|
|
171
|
+
pass
|
|
172
|
+
\`\`\`
|
|
173
|
+
|
|
174
|
+
## Complex Queries
|
|
175
|
+
\`\`\`python
|
|
176
|
+
from sqlalchemy import func, and_, or_, case
|
|
177
|
+
|
|
178
|
+
# Aggregation
|
|
179
|
+
async def get_post_counts(session: AsyncSession):
|
|
180
|
+
result = await session.execute(
|
|
181
|
+
select(User.id, User.name, func.count(Post.id).label('post_count'))
|
|
182
|
+
.join(Post, isouter=True)
|
|
183
|
+
.group_by(User.id)
|
|
184
|
+
.having(func.count(Post.id) > 0)
|
|
185
|
+
)
|
|
186
|
+
return result.all()
|
|
187
|
+
|
|
188
|
+
# Subqueries
|
|
189
|
+
async def get_users_with_recent_posts(session: AsyncSession):
|
|
190
|
+
subq = (
|
|
191
|
+
select(Post.author_id)
|
|
192
|
+
.where(Post.created_at > func.now() - timedelta(days=7))
|
|
193
|
+
.distinct()
|
|
194
|
+
.subquery()
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
result = await session.execute(
|
|
198
|
+
select(User).where(User.id.in_(select(subq)))
|
|
199
|
+
)
|
|
200
|
+
return result.scalars().all()
|
|
201
|
+
|
|
202
|
+
# Case expressions
|
|
203
|
+
async def get_users_by_activity(session: AsyncSession):
|
|
204
|
+
result = await session.execute(
|
|
205
|
+
select(
|
|
206
|
+
User,
|
|
207
|
+
case(
|
|
208
|
+
(func.count(Post.id) > 10, 'active'),
|
|
209
|
+
(func.count(Post.id) > 0, 'moderate'),
|
|
210
|
+
else_='inactive'
|
|
211
|
+
).label('activity')
|
|
212
|
+
)
|
|
213
|
+
.join(Post, isouter=True)
|
|
214
|
+
.group_by(User.id)
|
|
215
|
+
)
|
|
216
|
+
return result.all()
|
|
217
|
+
\`\`\`
|
|
218
|
+
|
|
219
|
+
## Relationships In-Depth
|
|
220
|
+
\`\`\`python
|
|
221
|
+
# Many-to-Many
|
|
222
|
+
user_roles = Table(
|
|
223
|
+
'user_roles',
|
|
224
|
+
Base.metadata,
|
|
225
|
+
Column('user_id', ForeignKey('users.id'), primary_key=True),
|
|
226
|
+
Column('role_id', ForeignKey('roles.id'), primary_key=True),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
class Role(Base):
|
|
230
|
+
__tablename__ = 'roles'
|
|
231
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
232
|
+
name: Mapped[str] = mapped_column(String(50), unique=True)
|
|
233
|
+
users: Mapped[list["User"]] = relationship(secondary=user_roles, back_populates="roles")
|
|
234
|
+
|
|
235
|
+
class User(Base):
|
|
236
|
+
# ... other fields
|
|
237
|
+
roles: Mapped[list["Role"]] = relationship(secondary=user_roles, back_populates="users")
|
|
238
|
+
|
|
239
|
+
# Self-referential (followers)
|
|
240
|
+
followers = Table(
|
|
241
|
+
'followers',
|
|
242
|
+
Base.metadata,
|
|
243
|
+
Column('follower_id', ForeignKey('users.id'), primary_key=True),
|
|
244
|
+
Column('followed_id', ForeignKey('users.id'), primary_key=True),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
class User(Base):
|
|
248
|
+
# ... other fields
|
|
249
|
+
following: Mapped[list["User"]] = relationship(
|
|
250
|
+
secondary=followers,
|
|
251
|
+
primaryjoin=id == followers.c.follower_id,
|
|
252
|
+
secondaryjoin=id == followers.c.followed_id,
|
|
253
|
+
backref="followers",
|
|
254
|
+
)
|
|
255
|
+
\`\`\`
|
|
256
|
+
|
|
257
|
+
## ❌ DON'T
|
|
258
|
+
- Use session.query() (legacy, use select())
|
|
259
|
+
- Forget eager loading (N+1 problem)
|
|
260
|
+
- Use sync operations in async code
|
|
261
|
+
- Commit inside functions (let caller decide)
|
|
262
|
+
|
|
263
|
+
## ✅ DO
|
|
264
|
+
- Use SQLAlchemy 2.0 style (Mapped, select)
|
|
265
|
+
- Use async for web applications
|
|
266
|
+
- Use selectinload for async relationship loading
|
|
267
|
+
- Use transactions for multi-step operations
|
|
268
|
+
- Use indices on frequently queried columns
|
|
269
|
+
- Use connection pooling
|