Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<spring-boot.version>3.5.7</spring-boot.version>
<jwt.version>0.12.7</jwt.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

Expand All @@ -38,11 +39,55 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JWT (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>

<!-- BCrypt for password hashing -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>

<!-- Bucket4j -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
25 changes: 7 additions & 18 deletions src/main/java/org/example/api/ScoreController.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@
import org.example.api.dto.PagedScoreResponse;
import org.example.service.ScoreService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.server.ResponseStatusException;

import java.time.LocalDate;

Expand All @@ -22,21 +20,12 @@ public ScoreController(ScoreService scoreService) {
}

@GetMapping("/repositories/score")
public PagedScoreResponse search(
@RequestParam(name = "language", required = false) String language,
@RequestParam(name = "created_after", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdAfter,
@RequestParam(name = "algo", defaultValue = "default") String algo,
@RequestParam(name = "per_page", defaultValue = "30") @Min(1) @Max(100) int perPage,
@RequestParam(name = "page", defaultValue = "1") @Min(1) int page
public PagedScoreResponse search(@RequestParam(name = "language", required = false) String language,
@RequestParam(name = "created_after", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdAfter,
@RequestParam(name = "algo", defaultValue = "default") String algo,
@RequestParam(name = "per_page", defaultValue = "30") @Min(1) @Max(100) int perPage,
@RequestParam(name = "page", defaultValue = "1") @Min(1) int page
) {
try {
return scoreService.calculateScores(language, createdAfter, algo, perPage, page);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
} catch (HttpClientErrorException.Forbidden exception) {
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
"GitHub rate limit exceeded. Please retry shortly.");
}
return scoreService.calculateScores(language, createdAfter, algo, perPage, page);
}
}
23 changes: 23 additions & 0 deletions src/main/java/org/example/auth/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.example.auth;

import jakarta.validation.Valid;
import org.example.auth.dto.LoginRequest;
import org.example.service.AuthService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/auth")
public class AuthController {

private final AuthService authService;

public AuthController(AuthService authService) {
this.authService = authService;
}

@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.authUser(request.username(), request.password()));
}
}
8 changes: 8 additions & 0 deletions src/main/java/org/example/auth/dto/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.example.auth.dto;

import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
3 changes: 3 additions & 0 deletions src/main/java/org/example/auth/dto/TokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.example.auth.dto;

public record TokenResponse(String token) {}
24 changes: 24 additions & 0 deletions src/main/java/org/example/config/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.example.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("githubSearch");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10)));
return cacheManager;
}
}

5 changes: 5 additions & 0 deletions src/main/java/org/example/exceptions/ErrorBody.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.example.exceptions;

import java.time.Instant;

public record ErrorBody(int status, String error, String message, Instant timestamp) {}
65 changes: 64 additions & 1 deletion src/main/java/org/example/exceptions/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,55 @@
package org.example.exceptions;

import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.server.ResponseStatusException;

import java.time.Instant;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

record ErrorBody(int status, String error, String message, Instant timestamp) {}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorBody> handleMethodArgumentNotValid(MethodArgumentNotValidException exception) {
HttpStatus status = HttpStatus.BAD_REQUEST;
String message = exception.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
if (message.isEmpty()) message = "Validation failed";

ErrorBody body = new ErrorBody(
status.value(),
status.getReasonPhrase(),
message,
Instant.now()
);

return new ResponseEntity<>(body, status);
}

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorBody> handleConstraintViolation(ConstraintViolationException exception) {
HttpStatus status = HttpStatus.BAD_REQUEST;
String message = exception.getConstraintViolations().stream()
.map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
.collect(Collectors.joining(", "));
if (message.isEmpty()) message = "Validation failed";

ErrorBody body = new ErrorBody(
status.value(),
status.getReasonPhrase(),
message,
Instant.now()
);

return new ResponseEntity<>(body, status);
}

@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorBody> handleResponseStatus(ResponseStatusException exception) {
Expand All @@ -28,6 +66,31 @@ public ResponseEntity<ErrorBody> handleResponseStatus(ResponseStatusException ex
return new ResponseEntity<>(body, status);
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorBody> handleIllegalArgument(IllegalArgumentException exception) {
HttpStatus status = HttpStatus.BAD_REQUEST;
ErrorBody body = new ErrorBody(
status.value(),
status.getReasonPhrase(),
exception.getMessage(),
Instant.now()
);
return new ResponseEntity<>(body, status);
}

@ExceptionHandler(HttpClientErrorException.Forbidden.class)
public ResponseEntity<ErrorBody> handleGithubRateLimit(HttpClientErrorException.Forbidden exception) {
HttpStatus status = HttpStatus.TOO_MANY_REQUESTS;
String message = "GitHub rate limit exceeded. Please retry shortly.";
ErrorBody body = new ErrorBody(
status.value(),
status.getReasonPhrase(),
message,
Instant.now()
);
return new ResponseEntity<>(body, status);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorBody> handleGeneric(Exception ex) {
ErrorBody body = new ErrorBody(
Expand Down
22 changes: 20 additions & 2 deletions src/main/java/org/example/github/GithubClient.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.example.github;

import org.example.github.dto.GithubSearchResponse;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
Expand All @@ -15,11 +16,13 @@ public GithubClient(RestClient githubClient) {
this.githubClient = githubClient;
}

@Cacheable(cacheNames = "githubSearch", key = "#root.target.buildCacheKey(#root.args[0], #root.args[1], #root.args[4], #root.args[5])")
public GithubSearchResponse searchRepositories(String language, LocalDate createdAfter, String sort, String order, int perPage, int page) {
String normalizedLanguage = normalizeLanguage(language);

StringBuilder path = new StringBuilder("/search/repositories?q=");
if (language != null && !language.isBlank()) {
path.append("language:").append(language.trim()).append(' ');
if (!normalizedLanguage.isBlank()) {
path.append("language:").append(normalizedLanguage).append(' ');
}
if (createdAfter != null) {
path.append("created:>=").append(createdAfter);
Expand All @@ -39,4 +42,19 @@ public GithubSearchResponse searchRepositories(String language, LocalDate create
.retrieve()
.body(GithubSearchResponse.class);
}

@SuppressWarnings("unused")
public String buildCacheKey(String language, LocalDate createdAfter, Integer perPage, Integer page) {
String created = createdAfter != null ? createdAfter.toString() : "";
String perPageValue = perPage != null ? perPage.toString() : "";
String pageValue = page != null ? page.toString() : "";
return normalizeLanguage(language) + '|' + created + '|' + perPageValue + '|' + pageValue;
}

private String normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "";
}
return language.trim().toLowerCase();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.example.config;
package org.example.github.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.example.config;
package org.example.github.config;


import jakarta.validation.constraints.Max;
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/org/example/security/JsonAuthEntryPoint.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.example.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.example.exceptions.ErrorBody;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Instant;

@Component
public class JsonAuthEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(HttpServletRequest req, HttpServletResponse res, AuthenticationException ex) throws IOException {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType("application/json");
var body = new ErrorBody(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase(),
"Invalid or expired token",
Instant.now());
objectMapper.writeValue(res.getOutputStream(), body);
}
}
Loading