diff --git a/.gitignore b/.gitignore index 199e786..39f53b9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ **/__pycache__ .pytest_cache .ruff_cache -junit \ No newline at end of file +junit +aws/dependencies +*.zip \ No newline at end of file diff --git a/aws/build_lambda_code.py b/aws/build_lambda_code.py new file mode 100644 index 0000000..33b38f4 --- /dev/null +++ b/aws/build_lambda_code.py @@ -0,0 +1,84 @@ +import inspect +import re +import os +import subprocess +import shutil +import zipfile +from imaginate_api.date.routes import images_by_date +from imaginate_api.utils import build_result, calculate_date +from imaginate_api.schemas.date_info import DateInfo + + +CWD = os.path.dirname(os.path.realpath(__file__)) +LAMBDA_LIBRARIES = """import os +import json +from enum import Enum +from http import HTTPStatus +from base64 import b64encode + +# External libraries from pymongo: +from pymongo import MongoClient +from gridfs import GridFS +from bson.objectid import ObjectId +""" +LAMBDA_SETUP = """db_name = 'imaginate_dev' +conn_uri = os.environ.get('MONGO_TOKEN') +client = MongoClient(conn_uri) +db = client[db_name] +fs = GridFS(db) +""" +LAMBDA_FUNC = """def handler(event, context): + if event and 'queryStringParameters' in event and event['queryStringParameters'] and 'day' in event['queryStringParameters']: + return images_by_date(event['queryStringParameters']['day']) + else: + return {'statusCode': HTTPStatus.BAD_REQUEST, 'body': json.dumps('Invalid date')} +""" +LAMBDA_SUBS = { + "abort": "return {'statusCode': HTTPStatus.BAD_REQUEST, 'body': json.dumps('Invalid date')}", + "@bp.route": "", # Remove this from function decorator + "return jsonify": "return {'statusCode': HTTPStatus.OK, 'body': json.dumps(out)}", +} + + +# Meta-program our source function to substitute Flask related libraries +def edit_source_function(source_function: str) -> str: + for sub in LAMBDA_SUBS: + source_function = re.sub( + r"^(\s*)" + sub + r".*$", + r"\g<1>" + LAMBDA_SUBS[sub], + source_function, + flags=re.MULTILINE, + ) + return source_function.strip() + + +if __name__ == "__main__": + # The main function AWS Lambda will directly invoke + source_function = edit_source_function(inspect.getsource(images_by_date)) + + # Order all the required external code needed: helper functions, classes and source function + all_functions = [ + inspect.getsource(DateInfo), + inspect.getsource(build_result), + inspect.getsource(calculate_date), + source_function, + ] + + # Save a .py file of our Lambda function code (mainly for verification purposes) + with open("aws/index.py", "w") as f: + f.write(LAMBDA_LIBRARIES + "\n") # Libaries defined as constants + f.write(LAMBDA_SETUP + "\n") + f.write("\n".join(all_functions) + "\n\n") # Functions retrieved from source code + f.write(LAMBDA_FUNC) + + # Following documentation from here: + # https://www.mongodb.com/developer/products/atlas/awslambda-pymongo/ + subprocess.run("mkdir dependencies", shell=True, cwd=CWD) + subprocess.run( + "pip install --upgrade --target ./dependencies pymongo", shell=True, cwd=CWD + ) + shutil.make_archive("aws", "zip", "aws/dependencies") + zf = zipfile.ZipFile("aws.zip", "a") + zf.write("aws/index.py", "index.py") + zf.close() + print("aws.zip successfully saved at root directory!") diff --git a/aws/cloudformation.yml b/aws/cloudformation.yml new file mode 100644 index 0000000..ae66979 --- /dev/null +++ b/aws/cloudformation.yml @@ -0,0 +1,121 @@ +# Sourced from: https://gist.github.com/magnetikonline/c314952045eee8e8375b82bc7ec68e88 +AWSTemplateFormatVersion: "2010-09-09" +Description: Imaginate API Gateway and Lambda function + +Parameters: + apiGatewayName: + Type: String + Default: ImaginateApi + apiGatewayStageName: + Type: String + AllowedPattern: '[a-z0-9]+' + Default: call + apiGatewayHTTPMethod: + Type: String + Default: GET + lambdaFunctionName: + Type: String + AllowedPattern: '[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+' + Default: get-images + mongoToken: + Type: String + NoEcho: true + +Resources: + apiGateway: + Type: AWS::ApiGateway::RestApi + Properties: + Description: Example API Gateway + EndpointConfiguration: + Types: + - REGIONAL + Name: !Ref apiGatewayName + + apiGatewayRootMethod: + Type: AWS::ApiGateway::Method + Properties: + AuthorizationType: NONE + RequestParameters: + method.request.querystring.day: true + HttpMethod: !Ref apiGatewayHTTPMethod + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + RequestParameters: + integration.request.querystring.day: method.request.querystring.day + Uri: !Sub + - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations + - lambdaArn: !GetAtt lambdaFunction.Arn + ResourceId: !GetAtt apiGateway.RootResourceId + RestApiId: !Ref apiGateway + + apiGatewayDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - apiGatewayRootMethod + Properties: + RestApiId: !Ref apiGateway + StageName: !Ref apiGatewayStageName + + lambdaFunction: + Type: AWS::Lambda::Function + Properties: + Code: + ZipFile: | + def handler(event, context): + pass + Description: Get images by date + FunctionName: !Ref lambdaFunctionName + Handler: index.handler + Role: !GetAtt lambdaIAMRole.Arn + Runtime: python3.12 + Environment: + Variables: + MONGO_TOKEN: !Ref mongoToken + + lambdaApiGatewayInvoke: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt lambdaFunction.Arn + Principal: apigateway.amazonaws.com + # NOTE: If the route is NOT at API Gateway root, `SourceArn` would take the form of: + # arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/${apiGatewayStageName}/${apiGatewayHTTPMethod}/PATH_PART + SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/${apiGatewayStageName}/${apiGatewayHTTPMethod}/ + + lambdaIAMRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Policies: + - PolicyDocument: + Version: "2012-10-17" + Statement: + - Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Effect: Allow + Resource: + - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${lambdaFunctionName}:* + PolicyName: lambda + + lambdaLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub /aws/lambda/${lambdaFunctionName} + RetentionInDays: 90 + +Outputs: + apiGatewayInvokeURL: + Value: !Sub https://${apiGateway}.execute-api.${AWS::Region}.amazonaws.com/${apiGatewayStageName} + lambdaArn: + Value: !GetAtt lambdaFunction.Arn \ No newline at end of file diff --git a/aws/index.py b/aws/index.py new file mode 100644 index 0000000..b0d3765 --- /dev/null +++ b/aws/index.py @@ -0,0 +1,80 @@ +import os +import json +from enum import Enum +from http import HTTPStatus +from base64 import b64encode + +# External libraries from pymongo: +from pymongo import MongoClient +from gridfs import GridFS +from bson.objectid import ObjectId + +db_name = "imaginate_dev" +conn_uri = os.environ.get("MONGO_TOKEN") +client = MongoClient(conn_uri) +db = client[db_name] +fs = GridFS(db) + + +class DateInfo(Enum): + START_DATE = 1722484800 # Timestamp for August 1st, 2024 + SECONDS_PER_DAY = 86400 + + +def build_result( + _id: ObjectId, real: bool, date: int, theme: str, status: str, filename: str +): + return { + "filename": filename, + "url": "image/read/" + str(_id), + "real": real, + "date": date, + "theme": theme, + "status": status, + } + + +def calculate_date(day: str | int | None): + if day is not None: + if isinstance(day, str): + day = int(day) + if day >= DateInfo.START_DATE.value: + return day + return DateInfo.START_DATE.value + day * DateInfo.SECONDS_PER_DAY.value + return None + + +def images_by_date(day): + try: + date = calculate_date(day) + if not date: + return {"statusCode": HTTPStatus.BAD_REQUEST, "body": json.dumps("Invalid date")} + except ValueError: + return {"statusCode": HTTPStatus.BAD_REQUEST, "body": json.dumps("Invalid date")} + + res = fs.find({"date": date}) + out = [] + for document in res: + current_res = build_result( + document._id, + document.real, + document.date, + document.theme, + document.status, + document.filename, + ) + encoded_data = b64encode(document.read()) + current_res["data"] = encoded_data.decode("utf-8") + out.append(current_res) + return {"statusCode": HTTPStatus.OK, "body": json.dumps(out)} + + +def handler(event, context): + if ( + event + and "queryStringParameters" in event + and "day" in event["queryStringParameters"] + ): + return images_by_date(event["queryStringParameters"]["day"]) + else: + return {"statusCode": HTTPStatus.BAD_REQUEST, "body": json.dumps("Invalid date")} diff --git a/imaginate_api/date/routes.py b/imaginate_api/date/routes.py index 7fae81b..96dcd11 100644 --- a/imaginate_api/date/routes.py +++ b/imaginate_api/date/routes.py @@ -2,7 +2,7 @@ from imaginate_api.extensions import fs from imaginate_api.utils import build_result, calculate_date from http import HTTPStatus -import base64 +from base64 import b64encode bp = Blueprint("date", __name__) @@ -28,7 +28,7 @@ def images_by_date(day): document.status, document.filename, ) - encoded_data = base64.b64encode(document.read()) + encoded_data = b64encode(document.read()) current_res["data"] = encoded_data.decode("utf-8") out.append(current_res) return jsonify(out) diff --git a/imaginate_api/schemas/date_info.py b/imaginate_api/schemas/date_info.py index 97534d5..93f3fe3 100644 --- a/imaginate_api/schemas/date_info.py +++ b/imaginate_api/schemas/date_info.py @@ -1,5 +1,6 @@ from enum import Enum + class DateInfo(Enum): - START_DATE = 1722484800 # timestamp for august 1st, 2024 + START_DATE = 1722484800 # Timestamp for August 1st, 2024 SECONDS_PER_DAY = 86400