Skip to content

Commit

Permalink
add ForemanApi
Browse files Browse the repository at this point in the history
  • Loading branch information
evgeni committed Oct 29, 2024
1 parent 871cd13 commit 81437b1
Show file tree
Hide file tree
Showing 3 changed files with 333 additions and 1 deletion.
3 changes: 2 additions & 1 deletion apypie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
from apypie.example import Example
from apypie.param import Param
from apypie.inflector import Inflector
from apypie.foreman import ForemanApi, ForemanApiException

__all__ = ['Api', 'Resource', 'Route', 'Action', 'Example', 'Param', 'Inflector']
__all__ = ['Api', 'Resource', 'Route', 'Action', 'Example', 'Param', 'Inflector', 'ForemanApi', 'ForemanApiException']
239 changes: 239 additions & 0 deletions apypie/foreman.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""
Apypie Foreman module
opinionated helpers to use Apypie with Foreman
"""
import time

from apypie.api import Api

PER_PAGE = 2 << 31


class ForemanApiException(Exception):
"""
General Exception, raised by any issue in ForemanApi
"""

def __init__(self, msg, error=None):
self.msg = msg
self.error = error
super().__init__()

def __repr__(self):
return str(self)

Check warning on line 24 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L24

Added line #L24 was not covered by tests

def __str__(self):
string = f'{self.__class__.__name__}: {self.msg}'
if self.error:
string += f' - {self.error}'

Check warning on line 29 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L29

Added line #L29 was not covered by tests
return string

@classmethod
def from_exception(cls, exc, msg):
"""
Create a ForemanException from any other Exception
Especially useful to gather the error message from HTTP responses.
"""
error = None
if hasattr(exc, 'response') and exc.response is not None:
try:
response = exc.response.json()
if 'error' in response:
error = response['error']

Check warning on line 44 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L41-L44

Added lines #L41 - L44 were not covered by tests
else:
error = response
except Exception: # pylint: disable=broad-except
error = exc.response.text

Check warning on line 48 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L46-L48

Added lines #L46 - L48 were not covered by tests
return cls(msg=msg, error=error)


class ForemanApi(Api):
"""
apypie.Api with default settings and helper functions for Foreman
"""

def __init__(self, **kwargs):
self.task_timeout = kwargs.pop('task_timeout', 60)
self.task_poll = 4
kwargs['api_version'] = 2
super().__init__(**kwargs)

def _resource(self, resource):
if resource not in self.resources:
raise ForemanApiException(msg="The server doesn't know about {0}, is the right plugin installed?".format(resource))
return self.resource(resource)

def _resource_call(self, resource, *args, **kwargs):
return self._resource(resource).call(*args, **kwargs)

def _resource_prepare_params(self, resource, action, params):
api_action = self._resource(resource).action(action)
return api_action.prepare_params(params)

def resource_action(self, resource, action, params, options=None, data=None, files=None, # pylint: disable=too-many-arguments
ignore_task_errors=False):
"""
Perform a generic action on a resource
Will wait for tasks if the action returns one
"""
resource_payload = self._resource_prepare_params(resource, action, params)
if options is None:
options = {}
try:
result = self._resource_call(resource, action, resource_payload, options=options, data=data, files=files)
is_foreman_task = isinstance(result, dict) and 'action' in result and 'state' in result and 'started_at' in result
if is_foreman_task:
result = self.wait_for_task(result, ignore_errors=ignore_task_errors)

Check warning on line 89 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L89

Added line #L89 was not covered by tests
except Exception as exc:
msg = 'Error while performing {0} on {1}: {2}'.format(
action, resource, str(exc))
raise ForemanApiException.from_exception(exc, msg) from exc
return result

def wait_for_task(self, task, ignore_errors=False):
"""
Wait for a foreman-tasks task, polling it every ``self.task_poll`` seconds.
Will raise a ForemanApiException when task has not finished in ``self.task_timeout`` seconds.
"""
duration = self.task_timeout
while task['state'] not in ['paused', 'stopped']:
duration -= self.task_poll
if duration <= 0:
raise ForemanApiException(msg="Timeout waiting for Task {0}".format(task['id']))
time.sleep(self.task_poll)

Check warning on line 107 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L102-L107

Added lines #L102 - L107 were not covered by tests

resource_payload = self._resource_prepare_params('foreman_tasks', 'show', {'id': task['id']})
task = self._resource_call('foreman_tasks', 'show', resource_payload)
if not ignore_errors and task['result'] != 'success':
msg = 'Task {0}({1}) did not succeed. Task information: {2}'.format(task['action'], task['id'], task['humanized']['errors'])
raise ForemanApiException(msg=msg)
return task

Check warning on line 114 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L109-L114

Added lines #L109 - L114 were not covered by tests

def show(self, resource, resource_id, params=None):
"""
Execute the ``show`` action on an entity.
:param resource: Plural name of the api resource to show
:type resource: str
:param resource_id: The ID of the entity to show
:type resource_id: int
:param params: Lookup parameters (i.e. parent_id for nested entities)
:type params: Union[dict,None], optional
"""
payload = {'id': resource_id}
if params:
payload.update(params)

Check warning on line 129 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L129

Added line #L129 was not covered by tests
return self.resource_action(resource, 'show', payload)

def list(self, resource, search=None, params=None):
"""
Execute the ``index`` action on an resource.
:param resource: Plural name of the api resource to show
:type resource: str
:param search: Search string as accepted by the API to limit the results
:type search: str, optional
:param params: Lookup parameters (i.e. parent_id for nested entities)
:type params: Union[dict,None], optional
"""
payload = {'per_page': PER_PAGE}
if search is not None:
payload['search'] = search

Check warning on line 145 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L145

Added line #L145 was not covered by tests
if params:
payload.update(params)

Check warning on line 147 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L147

Added line #L147 was not covered by tests

return self.resource_action(resource, 'index', payload)['results']

def create(self, resource, desired_entity, params=None):
"""
Create entity with given properties
:param resource: Plural name of the api resource to manipulate
:type resource: str
:param desired_entity: Desired properties of the entity
:type desired_entity: dict
:param params: Lookup parameters (i.e. parent_id for nested entities)
:type params: dict, optional
:return: The new current state of the entity
:rtype: dict
"""
payload = desired_entity.copy()
if params:
payload.update(params)

Check warning on line 167 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L167

Added line #L167 was not covered by tests
return self.resource_action(resource, 'create', payload)

def update(self, resource, desired_entity, params=None):
"""
Update entity with given properties
:param resource: Plural name of the api resource to manipulate
:type resource: str
:param desired_entity: Desired properties of the entity
:type desired_entity: dict
:param params: Lookup parameters (i.e. parent_id for nested entities)
:type params: dict, optional
:return: The new current state of the entity
:rtype: dict
"""
payload = desired_entity.copy()
if params:
payload.update(params)

Check warning on line 186 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L186

Added line #L186 was not covered by tests
return self.resource_action(resource, 'update', payload)

def delete(self, resource, current_entity, params=None): # pylint: disable=useless-return
"""
Delete a given entity
:param resource: Plural name of the api resource to manipulate
:type resource: str
:param current_entity: Current properties of the entity
:type current_entity: dict
:param params: Lookup parameters (i.e. parent_id for nested entities)
:type params: dict, optional
:return: The new current state of the entity
:rtype: Union[dict,None]
"""
payload = {'id': current_entity['id']}
if params:
payload.update(params)

Check warning on line 205 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L205

Added line #L205 was not covered by tests
entity = self.resource_action(resource, 'destroy', payload)

# this is a workaround for https://projects.theforeman.org/issues/26937
if entity and isinstance(entity, dict) and 'error' in entity and 'message' in entity['error']:
raise ForemanApiException(msg=entity['error']['message'])

Check warning on line 210 in apypie/foreman.py

View check run for this annotation

Codecov / codecov/patch

apypie/foreman.py#L210

Added line #L210 was not covered by tests

return None

def validate_payload(self, resource, action, payload):
"""
Check whether the payload only contains supported keys.
:param resource: Plural name of the api resource to check
:type resource: str
:param action: Name of the action to check payload against
:type action: str
:param payload: API paylod to be checked
:type payload: dict
:return: The payload as it can be submitted to the API and set of unssuported parameters
:rtype: tuple(dict, set)
"""
filtered_payload = self._resource_prepare_params(resource, action, payload)
unsupported_parameters = set(payload.keys()) - set(_recursive_dict_keys(filtered_payload))
return (filtered_payload, unsupported_parameters)


def _recursive_dict_keys(a_dict):
"""Find all keys of a nested dictionary"""
keys = set(a_dict.keys())
for _k, value in a_dict.items():
if isinstance(value, dict):
keys.update(_recursive_dict_keys(value))
return keys
92 changes: 92 additions & 0 deletions tests/test_foreman.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# pylint: disable=invalid-name,missing-docstring,protected-access
from apypie.foreman import ForemanApi, ForemanApiException, _recursive_dict_keys

import pytest

import json


def test_recursive_dict_keys():
a_dict = {'level1': 'has value', 'level2': {'real_level2': 'more value', 'level3': {'real_level3': 'nope'}}}
expected_keys = set(['level1', 'level2', 'level3', 'real_level2', 'real_level3'])
assert _recursive_dict_keys(a_dict) == expected_keys


@pytest.fixture
def foremanapi(fixture_dir, requests_mock, tmpdir):
with fixture_dir.join('foreman.json').open() as read_file:
data = json.load(read_file)
requests_mock.get('https://api.example.com/apidoc/v2.json', json=data)
return ForemanApi(uri='https://api.example.com', apidoc_cache_dir=tmpdir.strpath)


def test_init(foremanapi):
assert foremanapi
assert foremanapi.apidoc


def test_resources(foremanapi):
assert 'domains' in foremanapi.resources


def test_resource_action(foremanapi, requests_mock):
requests_mock.get('https://api.example.com/api/organizations/1', json={'id': 1})
org = foremanapi.resource_action('organizations', 'show', {'id': 1})
assert org


def test_resource_action_unknown_resource(foremanapi):
with pytest.raises(ForemanApiException) as excinfo:
foremanapi.resource_action('bubblegums', 'show', {'id': 1})
assert "The server doesn't know about bubblegums, is the right plugin installed?" in str(excinfo.value)


def test_resource_action_http_error(foremanapi, mocker):
mocker.patch('apypie.foreman.ForemanApi._resource_call', autospec=True, side_effect=Exception)
with pytest.raises(ForemanApiException) as excinfo:
foremanapi.resource_action('organizations', 'show', {'id': 1})
assert "ForemanApiException: Error while performing show on organizations" in str(excinfo.value)


def test_wait_for_task():
pass


def test_show(foremanapi, requests_mock):
requests_mock.get('https://api.example.com/api/organizations/1', json={'id': 1})
org = foremanapi.show('organizations', 1)
assert org


def test_list(foremanapi, requests_mock):
requests_mock.get('https://api.example.com/api/organizations?per_page=4294967296', json={'results': [{'id': 1}]})
orgs = foremanapi.list('organizations')
assert orgs


def test_create(foremanapi, requests_mock):
# needs to match the sent json!
requests_mock.post('https://api.example.com/api/organizations', json={'id': 1})
org = foremanapi.create('organizations', {'name': 'Test'})
assert org


def test_update(foremanapi, requests_mock):
# needs to match the sent json!
requests_mock.put('https://api.example.com/api/organizations/1', json={'id': 1})
org = foremanapi.update('organizations', {'id': 1, 'name': 'Test'})
assert org


def test_delete(foremanapi, requests_mock):
requests_mock.delete('https://api.example.com/api/organizations/1', status_code=204)
foremanapi.delete('organizations', {'id': 1})


@pytest.mark.parametrize("params,expected", [
({'name': 'test'}, ({'organization': {'name': 'test'}}, set())),
({'name': 'test', 'nope': 'nope'}, ({'organization': {'name': 'test'}}, {'nope'})),
])
def test_validate_payload(foremanapi, params, expected):
result = foremanapi.validate_payload('organizations', 'create', params)
assert result == expected

0 comments on commit 81437b1

Please sign in to comment.