All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.vmware.connectors.servicenow.ServiceNowController Maven / Gradle / Ivy

There is a newer version: 2.5
Show newest version
/*
 * Copyright © 2017 VMware, Inc. All Rights Reserved.
 * SPDX-License-Identifier: BSD-2-Clause
 */

package com.vmware.connectors.servicenow;

import com.google.common.collect.ImmutableMap;
import com.vmware.connectors.common.payloads.request.CardRequest;
import com.vmware.connectors.common.payloads.response.Card;
import com.vmware.connectors.common.payloads.response.CardAction;
import com.vmware.connectors.common.payloads.response.CardActionInputField;
import com.vmware.connectors.common.payloads.response.CardActionKey;
import com.vmware.connectors.common.payloads.response.CardBody;
import com.vmware.connectors.common.payloads.response.CardBodyField;
import com.vmware.connectors.common.payloads.response.CardBodyFieldType;
import com.vmware.connectors.common.payloads.response.Cards;
import com.vmware.connectors.common.utils.CardTextAccessor;
import com.vmware.connectors.common.JsonDocument;
import com.vmware.connectors.common.utils.Async;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.util.concurrent.ListenableFuture;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.AsyncRestOperations;
import org.springframework.web.util.UriComponentsBuilder;
import rx.Observable;
import rx.Single;

import javax.validation.Valid;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@RestController
public class ServiceNowController {

    private final static Logger logger = LoggerFactory.getLogger(ServiceNowController.class);

    private static final String AUTH_HEADER = "x-servicenow-authorization";
    private static final String BASE_URL_HEADER = "x-servicenow-base-url";
    private static final String ROUTING_PREFIX = "x-routing-prefix";

    private static final String REASON_PARAM_KEY = "reason";

    /**
     * The query param to specify which fields you want to come back in your
     * ServiceNow REST calls.
     */
    private static final String SNOW_SYS_PARAM_FIELDS = "sysparm_fields";

    /**
     * The query param to specify a limit of the results coming back in your
     * ServiceNow REST calls.
     *
     * The default is 10,000.
     */
    private static final String SNOW_SYS_PARAM_LIMIT = "sysparm_limit";

    /**
     * The maximum approval requests to fetch from ServiceNow.  Since we have
     * to filter results out based on the ticket_id param passed in by the
     * client, this has to be sufficiently large to not lose results.
     *
     * I wasn't able to find a REST call that would allow me to bulk lookup the
     * approval requests (or requests) by multiple request numbers
     * (ex. REQ0010001,REQ0010002,REQ0010003), so I'm forced to do things a
     * little less ideal than I would like (calling 1x per result of the
     * sysapproval_approver call to be able to match it to the request numbers
     * passed in by the client).
     */
    private static final int MAX_APPROVAL_RESULTS = 10000;

    private final AsyncRestOperations rest;
    private final CardTextAccessor cardTextAccessor;

    @Autowired
    public ServiceNowController(
            AsyncRestOperations rest,
            CardTextAccessor cardTextAccessor
    ) {
        this.rest = rest;
        this.cardTextAccessor = cardTextAccessor;
    }

    @PostMapping(
            path = "/cards/requests",
            produces = MediaType.APPLICATION_JSON_VALUE,
            consumes = MediaType.APPLICATION_JSON_VALUE
    )
    public Single> getCards(
            @RequestHeader(AUTH_HEADER) String auth,
            @RequestHeader(BASE_URL_HEADER) String baseUrl,
            @RequestHeader(ROUTING_PREFIX) String routingPrefix,
            @Valid @RequestBody CardRequest request
    ) {
        logger.trace("getCards called, baseUrl={}, routingPrefix={}, request={}", baseUrl, routingPrefix, request);

        Set requestNumbers = request.getTokens("ticket_id");

        if (CollectionUtils.isEmpty(requestNumbers)) {
            return Single.just(ResponseEntity.ok(new Cards()));
        }

        String email = request.getTokenSingleValue("email");

        if (email == null) {
            return Single.just(ResponseEntity.ok(new Cards()));
        }

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", auth);

        HttpEntity httpHeaders = new HttpEntity<>(headers);

        return callForUserSysId(baseUrl, email, httpHeaders)
                .flatMap(userSysId -> callForApprovalRequests(baseUrl, httpHeaders, userSysId))
                .flatMapObservable(approvalRequests -> callForAllRequestNumbers(baseUrl, httpHeaders, approvalRequests))
                .filter(info -> requestNumbers.contains(info.getNumber()))
                .reduce(
                        new Cards(),
                        (cards, info) -> appendCard(cards, info, routingPrefix)
                )
                .toSingle()
                .map(ResponseEntity::ok);
    }

