Routing

Map URLs to handler functions using expressive route patterns with typed parameters, wildcards, and regex filters.

Basic Routing

Routes map URL patterns to Python functions. Use the @app.route() decorator to register a handler:

from lcore import Lcore

app = Lcore()

@app.route('/')
def index():
    return 'Welcome home'

@app.route('/about')
def about():
    return 'About page'

Return Types

Handlers can return various types, and Lcore handles them automatically:

Return TypeBehavior
strSent as text/html response
bytesSent as raw bytes
dict / listSerialized as JSON with application/json Content-Type
HTTPResponseSent as-is with custom status/headers
File-like objectStreamed to client
NoneEmpty 200 response

Named Routes

@app.route('/users/<id:int>', name='user_detail')
def user_detail(id):
    return {'id': id}

# Build URL from name
url = app.get_url('user_detail', id=42)
# -> '/users/42'

HTTP Methods

By default, routes respond to GET requests. Specify other methods with the method parameter:

@app.route('/items', method='POST')
def create_item():
    return {'created': True}

# Multiple methods on one route
@app.route('/items/<id:int>', method=['GET', 'PUT', 'DELETE'])
def item(id):
    if request.method == 'GET':
        return get_item(id)
    elif request.method == 'PUT':
        return update_item(id)
    elif request.method == 'DELETE':
        return delete_item(id)

Shorthand Decorators

DecoratorMethodExample
@app.get(path)GETRetrieve resources
@app.post(path)POSTCreate resources
@app.put(path)PUTReplace resources
@app.delete(path)DELETERemove resources
@app.patch(path)PATCHPartial updates
@app.get('/users')
def list_users():
    return {'users': []}

@app.post('/users')
def create_user():
    data = request.json
    return {'id': 1, **data}

@app.put('/users/<id:int>')
def replace_user(id):
    return {'id': id, 'replaced': True}

@app.delete('/users/<id:int>')
def remove_user(id):
    return {'deleted': True}
Tip

HEAD requests are automatically handled for any GET route. The response body is stripped, but headers (including Content-Length) are preserved.

If a URL matches a route but not the HTTP method, Lcore returns 405 Method Not Allowed with an Allow header listing valid methods.

Dynamic Routes

Capture URL segments as parameters using angle brackets. Parameters are passed as keyword arguments to your handler:

@app.route('/hello/<name>')
def hello(name):
    return f'Hello, {name}!'

# GET /hello/Alice -> "Hello, Alice!"

Typed Parameters

Add a filter to constrain and convert parameter types:

FilterPatternPython TypeDescription
<name>[^/]+strMatches one path segment (default)
<name:int>\d+intMatches digits, converts to integer
<name:float>\d+\.\d+floatMatches decimal numbers
<name:path>.+strMatches multiple segments (including /)
<name:re:pattern>Custom regexstrMatches custom regex pattern
# Integer parameter
@app.route('/users/<id:int>')
def get_user(id):
    return {'id': id, 'type': type(id).__name__}
# GET /users/42 -> {"id": 42, "type": "int"}
# GET /users/abc -> 404

# Float parameter
@app.route('/coords/<lat:float>/<lon:float>')
def location(lat, lon):
    return {'lat': lat, 'lon': lon}

# Path parameter (matches slashes)
@app.route('/files/<filepath:path>')
def serve_file(filepath):
    return static_file(filepath, root='./public')
# GET /files/docs/guide/intro.pdf -> serves the file

# Regex parameter
@app.route('/api/v<version:re:[0-9]+>/status')
def api_status(version):
    return {'api_version': version}

# Multiple parameters
@app.route('/<year:int>/<month:int>/<slug>')
def article(year, month, slug):
    return {'year': year, 'month': month, 'slug': slug}

Async Routes

Critical: Async Handlers Block the Worker Thread

Lcore is a synchronous WSGI framework. WSGI (PEP 3333) is a synchronous protocol by design. Each request occupies exactly one OS thread from start to finish. This is not a flaw in Lcore; it is a hard constraint of the WSGI specification itself.

When you write an async def handler, Lcore accepts it and executes it by calling asyncio.run() inside a dedicated thread, which blocks that thread until the coroutine finishes. Consequences:

  • No concurrency benefit. await asyncio.sleep(1) blocks the worker thread for 1 second, identical to time.sleep(1). Under gunicorn with 4 threads, 4 concurrent async handlers bring the server to a halt.
  • No persistent event loop. Each handler invocation gets a brand-new event loop that is destroyed on return. Loop-bound libraries such as httpx, aiohttp, asyncpg, motor, redis.asyncio, and tortoise-orm will not work correctly inside Lcore async handlers because they require a single long-lived event loop and connection pools that persist across calls.
  • Higher overhead. Every async route invocation spawns a new thread and a new event loop, adding roughly 0.5 to 2 ms per request versus a plain sync handler.

Lcore will emit a UserWarning at startup for every async def route you register, so you are always informed.

When async def is acceptable in Lcore

There is one scenario where using async def in Lcore is harmless: calling an async helper that is pure CPU work wrapped in a coroutine with no I/O and no loop-bound state. In practice this is rare. For everything else, prefer sync equivalents.

import asyncio
from lcore import Lcore

app = Lcore()  # Lcore will emit a UserWarning for each async route below

# OK: works, but provides NO concurrency advantage.
# The worker thread is blocked for the duration of the coroutine.
@app.route('/ok-simple')
async def simple():
    # Fine: fast pure-Python computation inside a coroutine.
    return {'result': sum(range(1000))}

# INCORRECT: asyncio.gather() does run both coroutines, but the entire
# gather() blocks the worker thread until both complete. No parallelism.
@app.route('/broken-parallel')
async def broken_parallel():
    # Not concurrent in a WSGI context. Both still run sequentially
    # from the perspective of the OS thread scheduler.
    users, products = await asyncio.gather(fetch_users(), fetch_products())
    return {'users': users, 'products': products}

