Skip to content

Commit

Permalink
Merge pull request #361 from yrodiere/list-installs
Browse files Browse the repository at this point in the history
Expose the application GitHub client so that apps can list their own installations
  • Loading branch information
gsmet authored Sep 8, 2022
2 parents 4ddf48f + 6cee0d9 commit 7f5e4f2
Show file tree
Hide file tree
Showing 19 changed files with 407 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ private static void generateDispatcher(ClassOutput beanClassOutput,
TryBlock tryBlock = dispatchMethodCreator.tryBlock();

ResultHandle gitHubRh = tryBlock.invokeVirtualMethod(
MethodDescriptor.ofMethod(GitHubService.class, "getInstallationClient", GitHub.class, Long.class),
MethodDescriptor.ofMethod(GitHubService.class, "getInstallationClient", GitHub.class, long.class),
tryBlock.readInstanceField(
FieldDescriptor.of(dispatcherClassCreator.getClassName(), GITHUB_SERVICE_FIELD, GitHubService.class),
tryBlock.getThis()),
Expand All @@ -459,7 +459,7 @@ private static void generateDispatcher(ClassOutput beanClassOutput,
if (dispatchingConfiguration.requiresGraphQLClient()) {
gitHubGraphQLClientRh = tryBlock.invokeVirtualMethod(
MethodDescriptor.ofMethod(GitHubService.class, "getInstallationGraphQLClient", DynamicGraphQLClient.class,
Long.class),
long.class),
tryBlock.readInstanceField(
FieldDescriptor.of(dispatcherClassCreator.getClassName(), GITHUB_SERVICE_FIELD,
GitHubService.class),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.quarkiverse.githubapp.deployment;

import static org.assertj.core.api.Assertions.assertThat;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkiverse.githubapp.GitHubClientProvider;
import io.quarkiverse.githubapp.runtime.github.GitHubService;
import io.quarkus.arc.Arc;
import io.quarkus.test.QuarkusUnitTest;

public class GitHubClientProviderInjectionTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class))
.withConfigurationResource("application.properties");

@Test
public void test() {
assertThat(Arc.container().instance(GitHubClientProvider.class).get())
.isInstanceOf(GitHubService.class);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.quarkiverse.githubapp.deployment.junit;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;

Expand Down Expand Up @@ -77,7 +78,7 @@ <T extends GHObject> MockMap<Long, T> nonRepositoryMockMap(Class<T> type) {
void init() {
reset();

when(service.getInstallationClient(any()))
when(service.getInstallationClient(anyLong()))
.thenAnswer(invocation -> client(invocation.getArgument(0, Long.class)));
}

Expand Down
6 changes: 6 additions & 0 deletions integration-tests/testing-framework/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!-- For MockitoExtension; not everyone will want to use that, so the testing framework itself doesn't depend on it. -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

<!-- Minimal test dependencies to *-deployment artifacts for consistent build order -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.quarkiverse.githubapp.it.testingframework;

import java.io.IOException;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;

import io.quarkiverse.githubapp.GitHubClientProvider;

@ApplicationScoped
public class BackgroundProcessor {

public static Behavior behavior;

@Inject
GitHubClientProvider clientProvider;

public void process() throws IOException {
behavior.execute(clientProvider);
}

public interface Behavior {
void execute(GitHubClientProvider clientProvider) throws IOException;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.quarkiverse.githubapp.it.testingframework;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.util.Iterator;
import java.util.List;

import org.kohsuke.github.PagedIterator;
import org.kohsuke.github.PagedSearchIterable;

class MockHelper {

@SafeVarargs
@SuppressWarnings("unchecked")
public static <T> PagedSearchIterable<T> mockPagedIterable(T... contentMocks) {
PagedSearchIterable<T> iterableMock = mock(PagedSearchIterable.class);
when(iterableMock.iterator()).thenAnswer(ignored -> {
PagedIterator<T> iteratorMock = mock(PagedIterator.class);
Iterator<T> actualIterator = List.of(contentMocks).iterator();
when(iteratorMock.next()).thenAnswer(ignored2 -> actualIterator.next());
when(iteratorMock.hasNext()).thenAnswer(ignored2 -> actualIterator.hasNext());
return iteratorMock;
});
return iterableMock;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,24 @@
import static org.mockito.Mockito.verifyNoMoreInteractions;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import javax.inject.Inject;

import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.kohsuke.github.GHApp;
import org.kohsuke.github.GHAppInstallation;
import org.kohsuke.github.GHEvent;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.PagedIterable;
import org.kohsuke.github.PagedSearchIterable;
import org.kohsuke.github.ReactionContent;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;

import io.quarkiverse.githubapp.testing.GitHubAppTest;
import io.quarkus.test.junit.QuarkusTest;
Expand All @@ -24,6 +35,9 @@
@GitHubAppTest
public class TestingFrameworkTest {

@Inject
BackgroundProcessor backgroundProcessor;

@Test
void ghObjectMocking() {
String[] capture = new String[1];
Expand Down Expand Up @@ -172,4 +186,73 @@ void getUserLogin() {
assertThat(capture[0]).isEqualTo("dependabot[bot]");
}

@Test
@ExtendWith(MockitoExtension.class) // To get strict stubs, which simplifies verifyNoMoreInteractions() (stubbed calls are verified automatically)
void clientProvider() {
List<String> capture = new ArrayList<>();
// Use case: a background processor goes through all installations of the app,
// to perform an operation on every single repository.
BackgroundProcessor.behavior = (clientProvider) -> {
for (GHAppInstallation installation : clientProvider.getApplicationClient().getApp().listInstallations()) {
for (GHRepository repository : installation.listRepositories()) {
GitHub installationClient = clientProvider.getInstallationClient(installation.getId());
// Get the repository with enhanced permissions thanks to the installation client.
repository = installationClient.getRepository(repository.getName());
// Simulate doing stuff with the repository.
// Normally that stuff would require enhanced permissions,
// but here's we're just calling getFullName() to simplify.
capture.add(repository.getFullName());
}
}
};

GHApp app = Mockito.mock(GHApp.class);
GHAppInstallation installation1 = Mockito.mock(GHAppInstallation.class);
GHAppInstallation installation2 = Mockito.mock(GHAppInstallation.class);
GHRepository installation1Repo1 = Mockito.mock(GHRepository.class);
GHRepository installation2Repo1 = Mockito.mock(GHRepository.class);
GHRepository installation2Repo2 = Mockito.mock(GHRepository.class);

assertThatCode(() -> given()
.github(mocks -> {
Mockito.when(mocks.applicationClient().getApp()).thenReturn(app);
Mockito.when(installation1.getId()).thenReturn(1L);
Mockito.when(installation2.getId()).thenReturn(2L);
PagedIterable<GHAppInstallation> appInstallations = MockHelper.mockPagedIterable(installation1,
installation2);
Mockito.when(app.listInstallations()).thenReturn(appInstallations);

Mockito.when(installation1Repo1.getName()).thenReturn("quarkus");
PagedSearchIterable<GHRepository> installation1Repos = MockHelper.mockPagedIterable(installation1Repo1);
Mockito.when(installation1.listRepositories())
.thenReturn(installation1Repos);

Mockito.when(installation2Repo1.getName()).thenReturn("quarkus-github-app");
Mockito.when(installation2Repo2.getName()).thenReturn("quarkus-github-api");
PagedSearchIterable<GHRepository> installation2Repos = MockHelper.mockPagedIterable(installation2Repo1,
installation2Repo2);
Mockito.when(installation2.listRepositories())
.thenReturn(installation2Repos);

// Installation clients will return different Repository objects than the application client:
// that's expected.
Mockito.when(mocks.repository("quarkus").getFullName()).thenReturn("quarkusio/quarkus");
Mockito.when(mocks.repository("quarkus-github-app").getFullName())
.thenReturn("quarkiverse/quarkus-github-app");
Mockito.when(mocks.repository("quarkus-github-api").getFullName())
.thenReturn("quarkiverse/quarkus-github-api");
})
.when(backgroundProcessor::process)
.then().github(mocks -> {
Mockito.verifyNoMoreInteractions(app, installation1, installation2, installation1Repo1, installation2Repo1,
installation2Repo2);
Mockito.verifyNoMoreInteractions(mocks.ghObjects());
}))
.doesNotThrowAnyException();
assertThat(capture).containsExactlyInAnyOrder(
"quarkusio/quarkus",
"quarkiverse/quarkus-github-app",
"quarkiverse/quarkus-github-api");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.quarkiverse.githubapp;

import org.kohsuke.github.GHApp;
import org.kohsuke.github.GitHub;

import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient;

/**
* A provider of {@link org.kohsuke.github.GitHub GitHub clients} for the GitHub app.
* <p>
* Inject as a CDI bean.
* <p>
* <strong>NOTE:</strong> You generally will not need this bean when processing events,
* as clients can be automatically injected into event listener methods,
* simply by adding a parameter of type {@link GitHub} or {@link DynamicGraphQLClient} to the listener method.
* This provider is mostly useful for non-event use cases (e.g. cron jobs).
*/
public interface GitHubClientProvider {

/**
* Gets the {@link GitHub GitHub client} for the application:
* it can be used without any installation, but has very little access rights (almost as little as an anonymous client).
* <p>
* The client will remain functional a few minutes at best,
* so you should discard it as soon as possible and retrieve another one when necessary.
* <p>
* <strong>NOTE:</strong> You generally will not need this method when processing events, as the more powerful
* {@link #getInstallationClient(long) installation client} gets automatically injected into event listeners.
* This method can still be useful for non-event use cases (e.g. cron jobs),
* to {@link GitHub#getApp() retrieve information about the application},
* in particular {@link GHApp#listInstallations() list application installations}.
*
* @return The application client.
*/
GitHub getApplicationClient();

/**
* Gets the {@link GitHub GitHub client} for a given application installation.
* <p>
* The client will remain functional a few minutes at best,
* so you should discard it as soon as possible and retrieve another one when necessary.
* <p>
* <strong>NOTE:</strong> You generally will not need this method when processing events,
* as this client can be automatically injected into event listener listener methods,
* simply by adding a parameter of type {@link GitHub} to the method.
* This method can still be useful for non-event use cases (e.g. cron jobs),
* to retrieve installation clients after having {@link GHApp#listInstallations() list application installations}
* from the {@link #getApplicationClient() application client}.
*
* @return The client for the given installation.
*/
GitHub getInstallationClient(long installationId);

/**
* Gets the {@link DynamicGraphQLClient GraphQL GitHub client} for a given application installation.
* <p>
* The client will remain functional a few minutes at best,
* so you should discard it as soon as possible and retrieve another one when necessary.
* <p>
* <strong>NOTE:</strong> You generally will not need this method when processing events,
* as this client can be automatically injected into event listener methods,
* simply by adding a parameter of type {@link DynamicGraphQLClient} to the listener method.
* This method can still be useful for non-event use cases (e.g. cron jobs),
* to retrieve installation clients after having {@link GHApp#listInstallations() list application installations}
* from the {@link #getApplicationClient() application client}.
*
* @return The client for the given installation.
*/
DynamicGraphQLClient getInstallationGraphQLClient(long installationId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ private static boolean isBlank(String value) {
return value == null || value.isBlank();
}

private Long extractInstallationId(JsonObject body) {
public Long extractInstallationId(JsonObject body) {
Long installationId;

JsonObject installation = body.getJsonObject("installation");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@
import com.github.benmanes.caffeine.cache.Expiry;
import com.github.benmanes.caffeine.cache.LoadingCache;

import io.quarkiverse.githubapp.GitHubClientProvider;
import io.quarkiverse.githubapp.runtime.config.GitHubAppRuntimeConfig;
import io.quarkiverse.githubapp.runtime.signing.JwtTokenCreator;
import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient;
import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClientBuilder;

@ApplicationScoped
public class GitHubService {
public class GitHubService implements GitHubClientProvider {

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String AUTHORIZATION_HEADER_BEARER = "Bearer %s";
Expand Down Expand Up @@ -68,7 +69,8 @@ public long expireAfterRead(Long installationId, CachedInstallationToken cachedI
.build(new CreateInstallationToken());
}

public GitHub getInstallationClient(Long installationId) {
@Override
public GitHub getInstallationClient(long installationId) {
try {
return createInstallationClient(installationId);
} catch (IOException e1) {
Expand All @@ -90,7 +92,8 @@ public GitHub getInstallationClient(Long installationId) {
}
}

public DynamicGraphQLClient getInstallationGraphQLClient(Long installationId) {
@Override
public DynamicGraphQLClient getInstallationGraphQLClient(long installationId) {
try {
return createInstallationGraphQLClient(installationId);
} catch (IOException | ExecutionException | InterruptedException e1) {
Expand All @@ -112,7 +115,7 @@ public DynamicGraphQLClient getInstallationGraphQLClient(Long installationId) {
}
}

private GitHub createInstallationClient(Long installationId) throws IOException {
private GitHub createInstallationClient(long installationId) throws IOException {
CachedInstallationToken installationToken = installationTokenCache.get(installationId);

final GitHubBuilder gitHubBuilder = new GitHubBuilder()
Expand All @@ -127,7 +130,7 @@ private GitHub createInstallationClient(Long installationId) throws IOException
return gitHub;
}

private DynamicGraphQLClient createInstallationGraphQLClient(Long installationId)
private DynamicGraphQLClient createInstallationGraphQLClient(long installationId)
throws IOException, ExecutionException, InterruptedException {
CachedInstallationToken installationToken = installationTokenCache.get(installationId);

Expand Down Expand Up @@ -167,6 +170,11 @@ public CachedInstallationToken load(Long installationId) throws Exception {
}

// TODO even if we have a cache for the other one, we should probably also keep this one around for a few minutes
@Override
public GitHub getApplicationClient() {
return createApplicationGitHub();
}

private GitHub createApplicationGitHub() {
String jwtToken;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ public interface EventContextSpecification {
<T extends Throwable> EventContextSpecification github(GitHubMockSetup<T> gitHubMockSetup) throws T;

EventSenderOptions when();

<T extends Throwable> EventHandlingResponse when(TestedAction<T> action) throws T;
}
Loading

0 comments on commit 7f5e4f2

Please sign in to comment.