    private Single callForUserSysId(
            String baseUrl,
            String email,
            HttpEntity headers
    ) {
        logger.trace("callForUserSysId called: baseUrl={}", baseUrl);

        ListenableFuture> response = rest.exchange(
                UriComponentsBuilder.fromHttpUrl(baseUrl + "/api/now/table/{userTableName}")
                        .queryParam(SNOW_SYS_PARAM_FIELDS, joinFields(SysUser.Fields.SYS_ID))
                        .queryParam(SNOW_SYS_PARAM_LIMIT, 1)
                        /*
                         * TODO - This is flawed.  It turns out that emails do
                         * not have to uniquely identify users in ServiceNow.
                         * I am able to create 2 different sys_user records
                         * that have the same email.
                         */
                        .queryParam(SysUser.Fields.EMAIL.toString(), email)
                        .buildAndExpand(
                                ImmutableMap.of(
                                        "userTableName", SysUser.TABLE_NAME
                                )
                        )
                        .encode()
                        .toUri(),
                HttpMethod.GET,
                headers,
                JsonDocument.class
        );

        return Async.toSingle(response)
                .map(userInfoResponse -> userInfoResponse.getBody().read("$.result[0]." + SysUser.Fields.SYS_ID));
    }

    private Single> callForApprovalRequests(
            String baseUrl,
            HttpEntity headers,
            String userSysId
    ) {
        logger.trace("callForApprovalRequests called: baseUrl={}, userSysId={}", baseUrl, userSysId);

        String fields = joinFields(
                SysApprovalApprover.Fields.SYS_ID,
                SysApprovalApprover.Fields.SYSAPPROVAL,
                SysApprovalApprover.Fields.COMMENTS,
                SysApprovalApprover.Fields.DUE_DATE,
                SysApprovalApprover.Fields.SYS_CREATED_BY
        );

        ListenableFuture> response = rest.exchange(
                UriComponentsBuilder.fromHttpUrl(baseUrl + "/api/now/table/{apTableName}")
                        .queryParam(SNOW_SYS_PARAM_FIELDS, fields)
                        .queryParam(SNOW_SYS_PARAM_LIMIT, MAX_APPROVAL_RESULTS)
                        .queryParam(SysApprovalApprover.Fields.SOURCE_TABLE.toString(), ScRequest.TABLE_NAME)
                        .queryParam(SysApprovalApprover.Fields.STATE.toString(), SysApprovalApprover.States.REQUESTED)
                        .queryParam(SysApprovalApprover.Fields.APPROVER.toString(), userSysId)
                        .buildAndExpand(
                                ImmutableMap.of(
                                        "apTableName", SysApprovalApprover.TABLE_NAME
                                )
                        )
                        .encode()
                        .toUri(),
                HttpMethod.GET,
                headers,
                JsonDocument.class
        );

        return Async.toSingle(response)
                /*
                 * I had trouble getting JsonPath to return me something more meaningful than a List>.
                 *
                 * I considered making ApprovalRequest a proper DTO and annotating it with @JsonProperty and such,
                 * however, my current thoughts are that it would be weird to tie a hyper-generic api (specifying the
                 * fields for ServiceNow to return) to something more static (JsonProperty annotations on a class).
                 *
                 * I'm not even certain I will keep the ApprovalRequest class.  I found it useful to keep track of
                 * what information I had, but I'm not sure it follows the way we've been doing our code for the other
                 * microservices.
                 */
                .map(approvalRequestsResponse -> approvalRequestsResponse.getBody().>>read("$.result[*]"))
                .map(results -> results.stream().map(this::convertJsonDocToApprovalReq).collect(Collectors.toList()));
    }

    private String joinFields(Object... args) {
        return Arrays.stream(args)
                .map(Object::toString)
                .collect(Collectors.joining(","));
    }

    private ApprovalRequest convertJsonDocToApprovalReq(
            Map result
    ) {
        logger.trace("convertJsonDocToApprovalReq called: result={}", result);

        return new ApprovalRequest(
                (String) result.get(SysApprovalApprover.Fields.SYS_ID.toString()),
                ((Map) result.get(SysApprovalApprover.Fields.SYSAPPROVAL.toString())).get("value"),
                (String) result.get(SysApprovalApprover.Fields.COMMENTS.toString()),
                (String) result.get(SysApprovalApprover.Fields.DUE_DATE.toString()),
                (String) result.get(SysApprovalApprover.Fields.SYS_CREATED_BY.toString())
        );
    }

