def put_shopping_list(user_id: str, body: Iterable[str]) -> Response: """ PUT a new shopping list, replacing the existing list with the items specified in `body`. :param user_id: ID of the user :param body: List of shopping list items to add :return: (Response specifics, status code) """ verify_parameters(body, list_type=str) logging.info(f"Replacing shopping list for user with ID {user_id} with items {body}.") kwargs = format_query_fields( {"shoppingList": body}, projection_expression=False, attribute_names=True, attribute_values=True, ) meta_table, _ = get_meta_table() meta_table.update_item( Key={"userId": user_id}, UpdateExpression="SET #shoppingList = :shoppingList", ConditionExpression=Attr("userId").exists(), **kwargs, ) return {}, 204
def patch_shopping_list(user_id: str, body: Iterable[str]) -> Response: """ PATCH the shopping list, adding the items specified in `body`. :param user_id: ID of the user :param body: List of shopping list items to add :return: (Response specifics, status code) """ verify_parameters(body, list_type=str) logging.info(f"Updating shopping list for user with ID {user_id} with items {body}.") kwargs = format_query_fields( {"shoppingList": body}, projection_expression=False, attribute_names=True, attribute_values=True, ) meta_table, resource = get_meta_table() try: meta_table.update_item( Key={"userId": user_id}, UpdateExpression="SET #shoppingList = list_append(#shoppingList, :shoppingList)", ConditionExpression=Attr("userId").exists() & Attr("shoppingList").exists(), **kwargs, ) except resource.meta.client.exceptions.ConditionalCheckFailedException: meta_table.update_item( Key={"userId": user_id}, UpdateExpression="SET #shoppingList = :shoppingList", ConditionExpression=Attr("userId").exists(), **kwargs, ) return {}, 204
def confirm_sign_up(body: Mapping[str, str]) -> Response: """ Confirm sign up using a confirmation code. :param body: Mapping containing email and confirmationCode keys :return: (Response specifics, status code) """ logging.info("Starting confirm sign up flow.") email, confirmation_code = verify_parameters(body, "email", "confirmationCode") client: CognitoClient = boto3.client("cognito-idp") try: client.confirm_sign_up( ClientId=CLIENT_ID, Username=email, ConfirmationCode=confirmation_code, ) except client.exceptions.UserNotFoundException: return ResponseData(message="This email is not registered."), 404 except (client.exceptions.CodeMismatchException, ParamValidationError): return ResponseData(message="Invalid confirmation code."), 400 except client.exceptions.NotAuthorizedException: return ResponseData(message="User has already been verified."), 400 except Exception: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 logging.info("Confirm sign up flow successful.") return ResponseData(message="Your email has been verified!"), 200
def forgot_password(body: Mapping[str, str]) -> Response: """ Send a forgot password email to a user. :param body: Mapping containing an email key :return: (Response specifics, status code) """ logging.info("Starting forgot password flow.") (email, ) = verify_parameters(body, "email") client: CognitoClient = boto3.client("cognito-idp") try: client.forgot_password(ClientId=CLIENT_ID, Username=email) except client.exceptions.UserNotFoundException: return ResponseData(message="This email is not registered."), 404 except client.exceptions.InvalidParameterException: return ResponseData(message="User is not verified yet."), 403 except client.exceptions.LimitExceededException: return ResponseData( message="Too many requests, please try again later."), 429 except Exception: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 logging.info("Forgot password flow successful.") return ResponseData( message=f"A password reset code has been sent to {email}."), 200
def sign_in(body: Mapping[str, str]) -> Response: """ Sign in using email and password. :param body: Mapping containing email and password keys :return: (Response specifics, status code) """ logging.info(f"Starting sign in flow. {body}") email, password = verify_parameters(body, "email", "password") client: CognitoClient = boto3.client("cognito-idp") try: res = client.admin_initiate_auth( AuthFlow="ADMIN_USER_PASSWORD_AUTH", AuthParameters={ "USERNAME": email, "PASSWORD": password }, ClientId=CLIENT_ID, UserPoolId=USER_POOL_ID, ) except client.exceptions.InvalidParameterException as e: message: str = e.args[0] return ( ResponseData(message=message[message.rindex(": ") + 2:].replace("USERNAME", "EMAIL")), 400, ) except client.exceptions.NotAuthorizedException: return ResponseData(message="Incorrect password."), 401 except client.exceptions.UserNotConfirmedException: return ResponseData(message="User is not verified."), 403 except client.exceptions.UserNotFoundException: return ResponseData(message="This email is not registered."), 404 except Exception: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 res = res["AuthenticationResult"] access_token = res.get("AccessToken") refresh_token = res.get("RefreshToken") id_token = res.get("IdToken") user = client.get_user(AccessToken=access_token).get("Username") logging.info("Sign in flow successful.") return ( ResponseData( message="You have been signed in.", data={ "idToken": id_token, "refreshToken": refresh_token, "user": user }, ), 200, )
def delete_categories(user_id: str, category_ids: Iterable[int]): """ Batch delete a list of category IDs. :param user_id: ID of the user :param category_ids: List of category IDs :return: (Response specifics, status code) """ verify_parameters(category_ids, list_type=int) res_data = {} for category_id in category_ids: _, status_code = delete_category(user_id, category_id) status_code != 204 and res_data.setdefault("failedDeletions", []).append(category_id) if not res_data: return {}, 204 return ResponseData(data=res_data), 200
def delete_recipes(user_id: str, recipe_ids: Iterable[int]): """ Batch delete a list of recipe IDs. :param user_id: ID of the user :param recipe_ids: List of recipe IDs :return: (Response specifics, status code) """ verify_parameters(recipe_ids, list_type=int) res_data = {} for recipe_id in recipe_ids: if not isinstance(recipe_id, int): res_data.setdefault("failedDeletions", []).append(recipe_id) continue _, status_code = delete_recipe(user_id, recipe_id) status_code != 204 and res_data.setdefault("failedDeletions", []).append(recipe_id) if not res_data: return {}, 204 return ResponseData(data=res_data), 200
def refresh_id_token(body: Mapping[str, str]) -> Response: """ Refresh a user's ID token using a refresh token. :param body: Mapping containing a refreshToken key :return: (Response specifics, status code) """ logging.info("Starting refresh ID token flow.") (refresh_token, ) = verify_parameters(body, "refreshToken") client: CognitoClient = boto3.client("cognito-idp") try: res = client.admin_initiate_auth( AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=({ "REFRESH_TOKEN": refresh_token }), ClientId=CLIENT_ID, UserPoolId=USER_POOL_ID, ) except client.exceptions.NotAuthorizedException: return ( ResponseData(message="Invalid refresh token.", data={"refreshTokenExpired": True}), 401, ) except client.exceptions.UserNotConfirmedException: return ResponseData(message="User is not verified."), 403 except Exception: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 res = res["AuthenticationResult"] access_token = res.get("AccessToken") id_token = res.get("IdToken") user = client.get_user(AccessToken=access_token).get("Username") logging.info("Refresh ID token flow successful.") return ( ResponseData(message="You have been signed in.", data={ "idToken": id_token, "user": user }), 200, )
def confirm_forgot_password(body: Mapping[str, str]) -> Response: """ Reset a user's password using a confirmation code and a new password. :param body: Mapping containing email, password, and confirmationCode keys :return: (Response specifics, status code) """ logging.info("Starting confirm forgot password flow.") email, password, confirmation_code = verify_parameters( body, "email", "password", "confirmationCode") client: CognitoClient = boto3.client("cognito-idp") try: client.confirm_forgot_password( ClientId=CLIENT_ID, Username=email, ConfirmationCode=confirmation_code, Password=password, ) except ParamValidationError as e: report = e.kwargs["report"] if "Invalid type for parameter ConfirmationCode" in report: return ResponseData(message="Invalid confirmation code."), 400 elif "Invalid length for parameter Password" in report: return ResponseData(message="Invalid password."), 400 else: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 except client.exceptions.UserNotFoundException: return ResponseData(message="This email is not registered."), 404 except client.exceptions.InvalidPasswordException: return ResponseData(message="Invalid password."), 400 except client.exceptions.CodeMismatchException: return ResponseData(message="Invalid confirmation code."), 400 except client.exceptions.NotAuthorizedException: return ( ResponseData(message="Password reset has already been confirmed."), 400, ) except Exception: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 logging.info("Confirm forgot password flow successful.") return ResponseData( message="Your password has been successfully reset!"), 200
def sign_up(body: Mapping[str, str]) -> Response: """ Sign up using email and password. :param body: Mapping containing email and password keys :return: (Response specifics, status code) """ logging.info("Starting sign up flow.") email, password = verify_parameters(body, "email", "password") client: CognitoClient = boto3.client("cognito-idp") try: res = client.sign_up(ClientId=CLIENT_ID, Username=email, Password=password) except client.exceptions.UsernameExistsException: return ResponseData(message="This email is already registered."), 409 except (client.exceptions.InvalidPasswordException, ParamValidationError): return ResponseData(message="Invalid password."), 400 except Exception: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 meta_table, _ = get_meta_table() meta_table.put_item( Item={ "userId": res["UserSub"], "signUpTime": datetime.utcnow().replace(microsecond=0).isoformat(), }) logging.info("Sign up flow successful.") return ( ResponseData( message= f"Sign up successful! A verification email has been sent to {email}." ), 200, )
def resend_code(body: Mapping[str, str]) -> Response: """ Resend a confirmation code to a user's email. :param body: Mapping containing an email key :return: (Response specifics, status code) """ logging.info("Starting resend code flow.") (email, ) = verify_parameters(body, "email") client: CognitoClient = boto3.client("cognito-idp") try: client.resend_confirmation_code(ClientId=CLIENT_ID, Username=email) except client.exceptions.UserNotFoundException: return ResponseData(message="This email is not registered."), 404 except client.exceptions.InvalidParameterException: return ResponseData(message="User is already verified."), 400 except Exception: logging.exception(UNEXPECTED_EXCEPTION) return ResponseData(message=UNEXPECTED_EXCEPTION), 500 logging.info("Resend code flow successful.") return ResponseData( message=f"A verification code has been sent to {email}."), 200
def patch_recipe( user_id: str, body: Mapping[str, MutableMapping[str, Union[str, Union[Iterable[int], Iterable[str]]]]], recipe_id: int, *, batch: Optional[bool] = False, pool_res: Optional[MutableMapping[str, List[int]]] = None, ) -> Response: """ PATCH a recipe in the database, updating the specified entry. For categories: if being added or updated, specify by name. If being removed, specify by ID. :param user_id: ID of the user :param body: Mapping of PATCH operations to recipe data. Currently supports 'add', 'remove', and 'update' :param recipe_id: ID of the recipe being updated :param batch: Whether or not this is part of a batch operation :param pool_res: Response data used by the calling thread pool :return: (Response specifics, status code) """ kwargs = { "UpdateExpression": "", "ExpressionAttributeNames": {}, "ExpressionAttributeValues": {}, } remove_kwargs = { "UpdateExpression": "", "ExpressionAttributeNames": {}, "ExpressionAttributeValues": {}, } res_data = {} try: validation = { "add": dict, "remove": dict, "update": dict, } verify_parameters(body, valid_parameters=validation.keys(), parameter_types=validation) adds, removes, updates = (body.get(parameter, {}) for parameter in validation) c = "categories" if c in updates and (c in adds or c in removes): raise AssertionError( "Categories cannot be updated and added/removed simultaneously." ) if adds: (categories_to_add, ) = verify_parameters(adds, c, valid_parameters=[c]) verify_parameters(categories_to_add, list_type=str) existing_categories, new_categories, failed_adds = add_categories_from_recipe( user_id, categories_to_add) if existing_categories: res_data["existingCategories"] = existing_categories if new_categories: res_data["newCategories"] = new_categories kwargs["UpdateExpression"] += f"ADD #{c} :{c}" kwargs["ExpressionAttributeNames"][f"#{c}"] = c kwargs["ExpressionAttributeValues"][f":{c}"] = { category["categoryId"] for category in new_categories } if failed_adds: res_data["categoryFailedAdds"] = failed_adds if removes: # Can't add to and remove from a set in the same call, need separate kwargs (categories_to_remove, ) = verify_parameters(removes, c, valid_parameters=[c]) verify_parameters(categories_to_remove, list_type=int) remove_kwargs["UpdateExpression"] = f"DELETE #{c} :{c}" remove_kwargs["ExpressionAttributeNames"][f"#{c}"] = c remove_kwargs["ExpressionAttributeValues"][f":{c}"] = set( categories_to_remove) if updates: verify_parameters( updates, valid_parameters=editable_recipe_fields.keys(), parameter_types=editable_recipe_fields, ) verify_parameters(updates.get("ingredients", []), list_type=str) verify_parameters(updates.get("instructions", []), list_type=str) updates = add_image_from_recipe(updates) if "imgSrc" in updates: res_data["imgSrc"] = updates["imgSrc"] categories = updates.get(c) if categories: verify_parameters(categories, list_type=str) existing_categories, new_categories, failed_adds = add_categories_from_recipe( user_id, categories_to_add) if existing_categories: res_data["existingCategories"] = existing_categories if new_categories: res_data["newCategories"] = new_categories updates[c] = { category["categoryId"] for category in (*existing_categories, *new_categories) } if failed_adds: res_data["categoryFailedAdds"] = failed_adds except AssertionError: if batch: # Continue with the rest of the batch pool_res.setdefault("failedUpdates", []).append(recipe_id) return {}, 400 raise edit_time = datetime.utcnow().replace(microsecond=0).isoformat() updates["updateTime"] = edit_time kwargs[ "UpdateExpression"] += f" SET {', '.join(f'#{k} = :{k}' for k in updates)}" kwargs["ExpressionAttributeNames"].update({f"#{k}": k for k in updates}) kwargs["ExpressionAttributeValues"].update( {f":{k}": v for k, v in updates.items()}) kwargs["UpdateExpression"] = kwargs["UpdateExpression"].replace(" ", " ") recipes_table, resource = get_recipes_table() logging.info( f"Updating recipe with ID {recipe_id} for user with ID {user_id} and body {body}." ) try: if removes: recipes_table.update_item( Key={ "userId": user_id, "recipeId": recipe_id }, ConditionExpression=Attr("userId").exists() & Attr("recipeId").exists(), **remove_kwargs, ) recipes_table.update_item( Key={ "userId": user_id, "recipeId": recipe_id }, ConditionExpression=Attr("userId").exists() & Attr("recipeId").exists(), **kwargs, ) except resource.meta.client.exceptions.ConditionalCheckFailedException: pool_res.setdefault("failedUpdates", []).append(recipe_id) return ( ResponseData( message= f"User {user_id} does not have a recipe with ID {recipe_id}.", ), 404, ) if not res_data: return {}, 204 return ResponseData(data=res_data), 200
def post_recipe( user_id: str, body: RecipeEntry, recipe_id: Optional[int] = None, *, batch: Optional[bool] = False, recipes_table: Optional[Table] = None, ) -> Response: """ POST a recipe to the database, adding a new entry. If 'recipe_id' is specified, instead replace an existing recipe. :param user_id: ID of the user :param body: Recipe data to replace with :param recipe_id: ID of the recipe being replaced :param batch: Whether or not this is part of a batch operation :param recipes_table: Different DynamoDB table to use. Used for batch writing :return: (Response specifics, status code) """ # If not updating an existing recipe, name must be present # TODO: Verify types try: verify_parameters( body, "name" if not recipe_id else None, valid_parameters=editable_recipe_fields, parameter_types=editable_recipe_fields, ) verify_parameters(body.get("ingredients", []), list_type=str) verify_parameters(body.get("instructions", []), list_type=str) except AssertionError: if batch: # Continue with the rest of the batch return {}, 400 raise body = {k: v for k, v in body.items() if v} res_data = {} categories_to_add = body.pop("categories", []) if categories_to_add: existing_categories, new_categories, failed_adds = add_categories_from_recipe( user_id, categories_to_add) body["categories"] = { category["categoryId"] for category in (*existing_categories, *new_categories) } if existing_categories: res_data["existingCategories"] = existing_categories if new_categories: res_data["newCategories"] = new_categories if failed_adds: res_data["categoryFailedAdds"] = failed_adds edit_time = datetime.utcnow().replace(microsecond=0).isoformat() body["updateTime"] = edit_time body = add_image_from_recipe(body) if recipe_id is None: # POST status_code = 201 body["createTime"] = edit_time recipe_id = get_next_id(user_id, "recipe") logging.info( f"Creating recipe with ID {recipe_id} for user with ID {user_id} and body {body}." ) else: # PUT status_code = 200 recipes_table, _ = get_recipes_table() create_time = (recipes_table.get_item(Key={ "userId": user_id, "recipeId": recipe_id }).get("Item", {}).get("createTime", edit_time)) body["createTime"] = create_time logging.info( f"Updating recipe with ID {recipe_id} for user with ID {user_id} and body {body}." ) body["recipeId"] = recipe_id if not recipes_table: recipes_table, _ = get_recipes_table() recipes_table.put_item(Item={ **body, "userId": user_id, }) res_data.update(body) return ResponseData(data=res_data), status_code
def post_category( user_id: str, body: CategoryEntry, category_id: Optional[int] = None, *, batch: Optional[bool] = False, pool_res: Optional[MutableMapping[str, Union[List[str], List[CategoryEntry]]]] = None, ) -> Response: """ POST a category to the database, adding a new entry. If 'category_id' is specified, instead replace an existing category. :param user_id: ID of the user :param body: Category data to replace with :param category_id: ID of the category being replaced :param batch: Whether or not this is part of a batch operation :param pool_res: Response data used by the calling thread pool :return: (Response specifics, status code) """ logging.info(body) try: # If not updating an existing category, name must be present # TODO: Verify types verify_parameters( body, "name" if not category_id else None, valid_parameters=editable_category_fields, parameter_types=editable_category_fields, ) except AssertionError: if batch and pool_res: # Continue with the rest of the batch pool_res["failed_adds"].append(body["name"]) return {}, 400 raise edit_time = datetime.utcnow().replace(microsecond=0).isoformat() body["updateTime"] = edit_time if category_id is None: # POST status_code = 201 body["createTime"] = edit_time category_id = get_next_id(user_id, "category") logging.info( f"Creating category with ID {category_id} for user with ID {user_id} and body {body}." ) else: # PUT status_code = 200 kwargs = format_query_fields(["createTime"]) categories_table, _ = get_categories_table() body["createTime"] = (categories_table.get_item( Key={ "userId": user_id, "categoryId": category_id }, **kwargs).get("Item", {}).get("createTime", edit_time)) logging.info( f"Updating category with ID {category_id} for user with ID {user_id} and body {body}." ) body["categoryId"] = category_id categories_table, _ = get_categories_table() categories_table.put_item(Item={ **body, "userId": user_id, }) if batch and pool_res: pool_res["new_categories"].append(body) return ResponseData(data=body), status_code
def patch_category(user_id: str, body: Mapping[str, Mapping[str, str]], category_id: int, batch: bool = False) -> Optional[Response]: """ PATCH a category in the database, updating the specified entry. :param user_id: ID of the user :param body: Mapping of PATCH operations to category data. Currently supports 'update' :param category_id: ID of the category being updated :param batch: Whether or not this is part of a batch operation :return: (Response specifics, status code) """ kwargs = { "UpdateExpression": "", "ExpressionAttributeNames": {}, "ExpressionAttributeValues": {}, } try: validation = { "update": dict, } verify_parameters(body, valid_parameters=validation.keys(), parameter_types=validation) (updates, ) = (body.get(parameter) for parameter in validation) validation = { "name": str, } verify_parameters(updates, valid_parameters=validation.keys(), parameter_types=validation) edit_time = datetime.utcnow().replace(microsecond=0).isoformat() updates["updateTime"] = edit_time kwargs[ "UpdateExpression"] = f"SET {', '.join(f'#{k} = :{k}' for k in updates)}" kwargs["ExpressionAttributeNames"].update( {f"#{k}": k for k in updates}) kwargs["ExpressionAttributeValues"].update( {f":{k}": v for k, v in updates.items()}) kwargs["UpdateExpression"] = kwargs["UpdateExpression"].replace( " ", " ") except AssertionError: if batch: # Continue with the rest of the batch return {}, 400 raise table, resource = get_categories_table() logging.info( f"Updating category with ID {category_id} for user with ID {user_id}.") try: table.update_item( Key={ "userId": user_id, "categoryId": category_id }, ConditionExpression=Attr("userId").exists() & Attr("categoryId").exists(), **kwargs, ) except resource.meta.client.exceptions.ConditionalCheckFailedException: return ( ResponseData( message= f"User {user_id} does not have a category with ID {category_id}.", ), 404, ) return {}, 204