Skip to content

Commit

Permalink
Expose config file retrieval through a service
Browse files Browse the repository at this point in the history
  • Loading branch information
yrodiere committed Sep 12, 2022
1 parent 2c6dbc6 commit 7faa8a1
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 56 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@
import io.quarkiverse.githubapp.deployment.DispatchingConfiguration.EventDispatchingMethod;
import io.quarkiverse.githubapp.error.ErrorHandler;
import io.quarkiverse.githubapp.event.Actions;
import io.quarkiverse.githubapp.runtime.ConfigFileReader;
import io.quarkiverse.githubapp.runtime.GitHubAppRecorder;
import io.quarkiverse.githubapp.runtime.MultiplexedEvent;
import io.quarkiverse.githubapp.runtime.Multiplexer;
import io.quarkiverse.githubapp.runtime.RequestScopeCachingGitHubConfigFileProvider;
import io.quarkiverse.githubapp.runtime.error.DefaultErrorHandler;
import io.quarkiverse.githubapp.runtime.error.ErrorHandlerBridgeFunction;
import io.quarkiverse.githubapp.runtime.github.GitHubFileDownloader;
Expand Down Expand Up @@ -676,7 +676,7 @@ private static void generateMultiplexers(ClassOutput beanClassOutput,
j++;
}
if (originalMethod.hasAnnotation(CONFIG_FILE)) {
parameterTypes.add(ConfigFileReader.class.getName());
parameterTypes.add(RequestScopeCachingGitHubConfigFileProvider.class.getName());
}

