Skip to content

Commit

Permalink
Merge cb48970 into d3286fe
Browse files Browse the repository at this point in the history
  • Loading branch information
hakbailey committed Aug 31, 2021
2 parents d3286fe + cb48970 commit 5d614a5
Show file tree
Hide file tree
Showing 13 changed files with 475 additions and 102 deletions.
19 changes: 15 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,21 @@ This project uses `vcrpy <https://vcrpy.readthedocs.io/en/latest/>`_ to create t
TEST_DSPACE_EMAIL=<your test DSpace instance email>
TEST_DSPACE_PASSWORD=<your test DSpace instance password>

2. Run ``make test`` to run the tests. This will run all tests with API calls against *your real* DSpace test instance and record the requests/responses as cassettes.
2. Delete any test cassettes you want to replace. VCR is set to run once, meaning it will not overwrite existing cassettes.

3. Review new cassettes to make sure no sensitive data has been recorded. If it has, add to the vcr functions in ``conftest.py`` to scrub the sensitive data and rerun ``make test`` to confirm.
3. If the tests you want to create cassettes for use the ``test_client`` fixture, you will need to update it. In ``tests/conftest.py``, comment out the ``with my_vcr.use_cassette(...`` line of the ``test_client`` fixture and adjust the indenting so the whole fixture function looks like this::

4. *VERY IMPORTANT*: comment out the ``DSPACE_PYTHON_CLIENT_ENV`` variable in your .env file. This ensures that future local test runs use the cassettes instead of making calls to the real DSpace API.
@pytest.fixture
def test_client(my_vcr, vcr_env):
# with my_vcr.use_cassette("tests/vcr_cassettes/client/login.yaml"):
client = DSpaceClient(vcr_env["url"])
client.login(vcr_env["email"], vcr_env["password"])
return client

5. Run ``make test`` again to confirm that your cassettes are working properly.
4. Run ``make test`` to run the tests. This will run all tests with API calls against *your real* DSpace test instance and record the requests/responses as cassettes.

5. Review new cassettes to make sure no sensitive data has been recorded. If it has, add to the vcr functions in ``conftest.py`` to scrub the sensitive data and rerun ``make test`` to confirm.

6. *VERY IMPORTANT*: comment out the ``DSPACE_PYTHON_CLIENT_ENV`` variable in your .env file. And reset the `test_client` fixture to its previous state. This ensures that future local test runs use the cassettes instead of making calls to the real DSpace API.

7. Run ``make test`` again to confirm that your cassettes are working properly.
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
"sphinx_rtd_theme",
]
autodoc_typehints = "description"
autodoc_default_options = {"inherited-members": True}
napoleon_include_init_with_doc = True
napoleon_attr_annotations = True
napoleon_use_ivar = True

# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
Expand Down
8 changes: 8 additions & 0 deletions docs/source/dspace.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ dspace.client module
:undoc-members:
:show-inheritance:

dspace.item module
------------------

.. automodule:: dspace.item
:members:
:undoc-members:
:show-inheritance:

Module contents
---------------

Expand Down
70 changes: 49 additions & 21 deletions dspace/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# dspace/client.py
from typing import Any, Dict, Optional
from typing import Dict, Optional

import requests
import structlog
Expand All @@ -15,19 +15,16 @@ class DSpaceClient:
accept_header: The response type to use in the requests "accept" header -- one
of "application/json" or "application/xml", defaults to "application/json"
Returns:
:class:`DSpaceClient` object
Example:
>>> from dspace.client import DSpaceClient
>>> client = DSpaceClient("https://dspace.myuniversity.edu/api")
>>> client.login("user@example.com", "password")
Attributes:
base_url: The base url of the DSpace API
cookies: Cookies for use in client requests
headers: Headers for use in client requests
"""

def __init__(self, base_url: str, accept_header: str = "application/json"):
self.base_url: str = base_url.rstrip("/")
self.headers: dict[str] = {"accept": accept_header}
self.cookies: dict[str] = {}
self.headers: Dict[str, str] = {"accept": accept_header}
self.cookies: dict = {}
logger.debug(
f"Client initialized with params base_url={self.base_url}, "
f"accept_header={self.headers}"
Expand All @@ -54,8 +51,9 @@ def get(self, endpoint: str, params: Optional[dict] = None) -> requests.Response
:class:`requests.Response` object
Raises:
:class:`requests.exceptions.HTTPError`: Response status code is 4xx or 5xx
:class:`requests.exceptions.Timeout`: Server takes more than 5 seconds to
:class:`requests.exceptions.HTTPError`: if response status code is 4xx or
5xx
:class:`requests.exceptions.Timeout`: if server takes more than 5 seconds to
respond
"""
url = self.base_url + endpoint
Expand All @@ -65,6 +63,27 @@ def get(self, endpoint: str, params: Optional[dict] = None) -> requests.Response
response.raise_for_status()
return response

