Your First API 📝

In this tutorial, we’ll build a complete blog API to demonstrate Tatami’s features. You’ll learn about models, validation, error handling, and more advanced routing patterns.

What We’re Building

We’re creating a blog API with:

  • 📝 Posts: Create, read, update, delete blog posts

  • 👤 Authors: Manage author information

  • 🏷️ Tags: Categorize posts with tags

  • 💬 Comments: Add comments to posts

  • 🔍 Search: Find posts by title or content

Project Setup

Let’s start with a fresh project:

tatami create blog-api
cd blog-api

First, let’s understand the project structure that was created:

blog-api/
├── config.yaml          # Main configuration
├── config-dev.yaml      # Development-specific config
├── README.md            # Project documentation
├── favicon.ico          # Your API's favicon
├── routers/             # 🎯 API endpoints (we'll focus here)
├── services/            # 🧠 Business logic layer
├── middleware/          # 🔄 Request/response processing
├── static/              # 📁 Static files (CSS, JS, images)
└── templates/           # 📄 HTML templates

Understanding Routers vs Services

Routers handle HTTP concerns: - Route definitions (@get, @post, etc.) - Request/response handling - HTTP status codes - Parameter extraction

Services handle business logic: - Data access and manipulation - Business rules and validation - Pure Python functions - No HTTP knowledge

This separation keeps your code clean and testable!

Creating Data Models

First, let’s create our data models. Create routers/models.py:

# routers/models.py
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field

class Author(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: str = Field(..., regex=r'^[^@]+@[^@]+\.[^@]+$')
    bio: Optional[str] = Field(None, max_length=500)

class Tag(BaseModel):
    name: str = Field(..., min_length=1, max_length=50)
    color: str = Field('#blue', regex=r'^#[a-zA-Z]+$')

class PostCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    content: str = Field(..., min_length=1)
    author_id: int
    tags: List[str] = Field(default_factory=list)
    published: bool = False

class PostUpdate(BaseModel):
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    content: Optional[str] = Field(None, min_length=1)
    published: Optional[bool] = None
    tags: Optional[List[str]] = None

class Post(BaseModel):
    id: int
    title: str
    content: str
    author_id: int
    author_name: str
    tags: List[str]
    published: bool
    created_at: datetime
    updated_at: datetime

class Comment(BaseModel):
    id: int
    post_id: int
    author_name: str
    content: str
    created_at: datetime

class CommentCreate(BaseModel):
    author_name: str = Field(..., min_length=1, max_length=100)
    content: str = Field(..., min_length=1, max_length=1000)

Creating a Service Layer

Now let’s create a service to handle our business logic. Create services/blog_service.py:

# services/blog_service.py
from datetime import datetime
from typing import List, Optional
from tatami.di import injectable
from routers.models import Author, Post, PostCreate, PostUpdate, Comment, CommentCreate

@injectable
class BlogService:
    def __init__(self):
        # In-memory storage for this example
        # In a real app, this would be a database
        self.posts = []
        self.authors = [
            {"id": 1, "name": "Alice Johnson", "email": "alice@example.com", "bio": "Tech writer"},
            {"id": 2, "name": "Bob Smith", "email": "bob@example.com", "bio": "Developer"},
        ]
        self.comments = []
        self.next_post_id = 1
        self.next_comment_id = 1

    def create_post(self, post_data: PostCreate) -> Post:
        author = next((a for a in self.authors if a["id"] == post_data.author_id), None)
        if not author:
            raise ValueError("Author not found")

        new_post = {
            "id": self.next_post_id,
            "title": post_data.title,
            "content": post_data.content,
            "author_id": post_data.author_id,
            "author_name": author["name"],
            "tags": post_data.tags,
            "published": post_data.published,
            "created_at": datetime.now(),
            "updated_at": datetime.now(),
        }
        self.posts.append(new_post)
        self.next_post_id += 1
        return Post(**new_post)

    def get_posts(self, published_only: bool = False) -> List[Post]:
        posts = self.posts
        if published_only:
            posts = [p for p in posts if p["published"]]
        return [Post(**post) for post in posts]

    def get_post(self, post_id: int) -> Optional[Post]:
        post = next((p for p in self.posts if p["id"] == post_id), None)
        return Post(**post) if post else None

    def update_post(self, post_id: int, updates: PostUpdate) -> Optional[Post]:
        post = next((p for p in self.posts if p["id"] == post_id), None)
        if not post:
            return None

        # Update only provided fields
        if updates.title is not None:
            post["title"] = updates.title
        if updates.content is not None:
            post["content"] = updates.content
        if updates.published is not None:
            post["published"] = updates.published
        if updates.tags is not None:
            post["tags"] = updates.tags

        post["updated_at"] = datetime.now()
        return Post(**post)

    def delete_post(self, post_id: int) -> bool:
        post = next((p for p in self.posts if p["id"] == post_id), None)
        if post:
            self.posts.remove(post)
            return True
        return False

    def search_posts(self, query: str) -> List[Post]:
        results = []
        query_lower = query.lower()
        for post in self.posts:
            if query_lower in post["title"].lower() or query_lower in post["content"].lower():
                results.append(Post(**post))
        return results

    def get_authors(self) -> List[Author]:
        return [Author(**author) for author in self.authors]

    def add_comment(self, post_id: int, comment_data: CommentCreate) -> Optional[Comment]:
        post = next((p for p in self.posts if p["id"] == post_id), None)
        if not post:
            return None

        new_comment = {
            "id": self.next_comment_id,
            "post_id": post_id,
            "author_name": comment_data.author_name,
            "content": comment_data.content,
            "created_at": datetime.now(),
        }
        self.comments.append(new_comment)
        self.next_comment_id += 1
        return Comment(**new_comment)

    def get_comments(self, post_id: int) -> List[Comment]:
        post_comments = [c for c in self.comments if c["post_id"] == post_id]
        return [Comment(**comment) for comment in post_comments]

Creating the Posts Router

Now let’s create our main posts router. Create routers/posts.py:

# routers/posts.py
from typing import List, Optional
from tatami import router, get, post, put, delete
from tatami.param import Query
from services.blog_service import BlogService
from routers.models import Post, PostCreate, PostUpdate

class Posts(router('/posts')):
    """Blog posts management"""

    def __init__(self, blog_service: BlogService):
        super().__init__()
        self.blog = blog_service

    @get
    def list_posts(self, published: Optional[bool] = Query(None)) -> List[Post]:
        """Get all posts, optionally filter by published status"""
        if published is not None:
            return self.blog.get_posts(published_only=published)
        return self.blog.get_posts()

    @get('/search')
    def search_posts(self, q: str = Query(...)) -> List[Post]:
        """Search posts by title or content"""
        if len(q.strip()) < 2:
            return {"error": "Search query must be at least 2 characters"}, 400
        return self.blog.search_posts(q)

    @get('/{post_id}')
    def get_post(self, post_id: int) -> Post:
        """Get a specific post by ID"""
        post = self.blog.get_post(post_id)
        if not post:
            return {"error": "Post not found"}, 404
        return post

    @post('/')
    def create_post(self, post: PostCreate) -> Post:
        """Create a new blog post"""
        try:
            return self.blog.create_post(post)
        except ValueError as e:
            return {"error": str(e)}, 400

    @put('/{post_id}')
    def update_post(self, post_id: int, updates: PostUpdate) -> Post:
        """Update an existing post"""
        post = self.blog.update_post(post_id, updates)
        if not post:
            return {"error": "Post not found"}, 404
        return post

    @delete('/{post_id}')
    def delete_post(self, post_id: int) -> dict:
        """Delete a post"""
        if self.blog.delete_post(post_id):
            return {"message": "Post deleted successfully"}
        return {"error": "Post not found"}, 404

Creating Additional Routers

Let’s add routers for authors and comments. Create routers/authors.py:

# routers/authors.py
from typing import List
from tatami import router, get
from services.blog_service import BlogService
from routers.models import Author

class Authors(router('/authors')):
    """Author management"""

    def __init__(self, blog_service: BlogService):
        super().__init__()
        self.blog = blog_service

    @get
    def list_authors(self) -> List[Author]:
        """Get all authors"""
        return self.blog.get_authors()

And create routers/comments.py:

# routers/comments.py
from typing import List
from tatami import router, get, post
from services.blog_service import BlogService
from routers.models import Comment, CommentCreate

class Comments(router('/posts/{post_id}/comments')):
    """Post comments management"""

    def __init__(self, blog_service: BlogService):
        super().__init__()
        self.blog = blog_service

    @get
    def get_comments(self, post_id: int) -> List[Comment]:
        """Get all comments for a post"""
        return self.blog.get_comments(post_id)

    @post
    def add_comment(self, post_id: int, comment: CommentCreate) -> Comment:
        """Add a comment to a post"""
        result = self.blog.add_comment(post_id, comment)
        if not result:
            return {"error": "Post not found"}, 404
        return result

Running Your Blog API

Now let’s run our blog API:

tatami run .

Your API is now running at http://localhost:8000!

Testing the API

Let’s test our endpoints:

# Get all posts
curl http://localhost:8000/posts

# Create a new post
curl -X POST http://localhost:8000/posts \
     -H "Content-Type: application/json" \
     -d '{
       "title": "My First Blog Post",
       "content": "This is the content of my first post!",
       "author_id": 1,
       "tags": ["tutorial", "tatami"],
       "published": true
     }'

