Skip to content

Commit

Permalink
Support ticket servicenow integration (#4081)
Browse files Browse the repository at this point in the history
Add support ticket integration with ServiceNow API.
  • Loading branch information
abujeda authored Jan 27, 2025
1 parent 066eb55 commit 76ecb11
Show file tree
Hide file tree
Showing 8 changed files with 508 additions and 0 deletions.
99 changes: 99 additions & 0 deletions apps/dashboard/app/apps/service_now_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

require 'rest_client'

# HTTP client to create a ServiceNow incident using the API
# Credentials are not compulsory to support authentication through Apache proxy settings
# Configuration parameters:
# - `server`: URL for the ServiceNow server (required)
# - `user`: ServiceNow API username
# - `pass`: ServiceNow API password
# - `pass_env`: Environment variable to use for the ServiceNow API password
# - `auth_token`: ServiceNow API key
# - `auth_token_env`: Environment variable to use for the ServiceNow API key
# - `auth_header`: ServiceNow API key HTTP header. Defaults to x-sn-apikey.
# - `timeout`: Connection and read timeout in seconds. Defaults to 30.
# - `verify_ssl`: Whether or not the client should validate SSL certificates. Defaults to true.
# - `proxy`: Proxy server URL. Defaults to no proxy.
#
class ServiceNowClient

UA = 'Open OnDemand ruby ServiceNow Client'
attr_reader :server, :auth_header, :client, :timeout, :verify_ssl

def initialize(config)
# FROM CONFIGURATION
@user = config[:user]
@pass = config[:pass]
@auth_token = config[:auth_token]
@auth_header = config[:auth_header] || 'x-sn-apikey'
@timeout = config[:timeout] || 30
@verify_ssl = config[:verify_ssl] || false
@server = config[:server] if config[:server]

raise ArgumentError, 'server is a required parameter for ServiceNow client' unless @server

# Allow to pass secrets securely through environment variables
auth_token_env = config[:auth_token_env]
@auth_token = ENV[auth_token_env] if auth_token_env
pass_env = config[:pass_env]
@pass = ENV[pass_env] if pass_env

headers = { 'User-Agent' => UA,
'Cookie' => '' }
headers[@auth_header] = @auth_token if @auth_token

options = {
headers: headers,
timeout: @timeout,
verify_ssl: @verify_ssl,
}
options[:user] = @user if @user
options[:password] = @pass if @pass
options[:proxy] = config[:proxy] if config[:proxy]

@client = RestClient::Resource.new(@server, options)
end

def create(payload, attachments)
incident = @client['/api/now/table/incident'].post(payload.to_json, content_type: :json)
response_hash = JSON.parse(incident.body)['result'].symbolize_keys
incident_number = response_hash[:number]
incident_id = response_hash[:sys_id]

raise StandardError, "Unable to create ticket. Server response: #{incident}" unless incident_id

begin
attachments.to_a.each do |request_file|
add_attachment(incident_id, request_file)
end
attachments_success = true
rescue StandardError => e
Rails.logger.info "Could not add attachments to incident: #{incident_number}. Error=#{e}"
attachments_success = false
end

create_response(incident_number, attachments.to_a.size, attachments_success)
end

def add_attachment(incident_id, request_file)
params = {
table_name: 'incident',
table_sys_id: incident_id,
file_name: request_file.original_filename,
}
file = File.new(request_file.tempfile, 'rb')
@client['/api/now/attachment/file'].post(file, params: params, content_type: request_file.content_type)
end

private

def create_response(incident_number, attachments, attachments_success)
OpenStruct.new({
number: incident_number,
attachments: attachments,
attachments_success: attachments_success
})
end

end
113 changes: 113 additions & 0 deletions apps/dashboard/app/apps/support_ticket_service_now_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# frozen_string_literal: true

# Service class responsible to create a support ticket and delivery it via ServiceNow API
#
# It implements the support ticket interface as defined in the SupportTicketController
class SupportTicketServiceNowService

attr_reader :support_ticket_config

# Constructor
#
# @param [Hash] support_ticket_config Support ticket configuration
def initialize(support_ticket_config)
@support_ticket_config = support_ticket_config
end

# Creates a support ticket model with default data.
# Will load an interactive session if a session_id provided in the request parameters.
#
# @param [Hash] request_params Request data sent to the controller
#
# @return [SupportTicket] support_ticket model
def default_support_ticket(request_params)
support_ticket = SupportTicket.from_config(support_ticket_config)
support_ticket.username = CurrentUser.name
support_ticket.session_id = request_params[:session_id]
set_session(support_ticket)
end

# Uses SupportTicket model to create and validate the request data.
# The model needs to be validated before returning
#
# @param [Hash] request_data Request data posted to the controller
#
# @return [SupportTicket] support_ticket model
def validate_support_ticket(request_data = {})
support_ticket = SupportTicket.from_config(support_ticket_config)
support_ticket.attributes = request_data
set_session(support_ticket)
support_ticket.tap(&:validate)
end

# Creates a support ticket in the ServiceNow system configured
#
# @param [SupportTicket] support_ticket support ticket created in validate_support_ticket
#
# @return [String] success message
def deliver_support_ticket(support_ticket)
service_config = support_ticket_config.fetch(:servicenow_api, {})
session = get_session(support_ticket)
description = create_description_text(service_config, support_ticket, session)
payload = {
caller_id: support_ticket.email,
watch_list: support_ticket.cc,
short_description: support_ticket.subject,
description: description,
}

mapping_fields = service_config.fetch(:map, {}).to_h
mapping_fields.each do |snow_field, form_field|
# Map field names from the form into field names from ServiceNow
# arrays are supported for form_field names and the values will be joined with commas.
value = Array.wrap(form_field).map { |name| support_ticket.send(name).to_s }.reject(&:blank?).join(',')
payload[snow_field] = value
end

custom_payload = service_config.fetch(:payload, {})
custom_payload.each do |key, value|
# Use the values from the custom payload if available.
# Default to the values from the form when nil provided.
payload[key] = value.nil? ? support_ticket.send(key) : value
end

snow_client = ServiceNowClient.new(service_config)
result = snow_client.create(payload, support_ticket.attachments)
Rails.logger.info "Support Ticket created in ServiceNow: #{result.number} - Attachments[#{result.attachments}] success=#{result.attachments_success}"
message_key = result.attachments_success ? 'creation_success' : 'attachments_failure'
service_config.fetch(:success_message, I18n.t("dashboard.support_ticket.servicenow.#{message_key}", number: result.number))
end

private

def create_description_text(service_config, support_ticket_request, session)
ticket_template_context = {
session: session,
support_ticket: support_ticket_request,
}

template = service_config.fetch(:template, 'servicenow_content.text.erb')
ticket_content_template = ERB.new(File.read(Rails.root.join('app/views/support_ticket/servicenow').join(template)))
ticket_content_template.result_with_hash({ context: ticket_template_context, helpers: TemplateHelpers.new })
end

def set_session(support_ticket)
session = get_session(support_ticket)
if session
created_at = session.created_at ? Time.at(session.created_at).localtime.strftime("%Y-%m-%d %H:%M:%S %Z") : "N/A"
support_ticket.session_description = "#{session.title}(#{session.job_id}) - #{session.status} - #{created_at}"
end

support_ticket
end

def get_session(support_ticket)
if !support_ticket.session_id.blank? && BatchConnect::Session.exist?(support_ticket.session_id)
BatchConnect::Session.find(support_ticket.session_id)
end
end

class TemplateHelpers
include SupportTicketHelper
end
end
1 change: 1 addition & 0 deletions apps/dashboard/app/models/user_configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ def support_ticket_service
# Supported delivery mechanism
return SupportTicketEmailService.new(support_ticket) if support_ticket[:email]
return SupportTicketRtService.new(support_ticket) if support_ticket[:rt_api]
return SupportTicketServiceNowService.new(support_ticket) if support_ticket[:servicenow_api]

raise StandardError, I18n.t('dashboard.user_configuration.support_ticket_error')
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Ticket submitted from OnDemand dashboard application
Username: <%= context[:support_ticket].username %>
Email: <%= context[:support_ticket].email %>
CC: <%= context[:support_ticket].cc %>

<% if context[:session] %>
User selected session: <%= context[:session].id %>
Title: <%= context[:session].title %>
Scheduler job id: <%= context[:session].job_id %>
Status: <%= context[:session].status.to_sym %>
<% end %>

Description:
<%= context[:support_ticket].description %>

-------------------------------------
Session Information:
<% if context[:session] %>
<%= JSON.pretty_generate(
{
id: context[:session].id,
clusterId: context[:session].cluster_id,
jobId: context[:session].job_id,
createdAt: Time.at(context[:session].created_at).iso8601,
token: context[:session].token,
title: context[:session].title,
user_context: context[:session].user_context,
info: helpers.filter_session_parameters(context[:session].info),
deletedInDays: context[:session].days_till_old,
})
%>
<% else %>
No session was selected.
<% end %>
3 changes: 3 additions & 0 deletions apps/dashboard/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ en:
header: Support Ticket
rt:
creation_success: 'Support ticket created in RequestTracker system. TicketId: %{ticket_id}'
servicenow:
creation_success: "Support ticket created in ServiceNow. Number: %{number}"
attachments_failure: "Support ticket created in ServiceNow. Number: %{number}. But unable to add the attachments."
title: Support Ticket
validation:
attachments_items: The %{id} are invalid. %{items} added, maximum number of attachments is %{max}
Expand Down
67 changes: 67 additions & 0 deletions apps/dashboard/test/apps/service_now_client_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

require 'test_helper'

class ServiceNowClientTest < ActiveSupport::TestCase
test 'should throw exception when server is not provided' do
config = {
server: nil
}

assert_raises(ArgumentError) { ServiceNowClient.new(config) }
end

test 'should allow missing credentials' do
config = {
server: 'http://server.com',
}

assert_not_nil(ServiceNowClient.new(config))
end

test 'should set the expected default values' do
config = {
server: 'http://server.com'
}

target = ServiceNowClient.new(config)
assert_equal('x-sn-apikey', target.auth_header)
assert_equal(30, target.timeout)
assert_equal(false, target.verify_ssl)
end

test 'should set RestClient options when provided' do
config = {
server: 'http://server.com',
user: 'payload_username',
pass: 'payload_password',
auth_token: 'payload_token',
timeout: 90,
verify_ssl: true,
proxy: 'proxy.com:8888'
}

target = ServiceNowClient.new(config)
assert_equal('payload_username', target.client.options[:user])
assert_equal('payload_password', target.client.options[:password])
assert_equal('payload_token', target.client.options[:headers]['x-sn-apikey'])
assert_equal(90, target.client.options[:timeout])
assert_equal(true, target.client.options[:verify_ssl])
assert_equal('proxy.com:8888', target.client.options[:proxy])
end

test 'should set password and token from environment if configured' do
config = {
server: 'http://server.com',
pass_env: 'SNOW_PASSWORD',
auth_token_env: 'SNOW_TOKEN'
}

with_modified_env(SNOW_PASSWORD: 'password_from_env', SNOW_TOKEN: 'token_from_env') do
target = ServiceNowClient.new(config)
assert_equal('password_from_env', target.client.options[:password])
assert_equal('token_from_env', target.client.options[:headers]['x-sn-apikey'])
end

end
end
Loading

0 comments on commit 76ecb11

Please sign in to comment.