Gabriel Rocha
Projects

Heracles Tracker

REST API and PWA app to log workouts and executions and track load progression, with JWT authentication.

2026RepositoryLive demo

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:

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

  1. A 401 that in practice becomes a 403. I did not configure an AuthenticationEntryPoint; when the token is missing or invalid, JwtAuthFilter just continues the chain and Spring Security responds 403 instead of the 401 I document in the README. On top of that, the empty catch (Exception e) in the filter silently swallows any token parsing error (invalid signature, expired).

  2. Refresh token without rotation. /auth/refresh returns 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.

  3. Inconsistent response formats. register responds 201 with plain text "Conta criada com sucesso" (a String), and login returns 401 with an empty body; the rest of the API follows the JSON ErrorResponse. It is worth standardizing everything.

  4. 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 the ErrorResponse format.

  5. 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 a fetch join or assembling the DTOs inside the transaction.

  6. Secrets and seed in the dev profile. app.jwt.secret is hardcoded in application.properties and DataLoader creates teste@email.com with password 123456. Both are restricted to the development environment (the prod profile uses environment variables and does not run the loader), but the embedded secret is a risk if the wrong profile is activated.

Improvement points