    private Observable callForAllRequestNumbers(
            String baseUrl,
            HttpEntity headers,
            List approvalRequests
    ) {
        logger.trace("callForAllRequestNumbers called: baseUrl={}, approvalRequests={}", baseUrl, approvalRequests);

        return Observable.from(approvalRequests)
                .flatMap(approvalRequest -> callForAndAggregateRequestNumber(baseUrl, headers, approvalRequest));
    }

    private Observable callForAndAggregateRequestNumber(
            String baseUrl,
            HttpEntity headers,
            ApprovalRequest approvalRequest
    ) {
        logger.trace("callForAndAggregateRequestNumber called: baseUrl={}, approvalRequest={}", baseUrl, approvalRequest);

        return callForRequestNumber(baseUrl, headers, approvalRequest)
                .toObservable()
                .map(requestNumber -> new ApprovalRequestWithNumber(approvalRequest, requestNumber));
    }

    private Single callForRequestNumber(
            String baseUrl,
            HttpEntity headers,
            ApprovalRequest approvalRequest
    ) {
        logger.trace("callForRequestNumber called: baseUrl={}, approvalRequest={}", baseUrl, approvalRequest);

        String fields = joinFields(
                ScRequest.Fields.SYS_ID,
                ScRequest.Fields.NUMBER
        );

        ListenableFuture> response = rest.exchange(
                UriComponentsBuilder.fromHttpUrl(baseUrl + "/api/now/table/{scTableName}/{approvalSysId}")
                        .queryParam(SNOW_SYS_PARAM_FIELDS, joinFields(fields))
                        .buildAndExpand(
                                ImmutableMap.of(
                                        "scTableName", ScRequest.TABLE_NAME,
                                        "approvalSysId", approvalRequest.getApprovalSysId()
                                )
                        )
                        .encode()
                        .toUri(),
                HttpMethod.GET,
                headers,
                JsonDocument.class
        );

        return Async.toSingle(response)
                .map(reqNumResponse -> reqNumResponse.getBody().read("$.result." + ScRequest.Fields.NUMBER)); // NOPMD: a constant would make this harder to read
    }

    private Cards appendCard(Cards cards, ApprovalRequestWithNumber info, String routingPrefix) {
        logger.trace("appendCard called: cards={}, info={}, routingPrefix={}", cards, info, routingPrefix);

        cards.getCards().add(
                makeCard(
                        routingPrefix,
                        info
                )
        );

        return cards;
    }

    private Card makeCard(
            String routingPrefix,
            ApprovalRequestWithNumber info
    ) {
        logger.trace("makeCard called: routingPrefix={}, info={}", routingPrefix, info);

        return new Card.Builder()
                .setName("ServiceNow") // TODO - remove this in APF-536
                .setTemplate(routingPrefix + "templates/generic.hbs")
                .setHeader(cardTextAccessor.getHeader(info.getNumber()), cardTextAccessor.getMessage("subtitle"))
                .setBody(
                        new CardBody.Builder()
                                .setDescription(
                                        cardTextAccessor.getBody(
                                                info.getNumber(),
                                                info.getCreatedBy(),
                                                info.getDueDate()
                                        )
                                )
                                .addField(
                                        new CardBodyField.Builder()
                                                .setTitle(cardTextAccessor.getMessage("requestNumber.title"))
                                                .setType(CardBodyFieldType.GENERAL)
                                                .setDescription(cardTextAccessor.getMessage("requestNumber.description", info.getNumber()))
                                                .build()
                                )
                                .addField(
                                        new CardBodyField.Builder()
                                                .setTitle(cardTextAccessor.getMessage("createdBy.title"))
                                                .setType(CardBodyFieldType.GENERAL)
                                                .setDescription(cardTextAccessor.getMessage("createdBy.description", info.getCreatedBy()))
                                                .build()
                                )
                                .addField(
                                        new CardBodyField.Builder()
                                                .setTitle(cardTextAccessor.getMessage("dueDate.title"))
                                                .setType(CardBodyFieldType.GENERAL)
                                                .setDescription(cardTextAccessor.getMessage("dueDate.description", info.getDueDate()))
                                                .build()
                                )
                                .build()
                )
                .addAction(
                        new CardAction.Builder()
                                .setLabel(cardTextAccessor.getActionLabel("approve"))
                                .setCompletedLabel(cardTextAccessor.getActionCompletedLabel("approve"))
                                .setActionKey(CardActionKey.DIRECT)
                                .setUrl(getServiceNowUrl(routingPrefix, info.getRequestSysId()) + "/approve")
                                .setType(HttpMethod.POST)
                                .build()
                )
                .addAction(
                        new CardAction.Builder()
                                .setLabel(cardTextAccessor.getActionLabel("reject"))
                                .setCompletedLabel(cardTextAccessor.getActionCompletedLabel("reject"))
                                .setActionKey(CardActionKey.USER_INPUT)
                                .setUrl(getServiceNowUrl(routingPrefix, info.getRequestSysId()) + "/reject")
                                .setType(HttpMethod.POST)
                                .addUserInputField(
                                        new CardActionInputField.Builder()
                                                .setId(REASON_PARAM_KEY)
                                                .setLabel(cardTextAccessor.getActionLabel("reject.reason"))
                                                .setMinLength(1)
                                                .build()
                                )
                                .build()
                )
                .build();
    }

