diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9efdd0d..7b80ced 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,23 +18,23 @@ jobs: strategy: matrix: python-version: - - 3.8 - 3.9 - '3.10' - 3.11 + - 3.12 steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Create docker-compose.yml - run: curl https://raw.githubusercontent.com/farmOS/farmOS/2.x/docker/docker-compose.development.yml -o docker-compose.yml + run: curl https://raw.githubusercontent.com/farmOS/farmOS/3.x/docker/docker-compose.development.yml -o docker-compose.yml - name: Start containers run: docker-compose up -d && sleep 5 - name: Install farmOS run: | docker-compose exec -u www-data -T www drush site-install -y --db-url=pgsql://farm:farm@db/farm --account-pass=admin + docker-compose exec -u www-data -T www drush en farm_api_default_consumer -y docker-compose exec -u www-data -T www drush user-create tester --password test docker-compose exec -u www-data -T www drush user-add-role farm_manager tester - docker-compose exec -u www-data -T www drush config:set simple_oauth.settings access_token_expiration 15 -y - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: @@ -43,6 +43,8 @@ jobs: run: | python -m pip install --upgrade pip pip install pytest -e .[test] + - name: Check unasync + run: python farmOS/utils/unasync.py --check - name: Run farmOS.py tests. run: pytest tests env: diff --git a/farmOS/__init__.py b/farmOS/__init__.py index e4c77ff..1af6f00 100644 --- a/farmOS/__init__.py +++ b/farmOS/__init__.py @@ -1,156 +1,4 @@ -import logging -from datetime import datetime -from functools import partial -from urllib.parse import urlparse, urlunparse +from ._async.client import AsyncFarmClient +from ._sync.client import FarmClient -from . import client, client_2, subrequests -from .session import OAuthSession - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -class farmOS: - """A client that connects to the farmOS server.""" - - def __init__( - self, - hostname, - client_id="farm", - client_secret=None, - scope="farm_manager", - token=None, - token_updater=lambda new_token: None, - version=2, - ): - """ - Initialize instance of the farmOS client that connects to a single farmOS server. - - :param hostname: Valid hostname without a path or query params. The HTTPS scheme - will be added if none is specified. - :param client_id: OAuth Client ID. Defaults to "farm" - :param client_secret: OAuth Client Secret. Defaults to None. - :param scope: OAuth Scope. Defaults to "farm_manager". - :param token: An existing OAuth token to use. - :param token_updater: A function used to save OAuth tokens outside of the client. - :param version: The major version of the farmOS server. Defaults to 2. - """ - - logger.debug("Creating farmOS client.") - - # Save the token_updater function. - self.token_updater = token_updater - - # Save the session. - self.session = None - - if hostname is not None: - valid_schemes = ["http", "https"] - default_scheme = "https" - parsed_url = urlparse(hostname) - - # Validate the hostname. - # Add a default scheme if not provided. - if not parsed_url.scheme: - parsed_url = parsed_url._replace(scheme=default_scheme) - logger.debug("No scheme provided. Using %s", default_scheme) - - # Check for a valid scheme. - if parsed_url.scheme not in valid_schemes: - raise Exception("Not a valid scheme.") - - # If not netloc was provided, it was probably parsed as the path. - if not parsed_url.netloc and parsed_url.path: - parsed_url = parsed_url._replace(netloc=parsed_url.path) - parsed_url = parsed_url._replace(path="") - - # Check for netloc. - if not parsed_url.netloc: - raise Exception("Invalid hostname. Must have netloc.") - - # Don't allow path, params, or query. - if parsed_url.path or parsed_url.params or parsed_url.query: - raise Exception("Hostname cannot include path and query parameters.") - - # Build the url again to include changes. - hostname = urlunparse(parsed_url) - logger.debug("Complete hostname configured as %s", hostname) - - else: - raise Exception("No hostname provided and could not be loaded from config.") - - logger.debug("Creating an OAuth Session.") - # OR implement a method to check both token paths. - # maybe version can default to none, and check the server? - token_url = hostname + "/oauth/token" - - # Check the token expiration time. - if token is not None and "expires_at" in token: - # Create datetime objects for comparison. - now = datetime.now() - expiration_time = datetime.fromtimestamp(float(token["expires_at"])) - - # Calculate seconds until expiration. - timedelta = expiration_time - now - expires_in = timedelta.total_seconds() - - # Update the token expires_in value - token["expires_in"] = expires_in - - # Unset the 'expires_at' key. - token.pop("expires_at") - - # Determine the Content-Type header depending on server version. - content_type = ( - "application/vnd.api+json" if version == 2 else "application/json" - ) - - # Create an OAuth Session - self.session = OAuthSession( - hostname=hostname, - client_id=client_id, - client_secret=client_secret, - scope=scope, - token=token, - token_url=token_url, - content_type=content_type, - token_updater=self.token_updater, - ) - - self._client_id = client_id - self._client_secret = client_secret - - if self.session is None: - raise Exception( - "Could not create a session object. Supply authentication credentials when " - "initializing a farmOS Client." - ) - - if version == 2: - self.log = client_2.LogAPI(self.session) - self.asset = client_2.AssetAPI(self.session) - self.term = client_2.TermAPI(self.session) - self.resource = client_2.ResourceBase(self.session) - self.info = partial(client_2.info, self.session) - self.subrequests = subrequests.SubrequestsBase(self.session) - self.filter = client_2.filter - else: - self.log = client.LogAPI(self.session) - self.asset = client.AssetAPI(self.session) - self.area = client.AreaAPI(self.session) - self.term = client.TermAPI(self.session) - self.info = partial(client.info, self.session) - - def authorize(self, username=None, password=None, scope=None): - """Authorize with the farmOS server. - - The client must be authorized with the farmOS server before making requests. - This method utilizes the OAuth Password Credentials flow to authorize users. - - :param username: farmOS Username. Prompted if not included. - :param password: farmOS Password. Prompted if not included. - :param scope: Scope to authorize as with the farmOS server. Defaults to "farm_manager". - :return: OAuth Token. - """ - - return self.session.authorize(username, password, scope) +__all__ = ["AsyncFarmClient", "FarmClient"] diff --git a/farmOS/_async/__init__.py b/farmOS/_async/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmOS/_async/client.py b/farmOS/_async/client.py new file mode 100644 index 0000000..2e5ec76 --- /dev/null +++ b/farmOS/_async/client.py @@ -0,0 +1,18 @@ +from functools import partial + +from httpx import AsyncClient + +from farmOS._async import resource, subrequests +from farmOS.filter import filter + + +class AsyncFarmClient(AsyncClient): + def __init__(self, hostname, **kwargs): + super().__init__(base_url=hostname, **kwargs) + self.info = partial(resource.info, self) + self.filter = filter + self.subrequests = subrequests.SubrequestsBase(self) + self.resource = resource.ResourceBase(self) + self.log = resource.LogAPI(self) + self.asset = resource.AssetAPI(self) + self.term = resource.TermAPI(self) diff --git a/farmOS/_async/resource.py b/farmOS/_async/resource.py new file mode 100644 index 0000000..18fcfe4 --- /dev/null +++ b/farmOS/_async/resource.py @@ -0,0 +1,201 @@ +import logging +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class ResourceBase: + """Base class for JSONAPI resource methods.""" + + def __init__(self, client): + self.client = client + self.params = {} + + async def _get_records( + self, entity_type, bundle=None, resource_id=None, params=None + ): + """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" + if params is None: + params = {} + + params = {**self.params, **params} + + path = self._get_resource_path(entity_type, bundle, resource_id) + + response = await self.client.request(method="GET", url=path, params=params) + return response.json() + + async def get(self, entity_type, bundle=None, params=None): + return await self._get_records( + entity_type=entity_type, bundle=bundle, params=params + ) + + async def get_id(self, entity_type, bundle=None, resource_id=None, params=None): + return await self._get_records( + entity_type=entity_type, + bundle=bundle, + params=params, + resource_id=resource_id, + ) + + async def iterate(self, entity_type, bundle=None, params=None): + response = await self._get_records( + entity_type=entity_type, bundle=bundle, params=params + ) + more = True + while more: + # TODO: Should we merge in the "includes" info here? + for resource in response["data"]: + yield resource + try: + next_url = response["links"]["next"]["href"] + parsed_url = urlparse(next_url) + next_path = parsed_url._replace(scheme="", netloc="").geturl() + response = await self.client.request(method="GET", url=next_path) + response = response.json() + except KeyError: + more = False + + async def send(self, entity_type, bundle=None, payload=None): + # Default to empty payload dict. + if payload is None: + payload = {} + + # Set the resource type. + payload["type"] = self._get_resource_type(entity_type, bundle) + json_payload = { + "data": {**payload}, + } + + # If an ID is included, update the record + id = payload.pop("id", None) + if id: + json_payload["data"]["id"] = id + logger.debug("Updating record id: of entity type: %s", id, entity_type) + path = self._get_resource_path( + entity_type=entity_type, bundle=bundle, record_id=id + ) + response = await self.client.request( + method="PATCH", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, + ) + # If no ID is included, create a new record + else: + logger.debug("Creating record of entity type: %s", entity_type) + path = self._get_resource_path(entity_type=entity_type, bundle=bundle) + response = await self.client.request( + method="POST", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, + ) + + # Handle response from POST requests + if response.status_code == 201: + logger.debug("Record created.") + + # Handle response from PUT requests + if response.status_code == 200: + logger.debug("Record updated.") + + return response.json() + + async def delete(self, entity_type, bundle=None, id=None): + logger.debug("Deleted record id: %s of entity type: %s", id, entity_type) + path = self._get_resource_path( + entity_type=entity_type, bundle=bundle, record_id=id + ) + return await self.client.request(method="DELETE", url=path) + + @staticmethod + def _get_resource_path(entity_type, bundle=None, record_id=None): + """Helper function that builds paths to jsonapi resources.""" + + if bundle is None: + bundle = entity_type + + path = "api/" + entity_type + "/" + bundle + + if record_id: + path += "/" + str(record_id) + + return path + + @staticmethod + def _get_resource_type(entity_type, bundle=None): + """Helper function that builds a JSONAPI resource name.""" + + if bundle is None: + bundle = entity_type + + return entity_type + "--" + bundle + + +class ResourceHelperBase: + def __init__(self, client, entity_type): + self.entity_type = entity_type + self.resource_api = ResourceBase(client=client) + + async def get(self, bundle, params=None): + return await self.resource_api.get( + entity_type=self.entity_type, bundle=bundle, params=params + ) + + async def get_id(self, bundle, resource_id, params=None): + return await self.resource_api.get_id( + entity_type=self.entity_type, + bundle=bundle, + resource_id=resource_id, + params=params, + ) + + async def iterate(self, bundle, params=None): + async for item in self.resource_api.iterate( + entity_type=self.entity_type, bundle=bundle, params=params + ): + yield item + + async def send(self, bundle, payload=None): + return await self.resource_api.send( + entity_type=self.entity_type, bundle=bundle, payload=payload + ) + + async def delete(self, bundle, id): + return await self.resource_api.delete( + entity_type=self.entity_type, bundle=bundle, id=id + ) + + +class AssetAPI(ResourceHelperBase): + """API for interacting with farm assets""" + + def __init__(self, client): + # Define 'asset' as the JSONAPI resource type. + super().__init__(client=client, entity_type="asset") + + +class LogAPI(ResourceHelperBase): + """API for interacting with farm logs""" + + def __init__(self, client): + # Define 'log' as the JSONAPI resource type. + super().__init__(client=client, entity_type="log") + + +class TermAPI(ResourceHelperBase): + """API for interacting with farm Terms""" + + def __init__(self, client): + # Define 'taxonomy_term' as the farmOS API entity endpoint + super().__init__(client=client, entity_type="taxonomy_term") + + +async def info(client): + """Retrieve info about the farmOS server.""" + + logger.debug("Retrieving farmOS server info.") + response = await client.request(method="GET", url="api") + return response.json() diff --git a/farmOS/_async/subrequests.py b/farmOS/_async/subrequests.py new file mode 100644 index 0000000..fff1a57 --- /dev/null +++ b/farmOS/_async/subrequests.py @@ -0,0 +1,57 @@ +from typing import List, Optional, Union + +from farmOS.subrequests_model import Format, SubrequestsBlueprint + + +class SubrequestsBase: + """Class for handling subrequests""" + + subrequest_path = "subrequests" + + def __init__(self, client): + self.client = client + + async def send( + self, + blueprint: Union[SubrequestsBlueprint, List], + format: Optional[Union[Format, str]] = Format.json, + ): + if isinstance(blueprint, List): + blueprint = SubrequestsBlueprint(blueprint) + + # Modify each sub-request as needed. + for sub in blueprint: + # Build the URI if an endpoint is provided. + if sub.uri is None and sub.endpoint is not None: + sub.uri = sub.endpoint + + # Set the endpoint to None so it is not included in the serialized subrequest. + sub.endpoint = None + + # Auto populate headers for each sub-request. + if "Accept" not in sub.headers: + sub.headers["Accept"] = "application/vnd.api+json" + if sub.body is not None and "Content-Type" not in sub.headers: + sub.headers["Content-Type"] = "application/vnd.api+json" + + params = {} + if format == Format.json.value: + params = {"_format": "json"} + + # Generate the json to send. It is important to use the .model_dump_json() method + # of the model for correct serialization. + blueprint_json = blueprint.model_dump_json(exclude_none=True) + + response = await self.client.request( + method="POST", + url=self.subrequest_path, + params=params, + headers={"Content-Type": "application/json"}, + content=blueprint_json, + ) + + # Return a json response if requested. + if format == Format.json.value: + return response.json() + + return response diff --git a/farmOS/_sync/__init__.py b/farmOS/_sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/farmOS/_sync/client.py b/farmOS/_sync/client.py new file mode 100644 index 0000000..c5b3054 --- /dev/null +++ b/farmOS/_sync/client.py @@ -0,0 +1,18 @@ +from functools import partial + +from httpx import Client + +from farmOS._sync import resource, subrequests +from farmOS.filter import filter + + +class FarmClient(Client): + def __init__(self, hostname, **kwargs): + super().__init__(base_url=hostname, **kwargs) + self.info = partial(resource.info, self) + self.filter = filter + self.subrequests = subrequests.SubrequestsBase(self) + self.resource = resource.ResourceBase(self) + self.log = resource.LogAPI(self) + self.asset = resource.AssetAPI(self) + self.term = resource.TermAPI(self) diff --git a/farmOS/client_2.py b/farmOS/_sync/resource.py similarity index 66% rename from farmOS/client_2.py rename to farmOS/_sync/resource.py index 2ccde6e..e436376 100644 --- a/farmOS/client_2.py +++ b/farmOS/_sync/resource.py @@ -8,11 +8,13 @@ class ResourceBase: """Base class for JSONAPI resource methods.""" - def __init__(self, session): - self.session = session + def __init__(self, client): + self.client = client self.params = {} - def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): + def _get_records( + self, entity_type, bundle=None, resource_id=None, params=None + ): """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" if params is None: params = {} @@ -21,11 +23,13 @@ def _get_records(self, entity_type, bundle=None, resource_id=None, params=None): path = self._get_resource_path(entity_type, bundle, resource_id) - response = self.session.http_request(path=path, params=params) + response = self.client.request(method="GET", url=path, params=params) return response.json() def get(self, entity_type, bundle=None, params=None): - return self._get_records(entity_type=entity_type, bundle=bundle, params=params) + return self._get_records( + entity_type=entity_type, bundle=bundle, params=params + ) def get_id(self, entity_type, bundle=None, resource_id=None, params=None): return self._get_records( @@ -42,12 +46,13 @@ def iterate(self, entity_type, bundle=None, params=None): more = True while more: # TODO: Should we merge in the "includes" info here? - yield from response["data"] + for resource in response["data"]: + yield resource try: next_url = response["links"]["next"]["href"] parsed_url = urlparse(next_url) next_path = parsed_url._replace(scheme="", netloc="").geturl() - response = self.session.http_request(path=next_path) + response = self.client.request(method="GET", url=next_path) response = response.json() except KeyError: more = False @@ -62,7 +67,6 @@ def send(self, entity_type, bundle=None, payload=None): json_payload = { "data": {**payload}, } - options = {"json": json_payload} # If an ID is included, update the record id = payload.pop("id", None) @@ -72,15 +76,21 @@ def send(self, entity_type, bundle=None, payload=None): path = self._get_resource_path( entity_type=entity_type, bundle=bundle, record_id=id ) - response = self.session.http_request( - method="PATCH", path=path, options=options + response = self.client.request( + method="PATCH", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, ) # If no ID is included, create a new record else: logger.debug("Creating record of entity type: %s", entity_type) path = self._get_resource_path(entity_type=entity_type, bundle=bundle) - response = self.session.http_request( - method="POST", path=path, options=options + response = self.client.request( + method="POST", + url=path, + json=json_payload, + headers={"Content-Type": "application/vnd.api+json"}, ) # Handle response from POST requests @@ -98,7 +108,7 @@ def delete(self, entity_type, bundle=None, id=None): path = self._get_resource_path( entity_type=entity_type, bundle=bundle, record_id=id ) - return self.session.http_request(method="DELETE", path=path) + return self.client.request(method="DELETE", url=path) @staticmethod def _get_resource_path(entity_type, bundle=None, record_id=None): @@ -125,9 +135,9 @@ def _get_resource_type(entity_type, bundle=None): class ResourceHelperBase: - def __init__(self, session, entity_type): + def __init__(self, client, entity_type): self.entity_type = entity_type - self.resource_api = ResourceBase(session=session) + self.resource_api = ResourceBase(client=client) def get(self, bundle, params=None): return self.resource_api.get( @@ -143,9 +153,10 @@ def get_id(self, bundle, resource_id, params=None): ) def iterate(self, bundle, params=None): - return self.resource_api.iterate( + for item in self.resource_api.iterate( entity_type=self.entity_type, bundle=bundle, params=params - ) + ): + yield item def send(self, bundle, payload=None): return self.resource_api.send( @@ -161,66 +172,30 @@ def delete(self, bundle, id): class AssetAPI(ResourceHelperBase): """API for interacting with farm assets""" - def __init__(self, session): + def __init__(self, client): # Define 'asset' as the JSONAPI resource type. - super().__init__(session=session, entity_type="asset") + super().__init__(client=client, entity_type="asset") class LogAPI(ResourceHelperBase): """API for interacting with farm logs""" - def __init__(self, session): + def __init__(self, client): # Define 'log' as the JSONAPI resource type. - super().__init__(session=session, entity_type="log") + super().__init__(client=client, entity_type="log") class TermAPI(ResourceHelperBase): """API for interacting with farm Terms""" - def __init__(self, session): + def __init__(self, client): # Define 'taxonomy_term' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="taxonomy_term") + super().__init__(client=client, entity_type="taxonomy_term") -def info(session): +def info(client): """Retrieve info about the farmOS server.""" logger.debug("Retrieving farmOS server info.") - response = session.http_request("api") + response = client.request(method="GET", url="api") return response.json() - - -def filter(path, value, operation="="): - """Helper method to build JSONAPI filter query params.""" - - # TODO: Validate path, value, operation? - # TODO: Support filter groups. - # TODO: Support revision filters, are these edge cases? - # TODO: Support "pagination" query params? Separate method? - # TODO: Support "sort" query params? Separate method? - # TODO: Support "includes" query params? Separate method? - - filters = {} - - # If the operation is '=', only one query param is required. - if operation == "=": - param = f"filter[{path}]" - filters[param] = value - # Else we need a query param for the path, operation, and value. - else: - # TODO: Use a unique identifier instead of using "condition" - # otherwise the same path cannot be filtered multiple times. - base_param = f"filter[{path}_{operation.lower()}][condition]" - - path_param = base_param + "[path]" - filters[path_param] = path - - op_param = base_param + "[operator]" - filters[op_param] = operation - - value_param = base_param + "[value]" - if operation in ["IN", "NOT IN", ">", "<", "<>", "BETWEEN"]: - value_param += "[]" - filters[value_param] = value - - return filters diff --git a/farmOS/_sync/subrequests.py b/farmOS/_sync/subrequests.py new file mode 100644 index 0000000..bd6d6c1 --- /dev/null +++ b/farmOS/_sync/subrequests.py @@ -0,0 +1,57 @@ +from typing import List, Optional, Union + +from farmOS.subrequests_model import Format, SubrequestsBlueprint + + +class SubrequestsBase: + """Class for handling subrequests""" + + subrequest_path = "subrequests" + + def __init__(self, client): + self.client = client + + def send( + self, + blueprint: Union[SubrequestsBlueprint, List], + format: Optional[Union[Format, str]] = Format.json, + ): + if isinstance(blueprint, List): + blueprint = SubrequestsBlueprint(blueprint) + + # Modify each sub-request as needed. + for sub in blueprint: + # Build the URI if an endpoint is provided. + if sub.uri is None and sub.endpoint is not None: + sub.uri = sub.endpoint + + # Set the endpoint to None so it is not included in the serialized subrequest. + sub.endpoint = None + + # Auto populate headers for each sub-request. + if "Accept" not in sub.headers: + sub.headers["Accept"] = "application/vnd.api+json" + if sub.body is not None and "Content-Type" not in sub.headers: + sub.headers["Content-Type"] = "application/vnd.api+json" + + params = {} + if format == Format.json.value: + params = {"_format": "json"} + + # Generate the json to send. It is important to use the .model_dump_json() method + # of the model for correct serialization. + blueprint_json = blueprint.model_dump_json(exclude_none=True) + + response = self.client.request( + method="POST", + url=self.subrequest_path, + params=params, + headers={"Content-Type": "application/json"}, + content=blueprint_json, + ) + + # Return a json response if requested. + if format == Format.json.value: + return response.json() + + return response diff --git a/farmOS/client.py b/farmOS/client.py deleted file mode 100644 index 916a055..0000000 --- a/farmOS/client.py +++ /dev/null @@ -1,259 +0,0 @@ -import logging -from urllib.parse import parse_qs, urlparse - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -class BaseAPI: - """Base class for API methods - - This class includes basic implementations of the - get(), send() and delete() methods used in the API client. - - Keyword Arguments: - session: APISession for the farmOS instance - entity_type: String, used to set the entity type in the path - of all requests used with the API - """ - - def __init__(self, session, entity_type=None): - self.session = session - self.entity_type = entity_type - self.filters = {} - - def _get_single_record_data(self, id): - """Retrieve one record given the record ID""" - # Set path to return record type by specific ID - path = self.entity_type + "/" + str(id) + ".json" - - logger.debug( - "Getting single record data for id: %s of entity type: %s", - id, - self.entity_type, - ) - response = self.session.http_request(path=path) - - return response.json() - - def _get_record_data(self, filters): - """Retrieve one page of raw record data from the farmOS API.""" - # Set path to return record type + filters - path = self.entity_type + ".json" - # Combine instance filters and filters from the method call - filters = {**self.filters, **filters} - - response = self.session.http_request(path=path, params=filters) - - # Return object - data = {} - - response = response.json() - if "list" in response: - data["list"] = response["list"] - data["page"] = { - "self": _parse_api_page(url=response["self"]), - "first": _parse_api_page(url=response["first"]), - "last": _parse_api_page(url=response["last"]), - } - - return data - - def _get_all_record_data(self, filters, page=0, list=None): - """Recursive function to retrieve multiple pages of raw record data from the farmOS API.""" - if list is None: - list = [] - - filters["page"] = page - - logger.debug( - "Getting page: %s of record data of entity type: %s", - filters["page"], - self.entity_type, - ) - data = self._get_record_data(filters=filters) - - # Append record data to list of all requested data - if "list" in data: - list = list + data["list"] - - # Check to see if there are more pages - if "page" in data: - last_page = data["page"]["last"] - # Last page, return the list - if last_page == page: - data = { - "page": { - "first": data["page"]["first"], - "last": data["page"]["last"], - }, - "list": list, - } - return data - # Recursive call, get the next page - else: - return self._get_all_record_data( - list=list, page=(page + 1), filters=filters - ) - - def _get_records(self, filters=None): - """Helper function that checks to retrieve one record, one page or multiple pages of farmOS records""" - if filters is None: - filters = {} - - # Determine if filters is an int (id) or dict (filters object) - if isinstance(filters, int) or isinstance(filters, str): - return self._get_single_record_data(filters) - elif isinstance(filters, dict): - # Check if the caller requests a specific page - if "page" in filters: - logger.debug( - "Getting page: %s of record data of entity type: %s", - filters["page"], - self.entity_type, - ) - return self._get_record_data(filters=filters) - else: - logger.debug( - "Getting all record data of entity type: %s", self.entity_type - ) - return self._get_all_record_data(filters=filters) - - def get(self, filters=None): - """Simple get method""" - - data = self._get_records(filters=filters) - - return data - - def send(self, payload): - options = {"json": payload} - - # If an ID is included, update the record - id = payload.pop("id", None) - if id: - logger.debug("Updating record id: of entity type: %s", id, self.entity_type) - path = self.entity_type + "/" + str(id) - response = self.session.http_request( - method="PUT", path=path, options=options - ) - # If no ID is included, create a new record - else: - logger.debug("Creating record of entity type: %s", self.entity_type) - path = self.entity_type - response = self.session.http_request( - method="POST", path=path, options=options - ) - - # Handle response from POST requests - if response.status_code == 201: - logger.debug("Record created.") - return response.json() - - # Handle response from PUT requests - if response.status_code == 200: - logger.debug("Record updated.") - # farmOS returns no response data for PUT requests - # response_data = response.json() - - # Hard code the entity ID, path, and resource - entity_data = { - "id": id, - "uri": path, - "resource": self.entity_type, - } - return entity_data - - def delete(self, id): - logger.debug("Deleted record id: %s of entity type: %s", id, self.entity_type) - path = self.entity_type + "/" + str(id) - response = self.session.http_request(method="DELETE", path=path) - - return response - - -class TermAPI(BaseAPI): - """API for interacting with farm Terms""" - - def __init__(self, session): - # Define 'taxonomy_term' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="taxonomy_term") - - def get(self, filters=None): - """Get method that supports a bundle name as the 'filter' parameter""" - - # Check if filters parameter is a str - if isinstance(filters, str): - # Add filters to instance requests.session filter dict with keyword 'bundle' - self.filters["bundle"] = filters - # Reset filters to empty dict - filters = {} - - data = self._get_records(filters=filters) - - return data - - -class LogAPI(BaseAPI): - """API for interacting with farm logs""" - - def __init__(self, session): - # Define 'log' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="log") - - -class AssetAPI(BaseAPI): - """API for interacting with farm assets""" - - def __init__(self, session): - # Define 'farm_asset' as the farmOS API entity endpoint - super().__init__(session=session, entity_type="farm_asset") - - -class AreaAPI(TermAPI): - """API for interacting with farm areas, a subset of farm terms""" - - def __init__(self, session): - super().__init__(session=session) - self.filters["bundle"] = "farm_areas" - - def get(self, filters=None): - """Retrieve raw record data from the farmOS API. - - Override get() from BaseAPI to support TID (Taxonomy ID) - rather than record ID - """ - - # Determine if filters is an int (tid) or dict (filters object) - if isinstance(filters, int) or isinstance(filters, str): - tid = str(filters) - # Add tid to filters object - filters = { - "tid": tid, - } - - data = self._get_records(filters=filters) - - return data - - -def info(session): - """Retrieve info about the farmOS server.""" - - logger.debug("Retrieving farmOS server info.") - response = session.http_request("farm.json") - return response.json() - - -def _parse_api_page(url): - """Helper function that returns page numbers from the API response. - - The farmOS API returns URLs for the self, first and last pages of records - in requests that return many records. This function helps parse a raw page - number from the returned URL. - """ - - parsed_url = urlparse(url) - page_num = parse_qs(parsed_url.query)["page"][0] - - return int(page_num) diff --git a/farmOS/filter.py b/farmOS/filter.py new file mode 100644 index 0000000..5d04611 --- /dev/null +++ b/farmOS/filter.py @@ -0,0 +1,34 @@ +def filter(path, value, operation="="): + """Helper method to build JSONAPI filter query params.""" + + # TODO: Validate path, value, operation? + # TODO: Support filter groups. + # TODO: Support revision filters, are these edge cases? + # TODO: Support "pagination" query params? Separate method? + # TODO: Support "sort" query params? Separate method? + # TODO: Support "includes" query params? Separate method? + + filters = {} + + # If the operation is '=', only one query param is required. + if operation == "=": + param = f"filter[{path}]" + filters[param] = value + # Else we need a query param for the path, operation, and value. + else: + # TODO: Use a unique identifier instead of using "condition" + # otherwise the same path cannot be filtered multiple times. + base_param = f"filter[{path}_{operation.lower()}][condition]" + + path_param = base_param + "[path]" + filters[path_param] = path + + op_param = base_param + "[operator]" + filters[op_param] = operation + + value_param = base_param + "[value]" + if operation in ["IN", "NOT IN", ">", "<", "<>", "BETWEEN"]: + value_param += "[]" + filters[value_param] = value + + return filters diff --git a/farmOS/session.py b/farmOS/session.py deleted file mode 100644 index 1f3f2d8..0000000 --- a/farmOS/session.py +++ /dev/null @@ -1,168 +0,0 @@ -import logging - -from oauthlib.oauth2 import LegacyApplicationClient -from requests_oauthlib import OAuth2Session - -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - - -class OAuthSession(OAuth2Session): - """OAuthSession uses OAuth2 to authenticate with farmOS""" - - def __init__( - self, - hostname, - client_id, - client_secret=None, - scope=None, - token=None, - token_url=None, - authorization_url=None, - content_type="application/vnd.api+json", - token_updater=None, - *args, - **kwargs, - ): - # Default to the "farm_manager" scope. - if scope is None: - scope = "farm_manager" - - # Create a dictionary of credentials required to pass along with Refresh Tokens - # Required to generate a new access token - auto_refresh_kwargs = {"client_id": client_id, "client_secret": client_secret} - - super().__init__( - token=token, - client=LegacyApplicationClient(client_id=client_id), - auto_refresh_url=token_url, - auto_refresh_kwargs=auto_refresh_kwargs, - token_updater=token_updater, - scope=scope, - ) - - # Save values for later use. - self._token_url = token_url - self._authorization_base_url = authorization_url - self._content_type = content_type - self._client_id = client_id - self._client_secret = client_secret - self.hostname = hostname - - def authorize(self, username, password, scope=None): - """Authorize with the farmOS OAuth server.""" - - token = self.token - - logger.debug("Retrieving new OAuth Token.") - - # Ask for username if not provided. - if username is None: - from getpass import getpass - - username = getpass("Enter username: ") - - # Ask for password if not provided. - if password is None: - from getpass import getpass - - password = getpass("Enter password: ") - - # Use default scope if none provided. - if scope is None: - scope = self.scope - - token = self.fetch_token( - token_url=self._token_url, - client_id=self._client_id, - client_secret=self._client_secret, - username=username, - password=password, - scope=scope, - ) - - logger.debug("Fetched OAuth Access Token %s", token) - - # Save the token. - logger.debug("Saving token with token_updater utility.") - if self.token_updater is not None: - self.token_updater(token) - - return token - - def http_request(self, path, method="GET", options=None, params=None, headers=None): - """Raw HTTP request helper function. - - Keyword arguments: - :param path: the URL path. - :param method: the HTTP method. - :param options: a dictionary of data and parameters to pass on to the request. - :param params: URL query parameters. - :return: requests response object. - """ - - # Strip protocol, hostname, leading/trailing slashes, and whitespace from the path. - path = path.strip("/") - path = path.strip() - - # Assemble the URL. - url = f"{self.hostname}/{path}" - return self._http_request( - url=url, method=method, options=options, params=params, headers=headers - ) - - def _http_request(self, url, method="GET", options=None, params=None, headers=None): - # Automatically follow redirects, unless this is a POST request. - # The Python requests library converts POST to GET during a redirect. - # Allow this to be overridden in options. - allow_redirects = True - if method in ["POST", "PUT"]: - allow_redirects = False - if options and "allow_redirects" in options: - allow_redirects = options["allow_redirects"] - - if headers is None: - headers = {} - - # If there is data to be sent, include it. - data = None - if options and "data" in options: - data = options["data"] - headers["Content-Type"] = self._content_type - - # If there is a json data to be sent, include it. - json = None - if options and "json" in options: - json = options["json"] - if "Content-Type" not in headers: - headers["Content-Type"] = self._content_type - - # Perform the request. - response = self.request( - method, - url, - headers=headers, - allow_redirects=allow_redirects, - data=data, - json=json, - params=params, - ) - - # If this is a POST request, and a redirect occurred, attempt to re-POST. - redirect_codes = [300, 301, 302, 303, 304, 305, 306, 307, 308] - if method in ["POST", "PUT"] and response.status_code in redirect_codes: - if response.headers["Location"]: - response = self.request( - method, - response.headers["Location"], - allow_redirects=True, - data=data, - json=json, - params=params, - ) - - # Raise exception if error. - response.raise_for_status() - - # Return the response. - return response diff --git a/farmOS/subrequests.py b/farmOS/subrequests.py deleted file mode 100644 index 60f2073..0000000 --- a/farmOS/subrequests.py +++ /dev/null @@ -1,138 +0,0 @@ -import json -from enum import Enum -from typing import Any, Dict, List, Optional, Union - -from pydantic import BaseModel, Field, validator - -from .session import OAuthSession - -# Subrequests model derived from provided JSON Schema -# https://git.drupalcode.org/project/subrequests/-/blob/3.x/schema.json - - -class Action(Enum): - view = "view" - create = "create" - update = "update" - replace = "replace" - delete = "delete" - exists = "exists" - discover = "discover" - noop = "noop" - - -class Subrequest(BaseModel): - action: Action = Field( - ..., - description="The action intended for the request. Each action can resolve into a different HTTP method.", - title="Action", - ) - endpoint: Optional[str] = Field( - None, - description="The API endpoint to request. The base URL will be added automatically.", - title="Endpoint", - ) - uri: Optional[str] = Field( - None, description="The URI where to make the subrequest.", title="URI" - ) - requestId: Optional[str] = Field( - None, - description="ID other requests can use to reference this request.", - title="Request ID", - ) - body: Optional[Union[str, dict]] = Field( - None, - description="The JSON encoded body payload for HTTP requests send a body.", - title="Body", - ) - headers: Optional[Dict[str, Any]] = Field( - {}, description="HTTP headers to be sent with the request.", title="Headers" - ) - waitFor: Optional[List[str]] = Field( - None, - description="ID of other requests that this request depends on.", - title="Parent ID", - ) - - @validator("uri", pre=True, always=True) - def check_uri_or_endpoint(cls, uri, values): - endpoint = values.get("endpoint", None) - if uri is None and endpoint is None: - raise ValueError("Either uri or endpoint is required.") - return uri - - @validator("body", pre=True) - def serialize_body(cls, body): - if not isinstance(body, str): - return json.dumps(body) - return body - - -class SubrequestsBlueprint(BaseModel): - __root__: List[Subrequest] = Field( - ..., - description="Describes the subrequests payload format.", - title="Subrequests format", - ) - - -class Format(str, Enum): - html = "html" - json = "json" - - -class SubrequestsBase: - """Class for handling subrequests""" - - subrequest_path = "subrequests" - - def __init__(self, session: OAuthSession): - self.session = session - - def send( - self, - blueprint: Union[SubrequestsBlueprint, List], - format: Optional[Union[Format, str]] = Format.json, - ): - if isinstance(blueprint, List): - blueprint = SubrequestsBlueprint.parse_obj(blueprint) - - # Modify each sub-request as needed. - for sub in blueprint.__root__: - # Build the URI if an endpoint is provided. - if sub.uri is None and sub.endpoint is not None: - sub.uri = f"{self.session.hostname}/{sub.endpoint}" - - # Set the endpoint to None so it is not included in the serialized subrequest. - sub.endpoint = None - - # Auto populate headers for each sub-request. - if "Accept" not in sub.headers: - sub.headers["Accept"] = "application/vnd.api+json" - if sub.body is not None and "Content-Type" not in sub.headers: - sub.headers["Content-Type"] = "application/vnd.api+json" - - headers = {"Content-Type": "application/json"} - - params = {} - if format == Format.json.value: - params = {"_format": "json"} - - # Generate the json to send. It is important to use the .json() method - # of the model for correct serialization. - json = blueprint.json(exclude_none=True) - options = {"data": json} - - response = self.session.http_request( - method="POST", - path=self.subrequest_path, - options=options, - params=params, - headers=headers, - ) - - # Return a json response if requested. - if format == Format.json.value: - return response.json() - - return response diff --git a/farmOS/subrequests_model.py b/farmOS/subrequests_model.py new file mode 100644 index 0000000..4679bbd --- /dev/null +++ b/farmOS/subrequests_model.py @@ -0,0 +1,81 @@ +import json +from enum import Enum +from typing import Any, Dict, Iterator, List, Optional, Union + +from pydantic import BaseModel, Field, RootModel, field_validator, model_validator + +# Subrequests model derived from provided JSON Schema +# https://git.drupalcode.org/project/subrequests/-/blob/3.x/schema.json + + +class Action(Enum): + view = "view" + create = "create" + update = "update" + replace = "replace" + delete = "delete" + exists = "exists" + discover = "discover" + noop = "noop" + + +class Subrequest(BaseModel): + action: Action = Field( + ..., + description="The action intended for the request. Each action can resolve into a different HTTP method.", + title="Action", + ) + endpoint: Optional[str] = Field( + None, + description="The API endpoint to request. The base URL will be added automatically.", + title="Endpoint", + ) + uri: Optional[str] = Field( + None, description="The URI where to make the subrequest.", title="URI" + ) + requestId: Optional[str] = Field( + None, + description="ID other requests can use to reference this request.", + title="Request ID", + ) + body: Optional[Union[str, dict]] = Field( + None, + description="The JSON encoded body payload for HTTP requests send a body.", + title="Body", + ) + headers: Optional[Dict[str, Any]] = Field( + {}, description="HTTP headers to be sent with the request.", title="Headers" + ) + waitFor: Optional[List[str]] = Field( + None, + description="ID of other requests that this request depends on.", + title="Parent ID", + ) + + @model_validator(mode="after") + def check_uri_or_endpoint(self): + # endpoint = values.get("endpoint", None) + if self.uri is None and self.endpoint is None: + raise ValueError("Either uri or endpoint is required.") + return self.uri + + @field_validator("body") + def serialize_body(cls, body): + if not isinstance(body, str): + return json.dumps(body) + return body + + +class SubrequestsBlueprint(RootModel): + root: List + + def __iter__(self) -> Iterator[Subrequest]: + return iter(self.root) + + def __getitem__(self, item) -> Subrequest: + return self.root[item] + + +class Format(str, Enum): + html = "html" + json = "json" diff --git a/farmOS/utils/unasync.py b/farmOS/utils/unasync.py new file mode 100644 index 0000000..4918c34 --- /dev/null +++ b/farmOS/utils/unasync.py @@ -0,0 +1,74 @@ +#!venv/bin/python +import os +import re +import sys + +# Copied from encode/httpcore unasync.py +# https://github.com/encode/httpcore/blob/master/scripts/unasync.py +# Subs modified for farmOS.py + +SUBS = [ + ("from farmOS._async", "from farmOS._sync"), + ("AsyncClient", "Client"), + ("Async([A-Z][A-Za-z0-9_]*)", r"\2"), + ("async def", "def"), + ("async with", "with"), + ("async for", "for"), + ("await ", ""), + ("@pytest.mark.anyio", ""), +] +COMPILED_SUBS = [ + (re.compile(r"(^|\b)" + regex + r"($|\b)"), repl) for regex, repl in SUBS +] + + +def unasync_line(line): + for regex, repl in COMPILED_SUBS: + line = re.sub(regex, repl, line) + return line + + +def unasync_file(in_path, out_path): + with open(in_path, "r") as in_file: + with open(out_path, "w", newline="") as out_file: + for line in in_file.readlines(): + line = unasync_line(line) + out_file.write(line) + + +def unasync_file_check(in_path, out_path): + with open(in_path, "r") as in_file: + with open(out_path, "r") as out_file: + for in_line, out_line in zip(in_file.readlines(), out_file.readlines()): + expected = unasync_line(in_line) + if out_line != expected: + print(f"unasync mismatch between {in_path!r} and {out_path!r}") + print(f"Async code: {in_line!r}") + print(f"Expected sync code: {expected!r}") + print(f"Actual sync code: {out_line!r}") + sys.exit(1) + + +def unasync_dir(in_dir, out_dir, check_only=False): + for dirpath, dirnames, filenames in os.walk(in_dir): + for filename in filenames: + if not filename.endswith(".py"): + continue + rel_dir = os.path.relpath(dirpath, in_dir) + in_path = os.path.normpath(os.path.join(in_dir, rel_dir, filename)) + out_path = os.path.normpath(os.path.join(out_dir, rel_dir, filename)) + print(in_path, "->", out_path) + if check_only: + unasync_file_check(in_path, out_path) + else: + unasync_file(in_path, out_path) + + +def main(): + check_only = "--check" in sys.argv + unasync_dir("farmOS/_async", "farmOS/_sync", check_only=check_only) + unasync_dir("tests/_async", "tests/_sync", check_only=check_only) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index bf48467..f39d626 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ "pytest-runner", ] -install_requires = ["requests-oauthlib~=1.3.1", "pydantic~=1.7.3"] +install_requires = ["httpx~=0.26", "httpx_auth~=0.20", "pydantic~=2.0"] extras_require = { "test": ["pytest~=7.0", "black~=23.0", "setuptools~=68.0"], diff --git a/tests/_async/__init__.py b/tests/_async/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_async/functional/__init__.py b/tests/_async/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_async/functional/test_asset.py b/tests/_async/functional/test_asset.py new file mode 100644 index 0000000..365a4f8 --- /dev/null +++ b/tests/_async/functional/test_asset.py @@ -0,0 +1,75 @@ +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +# Create a test asset +test_asset = { + "type": "equipment", + "payload": { + "attributes": { + "name": "Tractor", + "manufacturer": "Allis-Chalmers", + "model": "G", + "serial_number": "1234567890", + } + }, +} + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_asset_crud(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.asset.send(test_asset["type"], test_asset["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_asset + test_asset["id"] = post_response["data"]["id"] + + # Get the asset by ID. + get_response = await farm.asset.get_id(test_asset["type"], test_asset["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_asset["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_asset_changes = { + "id": test_asset["id"], + "attributes": { + "name": "Old tractor", + "status": "archived", + }, + } + + # Update the asset. + patch_response = await farm.asset.send(test_asset["type"], test_asset_changes) + # Get the asset by ID. + get_response = await farm.asset.get_id(test_asset["type"], test_asset["id"]) + + # Assert that both responses have the correct values. + for response in [patch_response, get_response]: + for key, value in test_asset_changes["attributes"].items(): + assert response["data"]["attributes"][key] == value + + # Delete the asset. + deleted_response = await farm.asset.delete(test_asset["type"], test_asset["id"]) + assert deleted_response.status_code == 204 + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_asset_get(farm_auth, test_assets): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get one page of assets. + response = await farm.asset.get(test_asset["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all assets. + all_assets = [asset async for asset in farm.asset.iterate(test_asset["type"])] + assert len(all_assets) > len(response["data"]) diff --git a/tests/_async/functional/test_auth.py b/tests/_async/functional/test_auth.py new file mode 100644 index 0000000..830c62c --- /dev/null +++ b/tests/_async/functional/test_auth.py @@ -0,0 +1,108 @@ +import os + +import pytest +from httpx_auth import InvalidGrantRequest, OAuth2ResourceOwnerPasswordCredentials + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +# Variables for testing. +FARMOS_HOSTNAME = os.getenv("FARMOS_HOSTNAME") +FARMOS_OAUTH_USERNAME = os.getenv("FARMOS_OAUTH_USERNAME") +FARMOS_OAUTH_PASSWORD = os.getenv("FARMOS_OAUTH_PASSWORD") +FARMOS_OAUTH_CLIENT_ID = os.getenv("FARMOS_OAUTH_CLIENT_ID", "farm") +FARMOS_OAUTH_CLIENT_SECRET = os.getenv("FARMOS_OAUTH_CLIENT_SECRET", None) + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_invalid_login(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username="username", + password="password", + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_invalid_client_id(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id="bad_client", + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@pytest.mark.skip( + reason="simple_oauth seems to accept any secret if none is configured on the client." +) +@farmOS_testing_server +async def test_invalid_client_secret(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret="bad secret", + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_invalid_scope(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="bad_scope", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + await farm.info() + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_valid_login(): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = AsyncFarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + assert ".ey" in token + # Expiration should be 3600 but can fluctuate during tests. + assert 3590 < expires_in < 3610 + assert refresh is not None + + # Check that the user info is provided at farm.info. + info = await farm.info() + assert "meta" in info + assert "links" in info["meta"] + assert "me" in info["meta"]["links"] diff --git a/tests/_async/functional/test_info.py b/tests/_async/functional/test_info.py new file mode 100644 index 0000000..0820187 --- /dev/null +++ b/tests/_async/functional/test_info.py @@ -0,0 +1,26 @@ +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_get_farm_info(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + info = await farm.info() + + assert "links" in info + assert "meta" in info + + # Test user links info. + assert "links" in info["meta"] + assert "me" in info["meta"]["links"] + + # Test farm info. + assert "farm" in info["meta"] + farm_info = info["meta"]["farm"] + assert "name" in farm_info + assert "url" in farm_info + assert "version" in farm_info diff --git a/tests/_async/functional/test_log.py b/tests/_async/functional/test_log.py new file mode 100644 index 0000000..0703c05 --- /dev/null +++ b/tests/_async/functional/test_log.py @@ -0,0 +1,78 @@ +from datetime import datetime, timezone + +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +curr_time = datetime.now(timezone.utc) +timestamp = curr_time.isoformat(timespec="seconds") + +# Create a test log +test_log = { + "type": "observation", + "payload": { + "attributes": { + "name": "Testing from farmOS.py", + "timestamp": timestamp, + }, + }, +} + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_log_crud(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.log.send(test_log["type"], test_log["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_log + test_log["id"] = post_response["data"]["id"] + + # Get the log by ID. + get_response = await farm.log.get_id(test_log["type"], test_log["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_log["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_log_changes = { + "id": test_log["id"], + "attributes": { + "name": "Updated Log Name", + }, + } + # Update the log. + patch_response = await farm.log.send(test_log["type"], test_log_changes) + # Get the log by ID. + get_response = await farm.log.get_id(test_log["type"], test_log["id"]) + + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_log_changes["attributes"]["name"] + ) + + # Delete the log. + deleted_response = await farm.log.delete(test_log["type"], test_log["id"]) + assert deleted_response.status_code == 204 + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_log_get(farm_auth, test_logs): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get one page of logs. + response = await farm.log.get(test_log["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all logs. + all_logs = [log async for log in farm.log.iterate(test_log["type"])] + assert len(all_logs) > len(response["data"]) diff --git a/tests/_async/functional/test_resource.py b/tests/_async/functional/test_resource.py new file mode 100644 index 0000000..4e37576 --- /dev/null +++ b/tests/_async/functional/test_resource.py @@ -0,0 +1,50 @@ +import pytest +from httpx_auth import OAuth2 + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +# todo: Expand these tests to include a CRUD. Currently limited by user permissions. + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_user_update_self(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get current user ID. + info_response = await farm.info() + user_id = info_response["meta"]["links"]["me"]["meta"]["id"] + + user_changes = { + "id": user_id, + "attributes": {"timezone": "UTC"}, + } + # Update the user. + patch_response = await farm.resource.send("user", payload=user_changes) + + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + OAuth2.token_cache._add_access_token(state, token, expires_in) + + # Get the user by ID. + get_response = await farm.resource.get_id("user", resource_id=user_id) + + # Assert that both responses have the updated display_name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["timezone"] + == user_changes["attributes"]["timezone"] + ) + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_user_iterate(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + all_users = [ + user + async for user in farm.resource.iterate("user", params={"page[limit]": 1}) + ] + assert len(all_users) > 1 diff --git a/tests/_async/functional/test_subrequests.py b/tests/_async/functional/test_subrequests.py new file mode 100644 index 0000000..b2a0e5a --- /dev/null +++ b/tests/_async/functional/test_subrequests.py @@ -0,0 +1,151 @@ +import json +from datetime import datetime, timezone + +import pytest + +from farmOS import AsyncFarmClient +from farmOS.subrequests_model import Action, Format, Subrequest, SubrequestsBlueprint +from tests.conftest import farmOS_testing_server + +curr_time = datetime.now(timezone.utc) +timestamp = curr_time.isoformat(timespec="seconds") + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_subrequests(farm_auth): + plant_type = { + "data": { + "type": "taxonomy_term--plant_type", + "attributes": {"name": "New plant type"}, + } + } + + new_plant_type = Subrequest( + action=Action.create, + requestId="create-plant-type", + endpoint="api/taxonomy_term/plant_type", + body=plant_type, + ) + + plant = { + "data": { + "type": "asset--plant", + "attributes": { + "name": "My new plant", + }, + "relationships": { + "plant_type": { + "data": [ + { + "type": "taxonomy_term--plant_type", + "id": "{{create-plant-type.body@$.data.id}}", + } + ] + } + }, + } + } + new_asset = Subrequest( + action=Action.create, + requestId="create-asset", + waitFor=["create-plant-type"], + endpoint="api/asset/plant", + body=plant, + ) + + log = { + "data": { + "type": "log--seeding", + "attributes": { + "name": "Seeding my new plant", + }, + "relationships": { + "asset": { + "data": [ + { + "type": "asset--plant", + "id": "{{create-asset.body@$.data.id}}", + } + ] + } + }, + } + } + new_log = Subrequest( + action=Action.create, + requestId="create-log", + waitFor=["create-asset"], + endpoint="api/log/seeding", + body=log, + ) + + # Create a blueprint object + blueprint = SubrequestsBlueprint([new_plant_type, new_asset, new_log]) + + # Send the blueprint. + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.subrequests.send(blueprint, format=Format.json) + + # Expected results. + response_keys = { + "create-plant-type": { + "attributes": {"name": "New plant type"}, + "relationships": {}, + }, + "create-asset#body{0}": { + "attributes": { + "name": "My new plant", + }, + "relationships": { + "plant_type": [ + { + "type": "taxonomy_term--plant_type", + "id": "{{create-plant-type.body@$.data.id}}", + } + ] + }, + }, + "create-log#body{0}": { + "attributes": { + "name": "Seeding my new plant", + }, + "relationships": { + "asset": [ + { + "type": "asset--plant", + "id": "{{create-asset.body@$.data.id}}", + } + ] + }, + }, + } + for response_key, expected_data in response_keys.items(): + # Test that each response succeeded. + assert response_key in post_response + assert "headers" in post_response[response_key] + assert 201 == int(post_response[response_key]["headers"]["status"][0]) + + # Test that each resource was created. + assert "body" in post_response[response_key] + body = json.loads(post_response[response_key]["body"]) + resource_id = body["data"]["id"] + entity_type, bundle = body["data"]["type"].split("--") + created_resource = await farm.resource.get_id(entity_type, bundle, resource_id) + + assert created_resource is not None + assert created_resource["data"]["id"] == resource_id + + # Test for correct attributes. + for key, value in expected_data["attributes"].items(): + assert created_resource["data"]["attributes"][key] == value + + # Test for correct relationships. + for field_name, relationships in expected_data["relationships"].items(): + relationship_field = created_resource["data"]["relationships"][ + field_name + ] + assert len(relationship_field["data"]) == len(relationships) + for resource in relationships: + assert relationship_field["data"][0]["type"] == resource["type"] diff --git a/tests/_async/functional/test_term.py b/tests/_async/functional/test_term.py new file mode 100644 index 0000000..e7ce89a --- /dev/null +++ b/tests/_async/functional/test_term.py @@ -0,0 +1,65 @@ +import pytest + +from farmOS import AsyncFarmClient +from tests.conftest import farmOS_testing_server + +test_term = { + "type": "plant_type", + "payload": {"attributes": {"name": "Corn"}}, +} + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_term_crud(farm_auth): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + post_response = await farm.term.send(test_term["type"], test_term["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_term + test_term["id"] = post_response["data"]["id"] + + # Get the term by ID. + get_response = await farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_term["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_term_changes = { + "id": test_term["id"], + "attributes": {"name": "Updated corn"}, + } + # Update the term. + patch_response = await farm.term.send(test_term["type"], test_term_changes) + # Get the term by ID. + get_response = await farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_term_changes["attributes"]["name"] + ) + + # Delete the term. + deleted_response = await farm.term.delete(test_term["type"], test_term["id"]) + assert deleted_response.status_code == 204 + + +@pytest.mark.anyio +@farmOS_testing_server +async def test_term_get(farm_auth, test_terms): + hostname, auth = farm_auth + async with AsyncFarmClient(hostname, auth=auth) as farm: + # Get one page of plant_type terms. + response = await farm.term.get(test_term["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all plant_type terms. + all_terms = [term async for term in farm.term.iterate(test_term["type"])] + assert len(all_terms) > len(response["data"]) diff --git a/tests/_sync/__init__.py b/tests/_sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_sync/functional/__init__.py b/tests/_sync/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_sync/functional/test_asset.py b/tests/_sync/functional/test_asset.py new file mode 100644 index 0000000..ea322f3 --- /dev/null +++ b/tests/_sync/functional/test_asset.py @@ -0,0 +1,75 @@ +import pytest + +from farmOS import FarmClient +from tests.conftest import farmOS_testing_server + +# Create a test asset +test_asset = { + "type": "equipment", + "payload": { + "attributes": { + "name": "Tractor", + "manufacturer": "Allis-Chalmers", + "model": "G", + "serial_number": "1234567890", + } + }, +} + + + +@farmOS_testing_server +def test_asset_crud(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.asset.send(test_asset["type"], test_asset["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_asset + test_asset["id"] = post_response["data"]["id"] + + # Get the asset by ID. + get_response = farm.asset.get_id(test_asset["type"], test_asset["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_asset["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_asset_changes = { + "id": test_asset["id"], + "attributes": { + "name": "Old tractor", + "status": "archived", + }, + } + + # Update the asset. + patch_response = farm.asset.send(test_asset["type"], test_asset_changes) + # Get the asset by ID. + get_response = farm.asset.get_id(test_asset["type"], test_asset["id"]) + + # Assert that both responses have the correct values. + for response in [patch_response, get_response]: + for key, value in test_asset_changes["attributes"].items(): + assert response["data"]["attributes"][key] == value + + # Delete the asset. + deleted_response = farm.asset.delete(test_asset["type"], test_asset["id"]) + assert deleted_response.status_code == 204 + + + +@farmOS_testing_server +def test_asset_get(farm_auth, test_assets): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get one page of assets. + response = farm.asset.get(test_asset["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all assets. + all_assets = [asset for asset in farm.asset.iterate(test_asset["type"])] + assert len(all_assets) > len(response["data"]) diff --git a/tests/_sync/functional/test_auth.py b/tests/_sync/functional/test_auth.py new file mode 100644 index 0000000..bc923c3 --- /dev/null +++ b/tests/_sync/functional/test_auth.py @@ -0,0 +1,108 @@ +import os + +import pytest +from httpx_auth import InvalidGrantRequest, OAuth2ResourceOwnerPasswordCredentials + +from farmOS import FarmClient +from tests.conftest import farmOS_testing_server + +# Variables for testing. +FARMOS_HOSTNAME = os.getenv("FARMOS_HOSTNAME") +FARMOS_OAUTH_USERNAME = os.getenv("FARMOS_OAUTH_USERNAME") +FARMOS_OAUTH_PASSWORD = os.getenv("FARMOS_OAUTH_PASSWORD") +FARMOS_OAUTH_CLIENT_ID = os.getenv("FARMOS_OAUTH_CLIENT_ID", "farm") +FARMOS_OAUTH_CLIENT_SECRET = os.getenv("FARMOS_OAUTH_CLIENT_SECRET", None) + + + +@farmOS_testing_server +def test_invalid_login(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username="username", + password="password", + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() + + + +@farmOS_testing_server +def test_invalid_client_id(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id="bad_client", + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() + + + +@pytest.mark.skip( + reason="simple_oauth seems to accept any secret if none is configured on the client." +) +@farmOS_testing_server +def test_invalid_client_secret(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret="bad secret", + scope="farm_manager", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() + + + +@farmOS_testing_server +def test_invalid_scope(): + with pytest.raises(InvalidGrantRequest): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="bad_scope", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + farm.info() + + + +@farmOS_testing_server +def test_valid_login(): + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, + client_id=FARMOS_OAUTH_CLIENT_ID, + client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", + ) + farm = FarmClient(hostname=FARMOS_HOSTNAME, auth=auth) + + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + assert ".ey" in token + # Expiration should be 3600 but can fluctuate during tests. + assert 3590 < expires_in < 3610 + assert refresh is not None + + # Check that the user info is provided at farm.info. + info = farm.info() + assert "meta" in info + assert "links" in info["meta"] + assert "me" in info["meta"]["links"] diff --git a/tests/_sync/functional/test_info.py b/tests/_sync/functional/test_info.py new file mode 100644 index 0000000..00af5ed --- /dev/null +++ b/tests/_sync/functional/test_info.py @@ -0,0 +1,26 @@ +import pytest + +from farmOS import FarmClient +from tests.conftest import farmOS_testing_server + + + +@farmOS_testing_server +def test_get_farm_info(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + info = farm.info() + + assert "links" in info + assert "meta" in info + + # Test user links info. + assert "links" in info["meta"] + assert "me" in info["meta"]["links"] + + # Test farm info. + assert "farm" in info["meta"] + farm_info = info["meta"]["farm"] + assert "name" in farm_info + assert "url" in farm_info + assert "version" in farm_info diff --git a/tests/_sync/functional/test_log.py b/tests/_sync/functional/test_log.py new file mode 100644 index 0000000..fb8fe25 --- /dev/null +++ b/tests/_sync/functional/test_log.py @@ -0,0 +1,78 @@ +from datetime import datetime, timezone + +import pytest + +from farmOS import FarmClient +from tests.conftest import farmOS_testing_server + +curr_time = datetime.now(timezone.utc) +timestamp = curr_time.isoformat(timespec="seconds") + +# Create a test log +test_log = { + "type": "observation", + "payload": { + "attributes": { + "name": "Testing from farmOS.py", + "timestamp": timestamp, + }, + }, +} + + + +@farmOS_testing_server +def test_log_crud(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.log.send(test_log["type"], test_log["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_log + test_log["id"] = post_response["data"]["id"] + + # Get the log by ID. + get_response = farm.log.get_id(test_log["type"], test_log["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_log["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_log_changes = { + "id": test_log["id"], + "attributes": { + "name": "Updated Log Name", + }, + } + # Update the log. + patch_response = farm.log.send(test_log["type"], test_log_changes) + # Get the log by ID. + get_response = farm.log.get_id(test_log["type"], test_log["id"]) + + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_log_changes["attributes"]["name"] + ) + + # Delete the log. + deleted_response = farm.log.delete(test_log["type"], test_log["id"]) + assert deleted_response.status_code == 204 + + + +@farmOS_testing_server +def test_log_get(farm_auth, test_logs): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get one page of logs. + response = farm.log.get(test_log["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all logs. + all_logs = [log for log in farm.log.iterate(test_log["type"])] + assert len(all_logs) > len(response["data"]) diff --git a/tests/_sync/functional/test_resource.py b/tests/_sync/functional/test_resource.py new file mode 100644 index 0000000..ec601b4 --- /dev/null +++ b/tests/_sync/functional/test_resource.py @@ -0,0 +1,50 @@ +import pytest +from httpx_auth import OAuth2 + +from farmOS import FarmClient +from tests.conftest import farmOS_testing_server + +# todo: Expand these tests to include a CRUD. Currently limited by user permissions. + + + +@farmOS_testing_server +def test_user_update_self(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get current user ID. + info_response = farm.info() + user_id = info_response["meta"]["links"]["me"]["meta"]["id"] + + user_changes = { + "id": user_id, + "attributes": {"timezone": "UTC"}, + } + # Update the user. + patch_response = farm.resource.send("user", payload=user_changes) + + # Re-authorize the user after changing their profile. + state, token, expires_in, refresh = farm.auth.request_new_token() + OAuth2.token_cache._add_access_token(state, token, expires_in) + + # Get the user by ID. + get_response = farm.resource.get_id("user", resource_id=user_id) + + # Assert that both responses have the updated display_name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["timezone"] + == user_changes["attributes"]["timezone"] + ) + + + +@farmOS_testing_server +def test_user_iterate(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + all_users = [ + user + for user in farm.resource.iterate("user", params={"page[limit]": 1}) + ] + assert len(all_users) > 1 diff --git a/tests/_sync/functional/test_subrequests.py b/tests/_sync/functional/test_subrequests.py new file mode 100644 index 0000000..edfc4f9 --- /dev/null +++ b/tests/_sync/functional/test_subrequests.py @@ -0,0 +1,151 @@ +import json +from datetime import datetime, timezone + +import pytest + +from farmOS import FarmClient +from farmOS.subrequests_model import Action, Format, Subrequest, SubrequestsBlueprint +from tests.conftest import farmOS_testing_server + +curr_time = datetime.now(timezone.utc) +timestamp = curr_time.isoformat(timespec="seconds") + + + +@farmOS_testing_server +def test_subrequests(farm_auth): + plant_type = { + "data": { + "type": "taxonomy_term--plant_type", + "attributes": {"name": "New plant type"}, + } + } + + new_plant_type = Subrequest( + action=Action.create, + requestId="create-plant-type", + endpoint="api/taxonomy_term/plant_type", + body=plant_type, + ) + + plant = { + "data": { + "type": "asset--plant", + "attributes": { + "name": "My new plant", + }, + "relationships": { + "plant_type": { + "data": [ + { + "type": "taxonomy_term--plant_type", + "id": "{{create-plant-type.body@$.data.id}}", + } + ] + } + }, + } + } + new_asset = Subrequest( + action=Action.create, + requestId="create-asset", + waitFor=["create-plant-type"], + endpoint="api/asset/plant", + body=plant, + ) + + log = { + "data": { + "type": "log--seeding", + "attributes": { + "name": "Seeding my new plant", + }, + "relationships": { + "asset": { + "data": [ + { + "type": "asset--plant", + "id": "{{create-asset.body@$.data.id}}", + } + ] + } + }, + } + } + new_log = Subrequest( + action=Action.create, + requestId="create-log", + waitFor=["create-asset"], + endpoint="api/log/seeding", + body=log, + ) + + # Create a blueprint object + blueprint = SubrequestsBlueprint([new_plant_type, new_asset, new_log]) + + # Send the blueprint. + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.subrequests.send(blueprint, format=Format.json) + + # Expected results. + response_keys = { + "create-plant-type": { + "attributes": {"name": "New plant type"}, + "relationships": {}, + }, + "create-asset#body{0}": { + "attributes": { + "name": "My new plant", + }, + "relationships": { + "plant_type": [ + { + "type": "taxonomy_term--plant_type", + "id": "{{create-plant-type.body@$.data.id}}", + } + ] + }, + }, + "create-log#body{0}": { + "attributes": { + "name": "Seeding my new plant", + }, + "relationships": { + "asset": [ + { + "type": "asset--plant", + "id": "{{create-asset.body@$.data.id}}", + } + ] + }, + }, + } + for response_key, expected_data in response_keys.items(): + # Test that each response succeeded. + assert response_key in post_response + assert "headers" in post_response[response_key] + assert 201 == int(post_response[response_key]["headers"]["status"][0]) + + # Test that each resource was created. + assert "body" in post_response[response_key] + body = json.loads(post_response[response_key]["body"]) + resource_id = body["data"]["id"] + entity_type, bundle = body["data"]["type"].split("--") + created_resource = farm.resource.get_id(entity_type, bundle, resource_id) + + assert created_resource is not None + assert created_resource["data"]["id"] == resource_id + + # Test for correct attributes. + for key, value in expected_data["attributes"].items(): + assert created_resource["data"]["attributes"][key] == value + + # Test for correct relationships. + for field_name, relationships in expected_data["relationships"].items(): + relationship_field = created_resource["data"]["relationships"][ + field_name + ] + assert len(relationship_field["data"]) == len(relationships) + for resource in relationships: + assert relationship_field["data"][0]["type"] == resource["type"] diff --git a/tests/_sync/functional/test_term.py b/tests/_sync/functional/test_term.py new file mode 100644 index 0000000..7d4dee3 --- /dev/null +++ b/tests/_sync/functional/test_term.py @@ -0,0 +1,65 @@ +import pytest + +from farmOS import FarmClient +from tests.conftest import farmOS_testing_server + +test_term = { + "type": "plant_type", + "payload": {"attributes": {"name": "Corn"}}, +} + + + +@farmOS_testing_server +def test_term_crud(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + post_response = farm.term.send(test_term["type"], test_term["payload"]) + assert "id" in post_response["data"] + + # Once created, add 'id' to test_term + test_term["id"] = post_response["data"]["id"] + + # Get the term by ID. + get_response = farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the correct values. + for response in [post_response, get_response]: + for key, value in test_term["payload"]["attributes"].items(): + assert response["data"]["attributes"][key] == value + + test_term_changes = { + "id": test_term["id"], + "attributes": {"name": "Updated corn"}, + } + # Update the term. + patch_response = farm.term.send(test_term["type"], test_term_changes) + # Get the term by ID. + get_response = farm.term.get_id(test_term["type"], test_term["id"]) + + # Assert that both responses have the updated name. + for response in [patch_response, get_response]: + assert ( + response["data"]["attributes"]["name"] + == test_term_changes["attributes"]["name"] + ) + + # Delete the term. + deleted_response = farm.term.delete(test_term["type"], test_term["id"]) + assert deleted_response.status_code == 204 + + + +@farmOS_testing_server +def test_term_get(farm_auth, test_terms): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + # Get one page of plant_type terms. + response = farm.term.get(test_term["type"]) + assert "data" in response + assert "links" in response + assert len(response["data"]) == 50 + + # Get all plant_type terms. + all_terms = [term for term in farm.term.iterate(test_term["type"])] + assert len(all_terms) > len(response["data"]) diff --git a/tests/conftest.py b/tests/conftest.py index 9706ab2..87f6d14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ import os import pytest +from httpx_auth import OAuth2ResourceOwnerPasswordCredentials -import farmOS +from farmOS import FarmClient # Allow testing via http. os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" @@ -27,58 +28,71 @@ ) +@pytest.fixture +def anyio_backend(): + return "asyncio" + + @pytest.fixture(scope="module") -def test_farm(): +def farm_auth(): if valid_oauth_config: - farm = farmOS.farmOS( - hostname=FARMOS_HOSTNAME, + auth = OAuth2ResourceOwnerPasswordCredentials( + token_url=f"{FARMOS_HOSTNAME}/oauth/token", + username=FARMOS_OAUTH_USERNAME, + password=FARMOS_OAUTH_PASSWORD, client_id=FARMOS_OAUTH_CLIENT_ID, client_secret=FARMOS_OAUTH_CLIENT_SECRET, + scope="farm_manager", ) - farm.authorize(username=FARMOS_OAUTH_USERNAME, password=FARMOS_OAUTH_PASSWORD) - return farm + return FARMOS_HOSTNAME, auth @pytest.fixture(scope="module") -def test_logs(test_farm): - log_ids = [] - # Create logs. - for x in range(1, 60): - test_log = { - "type": "observation", - "payload": {"attributes": {"name": "Log #" + str(x)}}, - } - response = test_farm.log.send(test_log["type"], test_log["payload"]) - log_ids.append(response["data"]["id"]) - - return log_ids +def test_logs(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + log_ids = [] + # Create logs. + for x in range(1, 60): + test_log = { + "type": "observation", + "payload": {"attributes": {"name": "Log #" + str(x)}}, + } + response = farm.log.send(test_log["type"], test_log["payload"]) + log_ids.append(response["data"]["id"]) + + return log_ids @pytest.fixture(scope="module") -def test_assets(test_farm): - asset_ids = [] - # Create assets. - for x in range(1, 60): - test_asset = { - "type": "equipment", - "payload": {"attributes": {"name": "Asset #" + str(x)}}, - } - response = test_farm.asset.send(test_asset["type"], test_asset["payload"]) - asset_ids.append(response["data"]["id"]) - - return asset_ids +def test_assets(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + asset_ids = [] + # Create assets. + for x in range(1, 60): + test_asset = { + "type": "equipment", + "payload": {"attributes": {"name": "Asset #" + str(x)}}, + } + response = farm.asset.send(test_asset["type"], test_asset["payload"]) + asset_ids.append(response["data"]["id"]) + + return asset_ids @pytest.fixture(scope="module") -def test_terms(test_farm): - term_ids = [] - # Create terms. - for x in range(1, 60): - test_term = { - "type": "plant_type", - "payload": {"attributes": {"name": "Plant type #" + str(x)}}, - } - response = test_farm.term.send(test_term["type"], test_term["payload"]) - term_ids.append(response["data"]["id"]) - - return term_ids +def test_terms(farm_auth): + hostname, auth = farm_auth + with FarmClient(hostname, auth=auth) as farm: + term_ids = [] + # Create terms. + for x in range(1, 60): + test_term = { + "type": "plant_type", + "payload": {"attributes": {"name": "Plant type #" + str(x)}}, + } + response = farm.term.send(test_term["type"], test_term["payload"]) + term_ids.append(response["data"]["id"]) + + return term_ids diff --git a/tests/functional/test_asset.py b/tests/functional/test_asset.py deleted file mode 100644 index 30aca42..0000000 --- a/tests/functional/test_asset.py +++ /dev/null @@ -1,66 +0,0 @@ -from tests.conftest import farmOS_testing_server - -# Create a test asset -test_asset = { - "type": "equipment", - "payload": { - "attributes": { - "name": "Tractor", - "manufacturer": "Allis-Chalmers", - "model": "G", - "serial_number": "1234567890", - } - }, -} - - -@farmOS_testing_server -def test_asset_crud(test_farm): - post_response = test_farm.asset.send(test_asset["type"], test_asset["payload"]) - assert "id" in post_response["data"] - - # Once created, add 'id' to test_asset - test_asset["id"] = post_response["data"]["id"] - - # Get the asset by ID. - get_response = test_farm.asset.get_id(test_asset["type"], test_asset["id"]) - - # Assert that both responses have the correct values. - for response in [post_response, get_response]: - for key, value in test_asset["payload"]["attributes"].items(): - assert response["data"]["attributes"][key] == value - - test_asset_changes = { - "id": test_asset["id"], - "attributes": { - "name": "Old tractor", - "status": "archived", - }, - } - - # Update the asset. - patch_response = test_farm.asset.send(test_asset["type"], test_asset_changes) - # Get the asset by ID. - get_response = test_farm.asset.get_id(test_asset["type"], test_asset["id"]) - - # Assert that both responses have the correct values. - for response in [patch_response, get_response]: - for key, value in test_asset_changes["attributes"].items(): - assert response["data"]["attributes"][key] == value - - # Delete the asset. - deleted_response = test_farm.asset.delete(test_asset["type"], test_asset["id"]) - assert deleted_response.status_code == 204 - - -@farmOS_testing_server -def test_asset_get(test_farm, test_assets): - # Get one page of assets. - response = test_farm.asset.get(test_asset["type"]) - assert "data" in response - assert "links" in response - assert len(response["data"]) == 50 - - # Get all assets. - all_assets = list(test_farm.asset.iterate(test_asset["type"])) - assert len(all_assets) > len(response["data"]) diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py deleted file mode 100644 index 25a718f..0000000 --- a/tests/functional/test_auth.py +++ /dev/null @@ -1,80 +0,0 @@ -import os -import time - -import pytest -from oauthlib.oauth2 import InvalidClientError, InvalidGrantError, InvalidScopeError -from requests import HTTPError - -from farmOS import farmOS -from tests.conftest import farmOS_testing_server - -# Variables for testing. -FARMOS_HOSTNAME = os.getenv("FARMOS_HOSTNAME") -FARMOS_OAUTH_USERNAME = os.getenv("FARMOS_OAUTH_USERNAME") -FARMOS_OAUTH_PASSWORD = os.getenv("FARMOS_OAUTH_PASSWORD") - - -@farmOS_testing_server -def test_invalid_login(): - with pytest.raises(InvalidGrantError): - farm = farmOS(hostname=FARMOS_HOSTNAME, scope="farm_manager", version=2) - farm.authorize("username", "password") - - -@farmOS_testing_server -def test_invalid_client_id(): - with pytest.raises(InvalidClientError): - farm = farmOS( - hostname=FARMOS_HOSTNAME, - scope="farm_manager", - client_id="bad_client", - version=2, - ) - farm.authorize(FARMOS_OAUTH_USERNAME, FARMOS_OAUTH_PASSWORD) - - -@farmOS_testing_server -@pytest.mark.skip( - reason="simple_oauth seems to accept any secret if none is configured on the client." -) -def test_invalid_client_secret(): - with pytest.raises(InvalidClientError): - farm = farmOS(FARMOS_HOSTNAME, client_id="farm", client_secret="bad_pass") - farm.authorize(FARMOS_OAUTH_USERNAME, FARMOS_OAUTH_PASSWORD) - - -@farmOS_testing_server -def test_invalid_scope(): - with pytest.raises(InvalidScopeError): - farm = farmOS(hostname=FARMOS_HOSTNAME, scope="bad_scope", version=2) - farm.authorize(FARMOS_OAUTH_USERNAME, FARMOS_OAUTH_PASSWORD, scope="bad_scope") - - -@farmOS_testing_server -@pytest.mark.skip(reason="JSONAPI endpoints don't return 403.") -def test_unauthorized_request(test_farm): - with pytest.raises(HTTPError, match=r"403 *."): - farm = farmOS(hostname=FARMOS_HOSTNAME, scope="farm_manager", version=2) - farm.log.get("activity") - - -@farmOS_testing_server -def test_valid_login(test_farm): - token = test_farm.authorize( - username=FARMOS_OAUTH_USERNAME, password=FARMOS_OAUTH_PASSWORD - ) - - assert "access_token" in token - assert "refresh_token" in token - assert "expires_at" in token - assert "expires_in" in token - - # Sleep until the token expires. - time.sleep(token["expires_in"] + 1) - - # Make a request that will trigger a refresh. - # Ensure the request is still authenticated. - info = test_farm.info() - assert "meta" in info - assert "links" in info["meta"] - assert "me" in info["meta"]["links"] diff --git a/tests/functional/test_info.py b/tests/functional/test_info.py deleted file mode 100644 index e6e02ef..0000000 --- a/tests/functional/test_info.py +++ /dev/null @@ -1,23 +0,0 @@ -from tests.conftest import farmOS_testing_server - - -# -# Test farm info method -# -@farmOS_testing_server -def test_get_farm_info(test_farm): - info = test_farm.info() - - assert "links" in info - assert "meta" in info - - # Test user links info. - assert "links" in info["meta"] - assert "me" in info["meta"]["links"] - - # Test farm info. - assert "farm" in info["meta"] - farm_info = info["meta"]["farm"] - assert "name" in farm_info - assert "url" in farm_info - assert "version" in farm_info diff --git a/tests/functional/test_log.py b/tests/functional/test_log.py deleted file mode 100644 index a110b35..0000000 --- a/tests/functional/test_log.py +++ /dev/null @@ -1,69 +0,0 @@ -from datetime import datetime, timezone - -from tests.conftest import farmOS_testing_server - -curr_time = datetime.now(timezone.utc) -timestamp = curr_time.isoformat(timespec="seconds") - -# Create a test log -test_log = { - "type": "observation", - "payload": { - "attributes": { - "name": "Testing from farmOS.py", - "timestamp": timestamp, - }, - }, -} - - -@farmOS_testing_server -def test_log_crud(test_farm): - post_response = test_farm.log.send(test_log["type"], test_log["payload"]) - assert "id" in post_response["data"] - - # Once created, add 'id' to test_log - test_log["id"] = post_response["data"]["id"] - - # Get the log by ID. - get_response = test_farm.log.get_id(test_log["type"], test_log["id"]) - - # Assert that both responses have the correct values. - for response in [post_response, get_response]: - for key, value in test_log["payload"]["attributes"].items(): - assert response["data"]["attributes"][key] == value - - test_log_changes = { - "id": test_log["id"], - "attributes": { - "name": "Updated Log Name", - }, - } - # Update the log. - patch_response = test_farm.log.send(test_log["type"], test_log_changes) - # Get the log by ID. - get_response = test_farm.log.get_id(test_log["type"], test_log["id"]) - - # Assert that both responses have the updated name. - for response in [patch_response, get_response]: - assert ( - response["data"]["attributes"]["name"] - == test_log_changes["attributes"]["name"] - ) - - # Delete the log. - deleted_response = test_farm.log.delete(test_log["type"], test_log["id"]) - assert deleted_response.status_code == 204 - - -@farmOS_testing_server -def test_log_get(test_farm, test_logs): - # Get one page of logs. - response = test_farm.log.get(test_log["type"]) - assert "data" in response - assert "links" in response - assert len(response["data"]) == 50 - - # Get all logs. - all_logs = list(test_farm.log.iterate(test_log["type"])) - assert len(all_logs) > len(response["data"]) diff --git a/tests/functional/test_resource.py b/tests/functional/test_resource.py deleted file mode 100644 index 92be48f..0000000 --- a/tests/functional/test_resource.py +++ /dev/null @@ -1,42 +0,0 @@ -import os - -from tests.conftest import farmOS_testing_server - -FARMOS_OAUTH_USERNAME = os.getenv("FARMOS_OAUTH_USERNAME") -FARMOS_OAUTH_PASSWORD = os.getenv("FARMOS_OAUTH_PASSWORD") - -# todo: Expand these tests to include a CRUD. Currently limited by user permissions. - - -@farmOS_testing_server -def test_user_update_self(test_farm): - # Get current user ID. - info_response = test_farm.info() - user_id = info_response["meta"]["links"]["me"]["meta"]["id"] - - user_changes = { - "id": user_id, - "attributes": {"timezone": "UTC"}, - } - # Update the user. - patch_response = test_farm.resource.send("user", payload=user_changes) - - # Re-authorize the user after changing their profile. - test_farm.authorize(username=FARMOS_OAUTH_USERNAME, password=FARMOS_OAUTH_PASSWORD) - - # Get the user by ID. - get_response = test_farm.resource.get_id("user", resource_id=user_id) - - # Assert that both responses have the updated display_name. - for response in [patch_response, get_response]: - assert ( - response["data"]["attributes"]["timezone"] - == user_changes["attributes"]["timezone"] - ) - - -@farmOS_testing_server -def test_user_iterate(test_farm): - all_users = list(test_farm.resource.iterate("user", params={"page[limit]": 1})) - - assert len(all_users) > 1 diff --git a/tests/functional/test_subrequests.py b/tests/functional/test_subrequests.py deleted file mode 100644 index d423e07..0000000 --- a/tests/functional/test_subrequests.py +++ /dev/null @@ -1,140 +0,0 @@ -import json -from datetime import datetime, timezone - -from farmOS.subrequests import Action, Format, Subrequest, SubrequestsBlueprint -from tests.conftest import farmOS_testing_server - -curr_time = datetime.now(timezone.utc) -timestamp = curr_time.isoformat(timespec="seconds") - - -@farmOS_testing_server -def test_subrequests(test_farm): - plant_type = { - "data": { - "type": "taxonomy_term--plant_type", - "attributes": {"name": "New plant type"}, - } - } - - new_plant_type = Subrequest( - action=Action.create, - requestId="create-plant-type", - endpoint="api/taxonomy_term/plant_type", - body=plant_type, - ) - - plant = { - "data": { - "type": "asset--plant", - "attributes": { - "name": "My new plant", - }, - "relationships": { - "plant_type": { - "data": [ - { - "type": "taxonomy_term--plant_type", - "id": "{{create-plant-type.body@$.data.id}}", - } - ] - } - }, - } - } - new_asset = Subrequest( - action=Action.create, - requestId="create-asset", - waitFor=["create-plant-type"], - endpoint="api/asset/plant", - body=plant, - ) - - log = { - "data": { - "type": "log--seeding", - "attributes": { - "name": "Seeding my new plant", - }, - "relationships": { - "asset": { - "data": [ - { - "type": "asset--plant", - "id": "{{create-asset.body@$.data.id}}", - } - ] - } - }, - } - } - new_log = Subrequest( - action=Action.create, - requestId="create-log", - waitFor=["create-asset"], - endpoint="api/log/seeding", - body=log, - ) - - # Create a blueprint object - blueprint = SubrequestsBlueprint.parse_obj([new_plant_type, new_asset, new_log]) - - # Send the blueprint. - post_response = test_farm.subrequests.send(blueprint, format=Format.json) - - # Expected results. - response_keys = { - "create-plant-type": { - "attributes": {"name": "New plant type"}, - "relationships": {}, - }, - "create-asset#body{0}": { - "attributes": { - "name": "My new plant", - }, - "relationships": { - "plant_type": [ - { - "type": "taxonomy_term--plant_type", - "id": "{{create-plant-type.body@$.data.id}}", - } - ] - }, - }, - "create-log#body{0}": { - "attributes": { - "name": "Seeding my new plant", - }, - "relationships": { - "asset": [ - {"type": "asset--plant", "id": "{{create-asset.body@$.data.id}}"} - ] - }, - }, - } - for response_key, expected_data in response_keys.items(): - # Test that each response succeeded. - assert response_key in post_response - assert "headers" in post_response[response_key] - assert 201 == post_response[response_key]["headers"]["status"][0] - - # Test that each resource was created. - assert "body" in post_response[response_key] - body = json.loads(post_response[response_key]["body"]) - resource_id = body["data"]["id"] - entity_type, bundle = body["data"]["type"].split("--") - created_resource = test_farm.resource.get_id(entity_type, bundle, resource_id) - - assert created_resource is not None - assert created_resource["data"]["id"] == resource_id - - # Test for correct attributes. - for key, value in expected_data["attributes"].items(): - assert created_resource["data"]["attributes"][key] == value - - # Test for correct relationships. - for field_name, relationships in expected_data["relationships"].items(): - relationship_field = created_resource["data"]["relationships"][field_name] - assert len(relationship_field["data"]) == len(relationships) - for resource in relationships: - assert relationship_field["data"][0]["type"] == resource["type"] diff --git a/tests/functional/test_term.py b/tests/functional/test_term.py deleted file mode 100644 index a06c0f4..0000000 --- a/tests/functional/test_term.py +++ /dev/null @@ -1,56 +0,0 @@ -from tests.conftest import farmOS_testing_server - -test_term = { - "type": "plant_type", - "payload": {"attributes": {"name": "Corn"}}, -} - - -@farmOS_testing_server -def test_term_crud(test_farm): - post_response = test_farm.term.send(test_term["type"], test_term["payload"]) - assert "id" in post_response["data"] - - # Once created, add 'id' to test_term - test_term["id"] = post_response["data"]["id"] - - # Get the term by ID. - get_response = test_farm.term.get_id(test_term["type"], test_term["id"]) - - # Assert that both responses have the correct values. - for response in [post_response, get_response]: - for key, value in test_term["payload"]["attributes"].items(): - assert response["data"]["attributes"][key] == value - - test_term_changes = { - "id": test_term["id"], - "attributes": {"name": "Updated corn"}, - } - # Update the term. - patch_response = test_farm.term.send(test_term["type"], test_term_changes) - # Get the term by ID. - get_response = test_farm.term.get_id(test_term["type"], test_term["id"]) - - # Assert that both responses have the updated name. - for response in [patch_response, get_response]: - assert ( - response["data"]["attributes"]["name"] - == test_term_changes["attributes"]["name"] - ) - - # Delete the term. - deleted_response = test_farm.term.delete(test_term["type"], test_term["id"]) - assert deleted_response.status_code == 204 - - -@farmOS_testing_server -def test_term_get(test_farm, test_terms): - # Get one page of plant_type terms. - response = test_farm.term.get(test_term["type"]) - assert "data" in response - assert "links" in response - assert len(response["data"]) == 50 - - # Get all plant_type terms. - all_terms = list(test_farm.term.iterate(test_term["type"])) - assert len(all_terms) > len(response["data"])