From dc94c592d0ac74c00de5904a650a1adcb5ccada7 Mon Sep 17 00:00:00 2001 From: enrique Date: Thu, 16 May 2024 12:16:01 -0600 Subject: [PATCH] Adding CreateClientFunction --- .github/workflows/clients_pipeline.yaml | 61 +++++ src/clients/__init__.py | 0 src/clients/client_modules/dao/__init__.py | 0 src/clients/client_modules/dao/client_dao.py | 30 +++ .../client_modules/data_access/__init__.py | 0 .../data_access/dynamo_handler.py | 245 ++++++++++++++++++ .../data_access/geolocation_handler.py | 54 ++++ .../client_modules/data_mapper/__init__.py | 0 .../data_mapper/client_mapper.py | 83 ++++++ src/clients/client_modules/errors/__init__.py | 0 .../client_modules/errors/auth_error.py | 6 + .../client_modules/errors/base_error.py | 43 +++ .../client_modules/errors/util_error.py | 6 + src/clients/client_modules/models/__init__.py | 0 src/clients/client_modules/models/client.py | 14 + src/clients/client_modules/utils/__init__.py | 0 src/clients/client_modules/utils/aws.py | 54 ++++ src/clients/client_modules/utils/doorman.py | 193 ++++++++++++++ src/clients/client_modules/utils/encoders.py | 9 + src/clients/lambda_function.py | 94 +++++++ src/clients/samconfig.toml | 9 + src/clients/settings.py | 5 + src/clients/template.yaml | 157 +++++++++++ 23 files changed, 1063 insertions(+) create mode 100644 .github/workflows/clients_pipeline.yaml create mode 100644 src/clients/__init__.py create mode 100644 src/clients/client_modules/dao/__init__.py create mode 100644 src/clients/client_modules/dao/client_dao.py create mode 100644 src/clients/client_modules/data_access/__init__.py create mode 100644 src/clients/client_modules/data_access/dynamo_handler.py create mode 100644 src/clients/client_modules/data_access/geolocation_handler.py create mode 100644 src/clients/client_modules/data_mapper/__init__.py create mode 100644 src/clients/client_modules/data_mapper/client_mapper.py create mode 100644 src/clients/client_modules/errors/__init__.py create mode 100644 src/clients/client_modules/errors/auth_error.py create mode 100644 src/clients/client_modules/errors/base_error.py create mode 100644 src/clients/client_modules/errors/util_error.py create mode 100644 src/clients/client_modules/models/__init__.py create mode 100644 src/clients/client_modules/models/client.py create mode 100644 src/clients/client_modules/utils/__init__.py create mode 100644 src/clients/client_modules/utils/aws.py create mode 100644 src/clients/client_modules/utils/doorman.py create mode 100644 src/clients/client_modules/utils/encoders.py create mode 100644 src/clients/lambda_function.py create mode 100644 src/clients/samconfig.toml create mode 100644 src/clients/settings.py create mode 100644 src/clients/template.yaml diff --git a/.github/workflows/clients_pipeline.yaml b/.github/workflows/clients_pipeline.yaml new file mode 100644 index 0000000..144fc66 --- /dev/null +++ b/.github/workflows/clients_pipeline.yaml @@ -0,0 +1,61 @@ +name: clients_pipeline + +on: + push: + paths: + - 'src/clients/**' + branches: + - 'main' + - 'feature**' + workflow_dispatch: + +jobs: + run_unit_tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + make install + working-directory: ./tests + - name: Run tests + run: | + make test + working-directory: ./tests + + development: + if: startsWith(github.event.ref, 'refs/heads/main') + uses: ./.github/workflows/pipeline_template.yaml + needs: [run_unit_tests] + with: + sam_deploy_overrides: "StageName=development" + stack_name: ${{ vars.CLIENTS_STACK_NAME }}-development + sam_template: src/clients/template.yaml + aws_region: us-east-1 + pipeline_execution_role: ${{ vars.PIPELINE_EXECUTION_ROLE_DEV }} + cloudformation_execution_role: ${{ vars.CLOUDFORMATION_EXECUTION_ROLE_DEV }} + artifacts_bucket: ${{ vars.ARTIFACTS_BUCKET_DEV }} + stage_name: development + secrets: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID_MBU }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY_MBU }} + uat: + if: startsWith(github.event.ref, 'refs/heads/main') + uses: ./.github/workflows/pipeline_template.yaml + needs: [development] + with: + sam_deploy_overrides: "StageName=uat LogLevel=DEBUG" + stack_name: ${{ vars.CLIENTS_STACK_NAME }}-uat + sam_template: src/clients/template.yaml + aws_region: us-east-1 + pipeline_execution_role: ${{ vars.PIPELINE_EXECUTION_ROLE_UAT }} + cloudformation_execution_role: ${{ vars.CLOUDFORMATION_EXECUTION_ROLE_UAT }} + artifacts_bucket: ${{ vars.ARTIFACTS_BUCKET_UAT }} + stage_name: uat + secrets: + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID_HUAT }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY_HUAT }} diff --git a/src/clients/__init__.py b/src/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/client_modules/dao/__init__.py b/src/clients/client_modules/dao/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/client_modules/dao/client_dao.py b/src/clients/client_modules/dao/client_dao.py new file mode 100644 index 0000000..4d78d0c --- /dev/null +++ b/src/clients/client_modules/dao/client_dao.py @@ -0,0 +1,30 @@ +# Own's modules +from client_modules.data_access.dynamo_handler import DynamoDBHandler +import settings + + +class ClientDAO: + """ + A class for handling interactions with the DynamoDB table and the Lambda Function. + """ + + def __init__(self): + """ + Initializes a new instance of the DAO class. + """ + self.clients_db = DynamoDBHandler( + table_name=settings.CLIENTS_TABLE_NAME, + partition_key="Id", + ) + + def create_client(self, item: dict) -> dict: + """ + Attempts to insert a new record for a client into the DynamoDB table. + + :param item: Client representation + :type item: dict + :return: a dictionary that contains the response object + :rtype: dict + """ + response = self.clients_db.insert_record(item) + return response diff --git a/src/clients/client_modules/data_access/__init__.py b/src/clients/client_modules/data_access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/client_modules/data_access/dynamo_handler.py b/src/clients/client_modules/data_access/dynamo_handler.py new file mode 100644 index 0000000..f24ac24 --- /dev/null +++ b/src/clients/client_modules/data_access/dynamo_handler.py @@ -0,0 +1,245 @@ +# Python libraries +import json +from decimal import Decimal +from typing import Dict +from typing import Any + +# Own modules +from client_modules.utils.aws import AWSClientManager + +# Third-party libraries +from aws_lambda_powertools import Logger +from botocore.exceptions import ClientError + + +class DynamoDBHandler: + """ + A class for handling interactions with a DynamoDB table + """ + + HTTP_STATUS_OK = 200 + HTTP_STATUS_CREATED = 201 + HTTP_STATUS_NO_CONTENT = 204 + HTTP_STATUS_BAD_REQUEST = 400 + HTTP_STATUS_FORBIDDEN = 403 + HTTP_STATUS_NOT_FOUND = 404 + HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 + + def __init__(self, table_name: str, partition_key: str, sort_key: str = None): + self.table_name = table_name + self.partition_key = partition_key + self.sort_key = sort_key + aws_resources_manager = AWSClientManager() + dynamodb_resource = aws_resources_manager.dynamodb + self.table = dynamodb_resource.Table(table_name) + self.logger = Logger() + + def insert_record(self, item: dict) -> Dict[str, Any]: + """This function is used to save a record to a database. + It takes in a dictionary, which is build from a Client Model, as an argument and attempts to put the item into the database. + If the response from the database is successful, it returns a status of "success". + If not, it returns a status of "error" along with the HTTP status code and details about the error message. + If there is an AWS ClientError, it logs information about the error and also returns a status of "error" along + with the HTTP status code and details about the error message. + + :param item: client representation built as a dict + :type item: dict + :return: A summary of the put_item action + :rtype: Dict[str, Any] + """ + try: + db_item = json.loads(json.dumps(item), parse_float=Decimal) + response = self.table.put_item(Item=db_item) + if response["ResponseMetadata"]["HTTPStatusCode"] == self.HTTP_STATUS_OK: + self.logger.info("Client was created in DynamoDB") + return self.build_response_object( + status="success", + status_code=self.HTTP_STATUS_CREATED, + message="Record saved in DynamoDB", + ) + else: + message = response["Error"]["Message"] + self.logger.error(f"Failed saving record: Details: {message}") + return self.build_response_object( + status="error", + status_code=response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except ClientError as error: + message = f"{error.response['Error']['Message']}. {error.response['Error']['Code']}" + self.logger.error(f"ClientError when saving record: Details: {message}") + return self.build_response_object( + status="error", + status_code=error.response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except Exception as error: + self.logger.error(f"Exception when saving record: Details: {error}") + return self.build_response_object( + status="error", + status_code=self.HTTP_STATUS_INTERNAL_SERVER_ERROR, + message=str(error), + ) + + def scan_table(self) -> Dict[str, Any]: + """This function is used to fetch all the records in the table. + If the response from the database is successful, it returns a status of "success". + If not, it returns a status of "error" along with the HTTP status code and details about the error message. + If there is an AWS ClientError, it logs information about the error and also returns a status of "error" along + with the HTTP status code and details about the error message. + + :return: A summary of the put_item action + :rtype: Dict[str, Any] + """ + try: + response = self.table.scan() + if response["ResponseMetadata"]["HTTPStatusCode"] == self.HTTP_STATUS_OK: + self.logger.info("Clients were retrieved from DynamoDB") + return self.build_response_object( + status="success", + status_code=self.HTTP_STATUS_OK, + message=f"{len(response['Items'])} were found in DynamoDB Clients table", + payload=response["Items"], + ) + else: + message = response["Error"]["Message"] + self.logger.error(f"Failed saving record: Details: {message}") + return self.build_response_object( + status="error", + status_code=response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except ClientError as error: + message = f"{error.response['Error']['Message']}. {error.response['Error']['Code']}" + self.logger.error(f"ClientError when saving record: Details: {message}") + return self.build_response_object( + status="error", + status_code=error.response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except Exception as error: + self.logger.error(f"Exception when saving record: Details: {error}") + return self.build_response_object( + status="error", + status_code=self.HTTP_STATUS_INTERNAL_SERVER_ERROR, + message=str(error), + ) + + def delete_record(self, key: dict) -> Dict[str, Any]: + """ + This function is used to delete a record from the DynamoDB table. + It takes in a key dictionary as an argument and attempts to delete the item from the database. + If the response from the database is successful, it returns a status of "success". + If there is an AWS ClientError, it logs information about the error and also returns a status of "error" along + with the HTTP status code and details about the error message. + + :param key: The key of the item to be deleted + :type key: dict + :return: A summary of the delete_item action + :rtype: Dict[str, Any] + """ + try: + response = self.table.delete_item(Key=key) + if response["ResponseMetadata"]["HTTPStatusCode"] == self.HTTP_STATUS_OK: + self.logger.info(f"Client with key {key} was deleted from DynamoDB") + return self.build_response_object( + status="success", + status_code=self.HTTP_STATUS_OK, + message="Record deleted from DynamoDB", + ) + else: + message = response["Error"]["Message"] + self.logger.error(f"Failed deleting record: Details: {message}") + return self.build_response_object( + status="error", + status_code=response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except ClientError as error: + message = f"{error.response['Error']['Message']}. {error.response['Error']['Code']}" + self.logger.error(f"ClientError when deleting record: Details: {message}") + return self.build_response_object( + status="error", + status_code=error.response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except Exception as error: + self.logger.error(f"Exception when deleting record: Details: {error}") + return self.build_response_object( + status="error", + status_code=self.HTTP_STATUS_INTERNAL_SERVER_ERROR, + message=str(error), + ) + + def update_record(self, item: dict) -> Dict[str, Any]: + """ + This function is used to update a record in the database using put_item. + If the item already exists, it will be updated. + + :param item: Item as dict + :type item: dict + :return: A summary of the put_item action + :rtype: Dict[str, Any] + """ + try: + db_item = json.loads(json.dumps(item), parse_float=Decimal) + response = self.table.put_item(Item=db_item) + if response["ResponseMetadata"]["HTTPStatusCode"] == self.HTTP_STATUS_OK: + self.logger.info("Client was updated in DynamoDB") + return self.build_response_object( + status="success", + status_code=self.HTTP_STATUS_OK, + message="Record updated in DynamoDB", + ) + else: + message = response["Error"]["Message"] + self.logger.error(f"Failed updating record: Details: {message}") + return self.build_response_object( + status="error", + status_code=response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except ClientError as error: + message = f"{error.response['Error']['Message']}. {error.response['Error']['Code']}" + self.logger.error(f"ClientError when updating record: Details: {message}") + return self.build_response_object( + status="error", + status_code=error.response["ResponseMetadata"]["HTTPStatusCode"], + message=message, + ) + except Exception as error: + self.logger.error(f"Exception when updating record: Details: {error}") + return self.build_response_object( + status="error", + status_code=self.HTTP_STATUS_INTERNAL_SERVER_ERROR, + message=str(error), + ) + + def build_response_object( + self, + status: str, + status_code: int, + message: str, + payload: Dict[str, Any] = None, + ) -> Dict[str, Any]: + """ + This method maps an status code a message into the response dictionary + + :param status: Success or Error + :type status: str + :param status_code: Http Status Code + :type status_code: int + :param message: A string that contains the message to be returned + :type error_message: str + :param payload: Object with data from DynamoDb + :type error_message: dict + :return: a dictionary with the message + :rtype: Dict[str, Any] + """ + + return { + "status": status, + "status_code": status_code, + "message": message, + "payload": payload, + } diff --git a/src/clients/client_modules/data_access/geolocation_handler.py b/src/clients/client_modules/data_access/geolocation_handler.py new file mode 100644 index 0000000..7b346f8 --- /dev/null +++ b/src/clients/client_modules/data_access/geolocation_handler.py @@ -0,0 +1,54 @@ +# Python's libraries +from typing import Dict + +# Own's modules +from client_modules.utils.aws import AWSClientManager +import settings + +# Third-party libraries +from aws_lambda_powertools import Logger + + +class Geolocation: + + def get_lat_and_long_from_street_address( + self, str_address: str + ) -> Dict[str, float]: + """This function will check AWS Locatio Service to match an address with a geolocation coordinates + + + :param str_address: String representation of the address received by client inside the inputs payload + :type str_address: str + """ + logger = Logger() + if settings.environment != "local": + aws_resources = AWSClientManager() + location = None + if isinstance(str_address, str): + try: + aws_repsonse = aws_resources.location.search_place_index_for_text( + IndexName="HiBerrySearchLocationIndex", Text=str_address + ) + if aws_repsonse["ResponseMetadata"]["HTTPStatusCode"] == 200: + logger.info(f"Location: {str_address} found") + lat = aws_repsonse["Results"][0]["Place"]["Geometry"]["Point"][ + 1 + ] + long = aws_repsonse["Results"][0]["Place"]["Geometry"]["Point"][ + 0 + ] + location = {"latitude": lat, "longitude": long} + else: + logger.warning("AWS Response was not successfull") + + except Exception as e: + logger.warning( + f"Something failed while fetching data from AWS. Details {e}" + ) + else: + logger.warning("Input provided was not a string") + + return location + + else: + return {"latitude": 20.721722843875, "longitude": -103.370054309085} diff --git a/src/clients/client_modules/data_mapper/__init__.py b/src/clients/client_modules/data_mapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/client_modules/data_mapper/client_mapper.py b/src/clients/client_modules/data_mapper/client_mapper.py new file mode 100644 index 0000000..a331505 --- /dev/null +++ b/src/clients/client_modules/data_mapper/client_mapper.py @@ -0,0 +1,83 @@ +# Python's libraries +from typing import Dict +from typing import Any +from datetime import datetime + +# Own's modules +from client_modules.data_access.geolocation_handler import Geolocation + +# Third-party libraries +from aws_lambda_powertools import Logger + + +class ClientHelper: + def __init__( + self, client_data: Dict[str, Any], location_service: Geolocation = None + ): + self.client_data = client_data + self.logger = Logger() + self.location_service = location_service or Geolocation() + + def fetch_geolocation(self) -> Dict[str, float]: + """ + Fetches geolocation data based on the client data's address. + + :return: A dictionary with latitude and longitude + :rtype: Dict[str, float] + """ + geolocation = self.client_data.get("geolocation", None) + if geolocation is None: + self.logger.info( + "Input did not include geolocation data, invoking Geolocation Service" + ) + geolocation = self.location_service.get_lat_and_long_from_street_address( + str_address=self.client_data.get("address") + ) + return geolocation + else: + self.logger.info("Using provided geolocation data from input") + return geolocation + + def build_client( + self, + username: str, + ) -> Dict[str, Any]: + """This function will create a dictionary to send to DynamoDB to create a new record + + Arguments: + username -- who is sending the request + + Returns: + Object needed by DynamoDB to create a record + """ + client_errors = [] + latitude = None + longitude = None + + geolocation = self.fetch_geolocation() + if geolocation is None: + self.logger.info( + "Geolocation Data is missing, adding to the list of errors" + ) + client_errors.append( + { + "code": "ADDRESS_NEEDS_GEO", + "value": "Client requires geolocation coordinates to be updated manually", + } + ) + else: + latitude = float(geolocation.get("latitude", 0)) + longitude = float(geolocation.get("longitude", 0)) + + data = { + "phone_number": self.client_data["phone_number"], + "name": self.client_data["name"], + "address": self.client_data["address"], + "latitude": latitude, + "longitude": longitude, + "email": self.client_data["email"], + "errors": client_errors, + "created_by": username, + "created_at": datetime.now().isoformat(), + } + return data diff --git a/src/clients/client_modules/errors/__init__.py b/src/clients/client_modules/errors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/client_modules/errors/auth_error.py b/src/clients/client_modules/errors/auth_error.py new file mode 100644 index 0000000..59deffd --- /dev/null +++ b/src/clients/client_modules/errors/auth_error.py @@ -0,0 +1,6 @@ +# Own's Libraries +from client_modules.errors.base_error import BaseError + + +class AuthError(BaseError): + pass diff --git a/src/clients/client_modules/errors/base_error.py b/src/clients/client_modules/errors/base_error.py new file mode 100644 index 0000000..f47bb9f --- /dev/null +++ b/src/clients/client_modules/errors/base_error.py @@ -0,0 +1,43 @@ +# Python's Libraries +import logging + + +class BaseError(Exception): + + def __init__( + self, + _message, + _error=None, + _logger=None, + _source=None, + ): + self.logger = _logger or logging.getLogger(__name__) + + self.message = _message + self.source = _source + self.error = _error + + self.logger.error(self.__get_FullMsgError()) + + super().__init__(_message) + + def __get_FullMsgError(self): + value = f"{self.message}" + if self.source: + value = f"{value} ({self.source})" + + if self.error: + value = f"{value}: {self.error}" + + return value + + def __str__(self): + return self.__get_FullMsgError() + + +class ValidationError(BaseError): + pass + + +class SystemError(BaseError): + pass diff --git a/src/clients/client_modules/errors/util_error.py b/src/clients/client_modules/errors/util_error.py new file mode 100644 index 0000000..25aeb75 --- /dev/null +++ b/src/clients/client_modules/errors/util_error.py @@ -0,0 +1,6 @@ +# Own's Libraries +from client_modules.errors.base_error import BaseError + + +class UtilError(BaseError): + pass diff --git a/src/clients/client_modules/models/__init__.py b/src/clients/client_modules/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/client_modules/models/client.py b/src/clients/client_modules/models/client.py new file mode 100644 index 0000000..76c1082 --- /dev/null +++ b/src/clients/client_modules/models/client.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel +from pydantic import StrictStr, StrictFloat + + +class HIBerryClient(BaseModel): + phone_number: str + name: StrictStr + address: str + email: StrictStr | None = None + + +class Geolocation(BaseModel): + latitude: StrictFloat + longitude: StrictFloat diff --git a/src/clients/client_modules/utils/__init__.py b/src/clients/client_modules/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clients/client_modules/utils/aws.py b/src/clients/client_modules/utils/aws.py new file mode 100644 index 0000000..79b0881 --- /dev/null +++ b/src/clients/client_modules/utils/aws.py @@ -0,0 +1,54 @@ +import boto3 + + +class AWSClientManager: + """ + This class is a manager for + AWS resources, sessions, and clients. + The class support the most used resources + accross the project. + - s3 + - dynamodb + - location + + """ + + def __init__(self): + """ + It's init some most used resources + """ + self.lambda_client = self.get_client("lambda") + self.s3_resource = self.get_resource("s3") + self.s3_client = self.get_client("s3") + self.dynamodb = self.get_resource("dynamodb") + self.location = self.get_client("location") + + def get_session(self, region="us-east-1"): + """ + get a session using boto3 native session methods + PARAMS: + - region: Region Name + RETURNS: + - boto3 AWS session + """ + return boto3.Session(region_name=region) + + def get_resource(self, resource: str, region="us-east-1"): + """ + get a resource using boto3 native resource method + PARAMS: + - resource: Resource Name + RETURNS: + - boto3 AWS resource + """ + return boto3.resource(resource, region_name=region) + + def get_client(self, resource: str, region="us-east-1"): + """ + get a client using boto3 native client method + PARAMS: + - resource: Resource Name + RETURNS: + - boto3 AWS client + """ + return boto3.client(resource, region_name=region) diff --git a/src/clients/client_modules/utils/doorman.py b/src/clients/client_modules/utils/doorman.py new file mode 100644 index 0000000..31d6823 --- /dev/null +++ b/src/clients/client_modules/utils/doorman.py @@ -0,0 +1,193 @@ +# Python's libraries +import json +import os + +# Own's modules +from client_modules.errors.util_error import UtilError +from client_modules.utils.encoders import DecimalEncoder +from client_modules.errors.auth_error import AuthError +from settings import environment + +# Third-party libraries +from aws_lambda_powertools import Logger + +ACCESS_RULES = { + "Admin": [ + "GetAllClientsFunction", + "CreateClientFunction", + "DeleteClientFunction", + "UpdateClientFunction", + ], + "MesaDeControl": [ + "GetAllClientsFunction", + "CreateClientFunction", + "UpdateClientFunction", + ], + "Repartidor": [], +} + + +class DoormanUtil(object): + + def __init__(self, _request, _logger=None): + self.logger = _logger or Logger() + self.request = _request + + def get_body_from_request(self): + if "body" not in self.request: + raise UtilError( + _message="There is no body in request data", + _error=None, + _logger=self.logger, + ) + + if self.request["body"] is None: + raise UtilError( + _message="The body node is null", + _error=None, + _logger=self.logger, + ) + try: + body = json.loads(self.request["body"]) + except Exception as e: + raise UtilError( + _message=f"The body was not a JSON object. Details: {e}", + _error=None, + _logger=self.logger, + ) + + return body + + def get_query_param_from_request(self, _query_param_name, _is_required=False): + if "queryStringParameters" not in self.request: + if _is_required: + raise UtilError( + _message="There is no queryStringParameters in request data", + _error=None, + _logger=self.logger, + ) + else: + return None + + if _query_param_name not in self.request["queryStringParameters"]: + if _is_required: + raise UtilError( + _message=f"There is no {_query_param_name} in queryStringParameters", + _error=None, + _logger=self.logger, + ) + else: + return None + + try: + query_parameters = self.request["queryStringParameters"][_query_param_name] + query_param_value = None + + if query_parameters is None or query_parameters == "": + if _is_required: + raise UtilError( + _message=f"Value of {_query_param_name} is missing", + _error=None, + _logger=self.logger, + ) + + query_param_value = None + + else: + query_param_value = self.request["queryStringParameters"][ + _query_param_name + ] + + return query_param_value + + except Exception as e: + raise UtilError(_message=str(e), _error=str(e), _logger=self.logger) + + def build_response(self, payload: dict, status_code: int) -> dict: + """This code defines the response_success function, which is used to return a response to the client. + The function takes two parameters: payload and status_code. + The payload parameter is used to provide the body of the response, + while the status_code parameter is used to set the status code of the response. + The function then creates a response dictionary with headers that allow for cross-origin requests, as well as setting the status code and body of the response. + Finally, it logs the response and returns it to the client. + + :param _payload: payload to return to client, defaults to None + :type _payload: dict, optional + :param _status_code: HTTP status code, 201 for create + :type _status_code: int, optional + :return: dict with the formatted response + :rtype: dict + """ + response = { + "isBase64Encoded": False, + "statusCode": status_code, + "headers": { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type,Authorization,x-apigateway-header,X-Amz-Date,X-Api-Key,X-Amz-Security-Token", + "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS, DELETE", + }, + "body": json.dumps(payload, cls=DecimalEncoder), + } + + return response + + def get_username_from_context(self): + if environment == "local": + return "Admin" + + try: + email = self.request["requestContext"]["authorizer"]["claims"]["email"] + except KeyError: + raise AuthError(f"Missing context from Api gateway authorizer.") + + return email + + def _is_any_group_authorized(self, group_names: list) -> bool: + """ + Checks if any of the user's groups are authorized to access the current Lambda function. + + Parameters: + - group_names (list): A list of group names to which the user belongs. + - function_name (str): The name of the currently executing Lambda function. + + Returns: + - bool: True if the group is authorized, False otherwise. + """ + current_function_name = os.environ["AWS_LAMBDA_FUNCTION_NAME"] + for group_name in group_names: + if group_name in ACCESS_RULES: + for allowed_function in ACCESS_RULES[group_name]: + if current_function_name.startswith(allowed_function): + return True + + self.logger.error( + f"Group/s: {group_names} are not authorized to access {current_function_name}." + ) + return False + + def auth_user(self): + """ + Authorizes a user based on their Cognito group memberships. This function is intended for a Lambda + function triggered by AWS API Gateway with a Cognito Authorizer. + + The Cognito Authorizer adds user group information to the 'requestContext' in the Lambda event object. + This function extracts these group memberships and checks them against predefined access rules to + determine if the user is authorized to access the current Lambda function. + + Returns: + - bool: True if the user is authorized; False otherwise. + """ + if environment == "local": + return True + + user_groups = [] + try: + groups_string = self.request["requestContext"]["authorizer"]["claims"][ + "cognito:groups" + ] + user_groups = groups_string.split(",") + except KeyError: + return False + + return self._is_any_group_authorized(user_groups) diff --git a/src/clients/client_modules/utils/encoders.py b/src/clients/client_modules/utils/encoders.py new file mode 100644 index 0000000..5644d5c --- /dev/null +++ b/src/clients/client_modules/utils/encoders.py @@ -0,0 +1,9 @@ +import json +from decimal import Decimal + + +class DecimalEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, Decimal): + return str(obj) + return super(DecimalEncoder, self).default(obj) diff --git a/src/clients/lambda_function.py b/src/clients/lambda_function.py new file mode 100644 index 0000000..f0454e5 --- /dev/null +++ b/src/clients/lambda_function.py @@ -0,0 +1,94 @@ +# Python's libraries +from typing import Dict +from typing import Any + +# Own's modules +from client_modules.dao.client_dao import ClientDAO +from client_modules.models.client import HIBerryClient +from client_modules.utils.doorman import DoormanUtil +from client_modules.errors.auth_error import AuthError +from client_modules.data_mapper.client_mapper import ClientHelper + + +# Third-party libraries +from pydantic.error_wrappers import ValidationError +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext + + +def create_client(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]: + """This function is the entry point of this process that wil receive a payload as an input + an will attempt to create an entry in DynamoDB. + + :param event: Custom object that can come from an APIGateway + :type event: Dict + :param context: Regular lambda function context + :type context: LambdaContext + :return: Custom object with the reponse from the lambda, it could be a 201, if the resource was created + or >= 400 if theras was an error + :rtype: Dict + """ + + logger = Logger() + logger.info("Initializing Create Client function") + doorman = DoormanUtil(event, logger) + + try: + username = doorman.get_username_from_context() + is_auth = doorman.auth_user() + if is_auth is False: + raise AuthError(f"User {username} is not authorized to create a client") + + body = doorman.get_body_from_request() + + logger.debug(f"Incoming data is {body=} and {username=}") + + new_client_data = HIBerryClient(**body) + + logger.info( + f"Processing client with phone: {new_client_data.phone_number} and address {new_client_data.address}" + ) + + builder = ClientHelper(new_client_data.model_dump()) + client_db_data = builder.build_client(username=username) + + dao = ClientDAO() + create_response = dao.create_client(client_db_data) + + if create_response["status"] == "success": + logger.info("Client data received and created") + output_data = { + "latitude": client_db_data["latitude"], + "longitude": client_db_data["longitude"], + "errors": client_db_data["errors"] + } + + logger.debug(f"Outgoing data is {output_data=}") + return doorman.build_response(payload=output_data, status_code=201) + else: + return doorman.build_response( + payload={"message": create_response["message"]}, + status_code=create_response.get("status_code", 500), + ) + except ValidationError as validation_error: + error_details = "Some fields failed validation" + if validation_error._error_cache: + error_details = str(validation_error._error_cache) + return doorman.build_response( + payload={"message": error_details}, status_code=400 + ) + + except AuthError as auth_error: + error_details = f"Not authorized. {auth_error}" + logger.error(error_details) + return doorman.build_response( + payload={"message": error_details}, status_code=403 + ) + + except Exception as e: + error_details = f"Error processing the client: {e}" + logger.error(error_details, exc_info=True) + return doorman.build_response( + payload={"message": error_details}, status_code=500 + ) + diff --git a/src/clients/samconfig.toml b/src/clients/samconfig.toml new file mode 100644 index 0000000..0308ae5 --- /dev/null +++ b/src/clients/samconfig.toml @@ -0,0 +1,9 @@ +version = 0.1 +[default.deploy.parameters] +stack_name = "sam-hiberry-clients" +resolve_s3 = true +s3_prefix = "sam-hiberry-clients" +region = "us-east-1" +capabilities = "CAPABILITY_IAM" +parameter_overrides = "StageName=\"development\" LogLevel=\"INFO\"" +image_repositories = [] diff --git a/src/clients/settings.py b/src/clients/settings.py new file mode 100644 index 0000000..b0aafa4 --- /dev/null +++ b/src/clients/settings.py @@ -0,0 +1,5 @@ +# Python's Libraries +import os + +environment = os.environ.get("APP_ENVIRONMENT", "local") +CLIENTS_TABLE_NAME = "Clients" diff --git a/src/clients/template.yaml b/src/clients/template.yaml new file mode 100644 index 0000000..9508b37 --- /dev/null +++ b/src/clients/template.yaml @@ -0,0 +1,157 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + sam-hiberry-app-clients + +Globals: + Function: + Timeout: 120 + Api: + Cors: + AllowHeaders: "'Content-Type,Authorization,authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'" + AllowOrigin: "'*'" + MaxAge: "'3600'" + AllowMethods: "'HEAD,OPTIONS,POST,GET,PUT,DELETE'" + AllowCredentials: "'false'" + GatewayResponses: + DEFAULT_4xx: + ResponseParameters: + Headers: + Access-Control-Allow-Headers: "'Content-Type,Authorization,authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'" + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Methods: "'HEAD,OPTIONS,POST,GET,PUT,DELETE'" + DEFAULT_5xx: + ResponseParameters: + Headers: + Access-Control-Allow-Headers: "'Content-Type,Authorization,authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'" + Access-Control-Allow-Origin: "'*'" + Access-Control-Allow-Methods: "'HEAD,OPTIONS,POST,GET,PUT,DELETE'" + +Parameters: + StageName: + Type: String + Default: development + AllowedValues: + - local + - development + - uat + - prod + LogLevel: + Type: String + Default: INFO + +Resources: + + ClientsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: Clients + AttributeDefinitions: + - AttributeName: phone_number + AttributeType: S + - AttributeName: address + AttributeType: S + KeySchema: + - AttributeName: phone_number + KeyType: HASH + - AttributeName: address + KeyType: RANGE + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + + HiBerrySearchLocationIndex: + Type: AWS::Location::PlaceIndex + Properties: + DataSource: Esri + Description: Place index for locating delivery address Using Esri + IndexName: "HiBerrySearchLocationIndex" + PricingPlan: RequestBasedUsage + + CreateClientFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "CreateClientFunction-${StageName}" + CodeUri: . + Handler: lambda_function.create_client + Runtime: python3.11 + Environment: + Variables: + POWERTOOLS_SERVICE_NAME: create-client + POWERTOOLS_LOG_LEVEL: !Ref LogLevel + APP_ENVIRONMENT: !Ref StageName + Events: + HttpPost: + Type: Api + Properties: + RestApiId: !Ref ApiGateway + Path: /clients + Method: post + Auth: + Authorizer: HiBerryCognitoAuthorizer + Role: !GetAtt CreateClientRole.Arn + + ApiGateway: + Type: AWS::Serverless::Api + Properties: + StageName: !Ref StageName + Auth: + Authorizers: + HiBerryCognitoAuthorizer: + UserPoolArn: + - !Sub + - 'arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPoolId}' + - UserPoolId: !ImportValue HiBerryUserPoolIdExport + + # LambdaInvokePermission: + # Type: AWS::Lambda::Permission + # Properties: + # Action: lambda:InvokeFunction + # FunctionName: !GetAtt CreateClientFunction.Arn + # Principal: apigateway.amazonaws.com + + CreateClientRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "CreateClientRole-${StageName}" + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: DynamoDBPutItemPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:PutItem + Resource: !GetAtt ClientsTable.Arn + - PolicyName: LocationServiceAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - geo:SearchPlaceIndexForText + Resource: !GetAtt HiBerrySearchLocationIndex.Arn + - PolicyName: CloudWatchLogsPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: "*" +Outputs: + CreateClientFunction: + Description: "Lambda Function ARN" + Value: !GetAtt CreateClientFunction.Arn + HttpApiEndpoint: + Description: The default endpoint ApiGateway. + Value: !Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${StageName}/"