JSON Tutorial

This tutorial covers Tet’s advanced JSON handling capabilities, including safe serialization, custom adapters, and XSS prevention.

Enhanced JSON Renderer

Tet provides an enhanced JSON renderer that automatically handles common Python types and provides security features.

Basic Setup

Enable Tet’s JSON renderer in your application:

from pyramid.config import Configurator


def main():
    with Configurator() as config:
        # Include Tet's enhanced JSON renderer
        config.include("tet.renderers.json")

        return config.make_wsgi_app()

The enhanced renderer automatically handles:

  • datetime objects: Converted to ISO format strings

  • date objects: Converted to ISO format strings

  • SQLAlchemy NamedTuple results: Converted to dictionaries

Built-in Type Support

from datetime import datetime, date, timezone
from pyramid.view import view_config


@view_config(route_name="api_data", renderer="json")
def api_data_view(request):
    return {
        "timestamp": datetime.now(),  # Automatically converted
        "birthday": date(1990, 5, 15),  # Automatically converted
        "message": "Hello, World!",
        "count": 42,
    }


# Output:
# {
#     "timestamp": "2024-01-15T10:30:00",
#     "birthday": "1990-05-15",
#     "message": "Hello, World!",
#     "count": 42
# }

SQLAlchemy Integration

The JSON renderer automatically handles SQLAlchemy query results:

@view_config(route_name="user_stats", renderer="json")
def user_stats(request):
    session = request.dbsession

    # Named tuple results are automatically serializable
    stats = (
        session.query(User.name, User.email, func.count(Post.id).label("post_count"))
        .outerjoin(Post)
        .group_by(User.id)
        .all()
    )

    return {"user_stats": stats}  # Automatically converted to list of dicts

Custom JSON Adapters

Create custom adapters for your own types.

Simple Type Adapters

from decimal import Decimal
from uuid import UUID


def decimal_adapter(obj, request):
    """Convert Decimal to string to avoid precision loss."""
    return str(obj)


def uuid_adapter(obj, request):
    """Convert UUID to string."""
    return str(obj)


def main():
    with Configurator() as config:
        config.include("tet.renderers.json")

        # Add custom adapters
        config.add_json_adapter(for_=Decimal, adapter=decimal_adapter)
        config.add_json_adapter(for_=UUID, adapter=uuid_adapter)

        return config.make_wsgi_app()

Model Adapters

Create adapters for your SQLAlchemy models:

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    username = Column(String(50), unique=True)
    email = Column(String(100))
    password_hash = Column(String(128))  # Sensitive data
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
    is_active = Column(Boolean, default=True)


def user_adapter(user, request):
    """Safe user serialization - excludes sensitive data."""
    return {
        "id": user.id,
        "username": user.username,
        "email": user.email,
        "created_at": user.created_at,  # Automatically converted by Tet
        "is_active": user.is_active,
        # Note: password_hash is deliberately excluded
    }


# Register the adapter
config.add_json_adapter(for_=User, adapter=user_adapter)

Context-Aware Adapters

Adapters receive the request object, allowing for context-aware serialization:

def user_context_adapter(user, request):
    """Context-aware user serialization."""
    base_data = {"id": user.id, "username": user.username, "is_active": user.is_active}

    # Include email only for authenticated users
    if request.authenticated_userid:
        base_data["email"] = user.email

    # Include admin data for admin users
    if "group:admin" in request.effective_principals:
        base_data.update(
            {
                "created_at": user.created_at,
                "last_login": user.last_login,
                "login_count": user.login_count,
            }
        )

    return base_data

Multiple JSON Renderers

You can register multiple JSON renderers for different purposes.

Specialized Renderers

from pyramid.renderers import JSON


def main():
    with Configurator() as config:
        config.include("tet.renderers.json")

        # Create specialized renderers

        # Pretty-printed JSON for debugging
        debug_renderer = JSON(indent=2, sort_keys=True)
        config.add_json_renderer(renderer=debug_renderer, name="debug_json")

        # Compact JSON for APIs
        api_renderer = JSON(separators=(",", ":"))
        config.add_json_renderer(renderer=api_renderer, name="api_json")

        # Public API renderer with limited data
        public_renderer = JSON()
        public_renderer.add_adapter(User, user_public_adapter)
        config.add_json_renderer(renderer=public_renderer, name="public_json")

        return config.make_wsgi_app()

Using Different Renderers

