├── MANIFEST.in ├── requirements.txt ├── NOTICE.txt ├── elastic_app_search ├── __init__.py ├── __version__.py ├── exceptions.py ├── request_session.py └── client.py ├── logo-app-search.png ├── tests ├── __init__.py ├── test_request_session.py └── test_client.py ├── setup.cfg ├── tox.ini ├── .circleci └── config.yml ├── setup.py ├── .gitignore ├── LICENSE.txt └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.20.0 2 | PyJWT==1.5.3 3 | -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Elastic App Search Python client. 2 | 3 | Copyright 2012-2019 Elasticsearch B.V. 4 | -------------------------------------------------------------------------------- /elastic_app_search/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client 2 | from .__version__ import __version__ 3 | -------------------------------------------------------------------------------- /logo-app-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elastic/app-search-python/HEAD/logo-app-search.png -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # the inclusion of the tests module is not meant to offer best practices for 2 | # testing in general, but rather to support the `find_packages` example in 3 | # setup.py that excludes installing the "tests" package 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=1 6 | -------------------------------------------------------------------------------- /elastic_app_search/__version__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'elastic-app-search' 2 | __description__ = 'An API client for Elastic App Search' 3 | __url__ = 'https://github.com/elastic/app-search-python' 4 | __version__ = '7.10.0' 5 | __author__ = 'Elastic' 6 | __author_email__ = 'support@elastic.co' 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # this file is *not* meant to cover or endorse the use of tox or pytest or 2 | # testing in general, 3 | # 4 | # It's meant to show the use of: 5 | # 6 | # - check-manifest 7 | # confirm items checked into vcs are in your sdist 8 | # - python setup.py check (using the readme_renderer extension) 9 | # confirms your long_description will render correctly on pypi 10 | # 11 | # and also to help confirm pull requests to this project. 12 | 13 | [tox] 14 | envlist = py{27,33,34,35,36} 15 | 16 | [testenv] 17 | basepython = 18 | py27: python2.7 19 | py33: python3.3 20 | py34: python3.4 21 | py35: python3.5 22 | py36: python3.6 23 | deps = 24 | check-manifest 25 | readme_renderer 26 | flake8 27 | pytest 28 | commands = 29 | check-manifest --ignore tox.ini,tests* 30 | python setup.py check -m -r -s 31 | flake8 . 32 | py.test tests 33 | [flake8] 34 | exclude = .tox,*.egg,build 35 | select = E,W,F 36 | -------------------------------------------------------------------------------- /elastic_app_search/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions raised by Elastic App Search Client.""" 2 | 3 | class ElasticAppSearchError(Exception): 4 | """Base class for all Elastic App Search errors.""" 5 | 6 | class InvalidCredentials(ElasticAppSearchError): 7 | """Raised when request cannot authenticate""" 8 | 9 | class NonExistentRecord(ElasticAppSearchError): 10 | """Raised when record does not exist""" 11 | 12 | class RecordAlreadyExists(ElasticAppSearchError): 13 | """Raised when record already exists""" 14 | 15 | class BadRequest(ElasticAppSearchError): 16 | """Raised when bad request""" 17 | 18 | def __init__(self, message): 19 | super(ElasticAppSearchError, self).__init__(message) 20 | 21 | class Forbidden(ElasticAppSearchError): 22 | """Raised when http forbidden""" 23 | 24 | class SynchronousDocumentIndexingFailed(ElasticAppSearchError): 25 | """Raised when synchronous indexing of documents takes too long""" 26 | 27 | class InvalidDocument(ElasticAppSearchError): 28 | """When a document has a non-accepted field or is missing a required field""" 29 | 30 | def __init__(self, message, document): 31 | super(ElasticAppSearchError, self).__init__(message) 32 | self.document = document 33 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2.1 3 | 4 | executors: 5 | python-27: { docker: [{ image: "python:2.7.16" }] } 6 | python-35: { docker: [{ image: "python:3.5.7" }] } 7 | python-36: { docker: [{ image: "python:3.6.9" }] } 8 | python-37: { docker: [{ image: "python:3.7.4" }] } 9 | 10 | jobs: 11 | build: 12 | parameters: 13 | executor: 14 | type: executor 15 | executor: << parameters.executor >> 16 | working_directory: ~/repo 17 | steps: 18 | - checkout 19 | - restore_cache: 20 | keys: 21 | - v1-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ checksum "requirements.txt" }} 22 | - run: 23 | name: install dependencies 24 | command: | 25 | pip install virtualenv 26 | virtualenv venv 27 | . venv/bin/activate 28 | python setup.py install 29 | - save_cache: 30 | paths: 31 | - ./venv 32 | key: v1-dependencies-{{ .Environment.CIRCLE_JOB }}-{{ checksum "requirements.txt" }} 33 | - run: 34 | name: run tests 35 | command: | 36 | . venv/bin/activate 37 | python setup.py test 38 | 39 | workflows: 40 | run-tests: 41 | jobs: 42 | - build: { name: run-tests-python-2.7, executor: python-27 } 43 | - build: { name: run-tests-python-3.5, executor: python-35 } 44 | - build: { name: run-tests-python-3.6, executor: python-36 } 45 | - build: { name: run-tests-python-3.7, executor: python-37 } 46 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | 5 | here = path.abspath(path.dirname(__file__)) 6 | 7 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 8 | long_description = f.read() 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | about = {} 12 | with open(path.join(here, 'elastic_app_search', '__version__.py'), 'r', 'utf-8') as f: 13 | exec(f.read(), about) 14 | 15 | setup( 16 | name=about['__title__'], 17 | version=about['__version__'], 18 | description=about['__description__'], 19 | long_description=long_description, 20 | long_description_content_type='text/markdown', 21 | url=about['__url__'], 22 | author=about['__author__'], 23 | author_email=about['__author_email__'], 24 | license='Apache 2.0', 25 | classifiers=[ 26 | 'Development Status :: 4 - Beta', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: Apache Software License', 29 | 'Programming Language :: Python :: 2', 30 | 'Programming Language :: Python :: 2.7', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.3', 33 | 'Programming Language :: Python :: 3.4', 34 | 'Programming Language :: Python :: 3.5', 35 | 'Programming Language :: Python :: 3.6', 36 | ], 37 | keywords='elastic app search api', 38 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 39 | install_requires=[ 40 | 'requests', 41 | 'PyJWT<=1.7.1' 42 | ], 43 | tests_require=[ 44 | 'requests_mock', 45 | 'future' 46 | ], 47 | test_suite='tests' 48 | ) 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | doc/_* 102 | .idea/ 103 | -------------------------------------------------------------------------------- /elastic_app_search/request_session.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import elastic_app_search 3 | from .exceptions import InvalidCredentials, NonExistentRecord, RecordAlreadyExists, BadRequest, Forbidden 4 | 5 | 6 | class RequestSession: 7 | 8 | def __init__(self, api_key, base_url): 9 | self.api_key = api_key 10 | self.base_url = base_url 11 | self.session = requests.Session() 12 | 13 | headers = { 14 | 'Authorization': "Bearer {}".format(api_key), 15 | 'X-Swiftype-Client': 'elastic-app-search-python', 16 | 'X-Swiftype-Client-Version': elastic_app_search.__version__, 17 | 'content-type': 'application/json; charset=utf8' 18 | } 19 | self.session.headers.update(headers) 20 | 21 | def raise_if_error(self, response): 22 | if response.status_code == requests.codes.unauthorized: 23 | raise InvalidCredentials(response.reason) 24 | elif response.status_code == requests.codes.bad: 25 | raise BadRequest(response.text) 26 | elif response.status_code == requests.codes.conflict: 27 | raise RecordAlreadyExists() 28 | elif response.status_code == requests.codes.not_found: 29 | raise NonExistentRecord() 30 | elif response.status_code == requests.codes.forbidden: 31 | raise Forbidden() 32 | 33 | response.raise_for_status() 34 | 35 | def request(self, http_method, endpoint, base_url=None, **kwargs): 36 | return self.request_ignore_response(http_method, endpoint, base_url, **kwargs).json() 37 | 38 | def request_ignore_response(self, http_method, endpoint, base_url=None, **kwargs): 39 | base_url = base_url or self.base_url 40 | url = "{}/{}".format(base_url, endpoint) 41 | response = self.session.request(http_method, url, **kwargs) 42 | self.raise_if_error(response) 43 | return response 44 | -------------------------------------------------------------------------------- /tests/test_request_session.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, skipIf 2 | from requests.status_codes import codes 3 | from future.utils import iteritems 4 | import requests_mock 5 | 6 | import elastic_app_search 7 | from elastic_app_search.request_session import RequestSession 8 | from elastic_app_search.exceptions import InvalidCredentials 9 | 10 | 11 | class TestRequestSession(TestCase): 12 | 13 | api_host_key = 'api_host_key' 14 | 15 | def setUp(self): 16 | self.session = RequestSession(self.api_host_key, 'http://www.base_url.com') 17 | 18 | def test_request_success(self): 19 | expected_return = {'foo': 'bar'} 20 | endpoint = 'some_endpoint' 21 | 22 | with requests_mock.Mocker() as m: 23 | m.register_uri('POST', "{}/{}".format(self.session.base_url, endpoint), json=expected_return, status_code=200) 24 | response = self.session.request('post', endpoint) 25 | self.assertEqual(response, expected_return) 26 | 27 | def test_headers_initialization(self): 28 | headers_to_check = { 29 | k: v 30 | for k, v in iteritems(self.session.session.headers) 31 | if k in ['Authorization', 'X-Swiftype-Client', 'X-Swiftype-Client-Version'] 32 | } 33 | version = elastic_app_search.__version__ 34 | self.assertEqual( 35 | headers_to_check, 36 | { 37 | 'Authorization': 'Bearer {}'.format(self.api_host_key), 38 | 'X-Swiftype-Client': 'elastic-app-search-python', 39 | 'X-Swiftype-Client-Version': version 40 | } 41 | ) 42 | 43 | def test_request_throw_error(self): 44 | endpoint = 'some_endpoint' 45 | 46 | with requests_mock.Mocker() as m: 47 | m.register_uri('POST', "{}/{}".format(self.session.base_url, endpoint), status_code=codes.unauthorized) 48 | 49 | with self.assertRaises(InvalidCredentials) as _context: 50 | self.session.request('post', endpoint) 51 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Elasticsearch BV 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /elastic_app_search/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import jwt 3 | from .request_session import RequestSession 4 | from .exceptions import InvalidDocument 5 | 6 | 7 | class Client: 8 | 9 | ELASTIC_APP_SEARCH_BASE_ENDPOINT = 'api.swiftype.com/api/as/v1' 10 | SIGNED_SEARCH_TOKEN_JWT_ALGORITHM = 'HS256' 11 | 12 | def __init__(self, host_identifier='', api_key='', 13 | base_endpoint=ELASTIC_APP_SEARCH_BASE_ENDPOINT, 14 | use_https=True, 15 | account_host_key='' # Deprecated - use host_identifier instead 16 | ): 17 | self.host_identifier = host_identifier or account_host_key 18 | self.account_host_key = self.host_identifier # Deprecated 19 | self.api_key = api_key 20 | 21 | uri_scheme = 'https' if use_https else 'http' 22 | host_prefix = host_identifier + '.' if host_identifier else '' 23 | base_url = "{}://{}{}".format(uri_scheme, host_prefix, base_endpoint) 24 | self.session = RequestSession(self.api_key, base_url) 25 | 26 | def get_documents(self, engine_name, document_ids): 27 | """ 28 | Retrieves documents by id from an engine. 29 | 30 | :param engine_name: Name of engine to get documents from. 31 | :param document_ids: Ids of documents to be returned. 32 | :return: Array of dictionaries representing documents. 33 | """ 34 | endpoint = "engines/{}/documents".format(engine_name) 35 | data = json.dumps(document_ids) 36 | return self.session.request('get', endpoint, data=data) 37 | 38 | def list_documents(self, engine_name, current=1, size=20): 39 | """ 40 | Lists all documents in engine. 41 | 42 | :param current: Page of documents 43 | :param size: Number of documents to return per page 44 | :return: List of documemts. 45 | """ 46 | data = { 'page': { 'current': current, 'size': size } } 47 | return self.session.request('get', "engines/{}/documents/list".format(engine_name), json=data) 48 | 49 | def index_document(self, engine_name, document): 50 | """ 51 | Create or update a document for an engine. Raises 52 | :class:`~elastic_app_search.exceptions.InvalidDocument` when the document 53 | has processing errors 54 | 55 | :param engine_name: Name of engine to index documents into. 56 | :param document: Hash representing a single document. 57 | :return: dict processed document status 58 | """ 59 | document_status = self.index_documents(engine_name, [document])[0] 60 | errors = document_status['errors'] 61 | if errors: 62 | raise InvalidDocument('; '.join(errors), document) 63 | 64 | return { 65 | key: document_status[key] 66 | for key in document_status 67 | if key != 'errors' 68 | } 69 | 70 | def index_documents(self, engine_name, documents): 71 | """ 72 | Create or update documents for an engine. 73 | 74 | :param engine_name: Name of engine to index documents into. 75 | :param documents: Hashes representing documents. 76 | :return: Array of document status dictionaries. Errors will be present 77 | in a document status with a key of `errors`. 78 | """ 79 | endpoint = "engines/{}/documents".format(engine_name) 80 | data = json.dumps(documents) 81 | 82 | return self.session.request('post', endpoint, data=data) 83 | 84 | def update_documents(self, engine_name, documents): 85 | """ 86 | Update a batch of documents for an engine. 87 | 88 | :param engine_name: Name of engine to index documents into. 89 | :param documents: Hashes representing documents. 90 | :return: Array of document status dictionaries. Errors will be present 91 | in a document status with a key of `errors`. 92 | """ 93 | endpoint = "engines/{}/documents".format(engine_name) 94 | data = json.dumps(documents) 95 | 96 | return self.session.request('patch', endpoint, data=data) 97 | 98 | def destroy_documents(self, engine_name, document_ids): 99 | """ 100 | Destroys documents by id for an engine. 101 | 102 | :param engine_name: Name of engine. 103 | :param document_ids: Array of document ids of documents to be destroyed. 104 | :return: 105 | """ 106 | endpoint = "engines/{}/documents".format(engine_name) 107 | data = json.dumps(document_ids) 108 | return self.session.request('delete', endpoint, data=data) 109 | 110 | def get_schema(self, engine_name): 111 | """ 112 | Get current schema for an engine. 113 | 114 | :param engine_name: Name of engine. 115 | :return: Schema. 116 | """ 117 | endpoint = "engines/{}/schema".format(engine_name) 118 | return self.session.request('get', endpoint) 119 | 120 | def update_schema(self, engine_name, schema): 121 | """ 122 | Create new schema fields or update the fields if they already exists. 123 | 124 | :param engine_name: Name of engine. 125 | :param schema: Schema to be updated as dict. 126 | :return: Updated schema. 127 | """ 128 | endpoint = "engines/{}/schema".format(engine_name) 129 | data = json.dumps(schema) 130 | return self.session.request('post', endpoint, data=data) 131 | 132 | def list_engines(self, current=1, size=20): 133 | """ 134 | Lists engines that the api key has access to. 135 | 136 | :param current: Page of engines 137 | :param size: Number of engines to return per page 138 | :return: List of dictionaries with key value pair corresponding to the 139 | name of the engine. 140 | """ 141 | data = { 'page': { 'current': current, 'size': size } } 142 | return self.session.request('get', 'engines', json=data) 143 | 144 | def get_engine(self, engine_name): 145 | """ 146 | Retrieves an engine by name. 147 | :param engine_name: Name of an existing engine. 148 | :return: A dictionary corresponding to the name of the engine. 149 | """ 150 | return self.session.request('get', "engines/{}".format(engine_name)) 151 | 152 | def create_engine(self, engine_name, language=None, options=None): 153 | """ 154 | Creates an engine with the specified name. 155 | :param engine_name: Name of the new engine. 156 | :param language: Language of the new engine. 157 | :param options: Engine configuration. 158 | :return: A dictionary corresponding to the new engine. 159 | """ 160 | data = { 'name': engine_name } 161 | if language is not None: 162 | data['language'] = language 163 | if options is not None: 164 | data.update(options) 165 | return self.session.request('post', 'engines', json=data) 166 | 167 | def destroy_engine(self, engine_name): 168 | """ 169 | Destroys an engine by name. 170 | :param engine_name: Name of an existing engine. 171 | :return: A dictionary with a single key of `deleted` and a value of 172 | True or False. 173 | """ 174 | return self.session.request('delete', "engines/{}".format(engine_name)) 175 | 176 | def list_synonym_sets(self, engine_name, current=1, size=20): 177 | """ 178 | Lists all synonym sets in engine. 179 | 180 | :param engine_name: Name of the engine. 181 | :param current: Page of synonym sets. 182 | :param size: Number of synonym sets to return per page. 183 | :return: List of synonym sets. 184 | """ 185 | data = { 'page': { 'current': current, 'size': size } } 186 | return self.session.request('get', "engines/{}/synonyms".format(engine_name), json=data) 187 | 188 | def get_synonym_set(self, engine_name, synonym_set_id): 189 | """ 190 | Get a single synonym set. 191 | 192 | :param engine_name: Name of the engine. 193 | :param synonym_set_id: ID of the synonym set. 194 | :return: A single synonym set. 195 | """ 196 | return self.session.request('get', "engines/{}/synonyms/{}".format(engine_name, synonym_set_id)) 197 | 198 | def create_synonym_set(self, engine_name, synonyms): 199 | """ 200 | Create a synonym set. 201 | 202 | :param engine_name: Name of the engine. 203 | :param synonyms: List of synonyms. 204 | :return: A list of synonyms that was created. 205 | """ 206 | data = { 'synonyms': synonyms } 207 | return self.session.request('post', "engines/{}/synonyms".format(engine_name), json=data) 208 | 209 | def update_synonym_set(self, engine_name, synonym_set_id, synonyms): 210 | """ 211 | Update an existing synonym set. 212 | 213 | :param engine_name: Name of the engine. 214 | :param synonym_set_id: ID of the synonym set to update. 215 | :param synonyms: The updated list of synonyms. 216 | :return: The synonym set that was updated. 217 | """ 218 | data = { 'synonyms': synonyms } 219 | return self.session.request('put', "engines/{}/synonyms/{}".format(engine_name, synonym_set_id), json=data) 220 | 221 | def destroy_synonym_set(self, engine_name, synonym_set_id): 222 | """ 223 | Destroy a synonym set. 224 | 225 | :param engine_name: Name of the engine. 226 | :param synonym_set_id: ID of the synonym set to be deleted. 227 | :return: Delete status. 228 | """ 229 | return self.session.request('delete', "engines/{}/synonyms/{}".format(engine_name, synonym_set_id)) 230 | 231 | def search(self, engine_name, query, options=None): 232 | """ 233 | Search an engine. See https://swiftype.com/documentation/app-search/ for more details 234 | on options and return values. 235 | 236 | :param engine_name: Name of engine to search over. 237 | :param query: Query string to search for. 238 | :param options: Dict of search options. 239 | """ 240 | endpoint = "engines/{}/search".format(engine_name) 241 | options = options or {} 242 | options['query'] = query 243 | return self.session.request('get', endpoint, json=options) 244 | 245 | def multi_search(self, engine_name, searches=None): 246 | """ 247 | Run multiple searches for documents on a single request. 248 | See https://swiftype.com/documentation/app-search/ for more details 249 | on options and return values. 250 | 251 | :param engine_name: Name of engine to search over. 252 | :param options: Array of search options. ex. {query: String, options: Dict} 253 | """ 254 | 255 | def build_options_from_search(search): 256 | if 'options' in search: 257 | options = search['options'] 258 | else: 259 | options = {} 260 | options['query'] = search['query'] 261 | return options 262 | 263 | endpoint = "engines/{}/multi_search".format(engine_name) 264 | options = { 265 | 'queries': list(map(build_options_from_search, searches)) 266 | } 267 | return self.session.request('get', endpoint, json=options) 268 | 269 | def query_suggestion(self, engine_name, query, options=None): 270 | """ 271 | Request Query Suggestions. See https://swiftype.com/documentation/app-search/ for more details 272 | on options and return values. 273 | 274 | :param engine_name: Name of engine to search over. 275 | :param query: Query string to search for. 276 | :param options: Dict of search options. 277 | """ 278 | endpoint = "engines/{}/query_suggestion".format(engine_name) 279 | options = options or {} 280 | options['query'] = query 281 | return self.session.request('get', endpoint, json=options) 282 | 283 | def click(self, engine_name, options): 284 | """ 285 | Sends a click event to the Elastic App Search Api, to track a click-through event. 286 | See https://swiftype.com/documentation/app-search/ for more details 287 | on options and return values. 288 | 289 | :param engine_name: Name of engine to search over. 290 | :param options: Dict of search options. 291 | """ 292 | endpoint = "engines/{}/click".format(engine_name) 293 | return self.session.request_ignore_response('post', endpoint, json=options) 294 | 295 | def create_meta_engine(self, engine_name, source_engines): 296 | data = { 297 | 'name': engine_name, 298 | 'source_engines': source_engines, 299 | 'type': 'meta' 300 | } 301 | return self.session.request('post', 'engines', json=data) 302 | 303 | def add_meta_engine_sources(self, engine_name, source_engines): 304 | endpoint = "engines/{}/source_engines".format(engine_name) 305 | return self.session.request('post', endpoint, json=source_engines) 306 | 307 | def delete_meta_engine_sources(self, engine_name, source_engines): 308 | endpoint = "engines/{}/source_engines".format(engine_name) 309 | return self.session.request('delete', endpoint, json=source_engines) 310 | 311 | def get_search_settings(self, engine_name): 312 | """ 313 | Get search settings for an engine. 314 | 315 | :param engine_name: Name of the engine. 316 | """ 317 | endpoint = "engines/{}/search_settings".format(engine_name) 318 | return self.session.request('get', endpoint) 319 | 320 | def update_search_settings(self, engine_name, search_settings): 321 | """ 322 | Update search settings for an engine. 323 | 324 | :param engine_name: Name of the engine. 325 | :param search_settings: New search settings JSON 326 | """ 327 | endpoint = "engines/{}/search_settings".format(engine_name) 328 | return self.session.request('put', endpoint, json=search_settings) 329 | 330 | def reset_search_settings(self, engine_name): 331 | """ 332 | Reset search settings to default for the given engine. 333 | 334 | :param engine_name: Name of the engine. 335 | """ 336 | endpoint = "engines/{}/search_settings/reset".format(engine_name) 337 | return self.session.request('post', endpoint) 338 | 339 | @staticmethod 340 | def create_signed_search_key(api_key, api_key_name, options): 341 | """ 342 | Creates a signed API key that will overwrite all search options (except 343 | filters) made with this key. 344 | 345 | :param api_key: An API key to use for this client. 346 | :param api_key_name: The unique name for the API Key 347 | :param options: Search options to override. 348 | :return: A JWT signed api token. 349 | """ 350 | options['api_key_name'] = api_key_name 351 | return jwt.encode(options, api_key, algorithm=Client.SIGNED_SEARCH_TOKEN_JWT_ALGORITHM) 352 | 353 | def get_api_logs(self, engine_name, options=None): 354 | """ 355 | Searches the API logs. 356 | 357 | :param engine_name: Name of engine. 358 | :param options: Dict of search options. 359 | """ 360 | endpoint = "engines/{}/logs/api".format(engine_name) 361 | options = options or {} 362 | return self.session.request('get', endpoint, json=options) 363 | 364 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **⚠️ This client is deprecated ⚠️** 2 | > 3 | > As of Enterprise Search version 7.10.0, we are directing users to the new [Enterprise Search Python Client](https://github.com/elastic/enterprise-search-python) and 4 | > deprecating this client. 5 | > 6 | > This client will be compatible with all Enterprise Search 7.x releases, but will not be compatible with 8.x releases. Our development effort on this project will 7 | > be limited to bug fixes. All future enhancements will be focused on the Enterprise Search Python Client. 8 | > 9 | > Thank you! - Elastic 10 | 11 | 12 |

