Building Product Reviews

Introduction: From Configuration to Building Features

Welcome back! In our previous lesson, we configured OpenCode to understand our e-commerce project's rules using AGENTS.md and opencode.json. Now, it is time to put those rules into action. In this lesson, we will build a complete Product Reviews feature. Building a new feature in an existing project requires discipline. We will use a structured workflow loop: PlanImplementVerifyEdge CasesCleanup.

By following this loop, you ensure that your new code perfectly matches the existing application. We will create a database model, build API endpoints with canonical error handling, and secure them with validation and authentication. Let's start planning our feature!

Planning the Feature with OpenCode

Feature Planning is the process of defining exactly what your new code needs to do before you actually write it. This step matters because jumping straight into coding often leads to mistakes, missed requirements, and messy integration. When working with an AI assistant like OpenCode, a clear plan gives the AI the context it needs to generate accurate, helpful code that fits your project.

For our product reviews feature, we need users to be able to rate a product from 1 to 5, leave a text comment, and manage their own reviews. We can prompt OpenCode to help us outline this plan. We might ask it: Based on our existing product and user models, outline the necessary files and database fields to create a product reviews feature.

● Based on your project structure, here is the plan for Product Reviews:
  1. Model (src/models/review.py): Add id, rating (1-5), comment, user_id, product_id.
  2. Middleware (src/api/middleware/validation.py): Add validate_review to check inputs.
  3. Routes (src/api/routes/reviews.py): Add POST, GET, PUT, and DELETE endpoints.

This output shows that OpenCode understands our architecture. It correctly identified that we need a new database model, validation middleware to check user input, and a new routes file for our API endpoints. Having this roadmap makes the implementation phase much smoother.

Creating the Review Database Model

A Database Model is a class in your code that defines the structure of a table in your database. This matters because it ensures that all data saved by your application follows strict rules and types. Let's start by defining our Review model in src/models/review.py and setting up its basic columns.

from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import relationship
from src.models import db

class Review(db.Model):
    __tablename__ = "reviews"

    id = Column(Integer, primary_key=True)
    rating = Column(Integer, nullable=False)
    comment = Column(String(500), nullable=False)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

In this first block, we import the necessary tools from sqlalchemy. We define our table name as "reviews" and create our columns. Notice that we use ForeignKey for user_id and product_id. This links our review directly to the existing users and products tables, ensuring that a review cannot exist without a valid user and a valid product. We also include both created_at and updated_at timestamps. The onupdate parameter on updated_at tells SQLAlchemy to automatically refresh this value whenever a row is modified. Next, we need to add relationships and constraints to this model. A Check Constraint is a database-level rule that restricts what values can be saved.

    # Relationships
    user = relationship("User", back_populates="reviews")
    product = relationship("Product", back_populates="reviews")

    # Constraints
    __table_args__ = (
        CheckConstraint("rating >= 1 AND rating <= 5", name="valid_rating"),
    )

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "rating": self.rating,
            "comment": self.comment,
            "user_id": self.user_id,
            "product_id": self.product_id,
            "created_at": self.created_at.isoformat(),
            "updated_at": self.updated_at.isoformat(),
        }

Here, we define the relationship properties so we can easily access the related user or product objects in Python. The back_populates option requires a matching relationship to be declared on the other side as well, so both existing models must be updated. That means src/models/user.py needs a reviews relationship pointing back to Review.user, and src/models/product.py needs a reviews relationship pointing back to Review.product. The CheckConstraint is crucial because it physically prevents the database from saving a rating below 1 or above 5. Finally, we include a to_dict method to easily convert our database object into a JSON format when sending it back to the client. Notice that to_dict includes both created_at and updated_at, formatted as ISO 8601 strings so they serialize cleanly into JSON.

# In src/models/user.py — add to the User class:
reviews = relationship("Review", back_populates="user")

# In src/models/product.py — add to the Product class:
reviews = relationship("Review", back_populates="product")

Building API Endpoints with Canonical Error Handling

Canonical Error Handling is a standardized pattern for catching exceptions and returning consistent error messages across your entire application. This matters because it ensures that whoever is using your API always receives predictable responses, making debugging much easier. If you recall from our last lesson, we defined this exact pattern in our AGENTS.md file. Let's implement the route to create a new review in src/api/routes/reviews.py.

from flask import Blueprint, request, jsonify
from sqlalchemy.exc import SQLAlchemyError
from src.models.review import Review
from src.models.product import Product
from src.api.middleware.auth import require_auth
from src.api.middleware.validation import validate_review
from src.api.utils.database import db

reviews_bp = Blueprint("reviews", __name__, url_prefix="/api/v1/reviews")

@reviews_bp.route("/", methods=["POST"])
@require_auth
@validate_review
def create_review(current_user):
    try:
        data = request.get_json()
        
        review = Review(
            rating=data["rating"],
            comment=data["comment"],
            user_id=current_user.id,
            product_id=data["product_id"],
        )

        db.session.add(review)
        db.session.commit()

        return jsonify({"data": review.to_dict()}), 201

