Skip to content
Open
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
53 changes: 49 additions & 4 deletions .github/workflows/release-CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- uses: actions/setup-python@v6
Expand Down Expand Up @@ -56,7 +56,7 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
target: [x64, x86]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- uses: actions/setup-python@v6
Expand Down Expand Up @@ -86,7 +86,7 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
target: [x86_64, i686]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install uv
uses: astral-sh/setup-uv@v3
Expand Down Expand Up @@ -123,7 +123,7 @@ jobs:
]
target: [aarch64, armv7]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Build Wheels
uses: PyO3/maturin-action@v1
env:
Expand Down Expand Up @@ -202,3 +202,48 @@ jobs:
run: |
uv pip install --upgrade twine
twine upload --skip-existing *.whl

discord-notify:
name: Discord Release Notification
runs-on: ubuntu-latest
needs: [release]
if: always() && needs.release.result == 'success'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and send Discord notification
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "::warning::GITHUB_REF ($GITHUB_REF) is not a tag push — skipping Discord notification"
exit 0
fi

VERSION="${GITHUB_REF#refs/tags/v}"
PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]' | sed -n '2p')

if [ -n "$PREV_TAG" ]; then
CHANGELOG=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s" --no-merges | grep -v "^- Release " || true)
else
CHANGELOG=$(git log --pretty=format:"- %s" --no-merges -20 | grep -v "^- Release " || true)
fi

read -r -d '' MESSAGE << EOM || true
Hey @everyone 👋

**Release Robyn v${VERSION}**

${CHANGELOG}

