Repository: HTTP-APIs/hydrus (python)

Issue:
Add support to get/update/delete members of a collection.
### I'm submitting a 
- [ ] bug report.
- [x] feature request.

### Current Behaviour:
<!-- Describe about the bug -->
Currently, there is no API endpoint to get,update or delete members from a Collection. To delete a single object(member) from a Collection we'll have to make a POST request to `<collection_endpoint>/<collection_id>` with whole updated Collection.
### Expected Behaviour:
<!-- Describe what will happen if bug is removed -->
To make this process easier, we can add a feature or new endpoint in hydrus to delete or get a particular member from a Collection. To add a member to Collection, a PUT request can be made as usual.
### Steps to reproduce:
<!-- If you can then please provide the steps to reproduce the bug -->

### Snapshot:
<!-- If you can then please provide the screenshot of the issue you are facing -->

### Environment:
<!-- Please provide the following environment details --> 
* python version
* pip version
* OS details

### Do you want to work on this issue?
<!-- yes/no -->
**Yes**

Pull request:
Added new endpoint to Get/Delete member of a collection.
<!-- Please create/claim an issue before sending a PR -->
<!-- Add issue number (Eg: fixes #123) -->

Fixes #553 
Related to #494 

### Checklist
- [x] My branch is up-to-date with upstream/develop branch.
- [x] Everything works and tested for Python 3.6.0 and above.

### Description
<!-- Describe about what this PR does, previous state and new state of the output -->
Currently, there is no API endpoint to get, update or delete members from a Collection. To delete a single object(member) from a Collection we'll have to make a POST request to `<collection_endpoint>/<collection_id>` with updated Collection. 
I tried to create a new endpoint that takes both collection_id and member's id, so that we can delete or get a particular member from that Collection. Please let me know what should be a right approach to move forward with this. I also need some suggestions on what should be the name of these functions.

### Change logs

-  Added a new endpoint  `{api_name}/<string:path>/<uuid:collection_id_>/<uuid:id_>`
-  Added custom Exception to handle operations.

Using movie_apidoc for this example. 

**GET** :  `http://localhost:8080/serverapi/MovieCollection/26d9ae29-66af-4dc3-8214-085bfb3b9ee5`**/**`0fa1f87f-73d2-4ec4-ae9a-ecf18b2469dd`
```
{
    "@context": "http://localhost:8080/serverapi/contexts/MovieCollection.jsonld",
    "@id": "http://localhost:8080/serverapi/MovieCollection/26d9ae29-66af-4dc3-8214-085bfb3b9ee5",
    "@type": "MovieCollection",
    "members": [
        {
            "@id": "http://localhost:8080/serverapi/Movie/0fa1f87f-73d2-4ec4-ae9a-ecf18b2469dd",
            "@type": "hydra:Link"
        }
    ]
}
```

**DELETE** : `http://localhost:8080/serverapi/MovieCollection/26d9ae29-66af-4dc3-8214-085bfb3b9ee5`**/**`0fa1f87f-73d2-4ec4-ae9a-ecf18b2469dd`

```
{
    "@context": "http://www.w3.org/ns/hydra/context.jsonld",
    "@type": "Status",
    "description": "Object with ID 0fa1f87f-73d2-4ec4-ae9a-ecf18b2469dd successfully deleted from Collection with ID 26d9ae29-66af-4dc3-8214-085bfb3b9ee5",
    "statusCode": 200,
    "title": "Object successfully deleted."
}
```
To add a new member to Collection, a PUT request can be made as usual.

    

    

<!-- #### Added -->
<!-- Edit these points below to describe the new features added with this PR -->
<!-- - Feature 1 -->

<!-- - Feature 2 -->


<!-- #### Changed -->
<!-- Edit these points below to describe the changes made in existing functionality with this PR -->
<!-- - Change 1 -->
<!-- - Change 1 -->

<!-- #### Fixed -->
<!-- Edit these points below to describe the bug fixes made with this PR -->
<!-- - Bug 1 -->


<!-- #### Removed -->
<!-- Edit these points below to describe the removed features with this PR -->
<!-- - Deprecated feature 1 -->


Patch:
diff --git a/hydrus/app_factory.py b/hydrus/app_factory.py
index 39ada79..ba027c2 100644
--- a/hydrus/app_factory.py
+++ b/hydrus/app_factory.py
@@ -3,7 +3,7 @@ from flask_cors import CORS
 from flask_restful import Api
 
 from hydrus.resources import (Index, Vocab, Contexts, Entrypoint,
-                              ItemCollection, Item, Items)
+                              ItemCollection, Item, Items, ItemMember)
 
 
 def app_factory(api_name: str = "api", vocab_route: str = "vocab") -> Flask:
