Repository: pablosnt/rekono-cli (language: python)

Issue:
Optimize CLI code
- Optimize code to improve the structure and reduce duplicated code
- Create new CLI options to improve the management of API requests
- Create unit tests to check CLI code automatically


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml
index 8382c0e..317b73f 100644
--- a/.github/workflows/code-style.yml
+++ b/.github/workflows/code-style.yml
@@ -41,4 +41,6 @@ jobs:
           python -m pip install -r requirements.txt
       
       - name: MyPy check
-        run: mypy --namespace-packages --package rekono --install-types --non-interactive
+        run: |
+          mypy --namespace-packages --package rekono --install-types --non-interactive
+          mypy --namespace-packages --package tests --install-types --non-interactive
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 643f837..927b820 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,7 +9,7 @@ repos:
             require_serial: true
             verbose: true
     - repo: https://github.com/Yelp/detect-secrets
-      rev: v1.2.0
+      rev: v1.4.0
       hooks:
           - id: detect-secrets
             args: ["--baseline", ".secrets.baseline"]
@@ -17,3 +17,4 @@ repos:
       rev: 4.0.1
       hooks:
           - id: flake8
+            args: ["--config", "src/.flake8", "src/rekono/", "src/tests/"]
diff --git a/README.md b/README.md
index 96dbd8a..f8ba862 100644
--- a/README.md
+++ b/README.md
@@ -44,25 +44,19 @@ pip3 install rekono-cli
 2. Install the dependencies:
 
     ```
-    pip3 install -r requirements.txt
+    pip3 install -r src/requirements.txt
     ```
 
 3. Execute the CLI:
 
     ```
-    python3 rekono/main.py --help
+    python3 src/rekono/main.py --help
     ```
 
 
 ## Configuration
 
