Skip to content

Instantly share code, notes, and snippets.

@eric1234
Created July 14, 2011 17:03
Show Gist options
  • Save eric1234/1082868 to your computer and use it in GitHub Desktop.
Save eric1234/1082868 to your computer and use it in GitHub Desktop.
Maxhire API Wrapper
# An API wrapper for the MaxHire API. Based on the ActiveResource API.
# Designed to keep the horrors of SOAP away from decent code :)
#
# USAGE
#
# Creating a new object
#
# a = Maxhire::Person.new
# a.first = 'Eric'
# a.last = 'Anderson'
# ....
# a.save
#
# Creating object with mass assignment
#
# a = Maxhire::Person.new
# a.attributes = params[:person]
# a.save
#
# Or as a one-liner
#
# Maxhire::Application.new(params[:application]).save
#
# Pull down an existing record and update an attribute.
#
# a = Maxhire::Person.get params[:id]
# a.email = 'eric@example.com'
# a.save
#
# All objects are supported (Person, Document, Application, Interview,
# Placement, Company, Job and Activity). When the library is first
# loaded it will pull down all the schemas to define the accessors. This
# makes the library nicer to work with (as you get a NoMethodError if
# you try to set a invalid attribute) but it does mean there is a slight
# performance hit at load time.
#
# Do to this library querying the API as part of the loading process
# you need to specify the connection info via the following environment
# variables:
#
# * MAXHIRE_DATABASE_NAME
# * MAXHIRE_SECURITY_KEY
#
# DEPENDENCIES
#
# Savon:: Used to wrangle SOAP
# Nokogiri:: Used to wrangle XML
# Friends from stdlib
require 'singleton'
require 'base64'
# 3rd party friends
require 'savon'
require 'nokogiri'
# Savon and HTTPI are noisy. Shut them up
Savon.configure {|c| c.log = false}
HTTPI.log = false
# Monkey-patch Ruby
class Module
# Backport from Ruby 1.9 if not already defined
def singleton_class
class << self
self
end
end unless respond_to? :singleton_class
# Returns the class name without any namespace. So
# Maxhire::Person.base_name will return the string Person.
def base_name
name.split('::').last
end
end
module Maxhire
# All objects record a "enterby" and "modifiedby" field which store
# who made the record and last changed it. These are required fields.
# To provide a reasonable default we set these to the below string.
# Calling code can do a "replace" on the constant to replace it with
# something more relevant if desired.
#
# NOTE: If changed you are limited to 15 characters
EDITING_USER = 'N/A'
# Module to simplify specifying namespaces. Just include in module
# (or extend) and then you can just say ns(:xs, :xmlns) and
# a hash will be returned that looks like:
#
# {
# :xs => 'http://www.w3.org/2001/XMLSchema',
# :xmlns => 'http://www.maxhire.net/'
# }
#
# This module just keep the code clean and neat.
module NamespaceLookup
# The various XML namespaces used
NAMESPACES = {
:xs => 'http://www.w3.org/2001/XMLSchema',
:diffgr => 'urn:schemas-microsoft-com:xml-diffgram-v1',
:xmlns => 'http://www.maxhire.net/'
}
# Implementation of the "ns" method
def namespaces *namespaces
Array(namespaces).flatten.inject({}) do |memo, namespace|
memo[namespace.to_s] = NAMESPACES[namespace]
memo
end
end
alias_method :ns, :namespaces
end
# Abstract class for CRUD of Maxhire objects.
class Base
include NamespaceLookup
# Unique identifier of object assigned by MaxHire. Will be nil
# on an un-saved new record.
attr_reader :id
# Create a new object not currently backed by the database. When
# saved it will be persisted via MaxHire API.
def initialize attributes={}
# Setup XML object this data object writes to
@xml = self.class.new_record_xml
# Setup defaults on attributes and on XML
@attributes = defaults
@xml.write defaults
# Nothing is dirty yet
@dirty = {}
# Assign initial attributes
self.attributes = attributes
end
# Will return the given attribute.
#
# If the attribute is not a valid attribute for this object then
# an ArgumentError will be thrown.
#
# Calling obj.active is the exact same as calling obj[:active].
# Just depends on if you want to use dot notation or accessor
# notation.
def [] attribute
validate_attribute! attribute
@attributes[attribute.to_sym]
end
# Will assign the given value to the given attribute.
#
# If the attribute is not a valid attribute for this object then
# an ArgumentError will be thrown.
#
# Calling obj.active = true is the same a calling
# obj[:active] = true. Just depends on if you want to use dot
# notation or accessor notation.
def []= attribute, new_value
validate_attribute! attribute
@dirty[attribute.to_sym] = @attributes[attribute.to_sym] = new_value
end
# Will return all attributes assigned to this object
def attributes
@attributes.dup
end
# Will merge the new attributes into the existing attributes
def attributes= attributes
(attributes || {}).each {|f, v| self[f] = v}
end
# Keep the output more reasonable.
def inspect
"#{self.class.base_name} <#{@attributes.inspect}>"
end
alias_method :to_s, :inspect
# Will save this object back to the database. Correctly handles
# a new record vs. updating an existing record. Sometimes the
# Add/Update methods have extra arguments in addition to the main
# dataset. If you need to use those you can pass this method
# a block which yields a Builder object to add more XML info.
def save
# Prevent un-necessary API interaction.
return if @dirty.empty?
@xml.write @dirty
@dirty = {}
api_method = new? ? :add : :update
response = APIConnection.instance.request self.class.base_name, api_method do |xml|
xml.tag! "ds#{self.class.base_name}" do
xml << @xml.schema.to_s
xml << @xml.data.to_s
end
xml.tag! self.class.id_param, @id unless new?
yield xml if block_given?
end
response = process_update response, api_method
# Reload from Get as DataSet from X_GetEmptyForAdd cannot be used
# to update. Also useful if server adjusts data.
load_id response.at_xpath('//Id').content if new?
end
# Is this record a new record that will be inserted when saved or
# an existing record that will be updated?
def new?
id.nil?
end
# Will remove the record from Maxhire. Will freeze instance to
# prevent further interaction.
def destroy
response = APIConnection.instance.request self.class.base_name, :delete do |xml|
xml.tag! self.class.id_param, id
end
process_update response, :delete
freeze
end
# Will load the current object with the data on the given id.
# Nothing from the previous state is saved.
#
# Used internally by Maxhire::Base.get and Maxhire::Base.save and
# not intended for public usage.
def load_id id # :nodoc:
# Make API call to get data.
response = APIConnection.instance.request self.class.base_name, :get do |xml|
xml.tag! self.class.id_param, id
end
@xml = DataSet.new response.to_xml
self.class.overwrite_field_types @xml.columns
# Make sure the data was found
raise Error, 'Invalid id' unless @xml.xml.at_xpath '//Table'
# Load into internal attributes
@attributes = @xml.attributes
@id = id
# Assign defaults if no existing value
defaults.each do |fld, value|
self[fld] = value if self[fld].blank?
end
self
end
private
# Maxhire has some fields that are "required" even if you have
# no valid value to provide. This is usually for foreign keys and
# often a dummy value such as 0, 1, or 'N/A' can be provided.
#
# To get around this issue each subclass can define a constant
# DEFAULTS which is a hash of attributes and their default value.
#
# All numeric columns are assumed to be 0 to cut down on the number
# of defaults that must be specified. Also the "enterby" and
# "modifiedby" fields will be set to the constant EDITING_USER.
#
# This method returns that constant combined with the magic columns
# (numbers and editing user)
def defaults
defs = if self.class.const_defined? 'DEFAULTS'
self.class.const_get('DEFAULTS').dup
else
{}
end
# Set numeric fields to 0
self.class.columns.each do |attribute, specs|
defs[attribute] ||= 0 if
['int', 'decimal'].include? specs[:field_type]
end
# Set user
defs[:enterby] ||= EDITING_USER
defs[:modifiedby] ||= EDITING_USER
defs
end
# The status of an updated is encoded in an XML document which
# itself is encoded in the response XML document. This method will
# extract a reasonable answer out of that mess and raise an error if
# there was a problem.
#
# The api_method used is needed to be able to extract the response.
# The "inner" XML document is returned.
def process_update response, api_method
response_tag = "#{self.class.base_name}_#{api_method.to_s.capitalize}Result"
response = Nokogiri::XML response.to_xml
response = response.at_xpath("//xmlns:#{response_tag}", ns(:xmlns)).content
raise Error, "#{api_method} error" if response.empty?
response = Nokogiri::XML response
raise Error.from_error_message(response.at_xpath('//ErrorDescription').content) if
response.at_xpath('//Success').content == 'False'
response
end
# Will ensure the given attribute is a valid attribute. If not
# an ArgumentError will be thrown.
def validate_attribute! attribute
raise ArgumentError, "Attributes #{attribute} is not valid" unless
self.class.columns.keys.include? attribute.to_sym
end
class << self
# Will instantiate the object with the given id.
def get(id)
new.load_id id
end
# Will return the schema for this class.
#
# Pulled from the info in X_GetEmptyForAdd which is slightly
# different (and less detailed) then the schema that X_Get
# provides. But since we need to know the schema before we pull
# any actual data we are forced to use this version of the schema.
def columns
overwrite_field_types new_record_xml.columns
end
# In general most of the API calls use "intObjectNameId" for the
# paramter used to load, delete and update records. But some of
# the objects do not follow this pattern. Therefore we are
# providing a good default and but abstracting it as a method
# so the subclass can override.
def id_param
"int#{base_name}Id"
end
# The schema coming from Maxhire is not correct sometimes. For
# example work_emp_from is a dateTime field but the schema reports
# it as a string. Subclasses can define the constant
# FIELD_TYPE_OVERRIDES to specify the true types of each field.
# This ensures the proper encoding and decoding of values.
#
# Will take a column hash and adjusted it according to the
# overrides. This is used internally when getting the schema for
# new records as well as the schema for existing records.
def overwrite_field_types(columns) # :nodoc
overrides = if const_defined? 'FIELD_TYPE_OVERRIDES'
const_get('FIELD_TYPE_OVERRIDES').dup
else
{}
end
overrides.each do |column, type|
columns[column][:field_type] = type if columns.has_key? column
end
columns
end
# Using the X_GetEmptyForAdd API call determine columns and
# dynamically create accessors on singleton class.
def inherited subclass # :nodoc:
# Call API to get schema
xml = APIConnection.instance.request subclass.base_name, :get_empty_for_add
xml = DataSet.new xml.to_xml
xml.clear
# We will be installing some singleton methods on the meta_class
# so our subclass will have object specific methods.
meta_class = subclass.singleton_class
# Store template DataSet XML structure to create new records
meta_class.send(:define_method, :new_record_xml) {xml.dup}
subclass.columns.keys.each do |column_name|
# If method already defined do not override
next if subclass.instance_methods.include? column_name.to_s
# Install getter accessor
subclass.send :define_method, column_name do
self[column_name]
end
# Install setter accessor
subclass.send :define_method, "#{column_name}=" do |new_value|
self[column_name] = new_value
end
end
end
end
end
# When creating, updating and reading a record the data is not just
# a simple XML document. Instead it is a serialized dataset that
# includes:
#
# * the schema
# * the actual data for the record
# * a "before" snapshot of the data
# (probably used to resolve conflicts)
#
# We don't really provide full support for this XML document but this
# class encapsulates enough of it for our purposes.
class DataSet
include NamespaceLookup
# Parsed from the schema
attr_reader :columns, :xml
# The XML document as it comes from GetEmptyForAdd or Get. Note
# that the xml needs to include the schema and diffgram but it
# does not need to be exclusive. It can have other stuff and we
# will just extract what we need.
def initialize xml
@xml = Nokogiri::XML xml
@columns = {}
extract_columns
# The before snapshot is not needed and would require more
# code to maintain so just remove it.
before = @xml.at_xpath('//diffgr:diffgram/diffgr:before', ns(:diffgr))
before.remove if before
end
# Returns just the XML schema
def schema
@xml.at_xpath '//xs:schema', ns(:xs)
end
# The data part of the XML
def data
@xml.dup.at_xpath '//diffgr:diffgram', ns(:diffgr)
end
# Will return the attributes in the data.
def attributes
attrs = {}
@xml.at_xpath('//Table').children.each do |attribute|
name = attribute.name.snakecase.to_sym
value = @xml.at_xpath("//#{attribute.name}").content
type = columns[name][:field_type]
value = decode value, type
attrs[name] = value unless value.to_s.empty? ||
type == 'dateTime' && value.is_a?(DateTime) && value.year == 1900
end
attrs
end
# Will write the given attributes to the XML document. If the
# attribute node already exists simply update. If it does not
# exist the node will be created.
def write(attributes)
attributes.each do |attribute, value|
column = columns[attribute]
tag = if column
column[:soap_field]
else
attribute
end
attr_node = @xml.at_xpath "//#{tag}", ns(:diffgr)
attr_node = Nokogiri::XML::Node.new tag, @xml.document unless attr_node
attr_node.content = if column
encode value, column[:field_type]
else
value
end
attr_node.parent = @xml.at_xpath "//Table", ns(:diffgr)
end
end
# The dataset for a "New" record includes values for many fields.
# Some of these values are invalid if we submit them back to the
# API. This method can be used to clear all that old cruft.
def clear
@xml.at_xpath('//diffgr:diffgram/NewDataSet/Table', ns(:diffgr)).content = nil
end
private
# Will encode the given value according to the specified type
def encode value, type
case type
when 'string' then value.to_s
when 'decimal', 'float' then value.to_f
when 'int', 'short' then value.to_i
when 'boolean'
value = false if value.to_s == '0' || value == 'false'
value ? 1 : 0
when 'dateTime'
value = DateTime.parse value rescue value
if value.respond_to? :strftime
if %w(hour min sec).all? {|f| value.send(f.to_sym) == 0}
value.strftime '%Y-%m-%d'
else
value.strftime('%Y-%m-%dT%H:%M:%S%z').insert(-3, ':')
end
else
value.to_s
end
end
end
# Will decode a value according to the specified type
def decode value, type
case type
when 'string' then value.to_s
when 'decimal', 'float' then value.to_f
when 'int', 'short' then value.to_i
when 'boolean' then
value = false if value.to_s == '0' || value == 'false'
!!value
when 'dateTime' then DateTime.parse value rescue value
end
end
# Will read the XML data and schema and store the column specs
# in a useful data structure.
def extract_columns
data = @xml.at_xpath '//Table'
return unless data
data.children.each do |attribute|
name = attribute.name.snakecase.to_sym
type = @xml.at_xpath(
"//xs:element[@name='#{attribute.name}']", ns(:xs)
)['type'].split(':').last
# Save column info for type casing and saving back to the
# correct soap field later.
@columns[name] = {
:field_type => type,
:soap_field => attribute.name
}
end
end
end
# Provides simplification of Savon::Client by pre-filling parameters
# and extra help when request is called.
#
# This is an abstract class concretely implemented as APIConnection
# and AdHocConnection.
class Connection < Savon::Client
include Singleton
DATABASE_NAME = ENV['MAXHIRE_DATABASE_NAME']
SECURITY_KEY = ENV['MAXHIRE_SECURITY_KEY' ]
# Like Savon::Client.new only WSDL automatically supplied from
# subclass constant.
def initialize
super {wsdl.document = self.class.const_get('WSDL')}
end
# Like Savon::Client#request only auth automatically supplied and
# any error converted to raised Maxhire::Error.
#
# If a block is given then a Builder::XmlMarkup object will be
# passed to the block to allow addition tags to get appended to
# the body of the request. Also the soap object is passed as a
# second argument for more direct access.
def request method_name, &blk
begin
response = super method_name do
soap.namespaces["xmlns"] = 'http://www.maxhire.net/'
soap.header = {
'AuthHeader' => {
'DatabaseName' => DATABASE_NAME,
'SecurityKey' => SECURITY_KEY,
}
}
if block_given?
xml = Builder::XmlMarkup.new
if blk.arity == 2
yield xml, soap
else
yield xml
end
soap.body = xml.target! unless xml.target!.empty?
end
end
response
rescue Savon::SOAP::Fault
raise Error.from_error_message($!)
end
end
end
# Most of the API calls are handled through this connection.
class APIConnection < Connection
WSDL = 'https://www.maxhire.net/MaxHireAPI/Services.asmx?wsdl'
# Automatically constructs API method from object and method
def request object_name, method_name, &blk
method_name = "#{object_name.to_s.snakecase}_#{method_name.to_s.snakecase}".to_sym
super method_name, &blk
end
end
# A few misc API calls are on this WSDL. The primary one being the
# ability to call any stored procedure.
class AdhocConnection < Connection
include NamespaceLookup
WSDL = 'https://www.maxhire.net/MaxHireAPI/UserServices.asmx?wsdl'
# Will execute the stored procedure given
def stored_proc(name, params={})
# Get XML for params
param_xml = request :get_sql_params_data_set do |xml|
xml.lngNumOfRows params.keys.size
end
param_xml = Nokogiri::XML param_xml.to_xml
param_names = param_xml.xpath "//ParamName"
param_values = param_xml.xpath "//ParamValue"
params.each_with_index do |(key, value), idx|
param_names[idx].content = key
param_values[idx].content = value
end
param_xml
schema = param_xml.at_xpath '//xs:schema', ns(:xs)
data = param_xml.at_xpath '//diffgr:diffgram', ns(:diffgr)
# Actually execute API call
response = request :execute_custom_stored_procedure_get_results do |xml|
xml.dsParams do
xml << schema.to_s
xml << data.to_s
end
xml.strProcName name
end
# Return reasonable data structure from response mess
results = []
Nokogiri::XML(response.to_xml).xpath('//Table').each do |row|
data = {}
row.children().each do |column|
data[column.name.snakecase.to_sym] = column.content
end
results << data
end
results
end
end
# Encapsulates an error coming from MaxHire. Adds an error code in
# addition to the message StandardError supplies.
class Error < StandardError
attr_accessor :code
# Will create a new error instance with the given code and message
def initialize msg, code=nil
self.code = code
super msg
end
# Will parse the error message and extract the maxhire code and error
def self.from_error_message fault
code, msg = *fault.to_s.scan(/MaxHire Error (\d+): (.+)$/).first
new msg, code
end
end
# A candidate
class Person < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:active => 1,
:candid_divisions_id => 1,
:candid_status_id => 18,
:candid_recruiter => 'N/A',
}
FIELD_TYPE_OVERRIDES = {
:work_emp_from => 'dateTime',
:work_emp_to => 'dateTime',
}
# Overwrite parent to support check for duplicate flag. By default
# the check is enabled. The flag does nothing if we are updating
# an existing record.
#
# Note that this flag will cause an error to be thrown if the
# user does already exist.
def save(check_for_duplicates=true)
super() do |xml|
xml.blnCheckForDuplicates 1 if new? && check_for_duplicates
end
end
end
# A document attached to a specific Person. Note that "cid" is the
# field that should store the foreign key of the person we are
# attached to.
#
# file_ext is a required file but no reasonable default can be
# provided. If the file_data responds to :original_filename (common
# with uploaded files) or :path then the filename will be automatically
# extracted and the extension will be automatically extracted from
# that if a extension has not already been specified. But if the
# filename/extension cannot be determined then it must be supplied
# by the calling code or a validation error will be generated.
class Document < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:doctypes_id => 1,
:docs_divisions_id => 1,
}
# Will pull down the actual file.
def file
response = APIConnection.instance.request :document, :get_file do |xml|
xml.tag! "intDocumentId", id
end
response = Nokogiri::XML(response.to_xml)
response = response.at_xpath '//xmlns:Document_GetFileResult', ns(:xmlns)
Base64.decode64 response.content if response
end
# Overwritten to support additional info:
#
# file_data::
# The actual data that represents the file. This can be either
# raw data or a IO object that supports the read method. Leave
# blank if doing an update and only changing the meta data.
# resume::
# Set to true if this document should be the person's default
# resume.
# check_for_duplicates::
# Will raise an error if the system detects this file already
# exists. Does nothing if we are updating an existing record.
def save(file_data='', default_resume=false, check_for_duplicates=true)
raise Error, 'File not provided' if new? && !file_data
file_name = file_data.original_filename if file_data.respond_to? :original_filename
file_name = file_data.path if !file_name && file_data.respond_to?(:path)
self.file_ext ||= File.extname(file_name)[1..-1] if file_name
file_data = file_data.read if file_data.respond_to? :read
file_data = Base64.encode64(file_data).gsub! /\n/, ""
super() do |xml|
xml.bytFile file_data
xml.blnSetDefaultResume 1 if default_resume
xml.blnCheckForDuplicates 1 if new? && check_for_duplicates
end
end
# Override since it does not follow pattern of other objects
def self.id_param
'docs_id'
end
end
# An application to a job by a candidate.
#
# The following two fields are required and there are not reasonable
# defaults so they must be provided before save is called:
#
# reference:: The foreign key for the job being applied to
# id:: The foreign key to the company being applied to
class Application < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:profile_status_id => 8,
:divisions_id => 1,
:priority => 4,
}
end
# An interview with a candidate for a job.
class Interview < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:ref_divisions_id => 1,
}
end
# A fulfilled job with a candidate.
class Placement < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:placedivisions_id => 1,
}
# Override to provide a default value (today) for dateenter
def save
self.dateenter ||= Date.today
super
end
end
# An organization that a job belongs to
class Company < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:contacts_divisions_id => 1,
:company_status_id => 1,
:comp_recruiter => 'N/A',
}
# Override since it does not follow pattern of other objects
def self.id_param
'intId'
end
end
# A job that a candidate might apply for
class Job < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:jobs_divisions_id => 1,
:id => 1,
:jobs_counselor => 'N/A',
}
# Override since it does not follow pattern of other objects
def self.id_param
'intReference'
end
end
# Some sort of activity log on a candidate. The candidate foreign
# key is stored in the field 'cid' and is required to save a record.
class Activity < Base
# Fields that require a value in order to create a valid record
# even though there really is no valid value.
DEFAULTS = {
:activity_divisions_id => 1,
:activitycounsel => 'N/A',
}
end
end
@eric1234
Copy link
Author

