Skip to content
6 changes: 6 additions & 0 deletions src/main/java/net/ironoc/portfolio/client/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

import module java.base;

import net.ironoc.portfolio.dto.RepositoryIssueCreateDto;
import net.ironoc.portfolio.dto.RepositoryIssueDto;

public interface Client {

<T> List<T> callGitHubApi(String uri, Class<T> type, String httpMethod, Map<String, Object> uriVariables);

RepositoryIssueDto createGitHubIssue(String uri, RepositoryIssueCreateDto requestBody,
Map<String, Object> uriVariables);
}
82 changes: 79 additions & 3 deletions src/main/java/net/ironoc/portfolio/client/GitClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.fasterxml.jackson.databind.type.CollectionType;
import net.ironoc.portfolio.aws.SecretManager;
import net.ironoc.portfolio.config.PropertyConfigI;
import net.ironoc.portfolio.dto.RepositoryIssueCreateDto;
import net.ironoc.portfolio.dto.RepositoryIssueDto;
import net.ironoc.portfolio.logger.AbstractLogger;
import net.ironoc.portfolio.utils.UrlUtils;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -23,6 +25,8 @@
@Slf4j
public class GitClient extends AbstractLogger implements Client {

private static final String GITHUB_API_HOST = "api.github.com";

private final PropertyConfigI propertyConfig;

private final SecretManager secretManager;
Expand Down Expand Up @@ -63,7 +67,7 @@
if (StringUtils.isBlank(token)) {
log.warn("GIT token not set, the lower request rate will apply");
} else {
headers.set("Authorization", token);
headers.set("Authorization", buildAuthorizationHeader(token));
}
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
Expand All @@ -87,8 +91,64 @@
return dtos;
}

@Override
public RepositoryIssueDto createGitHubIssue(String uri, RepositoryIssueCreateDto requestBody,
Map<String, Object> uriVariables) {
try {
URI validatedApiUri = getValidatedApiUri(uri, uriVariables);
if (validatedApiUri == null) {
return null;
}
info("Triggering POST request: url={}", validatedApiUri);
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/vnd.github+json");
headers.set("X-GitHub-Api-Version", "2022-11-28");
headers.set("Content-Type", "application/json");

String token = secretManager.getGitSecret();
if (StringUtils.isBlank(token)) {
log.warn("GIT token not set, the lower request rate will apply");
} else {
headers.set("Authorization", buildAuthorizationHeader(token));
}
HttpEntity<RepositoryIssueCreateDto> entity = new HttpEntity<>(requestBody, headers);
ResponseEntity<RepositoryIssueDto> response = restTemplate.exchange(
validatedApiUri, HttpMethod.POST, entity, RepositoryIssueDto.class);
Comment thread
Copilot marked this conversation as resolved.
if (response == null || response.getBody() == null) {
error("No response body received from GitHub API for issue creation: uri={}", validatedApiUri);
return null;
}
return response.getBody();
} catch (Exception ex) {
error("Unexpected error occurred while creating issue.", ex);
return null;
}
}

private URI getValidatedApiUri(String uri, Map<String, Object> uriVariables) {
URI targetUri = UriComponentsBuilder.fromUriString(uri)
String trustedUriTemplate = propertyConfig.getGitApiEndpointIssues();
if (StringUtils.isBlank(trustedUriTemplate) || !urlUtils.isValidURL(trustedUriTemplate)) {
log.error("The configured Git endpoint is invalid, url={}", trustedUriTemplate);
return null;
}

URI configuredBaseUri = UriComponentsBuilder.fromUriString(trustedUriTemplate).build().toUri();
if (!StringUtils.equalsIgnoreCase("https", configuredBaseUri.getScheme())
|| StringUtils.isNotBlank(configuredBaseUri.getUserInfo())
|| configuredBaseUri.getFragment() != null
|| !StringUtils.equalsIgnoreCase(GITHUB_API_HOST, configuredBaseUri.getHost())) {
log.error("The configured Git endpoint is not allowed, url={}", configuredBaseUri);
return null;
}

Object username = uriVariables.get("username");
Object repo = uriVariables.get("repo");
if (!isValidGitPathVariable(username) || !isValidGitPathVariable(repo)) {
log.error("Invalid URI path variables for GitHub API request, username={}, repo={}", username, repo);
return null;
}

URI targetUri = UriComponentsBuilder.fromUriString(trustedUriTemplate)
.buildAndExpand(uriVariables)
.encode()
.toUri();
Expand All @@ -99,13 +159,22 @@

if (!StringUtils.equalsIgnoreCase("https", targetUri.getScheme())
|| StringUtils.isNotBlank(targetUri.getUserInfo())
|| targetUri.getFragment() != null) {
|| targetUri.getFragment() != null
|| !StringUtils.equalsIgnoreCase(GITHUB_API_HOST, targetUri.getHost())) {
log.error("The url is not valid for GIT client connection, url={}", targetUri);
return null;
}
return targetUri;
}

