"Test" Client in Production for Batch Endpoint

I’m working on a new prototype and exploring some ideas – I’m new to Sanic. One of the ideas is to explore a JSON API endpoint that allows batching of requests on a single database session.

Something akin to:

@app.get("/grommets")
async def grommet_handler(request):
   return await load_grommets()

@app.get("/widgets")
async def widget_handler(request):
   return await load_widgets()

@app.get("/batch")
async def batch_handler(request):
    grommets = await app.test_client.get('/grommets')
    widgets = await app.test_client.get('/widgets')
    return {
        "grommets": grommets,
        "widgets": widgets
    }

I would also attempt to coordinate a single database session between the requests, so they all go out in the same database transaction. (Similar to how GraphQL folks solve the N+1 problem)

  • Is there anything wrong with using the test client in production routes?
    • If so, is there a different client can be used for batching responses into a single response?
    • If not, then is it there a lifecycle that can be used in conjunction with injecting services so that a single database session can be injected into the sub-requests from the test client?

Thanks for any guidance!

You should not use the test client like that. It stands up a small dB instance and would consume a lot of overhead. Also, sharing sessions between requests sounds like a security issue, so before going that route, you should be careful.

A few things, first, there is a ctx on the http connection. You could open and stash the session there and then subsequent requests in that connection reuse it. But, if you have any sort of proxy in front of Sanic there is no guarantee they are the same end client.

Perhaps a cleaner solution is to open a background task, and open th session in that. Then push work to a queue from the request handler, and read from the queue in the background task.

I hope this helps. This is the strategy I would take.

ok, good to know. Thanks for the info.

Security concerns with session: definitely. I’d be managing that carefully and have a lot of experience with SQLAlchemy.

I’m experimenting with matching routes to a data loader so that I can move away from only singular HTTP Request-Response to a composable system that supports singular and batch routes. That way I’d have the flexibility to use common data loaders across:

  • Single route HTTP request-response
  • Batch route HTTP request-response (similar to how GraphQL can run multiple queries in a single request)
  • Websocket multiplexing

So if one had routes like /grommets and /widgets then a request to /batch with a JSON POST request of {"load": ["/grommets", "/widgets"]} would return the exact same thing as the non-batch routes, just nested in a batch JSON object.

Here is a simple (-ish) example of the system:

from sqlalchemy import DeclarativeBase, Session, select, create_engine
from sqlalchemy.orm import Mapped, mapped_column

# SQLAlchemy 2.0 Models
class Grommet(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

class Widget(DeclarativeBase):
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]

# Queries
def all_grommet_query():
    return select(Grommet)

def all_widget_query():
    return select(Widget)

# Loaders
def load_grommets(session):
    query = all_grommet_query()
    return session.execute(query)
   
def load_widgets(session):
    query = all_widget_query()
    return session.execute(query)

# Simple Route Lookup
routes = {
  "/grommets": load_grommets,
  "/widgets": load_widgets
}

# Handler - Load all 
def route_handler(session, request):
    response = {}
    for route in request["load"]:
         loader = routes[route]
         response[route] = loader(session)
    return response
        

# SQLAlchemy Engine
engine = create_engine("sqlite+pysqlite:///:memory:")

# -------------------------------
# EXAMPLE REQUESTS
# -------------------------------

# Simulate singular request for grommets
def single_grommets_request():
    request = {
        "load": ["/grommets"]
    }
    with Session(engine) as session:
        return route_handler(session, request).   # Respond with load_grommets 
    
# Simulate singular request for widgets
def single_widgets_request():
    request = {
        "load": ["/widgets"]
    }
    with Session(engine) as session:
        return route_handler(session, request). # Respond with load_widgets

# Simulate batch request for grommets and widgets
def batch_grommets_and_widgets_request():
    request = {
        "load": ["/grommets", "/widgets"]
    }
    with Session(engine) as session:
        return route_handler(session, request).  # Respond with load_grommets and load_widgets batched into single response

Lots of code, but basically it’s just mapping a URI Path (“route”) to database loader function of some kind. It’s so similar to the Sanic routing system, that all I’m really trying to do is avoid re-inventing the wheel by using (abusing?) the test client as a way to compose “sub requests” into a batch request, while also keeping those sub-requests accessible as well – a.k.a composability: I can add singular parts, or any sum of the parts, and it’s all consistent.

If I understand, it’s probably better to layer this in a way where the loaders are mapped in my own decorator system and then map that on to Sanic routes with app.add_route(...), so the database loader layer and the HTTP routing layer don’t get mixed up.

Thanks for the guidance. It helped clarify my thinking on this.

In that case, just call the handler as a function. The benefit of using decorators to create endpoints is they can be called alone like any other function.

@dmckeone
first. I is sanic and python beginner.

Can you see if this is helpful.

def reg_blueprint(app: Sanic):
    instance = Blueprint(app.name, url_prefix="/api")

    routing_dict = dict()

    for module in app.config.get("MODULES", ()):
        if not os.path.exists(f"{module}/urls.py"):
            continue

        app_router = importlib.import_module(f'{module}.urls')

        model_routing_dict = getattr(app_router, "routing_dict")
        if not model_routing_dict:
            continue

        MODEL_NAME = getattr(app_router, "MODEL_NAME", module)

        for k, v in model_routing_dict.items():
            routing_dict["/{0}/{1}/".format(MODEL_NAME, k.strip("/"))] = v

    methods = ["GET", "POST", "PUT", "DELETE"]
    for path, view in routing_dict.items():
        instance.add_route(view.as_view(), "{0}<key:.*>".format(path),
                           methods=methods)

    app.blueprint(instance)
    app.ctx.routes = {k.replace("/", "_"): v for k, v in routing_dict.items()}

routes = app.ctx.routes

Yes, I raised a similar question
Autodiscover Really good? · Issue #2755 · sanic-org/sanic (github.com)

Hope that helps

That looks fine. You probably do not need this though:

methods = ["GET", "POST", "PUT", "DELETE"]

With class-based views, the methods will be implicitly attached depending upon the class definition.