Gabriel Rocha
Projetos

Heracles Tracker

API REST e app PWA para registrar treinos, execuções e acompanhar a progressão de carga, com autenticação JWT.

2026RepositórioDemo

O Heracles é um projeto full-stack inspirado nos 12 trabalhos de Hércules: o usuário cria planos de treino, registra execuções e acompanha a evolução da carga. O front-end é um PWA (React + TypeScript) hospedado na Vercel; nesta página eu detalho a API REST, que é o coração do repositório.

A documentação interativa da API em produção, com todos os endpoints, schemas e exemplos, está publicada no Swagger/OpenAPI.

Arquitetura e stack

A API é um monólito Spring Boot com camadas clássicas: controller, service, repository, model, dto e security, com um handler global de erros e configurações em config. A stack do pom.xml:

Modelo de dados e isolamento por usuário

São seis entidades: Usuario, Treino, Exercicio, Execucao, ExercicioRealizado e RefreshToken. A regra central é o isolamento: cada usuário só enxerga o que é dele. Nos services, quando um recurso existe mas pertence a outro dono, eu lanço EntityNotFoundException com a mesma mensagem de “não encontrado”, para não confirmar a existência de dados alheios:

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);
}

Na criação de uma execução eu ainda valido que cada exercício informado existe e pertence àquele treino, reaproveitando a entidade real em vez do id solto:

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);

No cadastro de treino uso usuarioRepository.getReferenceById(...) para gravar só a FK, sem um SELECT extra só para montar o proxy.

Autenticação

Os endpoints de conta ficam em AuthController (/auth). O register checa email duplicado (409) e salva a senha com BCrypt. O login carrega o usuário pelo email, confere a senha e devolve um par de tokens:

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()));

O access token é um JWT com expiração configurável (15 minutos no application.properties); o refresh token é um UUID persistido no banco com validade de 30 dias. O refresh valida o UUID, gera um access novo e responde com o mesmo refresh token enviado:

@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()));
}

O logout valida o refresh e revoga os tokens do usuário (deleteByUsuario).

A cadeia de segurança

O JwtService gera e valida os tokens com jjwt, usando o email como subject. O JwtAuthFilter é um OncePerRequestFilter que roda antes do filtro de autenticação do Spring: lê o header Authorization, extrai o email e, se o token for válido, monta o UsernamePasswordAuthenticationToken no contexto:

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);

A SecurityConfig define a cadeia: CSRF desligado, sessão stateless, CORS liberado e os endpoints públicos declarados explicitamente; todo o resto exige autenticação:

.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)

Erros e rate limiting

O GlobalExceptionHandler (@RestControllerAdvice) padroniza as respostas de erro em um ErrorResponse com timestamp, status, mensagem, path e, na validação, um mapa fieldErrors por campo. EntityNotFoundException e rota inexistente viram 404, MethodArgumentNotValidException e IllegalArgumentException viram 400, e o restante vira 500 genérico (logado no servidor).

O rate limiting usa bucket4j num HandlerInterceptor: 100 requisições por minuto por IP, com retorno 429 e cabeçalho Retry-After:

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;

Testes, CI e deploy

São 31 testes unitários com JUnit 5 + Mockito (services, controller de auth e lógica JWT). O GitHub Actions roda mvn test no push para a main e, se passar, dispara o deploy no Render via Deploy Hook. A imagem Docker usa multi-stage build e, em produção, o perfil prod troca o H2 pelo PostgreSQL via variáveis de ambiente (DATABASE_URL, JWT_SECRET etc.). O DataLoader popula o banco com dados de exemplo e um usuário de teste, mas só fora do perfil prod.

Particularidades e pontos que revisaria

  1. 401 que na prática vira 403. Não configurei um AuthenticationEntryPoint; quando o token falta ou é inválido, o JwtAuthFilter só segue a cadeia e o Spring Security responde 403 no lugar do 401 que eu documento no README. Além disso, o catch (Exception e) vazio do filtro engole em silêncio qualquer erro de parsing do token (assinatura inválida, expirado).

  2. Refresh token sem rotação. O /auth/refresh devolve o mesmo refresh token recebido. Se ele vazar, fica válido por 30 dias e só o logout o derruba; o ideal seria rotacionar, emitindo outro token e invalidando o anterior.

  3. Formatos de resposta inconsistentes. O register responde 201 com texto puro "Conta criada com sucesso" (uma String), e o login devolve 401 com corpo vazio; o resto da API segue o ErrorResponse JSON. Vale padronizar tudo.

  4. Rate limiter com memória infinita. O mapa de buckets cresce por IP e nunca é limpo; numa instância de longa duração com muitos IPs, isso vaza memória. E a resposta 429 é texto puro (Muitas Requisições.), fora do formato ErrorResponse.

  5. Lazy loading fora de transação. As listagens e a montagem das respostas dependem do open-in-view do Spring Boot (padrão ligado) para carregar os relacionamentos preguiçosos. Se eu desligar spring.jpa.open-in-view=false, essas leituras quebram; o certo seria fetch join ou montar os DTOs dentro da transação.

  6. Segredos e seed no perfil dev. O app.jwt.secret fica fixo no application.properties e o DataLoader cria teste@email.com com senha 123456. Ambos estão restritos ao ambiente de desenvolvimento (o prod usa variáveis de ambiente e não roda o loader), mas o segredo embutido é um risco se o perfil errado for ativado.

Pontos de melhoria