Elastic App Search Logo

13 | 14 |

CircleCI build 15 | 16 | > A first-party Python client for building excellent, relevant search experiences with [Elastic App Search](https://www.elastic.co/products/app-search). 17 | 18 | ## Contents 19 | 20 | - [Getting started](#getting-started-) 21 | - [Dependencies](#dependencies) 22 | - [Versioning](#versioning) 23 | - [Usage](#usage) 24 | - [Running tests](#running-tests) 25 | - [FAQ](#faq-) 26 | - [Contribute](#contribute-) 27 | - [License](#license-) 28 | 29 | --- 30 | 31 | ## Getting started 🐣 32 | 33 | To install the client, use pip: 34 | 35 | ```python 36 | python -m pip install elastic-app-search 37 | ``` 38 | 39 | You can also download the project source and run:: 40 | 41 | ```python 42 | python setup.py install 43 | ``` 44 | 45 | ## Dependencies 46 | 47 | - Python 2.7 / Python 3.3 48 | - [Requests](https://github.com/requests/requests) 49 | - [PyJWT](https://github.com/jpadilla/pyjwt) 50 | 51 | ## Versioning 52 | 53 | This client is versioned and released alongside App Search. 54 | 55 | To guarantee compatibility, use the most recent version of this library within the major version of the corresponding App Search implementation. 56 | 57 | For example, for App Search `7.3`, use `7.3` of this library or above, but not `8.0`. 58 | 59 | If you are using the [SaaS version available on swiftype.com](https://app.swiftype.com/as) of App Search, you should use the version 7.5.x of the client. 60 | 61 | ## Usage 62 | 63 | ### Instantiating a client 64 | 65 | Using this client assumes that you have already an instance of [Elastic App Search](https://www.elastic.co/products/app-search) up and running. 66 | 67 | The client can be instantiated using the `base_endpoint`, `api_key` and `use_https` parameters: 68 | 69 | ```python 70 | >>> from elastic_app_search import Client 71 | >>> client = Client( 72 | base_endpoint='localhost:3002/api/as/v1', 73 | api_key='private-mu75psc5egt9ppzuycnc2mc3', 74 | use_https=False 75 | ) 76 | ``` 77 | 78 | Notes: 79 | 80 | The `[api_key]` authenticates requests to the API. 81 | You can use any key type with the client, however each has a different scope. 82 | For more information on keys, check out the [documentation](https://swiftype.com/documentation/app-search/api/credentials). 83 | 84 | The `base_endpoint` must exclude the protocol and include the `api/as/v1` prefix. This can typically be found in the Credentials tab within the App Search Dashboard. 85 | 86 | Set `use_https` to `True` or `False` depending how your server is configured. Often times it will be `False` when running in development on `localhost` and `True` for production environments. 87 | 88 | The following is example of a configuration for Elastic Cloud: 89 | 90 | ```python 91 | >>> from elastic_app_search import Client 92 | >>> client = Client( 93 | base_endpoint='77bf13bc2e9948729af339a446b06ddcc.app-search.us-east-1.aws.found.io/api/as/v1', 94 | api_key='private-mu75psc5egt9ppzuycnc2mc3', 95 | use_https=True 96 | ) 97 | ``` 98 | 99 | #### Swiftype.com App Search users: 100 | 101 | When using the [SaaS version available on swiftype.com](https://app.swiftype.com/as) of App Search, you can configure the client using your `host_identifier` instead of the `base_endpoint` parameter. 102 | The `host_identifier` can be found within the [Credentials](https://app.swiftype.com/as#/credentials) menu. 103 | 104 | ```python 105 | >>> from elastic_app_search import Client 106 | >>> host_identifier = 'host-c5s2mj' 107 | >>> api_key = 'private-mu75psc5egt9ppzuycnc2mc3' 108 | >>> client = Client(host_identifier, api_key) 109 | ``` 110 | 111 | 112 | ### Indexing: Creating or Updating a Single Document 113 | 114 | ```python 115 | >>> engine_name = 'favorite-videos' 116 | >>> document = { 117 | 'id': 'INscMGmhmX4', 118 | 'url': 'https://www.youtube.com/watch?v=INscMGmhmX4', 119 | 'title': 'The Original Grumpy Cat', 120 | 'body': 'A wonderful video of a magnificent cat.' 121 | } 122 | >>> client.index_document(engine_name, document) 123 | {'id': 'INscMGmhmX4'} 124 | ``` 125 | 126 | ### Indexing: Creating or Updating Multiple Documents 127 | 128 | ```python 129 | >>> engine_name = 'favorite-videos' 130 | >>> documents = [ 131 | { 132 | 'id': 'INscMGmhmX4', 133 | 'url': 'https://www.youtube.com/watch?v=INscMGmhmX4', 134 | 'title': 'The Original Grumpy Cat', 135 | 'body': 'A wonderful video of a magnificent cat.' 136 | }, 137 | { 138 | 'id': 'JNDFojsd02', 139 | 'url': 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 140 | 'title': 'Another Grumpy Cat', 141 | 'body': 'A great video of another cool cat.' 142 | } 143 | ] 144 | 145 | >>> client.index_documents(engine_name, documents) 146 | [{'id': 'INscMGmhmX4', 'errors': []}, {'id': 'JNDFojsd02', 'errors': []}] 147 | ``` 148 | 149 | ### Indexing: Updating documents (Partial Updates) 150 | 151 | ```python 152 | >>> engine_name = 'favorite-videos' 153 | >>> documents = [ 154 | { 155 | 'id': 'INscMGmhmX4', 156 | 'title': 'Updated title' 157 | } 158 | ] 159 | 160 | >>> client.update_documents(engine_name, documents) 161 | ``` 162 | 163 | ### Get Documents 164 | 165 | ```python 166 | >>> engine_name = 'favorite-videos' 167 | >>> client.get_documents(engine_name, ['INscMGmhmX4']) 168 | [{'id': 'INscMGmhmX4','url': 'https://www.youtube.com/watch?v=INscMGmhmX4','title': 'The Original Grumpy Cat','body': 'A wonderful video of a magnificent cat.'}] 169 | ``` 170 | 171 | ### List Documents 172 | 173 | ```python 174 | >>> engine_name = 'favorite-videos' 175 | >>> client.list_documents(engine_name, current=1, size=20) 176 | { 177 | 'meta': { 178 | 'page': { 179 | 'current': 1, 180 | 'total_pages': 1, 181 | 'total_results': 2, 182 | 'size': 20 183 | } 184 | }, 185 | 'results': [{'id': 'INscMGmhmX4','url': 'https://www.youtube.com/watch?v=INscMGmhmX4','title': 'The Original Grumpy Cat','body': 'A wonderful video of a magnificent cat.'}] 186 | } 187 | ``` 188 | 189 | ### Destroy Documents 190 | 191 | ```python 192 | >>> engine_name = 'favorite-videos' 193 | >>> client.destroy_documents(engine_name, ['INscMGmhmX4']) 194 | [{'id': 'INscMGmhmX4','result': True}] 195 | ``` 196 | 197 | ### Get Schema 198 | 199 | ```python 200 | >>> engine_name = 'favorite-videos' 201 | >>> client.get_schema(engine_name) 202 | {'name':'text', 'square_km': 'number', 'square_mi': 'text'} 203 | ``` 204 | 205 | ### Create/Update Schema 206 | 207 | ```python 208 | >>> engine_name = 'favorite-videos' 209 | >>> client.update_schema(engine_name, {'square_km': 'text'}) 210 | {'square_km': 'text'} 211 | >>> client.update_schema(engine_name, {'square_mi': 'text'}) 212 | {'square_km': 'text', 'square_mi': 'text'} 213 | >>> client.update_schema(engine_name, {'square_km': 'number'}) 214 | {'square_km': 'number', 'square_mi': 'text'} 215 | ``` 216 | 217 | ### List Engines 218 | 219 | ```python 220 | >>> client.list_engines(current=1, size=20) 221 | { 222 | 'meta': { 223 | 'page': { 224 | 'current': 1, 225 | 'total_pages': 1, 226 | 'total_results': 2, 227 | 'size': 20 228 | } 229 | }, 230 | 'results': [{'name': 'favorite-videos'}, {'name': 'another-engine'}] 231 | } 232 | ``` 233 | 234 | ### Get an Engine 235 | 236 | ```python 237 | >>> client.get_engine('favorite-videos') 238 | {'name': 'favorite-videos'} 239 | ``` 240 | 241 | ### Create an Engine 242 | 243 | ```python 244 | >>> client.create_engine('favorite-videos', 'en') 245 | {'name': 'favorite-videos', 'type': 'default', 'language': 'en'} 246 | ``` 247 | 248 | ### Destroy an Engine 249 | 250 | ```python 251 | >>> client.destroy_engine('favorite-videos') 252 | {'deleted': True} 253 | ``` 254 | 255 | ### List all synonym sets in an engine 256 | 257 | #### With default pagination (a page size of 20) 258 | 259 | ```python 260 | >>> client.list_synonym_sets('us-national-parks') 261 | { 262 | 'meta': { 263 | 'page': { 264 | 'current': 1, 265 | 'total_pages': 1, 266 | 'total_results': 3, 267 | 'size': 20 268 | } 269 | }, 270 | 'results': [ 271 | { 272 | 'id': 'syn-5b11ac66c9f9292013220ad3', 273 | 'synonyms': [ 274 | 'park', 275 | 'trail' 276 | ] 277 | }, 278 | { 279 | 'id': 'syn-5b11ac72c9f9296b35220ac9', 280 | 'synonyms': [ 281 | 'protected', 282 | 'heritage' 283 | ] 284 | }, 285 | { 286 | 'id': 'syn-5b11ac66c9f9292013220ad3', 287 | 'synonyms': [ 288 | 'hectares', 289 | 'acres' 290 | ] 291 | } 292 | ] 293 | } 294 | ``` 295 | 296 | #### With custom pagination 297 | 298 | ```python 299 | >>> client.list_synonym_sets('us-national-parks', size=1, current=1) 300 | { 301 | 'meta': { 302 | 'page': { 303 | 'current': 1, 304 | 'total_pages': 3, 305 | 'total_results': 3, 306 | 'size': 1 307 | } 308 | }, 309 | 'results': [ 310 | { 311 | 'id': 'syn-5b11ac66c9f9292013220ad3', 312 | 'synonyms': [ 313 | 'park', 314 | 'trail' 315 | ] 316 | } 317 | ] 318 | } 319 | ``` 320 | 321 | ### Get a single synonym set 322 | 323 | ```python 324 | >>> client.get_synonym_set('us-national-parks', 'syn-5b11ac66c9f9292013220ad3') 325 | { 326 | 'id': 'syn-5b11ac66c9f9292013220ad3', 327 | 'synonyms': [ 328 | 'park', 329 | 'trail' 330 | ] 331 | } 332 | ``` 333 | 334 | ### Create a synonym set 335 | 336 | ```python 337 | >>> client.create_synonym_set('us-national-parks', ['park', 'trail']) 338 | { 339 | 'id': 'syn-5b11ac72c9f9296b35220ac9', 340 | 'synonyms': [ 341 | 'park', 342 | 'trail' 343 | ] 344 | } 345 | ``` 346 | 347 | ### Update a synonym set 348 | 349 | ```python 350 | >>> client.update_synonym_set('us-national-parks', 'syn-5b11ac72c9f9296b35220ac9', ['park', 'trail', 'ground']) 351 | { 352 | 'id': 'syn-5b11ac72c9f9296b35220ac9', 353 | 'synonyms': [ 354 | 'park', 355 | 'trail', 356 | 'ground' 357 | ] 358 | } 359 | ``` 360 | 361 | ### Destroy a synonym set 362 | 363 | ```python 364 | >>> client.destroy_synonym_set('us-national-parks', 'syn-5b11ac66c9f9292013220ad3') 365 | { 366 | 'deleted': True 367 | } 368 | ``` 369 | 370 | ### Searching 371 | 372 | ```python 373 | >>> client.search('favorite-videos', 'grumpy cat', {}) 374 | {'meta': {'page': {'current': 1, 'total_pages': 1, 'total_results': 2, 'size': 10}, ...}, 'results': [...]} 375 | ``` 376 | 377 | ### Multi-Search 378 | 379 | ```python 380 | >>> client.multi_search('favorite-videos', [{ 381 | 'query': 'cat', 382 | 'options': { 'search_fields': { 'title': {} }} 383 | },{ 384 | 'query': 'dog', 385 | 'options': { 'search_fields': { 'body': {} }} 386 | }]) 387 | [{'meta': {...}, 'results': [...]}, {'meta': {...}, 'results': [...]}] 388 | ``` 389 | 390 | ### Query Suggestion 391 | 392 | ```python 393 | >>> client.query_suggestion('favorite-videos', 'cat', { 394 | 'size': 10, 395 | 'types': { 396 | 'documents': { 397 | 'fields': ['title'] 398 | } 399 | } 400 | }) 401 | {'results': {'documents': [{'suggestion': 'cat'}]}, 'meta': {'request_id': '390be384ad5888353e1b32adcfaaf1c9'}} 402 | ``` 403 | 404 | ### Clickthrough Tracking 405 | 406 | ```python 407 | >>> client.click(engine_name, {'query': 'cat', 'document_id': 'INscMGmhmX4'}) 408 | ``` 409 | 410 | ### Create a Signed Search Key 411 | 412 | Creating a search key that will only search over the body field. 413 | 414 | ```python 415 | >>> api_key = 'search-xxxxxxxxxxxxxxxxxxxxxxxx' 416 | >>> api_key_name = 'search-key' # This name must match the name of the key above from your App Search dashboard 417 | >>> signed_search_key = Client.create_signed_search_key(api_key, api_key_name, {'search_fields': { 'body': {}}}) 418 | >>> client = Client( 419 | base_endpoint='localhost:3002/api/as/v1', 420 | api_key=signed_search_key, 421 | use_https=False 422 | ) 423 | ``` 424 | 425 | ### Create a Meta Engine 426 | 427 | ```python 428 | >>> client.create_meta_engine( 429 | engine_name=engine_name, 430 | source_engines=[ 431 | 'source-engine-1', 432 | 'source-engine-2' 433 | ] 434 | ) 435 | {'source_engines': ['source-engine-1', 'source-engine-2'], 'type': 'meta', 'name': 'my-meta-engine'} 436 | ``` 437 | 438 | ### Add a Source Engine to a Meta Engine 439 | 440 | ```python 441 | >>> client.add_meta_engine_sources('my-meta-engine', ['source-engine-3']) 442 | {'source_engines': ['source-engine-1', 'source-engine-2', 'source-engine-3'], 'type': 'meta', 'name': 'my-meta-engine'} 443 | ``` 444 | 445 | ### Remove a Source Engine from a Meta Engine 446 | 447 | ```python 448 | >>> client.delete_meta_engine_sources('my-meta-engine', ['source-engine-3']) 449 | {'source_engines': ['source-engine-1', 'source-engine-2'], 'type': 'meta', 'name': 'my-meta-engine'} 450 | ``` 451 | 452 | ### Search the API logs 453 | 454 | ```python 455 | >>> client.get_api_logs('my-meta-engine', { 456 | "filters": { 457 | "date": { 458 | "from": "2020-03-30T00:00:00+00:00", 459 | "to": "2020-03-31T00:00:00+00:00" 460 | }, 461 | "status": "429" 462 | } 463 | }) 464 | { 465 | 'results': [], 466 | 'meta': { 467 | 'query': '', 468 | 'filters': { 469 | 'date': { 470 | 'from': '2020-03-27T00:00:00+00:00', 471 | 'to': '2020-03-31T00:00:00+00:00' 472 | }, 473 | 'status': '429' 474 | }, 475 | 'sort_direction': 'asc', 476 | 'page': { 477 | 'current': 1, 478 | 'total_pages': 0, 479 | 'total_results': 0, 480 | 'size': 10 481 | } 482 | } 483 | } 484 | ``` 485 | 486 | ### Get search settings 487 | 488 | ```python 489 | >>> client.get_search_settings(engine_name='us-national-parks') 490 | { 491 | "search_fields": { 492 | "name": { 493 | "weight": 1 494 | }, 495 | "description": { 496 | "weight": 1 497 | } 498 | }, 499 | "result_fields": { 500 | "name": { 501 | "raw": {} 502 | }, 503 | "description": { 504 | "raw": {} 505 | } 506 | }, 507 | "boosts": {} 508 | } 509 | ``` 510 | 511 | ### Update search settings 512 | 513 | ```python 514 | >>> client.update_search_settings( 515 | engine_name='us-national-parks', 516 | search_settings={ 517 | "search_fields": { 518 | "name": { 519 | "weight": 2 520 | }, 521 | "description": { 522 | "weight": 1 523 | } 524 | }, 525 | "result_fields": { 526 | "name": { 527 | "raw": {} 528 | }, 529 | "description": { 530 | "raw": {} 531 | } 532 | }, 533 | "boosts": {} 534 | } 535 | ) 536 | { 537 | "search_fields": { 538 | "name": { 539 | "weight": 2 540 | }, 541 | "description": { 542 | "weight": 1 543 | } 544 | }, 545 | "result_fields": { 546 | "name": { 547 | "raw": {} 548 | }, 549 | "description": { 550 | "raw": {} 551 | } 552 | }, 553 | "boosts": {} 554 | } 555 | ``` 556 | 557 | ### Reset search settings 558 | 559 | ```python 560 | >>> client.reset_search_settings(engine_name='us-national-parks') 561 | { 562 | "search_fields": { 563 | "name": { 564 | "weight": 1 565 | }, 566 | "description": { 567 | "weight": 1 568 | } 569 | }, 570 | "boosts": {} 571 | } 572 | ``` 573 | 574 | ## Running tests 575 | 576 | ```python 577 | python setup.py test 578 | ``` 579 | 580 | ## FAQ 🔮 581 | 582 | ### Where do I report issues with the client? 583 | 584 | If something is not working as expected, please open an [issue](https://github.com/elastic/app-search-python/issues/new). 585 | 586 | ### Where can I learn more about App Search? 587 | 588 | Your best bet is to read the [documentation](https://swiftype.com/documentation/app-search). 589 | 590 | ### Where else can I go to get help? 591 | 592 | You can checkout the [Elastic App Search community discuss forums](https://discuss.elastic.co/c/app-search). 593 | 594 | ## Contribute 🚀 595 | 596 | We welcome contributors to the project. Before you begin, a couple notes: 597 | 598 | - Prior to opening a pull request, please create an issue to [discuss the scope of your proposal](https://github.com/elastic/app-search-python/issues). 599 | - Please write simple code and concise documentation, when appropriate. 600 | 601 | ## License 📗 602 | 603 | [Apache 2.0](https://github.com/elastic/app-search-python/blob/master/LICENSE.txt) © [Elastic](https://github.com/elastic) 604 | 605 | Thank you to all the [contributors](https://github.com/elastic/app-search-python/graphs/contributors)! 606 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | import requests_mock 3 | import json 4 | 5 | from elastic_app_search import Client 6 | from elastic_app_search.exceptions import InvalidDocument 7 | 8 | 9 | class TestClient(TestCase): 10 | 11 | def setUp(self): 12 | self.engine_name = 'some-engine-name' 13 | self.client = Client('host_identifier', 'api_key') 14 | 15 | self.document_index_url = "{}/{}".format( 16 | self.client.session.base_url, 17 | "engines/{}/documents".format(self.engine_name) 18 | ) 19 | 20 | def test_deprecated_init_support_with_old_names(self): 21 | self.client = Client( 22 | account_host_key='host_identifier', api_key='api_key') 23 | self.assertEqual(self.client.account_host_key, 'host_identifier') 24 | 25 | def test_deprecated_init_support_with_new_names(self): 26 | self.client = Client( 27 | host_identifier='host_identifier', api_key='api_key') 28 | self.assertEqual(self.client.account_host_key, 'host_identifier') 29 | 30 | def test_deprecated_init_support_with_positional(self): 31 | self.client = Client('host_identifier', 'api_key', 32 | 'example.com', False) 33 | self.assertEqual(self.client.account_host_key, 'host_identifier') 34 | 35 | def test_host_identifier_is_optional(self): 36 | client = Client('', 'api_key', 'localhost:3002/api/as/v1', False) 37 | query = 'query' 38 | 39 | with requests_mock.Mocker() as m: 40 | url = "http://localhost:3002/api/as/v1/engines/some-engine-name/search" 41 | m.register_uri('GET', url, json={}, status_code=200) 42 | client.search(self.engine_name, query, {}) 43 | 44 | def test_index_document_processing_error(self): 45 | invalid_document = {'id': 'something', 'bad': {'no': 'nested'}} 46 | error = 'some processing error' 47 | stubbed_return = [{'id': 'something', 'errors': [error]}] 48 | with requests_mock.Mocker() as m: 49 | m.register_uri('POST', self.document_index_url, 50 | json=stubbed_return, status_code=200) 51 | 52 | with self.assertRaises(InvalidDocument) as context: 53 | self.client.index_document(self.engine_name, invalid_document) 54 | self.assertEqual(str(context.exception), error) 55 | 56 | def test_index_document_no_error_key_in_response(self): 57 | document_without_id = {'body': 'some value'} 58 | stubbed_return = [{'id': 'auto generated', 'errors': []}] 59 | 60 | with requests_mock.Mocker() as m: 61 | m.register_uri('POST', self.document_index_url, 62 | json=stubbed_return, status_code=200) 63 | response = self.client.index_document( 64 | self.engine_name, document_without_id) 65 | self.assertEqual(response, {'id': 'auto generated'}) 66 | 67 | def test_index_documents(self): 68 | id = 'INscMGmhmX4' 69 | valid_document = {'id': id} 70 | other_document = {'body': 'some value'} 71 | 72 | expected_return = [ 73 | {'id': id, 'errors': []}, 74 | {'id': 'some autogenerated id', 'errors': []} 75 | ] 76 | 77 | with requests_mock.Mocker() as m: 78 | m.register_uri('POST', self.document_index_url, 79 | json=expected_return, status_code=200) 80 | response = self.client.index_documents( 81 | self.engine_name, [valid_document, other_document]) 82 | self.assertEqual(response, expected_return) 83 | 84 | def test_update_documents(self): 85 | id = 'INscMGmhmX4' 86 | valid_document = {'id': id} 87 | other_document = {'body': 'some value'} 88 | 89 | expected_return = [ 90 | {'id': id, 'errors': []}, 91 | {'id': 'some autogenerated id', 'errors': []} 92 | ] 93 | 94 | with requests_mock.Mocker() as m: 95 | m.register_uri('PATCH', self.document_index_url, 96 | json=expected_return, status_code=200) 97 | response = self.client.update_documents( 98 | self.engine_name, [valid_document, other_document]) 99 | self.assertEqual(response, expected_return) 100 | 101 | def test_get_documents(self): 102 | id = 'INscMGmhmX4' 103 | expected_return = [ 104 | { 105 | 'id': id, 106 | 'url': 'http://www.youtube.com/watch?v=v1uyQZNg2vE', 107 | 'title': 'The Original Grumpy Cat', 108 | 'body': 'this is a test' 109 | } 110 | ] 111 | 112 | with requests_mock.Mocker() as m: 113 | m.register_uri('GET', self.document_index_url, 114 | json=expected_return, status_code=200) 115 | response = self.client.get_documents(self.engine_name, [id]) 116 | self.assertEqual(response, expected_return) 117 | 118 | def test_list_documents(self): 119 | expected_return = { 120 | 'meta': { 121 | 'page': {'current': 1, 'total_results': 1, 'total_pages': 1, 'size': 20}, 122 | 'results': [ 123 | {'body': 'this is a test', 'id': '1'}, 124 | {'body': 'this is also a test', 'id': '2'} 125 | ] 126 | } 127 | } 128 | 129 | def match_request_text(request): 130 | data = json.loads(request.text) 131 | return data["page"]["current"] == 1 and data["page"]["size"] == 20 132 | 133 | with requests_mock.Mocker() as m: 134 | url = "{}/engines/{}/documents/list".format( 135 | self.client.session.base_url, self.engine_name) 136 | m.register_uri('GET', 137 | url, 138 | additional_matcher=match_request_text, 139 | json=expected_return, 140 | status_code=200 141 | ) 142 | 143 | response = self.client.list_documents(self.engine_name) 144 | self.assertEqual(response, expected_return) 145 | 146 | def test_destroy_documents(self): 147 | id = 'INscMGmhmX4' 148 | expected_return = [ 149 | {'id': id, 'result': True} 150 | ] 151 | 152 | with requests_mock.Mocker() as m: 153 | m.register_uri('DELETE', self.document_index_url, 154 | json=expected_return, status_code=200) 155 | response = self.client.destroy_documents(self.engine_name, [id]) 156 | self.assertEqual(response, expected_return) 157 | 158 | def test_get_schema(self): 159 | expected_return = { 160 | 'square_km': 'text' 161 | } 162 | 163 | with requests_mock.Mocker() as m: 164 | url = "{}/engines/{}/schema".format( 165 | self.client.session.base_url, self.engine_name) 166 | m.register_uri('GET', 167 | url, 168 | json=expected_return, 169 | status_code=200 170 | ) 171 | 172 | response = self.client.get_schema(self.engine_name) 173 | self.assertEqual(response, expected_return) 174 | 175 | def test_update_schema(self): 176 | expected_return = { 177 | 'square_mi': 'number', 178 | 'square_km': 'number' 179 | } 180 | 181 | with requests_mock.Mocker() as m: 182 | url = "{}/engines/{}/schema".format( 183 | self.client.session.base_url, self.engine_name) 184 | m.register_uri('POST', 185 | url, 186 | json=expected_return, 187 | status_code=200 188 | ) 189 | 190 | response = self.client.update_schema( 191 | self.engine_name, expected_return) 192 | self.assertEqual(response, expected_return) 193 | 194 | def test_list_engines(self): 195 | expected_return = [ 196 | {'name': 'myawesomeengine'} 197 | ] 198 | 199 | def match_request_text(request): 200 | data = json.loads(request.text) 201 | return data["page"]["current"] == 1 and data["page"]["size"] == 20 202 | 203 | with requests_mock.Mocker() as m: 204 | url = "{}/{}".format(self.client.session.base_url, 'engines') 205 | m.register_uri('GET', 206 | url, 207 | additional_matcher=match_request_text, 208 | json=expected_return, 209 | status_code=200 210 | ) 211 | response = self.client.list_engines() 212 | self.assertEqual(response, expected_return) 213 | 214 | def test_list_engines_with_paging(self): 215 | expected_return = [ 216 | {'name': 'myawesomeengine'} 217 | ] 218 | 219 | def match_request_text(request): 220 | data = json.loads(request.text) 221 | return data["page"]["current"] == 10 and data["page"]["size"] == 2 222 | 223 | with requests_mock.Mocker() as m: 224 | url = "{}/{}".format(self.client.session.base_url, 'engines') 225 | m.register_uri( 226 | 'GET', 227 | url, 228 | additional_matcher=match_request_text, 229 | json=expected_return, 230 | status_code=200 231 | ) 232 | response = self.client.list_engines(current=10, size=2) 233 | self.assertEqual(response, expected_return) 234 | 235 | def test_get_engine(self): 236 | engine_name = 'myawesomeengine' 237 | expected_return = [ 238 | {'name': engine_name} 239 | ] 240 | 241 | with requests_mock.Mocker() as m: 242 | url = "{}/{}/{}".format(self.client.session.base_url, 243 | 'engines', 244 | engine_name) 245 | m.register_uri('GET', url, json=expected_return, status_code=200) 246 | response = self.client.get_engine(engine_name) 247 | self.assertEqual(response, expected_return) 248 | 249 | def test_create_engine(self): 250 | engine_name = 'myawesomeengine' 251 | expected_return = {'name': engine_name, 'language': 'en'} 252 | 253 | with requests_mock.Mocker() as m: 254 | url = "{}/{}".format(self.client.session.base_url, 'engines') 255 | m.register_uri('POST', url, json=expected_return, status_code=200) 256 | response = self.client.create_engine( 257 | engine_name=engine_name, language='en') 258 | self.assertEqual(response, expected_return) 259 | 260 | def test_create_engine_with_options(self): 261 | engine_name = 'myawesomeengine' 262 | expected_return = {'name': engine_name, 'type': 'meta', 263 | 'source_engines': [ 264 | 'source-engine-1', 265 | 'source-engine-2' 266 | ]} 267 | 268 | with requests_mock.Mocker() as m: 269 | url = "{}/{}".format(self.client.session.base_url, 'engines') 270 | m.register_uri('POST', url, json=expected_return, status_code=200) 271 | response = self.client.create_engine( 272 | engine_name=engine_name, options={ 273 | 'type': 'meta', 274 | 'source_engines': [ 275 | 'source-engine-1', 276 | 'source-engine-2' 277 | ] 278 | }) 279 | self.assertEqual(response, expected_return) 280 | 281 | def test_destroy_engine(self): 282 | engine_name = 'myawesomeengine' 283 | expected_return = {'deleted': True} 284 | 285 | with requests_mock.Mocker() as m: 286 | url = "{}/{}/{}".format(self.client.session.base_url, 287 | 'engines', 288 | engine_name) 289 | m.register_uri('DELETE', url, json=expected_return, 290 | status_code=200) 291 | response = self.client.destroy_engine(engine_name) 292 | self.assertEqual(response, expected_return) 293 | 294 | def test_list_synonym_sets(self): 295 | expected_return = { 296 | 'meta': { 297 | 'page': { 298 | 'current': 1, 299 | 'total_pages': 1, 300 | 'total_results': 3, 301 | 'size': 20 302 | } 303 | }, 304 | 'results': [ 305 | { 306 | 'id': 'syn-5b11ac66c9f9292013220ad3', 307 | 'synonyms': [ 308 | 'park', 309 | 'trail' 310 | ] 311 | }, 312 | { 313 | 'id': 'syn-5b11ac72c9f9296b35220ac9', 314 | 'synonyms': [ 315 | 'protected', 316 | 'heritage' 317 | ] 318 | }, 319 | { 320 | 'id': 'syn-5b11ac66c9f9292013220ad3', 321 | 'synonyms': [ 322 | 'hectares', 323 | 'acres' 324 | ] 325 | } 326 | ] 327 | } 328 | 329 | with requests_mock.Mocker() as m: 330 | url = "{}/engines/{}/synonyms".format( 331 | self.client.session.base_url, 332 | self.engine_name 333 | ) 334 | 335 | def match_request_text(request): 336 | data = json.loads(request.text) 337 | return data["page"]["current"] == 1 and data["page"]["size"] == 20 338 | 339 | m.register_uri( 340 | 'GET', 341 | url, 342 | additional_matcher=match_request_text, 343 | json=expected_return, 344 | status_code=200 345 | ) 346 | 347 | response = self.client.list_synonym_sets(self.engine_name) 348 | 349 | def test_get_synonym_set(self): 350 | synonym_id = 'syn-5b11ac66c9f9292013220ad3' 351 | expected_return = { 352 | 'id': synonym_id, 353 | 'synonyms': [ 354 | 'park', 355 | 'trail' 356 | ] 357 | } 358 | 359 | with requests_mock.Mocker() as m: 360 | url = "{}/engines/{}/synonyms/{}".format( 361 | self.client.session.base_url, 362 | self.engine_name, 363 | synonym_id 364 | ) 365 | m.register_uri( 366 | 'GET', 367 | url, 368 | json=expected_return, 369 | status_code=200 370 | ) 371 | 372 | response = self.client.get_synonym_set( 373 | self.engine_name, 374 | synonym_id 375 | ) 376 | self.assertEqual(response, expected_return) 377 | 378 | def test_create_synonym_set(self): 379 | synonym_set = ['park', 'trail'] 380 | expected_return = { 381 | 'id': 'syn-5b11ac72c9f9296b35220ac9', 382 | 'synonyms': [ 383 | 'park', 384 | 'trail' 385 | ] 386 | } 387 | 388 | with requests_mock.Mocker() as m: 389 | url = "{}/engines/{}/synonyms".format( 390 | self.client.session.base_url, 391 | self.engine_name 392 | ) 393 | m.register_uri( 394 | 'POST', 395 | url, 396 | json=expected_return, 397 | status_code=200 398 | ) 399 | 400 | response = self.client.create_synonym_set( 401 | self.engine_name, 402 | synonym_set 403 | ) 404 | self.assertEqual(response, expected_return) 405 | 406 | def test_update_synonym_set(self): 407 | synonym_id = 'syn-5b11ac72c9f9296b35220ac9' 408 | synonym_set = ['park', 'trail', 'ground'] 409 | expected_return = { 410 | 'id': synonym_id, 411 | 'synonyms': [ 412 | 'park', 413 | 'trail', 414 | 'ground' 415 | ] 416 | } 417 | 418 | with requests_mock.Mocker() as m: 419 | url = "{}/engines/{}/synonyms/{}".format( 420 | self.client.session.base_url, 421 | self.engine_name, 422 | synonym_id 423 | ) 424 | m.register_uri( 425 | 'PUT', 426 | url, 427 | json=expected_return, 428 | status_code=200 429 | ) 430 | 431 | response = self.client.update_synonym_set( 432 | self.engine_name, 433 | synonym_id, 434 | synonym_set 435 | ) 436 | self.assertEqual(response, expected_return) 437 | 438 | def test_destroy_synonym_set(self): 439 | synonym_id = 'syn-5b11ac66c9f9292013220ad3' 440 | expected_return = { 441 | 'deleted': True 442 | } 443 | 444 | with requests_mock.Mocker() as m: 445 | url = "{}/engines/{}/synonyms/{}".format( 446 | self.client.session.base_url, 447 | self.engine_name, 448 | synonym_id 449 | ) 450 | m.register_uri( 451 | 'DELETE', 452 | url, 453 | json=expected_return, 454 | status_code=200 455 | ) 456 | 457 | response = self.client.destroy_synonym_set( 458 | self.engine_name, 459 | synonym_id 460 | ) 461 | self.assertEqual(response, expected_return) 462 | 463 | def test_search(self): 464 | query = 'query' 465 | expected_return = {'meta': {}, 'results': []} 466 | 467 | with requests_mock.Mocker() as m: 468 | url = "{}/{}".format( 469 | self.client.session.base_url, 470 | "engines/{}/search".format(self.engine_name) 471 | ) 472 | m.register_uri('GET', url, json=expected_return, status_code=200) 473 | response = self.client.search(self.engine_name, query, {}) 474 | self.assertEqual(response, expected_return) 475 | 476 | def test_multi_search(self): 477 | expected_return = [{'meta': {}, 'results': []}, 478 | {'meta': {}, 'results': []}] 479 | 480 | with requests_mock.Mocker() as m: 481 | url = "{}/{}".format( 482 | self.client.session.base_url, 483 | "engines/{}/multi_search".format(self.engine_name) 484 | ) 485 | m.register_uri('GET', url, json=expected_return, status_code=200) 486 | response = self.client.multi_search(self.engine_name, {}) 487 | self.assertEqual(response, expected_return) 488 | 489 | def test_query_suggestion(self): 490 | query = 'query' 491 | expected_return = {'meta': {}, 'results': {}} 492 | 493 | with requests_mock.Mocker() as m: 494 | url = "{}/{}".format( 495 | self.client.session.base_url, 496 | "engines/{}/query_suggestion".format(self.engine_name) 497 | ) 498 | m.register_uri('GET', url, json=expected_return, status_code=200) 499 | response = self.client.query_suggestion( 500 | self.engine_name, query, {}) 501 | self.assertEqual(response, expected_return) 502 | 503 | def test_click(self): 504 | with requests_mock.Mocker() as m: 505 | url = "{}/{}".format( 506 | self.client.session.base_url, 507 | "engines/{}/click".format(self.engine_name) 508 | ) 509 | m.register_uri('POST', url, json={}, status_code=200) 510 | self.client.click(self.engine_name, { 511 | 'query': 'cat', 'document_id': 'INscMGmhmX4'}) 512 | 513 | def test_create_meta_engine(self): 514 | source_engines = ['source-engine-1', 'source-engine-2'] 515 | expected_return = {'source_engines': source_engines, 516 | 'type': 'meta', 'name': self.engine_name} 517 | 518 | with requests_mock.Mocker() as m: 519 | url = "{}/{}".format(self.client.session.base_url, 'engines') 520 | m.register_uri('POST', url, json=expected_return, status_code=200) 521 | response = self.client.create_meta_engine( 522 | self.engine_name, source_engines) 523 | self.assertEqual(response, expected_return) 524 | 525 | def test_add_meta_engine_sources(self): 526 | target_source_engine_name = 'source-engine-3' 527 | expected_return = {'source_engines': [ 528 | 'source-engine-1', 'source-engine-2', target_source_engine_name], 'type': 'meta', 'name': self.engine_name} 529 | 530 | with requests_mock.Mocker() as m: 531 | url = "{}/{}".format( 532 | self.client.session.base_url, 533 | "engines/{}/source_engines".format(self.engine_name) 534 | ) 535 | m.register_uri('POST', url, json=expected_return, status_code=200) 536 | response = self.client.add_meta_engine_sources( 537 | self.engine_name, [target_source_engine_name]) 538 | self.assertEqual(response, expected_return) 539 | 540 | def test_delete_meta_engine_sources(self): 541 | source_engine_name = 'source-engine-3' 542 | expected_return = {'source_engines': [ 543 | 'source-engine-1', 'source-engine-2'], 'type': 'meta', 'name': self.engine_name} 544 | 545 | with requests_mock.Mocker() as m: 546 | url = "{}/{}".format( 547 | self.client.session.base_url, 548 | "engines/{}/source_engines".format(self.engine_name) 549 | ) 550 | m.register_uri('DELETE', url, json=expected_return, 551 | status_code=200) 552 | response = self.client.delete_meta_engine_sources( 553 | self.engine_name, [source_engine_name]) 554 | self.assertEqual(response, expected_return) 555 | 556 | def test_get_api_logs(self): 557 | expected_return = {'meta': {}, 'results': []} 558 | 559 | with requests_mock.Mocker() as m: 560 | url = "{}/{}".format( 561 | self.client.session.base_url, 562 | "engines/{}/logs/api".format(self.engine_name) 563 | ) 564 | m.register_uri('GET', url, json=expected_return, status_code=200) 565 | response = self.client.get_api_logs(self.engine_name, options={}) 566 | self.assertEqual(response, expected_return) 567 | 568 | def test_get_search_settings(self): 569 | expected_return = { 570 | "search_fields": { 571 | "name": { 572 | "weight": 1 573 | }, 574 | "description": { 575 | "weight": 1 576 | } 577 | }, 578 | "result_fields": { 579 | "name": { 580 | "raw": {} 581 | }, 582 | "description": { 583 | "raw": {} 584 | } 585 | }, 586 | "boosts": {} 587 | } 588 | 589 | with requests_mock.Mocker() as m: 590 | url = "{}/engines/{}/search_settings".format( 591 | self.client.session.base_url, 592 | self.engine_name 593 | ) 594 | m.register_uri('GET', url, json=expected_return, status_code=200) 595 | response = self.client.get_search_settings(self.engine_name) 596 | self.assertEqual(response, expected_return) 597 | 598 | def test_update_search_settings(self): 599 | expected_return = { 600 | "search_fields": { 601 | "name": { 602 | "weight": 2 603 | }, 604 | "description": { 605 | "weight": 1 606 | } 607 | }, 608 | "result_fields": { 609 | "name": { 610 | "raw": {} 611 | }, 612 | "description": { 613 | "raw": {} 614 | } 615 | }, 616 | "boosts": {} 617 | } 618 | 619 | with requests_mock.Mocker() as m: 620 | url = "{}/engines/{}/search_settings".format( 621 | self.client.session.base_url, 622 | self.engine_name 623 | ) 624 | m.register_uri('PUT', url, json=expected_return, status_code=200) 625 | response = self.client.update_search_settings( 626 | engine_name=self.engine_name, 627 | search_settings=expected_return 628 | ) 629 | self.assertEqual(response, expected_return) 630 | 631 | def test_reset_search_settings(self): 632 | expected_return = { 633 | "search_fields": { 634 | "name": { 635 | "weight": 1 636 | }, 637 | "description": { 638 | "weight": 1 639 | } 640 | }, 641 | "result_fields": { 642 | "name": { 643 | "raw": {} 644 | }, 645 | "description": { 646 | "raw": {} 647 | } 648 | }, 649 | "boosts": {} 650 | } 651 | 652 | with requests_mock.Mocker() as m: 653 | url = "{}/engines/{}/search_settings/reset".format( 654 | self.client.session.base_url, 655 | self.engine_name 656 | ) 657 | m.register_uri('POST', url, json=expected_return, status_code=200) 658 | response = self.client.reset_search_settings( 659 | engine_name=self.engine_name 660 | ) 661 | self.assertEqual(response, expected_return) 662 | --------------------------------------------------------------------------------