We create a Flask Blueprint for our review routes. In the create_review function, we wrap our logic inside a try block. We extract the JSON data sent by the user, create a new Review object, and save it to the database using db.session.commit(). On success, we return the data wrapped in a {"data": ...} dictionary with a 201 status code. Now, we must complete our canonical error-handling pattern by catching potential failures without exposing internal exception details to the client.

    except SQLAlchemyError:
        db.session.rollback()
        return jsonify({"error": "Database error"}), 500
    except Exception:
        db.session.rollback()
        return jsonify({"error": "Internal server error"}), 500

Following our AGENTS.md rules, we catch SQLAlchemyError specifically for database issues and a generic Exception as a fallback. In both cases, we call db.session.rollback() to undo any broken database operations and return a safe, generic {"error": ...} message. We never expose the internal stack trace or raw exception message to the client.

Implementing Validation and Authentication Middleware

Middleware refers to reusable functions that intercept a request before it reaches your main route logic. This matters because it allows you to separate security and data validation from your actual business logic, keeping your route files clean and easy to read. Let's create a custom validation decorator in src/api/middleware/validation.py.

from functools import wraps
from flask import request, jsonify

def validate_review(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        data = request.get_json()

        if not data:
            return jsonify({"error": "No data provided"}), 400

        if request.method == "POST":
            if "rating" not in data:
                return jsonify({"error": "Rating is required"}), 400
            if "comment" not in data:
                return jsonify({"error": "Comment is required"}), 400
            if "product_id" not in data:
                return jsonify({"error": "Product id is required"}), 400

        if "rating" in data:
            if not isinstance(data["rating"], int):
                return jsonify({"error": "Rating must be an integer"}), 400
            if data["rating"] < 1 or data["rating"] > 5:
                return jsonify({"error": "Rating must be between 1 and 5"}), 400

        if "comment" in data:
            if not isinstance(data["comment"], str):
                return jsonify({"error": "Comment must be a string"}), 400
            if len(data["comment"]) < 1 or len(data["comment"]) > 500:
                return jsonify({"error": "Comment must be between 1 and 500 characters"}), 400

        return f(*args, **kwargs)
    return decorated_function

This validate_review function acts as a guard. Before the main route runs, it checks if the user provided JSON data. For POST requests, required fields such as rating, comment, and product_id must be enforced explicitly so missing data returns a clean 400 validation error instead of failing later at the database layer. For PUT requests, partial updates are often allowed, so it still makes sense to validate fields only when they are present. We also validate types: rating must be an integer between 1 and 5, and comment must be a string no longer than 500 characters, matching the String(500) constraint on our database column. If the data is valid, it calls the original function using f(*args, **kwargs). To use this middleware, along with authentication, we apply them as decorators to our routes back in reviews.py.

Verifying Integration and Handling Edge Cases

Edge Cases are situations outside of normal user behavior that can cause your application to behave unexpectedly or crash. Identifying and handling these scenarios matters because real users rarely use applications perfectly. Your code must be defensive. During our workflow loop, after implementing the main logic, we must verify integration and account for these tricky situations. One major edge case in a reviews system is preventing a user from reviewing the same product twice. Let's look at how we handle this edge case inside our create_review route.

        # Verify product exists
        product = db.session.get(Product, data["product_id"])
        if not product:
            return jsonify({"error": "Product not found"}), 404

        # Check for duplicate review
        existing = Review.query.filter_by(
            user_id=current_user.id, product_id=data["product_id"]
        ).first()
        
        if existing:
            return jsonify({"error": "You already reviewed this product"}), 400

Before we create a review, we run two important checks. First, we query the database to ensure that the product_id provided actually exists. If a user tries to review a deleted or fake product, we return a 404 error. Second, we query the Review table to see if a record already exists with the same user_id and product_id. If it does, we block the creation and return a friendly 400 error. To verify everything works, we can use an OpenCode terminal to send a test request.

$ curl -X POST http://localhost:5000/api/v1/reviews/ \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"product_id": 1, "rating": 5, "comment": "Great product!"}'

{"error": "You already reviewed this product"}

This output confirms our edge case logic is working perfectly. Our application gracefully handled an invalid request instead of crashing or polluting the database with duplicate data.

Summary and Practice Preparation

Excellent work! In this lesson, we transitioned from configuration to building a real feature. We followed a professional workflow loop: planning the requirements, implementing the database model, and writing the API routes. We applied the canonical error-handling rules we established in AGENTS.md during our previous lesson to ensure that our responses were consistent. Furthermore, we protected our application by implementing validation middleware, enforcing authentication, and handling edge cases like duplicate reviews.

Now, it is your turn to practice. In the upcoming exercises, you will write the models, middleware, and API routes for this exact Product Reviews feature. You will use the CodeSignal IDE (which has all the necessary libraries like Flask and SQLAlchemy pre-installed) to implement and test your code. Get ready to build!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal