Skip to content

cwaeland/courier

Repository files navigation

Courier

A lightweight, package-based routing framework for building RESTful APIs in Python.

Tests Python Version License

Courier eliminates boilerplate routing code by automatically mapping your Python package structure to URL routes. Organize your API endpoints into a logical package hierarchy, and Courier handles the rest.

Features

  • 🗂️ Package-Based Routing - Your package structure becomes your URL structure
  • 🔄 Automatic Route Discovery - No manual route registration required
  • Built-in Validation - Request schema validation using Voluptuous
  • 🔐 Token Authentication - Cryptographic signing with itsdangerous
  • 🌐 CORS Support - Built-in CORS headers and OPTIONS handling
  • 🔀 Parameter Transformation - Automatic snake_case ↔ camelCase conversion
  • Powered by Werkzeug - Battle-tested WSGI routing from the Flask team
  • 🧪 Fully Tested - Comprehensive test suite with CI/CD

Installation

pip install courier

Or install from source:

git clone https://github.com/cwaeland/courier.git
cd courier
pip install -e .

Quick Start

1. Create Your API Structure

my_project/
├── app.py
└── api/
    ├── __init__.py
    └── users.py

2. Define API Methods

api/users.py:

from courier.routing.api_method import APIMethod
from voluptuous import Schema, Required

class Users(APIMethod):
    """Handle user collection endpoints"""

    @staticmethod
    @APIMethod.public(
        http_method="get",
        requires_auth=False
    )
    def get(self, request):
        """List all users"""
        return {
            "users": [
                {"user_id": 1, "user_name": "Alice"},
                {"user_id": 2, "user_name": "Bob"}
            ]
        }

    @staticmethod
    @APIMethod.public(
        http_method="post",
        api_schema=Schema({
            Required("userName"): str,
            Required("email"): str
        }),
        requires_auth=False
    )
    def post(self, request):
        """Create a new user"""
        params = request["params"]
        return {
            "user_id": 3,
            "user_name": params["user_name"],
            "email": params["email"]
        }


class User(APIMethod):
    """Handle individual user endpoints"""

    @staticmethod
    def is_detail():
        return True

    @staticmethod
    def get_base_schema():
        return {"userId": int}

    @staticmethod
    @APIMethod.public(http_method="get", requires_auth=False)
    def get(self, request):
        """Get a specific user"""
        user_id = request["params"]["user_id"]
        return {
            "user_id": user_id,
            "user_name": f"User {user_id}"
        }

3. Create Your WSGI Application

app.py:

from courier import Courier
import os

# Create Courier app
app = Courier(
    web_root_path=os.path.join(os.path.dirname(__file__), "api"),
    web_root_name="api"
)

if __name__ == "__main__":
    from werkzeug.serving import run_simple
    run_simple('localhost', 5000, app, use_debugger=True, use_reloader=True)

4. Run Your API

python app.py

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

5. Test Your Endpoints

# List all users
curl http://localhost:5000/users/

# Get specific user
curl http://localhost:5000/users/1/

# Create new user
curl -X POST http://localhost:5000/users/ \
  -H "Content-Type: application/json" \
  -d '{"userName": "Charlie", "email": "charlie@example.com"}'

# View all routes
curl http://localhost:5000/routes

How It Works

Automatic Routing

Courier maps your package structure to URL paths:

api/
├── users.py          → /users/
├── posts.py          → /posts/
└── admin/
    └── settings.py   → /admin/settings/

Detail Routes

Classes with is_detail() = True create parameterized routes:

class User(APIMethod):
    @staticmethod
    def is_detail():
        return True

    @staticmethod
    def get_base_schema():
        return {"userId": int}  # Defines the parameter name

This creates routes like /users/{userId}/

Nested Resources

Create hierarchical APIs by nesting packages:

api/
└── users/
    ├── __init__.py      # Users collection
    └── posts/
        └── __init__.py  # User's posts

