-
Notifications
You must be signed in to change notification settings - Fork 5
Feature/6 GitHub auth and rate limiting #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
40e7b49
d4ede46
73fec1b
e442857
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| .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); | ||
|
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) { | ||
|
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)); | ||
|
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()))); | ||
|
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 | ||
|
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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() { | ||
|
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); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.