icasework 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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