Introduction: Making Recipe Search Smarter

Welcome back! In the last lesson, you learned how to build basic endpoints to retrieve recipes from your database, including fetching a single recipe by its ID and listing recipes with pagination. These are the building blocks of any recipe API.

Now, let’s take things a step further. Imagine you open a cooking app and want to search for recipes by a list of ingredients that you have in your kitchen. Or maybe you want to return recipe steps as a list so you can follow them one by one. These features make your app much more helpful and user-friendly.

In this lesson, you’ll learn how to:

  • Search for recipes by a list of ingredients.
  • Return recipe steps as a list, not just a long string.

These skills will help you build a smarter, more flexible recipe API.

Quick Model Recap

Before we dive in, let’s quickly remind ourselves how our data is organized. This will help you understand how searching and filtering work.

  • Recipe: Each recipe has a name, a steps field (a long text field), and a list of ingredients.
  • Ingredient: Each ingredient has a name.
  • Relationship: Ingredients and Recipes are linked through a Many-to-Many relationship. This means one recipe can have many ingredients, and one ingredient can belong to many recipes.

Here is how the models are defined:

from django.db import models

class Ingredient(models.Model):
    name = models.CharField(max_length=255)

class Recipe(models.Model):
    name = models.CharField(max_length=255)
    steps = models.TextField()
    ingredients = models.ManyToManyField(Ingredient)

This structure is important because it allows us to filter recipes based on the ingredients they are connected to.

Returning Recipe Steps as an Array

Many apps and websites want to show recipe steps one at a time, not as a single long string. To help with this, you can create an endpoint that returns the steps as a JSON list.

Step 1: Defining the View and Fetching the Recipe

First, we create a view function that takes a recipe_id from the URL. We use the .first() method to find the specific recipe:

from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from .models import Recipe

@require_http_methods(["GET"])
def get_recipe_steps_array(request, recipe_id: int):
    recipe = Recipe.objects.filter(pk=recipe_id).first()
    if not recipe:
        return JsonResponse({"detail": "Recipe not found"}, status=404)
  • If the recipe does not exist, we return a 404 Not Found response.
Step 2: Splitting the Steps String

In our database, steps is stored as a single string where each step is on a new line. We can split this string into a list using the newline character \n.

    steps_list = []
    if recipe.steps:
        # Split by newline and remove empty lines or extra whitespace
        steps_list = [step.strip() for step in recipe.steps.split("\n") if step.strip()]
  • split("\n") breaks the text into individual lines.
  • strip() ensures we don't include extra spaces at the beginning or end of a step.
  • The if step.strip() part ensures we don't include empty lines in our final list.
Step 3: Returning the Response

Finally, we return a JsonResponse containing the id, the name, and the newly created steps_list.

    return JsonResponse({
        "recipe_id": recipe.id, 
        "name": recipe.name, 
        "steps": steps_list
    })

Example output:

{
  "recipe_id": 2,
  "name": "Tomato Basil Soup",
  "steps": [
    "Chop tomatoes.",
    "Add basil.",
    "Simmer for 20 minutes."
  ]
}
Retrieving Recipes by Ingredients

Let’s say you want to find all recipes that use a specific set of ingredients, like tomato and basil. You’ll need an endpoint that takes a list of ingredient names and returns only the recipes that use all of them.

Step 1: Parsing the Request Body

Since we are sending a list of ingredients, we use a POST request. We need to manually parse the JSON body from the request.

import json
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
@require_http_methods(["POST"])
def get_recipes_by_ingredients(request):
    # Parse the JSON body
    try:
        payload = json.loads(request.body.decode("utf-8"))
    except json.JSONDecodeError:
        return JsonResponse({"detail": "Invalid JSON"}, status=400)

    ingredients = payload.get("ingredients")
    if not isinstance(ingredients, list):
        return JsonResponse({"detail": "ingredients must be a list"}, status=400)

    # Clean the names: lowercase and remove whitespace
    ingredient_names = [str(name).strip().lower() for name in ingredients if str(name).strip()]
    if not ingredient_names:
        return JsonResponse({"detail": "Empty ingredients list"}, status=400)
Step 2: Finding Recipes with Advanced Filtering

To find recipes containing all requested ingredients, we use annotate and Count. This allows us to count how many of our "target" ingredients each recipe has.

from django.db.models import Count, Q

recipes = (
    Recipe.objects.filter(ingredients__name__in=ingredient_names)
    .annotate(
        match_count=Count(
            "ingredients",
            filter=Q(ingredients__name__in=ingredient_names),
            distinct=True,
        )
    )
    .filter(match_count=len(ingredient_names))
    .prefetch_related("ingredients")
)
  • filter(ingredients__name__in=...): Initially narrows the list to recipes that have at least one of the ingredients.
  • annotate(...): Adds a temporary field called match_count to each recipe. It counts how many ingredients in that recipe match our provided list.
  • filter(match_count=len(ingredient_names)): This is the crucial part. If we send 2 ingredients, we only want recipes where the match_count is exactly 2.
  • prefetch_related("ingredients"): Optimizes the query so we can fetch ingredient names later without hitting the database repeatedly.
Step 3: Serializing and Returning the Results

To return the recipes, we convert the database objects into dictionaries.

def serialize_recipe(recipe):
    return {
        "id": recipe.id,
        "name": recipe.name,
        "steps": recipe.steps,
        "ingredients": [i.name for i in recipe.ingredients.all()]
    }

# Inside the view function:
return JsonResponse([serialize_recipe(r) for r in recipes], safe=False)

Example input:

{
  "ingredients": ["Tomato", "Basil"]
}

Example output:

[
  {
    "id": 2,
    "name": "Tomato Basil Soup",
    "ingredients": ["tomato", "basil", "garlic"],
    "steps": "Chop tomatoes...\nAdd basil...",
    "average_rating": 0
  }
]
Review And What’s Next

In this lesson, you learned how to:

  • Use string manipulation to return recipe steps as a clean list.
  • Handle POST requests and parse JSON bodies manually.
  • Perform complex filtering using annotate, Count, and Q objects to find recipes matching a specific list of ingredients.

These features make your recipe API much more useful and user-friendly. Up next, you’ll get to practice building and testing these endpoints yourself. Good luck!

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