swiftype-app-search 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,4 @@
1
+ require 'swiftype-app-search/client'
2
+
3
+ module SwiftypeAppSearch
4
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module SwiftypeAppSearch
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $:.unshift File.expand_path('../../lib', __FILE__)
4
+
5
+ require 'swiftype-app-search'
6
+ require 'irb'
7
+ require 'irb/completion'
8
+
9
+ IRB.start
@@ -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
@@ -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