-You can use the following environment variables to configure the Rekono CLI:
-
-- `REKONO_HOME`: Directory for Rekono installation. By default, Rekono will be installed in `/opt/rekono`
-- `REKONO_URL`: Rekono URL in format `<schema>://<host>` to be used in API requests
-- `REKONO_TOKEN`: API token for Rekono API authentication
-
-Check the [Rekono configuration](https://github.com/pablosnt/rekono/wiki/Configuration) for more information.
+You can use the `REKONO_TOKEN` environment variable to configure the API token for Rekono authentication.
 
 
 ## Support
@@ -83,4 +77,4 @@ If you need more specific help, you can also mail rekono.project@gmail.com.
 
 ## License
 
-Rekono is licensed under the [GNU GENERAL PUBLIC LICENSE Version 3](./LICENSE.md)
\ No newline at end of file
+Rekono is licensed under the [GNU GENERAL PUBLIC LICENSE Version 3](../LICENSE.md)
\ No newline at end of file
diff --git a/src/.flake8 b/src/.flake8
index 79fa917..f9e67f7 100644
--- a/src/.flake8
+++ b/src/.flake8
@@ -1,6 +1,6 @@
 [flake8]
-exclude = .git,__pycache__,venv/*
+exclude=.git,__pycache__,venv/*
 ignore=W504
 ; W504: Disallow line break after binary operator (and, or, etc.). Inconsistency with W503
-max-line-length = 120
-max-complexity = 10
\ No newline at end of file
+max-line-length=120
+max-complexity=10
\ No newline at end of file
diff --git a/src/MANIFEST.in b/src/MANIFEST.in
index 691204b..a7b0dc4 100644
--- a/src/MANIFEST.in
+++ b/src/MANIFEST.in
@@ -1,4 +1,4 @@
-include README.md
-include LICENSE.txt
-include requirements.txt
-include rekono/services/templates/rekono.service
\ No newline at end of file
+include ../README.md
+include ../CHANGELOG.md
+include ../LICENSE.txt
+include requirements.txt
\ No newline at end of file
diff --git a/src/mypy.ini b/src/mypy.ini
deleted file mode 100644
index 7c93b67..0000000
--- a/src/mypy.ini
+++ /dev/null
@@ -1,4 +0,0 @@
-[mypy]
-files = rekono/**
-; Mypy fails due to some external imports without hints
-ignore_missing_imports = True
\ No newline at end of file
diff --git a/src/rekono/api/__init__.py b/src/rekono/api/__init__.py
deleted file mode 100644
index 00198de..0000000
--- a/src/rekono/api/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-'''Rekono API Rest.'''
diff --git a/src/rekono/api/commands.py b/src/rekono/api/commands.py
deleted file mode 100644
index 610fb88..0000000
--- a/src/rekono/api/commands.py
+++ /dev/null
@@ -1,114 +0,0 @@
-import json
-from getpass import getpass
-from typing import List, Optional, Tuple
-
-import click
-from rekono.api.requests import request
-from rekono.config import API_TOKEN, API_URL
-
-import requests
-
-
-def get_url_and_token() -> Tuple[str, Optional[str]]:
-    '''Get URL and API token to make Rekono API requests.
-
-    Returns:
-        Tuple[str, str]: Rekono URL and API token.
-    '''
-    api_url = API_URL
-    api_token = API_TOKEN
-    if not api_token:
-        api_url = input('API URL: ')                                            # Ask user for API URL if needed
-        if api_token:
-            click.echo()
-    if not api_token:
-        username = input('Username: ')                                          # Ask user for Rekono username
-        password = getpass('Password: ')                                        # Ask user for Rekono password
-        click.echo()
-        response = request(                                                     # Get API token using Rekono credentials
-            'post', api_url, '/api/api-token/',
-            data='{' + f'"username": "{username}", "password": "{password}"' + '}'
-        )
-        if response.status_code == 200:
-            api_token = response.json().get('token')
-    return api_url, api_token
-
-
-def display_api_response(response: requests.Response) -> None:
-    '''Display Rekono API responses.
-
-    Args:
-        response (requests.Response): HTTP response
-    '''
-    if response.json():
-        text = json.dumps(response.json(), indent=4)                            # Parse response body as JSON
-    else:
-        text = response.text if response.text else 'No response content'        # Plain response body
-    click.echo(text)
-
-
-@click.group('api', help='Make requests to Rekono API REST')
-def api():
-    '''Rekono API requests.'''
-    pass
-
-
-@api.command('get', help='HTTP GET request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-@click.option(
-    '-p', '--parameter', 'query_parameters', type=str, multiple=True,
-    required=False, help='HTTP query parameter. Format <parameter>=<value>'
-)
-def get(endpoint: str, query_parameters: List[str]):
-    '''Make a HTTP GET request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-        query_parameters (List[str]): Query parameters to send
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('get', api_url, endpoint, api_token=api_token, params=query_parameters)
-    display_api_response(response)
-
-
-@api.command('post', help='HTTP POST request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-@click.option('-d', '--data', 'data', type=str, required=False, help='HTTP body data. JSON format')
-def post(endpoint: str, data: str):
-    '''Make a HTTP POST request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-        data (str): Body data to send
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('post', api_url, endpoint, api_token=api_token, data=data)
-    display_api_response(response)
-
-
-@api.command('put', help='HTTP PUT request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-@click.option('-d', '--data', 'data', type=str, required=False, help='HTTP body data. JSON format')
-def put(endpoint: str, data: str):
-    '''Make a HTTP PUT request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-        data (str): Body data to send
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('put', api_url, endpoint, api_token=api_token, data=data)
-    display_api_response(response)
-
-
-@api.command('delete', help='HTTP DELETE request to Rekono API')
-@click.argument('endpoint', type=str, nargs=1)
-def delete(endpoint: str):
-    '''Make a HTTP DELETE request to Rekono API.
-
-    Args:
-        endpoint (str): Endpoint to call
-    '''
-    api_url, api_token = get_url_and_token()
-    response = request('delete', api_url, endpoint, api_token=api_token)
-    display_api_response(response)
diff --git a/src/rekono/api/requests.py b/src/rekono/api/requests.py
deleted file mode 100644
index 78f1982..0000000
--- a/src/rekono/api/requests.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import sys
-from typing import Any, Dict, List
-
-import click
-import requests
-
-
-def get_url(url: str, endpoint: str) -> str:
-    '''Get Rekono URL from base URL and endpoint after sanitization.
-
-    Args:
-        url (str): Rekono base URL
-        endpoint (str): Rekono endpoint
-
-    Returns:
-        str: Rekono full URL
-    '''
-    if url.endswith('/'):
-        url = url[:-1]                                                          # Remove latest slash from base URL
-    prefix = '/api'
-    if not endpoint.startswith(prefix):
-        if not endpoint.startswith('/'):                                        # Add initial slash to the endpoint
-            endpoint = f'/{endpoint}'
-        endpoint = prefix + endpoint                                            # Add /api prefix to the endpoint
-    if not endpoint.endswith('/'):
-        endpoint = f'{endpoint}/'                                               # Add final slash to the endpoint
-    return url + endpoint
-
-
-def get_query_parameters(query_parameters: List[str]) -> Dict[str, Any]:
-    '''Get query parameters to send in HTTP request.
-
-    Args:
-        query_parameters (List[str]): Query parameters to send
-
-    Returns:
-        Dict[str, Any]: Query parameters in dict format
-    '''
-    parameters = {}
-    for query_parameter in query_parameters:
-        if '=' not in query_parameter:                                          # Invalid parameter
-            click.echo(f'Invalid query parameter: {query_parameter}')
-            sys.exit(1)
-        parsed = query_parameter.split('=')
-        parameters[parsed[0]] = parsed[1]                                       # Add query parameter
-    return parameters
-
-
-def request(
-    method: str,
-    url: str,
-    endpoint: str,
-    api_token: str = None,
-    params: List[str] = None,
-    data: str = None
-) -> requests.Response:
-    '''Make a request to the Rekono API.
-
-    Args:
-        method (str): HTTP method to use
-        url (str): Rekono base URL
-        endpoint (str): Rekono endpoint to call
-        api_token (str, optional): Rekono API token for authentication. Defaults to None.
-        params (List[str], optional): Query parameters to send. Defaults to None.
-        data (str, optional): Body data to send. Defaults to None.
-
-    Returns:
-        requests.Response: _description_
-    '''
-    headers = {
-        'Accept': 'application/json',
-        'Content-Type': 'application/json'
-    }
-    if api_token:
-        headers['Authorization'] = f'Token {api_token}'                         # Add header for authentication
-    response = requests.request(
-        method=method.lower() if method.lower() in ['get', 'post', 'put', 'delete'] else 'get',
-        url=get_url(url, endpoint),
-        params=get_query_parameters(params) if params else None,
-        data=data,
-        headers=headers
-    )
-    return response
diff --git a/src/rekono/client/__init__.py b/src/rekono/client/__init__.py
new file mode 100644
index 0000000..054cb2e
--- /dev/null
+++ b/src/rekono/client/__init__.py
@@ -0,0 +1,1 @@
+'''Rekono API client.'''
diff --git a/src/rekono/client/api.py b/src/rekono/client/api.py
new file mode 100644
index 0000000..fda76c5
--- /dev/null
+++ b/src/rekono/client/api.py
@@ -0,0 +1,168 @@
+'''Rekono API.'''
+
+import json
+from typing import Any, Callable, Dict, List, Optional, Union
+
+import requests
+from requests.adapters import HTTPAdapter, Retry
+from requests.exceptions import RetryError, Timeout
+from requests.models import Response
+
+from rekono.client.exceptions import AuthenticationError
+
+
+class Rekono:
+    '''Rekono API.'''
+
+    def __init__(
+        self,
+        url: str,
+        token: Optional[str] = None,
+        username: Optional[str] = None,
+        password: Optional[str] = None,
+        headers: Dict[str, str] = {},
+        verify: bool = False
+    ) -> None:
+        '''Rekono API client constructor.
+
+        Args:
+            url (str): Base Rekono URL.
+            token (Optional[str], optional): API token (required if no credentials provided). Defaults to None.
+            username (Optional[str], optional): Username (required if no token provided). Defaults to None.
+            password (Optional[str], optional): Password (required if no token provided). Defaults to None.
+            headers (Dict[str, str], optional): Extra HTTP request headers. Defaults to {}.
+            verify (bool, optional): Indicates if TLS verification should be performed or not. Defaults to False.
+
+        Raises:
+            AuthenticationError: Authentication error during basic authentication attempt.
+        '''
+        self.url = url
+        self.verify = verify
+        self.headers = headers
+        self.session = requests.Session()
+        # Configure retries of HTTP requests
+        retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429, 500, 502, 503, 504])
+        self.session.mount(self.url, HTTPAdapter(max_retries=retries))
+        if token:
+            self.token = token
+        elif username and password:
+            # Make basic authentication to get the API token
+            response = self.post('/api/api-token/', json.dumps({'username': username, 'password': password}))
+            if response.status_code == 200:
+                self.token = response.json().get('token')
+            else:                                                               # Invalid credentials
+                raise AuthenticationError('Unauthorized: Invalid username or password', response)
+        self.headers.update({                                                   # Configure HTTP request headers
+            'Authorization': f'Token {self.token}',
+            'Content-Type': 'application/json'
+        })
+
+    def _get_endpoint(self, endpoint: str) -> str:
+        '''Get valid Rekono endpoint from the provided value.
+
+        Args:
+            endpoint (str): Endpoint provided.
+
+        Returns:
+            str: Valid Rekono endpoint.
+        '''
+        if not endpoint.startswith('/api/'):                                    # Endpoint doesn't start by /api/
+            if endpoint.startswith('/'):
+                endpoint = endpoint[1:]
+            endpoint = '/api/' + endpoint                                       # Add /api/ prefix to the endpoint
+        if not endpoint.endswith('/'):                                          # Endpoint doesn't end by slash
+            endpoint += '/'                                                     # Add slash suffix to the endpoint
+        return endpoint
+
+    def _request(
+        self,
+        method: Callable,
+        endpoint: str,
+        parameters: Optional[Dict[str, Any]] = None,
+        body: Optional[str] = None
+    ) -> Response:
+        '''Perform HTTP request to Rekono API.
+
+        Args:
+            method (Callable): HTTP method to use.
+            endpoint (str): Endpoint to call.
+            parameters (Optional[Dict[str, Any]], optional): Query parameters to send. Defaults to None.
+            body (Optional[str], optional): Body to send. Defaults to None.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        url = self.url + self._get_endpoint(endpoint)                           # Prepare URL to call
+        try:
+            return method(url, params=parameters, data=body, headers=self.headers, verify=self.verify)  # Attempt one
+        except (ConnectionError, RetryError, Timeout):                          # Unexpected error during HTTP request
+            return method(url, params=parameters, data=body, headers=self.headers, verify=self.verify)  # Attempt two
+
+    def get(
+        self,
+        endpoint: str,
+        parameters: Dict[str, Any] = {},
+        all_pages: bool = False
+    ) -> Union[List[Response], Response]:
+        '''GET request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            parameters (Optional[Dict[str, Any]], optional): Query parameters to send. Defaults to {}.
+            all_pages (bool, optional): Iteration over all API pages is required. Defaults to False.
+
+        Returns:
+            Union[List[Response], Response]: HTTP responses if all pages is enabled or one HTTP response if not.
+        '''
+        if not all_pages:                                                       # All pages is disabled
+            return self._request(self.session.get, endpoint, parameters=parameters)     # Perform only one GET request
+        page = 1                                                                # Start page number
+        size = 100                                                              # Default page size (max is 1000)
+        count = 101                                                             # Initial fake count value
+        responses = []
+        while page * size < count:
+            parameters.update({'page': page, 'size': size})                     # Set pagination parameters
+            response = self._request(self.session.get, endpoint, parameters=parameters)     # Paginated GET request
+            responses.append(response)                                          # Save HTTP response
+            body = response.json()
+            if body and 'count' in body:
+                count = body.get('count', 0)                                    # Update count value
+                page += 1                                                       # Increase page number
+            else:
+                break
+        return responses
+
+    def post(self, endpoint: str, body: Optional[str] = None) -> Response:
+        '''POST request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            body (Optional[str], optional): Body to send. Defaults to None.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._request(self.session.post, endpoint, body=body)            # Perform POST request
+
+    def put(self, endpoint: str, body: Optional[str] = None) -> Response:
+        '''PUT request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            body (Optional[str], optional): Body to send. Defaults to None.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._request(self.session.put, endpoint, body=body)             # Perform PUT request
+
+    def delete(self, endpoint: str) -> Response:
+        '''DELETE request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._request(self.session.delete, endpoint)                     # Perform DELETE request
diff --git a/src/rekono/client/exceptions.py b/src/rekono/client/exceptions.py
new file mode 100644
index 0000000..df847fa
--- /dev/null
+++ b/src/rekono/client/exceptions.py
@@ -0,0 +1,18 @@
+'''Rekono exceptions.'''
+
+from requests.models import Response
+
+
+class AuthenticationError(Exception):
+    '''Authentication error during authentication attempt.'''
+
+    def __init__(self, message: str, response: Response) -> None:
+        '''Exception constructor.
+
+        Args:
+            message (str): Error message.
+            response (Response): Http response that causes the error.
+        '''
+        self.response = response
+        self.message = message
+        super().__init__(message)
diff --git a/src/rekono/commands/__init__.py b/src/rekono/commands/__init__.py
new file mode 100644
index 0000000..b6e1f1e
--- /dev/null
+++ b/src/rekono/commands/__init__.py
@@ -0,0 +1,1 @@
+'''Rekono CLI commands.'''
diff --git a/src/rekono/commands/api.py b/src/rekono/commands/api.py
new file mode 100644
index 0000000..03dbfb0
--- /dev/null
+++ b/src/rekono/commands/api.py
@@ -0,0 +1,10 @@
+'''Command to perform standard Rekono API requests.'''
+
+import click
+
+from rekono.framework.commands import RekonoApiCommand
+
+
+@click.group('api', cls=RekonoApiCommand, help='Perform Rekono API requests')
+def api():
+    '''Perform Rekono API requests.'''
diff --git a/src/rekono/config.py b/src/rekono/config.py
deleted file mode 100644
index 06c0933..0000000
--- a/src/rekono/config.py
+++ /dev/null
@@ -1,8 +0,0 @@
-import os
-
-from rekono.environment import REKONO_TOKEN, REKONO_URL
-
-'''Rekono CLI configuration.'''
-
-API_URL = os.getenv(REKONO_URL, 'http://127.0.0.1:8000')
-API_TOKEN = os.getenv(REKONO_TOKEN)                                             # Rekono API token
diff --git a/src/rekono/environment.py b/src/rekono/environment.py
deleted file mode 100644
index 599de43..0000000
--- a/src/rekono/environment.py
+++ /dev/null
@@ -1,4 +0,0 @@
-'''Environment variables used by Rekono CLI.'''
-
-REKONO_URL = 'REKONO_URL'
-REKONO_TOKEN = 'REKONO_TOKEN'
diff --git a/src/rekono/framework/__init__.py b/src/rekono/framework/__init__.py
new file mode 100644
index 0000000..0b6f92c
--- /dev/null
+++ b/src/rekono/framework/__init__.py
@@ -0,0 +1,1 @@
+'''Rekono CLI framework that includes base features, code and design.'''
diff --git a/src/rekono/framework/arguments.py b/src/rekono/framework/arguments.py
new file mode 100644
index 0000000..3ca4423
--- /dev/null
+++ b/src/rekono/framework/arguments.py
@@ -0,0 +1,5 @@
+'''Definition of CLI arguments.'''
+
+import click
+
+endpoint_argument = click.argument('endpoint', type=str, nargs=1)               # Endpoint argument
diff --git a/src/rekono/framework/commands.py b/src/rekono/framework/commands.py
new file mode 100644
index 0000000..eb554f1
--- /dev/null
+++ b/src/rekono/framework/commands.py
@@ -0,0 +1,386 @@
+'''Definition of base features for CLI commands.'''
+
+import json
+import os
+import sys
+from typing import Any, Dict, List, Optional, Union
+from urllib.parse import urlparse
+
+import click
+import requests
+from requests.models import Response
+
+from rekono.client.api import Rekono
+from rekono.client.exceptions import AuthenticationError
+from rekono.framework.arguments import endpoint_argument
+from rekono.framework.options import (all_pages_option, body_option,
+                                      headers_option, json_option,
+                                      no_verify_option, parameters_option,
+                                      quiet_option, show_headers_option,
+                                      status_code_option, url_option)
+
+
+class RekonoApiCommand(click.MultiCommand):
+    '''Base Rekono CLI command.'''
+
+    api_token_env = 'REKONO_TOKEN'                                              # Environment variable to set API token
+    commands = ['get', 'post', 'put', 'delete']                                 # List of CLI commands supported
+
+    def list_commands(self, ctx: click.Context) -> List[str]:
+        '''Return list of CLI commands.
+
+        Args:
+            ctx (click.Context): Click context.
+
+        Returns:
+            List[str]: List of CLI commands.
+        '''
+        return self.commands
+
+    def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]:
+        '''Get CLI command by name.
+
+        Args:
+            ctx (click.Context): Click context.
+            cmd_name (str): Command name.
+
+        Returns:
+            Optional[click.Command]: Click command.
+        '''
+        return getattr(self, cmd_name) if hasattr(self, cmd_name) else None
+
+    @staticmethod
+    def _get_url(url: str) -> str:
+        '''Get valid Rekono base URL from the user provided value.
+
+        Args:
+            url (str): User provided URL.
+
+        Returns:
+            str: Valid Rekono base URL.
+        '''
+        parser = urlparse(url)                                                  # Parse provided URL
+        if not parser.netloc:                                                   # Invalid URL
+            click.echo(click.style('URL is invalid', fg='red'), err=True, color=True)
+            url = click.prompt('URL', type=str)                                 # Ask user for base URL
+            return RekonoApiCommand._get_url(url)                               # Retry URL validation
+        return f'{parser.scheme or "https"}://{parser.netloc}'
+
+    @staticmethod
+    def _parse_key_value_params(items: List[str]) -> Dict[str, str]:
+        '''Parse key=value options.
+
+        Args:
+            items (List[str]): List of key=value values.
+
+        Returns:
+            Dict[str, str]: Dictionary with all keys and values.
+        '''
+        data = {}
+        for item in items or []:
+            if '=' in item:                                                     # Check separator
+                key, value = item.split('=', 1)                                 # Get key and value
+                data[key.strip()] = value.strip()
+        return data
+
+    @staticmethod
+    def _get_body(body: Optional[str]) -> Optional[str]:
+        '''Validate body value to be sent via HTTP.
+
+        Args:
+            body (Optional[str]): Body value.
+
+        Returns:
+            Optional[str]: Validated body value.
+        '''
+        if body:
+            try:
+                json.loads(body)                                                # Try to parse body value
+            except json.decoder.JSONDecodeError:                                # Invalid body value
+                click.echo(click.style('Invalid JSON format for body value', fg='red'), err=True, color=True)
+                sys.exit(1)
+        return body
+
+    @staticmethod
+    def _get_endpoint(endpoint: str) -> str:
+        '''Get valid endpoint value without parameters or other URL information.
+
+        Args:
+            endpoint (str): Endpoint value provided by user.
+
+        Returns:
+            str: Valid endpoint value.
+        '''
+        return urlparse(endpoint).path
+
+    @staticmethod
+    def _rekono_factory(url: str, no_verify: bool = False, headers: List[str] = []) -> Rekono:
+        '''Create Rekono client entity.
+
+        Args:
+            url (str): Base Rekono URL.
+            no_verify (bool, optional): Disable TLS validation. Defaults to False.
+            headers (List[str], optional): Extra HTTP request headers. Defaults to [].
+
+        Returns:
+            Rekono: Rekono API client.
+        '''
+        api_token = os.getenv(RekonoApiCommand.api_token_env)                   # Get API token from environment
+        if api_token:                                                           # API token provided
+            return Rekono(                                                      # Create Rekono API client
+                RekonoApiCommand._get_url(url),                                 # Get valid Rekono URL
+                token=api_token,
+                headers=RekonoApiCommand._parse_key_value_params(headers),      # Get HTTP headers
+                verify=not no_verify
+            )
+        username = click.prompt('Username', type=str)                           # Ask for username
+        password = click.prompt('Password', type=str, hide_input=True)          # Ask for password
+        if not username or not password:                                        # Invalid credentials
+            click.echo(click.style('Username and password are required', fg='red'), err=True, color=True)
+            return RekonoApiCommand._rekono_factory(url, not no_verify, headers)    # Retry Rekono client creation
+        try:
+            return Rekono(                                                      # Create Rekono API client
+                RekonoApiCommand._get_url(url),                                 # Get valid Rekono URL
+                username=username,
+                password=password,
+                headers=RekonoApiCommand._parse_key_value_params(headers),      # Get HTTP headers
+                verify=not no_verify
+            )
+        except AuthenticationError as ex:                                       # Invalid basic credentials
+            click.echo(click.style(ex.message, fg='red'), err=True, color=True)
+            sys.exit(1)
+
+    @staticmethod
+    def _get_data_from_responses(responses: List[Response]) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
+        '''Get data from Rekono API responses.
+
+        Args:
+            responses (List[Response]): List of Rekono API responses.
+
+        Returns:
+            Union[List[Dict[str, Any]], Dict[str, Any]]: Data returned by Rekono.
+        '''
+        content = []
+        for response in responses:                                              # For each response
+            try:
+                body = response.json()                                          # Parse JSON body from response
+            except requests.exceptions.JSONDecodeError:
+                continue
+            if isinstance(body, dict):                                          # Response body is a dictionary
+                body = body.get('results', body)                                # Get results field if it exists
+            if isinstance(body, list):                                          # Content is a list
+                content.extend(body)                                            # Save content
+            elif isinstance(body, dict):                                        # Content is a dictionary
+                if len(responses) == 1:                                         # Only one response
+                    return body                                                 # Return response content
+                content.append(body)                                            # Save content
+        return content
+
+    @staticmethod
+    def _display_responses(
+        responses: List[Response],
+        show_headers: bool,
+        just_show_status_code: bool,
+        quiet: bool
+    ) -> None:
+        '''Display Rekono API responses via standard output.
+
+        Args:
+            responses (List[Response]): Rekono API responses.
+            show_headers (bool): Display HTTP response headers.
+            just_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        if quiet:                                                               # No content should be displayed
+            return
+        if just_show_status_code or show_headers:                               # Headers or status should be displayed
+            for response in responses:                                          # For each response
+                if just_show_status_code:                                       # Just display status code
+                    click.echo(response.status_code)
+                elif show_headers:                                              # Show response headers
+                    click.echo()
+                    # Display HTTP request and response summary
+                    click.echo(f'{response.request.method} {response.request.path_url} {response.status_code}')
+                    for header, value in response.headers.items():
+                        click.echo(f'{header}: {value}')                        # Display HTTP response headers
+                    click.echo()
+                    data = RekonoApiCommand._get_data_from_responses([response])    # Get content from response
+                    click.echo(json.dumps(data, ensure_ascii=True, indent=4))   # Display content via standard output
+        else:                                                                   # Standard display options
+            data = RekonoApiCommand._get_data_from_responses(responses)         # Get content from responses
+            click.echo(json.dumps(data, ensure_ascii=True, indent=4))           # Display content via standard output
+
+    @staticmethod
+    def _save_output(responses: List[Response], filepath: Optional[str]) -> None:
+        '''Save responses content in JSON file.
+
+        Args:
+            responses (List[Response]): Rekono API responses.
+            filepath (Optional[str]): Filepath to the JSON file where content should be saved.
+        '''
+        if not filepath:                                                        # JSON filepath is provided
+            return
+        data = RekonoApiCommand._get_data_from_responses(responses)             # Get data from responses
+        with open(filepath, 'w', encoding='utf-8') as file:                     # Open JSON file
+            json.dump(data, file, ensure_ascii=True, indent=4)                  # Write content in JSON file
+
+    @staticmethod
+    @click.command(help='GET request to Rekono API')
+    @endpoint_argument
+    @url_option
+    @headers_option
+    @parameters_option
+    @no_verify_option
+    @all_pages_option
+    @show_headers_option
+    @status_code_option
+    @quiet_option
+    @json_option
+    def get(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        parameters: List[str],
+        no_verify: bool,
+        all_pages: bool,
+        show_headers: bool,
+        just_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ):
+        '''GET request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            parameters (List[str]): HTTP query parameters to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            all_pages (bool): Enable iteration over all API pages.
+            show_headers (bool): Display HTTP response headers.
+            just_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        client = RekonoApiCommand._rekono_factory(url, no_verify, headers)
+        response_or_responses = client.get(
+            RekonoApiCommand._get_endpoint(endpoint),
+            parameters=RekonoApiCommand._parse_key_value_params(parameters),
+            all_pages=all_pages
+        )
+        responses = response_or_responses if isinstance(response_or_responses, list) else [response_or_responses]
+        RekonoApiCommand._display_responses(responses, show_headers, just_show_status_code, quiet)
+        RekonoApiCommand._save_output(responses, json_output)
+
+    @staticmethod
+    @click.command(help='POST request to Rekono API')
+    @endpoint_argument
+    @url_option
+    @headers_option
+    @body_option
+    @no_verify_option
+    @show_headers_option
+    @status_code_option
+    @quiet_option
+    @json_option
+    def post(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        body: str,
+        no_verify: bool,
+        show_headers: bool,
+        just_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ):
+        '''POST request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            body (str): HTTP body to send in JSON format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            just_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        client = RekonoApiCommand._rekono_factory(url, no_verify, headers)
+        response = client.post(RekonoApiCommand._get_endpoint(endpoint), RekonoApiCommand._get_body(body))
+        RekonoApiCommand._display_responses([response], show_headers, just_show_status_code, quiet)
+        RekonoApiCommand._save_output([response], json_output)
+
+    @staticmethod
+    @click.command(help='PUT request to Rekono API')
+    @endpoint_argument
+    @url_option
+    @headers_option
+    @body_option
+    @no_verify_option
+    @show_headers_option
+    @status_code_option
+    @quiet_option
+    @json_option
+    def put(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        body: str,
+        no_verify: bool,
+        show_headers: bool,
+        just_show_status_code: bool,
+        quiet: bool,
+        json_output: str
+    ):
+        '''PUT request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            body (str): HTTP body to send in JSON format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            just_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+            json_output (str): Filepath to the JSON file where content should be saved.
+        '''
+        client = RekonoApiCommand._rekono_factory(url, no_verify, headers)
+        response = client.put(RekonoApiCommand._get_endpoint(endpoint), RekonoApiCommand._get_body(body))
+        RekonoApiCommand._display_responses([response], show_headers, just_show_status_code, quiet)
+        RekonoApiCommand._save_output([response], json_output)
+
+    @staticmethod
+    @click.command(help='DELETE request to Rekono API')
+    @endpoint_argument
+    @url_option
+    @headers_option
+    @no_verify_option
+    @show_headers_option
+    @status_code_option
+    @quiet_option
+    def delete(
+        endpoint: str,
+        url: str,
+        headers: List[str],
+        no_verify: bool,
+        show_headers: bool,
+        just_show_status_code: bool,
+        quiet: bool
+    ):
+        '''DELETE request to Rekono API.
+
+        Args:
+            endpoint (str): Endpoint to call.
+            url (str): Rekono base URL.
+            headers (List[str]): HTTP headers to send in key=value format.
+            no_verify (bool): Disable TLS validation.
+            show_headers (bool): Display HTTP response headers.
+            just_show_status_code (bool): Just display HTTP response status code.
+            quiet (bool): Don't display anything from response.
+        '''
+        client = RekonoApiCommand._rekono_factory(url, no_verify, headers)
+        response = client.delete(RekonoApiCommand._get_endpoint(endpoint))
+        RekonoApiCommand._display_responses([response], show_headers, just_show_status_code, quiet)
diff --git a/src/rekono/framework/options.py b/src/rekono/framework/options.py
new file mode 100644
index 0000000..8e04e7b
--- /dev/null
+++ b/src/rekono/framework/options.py
@@ -0,0 +1,66 @@
+'''Definition of CLI options.'''
+
+import click
+
+url_option = click.option(                                                      # URL option
+    '-u', '--url', 'url',
+    type=str, required=False,
+    default='http://127.0.0.1:8000',
+    help='Base URL to the Rekono backend'
+)
+
+headers_option = click.option(                                                  # Extra request header option
+    '-h', '--header', 'headers',
+    multiple=True, type=str,
+    required=False, default=[],
+    help='HTTP header to send in format "<key>=value"'
+)
+
+parameters_option = click.option(                                               # Request parameter option
+    '-p', '--parameter', 'parameters',
+    multiple=True, type=str,
+    required=False, default=[],
+    help='HTTP parameter to send in format "<key>=value"'
+)
+
+body_option = click.option(                                                     # Request body option
+    '-b', '--body', 'body',
+    multiple=False, type=str, required=False,
+    help='HTTP body to send in JSON format'
+)
+
+no_verify_option = click.option(                                                # Option to disable TLS verification
+    '-n', '--no-verify', 'no_verify',
+    is_flag=True, default=False,
+    help='Disable TLS verification'
+)
+
+all_pages_option = click.option(                                                # Option to iterate over all API pages
+    '-a', '--all-pages', 'all_pages',
+    is_flag=True, default=False,
+    help='Perform pagination over all pages'
+)
+
+show_headers_option = click.option(                                             # Option to show response headers
+    '-S', '--show-headers', 'show_headers',
+    is_flag=True, default=False,
+    help='Show response headers'
+)
+
+status_code_option = click.option(                                              # Option to only show response status
+    '-s', '--status-code', 'just_show_status_code',
+    is_flag=True, default=False,
+    help='Just show response status code'
+)
+
+quiet_option = click.option(                                                    # Option to don't show anything
+    '-q', '--quiet', 'quiet',
+    is_flag=True, default=False,
+    help='Don\'t show anything from response'
+)
+
+json_option = click.option(                                                     # JSON output option
+    '-j', '--json', 'json_output',
+    type=str, required=False,
+    help='Save response data in JSON file'
+)
diff --git a/src/rekono/main.py b/src/rekono/main.py
index 84b5712..627352b 100644
--- a/src/rekono/main.py
+++ b/src/rekono/main.py
@@ -1,17 +1,18 @@
+'''Main Rekono CLI exectuable.'''
+
 import click
 
 from rekono import VERSION
-from rekono.api.commands import api
+from rekono.commands.api import api
 
 
 @click.group()
 @click.version_option(version=VERSION, message='%(version)s')
 def rekono():
     '''Rekono CLI.'''
-    pass
 
 
-# Add Rekono commands
+# Add CLI commands
 rekono.add_command(api)
 
 
diff --git a/src/requirements.txt b/src/requirements.txt
index 1b407de..a335fc4 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -1,4 +1,4 @@
-click==8.0.4
-GitPython==3.1.27
-PyYAML==6.0
-requests==2.28.1
\ No newline at end of file
+click==8.1.3
+coverage==7.1.0
+pytest==7.2.1
+requests==2.28.2
\ No newline at end of file
diff --git a/src/setup.py b/src/setup.py
index 24e98a2..d2308b7 100644
--- a/src/setup.py
+++ b/src/setup.py
@@ -6,7 +6,7 @@
 from rekono import VERSION
 
 current_directory = os.path.dirname(os.path.realpath(__file__))
-with open(os.path.join(current_directory, 'README.md'), 'r') as readme:
+with open(os.path.join(current_directory, '..', 'README.md'), 'r') as readme:
     long_description = readme.read()
 
 with open(os.path.join(current_directory, 'requirements.txt'), 'r') as requirements:
@@ -14,9 +14,9 @@
 
 setup(
     name='rekono-cli',
-    packages=find_packages(),
+    packages=find_packages(exclude=['tests']),
     version=VERSION,
-    description='CLI to manage Rekono',
+    description='CLI to make requests to Rekono API',
     long_description=long_description,
     long_description_content_type='text/markdown',
     author='Pablo Santiago López',


Test patch:
diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml
new file mode 100644
index 0000000..b2c53f4
--- /dev/null
+++ b/.github/workflows/unit-testing.yml
@@ -0,0 +1,35 @@
+name: Unit testing
+on:
+  workflow_dispatch:
+  pull_request:
+    paths:
+      - 'src/rekono/**'
+      - 'src/tests/**'
+      - 'src/requirements.txt'
+
+env:
+  REQUIRED_COVERAGE: 95
+
+jobs:
+  unit-testing:
+    name: Unit testing
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: ./src
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - uses: actions/setup-python@v4
+        with:
+          python-version: '3.9'
+
+      - name: Install Python dependencies
+        run: python3 -m pip install -r requirements.txt
+
+      - name: Run unit tests
+        run: coverage run -m pytest
+      
+      - name: Check coverage
+        run: coverage report -m --skip-covered --omit="tests/*,rekono/client/*" --fail-under=$REQUIRED_COVERAGE
diff --git a/src/tests/__init__.py b/src/tests/__init__.py
new file mode 100644
index 0000000..52ede6b
--- /dev/null
+++ b/src/tests/__init__.py
@@ -0,0 +1,1 @@
+'''Unit tests for Rekono CLI.'''
diff --git a/src/tests/framework.py b/src/tests/framework.py
new file mode 100644
index 0000000..6789a89
--- /dev/null
+++ b/src/tests/framework.py
@@ -0,0 +1,91 @@
+'''Framework for unit testing of Rekono CLI.'''
+
+import json
+import os
+from typing import Any, Dict, List, Optional, Union
+from unittest import TestCase
+
+import click
+from click.testing import CliRunner
+
+from tests.mock import RekonoMock
+
+
+class RekonoCommandTest(TestCase):
+    '''Framework for unit testing of Rekono CLI.'''
+
+    command: Optional[click.BaseCommand] = None                                 # CLI command to test
+
+    def _test(
+        self,
+        arguments: List[str],
+        output: Optional[str] = None,
+        exit_code: int = 0,
+        json_file: Optional[str] = None,
+        invalid_url: bool = False
+    ) -> None:
+        '''Test Rekono CLI command.
+
+        Args:
+            arguments (List[str]): Command arguments
+            output (Optional[str], optional): Expected output. Defaults to None.
+            exit_code (int, optional): Expected HTTP status code. Defaults to 0.
+            json_file (Optional[str], optional): JSON file where the output is saved. Defaults to None.
+            invalid_url (bool, optional): Test command with invalid URL option. Defaults to False.
+        '''
+        if not self.command:                                                    # Check CLI command
+            return
+        credential = 'test'
+        runner = CliRunner()                                                    # Create CLI runner for testing
+        input_value = f'{credential}\n{credential}\n'                           # Input value with basic credentials
+        prefix = f'Username: {credential}\nPassword: \n'                        # Prefix for expected output
+        if invalid_url:                                                         # Invalid URL is enabled
+            input_value += f'{RekonoMock.url}\n'                                # Add URL as input value
+            # Add invalid URL message to output
+            prefix += f'{click.style("URL is invalid", fg="red")}\nURL: {RekonoMock.url}\n'
+        result = runner.invoke(self.command, arguments, input=input_value)      # Invoke CLI command
+        terminal_output = prefix + (f'{output}\n' if output else '')            # Expected CLI command
+        self.assertEqual(exit_code, result.exit_code)
+        self.assertEqual(terminal_output, result.output)
+        if json_file:                                                           # If JSOn file provided
+            self.assertTrue(os.path.isfile(json_file))
+            with open(json_file, 'r', encoding='utf-8') as file:                # Open file to check its content
+                self.assertEqual(output, file.read())
+            os.remove(json_file)                                                # Remove testing file
+
+    def _json_body(self, content: Union[Dict[str, Any], List[Dict[str, Any]]]) -> str:
+        '''Create JSON body from content.
+
+        Args:
+            content (Union[Dict[str, Any], List[Dict[str, Any]]]): Content to include in JSON body.
+
+        Returns:
+            str: JSON body.
+        '''
+        return json.dumps(content, ensure_ascii=True, indent=4)
+
+    def _expected_output_with_headers(
+        self,
+        method: str,
+        status_code: int,
+        response_headers: Dict[str, Any],
+        expected_output: Union[Dict[str, Any], List[Dict[str, Any]]]
+    ) -> str:
+        '''Get expected output with HTTP response headers.
+
+        Args:
+            method (str): HTTP method used in request.
+            status_code (int): Expected status code.
+            response_headers (Dict[str, Any]): Expected HTTP response headers.
+            expected_output (_type_, optional): Expected HTTP response body.
+
+        Returns:
+            str: Expected output with HTTP response headers.
+        '''
+        # Add request and response summary
+        expected_output_with_headers = f'\n{method.upper()} /api/entities/ {status_code}\n'
+        for key, value in response_headers.items():
+            expected_output_with_headers += f'{key}: {value}\n'                 # Add header values
+        expected_output_with_headers += '\n'
+        expected_output_with_headers += self._json_body(expected_output)        # Add expected body
+        return expected_output_with_headers
diff --git a/src/tests/mock.py b/src/tests/mock.py
new file mode 100644
index 0000000..b501f40
--- /dev/null
+++ b/src/tests/mock.py
@@ -0,0 +1,99 @@
+'''Rekono API client mock.'''
+
+import json
+from typing import Any, Dict, List, Optional, Union
+
+from requests.models import Request, Response
+from requests.structures import CaseInsensitiveDict
+
+
+class RekonoMock:
+    '''Rekono API client mock.'''
+
+    data = {                                                                    # Default body data
+        'id': 1,
+        'name': 'rekono',
+        'description': 'test'
+    }
+    url = 'https://rekono.test'                                                 # Rekono base URL for testing
+    headers = {                                                                 # HTTP headers for testing
+        'Header': 'Value'
+    }
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        '''Mock constructor for Rekono API client.'''
+
+    def _response_factory(
+        self,
+        method: str,
+        status_code: int,
+        content: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None
+    ) -> Response:
+        '''Create moked responses for Rekono API.
+
+        Args:
+            method (str): HTTP method used in the HTTP request.
+            status_code (int): Response status code.
+            content (Optional[Union[Dict[str, Any], List[Dict[str, Any]]]], optional): Response content. Defaults to None.  # noqa: E501
+
+        Returns:
+            Response: HTTP response.
+        '''
+        response = Response()
+        response.request = Request(method=method, url=self.url + '/api/entities/').prepare()    # Set related request
+        response.status_code = status_code
+        response.headers = CaseInsensitiveDict(self.headers)
+        response._content = json.dumps(content, ensure_ascii=True, indent=4).encode() if content else None  # Set body
+        return response
+
+    def get(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock GET request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('GET', 200, self.data)
+
+    def get_multiple_entities(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock GET request to Rekono API with multiple items.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('GET', 200, [self.data, self.data, self.data])
+
+    def get_paginated_entities(self, *args: Any, **kwargs: Any) -> List[Response]:
+        '''Mock GET request to Rekono API with pagination.
+
+        Returns:
+            List[Response]: List of HTTP responses.
+        '''
+        return [
+            self._response_factory('GET', 200, self.data),
+            self._response_factory('GET', 200, self.data),
+            self._response_factory('GET', 200, self.data)
+        ]
+
+    def post(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock POST request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('POST', 201, self.data)
+
+    def put(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock PUT request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('PUT', 200, self.data)
+
+    def delete(self, *args: Any, **kwargs: Any) -> Response:
+        '''Mock DELETE request to Rekono API.
+
+        Returns:
+            Response: HTTP response.
+        '''
+        return self._response_factory('DELETE', 204)
diff --git a/src/tests/test_api.py b/src/tests/test_api.py
new file mode 100644
index 0000000..168fe2e
--- /dev/null
+++ b/src/tests/test_api.py
@@ -0,0 +1,96 @@
+'''Test "api" CLI command.'''
+
+import json
+from unittest.mock import patch
+
+import click
+
+from rekono.commands.api import api
+from tests.framework import RekonoCommandTest
+from tests.mock import RekonoMock
+
+
+class ApiTest(RekonoCommandTest):
+    '''Test "api" CLI command.'''
+
+    command = api                                                               # Set CLI command
+
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_get(self) -> None:
+        '''Test "api get" command.'''
+        self._test(
+            ['get', '-u', RekonoMock.url, '-p', 'key1=value1', '-p', 'key2=value2', '-n', 'entities/1'],
+            output=self._json_body(RekonoMock.data)
+        )
+        self._test(                                                             # Test command with invalid URL option
+            ['get', '-u', 'invalidurl', 'entities/1'],
+            output=self._json_body(RekonoMock.data),
+            invalid_url=True
+        )
+
+    @patch('rekono.framework.commands.Rekono.get', RekonoMock.get_multiple_entities)
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_get_multiple(self) -> None:
+        '''Test "api get" command with multiple entities as response.'''
+        self._test(
+            ['get', '-h', 'key1=value1', '-h', 'key2=value2', '--no-verify', 'entities'],
+            output=self._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        )
+
+    @patch('rekono.framework.commands.Rekono.get', RekonoMock.get_paginated_entities)
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_get_all_pages(self) -> None:
+        '''Test "api get" command with paginated responses.'''
+        self._test(
+            ['get', '--all-pages', '--no-verify', 'entities'],
+            output=self._json_body([RekonoMock.data, RekonoMock.data, RekonoMock.data])
+        )
+
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_post(self) -> None:
+        '''Test "api post" command.'''
+        self._test(
+            ['post', '-b', json.dumps(RekonoMock.data), '-h', 'key1=value1', '-h', 'key2=value2', '-n', 'entities'],
+            output=self._json_body(RekonoMock.data)
+        )
+        self._test(                                                             # Test command with invalid body option
+            ['post', '-b', 'invalid JSON value', 'entities'],
+            output=click.style('Invalid JSON format for body value', fg='red'),
+            exit_code=1
+        )
+
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_put(self) -> None:
+        '''Test "api put" command.'''
+        self._test(
+            ['put', '-b', json.dumps(RekonoMock.data), '-h', 'key1=value1', '-h', 'key2=value2', '-n', 'entities/1'],
+            output=self._json_body(RekonoMock.data)
+        )
+
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_delete(self) -> None:
+        '''Test "api delete" command.'''
+        self._test(
+            ['delete', '-h', 'key1=value1', '-h', 'key2=value2', '--no-verify', 'projects/1'],
+            output=self._json_body([])
+        )
+
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_display_options(self) -> None:
+        '''Test display options with "api get" command.'''
+        self._test(                                                             # Test show headers option
+            ['get', '--show-headers', 'entities/1'],
+            output=self._expected_output_with_headers('GET', 200, RekonoMock.headers, RekonoMock.data)
+        )
+        self._test(['get', '--status-code', 'entities/1'], output='200')        # Test status code option
+        self._test(['get', '--quiet', 'entities/1'])                            # Test quiet option
+
+    @patch('rekono.framework.commands.Rekono', RekonoMock)
+    def test_json_output(self) -> None:
+        '''Test JSON option with "api get" command.'''
+        test_filepath = 'test_json_export.json'
+        self._test(
+            ['get', '--json', test_filepath, 'entities/1'],
+            output=self._json_body(RekonoMock.data),
+            json_file=test_filepath
+        )