diff --git a/.github/workflows/docker-build-publish-drs-filer.yml b/.github/workflows/docker-build-publish-drs-filer.yml new file mode 100644 index 0000000..f269086 --- /dev/null +++ b/.github/workflows/docker-build-publish-drs-filer.yml @@ -0,0 +1,44 @@ +name: drs-filer + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + publish_to_docker: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + + name: Build and publish to Docker + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.7 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.3.0 + + - name: Login to Docker Hub + uses: docker/login-action@v3.2.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5.1 + with: + images: elixircloud/drs-filer + + - name: Build and push Docker images + uses: docker/build-push-action@v6.3.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ddd8ee7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,45 @@ +name: Lint workflow + +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + linting_and_tests: + runs-on: ubuntu-latest + + name: Run linting and unit tests + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.7 + + - name: Set up Python + uses: actions/setup-python@v5.1.0 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest coverage coveralls + pip install -r requirements-test.txt + pip install -r requirements.txt + + - name: Lint with flake8 + run: flake8 + + - name: Run unit tests with coverage + run: | + coverage run --source drs_filer -m pytest + coverage report -m + + - name: Send coverage data to Coveralls + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: coveralls \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..500bb0c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,45 @@ +name: Test Drs-filer API Endpoints + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4.1.7 + + - name: Build and run Docker Compose + run: docker-compose up --build -d + + - name: Wait for the services to be ready + run: | + echo "Waiting for the services to be ready..." + for i in {1..10}; do + if curl -sSf http://localhost:8080/ga4gh/drs/v1 > /dev/null; then + echo "Service is up!" + break + fi + echo "Waiting for the service to be ready..." + sleep 3 + done + + - name: Set up Python + uses: actions/setup-python@v5.1.0 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest requests + + - name: Test with pytest + run: | + pytest tests/test.py -v -p no:warnings + env: + DRS_FILER_URL: http://localhost:8080/ga4gh/drs/v1 + + - name: Stop and remove Docker Compose services + run: docker-compose down \ No newline at end of file diff --git a/drs_filer/ga4gh/drs/server.py b/drs_filer/ga4gh/drs/server.py index e404112..47fea6e 100644 --- a/drs_filer/ga4gh/drs/server.py +++ b/drs_filer/ga4gh/drs/server.py @@ -22,6 +22,7 @@ logger = logging.getLogger(__name__) + @log_traffic def ListDrsObjects() -> List[Dict]: """Get all DRS objects. @@ -33,10 +34,10 @@ def ListDrsObjects() -> List[Dict]: current_app.config.foca.db.dbs['drsStore']. collections['objects'].client ) - cursor = db_collection.find({}, {'_id': 0}) - objects = [] - for obj in cursor: - objects.append(obj) + cursor = db_collection.find({}, {'_id': 0}) + objects = [] + for obj in cursor: + objects.append(obj) return objects diff --git a/requirements-test.txt b/requirements-test.txt index a024d5e..c0eba27 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,4 @@ +flask flake8 mongomock pytest diff --git a/setup.cfg b/setup.cfg index f655a7f..5106b62 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,6 @@ source = drs_filer omit = drs_filer/app.py + +[flake8] +max-line-length = 88 \ No newline at end of file diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 0000000..6e14f0c --- /dev/null +++ b/tests/test.py @@ -0,0 +1,270 @@ +import requests +import time +import pytest +import os + +DRS_FILER_URL = os.getenv('DRS_FILER_URL', 'http://localhost:8080/ga4gh/drs/v1') + + +@pytest.fixture(scope="function") +def get_object_id(): + yield test_create_object() + + +@pytest.fixture(scope="function") +def get_access_id(get_object_id): + yield test_get_object(get_object_id).get("access_methods")[0].get("access_id") + + +def handle_error(response): + """Helper function to handle common error cases.""" + if response.status_code == 400: + assert False, "Bad Request: The server could not understand the request." + elif response.status_code == 401: + assert False, "Unauthorized: Access is denied due to invalid credentials." + elif response.status_code == 403: + assert ( + False + ), "Forbidden: The server understood the request, but refuses to authorize it." + elif response.status_code == 404: + assert False, "Not Found: The requested resource could not be found." + elif response.status_code == 409: + assert ( + False + ), ( + "Conflict: The request could not be completed due to a conflict with the " + "current state of the target resource." + ) + elif response.status_code == 500: + assert False, "Internal Server Error" + else: + assert ( + False + ), ( + f"Unexpected Status Code: {response.status_code} - " + f"{response.json().get('msg')}" + ) + + +def object_exists(object_id): + try: + obj = test_get_object(object_id) + return obj is not None + except Exception: + return False + + +def check_access_exists(object_id, access_id): + try: + obj = test_get_object_access(object_id, access_id) + return obj is not None + except Exception: + return False + + +def test_create_object(): + data = { + "access_methods": [ + { + "access_url": {"headers": [], "url": "http://example.com/data"}, + "region": "us-east-1", + "type": "s3", + } + ], + "aliases": ["example_alias"], + "checksums": [{"checksum": "abc123", "type": "sha-256"}], + "contents": [], + "created_time": "2024-06-12T12:58:19Z", + "description": "An example object", + "mime_type": "application/json", + "name": "example_object", + "size": 1024, + "updated_time": "2024-06-12T12:58:19Z", + "version": "1.0", + } + + response = requests.post(f"{DRS_FILER_URL}/objects", json=data) + assert response.status_code == 200 + + if response.status_code == 200: + object_id = response.json() + return object_id + else: + handle_error(response) + return None + + +def test_get_objects(): + response = requests.get(f"{DRS_FILER_URL}/objects") + assert response.status_code == 200 + objects = response.json() + assert isinstance(objects, list) + assert len(objects) > 0 + + +def test_get_object(get_object_id, max_retries=5, retry_count=0): + object_id = get_object_id + + response = requests.get(f"{DRS_FILER_URL}/objects/{object_id}") + assert response.status_code == 200 or response.status_code == 202 + + if response.status_code == 200: + return response.json() + elif response.status_code == 202: + if retry_count < max_retries: + retry_after = int(response.headers.get("Retry-After", 5)) + print( + f"Accepted: Operation is delayed. Retry after {retry_after} seconds. " + f"Retry count: {retry_count + 1}" + ) + time.sleep(retry_after) + return test_get_object(object_id, max_retries, retry_count + 1) + else: + print(f"Maximum retry limit reached ({max_retries}).") + handle_error(response) + return None + else: + handle_error(response) + return None + + +def test_get_object_access(get_object_id, get_access_id): + object_id = get_object_id + access_id = get_access_id + assert object_id is not None, "Object ID should not be None" + assert access_id is not None, "Access ID should not be None" + + response = requests.get(f"{DRS_FILER_URL}/objects/{object_id}/access/{access_id}") + + if response.status_code == 200: + print( + f"Following is the object retrieved based on {object_id} and {access_id}:" + ) + print(response.json()) + elif response.status_code == 202: + retry_after = int(response.headers.get("Retry-After", 5)) + print(f"202 Accepted: Operation is delayed. Retry after {retry_after} seconds.") + time.sleep(retry_after) + return test_get_object_access(object_id, access_id) # Retry the request + else: + handle_error(response) + + +def test_update_object(get_object_id): + object_id = get_object_id + + data = { + "access_methods": [ + { + "access_url": {"headers": ["string"], "url": "string"}, + "region": "us-east-1", + "type": "s3", + }, + { + "access_url": {"headers": ["string"], "url": "string"}, + "region": "us-east-2", + "type": "s3", + }, + ], + "aliases": ["string"], + "checksums": [{"checksum": "string", "type": "sha-256"}], + "contents": [ + { + "contents": [], + "drs_uri": [ + "drs://drs.example.org/314159", + "drs://drs.example.org/213512", + ], + "id": "string", + "name": "string", + } + ], + "created_time": "2024-07-03T14:16:59.268Z", + "description": "string", + "mime_type": "application/json", + "name": "string", + "size": 0, + "updated_time": "2024-07-03T14:16:59.268Z", + "version": "string", + } + + response = requests.put(f"{DRS_FILER_URL}/objects/{object_id}", json=data) + assert response.status_code == 200 + object_id = get_object_id + + if response.status_code == 200: + print(f"Updated the object with ID: {object_id}") + else: + handle_error(response) + + +def test_delete_object_access(get_object_id, get_access_id): + object_id = get_object_id + access_id = get_access_id + + response = requests.delete( + f"{DRS_FILER_URL}/objects/{object_id}/access/{access_id}" + ) + assert response.status_code == 200 or response.status_code == 409 + assert ( + response.status_code == 200 and not check_access_exists(object_id, access_id) + ) or response.status_code == 409 + + if response.status_code == 404: + print(f"Object with ID {object_id} or access ID {access_id} not found.") + elif response.status_code == 409: + print( + f"Refusing to delete the last remaining access method for object " + f"{object_id}." + ) + elif response.status_code == 200: + print(f"Deleted access method with ID {access_id} for object {object_id}.") + else: + handle_error(response) + + +def test_delete_object(get_object_id): + object_id = get_object_id + + response = requests.delete(f"{DRS_FILER_URL}/objects/{object_id}") + assert response.status_code == 200 + assert not object_exists(object_id) + + if response.status_code == 200: + print(f"Deleted the object with ID: {object_id}") + else: + handle_error(response) + + +def test_post_service_info(): + data = { + "contactUrl": "mailto:support@example.com", + "createdAt": "2024-06-12T12:58:19Z", + "description": "This service provides...", + "documentationUrl": "https://docs.myservice.example.com", + "environment": "test", + "id": "org.ga4gh.myservice", + "name": "My project", + "organization": {"name": "My organization", "url": "https://example.com"}, + "type": {"artifact": "beacon", "group": "org.ga4gh", "version": "1.0.0"}, + "updatedAt": "2024-06-12T12:58:19Z", + "version": "1.0.0", + } + + response = requests.post(f"{DRS_FILER_URL}/service-info", json=data) + assert response.status_code == 201 + + if response.status_code == 201: + print("Service info was successfully created.") + else: + handle_error(response) + + +def test_get_service_info(): + response = requests.get(f"{DRS_FILER_URL}/service-info") + assert response.status_code == 200 + service_info = response.json() + assert "name" in service_info + assert "version" in service_info + assert "description" in service_info + assert "contactUrl" in service_info