JiraClient.java
package access.jira;
import access.config.Config;
import access.exception.InvalidInputException;
import access.exception.JiraUnavailableException;
import access.mail.MailBox;
import access.model.EntityType;
import access.remote.RestTemplateFactory;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableMap;
import lombok.SneakyThrows;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Pattern;
@EnableConfigurationProperties({JiraConfig.class, Config.class})
@Service
public class JiraClient {
private static final Logger LOG = LoggerFactory.getLogger(JiraClient.class);
private static final Pattern JIRA_ISSUE_KEY = Pattern.compile("^[A-Z][A-Z0-9]*-[0-9]+$");
//Jira usernames of "SURFconext Second Line [SURF-GROUP]" and "SURF Klantsupport [SURF-GROUP]"
private static final String QTC_REPORTER = "sa-jira-surfconext-secondline-grpusr@surf.nl";
private static final String QTC_ASSIGNEE = "sa-jira-klantsupport-grpusr@surf.nl";
private final JiraConfig jiraConfig;
private final Config config;
private final MailBox mailBox;
private final Map<String, Map<String, Map<String, String>>> mappings;
private final String issueType;
private final String issueTypeQTC;
private RestTemplate restTemplate;
private RestTemplate restTemplateQTC;
@SneakyThrows
@SuppressWarnings("unchcked")
public JiraClient(JiraConfig jiraConfig, Config config, ObjectMapper objectMapper, MailBox mailBox) {
this.jiraConfig = jiraConfig;
this.config = config;
this.mailBox = mailBox;
this.mappings = objectMapper.readValue(new ClassPathResource("jira/mappings.json").getInputStream(), new TypeReference<>() {
});
this.issueType = this.resolveIssueType("change");
this.issueTypeQTC = this.resolveIssueType("qtc");
if (jiraConfig.isEnabled()) {
this.restTemplate = RestTemplateFactory.buildRestTemplate(jiraConfig.getApiKey());
this.restTemplateQTC = RestTemplateFactory.buildRestTemplate(jiraConfig.getApiKeyQtc());
}
}
//Callers must be able to still persist the change request / entity a Jira ticket was meant to accompany even
//when Jira itself is unavailable (see JiraCreateResult), rather than aborting before Manage/the DB is touched
public record JiraCreateResult(String key, RuntimeException error) {
private static final String UNAVAILABLE_PLACEHOLDER = "JIRA-DOWN";
private static JiraCreateResult success(String key) {
return new JiraCreateResult(key, null);
}
private static JiraCreateResult failure(RuntimeException error) {
return new JiraCreateResult(null, error);
}
//The key to store alongside the change request / entity, regardless of whether Jira succeeded
public String keyOrPlaceholder() {
return error == null ? key : UNAVAILABLE_PLACEHOLDER;
}
//Call after the change request / entity has been persisted - throws only now, so a failure here
//still alerts the caller without undoing the persist that already happened
public void throwIfFailed() {
if (error != null) {
throw new JiraUnavailableException(error);
}
}
}
@SuppressWarnings("unchecked")
public JiraCreateResult create(JiraIssue issue, boolean isQtc) {
if (!jiraConfig.isEnabled()) {
return JiraCreateResult.success(String.format("CXT-%s", ThreadLocalRandom.current().nextInt(1000, 10000)));
}
Map<String, Object> fields = new HashMap<>();
fields.put("project", Map.of("key", isQtc ? jiraConfig.getProjectKeyQtc() : jiraConfig.getProjectKey()));
if (isQtc) {
fields.put("customfield_" + customField("service"), Map.of("value", "SURFconext"));
fields.put("reporter", Map.of("name", QTC_REPORTER));
fields.put("assignee", Map.of("name", QTC_ASSIGNEE));
} else {
fields.put("customfield_" + spCustomField(), issue.getServiceProviderEntityID());
fields.put("customfield_" + idpCustomField(), issue.getIdentityProviderEntityID());
EntityType entityType = issue.getEntityType().equals(EntityType.oauth20_rs) ? EntityType.oidc10_rp : issue.getEntityType();
fields.put("customfield_" + typeMetaDataCustomField(), Map.of("value", entityType.name()));
String manageIdentifier = issue.getManageIdentifier();
if (StringUtils.hasText(manageIdentifier)) {
String accessCatalogusAppURL = String.format("%s/application-detail/%s/%s",
config.getClientUrl(), issue.getEntityType().name(), manageIdentifier);
fields.put("customfield_" + accessCatalogusAppURLCustomField(), accessCatalogusAppURL);
}
fields.put("duedate", dueDate());
}
fields.put("customfield_" + emailToCustomField(isQtc), issue.getEmailTo());
fields.put("issuetype", ImmutableMap.of("id", isQtc ? issueTypeQTC : issueType));
fields.put("summary", issue.getSummary());
fields.put("description", issue.getDescription());
//We don't send keys with null or empty values
fields.entrySet().removeIf(entry -> entry.getValue() instanceof String && !StringUtils.hasText((String) entry.getValue()));
Map<String, Map<String, Object>> jiraIssue = Map.of("fields", fields);
LOG.info("Sending JSON {} to JIRA", jiraIssue);
String url = jiraConfig.getBaseUrl() + "/issue";
try {
Map<String, String> result = (isQtc ? restTemplateQTC : restTemplate).postForObject(url, jiraIssue, Map.class);
LOG.info("Response {} from JIRA", result);
if (!result.containsKey("key")) {
mailBox.sendJiraError("create", url, jiraIssue.toString(), "Unexpected JIRA results", result.toString());
}
return JiraCreateResult.success(result.get("key"));
} catch (RuntimeException e) {
String responseBody;
if (e instanceof HttpClientErrorException clientException) {
responseBody = clientException.getResponseBodyAsString();
LOG.error("Failed to create Jira issue: {} ({}) with response:{}, JSON Request: {}",
clientException.getStatusCode(),
clientException.getStatusText(),
clientException.getResponseBodyAsString(),
jiraIssue,
e);
} else {
responseBody = e.getMessage();
LOG.error("Failed to create Jira issue: {} ({}) with response:{}, JSON Request: {}",
"400",
responseBody,
responseBody,
jiraIssue,
e);
}
mailBox.sendJiraError("create", url, jiraIssue.toString(), e.getMessage(), responseBody);
return JiraCreateResult.failure(e);
}
}
//Deliberately still throws directly, unlike #create: a comment is always posted on a ticket for a change
//request that already exists, so there is nothing pending on this call succeeding that would need protecting
//from a transaction rollback the way #create's callers do
public void comment(String jiraKey, String comment) {
if (!jiraConfig.isEnabled() || !StringUtils.hasText(jiraKey)) {
return;
}
//jiraKey ends up concatenated into the request URL below - reject anything that isn't a well-formed
//Jira issue key (e.g. path-traversal segments) before it can redirect the request elsewhere on the host
if (!JIRA_ISSUE_KEY.matcher(jiraKey).matches()) {
throw new InvalidInputException("Invalid Jira issue key: " + jiraKey);
}
String commentUrl = jiraConfig.getBaseUrl() + "/issue/" + jiraKey + "/comment";
Map<String, String> body = Map.of("body", comment);
HttpEntity<Object> commentRequestEntity = new HttpEntity<>(body);
LOG.info("Sending JSON {} to JIRA", body);
try {
ResponseEntity<Map> responseEntity = restTemplate.exchange(commentUrl, HttpMethod.POST, commentRequestEntity, Map.class);
Map responseBody = responseEntity.getBody();
LOG.info("Response {} from JIRA", responseBody);
if (!responseBody.containsKey("body")) {
mailBox.sendJiraError("create", commentUrl, body.toString(), "Unexpected JIRA results", responseBody.toString());
}
} catch (RuntimeException e) {
String responseBody;
if (e instanceof HttpClientErrorException clientException) {
responseBody = clientException.getResponseBodyAsString();
LOG.error("Failed to post Jira comment: {} ({}) with response:{}, JSON Request: {}",
clientException.getStatusCode(),
clientException.getStatusText(),
clientException.getResponseBodyAsString(),
body.toString(),
e);
} else {
responseBody = e.getMessage();
LOG.error("Failed to create Jira issue: {} ({}) with response:{}, JSON Request: {}",
"400",
responseBody,
responseBody,
body.toString(),
e);
}
mailBox.sendJiraError("comment", commentUrl, body.toString(), e.getMessage(), responseBody);
throw e;
}
}
private String resolveIssueType(String type) {
return this.mappings.get(this.jiraConfig.getEnvironment())
.get("issueTypes").entrySet().stream()
.filter(entry -> entry.getKey().equals(type))
.map(Map.Entry::getValue)
.findFirst()
.get();
}
private String spCustomField() {
return this.customField("spEntityId");
}
private String idpCustomField() {
return this.customField("idpEntityId");
}
private String typeMetaDataCustomField() {
return this.customField("typeMetaData");
}
private String emailToCustomField(boolean isQtc) {
return this.customField(isQtc ? "emailToQTC" : "emailTo");
}
private String accessCatalogusAppURLCustomField() {
return this.customField("accessCatalogusAppURL");
}
private String customField(String name) {
return this.mappings.get(this.jiraConfig.getEnvironment()).get("customFields").get(name);
}
private String dueDate() {
LocalDate localDate = new Date().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
return localDate.plusWeeks(3).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
}