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