From 1843e0ed5979ae35e295f59728846d8e022bf2b1 Mon Sep 17 00:00:00 2001 From: Matt Wisner Date: Wed, 25 Oct 2017 12:17:49 -0400 Subject: [PATCH] Init status service --- .env | 3 + .env.example | 3 + .gitignore | 2 + .travis.yml | 5 +- README.rst | 15 +- {python_kong_management => kong}/__init__.py | 0 kong/client.py | 57 +++++++ kong/errors.py | 68 ++++++++ kong/request.py | 154 ++++++++++++++++++ kong/service/__init__.py | 0 kong/service/base_service.py | 5 + kong/service/status.py | 12 ++ pytest.ini | 2 + .../python_kong_management.py | 3 - requirements_dev.txt | 3 +- setup.cfg | 3 +- setup.py | 15 +- tests/integration/__init__.py | 0 tests/integration/test_service_status.py | 16 ++ tests/test_python_kong_management.py | 25 --- tests/unit/__init__.py | 0 tests/unit/test_service_status.py | 14 ++ tests/utils.py | 15 ++ tox.ini | 9 +- 24 files changed, 375 insertions(+), 54 deletions(-) create mode 100644 .env create mode 100644 .env.example rename {python_kong_management => kong}/__init__.py (100%) create mode 100644 kong/client.py create mode 100644 kong/errors.py create mode 100644 kong/request.py create mode 100644 kong/service/__init__.py create mode 100644 kong/service/base_service.py create mode 100644 kong/service/status.py create mode 100644 pytest.ini delete mode 100644 python_kong_management/python_kong_management.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_service_status.py delete mode 100644 tests/test_python_kong_management.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_service_status.py create mode 100644 tests/utils.py diff --git a/.env b/.env new file mode 100644 index 0000000..319ee04 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +KONG_MANAGEMENT=http://localhost:8000/management +KONG_USERNAME=kong +KONG_PASSWORD=kong diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..319ee04 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +KONG_MANAGEMENT=http://localhost:8000/management +KONG_USERNAME=kong +KONG_PASSWORD=kong diff --git a/.gitignore b/.gitignore index 71e6852..47034c5 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,5 @@ target/ # pyenv python configuration file .python-version +venv +.env diff --git a/.travis.yml b/.travis.yml index ec86eb1..2885662 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,10 @@ language: python python: + - 3.6 - 3.5 - 3.4 - 3.3 - - 2.7 - - 2.6 # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors install: pip install -U tox-travis @@ -26,4 +25,4 @@ deploy: on: tags: true repo: mwisner/python_kong_management - python: 2.7 + python: 3.6 diff --git a/README.rst b/README.rst index 43a6183..3656298 100644 --- a/README.rst +++ b/README.rst @@ -30,11 +30,12 @@ Features * TODO -Credits +Testing --------- - -This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. - -.. _Cookiecutter: https://github.com/audreyr/cookiecutter -.. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage - +```bash +cp .env.example .env +python3 -m venv venv +source venv +eval $(cat .env | sed 's/^/export /') +setup.py pytest +``` diff --git a/python_kong_management/__init__.py b/kong/__init__.py similarity index 100% rename from python_kong_management/__init__.py rename to kong/__init__.py diff --git a/kong/client.py b/kong/client.py new file mode 100644 index 0000000..cc8aa7e --- /dev/null +++ b/kong/client.py @@ -0,0 +1,57 @@ +import requests +from .errors import KongError + + +class Client(object): + + def __init__(self, base_url, username=None, password=None): + + if base_url[-1:] != '/': + base_url = base_url + '/' + + self.base_url = base_url + self.username = username + self.password = password + + self.rate_limit_details = {} + self.http_session = requests.Session() + + @property + def _auth(self): + if self.username and self.password: + return self.username, self.password + return None + + @property + def status(self): + from .service import status + return status.Status(self) + + def _execute_request(self, request, params): + result = request.execute(self.base_url, self._auth, params) + return result + + def get(self, path, params={}): + from . import request + req = request.Request('GET', path, self.http_session) + return self._execute_request(req, params) + + def post(self, path, params): + from . import request + req = request.Request('POST', path, self.http_session) + return self._execute_request(req, params) + + def put(self, path, params): + from . import request + req = request.Request('PUT', path, self.http_session) + return self._execute_request(req, params) + + def patch(self, path, params): + from . import request + req = request.Request('PATCH', path, self.http_session) + return self._execute_request(req, params) + + def delete(self, path, params): + from . import request + req = request.Request('DELETE', path, self.http_session) + return self._execute_request(req, params) diff --git a/kong/errors.py b/kong/errors.py new file mode 100644 index 0000000..12ed66d --- /dev/null +++ b/kong/errors.py @@ -0,0 +1,68 @@ + +class KongError(Exception): + + def __init__(self, message=None, context=None): + super(KongError, self).__init__(message) + self.message = message + self.context = context + + +class ArgumentError(ValueError, KongError): + pass + + +class HttpError(KongError): + pass + + +class ResourceNotFound(KongError): + pass + + +class AuthenticationError(KongError): + pass + + +class ServerError(KongError): + pass + + +class BadGatewayError(KongError): + pass + + +class ServiceUnavailableError(KongError): + pass + + +class BadRequestError(KongError): + pass + + +class RateLimitExceeded(KongError): + pass + + +class MultipleMatchingUsersError(KongError): + pass + + +class UnexpectedError(KongError): + pass + + +class TokenUnauthorizedError(KongError): + pass + + +error_codes = { + 'unauthorized': AuthenticationError, + 'forbidden': AuthenticationError, + 'bad_request': BadRequestError, + 'action_forbidden': BadRequestError, + 'missing_parameter': BadRequestError, + 'parameter_invalid': BadRequestError, + 'parameter_not_found': BadRequestError, + 'not_found': ResourceNotFound, + 'service_unavailable': ServiceUnavailableError, +} diff --git a/kong/request.py b/kong/request.py new file mode 100644 index 0000000..e9ce3e8 --- /dev/null +++ b/kong/request.py @@ -0,0 +1,154 @@ +from . import errors + +import certifi +import json +import logging +import os +import requests + +logger = logging.getLogger('kong.request') + + +def configure_timeout(): + """Configure the request timeout.""" + timeout = os.getenv('KONG_REQUEST_TIMEOUT', '90') + try: + return int(timeout) + except ValueError: + logger.warning('%s is not a valid timeout value.', timeout) + return 90 + + +class Request(object): + + timeout = configure_timeout() + + def __init__(self, http_method, path, http_session=None): + self.http_method = http_method + self.path = path + self.http_session = http_session + + def execute(self, base_url, auth, params): + return self.send_request_to_path(base_url, auth, params) + + def send_request_to_path(self, base_url, auth, params=None): + """ Construct an API request, send it to the API, and parse the + response. """ + from . import __version__ + req_params = {} + + if auth: + req_params['auth'] = auth + + # full URL + url = base_url + self.path + + headers = { + 'User-Agent': 'python-kong/' + __version__, + 'AcceptEncoding': 'gzip, deflate', + 'Accept': 'application/json', + } + + if self.http_method in ('POST', 'PUT', 'DELETE'): + headers['content-type'] = 'application/json' + req_params['data'] = json.dumps(params, cls=ResourceEncoder) + elif self.http_method == 'GET': + req_params['params'] = params + req_params['headers'] = headers + + # request logging + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Sending %s request to: %s", self.http_method, url) + logger.debug(" headers: %s", headers) + if self.http_method == 'GET': + logger.debug(" params: %s", req_params['params']) + else: + logger.debug(" params: %s", req_params['data']) + + if self.http_session is None: + resp = requests.request( + self.http_method, url, timeout=self.timeout, + verify=certifi.where(), **req_params) + else: + resp = self.http_session.request( + self.http_method, url, timeout=self.timeout, verify=certifi.where(), **req_params) + + # response logging + if logger.isEnabledFor(logging.DEBUG): + logger.debug("Response received from %s", url) + logger.debug(" encoding=%s status:%s", + resp.encoding, resp.status_code) + logger.debug(" content:\n%s", resp.content) + + parsed_body = self.parse_body(resp) + self.raise_errors_on_failure(resp) + return parsed_body + + def parse_body(self, resp): + if resp.content and resp.content.strip(): + try: + # use supplied or inferred encoding to decode the + # response content + decoded_body = resp.content.decode( + resp.encoding or resp.apparent_encoding) + body = json.loads(decoded_body) + #if body.get('type') == 'error.list': + # self.raise_application_errors_on_failure(body, resp.status_code) # noqa + return body + except ValueError: + self.raise_errors_on_failure(resp) + + def raise_errors_on_failure(self, resp): + if resp.status_code == 404: + raise errors.ResourceNotFound('Resource Not Found') + elif resp.status_code == 401: + raise errors.AuthenticationError('Unauthorized') + elif resp.status_code == 403: + raise errors.AuthenticationError('Forbidden') + elif resp.status_code == 500: + raise errors.ServerError('Server Error') + elif resp.status_code == 502: + raise errors.BadGatewayError('Bad Gateway Error') + elif resp.status_code == 503: + raise errors.ServiceUnavailableError('Service Unavailable') + + def raise_application_errors_on_failure(self, error_list_details, http_code): # noqa + # Currently, we don't support multiple errors + error_details = error_list_details['errors'][0] + error_code = error_details.get('type') + if error_code is None: + error_code = error_details.get('code') + error_context = { + 'http_code': http_code, + 'application_error_code': error_code + } + error_class = errors.error_codes.get(error_code) + if error_class is None: + # unexpected error + if error_code: + message = self.message_for_unexpected_error_with_type( + error_details, http_code) + else: + message = self.message_for_unexpected_error_without_type( + error_details, http_code) + error_class = errors.UnexpectedError + else: + message = error_details.get('message') + raise error_class(message, error_context) + + def message_for_unexpected_error_with_type(self, error_details, http_code): # noqa + error_type = error_details.get('type') + message = error_details.get('message') + return "The error of type '%s' is not recognized. It occurred with the message: %s and http_code: '%s'. Please contact Kong with these details." % (error_type, message, http_code) # noqa + + def message_for_unexpected_error_without_type(self, error_details, http_code): # noqa + message = error_details['message'] + return "An unexpected error occured. It occurred with the message: %s and http_code: '%s'. Please contact Kong with these details." % (message, http_code) # noqa + + +class ResourceEncoder(json.JSONEncoder): + def default(self, o): + if hasattr(o, 'attributes'): + # handle API resources + return o.attributes + return super(ResourceEncoder, self).default(o) diff --git a/kong/service/__init__.py b/kong/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kong/service/base_service.py b/kong/service/base_service.py new file mode 100644 index 0000000..7556d17 --- /dev/null +++ b/kong/service/base_service.py @@ -0,0 +1,5 @@ + +class BaseService(object): + + def __init__(self, client): + self.client = client diff --git a/kong/service/status.py b/kong/service/status.py new file mode 100644 index 0000000..52316d0 --- /dev/null +++ b/kong/service/status.py @@ -0,0 +1,12 @@ +from .base_service import BaseService +from kong.errors import KongError + + +class Status(BaseService): + def get(self): + """ + + :return: + """ + response = self.client.get('status/') + return response diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/python_kong_management/python_kong_management.py b/python_kong_management/python_kong_management.py deleted file mode 100644 index 7fbbae4..0000000 --- a/python_kong_management/python_kong_management.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Main module.""" diff --git a/requirements_dev.txt b/requirements_dev.txt index 014b44a..0860986 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ -pip==8.1.2 +pip==9.0.1 bumpversion==0.5.3 wheel==0.29.0 watchdog==0.8.3 @@ -10,3 +10,4 @@ cryptography==1.7 PyYAML==3.11 pytest==2.9.2 pytest-runner==2.11.1 +requests==2.18.4 diff --git a/setup.cfg b/setup.cfg index 3b311ba..27bf9ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,4 +19,5 @@ exclude = docs [aliases] test = pytest -# Define setup.py command aliases here +unit = pytest te + diff --git a/setup.py b/setup.py index a988953..4779d14 100644 --- a/setup.py +++ b/setup.py @@ -12,17 +12,16 @@ history = history_file.read() requirements = [ - # TODO: put package requirements here + 'requests', ] setup_requirements = [ 'pytest-runner', - # TODO(mwisner): put setup requirements (distutils extensions, etc.) here ] test_requirements = [ 'pytest', - # TODO: put package test requirements here + 'requests', ] setup( @@ -32,25 +31,23 @@ long_description=readme + '\n\n' + history, author="Matt Wisner", author_email='mwisner@fourthlab.com', - url='https://github.com/mwisner/python_kong_management', - packages=find_packages(include=['python_kong_management']), + url='https://github.com/mwisner/python-kong-management', + packages=find_packages(include=['kong']), include_package_data=True, install_requires=requirements, license="MIT license", zip_safe=False, - keywords='python_kong_management', + keywords='python-kong-management', classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Natural Language :: English', - "Programming Language :: Python :: 2", - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ], test_suite='tests', tests_require=test_requirements, diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_service_status.py b/tests/integration/test_service_status.py new file mode 100644 index 0000000..c7033d6 --- /dev/null +++ b/tests/integration/test_service_status.py @@ -0,0 +1,16 @@ +import unittest + +from ..utils import get_integration_testing_client + + +class TestStatusService(unittest.TestCase): + @classmethod + def setup_class(cls): + cls.client = get_integration_testing_client() + + def test_status(self): + response = self.client.status.get() + assert response.get('database') + assert response.get('server') + + diff --git a/tests/test_python_kong_management.py b/tests/test_python_kong_management.py deleted file mode 100644 index a305ae4..0000000 --- a/tests/test_python_kong_management.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Tests for `python_kong_management` package.""" - -import pytest - - -from python_kong_management import python_kong_management - - -@pytest.fixture -def response(): - """Sample pytest fixture. - - See more at: http://doc.pytest.org/en/latest/fixture.html - """ - # import requests - # return requests.get('https://github.com/audreyr/cookiecutter-pypackage') - - -def test_content(response): - """Sample pytest test function with the pytest fixture as an argument.""" - # from bs4 import BeautifulSoup - # assert 'GitHub' in BeautifulSoup(response.content).title.string diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_service_status.py b/tests/unit/test_service_status.py new file mode 100644 index 0000000..aed8858 --- /dev/null +++ b/tests/unit/test_service_status.py @@ -0,0 +1,14 @@ +import unittest + +from kong.client import Client + + +class TestStatusService(unittest.TestCase): + @classmethod + def setup_class(cls): + cls.client = Client("http://www.test.com", username="foo", password="bar") + + def test_status(self): + assert self.client + + diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..56511af --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,15 @@ +import os +from kong.client import Client +from kong.errors import KongError + + +def get_integration_testing_client(): + """ + Load the client using + :return: + """ + management_domain = os.environ.get('KONG_MANAGEMENT') + username = os.environ.get('KONG_USERNAME') + password = os.environ.get('KONG_PASSWORD') + + return Client(management_domain, username=username, password=password) diff --git a/tox.ini b/tox.ini index d1e9752..1348c5e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,17 @@ [tox] -envlist = py26, py27, py33, py34, py35, flake8 +envlist = py33, py34, py35, py36, flake8 [travis] python = 3.5: py35 + 3.7: py37 3.4: py34 3.3: py33 - 2.7: py27 - 2.6: py26 [testenv:flake8] basepython=python deps=flake8 -commands=flake8 python_kong_management +commands=flake8 kong [testenv] setenv = @@ -21,7 +20,7 @@ deps = -r{toxinidir}/requirements_dev.txt commands = pip install -U pip - py.test --basetemp={envtmpdir} + py.test tests/unit --basetemp={envtmpdir} ; If you want to make tox run the tests with the same versions, create a