=============
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:
.. code-block:: python
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
---------------------
.. code-block:: python
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:
.. code-block:: python
@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
--------------------
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
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
---------------------
.. code-block:: python
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
-------------------------
.. code-block:: python
@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:
.. code-block:: python
import json
# Dangerous user input
user_input = {"message": ""}
# Standard JSON - UNSAFE
json_string = json.dumps(user_input)
# {"message": ""}
When embedded in HTML:
.. code-block:: text
"};
Safe Serialization Solution
---------------------------
Use Tet's ``js_safe_dumps`` function:
.. code-block:: python
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:
.. code-block:: html
User Profile
Advanced JSON Handling
======================
Complex serialization scenarios and patterns.
Nested Object Serialization
---------------------------
Handle complex nested objects:
.. code-block:: python
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:
.. code-block:: python
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:
.. code-block:: python
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
----------------------
.. code-block:: python
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
----------------------
.. code-block:: python
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
----------------
.. code-block:: python
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
------------------
.. code-block:: python
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
----------------------
.. code-block:: python
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