swiftype-app-search 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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +4 -0
- data/.travis.yml +15 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +123 -0
- data/Rakefile +1 -0
- data/lib/data/ca-bundle.crt +3554 -0
- data/lib/swiftype-app-search.rb +4 -0
- data/lib/swiftype-app-search/client.rb +62 -0
- data/lib/swiftype-app-search/client/documents.rb +56 -0
- data/lib/swiftype-app-search/client/engines.rb +23 -0
- data/lib/swiftype-app-search/client/search.rb +17 -0
- data/lib/swiftype-app-search/exceptions.rb +24 -0
- data/lib/swiftype-app-search/request.rb +111 -0
- data/lib/swiftype-app-search/utils.rb +18 -0
- data/lib/swiftype-app-search/version.rb +3 -0
- data/script/console +9 -0
- data/spec/client_spec.rb +89 -0
- data/spec/spec_helper.rb +18 -0
- data/swiftype-app-search.gemspec +24 -0
- metadata +123 -0
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'set'
|
2
|
+
require 'swiftype-app-search/request'
|
3
|
+
require 'swiftype-app-search/utils'
|
4
|
+
require 'jwt'
|
5
|
+
|
6
|
+
module SwiftypeAppSearch
|
7
|
+
# API client for the {Swiftype App Search API}[https://swiftype.com/app-search].
|
8
|
+
class Client
|
9
|
+
autoload :Documents, 'swiftype-app-search/client/documents'
|
10
|
+
autoload :Engines, 'swiftype-app-search/client/engines'
|
11
|
+
autoload :Search, 'swiftype-app-search/client/search'
|
12
|
+
|
13
|
+
DEFAULT_TIMEOUT = 15
|
14
|
+
|
15
|
+
include SwiftypeAppSearch::Request
|
16
|
+
|
17
|
+
attr_reader :api_key, :open_timeout, :overall_timeout, :api_endpoint
|
18
|
+
|
19
|
+
# Create a new SwiftypeAppSearch::Client client
|
20
|
+
#
|
21
|
+
# @param options [Hash] a hash of configuration options that will override what is set on the SwiftypeAppSearch class.
|
22
|
+
# @option options [String] :account_host_key an Account Host Key to use for this client
|
23
|
+
# @option options [String] :api_key an Api Key to use for this client
|
24
|
+
# @option options [Numeric] :overall_timeout overall timeout for requests in seconds (default: 15s)
|
25
|
+
# @option options [Numeric] :open_timeout the number of seconds Net::HTTP (default: 15s)
|
26
|
+
# will wait while opening a connection before raising a Timeout::Error
|
27
|
+
def initialize(options = {})
|
28
|
+
@api_endpoint = options.fetch(:api_endpoint) { "https://#{options.fetch(:account_host_key)}.api.swiftype.com/api/as/v1/" }
|
29
|
+
@api_key = options.fetch(:api_key)
|
30
|
+
@open_timeout = options.fetch(:open_timeout, DEFAULT_TIMEOUT).to_f
|
31
|
+
@overall_timeout = options.fetch(:overall_timeout, DEFAULT_TIMEOUT).to_f
|
32
|
+
end
|
33
|
+
|
34
|
+
module SignedSearchOptions
|
35
|
+
ALGORITHM = 'HS256'.freeze
|
36
|
+
|
37
|
+
module ClassMethods
|
38
|
+
# Build a JWT for authentication
|
39
|
+
#
|
40
|
+
# @param [String] api_key the API Key to sign the request with
|
41
|
+
# @param [String] api_key_id the unique API Key identifier
|
42
|
+
# @option options see the {App Search API}[https://swiftype.com/documentation/app-search/] for supported search options.
|
43
|
+
#
|
44
|
+
# @return [String] the JWT to use for authentication
|
45
|
+
def create_signed_search_key(api_key, api_key_id, options = {})
|
46
|
+
raise 'Must create signed search key with an API Key, cannot use a Search Key' unless api_key.start_with?('api')
|
47
|
+
payload = Utils.symbolize_keys(options).merge(:api_key_id => api_key_id)
|
48
|
+
JWT.encode(payload, api_key, ALGORITHM)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.included(base)
|
53
|
+
base.extend(ClassMethods)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
include SwiftypeAppSearch::Client::Documents
|
58
|
+
include SwiftypeAppSearch::Client::Engines
|
59
|
+
include SwiftypeAppSearch::Client::Search
|
60
|
+
include SwiftypeAppSearch::Client::SignedSearchOptions
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# Documents have fields that can be searched or filtered.
|
2
|
+
#
|
3
|
+
# For more information on indexing documents, see the {App Search documentation}[https://swiftype.com/documentation/app-search/].
|
4
|
+
module SwiftypeAppSearch
|
5
|
+
class Client
|
6
|
+
module Documents
|
7
|
+
REQUIRED_TOP_LEVEL_KEYS = [
|
8
|
+
'id'
|
9
|
+
].map!(&:freeze).to_set.freeze
|
10
|
+
|
11
|
+
# Retrieve Documents from the API by IDs for the {App Search API}[https://swiftype.com/documentation/app-search/]
|
12
|
+
#
|
13
|
+
# @param [String] engine_name the unique Engine name
|
14
|
+
# @param [Array<String>] ids an Array of Document IDs
|
15
|
+
#
|
16
|
+
# @return [Array<Hash>] an Array of Documents
|
17
|
+
|
18
|
+
def get_documents(engine_name, ids)
|
19
|
+
get("engines/#{engine_name}/documents", ids)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Index a batch of documents using the {App Search API}[https://swiftype.com/documentation/app-search/].
|
23
|
+
#
|
24
|
+
# @param [String] engine_name the unique Engine name
|
25
|
+
# @param [Array] documents an Array of Document Hashes
|
26
|
+
#
|
27
|
+
# @return [Array<Hash>] an Array of processed Document Status hashes
|
28
|
+
#
|
29
|
+
# @raise [SwiftypeAppSearch::InvalidDocument] when a single document is missing required fields or contains unsupported fields
|
30
|
+
# @raise [Timeout::Error] when timeout expires waiting for statuses
|
31
|
+
def index_documents(engine_name, documents)
|
32
|
+
documents.map! { |document| validate_and_normalize_document(document) }
|
33
|
+
post("engines/#{engine_name}/documents", documents)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Destroy a batch of documents given a list of IDs
|
37
|
+
#
|
38
|
+
# @param [Array<String>] ids an Array of Document IDs
|
39
|
+
#
|
40
|
+
# @return [Array<Hash>] an Array of Document destroy result hashes
|
41
|
+
def destroy_documents(engine_name, ids)
|
42
|
+
delete("engines/#{engine_name}/documents", ids)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
def validate_and_normalize_document(document)
|
47
|
+
document = Utils.stringify_keys(document)
|
48
|
+
document_keys = document.keys.to_set
|
49
|
+
missing_keys = REQUIRED_TOP_LEVEL_KEYS - document_keys
|
50
|
+
raise InvalidDocument.new("missing required fields (#{missing_keys.to_a.join(', ')})") if missing_keys.any?
|
51
|
+
|
52
|
+
document
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# Engines are the core concept representing indexes in App Search.
|
2
|
+
#
|
3
|
+
module SwiftypeAppSearch
|
4
|
+
class Client
|
5
|
+
module Engines
|
6
|
+
def list_engines
|
7
|
+
get("engines")
|
8
|
+
end
|
9
|
+
|
10
|
+
def get_engine(engine_name)
|
11
|
+
get("engines/#{engine_name}")
|
12
|
+
end
|
13
|
+
|
14
|
+
def create_engine(engine_name)
|
15
|
+
post("engines", :name => engine_name)
|
16
|
+
end
|
17
|
+
|
18
|
+
def destroy_engine(engine_name)
|
19
|
+
delete("engines/#{engine_name}")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module SwiftypeAppSearch
|
2
|
+
class Client
|
3
|
+
module Search
|
4
|
+
# Search for documents
|
5
|
+
#
|
6
|
+
# @param [String] engine_name the unique Engine name
|
7
|
+
# @param [String] query the search query
|
8
|
+
# @option options see the {App Search API}[https://swiftype.com/documentation/app-search/] for supported search options.
|
9
|
+
#
|
10
|
+
# @return [Array<Hash>] an Array of Document destroy result hashes
|
11
|
+
def search(engine_name, query, options = {})
|
12
|
+
params = Utils.symbolize_keys(options).merge(:query => query)
|
13
|
+
request(:post, "engines/#{engine_name}/search", params)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module SwiftypeAppSearch
|
2
|
+
class ClientException < StandardError
|
3
|
+
attr_reader :errors
|
4
|
+
|
5
|
+
def initialize(response)
|
6
|
+
@errors = response['errors'] || [ response ]
|
7
|
+
message = (errors.count == 1) ? "Error: #{errors.first}" : "Errors: #{errors.inspect}"
|
8
|
+
super(message)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
class NonExistentRecord < ClientException; end
|
13
|
+
class InvalidCredentials < ClientException; end
|
14
|
+
class BadRequest < ClientException; end
|
15
|
+
class Forbidden < ClientException; end
|
16
|
+
class InvalidDocument < ClientException; end
|
17
|
+
|
18
|
+
class UnexpectedHTTPException < ClientException
|
19
|
+
def initialize(http_response)
|
20
|
+
@errors = []
|
21
|
+
super("HTTP #{http_response.code}: #{http_response.body}")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'net/https'
|
2
|
+
require 'json'
|
3
|
+
require 'swiftype-app-search/exceptions'
|
4
|
+
require 'swiftype-app-search/version'
|
5
|
+
require 'openssl'
|
6
|
+
|
7
|
+
module SwiftypeAppSearch
|
8
|
+
DEFAULT_USER_AGENT = "swiftype-app-search-ruby/#{SwiftypeAppSearch::VERSION}"
|
9
|
+
|
10
|
+
module Request
|
11
|
+
attr_accessor :last_request
|
12
|
+
|
13
|
+
def get(path, params={})
|
14
|
+
request(:get, path, params)
|
15
|
+
end
|
16
|
+
|
17
|
+
def post(path, params={})
|
18
|
+
request(:post, path, params)
|
19
|
+
end
|
20
|
+
|
21
|
+
def put(path, params={})
|
22
|
+
request(:put, path, params)
|
23
|
+
end
|
24
|
+
|
25
|
+
def patch(path, params={})
|
26
|
+
request(:patch, path, params)
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete(path, params={})
|
30
|
+
request(:delete, path, params)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Construct and send a request to the API.
|
34
|
+
#
|
35
|
+
# @raise [Timeout::Error] when the timeout expires
|
36
|
+
def request(method, path, params = {})
|
37
|
+
Timeout.timeout(overall_timeout) do
|
38
|
+
uri = URI.parse("#{api_endpoint}#{path}")
|
39
|
+
|
40
|
+
request = build_request(method, uri, params)
|
41
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
42
|
+
http.open_timeout = open_timeout
|
43
|
+
http.read_timeout = overall_timeout
|
44
|
+
|
45
|
+
http.set_debug_output(STDERR) if debug?
|
46
|
+
|
47
|
+
if uri.scheme == 'https'
|
48
|
+
http.use_ssl = true
|
49
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
50
|
+
http.ca_file = File.join(File.dirname(__FILE__), '..', 'data', 'ca-bundle.crt')
|
51
|
+
http.ssl_timeout = open_timeout
|
52
|
+
end
|
53
|
+
|
54
|
+
@last_request = request
|
55
|
+
|
56
|
+
response = http.request(request)
|
57
|
+
response_json = parse_response(response)
|
58
|
+
|
59
|
+
case response
|
60
|
+
when Net::HTTPSuccess
|
61
|
+
return response_json
|
62
|
+
when Net::HTTPBadRequest
|
63
|
+
raise SwiftypeAppSearch::BadRequest, response_json
|
64
|
+
when Net::HTTPUnauthorized
|
65
|
+
raise SwiftypeAppSearch::InvalidCredentials, response_json
|
66
|
+
when Net::HTTPNotFound
|
67
|
+
raise SwiftypeAppSearch::NonExistentRecord, response_json
|
68
|
+
when Net::HTTPForbidden
|
69
|
+
raise SwiftypeAppSearch::Forbidden, response_json
|
70
|
+
else
|
71
|
+
raise SwiftypeAppSearch::UnexpectedHTTPException, response
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def parse_response(response)
|
79
|
+
body = response.body.to_s.strip
|
80
|
+
body == '' ? {} : JSON.parse(body)
|
81
|
+
end
|
82
|
+
|
83
|
+
def debug?
|
84
|
+
@debug ||= (ENV['AS_DEBUG'] == 'true')
|
85
|
+
end
|
86
|
+
|
87
|
+
def build_request(method, uri, params)
|
88
|
+
klass = case method
|
89
|
+
when :get
|
90
|
+
Net::HTTP::Get
|
91
|
+
when :post
|
92
|
+
Net::HTTP::Post
|
93
|
+
when :put
|
94
|
+
Net::HTTP::Put
|
95
|
+
when :patch
|
96
|
+
Net::HTTP::Patch
|
97
|
+
when :delete
|
98
|
+
Net::HTTP::Delete
|
99
|
+
end
|
100
|
+
|
101
|
+
req = klass.new(uri.request_uri)
|
102
|
+
req.body = JSON.generate(params) unless params.length == 0
|
103
|
+
|
104
|
+
req['User-Agent'] = DEFAULT_USER_AGENT
|
105
|
+
req['Content-Type'] = 'application/json'
|
106
|
+
req['Authorization'] = "Bearer #{api_key}"
|
107
|
+
|
108
|
+
req
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module SwiftypeAppSearch
|
2
|
+
module Utils
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def stringify_keys(hash)
|
6
|
+
hash.each_with_object({}) do |(key, value), out|
|
7
|
+
out[key.to_s] = value
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def symbolize_keys(hash)
|
12
|
+
hash.each_with_object({}) do |(key, value), out|
|
13
|
+
new_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
14
|
+
out[new_key] = value
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
data/script/console
ADDED
data/spec/client_spec.rb
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
describe SwiftypeAppSearch::Client do
|
2
|
+
let(:engine_name) { "ruby-client-test-#{Time.now.to_i}" }
|
3
|
+
|
4
|
+
include_context "App Search Credentials"
|
5
|
+
let(:client) { SwiftypeAppSearch::Client.new(:account_host_key => as_account_host_key, :api_key => as_api_key) }
|
6
|
+
|
7
|
+
context 'Documents' do
|
8
|
+
%i[index_documents].each do |method|
|
9
|
+
context "##{method}" do
|
10
|
+
it 'should validate required document fields' do
|
11
|
+
documents = [{'url' => 'http://www.youtube.com/watch?v=v1uyQZNg2vE'}]
|
12
|
+
expect do
|
13
|
+
client.public_send(method, engine_name, documents)
|
14
|
+
end.to raise_error(SwiftypeAppSearch::InvalidDocument, 'Error: missing required fields (id)')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
context 'Engines' do
|
21
|
+
after do
|
22
|
+
# Clean up the test engine from our account
|
23
|
+
begin
|
24
|
+
client.destroy_engine(engine_name)
|
25
|
+
rescue SwiftypeAppSearch::NonExistentRecord
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
context '#create_engine' do
|
30
|
+
it 'should create an engine when given a right set of parameters' do
|
31
|
+
expect { client.get_engine(engine_name) }.to raise_error(SwiftypeAppSearch::NonExistentRecord)
|
32
|
+
client.create_engine(engine_name)
|
33
|
+
expect { client.get_engine(engine_name) }.to_not raise_error
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'should return an engine object' do
|
37
|
+
engine = client.create_engine(engine_name)
|
38
|
+
expect(engine).to be_kind_of(Hash)
|
39
|
+
expect(engine['name']).to eq(engine_name)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'should return an error when the engine name has already been taken' do
|
43
|
+
client.create_engine(engine_name)
|
44
|
+
expect { client.create_engine(engine_name) }.to raise_error do |e|
|
45
|
+
expect(e).to be_a(SwiftypeAppSearch::BadRequest)
|
46
|
+
expect(e.errors).to eq(['Name is already taken'])
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context '#list_engines' do
|
52
|
+
it 'should return an array with a list of engines' do
|
53
|
+
expect(client.list_engines).to be_an(Array)
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should include the engine name in listed objects' do
|
57
|
+
# Create an engine
|
58
|
+
client.create_engine(engine_name)
|
59
|
+
|
60
|
+
# Get the list
|
61
|
+
engines = client.list_engines
|
62
|
+
expect(engines.find { |e| e['name'] == engine_name }).to_not be_nil
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
context '#destroy_engine' do
|
67
|
+
it 'should destroy the engine if it exists' do
|
68
|
+
client.create_engine(engine_name)
|
69
|
+
expect { client.get_engine(engine_name) }.to_not raise_error
|
70
|
+
|
71
|
+
client.destroy_engine(engine_name)
|
72
|
+
expect { client.get_engine(engine_name) }.to raise_error(SwiftypeAppSearch::NonExistentRecord)
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'should raise an error if the engine does not exist' do
|
76
|
+
expect { client.destroy_engine(engine_name) }.to raise_error(SwiftypeAppSearch::NonExistentRecord)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
context 'Configuration' do
|
82
|
+
context 'account_host_key' do
|
83
|
+
it 'sets the base url correctly' do
|
84
|
+
client = SwiftypeAppSearch::Client.new(:account_host_key => 'host-asdf', :api_key => 'foo')
|
85
|
+
expect(client.api_endpoint).to eq('https://host-asdf.api.swiftype.com/api/as/v1/')
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'rspec'
|
3
|
+
require 'webmock'
|
4
|
+
require 'awesome_print'
|
5
|
+
require 'swiftype-app-search'
|
6
|
+
|
7
|
+
RSpec.shared_context "App Search Credentials" do
|
8
|
+
let(:as_api_key) { ENV.fetch('AS_API_KEY', 'API_KEY') }
|
9
|
+
let(:as_account_host_key) { ENV.fetch('AS_ACCOUNT_HOST_KEY', 'ACCOUNT_HOST_KEY') }
|
10
|
+
end
|
11
|
+
|
12
|
+
RSpec.configure do |config|
|
13
|
+
# Run specs in random order to surface order dependencies. If you find an
|
14
|
+
# order dependency and want to debug it, you can fix the order by providing
|
15
|
+
# the seed, which is printed after each run.
|
16
|
+
# --seed 1234
|
17
|
+
config.order = "random"
|
18
|
+
end
|