@view_config(route_name="debug_data", renderer="debug_json")
def debug_view(request):
    return {"complex_data": get_complex_debug_data()}


@view_config(route_name="api_data", renderer="api_json")
def api_view(request):
    return {"users": User.query.all()}


@view_config(route_name="public_api", renderer="public_json")
def public_api_view(request):
    return {"users": User.query.filter_by(is_public=True).all()}

Safe JavaScript Serialization

Tet provides utilities to safely embed JSON in HTML pages, preventing XSS attacks.

The XSS Problem

Standard JSON serialization can be dangerous when embedded in HTML:

import json

# Dangerous user input
user_input = {"message": "</script><script>alert('XSS')</script>"}

# Standard JSON - UNSAFE
json_string = json.dumps(user_input)
# {"message": "</script><script>alert('XSS')</script>"}

When embedded in HTML:

<!-- DANGEROUS - DON'T DO THIS -->
<script>
    var data = {"message": "</script><script>alert('XSS')</script>"};
</script>
<!-- The XSS payload executes! -->

Safe Serialization Solution

Use Tet’s js_safe_dumps function:

from tet.util.json import js_safe_dumps

# Safe serialization
safe_json = js_safe_dumps(user_input)
# {"message": "\\u003c/script\\u003e\\u003cscript\\u003ealert('XSS')\\u003c/script\\u003e"}


@view_config(route_name="user_page", renderer="mytemplate.tk")
def user_page_view(request):
    user_data = {
        "name": request.user.name,
        "preferences": request.user.preferences,
        "bio": request.user.bio,  # Could contain dangerous content
    }

    # Safe for HTML embedding
    safe_user_json = js_safe_dumps(user_data)

    return {"user_json": safe_user_json}

Template Integration

In your Tonnikala template:

<!DOCTYPE html>
<html>
<head>
    <title>User Profile</title>
</head>
<body>
    <div id="user-profile"></div>

    <script>
        // Safe JSON embedding - no XSS risk
        var userData = $literal(user_json);

        // Use the data safely
        document.getElementById('user-profile').innerHTML =
            '<h1>' + escapeHtml(userData.name) + '</h1>' +
            '<p>' + escapeHtml(userData.bio) + '</p>';

        function escapeHtml(text) {
            var div = document.createElement('div');
            div.textContent = text;
            return div.innerHTML;
        }
    </script>
</body>
</html>

Advanced JSON Handling

Complex serialization scenarios and patterns.

Nested Object Serialization

Handle complex nested objects:

class BlogPost(Base):
    __tablename__ = "posts"

    id = Column(Integer, primary_key=True)
    title = Column(String(200))
    content = Column(Text)
    author_id = Column(Integer, ForeignKey("users.id"))
    created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))

    # Relationship
    author = relationship("User", back_populates="posts")
    comments = relationship("Comment", back_populates="post")


def post_adapter(post, request):
    """Serialize blog post with nested objects."""
    return {
        "id": post.id,
        "title": post.title,
        "content": post.content,
        "created_at": post.created_at,
        "author": {"id": post.author.id, "username": post.author.username},
        "comment_count": len(post.comments),
        "comments": [
            {
                "id": comment.id,
                "content": comment.content,
                "author": comment.author.username,
                "created_at": comment.created_at,
            }
            for comment in post.comments[:5]  # Limit to recent comments
        ],
    }

Pagination-Aware JSON

Handle paginated results:

def paginated_adapter(query_result, request):
    """Adapter for paginated query results."""
    page = int(request.params.get("page", 1))
    per_page = int(request.params.get("per_page", 20))

    # Paginate the query
    total = query_result.count()
    items = query_result.offset((page - 1) * per_page).limit(per_page).all()

    return {
        "items": items,  # Will be serialized by their own adapters
        "pagination": {
            "page": page,
            "per_page": per_page,
            "total": total,
            "pages": (total + per_page - 1) // per_page,
            "has_next": page * per_page < total,
            "has_prev": page > 1,
        },
    }

Error Response JSON

Standardize error responses:

from datetime import datetime, timezone

from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound


class APIError(Exception):
    def __init__(self, message, code=None, details=None):
        self.message = message
        self.code = code
        self.details = details or {}


