swiftype-enterprise 1.0.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.
@@ -0,0 +1,6 @@
1
+ require 'swiftype-enterprise/client'
2
+
3
+ module SwiftypeEnterprise
4
+ extend SwiftypeEnterprise::Configuration
5
+ end
6
+
@@ -0,0 +1,140 @@
1
+ require 'set'
2
+ require 'swiftype-enterprise/configuration'
3
+ require 'swiftype-enterprise/request'
4
+ require 'swiftype-enterprise/utils'
5
+
6
+ module SwiftypeEnterprise
7
+ # API client for the {Swiftype Enterprise API}[https://swiftype.com/enterprise-search].
8
+ class Client
9
+ DEFAULT_TIMEOUT = 15
10
+
11
+ include SwiftypeEnterprise::Request
12
+
13
+ def self.configure(&block)
14
+ SwiftypeEnterprise.configure &block
15
+ end
16
+
17
+ # Create a new SwiftypeEnterprise::Client client
18
+ #
19
+ # @param options [Hash] a hash of configuration options that will override what is set on the SwiftypeEnterprise class.
20
+ # @option options [String] :access_token an Access Token to use for this client
21
+ # @option options [Numeric] :overall_timeout overall timeout for requests in seconds (default: 15s)
22
+ # @option options [Numeric] :open_timeout the number of seconds Net::HTTP (default: 15s)
23
+ # will wait while opening a connection before raising a Timeout::Error
24
+ def initialize(options={})
25
+ @options = options
26
+ end
27
+
28
+ def access_token
29
+ @options[:access_token] || SwiftypeEnterprise.access_token
30
+ end
31
+
32
+ def open_timeout
33
+ @options[:open_timeout] || DEFAULT_TIMEOUT
34
+ end
35
+
36
+ def overall_timeout
37
+ (@options[:overall_timeout] || DEFAULT_TIMEOUT).to_f
38
+ end
39
+
40
+ # Documents have fields that can be searched or filtered.
41
+ #
42
+ # For more information on indexing documents, see the {Content Source documentation}[https://app.swiftype.com/ent/docs/custom_sources].
43
+ module ContentSourceDocuments
44
+ REQUIRED_TOP_LEVEL_KEYS = [
45
+ 'external_id',
46
+ 'url',
47
+ 'title',
48
+ 'body'
49
+ ].map!(&:freeze).to_set.freeze
50
+ OPTIONAL_TOP_LEVEL_KEYS = [
51
+ 'created_at',
52
+ 'updated_at',
53
+ 'type',
54
+ ].map!(&:freeze).to_set.freeze
55
+ CORE_TOP_LEVEL_KEYS = (REQUIRED_TOP_LEVEL_KEYS + OPTIONAL_TOP_LEVEL_KEYS).freeze
56
+
57
+ # Retrieve Document Receipts from the API by ID for the {asynchronous API}[https://app.swiftype.com/ent/docs/custom_sources]
58
+ #
59
+ # @param [Array<String>] receipt_ids an Array of Document Receipt IDs
60
+ #
61
+ # @return [Array<Hash>] an Array of Document Receipt hashes
62
+ def document_receipts(receipt_ids)
63
+ get('ent/document_receipts/bulk_show.json', :ids => receipt_ids.join(','))
64
+ end
65
+
66
+ # Index a batch of documents synchronously using the {Content Source API}[https://app.swiftype.com/ent/docs/custom_sources].
67
+ #
68
+ # @param [String] content_source_key the unique Content Source key as found in your Content Sources dashboard
69
+ # @param [Array] documents an Array of Document Hashes
70
+ # @option options [Numeric] :timeout (10) Number of seconds to wait before raising an exception
71
+ #
72
+ # @return [Array<Hash>] an Array of processed Document Receipt hashes
73
+ #
74
+ # @raise [Timeout::Error] when timeout expires waiting for receipts
75
+ def index_documents(content_source_key, documents, options = {})
76
+ documents = Array(documents).map! { |document| validate_and_normalize_document(document) }
77
+
78
+ res = async_create_or_update_documents(content_source_key, documents)
79
+ receipt_ids = res['document_receipts'].map { |a| a['id'] }
80
+
81
+ poll(options) do
82
+ receipts = document_receipts(receipt_ids)
83
+ flag = receipts.all? { |a| a['status'] != 'pending' }
84
+ flag ? receipts : false
85
+ end
86
+ end
87
+
88
+ # Index a batch of documents asynchronously using the {Content Source API}[https://app.swiftype.com/ent/docs/custom_sources].
89
+ #
90
+ # @param [String] content_source_key the unique Content Source key as found in your Content Sources dashboard
91
+ # @param [Array] documents an Array of Document Hashes
92
+ # @param [Hash] options additional options
93
+ #
94
+ # @return [Array<String>] an Array of Document Receipt IDs pending completion
95
+ def async_index_documents(content_source_key, documents, options = {})
96
+ documents = Array(documents).map! { |document| validate_and_normalize_document(document) }
97
+
98
+ res = async_create_or_update_documents(content_source_key, documents)
99
+ res['document_receipts'].map { |a| a['id'] }
100
+ end
101
+
102
+ # Destroy a batch of documents given a list of external IDs
103
+ #
104
+ # @param [Array<String>] document_ids an Array of Document External IDs
105
+ #
106
+ # @return [Array<Hash>] an Array of Document destroy result hashes
107
+ def destroy_documents(content_source_key, document_ids)
108
+ document_ids = Array(document_ids)
109
+ post("ent/sources/#{content_source_key}/documents/bulk_destroy.json", document_ids)
110
+ end
111
+
112
+ private
113
+ def async_create_or_update_documents(content_source_key, documents)
114
+ post("ent/sources/#{content_source_key}/documents/bulk_create.json", documents)
115
+ end
116
+
117
+ def validate_and_normalize_document(document)
118
+ document = Utils.stringify_keys(document)
119
+ missing_keys = REQUIRED_TOP_LEVEL_KEYS - document.keys
120
+ raise SwiftypeEnterprise::InvalidDocument.new("missing required fields (#{missing_keys.to_a.join(', ')})") if missing_keys.any?
121
+
122
+ normalized_document = {}
123
+
124
+ body_content = [document.delete('body')]
125
+ document.each do |key, value|
126
+ if CORE_TOP_LEVEL_KEYS.include?(key)
127
+ normalized_document[key] = value
128
+ else
129
+ body_content << "#{key}: #{value}"
130
+ end
131
+ end
132
+ normalized_document['body'] = body_content.join("\n")
133
+
134
+ normalized_document
135
+ end
136
+ end
137
+
138
+ include SwiftypeEnterprise::Client::ContentSourceDocuments
139
+ end
140
+ end
@@ -0,0 +1,53 @@
1
+ require 'uri'
2
+ require 'swiftype-enterprise/version'
3
+
4
+ module SwiftypeEnterprise
5
+ module Configuration
6
+ DEFAULT_ENDPOINT = "https://api.swiftype.com/api/v1/"
7
+ DEFAULT_USER_AGENT = "SwiftypeEnterprise-Ruby/#{SwiftypeEnterprise::VERSION}"
8
+
9
+ VALID_OPTIONS_KEYS = [
10
+ :access_token,
11
+ :user_agent,
12
+ :endpoint
13
+ ].freeze
14
+
15
+ attr_accessor *VALID_OPTIONS_KEYS
16
+
17
+ def self.extended(base)
18
+ base.reset
19
+ end
20
+
21
+ # Reset configuration to default values.
22
+ def reset
23
+ self.access_token = nil
24
+ self.endpoint = DEFAULT_ENDPOINT
25
+ self.user_agent = DEFAULT_USER_AGENT
26
+ self
27
+ end
28
+
29
+ # Yields the SwiftypeEnterprise::Configuration module which can be used to set configuration options.
30
+ #
31
+ # @return self
32
+ def configure
33
+ yield self
34
+ self
35
+ end
36
+
37
+ # Return a hash of the configured options.
38
+ def options
39
+ options = {}
40
+ VALID_OPTIONS_KEYS.each{ |k| options[k] = send(k) }
41
+ options
42
+ end
43
+
44
+ # setter for endpoint that ensures it always ends in '/'
45
+ def endpoint=(endpoint)
46
+ if endpoint.end_with?('/')
47
+ @endpoint = endpoint
48
+ else
49
+ @endpoint = "#{endpoint}/"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,9 @@
1
+ module SwiftypeEnterprise
2
+ class ClientException < StandardError; end
3
+ class NonExistentRecord < ClientException; end
4
+ class InvalidCredentials < ClientException; end
5
+ class BadRequest < ClientException; end
6
+ class Forbidden < ClientException; end
7
+ class UnexpectedHTTPException < ClientException; end
8
+ class InvalidDocument < ClientException; end
9
+ end
@@ -0,0 +1,118 @@
1
+ require 'net/https'
2
+ require 'json'
3
+ require 'swiftype-enterprise/exceptions'
4
+ require 'openssl'
5
+
6
+ module SwiftypeEnterprise
7
+ module Request
8
+ def get(path, params={})
9
+ request(:get, path, params)
10
+ end
11
+
12
+ def post(path, params={})
13
+ request(:post, path, params)
14
+ end
15
+
16
+ def put(path, params={})
17
+ request(:put, path, params)
18
+ end
19
+
20
+ def delete(path, params={})
21
+ request(:delete, path, params)
22
+ end
23
+
24
+ # Poll a block with backoff until a timeout is reached.
25
+ #
26
+ # @param [Hash] options optional arguments
27
+ # @option options [Numeric] :timeout (10) Number of seconds to wait before timing out
28
+ #
29
+ # @yieldreturn a truthy value to return from poll
30
+ # @yieldreturn [false] to continue polling.
31
+ #
32
+ # @return the truthy value returned from the block.
33
+ #
34
+ # @raise [Timeout::Error] when the timeout expires
35
+ def poll(options={})
36
+ timeout = options[:timeout] || 10
37
+ delay = 0.05
38
+ Timeout.timeout(timeout) do
39
+ while true
40
+ res = yield
41
+ return res if res
42
+ sleep delay *= 2
43
+ end
44
+ end
45
+ end
46
+
47
+ # Construct and send a request to the API.
48
+ #
49
+ # @raise [Timeout::Error] when the timeout expires
50
+ def request(method, path, params = {})
51
+ Timeout.timeout(overall_timeout) do
52
+ uri = URI.parse("#{SwiftypeEnterprise.endpoint}#{path}")
53
+
54
+ request = build_request(method, uri, params)
55
+ http = Net::HTTP.new(uri.host, uri.port)
56
+ http.open_timeout = open_timeout
57
+ http.read_timeout = overall_timeout
58
+
59
+ if uri.scheme == 'https'
60
+ http.use_ssl = true
61
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
62
+ http.ca_file = File.join(File.dirname(__FILE__), '..', 'data', 'ca-bundle.crt')
63
+ http.ssl_timeout = open_timeout
64
+ end
65
+
66
+ response = http.request(request)
67
+ handle_errors(response)
68
+ JSON.parse(response.body) if response.body && response.body.strip != ''
69
+ end
70
+ end
71
+
72
+ private
73
+ def handle_errors(response)
74
+ case response
75
+ when Net::HTTPSuccess
76
+ response
77
+ when Net::HTTPUnauthorized
78
+ raise SwiftypeEnterprise::InvalidCredentials
79
+ when Net::HTTPNotFound
80
+ raise SwiftypeEnterprise::NonExistentRecord
81
+ when Net::HTTPBadRequest
82
+ raise SwiftypeEnterprise::BadRequest
83
+ when Net::HTTPForbidden
84
+ raise SwiftypeEnterprise::Forbidden
85
+ else
86
+ raise SwiftypeEnterprise::UnexpectedHTTPException, "#{response.code} #{response.body}"
87
+ end
88
+ end
89
+
90
+ def build_request(method, uri, params)
91
+ klass = case method
92
+ when :get
93
+ Net::HTTP::Get
94
+ when :post
95
+ Net::HTTP::Post
96
+ when :put
97
+ Net::HTTP::Put
98
+ when :delete
99
+ Net::HTTP::Delete
100
+ end
101
+
102
+ case method
103
+ when :get, :delete
104
+ uri.query = URI.encode_www_form(params) if params && !params.empty?
105
+ req = klass.new(uri.request_uri)
106
+ when :post, :put
107
+ req = klass.new(uri.request_uri)
108
+ req.body = JSON.generate(params) unless params.length == 0
109
+ end
110
+
111
+ req['User-Agent'] = SwiftypeEnterprise.user_agent
112
+ req['Content-Type'] = 'application/json'
113
+ req['Authorization'] = "Bearer #{access_token}"
114
+
115
+ req
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,13 @@
1
+ module SwiftypeEnterprise
2
+ module Utils
3
+ extend self
4
+
5
+ def stringify_keys(hash)
6
+ output = {}
7
+ hash.each do |key, value|
8
+ output[key.to_s] = value
9
+ end
10
+ output
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module SwiftypeEnterprise
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,119 @@
1
+ require 'spec_helper'
2
+
3
+ describe SwiftypeEnterprise::Client do
4
+ let(:engine_slug) { 'swiftype-api-example' }
5
+ let(:client) { SwiftypeEnterprise::Client.new }
6
+
7
+ before :each do
8
+ SwiftypeEnterprise.access_token = 'cGUN-vBokevBhhzyA669'
9
+ end
10
+
11
+ context 'ContentSourceDocuments' do
12
+ def check_receipt_response_format(response, options = {})
13
+ expect(response.keys).to match_array(["document_receipts", "batch_link"])
14
+ expect(response["document_receipts"]).to be_a_kind_of(Array)
15
+ expect(response["document_receipts"].first.keys).to match_array(["id", "external_id", "links", "status", "errors"])
16
+ expect(response["document_receipts"].first["external_id"]).to eq(options[:external_id]) if options[:external_id]
17
+ expect(response["document_receipts"].first["status"]).to eq(options[:status]) if options[:status]
18
+ expect(response["document_receipts"].first["errors"]).to eq(options[:errors]) if options[:errors]
19
+ end
20
+
21
+ let(:content_source_key) { '59542d332139de0acacc7dd4' }
22
+ let(:documents) do
23
+ [{'external_id'=>'INscMGmhmX4',
24
+ 'url' => 'http://www.youtube.com/watch?v=v1uyQZNg2vE',
25
+ 'title' => 'The Original Grumpy Cat',
26
+ 'body' => 'this is a test'},
27
+ {'external_id'=>'JNDFojsd02',
28
+ 'url' => 'http://www.youtube.com/watch?v=tsdfhk2j',
29
+ 'title' => 'Another Grumpy Cat',
30
+ 'body' => 'this is also a test'}]
31
+ end
32
+
33
+ context '#document_receipts' do
34
+ before :each do
35
+ def get_receipt_ids
36
+ receipt_ids = nil
37
+ VCR.use_cassette(:async_create_or_update_document_success) do
38
+ receipt_ids = client.async_index_documents(content_source_key, documents)
39
+ end
40
+ receipt_ids
41
+ end
42
+ end
43
+
44
+ it 'returns array of hashes one for each receipt' do
45
+ VCR.use_cassette(:document_receipts_multiple) do
46
+ receipt_ids = get_receipt_ids
47
+ response = client.document_receipts(receipt_ids)
48
+ expect(response.size).to eq(2)
49
+ expect(response.first.keys).to match_array(["id", "external_id", "links", "status", "errors"])
50
+ end
51
+ end
52
+ end
53
+
54
+ context '#index_documents' do
55
+ it 'returns document_receipts when successful' do
56
+ VCR.use_cassette(:async_create_or_update_document_success) do
57
+ VCR.use_cassette(:document_receipts_multiple_complete) do
58
+ response = client.index_documents(content_source_key, documents)
59
+ expect(response.map(&:keys).map(&:sort)).to eq([["errors", "external_id", "id", "links", "status"], ["errors", "external_id", "id", "links", "status"]])
60
+ expect(response.map { |a| a["status"] }).to eq(["complete", "complete"])
61
+ end
62
+ end
63
+ end
64
+
65
+ it 'should timeout if the process takes longer than the timeout option passed' do
66
+ allow(client).to receive(:document_receipts) { sleep 0.05 }
67
+
68
+ VCR.use_cassette(:async_create_or_update_document_success) do
69
+ expect do
70
+ client.index_documents(content_source_key, documents, :timeout => 0.01)
71
+ end.to raise_error(Timeout::Error)
72
+ end
73
+ end
74
+
75
+ it 'should validate required document fields' do
76
+ documents = [{'external_id'=>'INscMGmhmX4', 'url' => 'http://www.youtube.com/watch?v=v1uyQZNg2vE'}]
77
+ expect do
78
+ client.index_documents(content_source_key, documents)
79
+ end.to raise_error(SwiftypeEnterprise::InvalidDocument)
80
+ end
81
+
82
+ it 'should accept non-core document fields' do
83
+ documents.first['a_new_field'] = 'some value'
84
+ VCR.use_cassette(:async_create_or_update_document_success) do
85
+ VCR.use_cassette(:document_receipts_multiple_complete) do
86
+ response = client.index_documents(content_source_key, documents)
87
+ expect(response.map { |a| a["status"] }).to eq(["complete", "complete"])
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ context '#async_index_documents' do
94
+ it 'returns receipt IDs when successful' do
95
+ VCR.use_cassette(:async_create_or_update_document_success) do
96
+ VCR.use_cassette(:document_receipts_multiple_complete) do
97
+ response = client.async_index_documents(content_source_key, documents)
98
+ expect(response.size).to eq(2)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ context '#destroy_documents' do
105
+ it 'returns #async_create_or_update_documents format return when async has been passed as true' do
106
+ VCR.use_cassette(:async_create_or_update_document_success) do
107
+ VCR.use_cassette(:document_receipts_multiple_complete) do
108
+ client.index_documents(content_source_key, documents)
109
+ VCR.use_cassette(:destroy_documents_success) do
110
+ response = client.destroy_documents(content_source_key, [documents.first['external_id']])
111
+ expect(response.size).to eq(1)
112
+ expect(response.first['success']).to eq(true)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end