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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.travis.yml +15 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +111 -0
- data/Rakefile +1 -0
- data/lib/data/ca-bundle.crt +3554 -0
- data/lib/swiftype-enterprise.rb +6 -0
- data/lib/swiftype-enterprise/client.rb +140 -0
- data/lib/swiftype-enterprise/configuration.rb +53 -0
- data/lib/swiftype-enterprise/exceptions.rb +9 -0
- data/lib/swiftype-enterprise/request.rb +118 -0
- data/lib/swiftype-enterprise/utils.rb +13 -0
- data/lib/swiftype-enterprise/version.rb +3 -0
- data/spec/client_spec.rb +119 -0
- data/spec/configuration_spec.rb +19 -0
- data/spec/fixtures/vcr/document_receipts_multiple_complete.yml +203 -0
- data/spec/spec_helper.rb +28 -0
- data/swiftype-enterprise.gemspec +22 -0
- metadata +122 -0
@@ -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
|
data/spec/client_spec.rb
ADDED
@@ -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
|