private boolean isValidGitPathVariable(Object value) {
if (value == null) {
return false;
}
String text = StringUtils.trimToEmpty(String.valueOf(value));
return StringUtils.isNotBlank(text) && text.matches("^[A-Za-z0-9-]+$");
}

private <T> List<T> readJsonResponse(String jsonResponse, Class<T> type) throws Exception {
List<T> items;
CollectionType listType = objectMapper.getTypeFactory()
Expand All @@ -114,4 +183,11 @@
debug("List.of(DTO)={}", items);
return items;
}

protected String buildAuthorizationHeader(String token) {
String trimmedToken = StringUtils.trimToEmpty(token);
return StringUtils.startsWithIgnoreCase(trimmedToken, "Bearer ")
? trimmedToken
: "Bearer " + trimmedToken;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import module java.base;

import net.ironoc.portfolio.domain.RepositoryDetailDomain;
import net.ironoc.portfolio.domain.RepositoryIssueCreateDomain;
import net.ironoc.portfolio.domain.RepositoryIssueDomain;
import net.ironoc.portfolio.dto.RepositoryDetailDto;
import net.ironoc.portfolio.dto.RepositoryIssueCreateDto;
import net.ironoc.portfolio.dto.RepositoryIssueDto;
import net.ironoc.portfolio.logger.AbstractLogger;
import net.ironoc.portfolio.service.GitDetailsService;
Expand All @@ -18,6 +20,8 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand Down Expand Up @@ -76,6 +80,22 @@ public ResponseEntity<List<RepositoryIssueDomain>> getIssuesByUsernameAndRepoPat
return getIssuesByUsernameAndRepo(request, username, repository);
}

@Operation(summary = "Create project issue by GitHub username & repository (project name).",
description = "Creates a Github issue per 'username' & 'repository' path variables.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201",
description = "Successfully created GitHub issue for username & repository path variables.")
})
@PostMapping(value = {"/create-repo-issue/{username}/{repository}/"},
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<RepositoryIssueDomain> createIssueByUsernameAndRepoPathVars(HttpServletRequest request,
@PathVariable(value = "username") String username,
@PathVariable(value = "repository") String repository,
@RequestBody RepositoryIssueCreateDomain issue) {
return createIssueByUsernameAndRepo(request, username, repository, issue);
}

private ResponseEntity<List<RepositoryDetailDomain>> getReposSortedAndWithIssueCount(HttpServletRequest request, String username) {
// username validation (must contain only letters, numbers and/or dash chars)
String userId;
Expand Down Expand Up @@ -146,6 +166,47 @@ private ResponseEntity<List<RepositoryIssueDomain>> getIssuesByUsernameAndRepo(H
.body(gitDetailsService.mapIssuesToResponse(repositoryIssueDtos));
}

private ResponseEntity<RepositoryIssueDomain> createIssueByUsernameAndRepo(HttpServletRequest request,
String username,
String repository,
RepositoryIssueCreateDomain issue) {
String userId;
String repo;
if (!StringUtils.isNoneBlank(username, repository) || issue == null
|| StringUtils.isBlank(issue.getTitle())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
} else if (!StringUtils.isAlphanumericSpace(sanitizeValue(username)
.replaceAll("-", " "))) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
} else if (!StringUtils.isAlphanumericSpace(sanitizeValue(repository)
.replaceAll("-", " "))) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null);
} else {
List<String> pathVars = sanitizeValues(username, repository);
userId = pathVars.get(0);
repo = pathVars.get(1);
}

info("Github create issue by username={} and repo={} for request, host={}, uri={}, user-agent={}",
userId, repo,
request.getHeader("host"),
request.getRequestURI(),
request.getHeader("user-agent"));
RepositoryIssueDto createdIssue = gitDetailsService.createIssue(userId, repo,
RepositoryIssueCreateDto.builder()
.title(issue.getTitle())
.body(issue.getBody())
.assignees(issue.getAssignees())
.milestone(issue.getMilestone())
.labels(issue.getLabels())
.build());
if (createdIssue == null) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
}
List<RepositoryIssueDomain> issueDomain = gitDetailsService.mapIssuesToResponse(List.of(createdIssue));
return ResponseEntity.status(HttpStatus.CREATED).body(issueDomain.get(0));
}