    private String getServiceNowUrl(String routingPrefix, String ticketId) {
        return routingPrefix + "api/v1/tickets/" + ticketId;
    }

    @PostMapping(
            path = "/api/v1/tickets/{requestSysId}/approve",
            consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE
    )
    public Single>> approve(
            @RequestHeader(AUTH_HEADER) String auth,
            @RequestHeader(BASE_URL_HEADER) String baseUrl,
            @PathVariable("requestSysId") String requestSysId
    ) {
        logger.trace("approve called: baseUrl={}, requestSysId={}", baseUrl, requestSysId);

        return updateRequest(auth, baseUrl, requestSysId, SysApprovalApprover.States.APPROVED, null);
    }

    private Single>> updateRequest(
            String auth,
            String baseUrl,
            String requestSysId,
            SysApprovalApprover.States state,
            String comments
    ) {
        logger.trace("updateState called: baseUrl={}, requestSysId={}, state={}", baseUrl, requestSysId, state);

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", auth);
        headers.set("Content-Type", MediaType.APPLICATION_JSON_VALUE);

        ImmutableMap.Builder body = new ImmutableMap.Builder()
                .put(SysApprovalApprover.Fields.STATE.toString(), state.toString());

        if (StringUtils.isNotBlank(comments)) {
            body.put(SysApprovalApprover.Fields.COMMENTS.toString(), comments);
        }

        String fields = joinFields(
                SysApprovalApprover.Fields.SYS_ID,
                SysApprovalApprover.Fields.STATE,
                SysApprovalApprover.Fields.COMMENTS
        );

        ListenableFuture> response = rest.exchange(
                UriComponentsBuilder.fromHttpUrl(baseUrl + "/api/now/table/{apTableName}/{requestSysId}")
                        .queryParam(SNOW_SYS_PARAM_FIELDS, fields)
                        .buildAndExpand(
                                ImmutableMap.of(
                                        "apTableName", SysApprovalApprover.TABLE_NAME,
                                        "requestSysId", requestSysId
                                )
                        )
                        .encode()
                        .toUri(),
                HttpMethod.PATCH,
                new HttpEntity<>(body.build(), headers),
                JsonDocument.class
        );

        return Async.toSingle(response)
                .map(ResponseEntity::getBody)
                .map(data -> ImmutableMap.of(
                        "approval_sys_id", data.read("$.result." + SysApprovalApprover.Fields.SYS_ID), // NOPMD: a constant would make this harder to read
                        "approval_state", data.read("$.result." + SysApprovalApprover.Fields.STATE), // NOPMD:: a constant would make this harder to read
                        "approval_comments", data.read("$.result." + SysApprovalApprover.Fields.COMMENTS) // NOPMD:: a constant would make this harder to read
                ))
                .map(ResponseEntity::ok);
    }

    @PostMapping(
            path = "/api/v1/tickets/{requestSysId}/reject",
            consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE
    )
    public Single>> reject(
            @RequestHeader(AUTH_HEADER) String auth,
            @RequestHeader(BASE_URL_HEADER) String baseUrl,
            @PathVariable("requestSysId") String requestSysId,
            @RequestParam(REASON_PARAM_KEY) String reason
    ) {
        logger.trace("reject called: baseUrl={}, requestSysId={}, reason={}", baseUrl, requestSysId, reason);

        return updateRequest(auth, baseUrl, requestSysId, SysApprovalApprover.States.REJECTED, reason);
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy