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
13 changes: 12 additions & 1 deletion backend/libs/github-client/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
plugins {
`java-library`
id("java-library")
}

dependencies {
implementation(project(":libs:common"))

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()
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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> T get(String uri, String installationId, Class<T> responseType) {
rateLimiterService.checkLimit(installationId);

String token = tokenManager.getInstallationToken(installationId);

return webClient
.get()
.uri(uri)
.header("Authorization", "Bearer " + token)
.exchange()
Comment thread
nakberli841-bot marked this conversation as resolved.
.flatMap(response -> {
String remaining =
response.headers().header("X-RateLimit-Remaining").get(0);
rateLimiterService.updateLimit(installationId, remaining);

return response.bodyToMono(responseType);
})
.block();
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> redisTemplate;

public RateLimiterService(RedisTemplate<String, String> 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);
Comment thread
nakberli841-bot marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> redisTemplate;
private final WebClient webClient;

public TokenManager(RedisTemplate<String, String> redisTemplate, WebClient webClient) {
Comment thread
nakberli841-bot marked this conversation as resolved.
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));
Comment thread
nakberli841-bot marked this conversation as resolved.

return newToken;
}

private String generateJwt() {
try {
String key = new String(Files.readAllBytes(Paths.get(
getClass().getClassLoader().getResource("private-key.pem").toURI())));
Comment thread
nakberli841-bot marked this conversation as resolved.
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
Comment thread
nakberli841-bot marked this conversation as resolved.
.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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> redisTemplate;

@Autowired
private GitHubClient gitHubClient;

@Test
void testFullFlow() {
assertNotNull(gitHubClient);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named testFullFlow but it only checks the bean is non-null, and it only passes because TestGitHubConfig supplies the WebClient that prod lacks. A real flow test would stub the GitHub responses (MockWebServer or a WebClient exchange function) and assert get() sends the bearer token, parses the body, and updates the rate limit from the response headers.

}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> redisTemplate;

@Mock
private ValueOperations<String, String> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> redisTemplate;

@Mock
private ValueOperations<String, String> 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() {
Comment thread
nakberli841-bot marked this conversation as resolved.
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);
}
}
Loading