- Java
- Spring Boot
- Spring Security
- JWT
- PostgreSQL
- Docker
- React
- TypeScript
- CI/CD
Heracles Tracker
REST API and PWA app to log workouts and executions and track load progression, with JWT authentication.
Heracles is a full-stack project inspired by the 12 labors of Hercules: the user creates workout plans, logs executions, and tracks load progression. The front-end is a PWA (React + TypeScript) hosted on Vercel; on this page I detail the REST API, which is the heart of the repository.
The interactive documentation of the API in production, with all endpoints, schemas, and examples, is published on Swagger/OpenAPI.
Architecture and stack
The API is a Spring Boot monolith with classic layers: controller, service, repository, model, dto, and security, with a global error handler and configurations in config. The stack from pom.xml:
- Java 21 + Spring Boot 4.1.0 with Spring Web MVC, Data JPA (Hibernate), and Bean Validation.
- Spring Security 6 with JWT (jjwt 0.12.6), stateless authentication.
- springdoc-openapi 2.8.8 for Swagger/OpenAPI, bucket4j 8.10.1 for rate limiting, and Spring Actuator for health check.
- PostgreSQL (Neon) in production, in-memory H2 in development, Lombok, and the Maven wrapper.
Data model and per-user isolation
There are six entities: Usuario, Treino, Exercicio, Execucao, ExercicioRealizado, and RefreshToken. The central rule is isolation: each user only sees what is theirs. In the services, when a resource exists but belongs to another owner, I throw EntityNotFoundException with the same message as “not found”, so as not to confirm the existence of someone else’s data:
Treino treino = treinoRepository.findById(treinoId)
.orElseThrow(() -> new EntityNotFoundException("Treino nao encontrado com ID: " + treinoId));
if (!treino.getUsuario().getId().equals(usuarioId)) {
throw new EntityNotFoundException("Treino nao encontrado com ID: " + treinoId);
}
When creating an execution, I also validate that each exercise informed exists and belongs to that workout, reusing the real entity instead of the loose id:
Exercicio exercicio = exercicioRepository.findById(realizado.getExercicio().getId())
.orElseThrow(() -> new IllegalArgumentException("Exercicio nao encontrado com ID: " + realizado.getExercicio().getId()));
if (!exercicio.getTreino().getId().equals(treinoId)) {
throw new IllegalArgumentException(
"Exercicio '" + exercicio.getNome() + "' nao pertence ao treino " + treino.getNome());
}
realizado.setExercicio(exercicio);
When registering a workout I use usuarioRepository.getReferenceById(...) to write only the FK, without an extra SELECT just to build the proxy.
Authentication
The account endpoints live in AuthController (/auth). register checks for a duplicate email (409) and saves the password with BCrypt. login loads the user by email, checks the password, and returns a token pair:
UserDetails userDetails;
try {
userDetails = userDetailsServiceImpl.loadUserByUsername(request.getEmail());
} catch (UsernameNotFoundException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (!passwordEncoder.matches(request.getPassword(), userDetails.getPassword())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Usuario usuario = usuarioRepository.findByEmail(request.getEmail()).get();
String accessToken = jwtService.generateToken(userDetails);
RefreshToken refreshToken = refreshTokenService.criar(usuario);
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken.getToken()));
The access token is a JWT with a configurable expiration (15 minutes in application.properties); the refresh token is a UUID persisted in the database with a 30-day validity. refresh validates the UUID, generates a new access token, and responds with the same refresh token sent:
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
RefreshToken refreshToken = refreshTokenService.validar(request.getRefreshToken());
String accessToken = jwtService.generateToken(
userDetailsServiceImpl.loadUserByUsername(refreshToken.getUsuario().getEmail()));
return ResponseEntity.ok(new AuthResponse(accessToken, request.getRefreshToken()));
}
logout validates the refresh token and revokes the user’s tokens (deleteByUsuario).
The security chain
JwtService generates and validates the tokens with jjwt, using the email as subject. JwtAuthFilter is a OncePerRequestFilter that runs before Spring’s authentication filter: it reads the Authorization header, extracts the email, and, if the token is valid, sets the UsernamePasswordAuthenticationToken in the context:
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
String email = jwtService.extractEmail(token);
if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsServiceImpl.loadUserByUsername(email);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (Exception e) {
}
filterChain.doFilter(request, response);
SecurityConfig defines the chain: CSRF disabled, stateless session, CORS enabled, and the public endpoints declared explicitly; everything else requires authentication:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/", "/h2-console/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
Errors and rate limiting
GlobalExceptionHandler (@RestControllerAdvice) standardizes error responses into an ErrorResponse with timestamp, status, message, path, and, on validation, a fieldErrors map per field. EntityNotFoundException and a nonexistent route become 404, MethodArgumentNotValidException and IllegalArgumentException become 400, and the rest becomes a generic 500 (logged on the server).
Rate limiting uses bucket4j in a HandlerInterceptor: 100 requests per minute per IP, returning 429 with a Retry-After header:
Bandwidth limit = Bandwidth.builder()
.capacity(100)
.refillIntervally(100, Duration.ofMinutes(1))
.initialTokens(100)
.build();
Bucket bucket = buckets.computeIfAbsent(ip, k -> createBucket());
if (bucket.tryConsume(1)) {
return true;
}
response.setStatus(429);
response.setHeader("Retry-After", "60");
response.getWriter().write("Muitas Requisições.");
return false;
Tests, CI, and deploy
There are 31 unit tests with JUnit 5 + Mockito (services, auth controller, and JWT logic). GitHub Actions runs mvn test on push to main and, if it passes, triggers the deploy on Render via a Deploy Hook. The Docker image uses a multi-stage build and, in production, the prod profile swaps H2 for PostgreSQL via environment variables (DATABASE_URL, JWT_SECRET, etc.). DataLoader populates the database with sample data and a test user, but only outside the prod profile.
Particularities and points I would revisit
-
A 401 that in practice becomes a 403. I did not configure an
AuthenticationEntryPoint; when the token is missing or invalid,JwtAuthFilterjust continues the chain and Spring Security responds 403 instead of the 401 I document in the README. On top of that, the emptycatch (Exception e)in the filter silently swallows any token parsing error (invalid signature, expired). -
Refresh token without rotation.
/auth/refreshreturns the same refresh token received. If it leaks, it stays valid for 30 days and only logout brings it down; the ideal would be to rotate it, issuing another token and invalidating the previous one. -
Inconsistent response formats.
registerresponds 201 with plain text"Conta criada com sucesso"(aString), andloginreturns 401 with an empty body; the rest of the API follows the JSONErrorResponse. It is worth standardizing everything. -
Rate limiter with unbounded memory. The bucket map grows per IP and is never cleaned; on a long-running instance with many IPs, this leaks memory. And the 429 response is plain text (
Muitas Requisições.), outside theErrorResponseformat. -
Lazy loading outside a transaction. The listings and the assembly of the responses depend on Spring Boot’s open-in-view (enabled by default) to load lazy relationships. If I turn off
spring.jpa.open-in-view=false, those reads break; the right thing would be afetch joinor assembling the DTOs inside the transaction. -
Secrets and seed in the dev profile.
app.jwt.secretis hardcoded inapplication.propertiesandDataLoadercreatesteste@email.comwith password123456. Both are restricted to the development environment (theprodprofile uses environment variables and does not run the loader), but the embedded secret is a risk if the wrong profile is activated.
Improvement points
- Configure an
AuthenticationEntryPointthat returns 401 in JSON when the token is missing or invalid, and log the reason in the filter. - Rotate the refresh token on
/auth/refresh(new UUID + invalidate the previous one). - Standardize the success and error responses of
/authand the 429 in theErrorResponseformat. - Add cleanup or a TTL to the rate limiter’s bucket map.
- Revisit relationship loading (fetch join/
@EntityGraph) and turn off open-in-view. - Move the development JWT secret to an environment variable with a fallback only in the dev profile.
Gabriel Rocha