Upgrade: \`pip install --upgrade robyn\`

https://github.com/sparckles/Robyn/releases/tag/v${VERSION}
EOM

PAYLOAD=$(jq -n --arg msg "$MESSAGE" '{content: $msg}')

curl -f -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK_URL"
4 changes: 3 additions & 1 deletion integration_tests/base_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections import defaultdict
from typing import List, Optional, TypedDict

from integration_tests.subroutes import di_subrouter, static_router, sub_router
from integration_tests.subroutes import async_auth_subrouter, di_subrouter, inherited_auth_subrouter, static_router, sub_router
from robyn import Headers, Request, Response, Robyn, SSEMessage, SSEResponse, WebSocketDisconnect, jsonify, serve_file, serve_html
from robyn.authentication import AuthenticationHandler, BearerGetter, Identity
from robyn.robyn import QueryParams, Url
Expand Down Expand Up @@ -1663,6 +1663,8 @@ def main():
app.include_router(sub_router)
app.include_router(di_subrouter)
app.include_router(static_router)
app.include_router(async_auth_subrouter)
app.include_router(inherited_auth_subrouter)

class BasicAuthHandler(AuthenticationHandler):
def authenticate(self, request: Request) -> Optional[Identity]:
Expand Down
3 changes: 2 additions & 1 deletion integration_tests/subroutes/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from robyn import SubRouter, WebSocketDisconnect, jsonify

from .auth_subrouter import async_auth_subrouter, inherited_auth_subrouter
from .di_subrouter import di_subrouter
from .file_api import static_router

sub_router = SubRouter(__name__, prefix="/sub_router")

__all__ = ["sub_router", "di_subrouter", "static_router"]
__all__ = ["sub_router", "di_subrouter", "static_router", "async_auth_subrouter", "inherited_auth_subrouter"]


@sub_router.websocket("/ws")
Expand Down
37 changes: 37 additions & 0 deletions integration_tests/subroutes/auth_subrouter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from typing import Optional

from robyn import Request, SubRouter
from robyn.authentication import AuthenticationHandler, BearerGetter, Identity


class AsyncAuthHandler(AuthenticationHandler):
"""Auth handler with async authenticate() for testing async ORM patterns."""

async def authenticate(self, request: Request) -> Optional[Identity]:
token = self.token_getter.get_token(request)
if token is not None:
self.token_getter.set_token(request, token)
if token == "valid":
return Identity(claims={"async_key": "async_value"})
return None


async_auth_subrouter = SubRouter(__name__, prefix="/async_auth_sub")
async_auth_subrouter.configure_authentication(AsyncAuthHandler(token_getter=BearerGetter()))


@async_auth_subrouter.get("/protected", auth_required=True)
async def async_auth_protected(request: Request):
assert request.identity is not None
assert request.identity.claims == {"async_key": "async_value"}
return "async_auth_protected"


inherited_auth_subrouter = SubRouter(__name__, prefix="/inherited_auth_sub")


@inherited_auth_subrouter.get("/protected", auth_required=True)
async def inherited_auth_protected(request: Request):
assert request.identity is not None
assert request.identity.claims == {"key": "value"}
return "inherited_auth_protected"
57 changes: 57 additions & 0 deletions integration_tests/test_async_authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import pytest

from integration_tests.helpers.http_methods_helpers import get


# --- Async authentication handler (SubRouter with its own async handler) ---


@pytest.mark.benchmark
def test_async_auth_handler_valid_token(session):
r = get("/async_auth_sub/protected", headers={"Authorization": "Bearer valid"})
assert r.text == "async_auth_protected"


@pytest.mark.benchmark
def test_async_auth_handler_invalid_token(session):
r = get(
"/async_auth_sub/protected",
headers={"Authorization": "Bearer invalid"},
should_check_response=False,
)
assert r.status_code == 401
assert r.headers.get("WWW-Authenticate") == "BearerGetter"


@pytest.mark.benchmark
def test_async_auth_handler_no_token(session):
r = get("/async_auth_sub/protected", should_check_response=False)
assert r.status_code == 401
assert r.headers.get("WWW-Authenticate") == "BearerGetter"


# --- Inherited authentication (SubRouter without its own handler, inherits parent's) ---


@pytest.mark.benchmark
def test_inherited_auth_valid_token(session):
r = get("/inherited_auth_sub/protected", headers={"Authorization": "Bearer valid"})
assert r.text == "inherited_auth_protected"


@pytest.mark.benchmark
def test_inherited_auth_invalid_token(session):
r = get(
"/inherited_auth_sub/protected",
headers={"Authorization": "Bearer invalid"},
should_check_response=False,
)
assert r.status_code == 401
assert r.headers.get("WWW-Authenticate") == "BearerGetter"


@pytest.mark.benchmark
def test_inherited_auth_no_token(session):
r = get("/inherited_auth_sub/protected", should_check_response=False)
assert r.status_code == 401
assert r.headers.get("WWW-Authenticate") == "BearerGetter"
14 changes: 14 additions & 0 deletions robyn/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,15 +585,29 @@ def include_router(self, router: "SubRouter"):

self.dependencies.merge_dependencies(router)

if router.authentication_handler is None and self.authentication_handler is not None:
router.authentication_handler = self.authentication_handler
router.middleware_router.set_authentication_handler(self.authentication_handler)

def configure_authentication(self, authentication_handler: AuthenticationHandler):
"""
Configures the authentication handler for the application.

The handler is also propagated to any already-included SubRouters that
have not configured their own authentication handler, so that routes
declared with ``auth_required=True`` on those SubRouters work without
requiring a separate ``configure_authentication`` call on each one.

:param authentication_handler: the instance of a class inheriting the AuthenticationHandler base class
"""
self.authentication_handler = authentication_handler
self.middleware_router.set_authentication_handler(authentication_handler)

for router in self.included_routers:
if router.authentication_handler is None:
router.authentication_handler = authentication_handler
router.middleware_router.set_authentication_handler(authentication_handler)

@property
def mcp(self):
"""
Expand Down
9 changes: 8 additions & 1 deletion robyn/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,15 @@ def unauthorized_response(self) -> Response:
def authenticate(self, request: Request) -> Optional[Identity]:
"""
Authenticates the user.

This method may be overridden as either a regular (sync) method or an
``async def`` coroutine. When the implementation is async, the
framework will ``await`` it automatically, allowing the use of async
ORMs, HTTP clients, or any other async I/O inside the authentication
logic.

:param request: The request object.
:return: The identity of the user.
:return: The identity of the user, or ``None`` to reject the request.
"""
raise NotImplementedError()

Expand Down
9 changes: 8 additions & 1 deletion robyn/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,16 +407,23 @@ def add_route( # type: ignore
def add_auth_middleware(self, endpoint: str, route_type: HttpMethod):
"""
This method adds an authentication middleware to the specified endpoint.

Supports both sync and async ``authenticate()`` implementations.
The wrapper is always async so the Rust executor can ``await`` it;
when the underlying ``authenticate()`` is synchronous, the extra
overhead is negligible.
"""

injected_dependencies: dict = {}

def decorator(handler):
@wraps(handler)
def inner_handler(request: Request, *args):
async def inner_handler(request: Request, *args):
if not self.authentication_handler:
raise AuthenticationNotConfiguredError()
identity = self.authentication_handler.authenticate(request)
if inspect.isawaitable(identity):
identity = await identity
if identity is None:
return self.authentication_handler.unauthorized_response
request.identity = identity
Expand Down
Loading