-
Notifications
You must be signed in to change notification settings - Fork 28
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
Metadata backend and frontend #19
Changes from all commits
ea4560c
450bffe
199e422
2dbdc8c
61c2858
8c9256a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
|
||
import boto3 | ||
import json | ||
import logging | ||
import os | ||
import traceback | ||
|
||
# region Logging | ||
|
||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") | ||
logger = logging.getLogger() | ||
|
||
if logger.hasHandlers(): | ||
# The Lambda environment pre-configures a handler logging to stderr. If a handler is already configured, | ||
# `.basicConfig` does not execute. Thus we set the level directly. | ||
logger.setLevel(LOG_LEVEL) | ||
else: | ||
logging.basicConfig(level=LOG_LEVEL) | ||
|
||
# endregion | ||
|
||
def mask_sensitive_data(event): | ||
# remove sensitive data from request object before logging | ||
keys_to_redact = ["authorization"] | ||
result = {} | ||
for k, v in event.items(): | ||
if isinstance(v, dict): | ||
result[k] = mask_sensitive_data(v) | ||
elif k in keys_to_redact: | ||
result[k] = "<redacted>" | ||
else: | ||
result[k] = v | ||
return result; | ||
|
||
def build_response(http_code, body): | ||
return { | ||
"headers": { | ||
"Cache-Control": "no-cache, no-store", # tell cloudfront and api gateway not to cache the response | ||
"Content-Type": "application/json", | ||
}, | ||
"statusCode": http_code, | ||
"body": body, | ||
} | ||
|
||
|
||
region = os.environ['AWS_REGION'] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we may not need this, in lambda boto3 automatically picks up the right region |
||
dynamodb = boto3.resource('dynamodb', region_name=region) | ||
table = dynamodb.Table(os.environ['METADATA_STORAGE_TABLE_NAME']) | ||
|
||
def to_update_expr(record): | ||
|
||
keys= record.keys() | ||
keys_attr_names = ["#f{n}".format(n=x) for x in range(len(keys))] | ||
values_attr_names = [":v{n}".format(n=x) for x in range(len(keys))] | ||
|
||
keys_map = { | ||
k: key | ||
for k, key in zip(keys_attr_names, keys) | ||
} | ||
values_map = { | ||
v1: record[v] | ||
for v, v1 in zip(keys, values_attr_names) | ||
} | ||
expr = "SET " + ", ".join([ | ||
"{f} = {v}".format(f=f, v=v) | ||
for f,v in zip(keys_attr_names, values_attr_names) | ||
]) | ||
return keys_map, values_map, expr | ||
|
||
|
||
|
||
def create_or_update(databaseId, assetId, metadata): | ||
keys_map, values_map, expr = to_update_expr(metadata) | ||
return table.update_item( | ||
Key={ | ||
"databaseId": databaseId, | ||
"assetId": assetId, | ||
}, | ||
ExpressionAttributeNames=keys_map, | ||
ExpressionAttributeValues=values_map, | ||
UpdateExpression=expr, | ||
) | ||
|
||
|
||
|
||
class ValidationError(Exception): | ||
def __init__(self, code: int, resp: object) -> None: | ||
self.code = code | ||
self.resp = resp | ||
|
||
|
||
def validate_event(event): | ||
if "pathParameters" not in event or "assetId" not in event['pathParameters']: | ||
raise ValidationError(404, { "error": "missing path parameters"}) | ||
if "pathParameters" not in event or "databaseId" not in event['pathParameters']: | ||
raise ValidationError(404, { "error": "missing path parameters"}) | ||
|
||
|
||
def validate_body(event): | ||
|
||
if "body" not in event: | ||
raise ValidationError(400, {"error": "missing request body"}) | ||
|
||
body = json.loads(event['body']) | ||
|
||
for req_field in ["metadata", "version"]: | ||
if req_field not in body: | ||
raise ValidationError(400, {"error": "{f} field is missing".format(f=req_field)}) | ||
|
||
if body['version'] == "1": | ||
for k, v in body['metadata'].items(): | ||
if not isinstance(k, str): | ||
raise ValidationError(400, {"error": "metadata version 1 requires string keys and values"}) | ||
if not isinstance(v, str): | ||
raise ValidationError(400, {"error": "metadata version 1 requires string keys and values"}) | ||
|
||
return body |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import json | ||
import traceback | ||
|
||
from backend.handlers.metadata import logger, mask_sensitive_data, build_response, create_or_update, validate_event, validate_body, ValidationError | ||
|
||
|
||
def lambda_handler(event, context): | ||
logger.info(mask_sensitive_data(event)) | ||
try: | ||
|
||
validate_event(event) | ||
|
||
body = validate_body(event) | ||
databaseId = event['pathParameters']['databaseId'] | ||
assetId = event['pathParameters']['assetId'] | ||
|
||
create_or_update(databaseId, assetId, body['metadata']) | ||
|
||
return build_response(200, json.dumps({ "status": "OK" })) | ||
except ValidationError as ex: | ||
logger.info(traceback.format_exc()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logger.error? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a client input validation error opposed to an application level error. Might be good for us to define how we use different log levels. I've often used conventions like these:
Maybe the |
||
return build_response(ex.code, json.dumps(ex.resp)) | ||
except Exception as ex: | ||
logger.error(traceback.format_exc()) | ||
return build_response(500, "Server Error") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import os | ||
import sys | ||
import json | ||
from decimal import Decimal | ||
from backend.common.validators import validate | ||
import traceback | ||
|
||
from backend.handlers.metadata import logger, mask_sensitive_data, build_response, ValidationError, table, validate_event | ||
|
||
|
||
def delete_item(databaseId, assetId): | ||
table.delete_item( | ||
Key={ | ||
"databaseId": databaseId, | ||
"assetId": assetId, | ||
}, | ||
) | ||
|
||
def lambda_handler(event, context): | ||
logger.info(mask_sensitive_data(event)) | ||
try: | ||
validate_event(event) | ||
databaseId = event['pathParameters']['databaseId'] | ||
assetId = event['pathParameters']['assetId'] | ||
delete_item(databaseId, assetId) | ||
response = { "status": "OK", "message": "{assetId} deleted".format(assetId=assetId) } | ||
return build_response(200, json.dumps(response)) | ||
except ValidationError as ex: | ||
logger.info(traceback.format_exc()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. logger.error here too |
||
return build_response(ex.code, json.dumps(ex.resp)) | ||
except Exception as ex: | ||
logger.error(traceback.format_exc()) | ||
return build_response(500, "Server Error") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import os | ||
import sys | ||
import json | ||
from decimal import Decimal | ||
import traceback | ||
|
||
from backend.handlers.metadata import logger, mask_sensitive_data, build_response, table, validate_event, ValidationError | ||
|
||
|
||
def get_metadata(databaseId, assetId): | ||
resp = table.get_item( | ||
Key={ | ||
"databaseId": databaseId, | ||
"assetId": assetId, | ||
} | ||
) | ||
if "Item" not in resp: | ||
raise ValidationError(404, "Item Not Found") | ||
return resp['Item'] | ||
|
||
|
||
def lambda_handler(event, context): | ||
logger.info(mask_sensitive_data(event)) | ||
try: | ||
validate_event(event) | ||
databaseId = event['pathParameters']['databaseId'] | ||
assetId = event['pathParameters']['assetId'] | ||
|
||
return build_response(200, json.dumps({ | ||
"version": "1", | ||
"metadata": get_metadata(databaseId, assetId) | ||
})) | ||
|
||
except ValidationError as ex: | ||
logger.info(traceback.format_exc()) | ||
return build_response(ex.code, json.dumps(ex.resp)) | ||
except Exception as ex: | ||
logger.error(traceback.format_exc()) | ||
return build_response(500, "Server Error") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am curious if this change is intentional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this makes it so that we don't have to run the pip install step every time a file changes in the backend folder. Speeds up deploys a lot. If requirements.txt changes, the pip install will run.