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.
@@ -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