MethodCreator methodCreator = multiplexerClassCreator.getMethodCreator(
Expand Down Expand Up @@ -767,7 +767,8 @@ private static void generateMultiplexers(ClassOutput beanClassOutput,
.ofMethod(PayloadHelper.class, "getRepository", GHRepository.class, GHEventPayload.class),
payloadRh);
ResultHandle configObject = tryBlock.invokeVirtualMethod(
MethodDescriptor.ofMethod(ConfigFileReader.class, "getConfigObject", Object.class,
MethodDescriptor.ofMethod(RequestScopeCachingGitHubConfigFileProvider.class, "getConfigObject",
Object.class,
GHRepository.class, String.class, ConfigFile.Source.class, Class.class),
configFileReaderRh,
ghRepositoryRh,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

@QuarkusTest
@GitHubAppTest
public class ConfigFileReaderCliTest {
public class ConfigFileProviderCliTest {

public static final String HELLO = "hello from config file reader";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.quarkiverse.githubapp;

import java.util.Optional;

import org.kohsuke.github.GHRepository;

/**
* A provider of configuration files fetched from {@link org.kohsuke.github.GHRepository GitHub repositories}.
* <p>
* Inject as a CDI bean.
* <p>
* <strong>NOTE:</strong> You generally will not need this bean when processing events,
* as configuration files can be automatically injected into event listener methods,
* simply by annotating a parameter with {@link ConfigFile}.
* This provider is mostly useful for non-event use cases (e.g. cron jobs).
*
* @see ConfigFile
* @see ConfigFile.Source
*/
public interface GitHubConfigFileProvider {

/**
* Fetches the configuration file at the given path from the main branch of the given repository,
* optionally (if {@code type} is not just {@link String}) deserializing it to the given type using Jackson.
* <p>
* <strong>NOTE:</strong> You generally will not need this method when processing events,
* as configuration files can be automatically injected into event listener methods,
* simply by annotating a parameter with {@link ConfigFile}.
* This provider is mostly useful for non-event use cases (e.g. cron jobs).
*
* @param repository The GitHub code repository to retrieve the file from.
* @param path The path to the file in the code repository,
* either absolute (if it starts with {@code /}) or relative to {@code /.github/} (if it doesn't start with
* {@code /}).
* @param source Which repository to extract the file from in the case of forked repositories.
* @param type The type to deserialize the file to.
* @return The configuration file wrapped in an {@link java.util.Optional}, or {@link Optional#empty()} if it is missing.
* @throws java.io.IOException If the configuration file cannot be retrieved.
* @throws IllegalStateException If the configuration file cannot be deserialized to the given type.
* @see ConfigFile
* @see ConfigFile.Source
*/
<T> Optional<T> fetchConfigFile(GHRepository repository, String path, ConfigFile.Source source, Class<T> type);

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

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

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

import org.kohsuke.github.GHRepository;

import io.quarkiverse.githubapp.ConfigFile;
import io.quarkiverse.githubapp.GitHubConfigFileProvider;
import io.quarkiverse.githubapp.runtime.config.GitHubAppRuntimeConfig;
import io.quarkiverse.githubapp.runtime.github.GitHubConfigFileProviderImpl;

@RequestScoped
public class RequestScopeCachingGitHubConfigFileProvider {

@Inject
GitHubAppRuntimeConfig gitHubAppRuntimeConfig;

@Inject
GitHubConfigFileProvider gitHubConfigFileProvider;

private final Map<String, Object> cache = new ConcurrentHashMap<>();

public Object getConfigObject(GHRepository ghRepository, String path, ConfigFile.Source source, Class<?> type) {
String cacheKey = getCacheKey(ghRepository, path, source);

Object cachedObject = cache.get(cacheKey);
if (cachedObject != null) {
return cachedObject;
}

return cache.computeIfAbsent(cacheKey,
k -> gitHubConfigFileProvider.fetchConfigFile(ghRepository, path, source, type).orElse(null));
}

private String getCacheKey(GHRepository ghRepository, String path,
ConfigFile.Source source) {
String fullPath = GitHubConfigFileProviderImpl.getFilePath(path.trim());
ConfigFile.Source effectiveSource = gitHubAppRuntimeConfig.getEffectiveSource(source);
// we should only handle the config files of one repository in a given ConfigFileReader
// as it's request scoped but let's be on the safe side
return ghRepository.getFullName() + ":" + effectiveSource.name() + ":" + fullPath;
}

}
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
package io.quarkiverse.githubapp.runtime;
package io.quarkiverse.githubapp.runtime.github;

import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

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

import org.kohsuke.github.GHRepository;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.quarkiverse.githubapp.ConfigFile;
import io.quarkiverse.githubapp.runtime.UtilsProducer.Yaml;
import io.quarkiverse.githubapp.GitHubConfigFileProvider;
import io.quarkiverse.githubapp.runtime.UtilsProducer;
import io.quarkiverse.githubapp.runtime.config.GitHubAppRuntimeConfig;
import io.quarkiverse.githubapp.runtime.github.GitHubFileDownloader;

@RequestScoped
public class ConfigFileReader {
@ApplicationScoped
public class GitHubConfigFileProviderImpl implements GitHubConfigFileProvider {

private static final List<String> YAML_EXTENSIONS = Arrays.asList(".yml", ".yaml");
private static final List<String> JSON_EXTENSIONS = Collections.singletonList(".json");
Expand All @@ -32,8 +30,6 @@ public class ConfigFileReader {
private static final String PARENT_DIRECTORY = "..";
private static final String ROOT_DIRECTORY = "/";

private final Map<String, Object> cache = new ConcurrentHashMap<>();

@Inject
GitHubAppRuntimeConfig gitHubAppRuntimeConfig;

Expand All @@ -44,52 +40,49 @@ public class ConfigFileReader {
ObjectMapper jsonObjectMapper;

@Inject
@Yaml
@UtilsProducer.Yaml
ObjectMapper yamlObjectMapper;

public Object getConfigObject(GHRepository ghRepository, String path, ConfigFile.Source source, Class<?> type) {
String fullPath = getFilePath(path.trim());
ConfigFile.Source effectiveSource = gitHubAppRuntimeConfig.getEffectiveSource(source);
String cacheKey = getCacheKey(ghRepository, fullPath, effectiveSource);

Object cachedObject = cache.get(cacheKey);
if (cachedObject != null) {
return cachedObject;
}

return cache.computeIfAbsent(cacheKey, k -> readConfigFile(ghRepository, fullPath, effectiveSource, type));
}
@Override
public <T> Optional<T> fetchConfigFile(GHRepository repository, String path, ConfigFile.Source source, Class<T> type) {
GHRepository configGHRepository = getConfigRepository(repository, source, path);

private Object readConfigFile(GHRepository currentGhRepository, String fullPath, ConfigFile.Source source, Class<?> type) {
GHRepository ghRepository = getConfigRepository(currentGhRepository, source, fullPath);
String fullPath = getFilePath(path);

Optional<String> contentOptional = gitHubFileDownloader.getFileContent(ghRepository, fullPath);
Optional<String> contentOptional = gitHubFileDownloader.getFileContent(configGHRepository, fullPath);
if (contentOptional.isEmpty()) {
return null;
return Optional.empty();
}

String content = contentOptional.get();

if (matchExtensions(fullPath, TEXT_EXTENSIONS) && !String.class.equals(type)) {
throw new IllegalArgumentException(
"Text extensions (" + String.join(", ", TEXT_EXTENSIONS) + ") only support String: " + fullPath
+ " required type " + type.getName());
}

if (String.class.equals(type)) {
return content;
@SuppressWarnings("unchecked")
Optional<T> result = (Optional<T>) contentOptional;
return result;
}

try {
ObjectMapper objectMapper = getObjectMapper(fullPath);
return objectMapper.readValue(content, type);
return Optional.ofNullable(objectMapper.readValue(contentOptional.get(), type));
} catch (Exception e) {
throw new IllegalStateException("Error deserializing config file " + fullPath + " to type " + type.getName(), e);
}
}

public ConfigFile.Source getEffectiveSource(ConfigFile.Source source) {
return (source == ConfigFile.Source.DEFAULT)
? (gitHubAppRuntimeConfig.readConfigFilesFromSourceRepository ? ConfigFile.Source.SOURCE_REPOSITORY
: ConfigFile.Source.CURRENT_REPOSITORY)
: source;
}

private GHRepository getConfigRepository(GHRepository ghRepository, ConfigFile.Source source, String path) {
ConfigFile.Source effectiveSource = gitHubAppRuntimeConfig.getEffectiveSource(source);
ConfigFile.Source effectiveSource = getEffectiveSource(source);

if (effectiveSource == ConfigFile.Source.CURRENT_REPOSITORY) {
return ghRepository;
Expand All @@ -113,13 +106,6 @@ private GHRepository getConfigRepository(GHRepository ghRepository, ConfigFile.S
}
}

private static String getCacheKey(GHRepository ghRepository, String fullPath,
ConfigFile.Source effectiveSource) {
// we should only handle the config files of one repository in a given ConfigFileReader
// as it's request scoped but let's be on the safe side
return ghRepository.getFullName() + ":" + effectiveSource.name() + ":" + fullPath;
}

private ObjectMapper getObjectMapper(String path) {
if (matchExtensions(path, YAML_EXTENSIONS)) {
return yamlObjectMapper;
Expand All @@ -139,15 +125,17 @@ private static boolean matchExtensions(String path, Collection<String> extension
return false;
}

private static String getFilePath(String path) {
if (path.contains(PARENT_DIRECTORY)) {
public static String getFilePath(String path) {
String trimmedPath = path.trim();

if (trimmedPath.contains(PARENT_DIRECTORY)) {
throw new IllegalArgumentException("Config file paths containing '..' are not accepted: " + path);
}

if (path.startsWith(ROOT_DIRECTORY)) {
if (trimmedPath.startsWith(ROOT_DIRECTORY)) {
return path.substring(1);
}

return DEFAULT_DIRECTORY + path;
return DEFAULT_DIRECTORY + trimmedPath;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.mockito.MockSettings;
import org.mockito.Mockito;

import io.quarkiverse.githubapp.runtime.github.GitHubConfigFileProviderImpl;
import io.quarkiverse.githubapp.runtime.github.GitHubFileDownloader;
import io.quarkiverse.githubapp.runtime.github.GitHubService;
import io.quarkiverse.githubapp.testing.dsl.GitHubMockContext;
Expand Down Expand Up @@ -96,7 +97,7 @@ public void configFileFromClasspath(String pathInRepository, String pathInClassP

@Override
public void configFileFromString(String pathInRepository, String configFile) {
when(fileDownloader.getFileContent(any(), eq(getGitHubFilePath(pathInRepository))))
when(fileDownloader.getFileContent(any(), eq(GitHubConfigFileProviderImpl.getFilePath(pathInRepository))))
.thenReturn(Optional.of(configFile));
}

Expand Down Expand Up @@ -181,14 +182,6 @@ void reset() {
}
}

private static String getGitHubFilePath(String path) {
if (path.startsWith("/")) {
return path.substring(1);
}

return ".github/" + path;
}

private DefaultableMocking<? extends GHObject> ghObjectMocking(GHObject original) {
Class<? extends GHObject> type = original.getClass();
if (GHRepository.class.equals(type)) {
Expand Down

0 comments on commit 7faa8a1

Please sign in to comment.