def get_object_by_handle(self, handle: str) -> requests.Response:
"""Get a DSpace object based on its handle instead of its UUID.
Args:
handle: Handle of a DSpace community, collection, or item, e.g.
'1721.1/130883'
Returns:
:class:`requests.Response` object.
Response body is a json representation of the DSpace object with the
provided handle
Raises:
:class:`requests.HTTPError`: 500 Server Error if no object matching the
provided handle is found
"""
logger.debug(f"Retrieving object by handle {handle}")
endpoint = f"/handle/{handle}"
response = self.get(endpoint)
return response

def login(self, email: str, password: str) -> None:
"""Authenticate a user to the DSpace REST API. If authentication is successful,
adds an object to `self.cookies` equal to the response 'JSESSIONID' cookie.
Expand All @@ -74,7 +93,8 @@ def login(self, email: str, password: str) -> None:
password: The password of the DSpace user
Raises:
:class:`requests.exceptions.HTTPError`: Response status code 401 unauthorized
:class:`requests.exceptions.HTTPError`: 401 Client Error if provided
credentials are unauthorized
"""
logger.debug(f"Attempting to authenticate to {self.base_url} as {email}")
endpoint = "/login"
Expand All @@ -84,7 +104,11 @@ def login(self, email: str, password: str) -> None:
logger.debug(f"Successfully authenticated to {self.base_url} as {email}")

def post(
self, endpoint: str, data: Optional[bytes], params: Optional[dict] = None
self,
endpoint: str,
data: Optional[bytes] = None,
json: Optional[dict] = None,
params: Optional[dict] = None,
) -> requests.Response:
"""Send a POST request to a specified endpoint and return the result.
Expand All @@ -95,38 +119,42 @@ def post(
Args:
endpoint: The DSPace REST endpoint to post to, e.g. "/login"
data: The data to post
json: Data to post as JSON (uses requests' built-in JSON encoder)
params: Additional params that should be submitted with the request
Returns:
:class:`requests.Response` object
Raises:
:class:`requests.exceptions.HTTPError`: Response status code 4xx or 5xx
:class:`requests.exceptions.Timeout`: Server takes more than 5 seconds to
respond
:class:`requests.exceptions.HTTPError`: if response status code is 4xx or
5xx
:class:`requests.exceptions.Timeout`: if server takes more than 15 seconds
to respond
"""
url = self.base_url + endpoint
response = requests.post(
url,
cookies=self.cookies,
data=data,
headers=self.headers,
json=json,
params=params,
timeout=5.0,
timeout=15.0,
)
response.raise_for_status()
return response

def status(self) -> Dict[str, Any]:
def status(self) -> requests.Response:
"""Get current authentication status of :class:`DSpaceClient` instance.
Returns:
Dict representation of `DSpace Status object`_
:class:`requests.Response` object.
Response body is a json representation of a `DSpace Status object`_
.. _DSpace Status object: https://wiki.lyrasis.org/display/DSDOC6x/\
REST+API#RESTAPI-StatusObject
"""
logger.debug(f"Retrieving authentication status from {self.base_url}")
endpoint = "/status"
response = self.get(endpoint)
return response.json()
return response
120 changes: 120 additions & 0 deletions dspace/item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# dspace/item.py
"""dspace.item
This module includes classes representing DSpace Item and MetadataEntry objects, along
with functions for interacting with the DSpace REST API "/items" endpoint.
"""

from __future__ import annotations

from typing import Dict, List, Optional

import structlog
from requests import Response

from dspace.client import DSpaceClient

logger = structlog.get_logger(__name__)


