You Can Now Like Each Article
Come and give me a thumbs up!
Written by Hannah Willoughby
You can now like my articles! Just click the thumbs up below the article information or at the bottom of the article to give the it a like š You donāt need to be logged in or anything, it just saves that you liked the article as a cookie.
Technical Considerations
Here are some things I either needed to figure out a good solution for or just didnāt know design patterns for yet.
- How to store the likes in the database while:
- Allowing anyone to like an article, even if they arenāt logged in
- Preventing the same user from liking a post multiple times
- How to prevent overwriting number of likes during high traffic periods
ChatGPT recommended a few different approaches to the first question: a likes table that stores the likes for each post along with the IP address of the person who liked the post; or using cookies to keep track of which post a user has liked, along with a simple number of likes column on the articles table. I figured using cookies would be the most simple approach, as well as just better privacy-wise because itās not storing the IP addresses of my users. The cookies solution would also require some sort of dialog for consent to store a cookie on the userās device.
As for the second question, ChatGPT recommended a bunch of things I donāt know how to implement yet: locking mechanisms (maintaining a separate table to track locks on rows), transactional processing (making a change and then saving it, and if an error occurs, it rolls back to the original state), optimistic concurrency control (adding a timestamp column and making sure itās the correct value before making a change), distributed locking (locking mechanisims provided by AWS). Turns out I was already doing transactional processing, but I had trouble figuring out what to do with the failed commits. My architecture is a distributed system, so I figured there has to be some sort of design pattern for implementing this in AWS. After some googling, I read about automatic retries with AWS Lambda, and decided this would be the best approach for now because I donāt really get much traffic.
Initial Architecture
Based on the above logic, the steps for how I imlemented a ālikesā system are as follows:
- Add a likes column to the articles database
- Implement a lambda for increasing or decreasing the number of likes on an article in the database
- Add an API gateway endpoint to invoke the new lambda
- Design and implement a UI for displaying the number of likes on an article (using the created endpoint)
- Store that the user has liked the article in a cookie, and display whether the user has liked the article
- Allow the user to unlike the post in the UI
This was straightforward: I added a line to my model definition for the articles table, then ran
alembic revision --autogenerate -m "Add likes to article table"
to generate a migration file, then made sure it was correct, then ran
alembic upgrade head
to run the migration.
I added the following line to my Article model in sqlalchemy:
likes: Mapped[int] = mapped_column(server_default="0")
to get alembic to generate a migration file as follows:
"""Add likes to article table
Revision ID: 5b2423dc9d5c
Revises: 239d28898a5f
Create Date: 2024-04-07 14:26:20.671103
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5b2423dc9d5c'
down_revision: Union[str, None] = '239d28898a5f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('articles', sa.Column('likes', sa.Integer(), nullable=False, server_default=str(0)))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('articles', 'likes')
# ### end Alembic commands ###
Then, I verified that the likes column was in the table by doing a simple select statement:
And there it is! A likes column for each existing post with the default of 0 likes!
Implementing a Lambda function with Connection to a Postgres Database
The next step is to create a lambda that increases or decreases the count of the likes on an article without it going below zero. This was simple to accomplish with my existing architecture. Below is the source code for the lambda function:
from os import environ
from psycopg2 import connect, DatabaseError
from psycopg2.extras import RealDictCursor
from sys import exit
from json import dumps, loads
username = environ.get("POSTGRES_USERNAME")
password = environ.get("POSTGRES_PASSWORD")
host = environ.get("POSTGRES_HOST")
port = environ.get("POSTGRES_PORT")
db_name = environ.get("POSTGRES_DB_NAME")
def lambda_handler(event, context):
body = event.get("body", {})
path_parmas = event.get("pathParameters", {})
slug = path_parmas.get("slug")
decrease = body.get("decrease", False)
if slug is None:
return {
"statusCode": 400,
"body": dumps({"message": "Slug is required in body"}),
"headers": {
'Access-Control-Allow-Origin' : '*'
}
}
try:
conn = connect(host=host, database=db_name, user=username, password=password, port=port)
except DatabaseError as e:
print("Could not connect to db", e)
exit(1)
sql = "SELECT likes FROM articles WHERE slug = %s"
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(sql, (slug,))
results = cursor.fetchone()
if results is None:
return {
"statusCode": 404,
"body": dumps({"message": f"Could not find slug: {slug}"}),
"headers": {
'Access-Control-Allow-Origin' : '*'
}
}
likes = results.get("likes")
if decrease:
new_likes = likes - 1
if new_likes < 0:
new_likes = 0
else:
new_likes = likes + 1
update_sql = "UPDATE articles SET likes = %s WHERE slug = %s"
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(update_sql, (new_likes, slug))
conn.commit()
return {
"statusCode": 200,
"body": dumps({"likes": new_likes}, default=str),
'headers' : {
'Access-Control-Allow-Origin' : '*'
}
}
And since I already had a module for deploying a lambda, all I had to do was instantiate a new module in terraform for the new function.
module "like_article_lambda" {
source = "../lambda"
database_host = var.database_host
database_port = var.database_port
database_username = var.database_username
database_password = var.database_password
database_name = var.database_name
function_name = "like_article"
table_name = "article"
lambda_layer_arns = var.lambda_layer_arns
}
And here is a successful execution!
Adding an API Gateway Endpoint that Invokes the Lambda Function
This was very copy/paste with my existing infrastructure. (Maybe I should make a module?) Below is the terraform code:
resource "aws_apigatewayv2_integration" "like_article_lambda_integration" {
api_id = aws_apigatewayv2_api.api.id
integration_type = "AWS_PROXY"
connection_type = "INTERNET"
integration_method = "POST"
integration_uri = module.like_article_lambda.invoke_arn
passthrough_behavior = "WHEN_NO_MATCH"
}
resource "aws_apigatewayv2_route" "like_article_route" {
api_id = aws_apigatewayv2_api.api.id
route_key = "POST /articles/{slug}/like"
target = "integrations/${aws_apigatewayv2_integration.like_article_lambda_integration.id}"
}
resource "aws_lambda_permission" "like_article_lambda_permission" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = module.like_article_lambda.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.api.execution_arn}/*"
}
And below is the integration in the AWS console!
UI
All I did for the UI was use a package called react-cookie
that updates the cookie that stores the likes. This is wired up to a hook that fires off a POST request to the new api endpoint that increases or decreases the likes on a article.
const LikesContainer = styled("div")({
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "5px",
margin: "10px"
})
interface LikesProps {
slug: string
likes: number
}
const Likes: React.FC<LikesProps> = ({ slug, likes }) => {
const cookieName = 'hannahshobbyroom-likes'
const [cookies, setCookie] = useCookies([cookieName])
const theme = useTheme()
const [liked, setLiked] = useState(false)
const [numLikes, setNumLikes] = useState(likes)
useMemo(() => {
if (cookies[cookieName] && cookies[cookieName][slug]) {
setLiked(true)
}
}, [cookies])
const toggleLike = () => {
const newValue = !liked
try {
axios.post(
`${process.env.REACT_APP_API_URL}/articles/${slug}/like`,
{
"decrease": !newValue
}
)
if (newValue) {
setCookie(cookieName, {...cookies[cookieName], [slug]: true})
setNumLikes(numLikes + 1)
} else {
let newCookies = {...cookies[cookieName]}
delete newCookies[slug]
setCookie(cookieName, newCookies)
setNumLikes(numLikes - 1)
}
} catch (e) {
console.log(e)
}
setLiked(newValue)
}
return (
<LikesContainer onClick={toggleLike}>
<ThumbUpAltIcon color={liked ? "secondary" : "primary" } /> {numLikes}
</LikesContainer>
)
}
Conclusion
At any rate, Iām happy to have implemented this feature! Now try it out by giving this article a heart below š
Any questions or comments? Let me know in the comments!