You Can Now Like Each Article

You Can Now Like Each Article

Come and give me a thumbs up!

Written by Hannah Willoughby

Coding React.jsSqlAlchemyAWS

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.

Clicking the like button

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.

  1. 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
  2. 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

Initial Architecture

Based on the above logic, the steps for how I imlemented a ā€œlikesā€ system are as follows:

  1. Add a likes column to the articles database
  2. Implement a lambda for increasing or decreasing the number of likes on an article in the database
  3. Add an API gateway endpoint to invoke the new lambda
  4. Design and implement a UI for displaying the number of likes on an article (using the created endpoint)
  5. Store that the user has liked the article in a cookie, and display whether the user has liked the article
  6. 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:

database check

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!

lambda 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!

API Gateway Integration

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!

Written by Hannah Willoughby

Published on