class Item:
"""Class representing a DSpace Item object and its associated API calls.
Args:
bitstreams: Bitstream objects to associate with the item
metadata: :class:`MetadataEntry` objects to associate with the item
Attributes:
bitstreams (list): List of Bitstream objects belonging to the item
metadata (list of :obj:`MetadataEntry`): List of
:class:`MetadataEntry` objects representing the item's metadata
"""

def __init__(
self,
bitstreams: Optional[List] = None,
metadata: Optional[List[MetadataEntry]] = None,
):
self.bitstreams = bitstreams or []
self.metadata = metadata or []

def post(self, client: DSpaceClient, collection_uuid: str) -> Response:
"""Post item to a collection and return the response
Args:
client: An authenticated instance of the :class:`DSpaceClient` class
collection_id: The UUID (not the handle) of the DSpace collection to post
the item to
Returns:
:class:`requests.Response` object
Raises:
:class:`requests.HTTPError`: 404 Not Found if no collection matching
provided UUID
"""

endpoint = f"/collections/{collection_uuid}/items"
metadata = {"metadata": [m.to_dict() for m in self.metadata]}
logger.debug(
f"Posting new item to {client.base_url}{endpoint} with metadata "
f"{metadata}"
)
return client.post(endpoint, json=metadata)


class MetadataEntry:
"""Class representing a `DSpace MetadataEntry object <https://wiki.lyrasis.org/
display/DSDOC6x/REST+API#RESTAPI-MetadataEntryObject>`_.
Args:
key: DSpace metadata field name in qualified Dublin Core format, e.g.
'dc.description.abstract'
value: DSpace metadata field value, e.g. 'The abstract for this item'
language: Language of the DSpace metadata field, e.g. 'en_US'
Attributes:
key (str): Name of the metadata entry
value (str): Value of the metadata entry
language (str, optional): Language of the metadata entry
"""

def __init__(self, key: str, value: str, language: Optional[str] = None):
self.key = key
self.value = value
self.language = language

def to_dict(self) -> dict:
"""Method to convert the MetadataEntry object to a dict
Returns:
Dict representation of the metadata entry
"""
return {key: value for key, value in self.__dict__.items() if value}

@classmethod
def from_dict(cls, entry: Dict[str, str]) -> MetadataEntry:
"""
Class method to create a MetadataEntry object from a dict.
Args:
entry: A dict representing a DSpace metadata field name, value, and
optionally language. Dict must be structured as follows::
{
"key": "dc.title",
"value": "Item Title",
"language": "en_US" [Optional]
}
Returns:
: class: `MetadataEntry` object
Raises:
: class: `KeyError`: if metadata entry dict does not contain both "key" and
"value" items
"""
return cls(
key=entry["key"], value=entry["value"], language=entry.get("language", None)
)
18 changes: 15 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import vcr
from dotenv import load_dotenv

from dspace.client import DSpaceClient

load_dotenv()


Expand All @@ -25,6 +27,14 @@ def my_vcr():
return my_vcr


@pytest.fixture
def test_client(my_vcr, vcr_env):
with my_vcr.use_cassette("tests/vcr_cassettes/client/login.yaml"):
client = DSpaceClient(vcr_env["url"])
client.login(vcr_env["email"], vcr_env["password"])
return client


@pytest.fixture
def vcr_env():
if os.getenv("DSPACE_PYTHON_CLIENT_ENV") == "vcr_create_cassettes":
Expand Down Expand Up @@ -52,7 +62,7 @@ def vcr_scrub_request(request):

def vcr_scrub_response(response):
"""Replaces the response session cookie and any user data in the response body with
fake data"""
fake data. Also replaces response body content that isn't needed for testing."""
if "Set-Cookie" in response["headers"]:
response["headers"]["Set-Cookie"] = [
"JSESSIONID=sessioncookie; Path=/rest; Secure; HttpOnly"
Expand All @@ -62,12 +72,14 @@ def vcr_scrub_response(response):
except json.decoder.JSONDecodeError:
pass
else:
response_json.pop("introductoryText", None)
try:
email = response_json["email"]
if email is not None and email != "user@example.com":
if email is not None:
response_json["email"] = "user@example.com"
response_json["fullname"] = "Test User"
response["body"]["string"] = json.dumps(response_json)
except (KeyError, TypeError):
pass
if response_json != json.loads(response["body"]["string"]):
response["body"]["string"] = json.dumps(response_json)
return response
Loading

0 comments on commit 5d614a5

Please sign in to comment.