Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0ace1b01 | ||
|
|
d9d7ff0825 | ||
|
|
7c42a18436 | ||
|
|
ea84fcb435 | ||
|
|
f30c4733f0 | ||
|
|
cd0b3234dd | ||
|
|
1f64478957 | ||
|
|
815594fc8b | ||
|
|
086f2af3de | ||
|
|
f317b15bdb | ||
|
|
b6f232db11 | ||
|
|
e7ee74d6bb | ||
|
|
847dfd1321 | ||
|
|
1aa035378e | ||
|
|
1edfb8daa7 |
27
CHANGES.md
27
CHANGES.md
@@ -1,16 +1,29 @@
|
||||
# Microdot change log
|
||||
|
||||
**Release 2.3.2** - 2025-05-08
|
||||
|
||||
- Use async error handlers in auth module [#298](https://github.com/miguelgrinberg/microdot/issues/298) ([commit](https://github.com/miguelgrinberg/microdot/commit/d9d7ff0825e4c5fbed6564d3684374bf3937df11))
|
||||
|
||||
**Release 2.3.1** - 2025-04-13
|
||||
|
||||
- Additional support needed when using `orjson` ([commit](https://github.com/miguelgrinberg/microdot/commit/cd0b3234ddb0c8ff4861d369836ec2aed77494db))
|
||||
|
||||
**Release 2.3.0** - 2025-04-12
|
||||
|
||||
- Support optional authentication methods ([commit](https://github.com/miguelgrinberg/microdot/commit/f317b15bdbf924007e5e3414e0c626baccc3ede6))
|
||||
- Catch SSL exceptions while writing the response [#206](https://github.com/miguelgrinberg/microdot/issues/206) ([commit](https://github.com/miguelgrinberg/microdot/commit/e7ee74d6bba74cfd89b9ddc38f28e02514eb1791))
|
||||
- Use `orjson` instead of `json` if available ([commit](https://github.com/miguelgrinberg/microdot/commit/086f2af3deab86d4340f3f1feb9e019de59f351d))
|
||||
- Addressed typing warnings from pyright ([commit](https://github.com/miguelgrinberg/microdot/commit/b6f232db1125045d79c444c736a2ae59c5501fdd))
|
||||
|
||||
**Release 2.2.0** - 2025-03-22
|
||||
|
||||
- Support for multipart/form-data requests [#287](https://github.com/miguelgrinberg/microdot/issues/287) ([commit](https://github.com/miguelgrinberg/microdot/commit/11a91a60350518e426b557fae8dffe75912f8823))
|
||||
- Support custom path components in URLs ([commit](https://github.com/miguelgrinberg/microdot/commit/c92b5ae28222af5a1094f5d2f70a45d4d17653d5))
|
||||
- Expose the Jinja environment as Template.jinja_env ([commit](https://github.com/miguelgrinberg/microdot/commit/953dd9432122defe943f0637bbe7e01f2fc7743f))
|
||||
- Delay route compilation to allow late register_type calls ([commit](https://github.com/miguelgrinberg/microdot/commit/aa76e6378b37faab52008a8aab8db75f81b29323))
|
||||
- urldecoding should always be done in bytes ([commit](https://github.com/miguelgrinberg/microdot/commit/d203df75fef32c7cc0fe7cc6525e77522b37a289))
|
||||
- Simplified urldecode logic ([commit](https://github.com/miguelgrinberg/microdot/commit/3bc31f10b2b2d4460c62366013278d87665f0f97))
|
||||
- Support for `multipart/form-data` requests [#287](https://github.com/miguelgrinberg/microdot/issues/287) ([commit](https://github.com/miguelgrinberg/microdot/commit/11a91a60350518e426b557fae8dffe75912f8823))
|
||||
- Support custom path components in URLs ([commit #1](https://github.com/miguelgrinberg/microdot/commit/c92b5ae28222af5a1094f5d2f70a45d4d17653d5) [commit #2](https://github.com/miguelgrinberg/microdot/commit/aa76e6378b37faab52008a8aab8db75f81b29323))
|
||||
- Expose the Jinja environment as `Template.jinja_env` ([commit](https://github.com/miguelgrinberg/microdot/commit/953dd9432122defe943f0637bbe7e01f2fc7743f))
|
||||
- Simplified urldecode logic ([commit #1](https://github.com/miguelgrinberg/microdot/commit/3bc31f10b2b2d4460c62366013278d87665f0f97) [commit #2](https://github.com/miguelgrinberg/microdot/commit/d203df75fef32c7cc0fe7cc6525e77522b37a289))
|
||||
- Additional urldecode tests ([commit](https://github.com/miguelgrinberg/microdot/commit/99f65c0198590c0dfb402c24685b6f8dfba1935d))
|
||||
- Update micropython version used in tests to 1.24.1 ([commit](https://github.com/miguelgrinberg/microdot/commit/4cc2e95338a7de3b03742389004147ee21285621))
|
||||
- Documentation improvements ([commit](https://github.com/miguelgrinberg/microdot/commit/c6b99b6d8117d4e40e16d5b953dbf4deb023d24d))
|
||||
- Update micropython version used in tests to 1.24.1 ([commit](https://github.com/miguelgrinberg/microdot/commit/4cc2e95338a7de3b03742389004147ee21285621))
|
||||
|
||||
**Release 2.1.0** - 2025-02-04
|
||||
|
||||
|
||||
@@ -414,10 +414,25 @@ decorator::
|
||||
While running an authenticated request, the user object returned by the
|
||||
authenticaction function is accessible as ``request.g.current_user``.
|
||||
|
||||
If an endpoint is intended to work with or without authentication, then it can
|
||||
be protected with the ``auth.optional`` decorator::
|
||||
|
||||
@app.route('/')
|
||||
@auth.optional
|
||||
async def index(request):
|
||||
if g.current_user:
|
||||
return f'Hello, {request.g.current_user}!'
|
||||
else:
|
||||
return 'Hello, anonymous user!'
|
||||
|
||||
As shown in the example, a route can check ``g.current_user`` to determine if
|
||||
the user is authenticated or not.
|
||||
|
||||
Token Authentication
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
To set up token authentication, create an instance of :class:`TokenAuth <microdot.auth.TokenAuth>`::
|
||||
To set up token authentication, create an instance of
|
||||
:class:`TokenAuth <microdot.auth.TokenAuth>`::
|
||||
|
||||
from microdot.auth import TokenAuth
|
||||
|
||||
@@ -437,7 +452,17 @@ protect your routes::
|
||||
@auth
|
||||
async def index(request):
|
||||
return f'Hello, {request.g.current_user}!'
|
||||
|
||||
|
||||
Optional authentication can also be used with tokens::
|
||||
|
||||
@app.route('/')
|
||||
@auth.optional
|
||||
async def index(request):
|
||||
if g.current_user:
|
||||
return f'Hello, {request.g.current_user}!'
|
||||
else:
|
||||
return 'Hello, anonymous user!'
|
||||
|
||||
User Logins
|
||||
~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ flask==3.0.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# quart
|
||||
gunicorn==22.0.0
|
||||
gunicorn==23.0.0
|
||||
# via -r requirements.in
|
||||
h11==0.14.0
|
||||
h11==0.16.0
|
||||
# via
|
||||
# hypercorn
|
||||
# uvicorn
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "microdot"
|
||||
version = "2.2.0"
|
||||
version = "2.3.2"
|
||||
authors = [
|
||||
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
|
||||
]
|
||||
|
||||
@@ -36,6 +36,24 @@ class BaseAuth:
|
||||
|
||||
return wrapper
|
||||
|
||||
def optional(self, f):
|
||||
"""Decorator to protect a route with optional authentication.
|
||||
|
||||
This decorator makes authentication for the decorated route optional,
|
||||
meaning that the route is allowed to run with or with
|
||||
authentication given in the request.
|
||||
"""
|
||||
async def wrapper(request, *args, **kwargs):
|
||||
auth = self._get_auth(request)
|
||||
if not auth:
|
||||
request.g.current_user = None
|
||||
else:
|
||||
request.g.current_user = await invoke_handler(
|
||||
self.auth_callback, request, *auth)
|
||||
return await invoke_handler(f, request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BasicAuth(BaseAuth):
|
||||
"""Basic Authentication.
|
||||
@@ -67,7 +85,7 @@ class BasicAuth(BaseAuth):
|
||||
return None
|
||||
return username, password
|
||||
|
||||
def authentication_error(self, request):
|
||||
async def authentication_error(self, request):
|
||||
return '', self.error_status, {
|
||||
'WWW-Authenticate': '{} realm="{}", charset="{}"'.format(
|
||||
self.scheme, self.realm, self.charset)}
|
||||
@@ -140,5 +158,5 @@ class TokenAuth(BaseAuth):
|
||||
"""
|
||||
self.error_callback = f
|
||||
|
||||
def authentication_error(self, request):
|
||||
async def authentication_error(self, request):
|
||||
abort(self.error_status)
|
||||
|
||||
@@ -7,10 +7,14 @@ servers for MicroPython and standard Python.
|
||||
"""
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
try:
|
||||
import orjson as json
|
||||
except ImportError:
|
||||
import json
|
||||
|
||||
try:
|
||||
from inspect import iscoroutinefunction, iscoroutine
|
||||
from functools import partial
|
||||
@@ -574,9 +578,9 @@ class Response:
|
||||
self.headers = NoCaseDict(headers or {})
|
||||
self.reason = reason
|
||||
if isinstance(body, (dict, list)):
|
||||
self.body = json.dumps(body).encode()
|
||||
body = json.dumps(body)
|
||||
self.headers['Content-Type'] = 'application/json; charset=UTF-8'
|
||||
elif isinstance(body, str):
|
||||
if isinstance(body, str):
|
||||
self.body = body.encode()
|
||||
else:
|
||||
# this applies to bytes, file-like objects or generators
|
||||
@@ -1336,9 +1340,9 @@ class Microdot:
|
||||
print_exception(exc)
|
||||
|
||||
res = await self.dispatch_request(req)
|
||||
if res != Response.already_handled: # pragma: no branch
|
||||
await res.write(writer)
|
||||
try:
|
||||
if res != Response.already_handled: # pragma: no branch
|
||||
await res.write(writer)
|
||||
await writer.aclose()
|
||||
except OSError as exc: # pragma: no cover
|
||||
if exc.errno in MUTED_SOCKET_ERRORS:
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
from microdot.helpers import wraps
|
||||
|
||||
try:
|
||||
import orjson as json
|
||||
except ImportError:
|
||||
import json
|
||||
|
||||
|
||||
class SSE:
|
||||
"""Server-Sent Events object.
|
||||
@@ -25,8 +29,8 @@ class SSE:
|
||||
given, it must be a string.
|
||||
"""
|
||||
if isinstance(data, (dict, list)):
|
||||
data = json.dumps(data).encode()
|
||||
elif isinstance(data, str):
|
||||
data = json.dumps(data)
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
elif not isinstance(data, bytes):
|
||||
data = str(data).encode()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from microdot.microdot import Request, Response, AsyncBytesIO
|
||||
|
||||
try:
|
||||
@@ -6,6 +5,11 @@ try:
|
||||
except: # pragma: no cover # noqa: E722
|
||||
WebSocket = None
|
||||
|
||||
try:
|
||||
import orjson as json
|
||||
except ImportError:
|
||||
import json
|
||||
|
||||
__all__ = ['TestClient', 'TestResponse']
|
||||
|
||||
|
||||
@@ -19,7 +23,7 @@ class TestResponse:
|
||||
#: explicitly sets it on the response object.
|
||||
self.reason = None
|
||||
#: A dictionary with the response headers.
|
||||
self.headers = None
|
||||
self.headers = {}
|
||||
#: The body of the response, as a bytes object.
|
||||
self.body = None
|
||||
#: The body of the response, decoded to a UTF-8 string. Set to
|
||||
@@ -101,10 +105,10 @@ class TestClient:
|
||||
if body is None:
|
||||
body = b''
|
||||
elif isinstance(body, (dict, list)):
|
||||
body = json.dumps(body).encode()
|
||||
body = json.dumps(body)
|
||||
if 'Content-Type' not in headers: # pragma: no cover
|
||||
headers['Content-Type'] = 'application/json'
|
||||
elif isinstance(body, str):
|
||||
if isinstance(body, str):
|
||||
body = body.encode()
|
||||
if body and 'Content-Length' not in headers:
|
||||
headers['Content-Length'] = str(len(body))
|
||||
@@ -195,7 +199,7 @@ class TestClient:
|
||||
('127.0.0.1', 1234))
|
||||
res = await self.app.dispatch_request(req)
|
||||
if res == Response.already_handled:
|
||||
return None
|
||||
return TestResponse()
|
||||
res.complete()
|
||||
|
||||
self._update_cookies(res)
|
||||
|
||||
@@ -45,6 +45,38 @@ class TestAuth(unittest.TestCase):
|
||||
b'foo:baz').decode()}))
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
def test_basic_optional_auth(self):
|
||||
app = Microdot()
|
||||
basic_auth = BasicAuth()
|
||||
|
||||
@basic_auth.authenticate
|
||||
def authenticate(request, username, password):
|
||||
if username == 'foo' and password == 'bar':
|
||||
return {'username': username}
|
||||
|
||||
@app.route('/')
|
||||
@basic_auth.optional
|
||||
def index(request):
|
||||
return request.g.current_user['username'] \
|
||||
if request.g.current_user else ''
|
||||
|
||||
client = TestClient(app)
|
||||
res = self._run(client.get('/'))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, '')
|
||||
|
||||
res = self._run(client.get('/', headers={
|
||||
'Authorization': 'Basic ' + binascii.b2a_base64(
|
||||
b'foo:bar').decode()}))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, 'foo')
|
||||
|
||||
res = self._run(client.get('/', headers={
|
||||
'Authorization': 'Basic ' + binascii.b2a_base64(
|
||||
b'foo:baz').decode()}))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, '')
|
||||
|
||||
def test_token_auth(self):
|
||||
app = Microdot()
|
||||
token_auth = TokenAuth()
|
||||
@@ -67,7 +99,7 @@ class TestAuth(unittest.TestCase):
|
||||
'Authorization': 'Basic foo'}))
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
res = self._run(client.get('/', headers={'Authorization': 'foo'}))
|
||||
res = self._run(client.get('/', headers={'Authorization': 'invalid'}))
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
res = self._run(client.get('/', headers={
|
||||
@@ -75,6 +107,39 @@ class TestAuth(unittest.TestCase):
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, 'user')
|
||||
|
||||
def test_token_optional_auth(self):
|
||||
app = Microdot()
|
||||
token_auth = TokenAuth()
|
||||
|
||||
@token_auth.authenticate
|
||||
def authenticate(request, token):
|
||||
if token == 'foo':
|
||||
return 'user'
|
||||
|
||||
@app.route('/')
|
||||
@token_auth.optional
|
||||
def index(request):
|
||||
return request.g.current_user or ''
|
||||
|
||||
client = TestClient(app)
|
||||
res = self._run(client.get('/'))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, '')
|
||||
|
||||
res = self._run(client.get('/', headers={
|
||||
'Authorization': 'Basic foo'}))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, '')
|
||||
|
||||
res = self._run(client.get('/', headers={'Authorization': 'foo'}))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, '')
|
||||
|
||||
res = self._run(client.get('/', headers={
|
||||
'Authorization': 'Bearer foo'}))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.text, 'user')
|
||||
|
||||
def test_token_auth_custom_header(self):
|
||||
app = Microdot()
|
||||
token_auth = TokenAuth(header='X-Auth-Token')
|
||||
|
||||
@@ -771,7 +771,7 @@ class TestMicrodot(unittest.TestCase):
|
||||
|
||||
client = TestClient(app)
|
||||
res = self._run(client.get('/'))
|
||||
self.assertEqual(res, None)
|
||||
self.assertEqual(res.body, None)
|
||||
|
||||
def test_mount(self):
|
||||
subapp = Microdot()
|
||||
|
||||
@@ -46,11 +46,11 @@ class TestWebSocket(unittest.TestCase):
|
||||
|
||||
client = TestClient(app)
|
||||
res = self._run(client.websocket('/echo', ws))
|
||||
self.assertIsNone(res)
|
||||
self.assertIsNone(res.body)
|
||||
self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537])
|
||||
|
||||
res = self._run(client.websocket('/divzero', ws))
|
||||
self.assertIsNone(res)
|
||||
self.assertIsNone(res.body)
|
||||
WebSocket.max_message_length = -1
|
||||
|
||||
@unittest.skipIf(sys.implementation.name == 'micropython',
|
||||
@@ -74,7 +74,7 @@ class TestWebSocket(unittest.TestCase):
|
||||
|
||||
client = TestClient(app)
|
||||
res = self._run(client.websocket('/echo', ws))
|
||||
self.assertIsNone(res)
|
||||
self.assertIsNone(res.body)
|
||||
self.assertEqual(results, [])
|
||||
Request.max_body_length = saved_max_body_length
|
||||
|
||||
|
||||
Reference in New Issue
Block a user