@@ -38,6 +38,10 @@ def app_factory(api_name: str = "api", vocab_route: str = "vocab") -> Flask:
         Item,
         f"/{api_name}/<string:path>/<uuid:id_>",
         endpoint="item")
+    api.add_resource(
+        ItemMember,
+        f"/{api_name}/<string:path>/<uuid:collection_id_>/<uuid:id_>",
+        endpoint="member")
     api.add_resource(
         Items,
         f"/{api_name}/<string:path>/add/<int_list>",
diff --git a/hydrus/data/crud.py b/hydrus/data/crud.py
index 2743218..74a23be 100644
--- a/hydrus/data/crud.py
+++ b/hydrus/data/crud.py
@@ -56,7 +56,9 @@ from hydrus.data.resource_based_classes import (
     delete_object,
     get_all_filtered_instances,
     get_single_response,
-    get_database_class
+    get_database_class,
+    get_collection_member,
+    delete_collection_member
 )
 from hydrus.conf import get_host_domain
 
@@ -353,6 +355,55 @@ def delete_single(type_: str, session: scoped_session) -> None:
     return delete(instance.id, type_, session=session)
 
 
+def get_member(collection_id: str, member_id: str, type_: str, api_name: str,
+               session: scoped_session, path: str = None) -> Dict[str, str]:
+    """Retrieve an Instance with given IDs from the database [GET].
+    :param collection_id: id of the collection to be fetched
+    :param member_id: id of the member to be fetched
+    :param type_: type of object
+    :param api_name: name of api specified while starting server
+    :param session: sqlalchemy scoped session
+    :param path: endpoint
+    :return: response to the request
+
+
+    Raises:
+        ClassNotFound: If the `type_` is not a valid/defined RDFClass.
+        InstanceNotFound: If no Instance of the 'type_` class if found.
+
+    """
+    query_info = {
+        "@type": type_,
+        "member_id": member_id,
+        "collection_id": collection_id
+    }
+
+    object_template = get_collection_member(query_info, session)
+    object_template["@id"] = f"{get_host_domain()}/{api_name}/{path}/{collection_id}"
+
+    return object_template
+
+
+def delete_member(collection_id: str, member_id: str, type_: str, session: scoped_session) -> None:
+    """Delete an Instance and all its relations from DB given id [DELETE].
+    :param collection_id: id of the collection
+    :param member_id: id of member to be deleted
+    :param type_: type of object to be deleted
+    :param session: sqlalchemy scoped session
+
+    Raises:
+        ClassNotFound: If `type_` does not represent a valid/defined RDFClass.
+        InstanceNotFound: If no instace of type `type_` with id `id_` exists.
+
+    """
+    query_info = {
+        "@type": type_,
+        "member_id": member_id,
+        "collection_id": collection_id
+    }
+    delete_collection_member(query_info, session)
+
+
 def insert_modification_record(method: str, resource_url: str,
                                session: scoped_session) -> int:
     """
diff --git a/hydrus/data/exceptions.py b/hydrus/data/exceptions.py
index 9df165e..d3a143d 100644
--- a/hydrus/data/exceptions.py
+++ b/hydrus/data/exceptions.py
@@ -34,6 +34,28 @@ class InstanceNotFound(Exception):
             return HydraError(code=404, title="Instance not found", desc=description)
 
 
+class MemberInstanceNotFound(Exception):
+    """Error when the Instance (Member in Collection) is not found."""
+
+    def __init__(self, type_: str, collection_id_: Union[str, None]=None,
+                 member_id_: Union[str, None]=None) -> None:
+        """Constructor."""
+        self.type_ = type_
+        self.collection_id = collection_id_
+        self.member_id = member_id_
+
+    def get_HTTP(self) -> HydraError:
+        """Return the HTTP response for the Exception."""
+        if str(self.member_id) is None:
+            description = f"Instance of type {self.type_} not found"
+            return HydraError(code=404, title="Instance not found", desc=description)
+        else:
+            description = (f"Instance of type {self.type_} with"
+                           f" Collection ID {str(self.collection_id)}"
+                           f" and member_id {str(self.member_id)} not found")
+            return HydraError(code=404, title="Instance not found", desc=description)
+
+
 class PropertyNotFound(Exception):
     """Error when the Property is not found."""
 
diff --git a/hydrus/data/resource_based_classes.py b/hydrus/data/resource_based_classes.py
index d2ada2f..4c8118d 100644
--- a/hydrus/data/resource_based_classes.py
+++ b/hydrus/data/resource_based_classes.py
@@ -12,6 +12,7 @@ from hydrus.data.exceptions import (
     InvalidSearchParameter,
     PropertyNotFound,
     PropertyNotGiven,
+    MemberInstanceNotFound
 )
 from sqlalchemy import exists
 from sqlalchemy.exc import InvalidRequestError, IntegrityError
@@ -225,6 +226,56 @@ def update_object(
     return id_
 
 
+def get_collection_member(query_info: Dict[str, str], session: scoped_session) -> Dict[str, str]:
+    """
+    Get member from a collection
+    :param query_info: Dict containing the ids and @type of object that has to retrieved
+    :param session: sqlalchemy session
+    :return: dict of object with its properties
+    """
+    type_ = query_info["@type"]
+    collection_id = query_info["collection_id"]
+    member_id = query_info["member_id"]
+    database_class = get_database_class(type_)
+    objects = (
+        session.query(database_class.members, database_class.member_type)
+        .filter(database_class.collection_id == collection_id, database_class.members == member_id)
+        .all()
+    )
+    if len(objects) == 0:
+        raise MemberInstanceNotFound(type_=type_, collection_id_=collection_id,
+                                     member_id_=member_id)
+    object_template = {}
+    object_template["@type"] = query_info["@type"]
+    object_template["members"] = objects
+    return object_template
+
+
+def delete_collection_member(query_info: Dict[str, str], session: scoped_session) -> None:
+    """
+    Delete the object from the collection in database
+    :param query_info: Dict containing the id and @type of object that has to retrieved
+    :param session: sqlalchemy session
+    """
+    type_ = query_info["@type"]
+    member_id = query_info["member_id"]
+    collection_id = query_info["collection_id"]
+    database_class = get_database_class(type_)
+    objects = (
+        session.query(database_class)
+        .filter(database_class.collection_id == collection_id,
+                database_class.members == member_id).delete()
+    )
+    if objects == 0:
+        raise MemberInstanceNotFound(type_=type_, collection_id_=collection_id,
+                                     member_id_=member_id)
+    try:
+        session.commit()
+    except InvalidRequestError:
+        session.rollback()
+    return collection_id
+
+
 def get_all_filtered_instances(
     session: scoped_session, search_params: Dict[str, Any], type_: str, collection: bool = False
 ):
diff --git a/hydrus/itemhelpers.py b/hydrus/itemhelpers.py
index 9d8916e..c7faa8f 100644
--- a/hydrus/itemhelpers.py
+++ b/hydrus/itemhelpers.py
@@ -9,7 +9,8 @@ from hydrus.data.exceptions import (
     ClassNotFound,
     InstanceNotFound,
     InstanceExists,
-    PropertyNotFound)
+    PropertyNotFound,
+    MemberInstanceNotFound)
 from hydrus.helpers import (
     set_response_headers,
     finalize_response,
@@ -163,3 +164,53 @@ def items_delete_check_support(id_, class_type, path, is_collection):
     except (ClassNotFound, InstanceNotFound) as e:
         error = e.get_HTTP()
         return error_response(error)
+
+
+def member_get_check_support(collection_id, member_id, class_type, class_path, path):
+    """Check if class_type supports GET operation"""
+    try:
+        # Try getting the Item based on Collection ID and Member ID and Class type
+        response = crud.get_member(
+            collection_id,
+            member_id,
+            class_type,
+            api_name=get_api_name(),
+            session=get_session(),
+            path=path)
+
+        response = finalize_response(class_path, response)
+        return set_response_headers(
+            jsonify(hydrafy(response, path=path)))
+
+    except (ClassNotFound, MemberInstanceNotFound) as e:
+        error = e.get_HTTP()
+        return error_response(error)
+
+
+def member_delete_check_support(collection_id, member_id, class_type, path):
+    """Check if class_type supports DELETE operation"""
+    try:
+        # Delete the Item with IDs collection_id and member_id
+        # collection_id is id of a collection
+        # member_id is the id of member of a collection
+        crud.delete_member(
+            collection_id,
+            member_id,
+            class_type,
+            session=get_session()
+        )
+
+        method = "DELETE"
+        resource_url = f"{get_hydrus_server_url()}{get_api_name()}/{path}/{collection_id}"
+        last_job_id = crud.get_last_modification_job_id(session=get_session())
+        new_job_id = crud.insert_modification_record(method, resource_url,
+                                                     session=get_session())
+        status_description = (f"Object with ID {member_id} successfully"
+                              f" deleted from Collection with ID {collection_id}")
+        status = HydraStatus(code=200, title="Object successfully deleted.",
+                             desc=status_description)
+        return set_response_headers(jsonify(status.generate()))
+
+    except (ClassNotFound, MemberInstanceNotFound) as e:
+        error = e.get_HTTP()
+        return error_response(error)
diff --git a/hydrus/resources.py b/hydrus/resources.py
index b593ea9..e5ec90b 100644
--- a/hydrus/resources.py
+++ b/hydrus/resources.py
@@ -38,7 +38,9 @@ from hydrus.itemhelpers import (
     items_get_check_support,
     items_post_check_support,
     items_put_check_support,
-    items_delete_check_support
+    items_delete_check_support,
+    member_get_check_support,
+    member_delete_check_support
 )
 from hydrus.item_collection_helpers import (
     item_collection_get_response,
@@ -196,6 +198,55 @@ class ItemCollection(Resource):
         return item_collection_put_response(path)
 
 
+class ItemMember(Resource):
+    """Handles operations(GET,DELETE) related to member of an Item(Collection).
+    (Item should be hydra:Collection)"""
+    @authenticate
+    def get(self, id_: str, path: str, collection_id_: str) -> Response:
+        """
+        GET object with collection_id = collection_id_
+        and member = id_ (member of a collection) from the database.
+        :param id_ : Item ID (Member)
+        :param collection_id_ : Item ID (Collection)
+        :param path : Path for Item ( Specified in APIDoc @id)
+        :return : object with member=id_ and collection_id=collection_id_
+        """
+        collection_id = str(collection_id_)
+        member_id = str(id_)
+        collections, parsed_classes = get_collections_and_parsed_classes()
+        if path in parsed_classes:
+            abort(405)
+        if path in collections:
+            item_class = collections[path]["collection"]
+            class_type = item_class.name
+            # Get path of the collection-class
+            class_path = item_class.path
+        if checkClassOp(class_path, "GET"):
+            return member_get_check_support(collection_id, member_id, class_type, class_path, path)
+        abort(405)
+
+    @authenticate
+    def delete(self, id_: str, path: str, collection_id_: str) -> Response:
+        """
+        Delete object with id=id_ from database.
+        :param id_ - ID of Item to be deleted
+        :param path - Path for Item type( Specified in APIDoc @id) to be deleted
+        """
+        collection_id = str(collection_id_)
+        member_id = str(id_)
+        collections, parsed_classes = get_collections_and_parsed_classes()
+        if path in parsed_classes:
+            abort(405)
+        if path in collections:
+            item_class = collections[path]["collection"]
+            class_type = item_class.name
+            # Get path of the collection-class
+            class_path = item_class.path
+        if checkClassOp(class_path, "DELETE"):
+            return member_delete_check_support(collection_id, member_id, class_type, path)
+        abort(405)
+
+
 class Items(Resource):
     @authenticate
     def put(self, path, int_list="") -> Response:


Test patch:
diff --git a/hydrus/tests/functional/test_app.py b/hydrus/tests/functional/test_app.py
index cc4b996..60d46c5 100644
--- a/hydrus/tests/functional/test_app.py
+++ b/hydrus/tests/functional/test_app.py
@@ -388,6 +388,51 @@ class TestApp():
                         response_get = test_app_client.get(endpoints[endpoint])
                         assert response_get.status_code == 405
 
+    def test_Collections_member_GET(self, test_app_client, constants, doc):
+        """Test endpoint to get member from a collection"""
+        API_NAME = constants['API_NAME']
+        index = test_app_client.get(f'/{API_NAME}')
+        assert index.status_code == 200
+        endpoints = json.loads(index.data.decode('utf-8'))
+        for endpoint in endpoints['collections']:
+            collection_name = '/'.join(endpoint['@id'].split(f'/{API_NAME}/')[1:])
+            collection = doc.collections[collection_name]['collection']
+            collection_methods = [x.method for x in collection.supportedOperation]
+            if 'PUT' in collection_methods:
+                dummy_object = gen_dummy_object(collection.name, doc)
+                good_response_put = test_app_client.put(endpoint['@id'],
+                                                        data=json.dumps(dummy_object))
+                assert good_response_put.status_code == 201
+                collection_endpoint = good_response_put.location
+                if 'GET' in collection_methods:
+                    for member in dummy_object['members']:
+                        member_id = member['@id'].split('/')[-1]
+                        get_response = test_app_client.get(f'{collection_endpoint}/{member_id}')
+                        assert get_response.status_code == 200
+
+    def test_Collections_member_DELETE(self, test_app_client, constants, doc):
+        """Test endpoint to delete member from a collection."""
+        API_NAME = constants['API_NAME']
+        index = test_app_client.get(f'/{API_NAME}')
+        assert index.status_code == 200
+        endpoints = json.loads(index.data.decode('utf-8'))
+        for endpoint in endpoints['collections']:
+            collection_name = '/'.join(endpoint['@id'].split(f'/{API_NAME}/')[1:])
+            collection = doc.collections[collection_name]['collection']
+            collection_methods = [x.method for x in collection.supportedOperation]
+            if 'PUT' in collection_methods:
+                dummy_object = gen_dummy_object(collection.name, doc)
+                good_response_put = test_app_client.put(endpoint['@id'],
+                                                        data=json.dumps(dummy_object))
+                assert good_response_put.status_code == 201
+                collection_endpoint = good_response_put.location
+                if 'DELETE' in collection_methods:
+                    for member in dummy_object['members']:
+                        member_id = member['@id'].split('/')[-1]
+                        full_endpoint = f'{collection_endpoint}/{member_id}'
+                        delete_response = test_app_client.delete(full_endpoint)
+                        assert delete_response.status_code == 200
+
     def test_IriTemplate(self, test_app_client, constants, doc):
         """Test structure of IriTemplates attached to parsed classes"""
         API_NAME = constants['API_NAME']