JiraClient.java

package access.jira;

import access.exception.InvalidInputException;
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)
@Service
public class JiraClient {

    private static final Logger LOG = LoggerFactory.getLogger(JiraClient.class);

    private final JiraConfig jiraConfig;
    private final MailBox mailBox;
    private final Map<String, Map<String, Map<String, String>>> mappings;
    private final String issueType;
    private RestTemplate restTemplate;

    @SneakyThrows
    @SuppressWarnings("unchcked")
    public JiraClient(JiraConfig jiraConfig, ObjectMapper objectMapper, MailBox mailBox) {
        this.jiraConfig = jiraConfig;
        this.mailBox = mailBox;
        this.mappings = objectMapper.readValue(new ClassPathResource("jira/mappings.json").getInputStream(), new TypeReference<>() {
        });
        this.issueType = this.resolveIssueType();
        if (jiraConfig.isEnabled()) {
            this.restTemplate = RestTemplateFactory.buildRestTemplate(jiraConfig.getApiKey());
        }
    }

    @SuppressWarnings("unchecked")
    public String create(JiraIssue issue) {
        if (!jiraConfig.isEnabled()) {
            return String.format("CXT-%s", ThreadLocalRandom.current().nextInt(1000, 10000));
        }
        Map<String, Object> fields = new HashMap<>();
        fields.put("project", Map.of("key", jiraConfig.getProjectKey()));
        fields.put("customfield_" + spCustomField(), issue.getServiceProviderEntityID());
        fields.put("customfield_" + idpCustomField(), issue.getIdentityProviderEntityID());
        //can be removed when https://jira.ia.surf.nl/servicedesk/customer/portal/1/ISSD-46489 is resolved
        EntityType entityType = issue.getEntityType().equals(EntityType.oauth20_rs) ? EntityType.oidc10_rp : issue.getEntityType();
        fields.put("customfield_" + typeMetaDataCustomField(), Map.of("value", entityType.name()));
        fields.put("customfield_" + emailToCustomField(), issue.getEmailTo());
        fields.put("issuetype", ImmutableMap.of("id", issueType));
        fields.put("summary", issue.getSummary());
        fields.put("description", issue.getDescription());
        fields.put("duedate", dueDate());
        //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 = 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 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);
            throw e;
        }
    }

    private static final Pattern JIRA_ISSUE_KEY = Pattern.compile("^[A-Z][A-Z0-9]*-[0-9]+$");

    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() {
        return this.mappings.get(this.jiraConfig.getEnvironment())
            .get("issueTypes").entrySet().stream()
            .filter(entry -> entry.getKey().equals("change"))
            .map(entry -> 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() {
        return this.customField("emailTo");
    }

    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"));
    }

}