def api_error_adapter(error, request):
    """Serialize API errors consistently."""
    return {
        "error": {
            "message": error.message,
            "code": error.code,
            "details": error.details,
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
    }


def error_view(request):
    """Handle API errors."""
    try:
        # Your business logic
        result = do_something()
        return {"data": result}
    except ValidationError as e:
        raise APIError(
            message="Validation failed",
            code="VALIDATION_ERROR",
            details={"field_errors": e.errors},
        )
    except PermissionError:
        raise APIError(message="Permission denied", code="PERMISSION_DENIED")

Performance Optimization

Optimize JSON serialization for performance.

Lazy Loading with JSON

from sqlalchemy.orm import load_only


@view_config(route_name="users_api", renderer="json")
def users_api_light(request):
    """Optimized user listing - only load needed fields."""
    session = request.dbsession

    # Only load fields we'll serialize
    users = (
        session.query(User)
        .options(load_only(User.id, User.username, User.email, User.created_at))
        .all()
    )

    return {"users": users}

Caching JSON Responses

from functools import lru_cache
from pyramid.response import Response
import json


@lru_cache(maxsize=100)
def get_cached_stats():
    """Cache expensive statistics calculation."""
    # Expensive computation
    return calculate_complex_stats()


@view_config(route_name="stats_api")
def stats_api(request):
    """Cached JSON response."""
    stats = get_cached_stats()

    # Manual JSON response with caching headers
    response = Response(body=json.dumps(stats), content_type="application/json")
    response.cache_control.max_age = 300  # 5 minutes
    return response

JSON Schema Validation

Validate JSON input using schemas.

Input Validation

import jsonschema
from pyramid.httpexceptions import HTTPBadRequest

USER_CREATE_SCHEMA = {
    "type": "object",
    "properties": {
        "username": {
            "type": "string",
            "minLength": 3,
            "maxLength": 50,
            "pattern": "^[a-zA-Z0-9_]+$",
        },
        "email": {"type": "string", "format": "email"},
        "age": {"type": "integer", "minimum": 13, "maximum": 120},
    },
    "required": ["username", "email"],
    "additionalProperties": False,
}


def validate_json_input(data, schema):
    """Validate JSON data against schema."""
    try:
        jsonschema.validate(data, schema)
    except jsonschema.ValidationError as e:
        raise HTTPBadRequest(
            json_body={
                "error": "Validation failed",
                "details": e.message,
                "path": list(e.absolute_path),
            }
        )


@view_config(route_name="create_user", request_method="POST", renderer="json")
def create_user(request):
    # Validate input
    validate_json_input(request.json_body, USER_CREATE_SCHEMA)

    # Create user with validated data
    user_data = request.json_body
    user = User(
        username=user_data["username"],
        email=user_data["email"],
        age=user_data.get("age"),
    )

    return {"user": user}

Testing JSON APIs

Test your JSON endpoints thoroughly.

Basic JSON Testing

def test_user_api(app):
    # Test successful response
    response = app.get("/api/users/1")
    assert response.status_code == 200
    assert response.content_type == "application/json"

    data = response.json
    assert "id" in data
    assert "username" in data
    assert "password_hash" not in data  # Sensitive data excluded


def test_json_input_validation(app):
    # Test invalid JSON input
    invalid_data = {"username": "x"}  # Too short

    response = app.post_json("/api/users", invalid_data, expect_errors=True)
    assert response.status_code == 400

    error_data = response.json
    assert "error" in error_data

Custom JSON Assertions

def assert_json_structure(data, expected_keys):
    """Assert JSON has expected structure."""
    assert isinstance(data, dict)
    for key in expected_keys:
        assert key in data, f"Missing key: {key}"


def assert_iso_datetime(datetime_string):
    """Assert string is valid ISO datetime."""
    from datetime import datetime

    try:
        datetime.fromisoformat(datetime_string.replace("Z", "+00:00"))
    except ValueError:
        raise AssertionError(f"Invalid ISO datetime: {datetime_string}")


def test_user_json_structure(app):
    response = app.get("/api/users/1")
    user_data = response.json

    assert_json_structure(user_data, ["id", "username", "email", "created_at"])
    assert_iso_datetime(user_data["created_at"])

Best Practices

Security First - Always use js_safe_dumps when embedding JSON in HTML - Never include sensitive data in JSON responses - Validate all JSON input with schemas

Performance - Use lazy loading for database queries - Cache expensive JSON responses - Only load and serialize needed fields

Consistency - Use adapters for consistent object serialization - Standardize error response formats - Use ISO format for dates and times

Testing - Test JSON structure and content - Test both success and error responses - Validate security aspects (no sensitive data leakage)

Documentation - Document JSON API schemas - Provide example requests and responses - Document custom adapters and their behavior