# INCORRECT: async libraries that require a persistent event loop will
# fail or silently misbehave because each request gets a brand-new loop.
# import httpx
# import asyncpg
# async def broken_db():
#     conn = await asyncpg.connect(...)  # new loop every call, pool unusable
#     ...

# CORRECT: write synchronous handlers.
# Use requests, psycopg2, redis-py, etc.
@app.route('/correct')
def sync_handler():
    # Fast, no overhead, works correctly with every WSGI server.
    return {'type': 'sync'}
Coming Soon: lcore-asgi

A companion library, lcore-asgi, is currently in development. It will bring full ASGI support to the Lcore ecosystem, including:

  • WebSockets - bidirectional real-time connections
  • True async concurrency - non-blocking I/O with a persistent event loop
  • Async-native libraries - full compatibility with httpx, asyncpg, aiohttp, motor, and others
  • HTTP/2 and Server-Sent Events
  • Familiar Lcore API - same routing, middleware, and plugin model you already know

Until lcore-asgi is released, async workloads should use a dedicated ASGI framework (see below).

Need genuine async concurrency right now?

If your use case requires concurrent async I/O such as WebSockets, many simultaneous outbound HTTP calls, streaming, or async database drivers, use an ASGI framework in the meantime. Lcore does not currently provide an ASGI interface.

  • FastAPI - async-first, automatic OpenAPI, Pydantic validation
  • Starlette - lightweight ASGI toolkit, full async support
  • Quart - Flask-compatible async WSGI/ASGI hybrid

Async Middleware

Middleware can also be async:

from lcore import Middleware

class AsyncTimingMiddleware(Middleware):
    name = 'async_timing'
    order = 2

    async def __call__(self, ctx, next_handler):
        import time
        start = time.time()
        result = next_handler(ctx)
        duration = time.time() - start
        ctx.response.set_header('X-Time', f'{duration:.4f}s')
        return result

Route Filters

Create custom route filters for reusable parameter validation and conversion:

# Custom UUID filter
import re

app.add_route_filter(
    'uuid',
    r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
    to_python=lambda val: val,
    to_url=lambda val: str(val)
)

@app.route('/items/<item_id:uuid>')
def get_item(item_id):
    return {'item_id': item_id}
# GET /items/550e8400-e29b-41d4-a716-446655440000 -> matches
# GET /items/invalid -> 404

The add_route_filter method accepts:

ParameterTypeDescription
namestrFilter name used in route patterns
patternstrRegex pattern to match
to_pythoncallableConvert matched string to Python type
to_urlcallableConvert Python value back to URL string

Route Groups

Group routes under a common prefix using the app.group() context manager:

with app.group('/api/v1'):
    @app.route('/users')
    def list_users():
        return {'users': []}

    @app.route('/users/<id:int>')
    def get_user(id):
        return {'id': id}

    @app.route('/products')
    def list_products():
        return {'products': []}

# Routes registered:
#   /api/v1/users
#   /api/v1/users/<id:int>
#   /api/v1/products

Groups can share common configuration:

with app.group('/admin', auth_required=True):
    @app.route('/dashboard')
    def dashboard():
        return 'Admin Dashboard'

Error Routes

Define custom error pages with the @app.error() decorator:

@app.error(404)
def not_found(error):
    return {'error': 'Not found', 'path': request.path}

@app.error(500)
def server_error(error):
    return {'error': 'Internal server error'}

# The error handler receives an HTTPError object with:
#   error.status_code  - HTTP status code
#   error.body         - Error message
#   error.exception    - Original exception (if any)
#   error.traceback    - Traceback string (if debug)

Raising Errors

from lcore import abort, HTTPError

@app.route('/protected')
def protected():
    # Quick abort
    abort(403, 'Access denied')

    # Or raise with more control
    raise HTTPError(
        status=422,
        body='Invalid input',
        headers={'X-Error': 'validation'}
    )

URL Building

Build URLs from route names to avoid hardcoding paths:

@app.route('/users/<id:int>', name='user_detail')
def user_detail(id):
    return {'id': id}

@app.route('/users/<id:int>/posts/<post_id:int>', name='user_post')
def user_post(id, post_id):
    return {'user': id, 'post': post_id}

# Build URLs
app.get_url('user_detail', id=42)
# -> '/users/42'

app.get_url('user_post', id=42, post_id=7)
# -> '/users/42/posts/7'
Note

If a route has no explicit name, it defaults to None and cannot be used for URL building. Always name routes you intend to reference.

Route Inspection

List Routes

Use app.show_routes() to get a structured list of all registered routes:

routes = app.show_routes()
for r in routes:
    print(f"{r['method']:6s} {r['rule']:30s} {r['name'] or '-'}")

# GET    /                              index
# GET    /users                         list_users
# POST   /users                         create_user
# GET    /users/<id:int>                user_detail

Each route dict contains: method, rule, name, callback, plugins.

Auto-Generated API Docs

Generate API documentation automatically from your routes, docstrings, and parameter types:

@app.route('/api/users/<id:int>', method='GET', name='get_user')
def get_user(id):
    """Fetch a single user by their unique ID."""
    return {'id': id}

# Get docs as dict
docs = app.api_docs()
print(docs['routes'][0])
# {'method': 'GET', 'rule': '/api/users/<id:int>',
#  'name': 'get_user', 'docstring': 'Fetch a single user...',
#  'parameters': [{'name': 'id', 'type': 'int'}], ...}

# Get docs as JSON string
json_docs = app.api_docs_json()

# Serve docs as an endpoint
@app.route('/docs')
def docs():
    return app.api_docs()