private List<String> sanitizeValues(String... values) {
// trim leading and trailing whitespace
String sanitizedValueUserId = sanitizeValue(values[0]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package net.ironoc.portfolio.domain;

import module java.base;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

@Builder
@AllArgsConstructor
@Getter
public class RepositoryIssueCreateDomain {

@Schema(name = "title", description = "Issue title text.",
example = "Found a bug", requiredMode = Schema.RequiredMode.REQUIRED)
private String title;

@Schema(name = "body", description = "Issue content and description.",
example = "I'm having a problem with this.", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String body;

@Schema(name = "assignees", description = "Issue assignees.",
example = "[\"octocat\"]", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private List<String> assignees;

@Schema(name = "milestone", description = "Issue milestone number.",
example = "1", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private Integer milestone;

@Schema(name = "labels", description = "Issue labels.",
example = "[\"bug\"]", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private List<String> labels;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package net.ironoc.portfolio.dto;

import module java.base;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RepositoryIssueCreateDto {

private String title;

private String body;

private List<String> assignees;

private Integer milestone;

private List<String> labels;
}
3 changes: 3 additions & 0 deletions src/main/java/net/ironoc/portfolio/service/GitDetails.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import net.ironoc.portfolio.domain.RepositoryDetailDomain;
import net.ironoc.portfolio.domain.RepositoryIssueDomain;
import net.ironoc.portfolio.dto.RepositoryDetailDto;
import net.ironoc.portfolio.dto.RepositoryIssueCreateDto;
import net.ironoc.portfolio.dto.RepositoryIssueDto;

public interface GitDetails {
Expand All @@ -19,5 +20,7 @@ List<RepositoryDetailDto> mapResponseToRepositories(

List<RepositoryIssueDto> getIssues(String userId, String repo, boolean isJob);

RepositoryIssueDto createIssue(String userId, String repo, RepositoryIssueCreateDto requestBody);

List<RepositoryIssueDomain> mapIssuesToResponse(List<RepositoryIssueDto> repositoryIssueDtos);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import net.ironoc.portfolio.domain.RepositoryIssueDomain;
import net.ironoc.portfolio.dto.LabelDto;
import net.ironoc.portfolio.dto.RepositoryDetailDto;
import net.ironoc.portfolio.dto.RepositoryIssueCreateDto;
import net.ironoc.portfolio.dto.RepositoryIssueDto;
import net.ironoc.portfolio.logger.AbstractLogger;
import net.ironoc.portfolio.utils.UrlUtils;
Expand All @@ -31,6 +32,8 @@ public class GitDetailsService extends AbstractLogger implements GitDetails {
private final UrlUtils urlUtils;

protected static final String IRONOC_GIT_USER = "conorheffron";
private static final int DEFAULT_ISSUES_PAGE = 1;
private static final int DEFAULT_ISSUES_PER_PAGE = 1;

@Autowired
public GitDetailsService(PropertyConfigI propertyConfig,
Expand Down Expand Up @@ -68,7 +71,7 @@ public List<RepositoryDetailDto> getRepoDetails(String username, boolean isJob)
error("Illegal argument passed for uri value: {}", uri);
}
if (StringUtils.isBlank(apiUri) || StringUtils.isBlank(uri)
| !urlUtils.isValidURL(apiUri)) {
|| !urlUtils.isValidURL(apiUri)) {
warn("URL is not valid: url={}", apiUri);
return Collections.emptyList();
}
Expand Down Expand Up @@ -148,6 +151,30 @@ public List<RepositoryIssueDto> getIssues(String userId, String repo, boolean is
uriVariables);
}

@Override
public RepositoryIssueDto createIssue(String userId, String repo, RepositoryIssueCreateDto requestBody) {
String uri = propertyConfig.getGitApiEndpointIssues();
String apiUri = "";
try {
apiUri = UriComponentsBuilder.fromUriString(uri)
.buildAndExpand(userId, repo, DEFAULT_ISSUES_PER_PAGE, DEFAULT_ISSUES_PAGE)
.toUriString();
} catch (IllegalArgumentException e) {
error("Illegal argument passed for uri value: {}", uri);
}
if (StringUtils.isBlank(apiUri) || StringUtils.isBlank(uri)
|| !urlUtils.isValidURL(apiUri)) {
warn("URL is not valid: url={}", apiUri);
return null;
}
Map<String, Object> uriVariables = new HashMap<>();
uriVariables.put("username", userId);
uriVariables.put("repo", repo);
uriVariables.put("per_page", DEFAULT_ISSUES_PER_PAGE);
uriVariables.put("page", DEFAULT_ISSUES_PAGE);
return gitClient.createGitHubIssue(uri, requestBody, uriVariables);
}

@Override
public List<RepositoryIssueDomain> mapIssuesToResponse(List<RepositoryIssueDto> repositoryIssueDtos) {
return repositoryIssueDtos.stream()
Expand Down
Loading
Loading