@kleine2 - Took a look at what your comment so I can update the code and now I'm not sure my previous comment really addressed what you were saying before. The original line you said to add. Where were you meaning that to be added? How is "docs_id" in your original code initialized? It it an attribute of the document coming from Maxhire?

@kleine2
Copy link

kleine2 commented Jan 19, 2012

In the file method in the Document class I made this change:


def file
     response = APIConnection.instance.request :document, :get_file do |xml|
        xml.tag! "intDocumentId", docs_id
      end
      ....

There are a whole bunch of other issues I am dealing with in a more get it done type of way (versus more generic) especially with the Application object.
Are you still invested into this work? If you are, maybe we could discuss some details more privately.

@eric1234
Copy link
Author

@kleine2 - After looking again I think my original comment is correct. I just needed to override id_param so it would know which field stores the id for Document. I never ran into this myself because I only create Document, I never deal with existing documents.

Regarding your question about new? I don't believe you need to change anything. Line 249 will assign the id with the correct value, even if there is an "attribute" called "id" which is not the unique identifier (that id becomes not accessible using the generated getter/setter but you can access it via [] and []=).

I am not currently invested in the work as the project it was created for is over for now. But if that project opens back up I may become invested in it again. Or if another project comes along with Maxhire I could become invested in it again. So not a personal passion but something I will work on as part of a paying project.

If you have some changes my suggestion is to just fork my Gist into your own. That way others can compare and contrast. I don't mind handing over the maintenance of this library to someone else but in case I do become invested in it again I would prefer it not be updated in a "get it done type of way". If I do become invested again I will certainly review your fork to incorporate the patches (in a way that is as clean as possible). Also if there is interest from others to turn this into a real library (i.e. a real git project) and maintain it in a clean way that is fine with me as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment