diff --git a/backend/libs/github-client/build.gradle.kts b/backend/libs/github-client/build.gradle.kts index 88d28b7..68b18e3 100644 --- a/backend/libs/github-client/build.gradle.kts +++ b/backend/libs/github-client/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - `java-library` + id("java-library") } dependencies { @@ -7,6 +7,17 @@ dependencies { api("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.boot:spring-boot-starter-webflux") + + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") testImplementation("org.springframework.boot:spring-boot-starter-test") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } + +tasks.test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/backend/libs/github-client/src/main/java/dev/cleat/githubclient/dto/GitHubTokenResponse.java b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/dto/GitHubTokenResponse.java new file mode 100644 index 0000000..7019cf6 --- /dev/null +++ b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/dto/GitHubTokenResponse.java @@ -0,0 +1,29 @@ +package dev.cleat.githubclient.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class GitHubTokenResponse { + + private String token; + + @JsonProperty("expires_at") + private String expiresAt; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(String expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/GitHubClient.java b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/GitHubClient.java new file mode 100644 index 0000000..af8b74f --- /dev/null +++ b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/GitHubClient.java @@ -0,0 +1,38 @@ +package dev.cleat.githubclient.service; + +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +public class GitHubClient { + private final WebClient webClient; + private final TokenManager tokenManager; + private final RateLimiterService rateLimiterService; + + public GitHubClient( + WebClient.Builder webClientBuilder, TokenManager tokenManager, RateLimiterService rateLimiterService) { + this.webClient = webClientBuilder.build(); + this.tokenManager = tokenManager; + this.rateLimiterService = rateLimiterService; + } + + public T get(String uri, String installationId, Class responseType) { + rateLimiterService.checkLimit(installationId); + + String token = tokenManager.getInstallationToken(installationId); + + return webClient + .get() + .uri(uri) + .header("Authorization", "Bearer " + token) + .exchange() + .flatMap(response -> { + String remaining = + response.headers().header("X-RateLimit-Remaining").get(0); + rateLimiterService.updateLimit(installationId, remaining); + + return response.bodyToMono(responseType); + }) + .block(); + } +} diff --git a/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/RateLimiterService.java b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/RateLimiterService.java new file mode 100644 index 0000000..caaae40 --- /dev/null +++ b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/RateLimiterService.java @@ -0,0 +1,25 @@ +package dev.cleat.githubclient.service; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +public class RateLimiterService { + private final RedisTemplate redisTemplate; + + public RateLimiterService(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void checkLimit(String installationId) { + String remaining = redisTemplate.opsForValue().get("rate_limit:" + installationId); + + if (remaining != null && Integer.parseInt(remaining) <= 0) { + throw new RuntimeException("Rate limit exceeded for installation: " + installationId); + } + } + + public void updateLimit(String installationId, String remaining) { + redisTemplate.opsForValue().set("rate_limit:" + installationId, remaining); + } +} diff --git a/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/TokenManager.java b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/TokenManager.java new file mode 100644 index 0000000..9e3b64f --- /dev/null +++ b/backend/libs/github-client/src/main/java/dev/cleat/githubclient/service/TokenManager.java @@ -0,0 +1,83 @@ +package dev.cleat.githubclient.service; + +import dev.cleat.githubclient.dto.GitHubTokenResponse; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +public class TokenManager { + + private final RedisTemplate redisTemplate; + private final WebClient webClient; + + public TokenManager(RedisTemplate redisTemplate, WebClient webClient) { + this.redisTemplate = redisTemplate; + this.webClient = webClient; + } + + public String getInstallationToken(String installationId) { + String cachedToken = redisTemplate.opsForValue().get("token:" + installationId); + + if (cachedToken != null) { + return cachedToken; + } + + return mintNewToken(installationId); + } + + private String mintNewToken(String installationId) { + String jwt = generateJwt(); + + GitHubTokenResponse response = webClient + .post() + .uri("/app/installations/" + installationId + "/access_tokens") + .header("Authorization", "Bearer " + jwt) + .retrieve() + .bodyToMono(GitHubTokenResponse.class) + .block(); + + if (response == null || response.getToken() == null) { + throw new RuntimeException("GitHub-dan token almaq mümkün olmadı"); + } + + String newToken = response.getToken(); + + redisTemplate.opsForValue().set("token:" + installationId, newToken, Duration.ofSeconds(3600)); + + return newToken; + } + + private String generateJwt() { + try { + String key = new String(Files.readAllBytes(Paths.get( + getClass().getClassLoader().getResource("private-key.pem").toURI()))); + String privateKeyPEM = key.replace("-----BEGIN PRIVATE KEY-----", "") + .replaceAll(System.lineSeparator(), "") + .replace("-----END PRIVATE KEY-----", ""); + + byte[] encoded = Base64.getDecoder().decode(privateKeyPEM); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(encoded)); + + return Jwts.builder() + .setIssuer("YOUR_GITHUB_APP_ID") // Bura öz App ID-ni yaz + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 600000)) // 10 dəqiqəlik etibarlılıq + .signWith(privateKey, SignatureAlgorithm.RS256) + .compact(); + } catch (Exception e) { + throw new RuntimeException("JWT yaradılması zamanı xəta baş verdi", e); + } + } +} diff --git a/backend/libs/github-client/src/test/java/dev/cleat/githubclient/config/TestGitHubConfig.java b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/config/TestGitHubConfig.java new file mode 100644 index 0000000..3d42c7c --- /dev/null +++ b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/config/TestGitHubConfig.java @@ -0,0 +1,16 @@ +package dev.cleat.githubclient.config; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +@ComponentScan(basePackages = "dev.cleat.githubclient") +public class TestGitHubConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/GitHubIntegrationTest.java b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/GitHubIntegrationTest.java new file mode 100644 index 0000000..8f5fd59 --- /dev/null +++ b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/GitHubIntegrationTest.java @@ -0,0 +1,25 @@ +package dev.cleat.githubclient.service; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import dev.cleat.githubclient.config.TestGitHubConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest(classes = TestGitHubConfig.class) +class GitHubIntegrationTest { + + @MockitoBean + private RedisTemplate redisTemplate; + + @Autowired + private GitHubClient gitHubClient; + + @Test + void testFullFlow() { + assertNotNull(gitHubClient); + } +} diff --git a/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/RateLimiterServiceTest.java b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/RateLimiterServiceTest.java new file mode 100644 index 0000000..fcb80c4 --- /dev/null +++ b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/RateLimiterServiceTest.java @@ -0,0 +1,34 @@ +package dev.cleat.githubclient.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class RateLimiterServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private RateLimiterService rateLimiterService; + + @Test + void checkLimitThrowsExceptionWhenLimitIsZero() { + String id = "123"; + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("rate_limit:" + id)).thenReturn("0"); + + assertThrows(RuntimeException.class, () -> rateLimiterService.checkLimit(id)); + } +} diff --git a/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/TokenManagerTest.java b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/TokenManagerTest.java new file mode 100644 index 0000000..ed3b703 --- /dev/null +++ b/backend/libs/github-client/src/test/java/dev/cleat/githubclient/service/TokenManagerTest.java @@ -0,0 +1,51 @@ +package dev.cleat.githubclient.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class TokenManagerTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private TokenManager tokenManager; + + @Test + void getInstallationTokenReturnsCachedTokenWhenExistsInRedis() { + String installationId = "123"; + String expectedToken = "valid-token"; + + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("token:" + installationId)).thenReturn(expectedToken); + + String actualToken = tokenManager.getInstallationToken(installationId); + + assertEquals(expectedToken, actualToken); + verify(redisTemplate, times(1)).opsForValue(); + } + + @Test + void getInstallationTokenCallsMintNewTokenWhenNotInCache() { + String installationId = "123"; + + // leninet() istifadə edirik ki, metod çağırılmasa belə xəta verməsin + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(valueOperations.get("token:" + installationId)).thenReturn(null); + } +}