# Search for posts
curl "http://localhost:8000/posts/search?q=blog"

# Get all authors
curl http://localhost:8000/authors

# Add a comment to post 1
curl -X POST http://localhost:8000/posts/1/comments \
     -H "Content-Type: application/json" \
     -d '{
       "author_name": "John Doe",
       "content": "Great post! Thanks for sharing."
     }'

Exploring the Documentation

Visit http://localhost:8000/docs/ to see Tatami’s documentation landing page with links to all available formats.

Then explore http://localhost:8000/docs/swagger for interactive API documentation. Notice how:

  • All endpoints are automatically documented

  • Request/response schemas are generated from your Pydantic models

  • You can test endpoints directly from the docs

  • Query parameters and path parameters are clearly shown

💡 Pro Tip: You can customize the documentation landing page by creating templates/__tatami__/docs_landing.html in your project!

What You’ve Learned

In this tutorial, you’ve mastered:

🏗️ Project Organization: Separating routers, services, and models

📋 Data Modeling: Using Pydantic for validation and documentation

🎯 Advanced Routing: Query parameters, path parameters, and nested routes

  • 🧠 Service Layer: Keeping business logic separate from HTTP concerns

  • 🔍 Search & Filtering: Implementing search and filtering endpoints

  • Error Handling: Returning appropriate HTTP status codes

  • 🧪 API Testing: Testing your endpoints with curl

Next Steps

Your blog API is working great! In the next tutorials, you’ll learn about:

  • Project structure best practices and conventions

  • Advanced routing patterns and middleware

  • Database integration and data persistence

  • Dependency injection for better code organization

  • Testing strategies for Tatami applications

Keep building amazing APIs! 🚀