Results in routes:

  • /users/ - User collection
  • /users/{userId}/ - Specific user
  • /users/{userId}/posts/ - User's posts
  • /users/{userId}/posts/{postId}/ - Specific post

Request/Response Flow

  1. Inbound: camelCase parameters → snake_case

    {"userName": "Alice"} → {"user_name": "Alice"}
  2. Processing: Your method logic runs

  3. Outbound: snake_case response → camelCase JSON

    {"user_name": "Alice"} → {"userName": "Alice"}
  4. Response Format:

    {
      "status": "success",
      "code": null,
      "message": null,
      "signature": null,
      "return_value": {
        "userName": "Alice"
      }
    }

Validation

Use Voluptuous schemas to validate request parameters:

from voluptuous import Schema, Required, Email, Range

@APIMethod.public(
    http_method="post",
    api_schema=Schema({
        Required("userName"): str,
        Required("email"): Email(),
        Required("age"): Range(min=18, max=120)
    })
)
def post(self, request):
    # Parameters are already validated!
    params = request["params"]
    return {"user_created": True}

Invalid requests automatically return a 422 error with details.

Authentication

Add token-based authentication:

from courier.routing.authenticator import APIAuthenticator
from itsdangerous import TimestampSigner

# Create authenticator
signer = TimestampSigner("your-secret-key")
auth = APIAuthenticator(signer, expiration=3600)

class ProtectedResource(APIMethod):
    @staticmethod
    def get_default_auth():
        return auth

    @staticmethod
    @APIMethod.public(http_method="get", requires_auth=True)
    def get(self, request):
        # Access authenticated user
        user_token = self.user_token
        return {"message": f"Hello {user_token}!"}

Custom Validation

Create reusable validators:

from voluptuous import Invalid
import re

def Username(value):
    """Validate username format"""
    if not re.match(r'^[a-zA-Z0-9_]{3,20}$', value):
        raise Invalid('Username must be 3-20 alphanumeric characters or underscores')
    return value

# Use in schema
api_schema=Schema({
    Required("userName"): Username
})

Error Handling

Courier provides structured error responses:

from courier.routing.exc import APIError

@APIMethod.public(http_method="get")
def get(self, request):
    user_id = request["params"]["user_id"]

    if user_id > 1000:
        raise APIError("UserNotFound", "User does not exist")

    return {"user_id": user_id}

Returns:

{
  "status": "error",
  "code": "UserNotFound",
  "message": "User does not exist",
  "return_value": null
}

Advanced Usage

Custom JSON Serialization

Handle custom types in responses:

class MyAPI(APIMethod):
    @staticmethod
    def get_extra_json_types():
        return [
            {
                "instance": MyCustomClass,
                "callable": lambda api_obj, obj: obj.to_dict()
            }
        ]

ACL (Access Control)

Implement custom access control:

@staticmethod
def process_acl(user_token, params, **add_params):
    # Check if user has permission
    if not user_has_permission(user_token, params):
        raise APIError("Forbidden", "Access denied")
    return {}

Development

Running Tests

# Install development dependencies
pip install -r requirements-dev.txt

# Run tests
pytest

# Run with coverage
pytest --cov=courier --cov-report=html

Code Quality

# Format code
black courier/ tests/

# Lint code
ruff check courier/ tests/

# Type checking
mypy courier/

Requirements

  • Python 3.8+
  • Werkzeug >= 3.0.0
  • Voluptuous >= 0.14.2
  • itsdangerous >= 2.2.0

WSGI Servers

Deploy with any WSGI server:

Gunicorn

pip install gunicorn
gunicorn app:app

uWSGI

pip install uwsgi
uwsgi --http :5000 --wsgi-file app.py --callable app

Waitress

pip install waitress
waitress-serve --port=5000 app:app

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

Changelog

See CHANGES.md for version history.

License

MIT License - see LICENSE.md for details.

Credits

Created by Cameron Waeland

Modernized and maintained by the community


Built with ❤️ using Werkzeug, Voluptuous, and itsdangerous

About

A lightweight Python framework that automatically maps package structures to RESTful API routes.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages