icasework 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/icasework.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/icasework/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'icasework'
7
+ spec.version = Icasework::VERSION
8
+ spec.authors = ['mySociety']
9
+ spec.email = ['hello@mysociety.org']
10
+
11
+ spec.summary = 'Ruby library for the iCasework API.'
12
+ spec.description = 'iCasework is a case management software that enables ' \
13
+ 'organisations of all sizes to do a better job of case management'
14
+ spec.homepage = 'https://github.com/mysociety/icasework-ruby/'
15
+ spec.license = 'MIT'
16
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
17
+
18
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
19
+
20
+ spec.metadata['homepage_uri'] = spec.homepage
21
+ spec.metadata['source_code_uri'] = spec.homepage
22
+
23
+ # Specify which files should be added to the gem when it is released.
24
+ # The `git ls-files -z` loads the files in the RubyGem that have been added
25
+ # into git.
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ `git ls-files -z`.split("\x0").reject do |f|
28
+ f.match(%r{^(test|spec|features)/})
29
+ end
30
+ end
31
+ spec.bindir = 'exe'
32
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ['lib']
34
+
35
+ spec.add_dependency 'activesupport', '>= 4.0.0'
36
+ spec.add_dependency 'jwt', '~> 2.2.0'
37
+ spec.add_dependency 'nokogiri', '~> 1.0'
38
+ spec.add_dependency 'pdf-reader', '~> 2.4.0'
39
+ spec.add_dependency 'rest-client', '~> 2.1.0'
40
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/deep_merge'
4
+
5
+ module Icasework
6
+ ##
7
+ # A Ruby representation of a case in iCasework
8
+ #
9
+ class Case
10
+ class << self
11
+ def where(params)
12
+ cases = Icasework::Resource.get_cases(params).data[:cases]
13
+ return [] unless cases
14
+
15
+ cases[:case].map { |data| new(case_data(data)) }
16
+ end
17
+
18
+ def create(params)
19
+ data = Icasework::Resource.create_case(params).data[:createcaseresponse]
20
+ return nil unless data
21
+
22
+ new(case_details: { case_id: data[:caseid] })
23
+ end
24
+
25
+ private
26
+
27
+ def case_data(data)
28
+ {
29
+ case_details: case_details_data(data),
30
+ case_status: case_status_data(data),
31
+ case_status_receipt: case_status_receipt_data(data),
32
+ attributes: data[:attributes],
33
+ classifications: [data[:classifications][:classification]].flatten,
34
+ documents: [data[:documents][:document]].flatten
35
+ }
36
+ end
37
+
38
+ def case_details_data(data)
39
+ { case_id: data[:case_id], case_type: data[:type],
40
+ case_label: data[:label] }
41
+ end
42
+
43
+ def case_status_receipt_data(data)
44
+ { method: data[:request_method], time_created: data[:request_date] }
45
+ end
46
+
47
+ def case_status_data(data)
48
+ { status: data[:status] }
49
+ end
50
+ end
51
+
52
+ def initialize(hash)
53
+ @hash = LazyHash.new(hash) do
54
+ load_additional_data!
55
+ end
56
+ end
57
+
58
+ def case_id
59
+ self[:case_details][:case_id]
60
+ end
61
+
62
+ def [](key)
63
+ @hash[key]
64
+ end
65
+
66
+ def classifications
67
+ @hash[:classifications].map { |c| Classification.new(c) }
68
+ end
69
+
70
+ def documents
71
+ @hash[:documents].map { |d| Document.new(d) }
72
+ end
73
+
74
+ def to_h
75
+ @hash.to_h
76
+ end
77
+
78
+ private
79
+
80
+ def load_additional_data!
81
+ return @hash if @loaded
82
+
83
+ @loaded = true
84
+ @hash.deep_merge!(fetch_additional_data)
85
+ end
86
+
87
+ def fetch_additional_data
88
+ @fetch_additional_data ||= begin
89
+ cases = Icasework::Resource.get_case_details(
90
+ case_id: case_id
91
+ ).data[:cases]
92
+
93
+ if cases
94
+ cases[:case]
95
+ else
96
+ {}
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icasework
4
+ ##
5
+ # A Ruby representation of a classification in iCasework
6
+ #
7
+ class Classification
8
+ attr_reader :group, :title
9
+
10
+ def initialize(attributes)
11
+ @group = attributes[:group]
12
+ @title = attributes[:__content__]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pdf/reader'
4
+
5
+ module Icasework
6
+ ##
7
+ # A Ruby representation of a document in iCasework
8
+ #
9
+ class Document
10
+ class << self
11
+ def where(params)
12
+ documents = Icasework::Resource.get_case_documents(params).
13
+ data[:documents]
14
+ return [] unless documents
15
+
16
+ [documents[:document]].flatten.map do |attributes|
17
+ new(attributes)
18
+ end
19
+ end
20
+
21
+ def find(document_id: nil, **params)
22
+ documents = where(params)
23
+ return documents unless document_id
24
+
25
+ documents.find { |d| d.attributes[:id] == document_id }
26
+ end
27
+ end
28
+
29
+ attr_reader :attributes, :url
30
+
31
+ def initialize(attributes)
32
+ @attributes = attributes
33
+ @url = attributes[:__content__]
34
+ end
35
+
36
+ def pdf?
37
+ attributes[:type] == 'application/pdf'
38
+ end
39
+
40
+ def pdf_contents
41
+ return unless pdf?
42
+
43
+ PDF::Reader.open(pdf_file) do |reader|
44
+ reader.pages.map(&:text).join
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def pdf_file
51
+ raw = RestClient::Request.execute(
52
+ method: :get,
53
+ url: url,
54
+ raw_response: true
55
+ )
56
+ raw.file
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icasework
4
+ ##
5
+ # An API authentication error
6
+ #
7
+ AuthenticationError = Class.new(RuntimeError)
8
+
9
+ ##
10
+ # A request error
11
+ #
12
+ RequestError = Class.new(RuntimeError)
13
+
14
+ ##
15
+ # A response error
16
+ #
17
+ ResponseError = Class.new(RuntimeError)
18
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module Icasework
6
+ ##
7
+ # A hash which will attempt to lazy load a value from given block when the key
8
+ # is missing.
9
+ #
10
+ class LazyHash < SimpleDelegator
11
+ def initialize(hash, key = nil, &block)
12
+ @hash = hash
13
+ @key = key
14
+ @block = block
15
+
16
+ @hash.default_proc = proc do |h, k|
17
+ new_hash = @block.call
18
+ new_hash = new_hash[@key] if @key
19
+ h[k] = new_hash.fetch(k)
20
+ end
21
+
22
+ super(@hash)
23
+ end
24
+
25
+ def [](key)
26
+ value = @hash[key]
27
+ case value
28
+ when Hash
29
+ LazyHash.new(value, key, &@block)
30
+ else
31
+ value
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icasework
4
+ class Resource
5
+ ##
6
+ # Method to output a Icasework::Resource instance as a curl command:
7
+ #
8
+ module Curl
9
+ def to_curl
10
+ "curl #{curl_params}#{curl_auth}'#{url}'"
11
+ end
12
+
13
+ private
14
+
15
+ def curl_auth
16
+ auth_header = headers[:authorization]
17
+ "-H 'Authorization: #{auth_header}' " if auth_header
18
+ end
19
+
20
+ def curl_params
21
+ case method
22
+ when :get
23
+ return '-X GET ' if payload[:params].empty?
24
+
25
+ "-G -d '#{URI.encode_www_form(payload[:params])}' "
26
+ when :post
27
+ return '-X POST ' if payload.empty?
28
+
29
+ "-X POST -d '#{URI.encode_www_form(payload)}' "
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icasework
4
+ class Resource
5
+ ##
6
+ # Converts data returned from the iCasework API into a more "Ruby like" hash
7
+ #
8
+ module Data
9
+ class << self
10
+ def process(data)
11
+ case data
12
+ when Hash
13
+ convert_keys(array_keys_to_array(flat_keys_to_nested(data)))
14
+ when Array
15
+ data.map { |d| process(d) }
16
+ else
17
+ data
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # converts: { 'foo.bar': 'baz' }
24
+ # into { foo: { bar: 'baz' } }
25
+ def flat_keys_to_nested(hash)
26
+ hash.each_with_object({}) do |(key, value), all|
27
+ key_parts = key.to_s.split('.')
28
+ leaf = key_parts[0...-1].inject(all) { |h, k| h[k] ||= {} }
29
+ leaf[key_parts.last] = process(value)
30
+ end
31
+ end
32
+
33
+ # converts: { 'n1': 'foo', 'n2': 'bar' }
34
+ # into: { n: ['foo', 'bar'] }
35
+ def array_keys_to_array(hash)
36
+ hash.each_with_object({}) do |(key, value), all|
37
+ if key.to_s =~ /^(.*)\d+$/
38
+ key = Regexp.last_match(1)
39
+ all[key] ||= []
40
+ all[key] << process(value)
41
+ else
42
+ all[key] = process(value)
43
+ end
44
+ end
45
+ end
46
+
47
+ # converts: 'FooBar'
48
+ # into: :foo_bar
49
+ def convert_keys(hash)
50
+ hash.each_with_object({}) do |(key, value), all|
51
+ converted_key = key.gsub(/([a-z\d])?([A-Z])/) do
52
+ first = Regexp.last_match(1)
53
+ second = Regexp.last_match(2)
54
+ "#{"#{first}_" if first}#{second.downcase}"
55
+ end
56
+
57
+ all[converted_key.to_sym] = process(value)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icasework
4
+ class Resource
5
+ ##
6
+ # Converts payload for iCasework API endpoints into a flat/titlecase keys
7
+ #
8
+ module Payload
9
+ class << self
10
+ def process(data)
11
+ case data
12
+ when Hash
13
+ nested_to_flat_keys(convert_keys(data))
14
+ else
15
+ data
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ # converts { 'foo' => { 'bar' => 'baz' } }
22
+ # into: { 'foo.bar' => 'baz' }
23
+ def nested_to_flat_keys(hash, key = [])
24
+ return { key.join('.') => process(hash) } unless hash.is_a?(Hash)
25
+
26
+ hash.inject({}) do |h, v|
27
+ h.merge!(nested_to_flat_keys(v[-1], key + [v[0]]))
28
+ end
29
+ end
30
+
31
+ # converts: :foo_bar
32
+ # into: 'FooBar'
33
+ def convert_keys(hash)
34
+ hash.each_with_object({}) do |(key, value), all|
35
+ converted_key = key if valid_keys.include?(key.to_s)
36
+ converted_key ||= key.to_s.gsub(/(?:^|_)([a-z])/i) do
37
+ Regexp.last_match(1).upcase
38
+ end
39
+
40
+ all[converted_key.to_s] = process(value)
41
+ end
42
+ end
43
+
44
+ def valid_keys
45
+ %w[db fromseq toseq grant_type assertion access_token]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rest_client'
4
+
5
+ module Icasework
6
+ ##
7
+ # API Endpoints for configured database
8
+ #
9
+ class Resource
10
+ class << self
11
+ def token(payload = {})
12
+ new(:post, 'token', payload, authorised: false, format: nil)
13
+ end
14
+
15
+ def get_cases(payload = {})
16
+ new(:get, 'getcases', payload, subdomain: 'uatportal')
17
+ end
18
+
19
+ def get_case_attribute(payload = {})
20
+ new(:get, 'getcaseattribute', payload)
21
+ end
22
+
23
+ def get_case_details(payload = {})
24
+ new(:get, 'getcasedetails', payload)
25
+ end
26
+
27
+ def get_case_documents(payload = {})
28
+ new(:get, 'getcasedocuments', payload)
29
+ end
30
+
31
+ def create_case(payload = {})
32
+ new(:post, 'createcase', payload)
33
+ end
34
+ end
35
+
36
+ require 'icasework/resource/curl'
37
+ include Curl
38
+
39
+ attr_reader :method
40
+
41
+ def initialize(method, path, payload, **options)
42
+ @method = method
43
+ @path = path
44
+ @payload = payload
45
+ @options = options
46
+ end
47
+
48
+ def url
49
+ if Icasework.production?
50
+ "https://#{Icasework.account}.icasework.com/#{@path}"
51
+ else
52
+ subdomain = @options.fetch(:subdomain, 'uat')
53
+ "https://#{subdomain}.icasework.com/#{@path}?db=#{Icasework.account}"
54
+ end
55
+ end
56
+
57
+ def headers
58
+ return {} unless authorised?
59
+
60
+ headers = {}
61
+ headers[:authorization] = "Bearer #{Icasework::Token::Bearer.generate}"
62
+ headers
63
+ end
64
+
65
+ def payload
66
+ return @payload if @payload_parsed
67
+
68
+ @payload[:format] = format if format
69
+
70
+ @payload = Payload.process(@payload)
71
+ @payload = { params: @payload } if method == :get
72
+
73
+ @payload_parsed = true
74
+ @payload
75
+ end
76
+
77
+ def data
78
+ response
79
+ end
80
+
81
+ private
82
+
83
+ def authorised?
84
+ @options.fetch(:authorised, true)
85
+ end
86
+
87
+ def format
88
+ @options.fetch(:format, 'xml')
89
+ end
90
+
91
+ def resource
92
+ RestClient::Resource.new(url, headers: headers)
93
+ end
94
+
95
+ def response
96
+ resource.public_send(method, payload, &parser)
97
+ rescue RestClient::Exception => e
98
+ raise RequestError, e.message
99
+ end
100
+
101
+ def parser
102
+ lambda do |response, _request, _result|
103
+ Data.process(parse_format(response))
104
+ rescue JSON::ParserError
105
+ raise ResponseError, "JSON invalid (#{response.body[0...100]})"
106
+ rescue REXML::ParseException
107
+ raise ResponseError, "XML invalid (#{response.body[0...100]})"
108
+ end
109
+ end
110
+
111
+ def parse_format(response)
112
+ case format
113
+ when 'xml'
114
+ XMLConverter.new(response.body).to_h
115
+ else
116
+ JSON.parse(response.body)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icasework
4
+ module Token
5
+ ##
6
+ # Generate access token for Bearer authorisation header
7
+ #
8
+ class Bearer
9
+ class << self
10
+ def generate
11
+ new Icasework::Resource.token(payload).data
12
+ rescue RequestError, ResponseError => e
13
+ raise AuthenticationError, e.message
14
+ end
15
+
16
+ private
17
+
18
+ def payload
19
+ {
20
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
21
+ assertion: Icasework::Token::JWT.generate
22
+ }
23
+ end
24
+ end
25
+
26
+ def initialize(data)
27
+ @access_token = data.fetch(:access_token)
28
+ @token_type = data.fetch(:token_type)
29
+ @expires_in = data.fetch(:expires_in)
30
+ end
31
+
32
+ def to_s
33
+ @access_token
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Icasework
6
+ module Token
7
+ ##
8
+ # Generate JSON web token for OAuth API authentication
9
+ #
10
+ class JWT
11
+ class << self
12
+ def generate
13
+ new ::JWT.encode(payload, Icasework.secret_key, 'HS256')
14
+ end
15
+
16
+ private
17
+
18
+ def payload
19
+ {
20
+ iss: Icasework.api_key,
21
+ aud: Icasework::Resource.token.url,
22
+ iat: Time.now.to_i
23
+ }
24
+ end
25
+ end
26
+
27
+ def initialize(token)
28
+ @token = token
29
+ end
30
+
31
+ def to_s
32
+ @token
33
+ end
34
+
35
+ def ==(other)
36
+ @token == other
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Icasework
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/conversions'
4
+
5
+ module Icasework
6
+ ##
7
+ # A patched version of ActiveSupport's XML converter which includes XML tag
8
+ # attributes
9
+ #
10
+ # Credit: https://stackoverflow.com/a/29431089
11
+ #
12
+ class XMLConverter < ActiveSupport::XMLConverter
13
+ private
14
+
15
+ def become_content?(value)
16
+ value['type'] == 'file' ||
17
+ (value['__content__'] &&
18
+ (value.keys.size == 1 && value['__content__'].present?))
19
+ end
20
+ end
21
+ end
data/lib/icasework.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'icasework/version'
4
+
5
+ ##
6
+ # This module is the main entry point of the Gem
7
+ #
8
+ module Icasework
9
+ require 'icasework/case'
10
+ require 'icasework/classification'
11
+ require 'icasework/document'
12
+ require 'icasework/errors'
13
+ require 'icasework/lazy_hash'
14
+ require 'icasework/resource'
15
+ require 'icasework/resource/data'
16
+ require 'icasework/resource/payload'
17
+ require 'icasework/token/jwt'
18
+ require 'icasework/token/bearer'
19
+ require 'icasework/xml_converter'
20
+
21
+ ConfigurationError = Class.new(StandardError)
22
+
23
+ class << self
24
+ attr_writer :account, :api_key, :secret_key
25
+
26
+ def account
27
+ @account || raise(
28
+ ConfigurationError, 'Icasework.account not configured'
29
+ )
30
+ end
31
+
32
+ def api_key
33
+ @api_key || raise(
34
+ ConfigurationError, 'Icasework.api_key not configured'
35
+ )
36
+ end
37
+
38
+ def secret_key
39
+ @secret_key || raise(
40
+ ConfigurationError, 'Icasework.secret_key not configured'
41
+ )
42
+ end
43
+
44
+ def env=(env)
45
+ @production = (env == 'production')
46
+ end
47
+
48
+ def production?
49
+ @production || false
50
+ end
51
+ end
52
+ end