Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added =
Empty file.
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
.PHONY: run grpcui
.PHONY: run grpcui generate

run:
cd backend && uv run python main.py

grpcui:
grpcui -plaintext localhost:50051

generate:
buf generate


2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ COPY --from=builder /app /app
RUN mkdir -p /app/data
ENV DB_PATH=/app/data/auction.db

# FastAPI (HTTP) + gRPC ports
# FastAPI (HTTP) + ConnectRPC ports
EXPOSE 8000 50051

# Run via the uv-managed venv
Expand Down
7 changes: 5 additions & 2 deletions backend/api/di/providers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"""
Dishka providers for the auction application.

Scope hierarchy (per gRPC call / HTTP request):
These providers are used by the FastAPI REST API only. The ConnectRPC
service uses manual per-request DI instead of Dishka.

Scope hierarchy (per HTTP request):

APP scope
└── REQUEST scope (one per call/request — like .NET AddScoped or FastAPI Depends)
└── REQUEST scope (one per HTTP request — like .NET AddScoped or FastAPI Depends)
├── aiosqlite.Connection ← opened here, closed on scope exit
├── AuctionRepository ← depends on Connection
└── AuctionService ← depends on AuctionRepository
Expand Down
138 changes: 79 additions & 59 deletions backend/api/rpc/auction_servicer.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
"""
gRPC servicer for the auction application.
ConnectRPC servicer for the auction application.

Each RPC method is decorated with @inject so that Dishka resolves
FromDishka[AuctionService] from the REQUEST scope that was automatically
opened by DishkaAioInterceptor before the call reaches this handler.
Implements the generated AuctionService Protocol from auction_connect.py.
Each handler creates its own dependency chain manually (no Dishka for
connect-python) — aiosqlite.Connection → AuctionRepository → AuctionService.

The flow per call:
1. DishkaAioInterceptor opens a REQUEST scope on the container.
2. Dishka creates (in order): Connection → AuctionRepository → AuctionService.
3. The @inject decorator resolves AuctionService and passes it in.
4. The handler runs.
5. DishkaAioInterceptor exits the REQUEST scope → Connection is closed.
1. ConnectRPC ASGI app dispatches the request to the matching handler.
2. The handler opens a DB connection, creates the repo + service.
3. The handler runs the business logic.
4. The connection is closed via ``async with``.
"""

import grpc
from dishka.integrations.grpcio import FromDishka, inject
import aiosqlite
from connectrpc.code import Code
from connectrpc.errors import ConnectError
from connectrpc.request import RequestContext

from generated import auction_pb2
from generated import auction_pb2_grpc
from service.auction_service import AuctionService
from service.database import AuctionRepository, DB_PATH
from service.models import CreateItemRequest, PlaceBidRequest


Expand All @@ -40,49 +41,64 @@ def _bid_to_proto(bid) -> auction_pb2.BidResponse: # type: ignore[name-defined]
)


class AuctionServicer(auction_pb2_grpc.AuctionServiceServicer):
async def _make_service() -> tuple[aiosqlite.Connection, AuctionService]:
"""Create a fresh Connection → Repository → Service chain."""
conn = await aiosqlite.connect(DB_PATH)
repo = AuctionRepository(conn)
return conn, AuctionService(repo)


class AuctionServicer:
"""
gRPC servicer. Contains no state — all dependencies arrive via
Dishka injection on a per-call basis (REQUEST scope).
ConnectRPC servicer implementing the generated ``AuctionService`` Protocol.

Each method manually creates a per-request dependency chain
(Connection → AuctionRepository → AuctionService) and ensures
the connection is closed when done.
"""

# ── Items ──────────────────────────────────────────────────────────────────
# ── Items ──────────────────────────────────────────────────────────────

@inject
async def CreateItem(
async def create_item(
self,
request: auction_pb2.CreateItemRequest, # type: ignore[name-defined]
context: grpc.aio.ServicerContext,
service: FromDishka[AuctionService],
ctx: RequestContext,
) -> auction_pb2.ItemResponse: # type: ignore[name-defined]
item = await service.create_item(
CreateItemRequest(
title=request.title,
description=request.description,
starting_price=request.starting_price,
conn, service = await _make_service()
try:
item = await service.create_item(
CreateItemRequest(
title=request.title,
description=request.description,
starting_price=request.starting_price,
)
)
)
return _item_to_proto(item)
return _item_to_proto(item)
finally:
await conn.close()

@inject
async def ListItems(
async def list_items(
self,
request: auction_pb2.Empty, # type: ignore[name-defined]
context: grpc.aio.ServicerContext,
service: FromDishka[AuctionService],
ctx: RequestContext,
) -> auction_pb2.ListItemsResponse: # type: ignore[name-defined]
items = await service.list_items()
return auction_pb2.ListItemsResponse(items=[_item_to_proto(i) for i in items])
conn, service = await _make_service()
try:
items = await service.list_items()
return auction_pb2.ListItemsResponse(
items=[_item_to_proto(i) for i in items]
)
finally:
await conn.close()

# ── Bids ───────────────────────────────────────────────────────────────────
# ── Bids ───────────────────────────────────────────────────────────────

@inject
async def PlaceBid(
async def place_bid(
self,
request: auction_pb2.PlaceBidRequest, # type: ignore[name-defined]
context: grpc.aio.ServicerContext,
service: FromDishka[AuctionService],
ctx: RequestContext,
) -> auction_pb2.BidResponse: # type: ignore[name-defined]
conn, service = await _make_service()
try:
bid = await service.place_bid(
PlaceBidRequest(
Expand All @@ -91,33 +107,37 @@ async def PlaceBid(
bidder_name=request.bidder_name,
)
)
return _bid_to_proto(bid)
except ValueError as exc:
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(exc))
raise # unreachable after abort, but satisfies the type checker
return _bid_to_proto(bid)
raise ConnectError(Code.INVALID_ARGUMENT, str(exc))
finally:
await conn.close()

@inject
async def ListBids(
async def list_bids(
self,
request: auction_pb2.ListBidsRequest, # type: ignore[name-defined]
context: grpc.aio.ServicerContext,
service: FromDishka[AuctionService],
ctx: RequestContext,
) -> auction_pb2.ListBidsResponse: # type: ignore[name-defined]
bids = await service.list_bids(request.item_id)
return auction_pb2.ListBidsResponse(bids=[_bid_to_proto(b) for b in bids])
conn, service = await _make_service()
try:
bids = await service.list_bids(request.item_id)
return auction_pb2.ListBidsResponse(bids=[_bid_to_proto(b) for b in bids])
finally:
await conn.close()

@inject
async def GetWinningBid(
async def get_winning_bid(
self,
request: auction_pb2.GetWinningBidRequest, # type: ignore[name-defined]
context: grpc.aio.ServicerContext,
service: FromDishka[AuctionService],
ctx: RequestContext,
) -> auction_pb2.BidResponse: # type: ignore[name-defined]
bid = await service.get_winning_bid(request.item_id)
if bid is None:
await context.abort(
grpc.StatusCode.NOT_FOUND,
f"No bids found for item {request.item_id}",
)
raise RuntimeError("unreachable")
return _bid_to_proto(bid)
conn, service = await _make_service()
try:
bid = await service.get_winning_bid(request.item_id)
if bid is None:
raise ConnectError(
Code.NOT_FOUND,
f"No bids found for item {request.item_id}",
)
return _bid_to_proto(bid)
finally:
await conn.close()
Loading