├── LICENSE ├── README.md ├── pyproject.toml ├── tests.py └── uhttp.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 gus <0x67757300@gmail.com> 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # µHTTP - Pythonic Web Development 2 | 3 | ### Why 4 | 5 | - Easy: intuitive, clear logic 6 | - Simple: small code base, no external dependencies 7 | - Modular: application mounting, custom route behavior 8 | - Flexible: unopinionated, paradigm-free 9 | - Fast: minimal overhead 10 | - Safe: small attack surface 11 | 12 | ### Installation 13 | 14 | µHTTP is on [PyPI](https://pypi.org/project/uhttp/). 15 | 16 | ```bash 17 | pip install uhttp 18 | ``` 19 | 20 | Also, an [ASGI](https://asgi.readthedocs.io/en/latest/) server might be needed. 21 | 22 | ```bash 23 | pip install uvicorn 24 | ``` 25 | 26 | ### Hello, world! 27 | 28 | ```python 29 | from uhttp import Application 30 | 31 | app = Application() 32 | 33 | @app.get('/') 34 | def hello(request): 35 | return f'Hello, {request.ip}!' 36 | 37 | 38 | if __name__ == '__main__': 39 | import uvicorn 40 | uvicorn.run('__main__:app') 41 | ``` 42 | 43 | ## Documentation 44 | 45 | ### Application 46 | 47 | An [ASGI](https://asgi.readthedocs.io/en/latest/) application. Called once per request by the server. 48 | 49 | ```python 50 | Application(*, routes=None, startup=None, shutdown=None, before=None, after=None, max_content=1048576) 51 | ``` 52 | 53 | E.g.: 54 | 55 | ```python 56 | app = Application( 57 | startup=[open_db], 58 | before=[counter, auth], 59 | routes={ 60 | '/': { 61 | 'GET': lambda request: 'HI!', 62 | 'POST': new 63 | }, 64 | '/users/': { 65 | 'GET': users, 66 | 'PUT': users 67 | } 68 | }, 69 | after=[logger], 70 | shutdown=[close_db] 71 | ) 72 | ``` 73 | 74 | #### Application Mounting 75 | 76 | Mounts another application at the specified prefix. 77 | 78 | ```python 79 | app.mount(another, prefix='') 80 | ``` 81 | 82 | E.g.: 83 | 84 | ```python 85 | utils = Application() 86 | 87 | @utils.before 88 | def incoming(request): 89 | print(f'Incoming from {request.ip}') 90 | 91 | app.mount(utils) 92 | ``` 93 | 94 | #### Application Lifespan (Startup) 95 | 96 | Append the decorated function to the list of functions called at the beginning of the [Lifespan](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) protocol. 97 | 98 | ```python 99 | @app.startup 100 | [async] def func(state) 101 | ``` 102 | 103 | E.g.: 104 | 105 | ```python 106 | @app.startup 107 | async def open_db(state): 108 | state['db'] = await aiosqlite.connect('db.sqlite') 109 | ``` 110 | 111 | #### Application Lifespan (Shutdown) 112 | 113 | Appends the decorated function to the list of functions called at the end of the Lifespan protocol. 114 | 115 | ```python 116 | @app.shutdown 117 | [async] def func(state) 118 | ``` 119 | 120 | E.g.: 121 | 122 | ```python 123 | @app.shutdown 124 | async def close_db(state): 125 | await state['db'].close() 126 | ``` 127 | 128 | #### Application Middleware (Before) 129 | 130 | Appends the decorated function to the list of functions called before a response is made. 131 | 132 | ```python 133 | @app.before 134 | [async] def func(request) 135 | ``` 136 | 137 | E.g.: 138 | 139 | ```python 140 | @app.before 141 | def restrict(request): 142 | user = request.state['session'].get('user') 143 | if user != 'admin': 144 | raise Response(401) 145 | ``` 146 | 147 | #### Application Middleware (After) 148 | 149 | Appends the decorated function to the list of functions called after a response is made. 150 | 151 | ```python 152 | @app.after 153 | [async] def func(request, response) 154 | ``` 155 | 156 | E.g.: 157 | 158 | ```python 159 | @app.after 160 | def logger(request, response): 161 | print(request, '-->', response) 162 | ``` 163 | 164 | #### Application Routing 165 | 166 | Inserts the decorated function to the routing table. 167 | 168 | ```python 169 | @app.route(path, methods=('GET',)) 170 | [async] def func(request) 171 | ``` 172 | 173 | Paths are compiled at startup as regular expression patterns. Named groups define path parameters. 174 | 175 | If the request path doesn't match any route pattern, a `404 Not Found` response is returned. 176 | 177 | If the request method isn't in the route methods, a `405 Method Not Allowed` response is returned. 178 | 179 | Decorators for the standard methods are also available. 180 | 181 | E.g.: 182 | 183 | ```python 184 | @app.route('/', methods=('GET', 'POST')) 185 | def index(request): 186 | return f'{request.method}ing from {request.ip}' 187 | 188 | @app.get(r'/user/(?P\d+)') 189 | def profile(request): 190 | user = request.state['db'].get_or_404(request.params['id']) 191 | return f'{user.name} has {user.friends} friends!' 192 | ``` 193 | 194 | ### Request 195 | 196 | An HTTP request. Created every time the application is called on the HTTP protocol with a shallow copy of the state. 197 | 198 | ```python 199 | Request(method, path, *, ip='', params=None, args=None, headers=None, cookies=None, body=b'', json=None, form=None, state=None) 200 | ``` 201 | 202 | ### Response 203 | 204 | An HTTP Response. May be raised or returned at any time in middleware or route functions. 205 | 206 | ```python 207 | Response(status, *, headers=None, cookies=None, body=b'') 208 | ``` 209 | 210 | E.g.: 211 | 212 | ```python 213 | @app.startup 214 | def open_db(state): 215 | state['db'] = { 216 | 1: { 217 | 'name': 'admin', 218 | 'likes': ['terminal', 'old computers'] 219 | }, 220 | 2: { 221 | 'name': 'john', 222 | 'likes': ['animals'] 223 | } 224 | } 225 | 226 | def get_or_404(db, id): 227 | if user := db.get(id): 228 | return user 229 | else: 230 | raise Response(404) 231 | 232 | @app.get(r'/user/(?P\d+)') 233 | def profile(request): 234 | user = get_or_404(request.state['db'], request.params['id']) 235 | if request.args.get('json'): 236 | return user 237 | else: 238 | return f"{user['name']} likes {', '.join(user['likes'])}" 239 | ``` 240 | 241 | ## Patterns 242 | 243 | ### Sessions 244 | 245 | Session implementation based on [JavaScript Web Signatures](https://datatracker.ietf.org/doc/html/rfc7515). Sessions are stored in the client's browser as a tamper-proof cookie. Depends on [PyJWT](https://pypi.org/project/PyJWT/). 246 | 247 | ```python 248 | import os 249 | import time 250 | import jwt 251 | from uhttp import Application, Response 252 | 253 | app = Application() 254 | secret = os.getenv('APP_SECRET', 'dev') 255 | 256 | @app.before 257 | def get_token(request): 258 | session = request.cookies.get('session') 259 | if session and session.value: 260 | try: 261 | request.state['session'] = jwt.decode( 262 | jwt=session.value, 263 | key=secret, 264 | algorithms=['HS256'] 265 | ) 266 | except jwt.exceptions.PyJWTError: 267 | request.state['session'] = {'exp': 0} 268 | raise Response(400) 269 | else: 270 | request.state['session'] = {} 271 | 272 | @app.after 273 | def set_token(request, response): 274 | if session := request.state.get('session'): 275 | session.setdefault('exp', int(time.time()) + 604800) 276 | response.cookies['session'] = jwt.encode( 277 | payload=session, 278 | key=secret, 279 | algorithm='HS256' 280 | ) 281 | response.cookies['session']['expires'] = time.strftime( 282 | '%a, %d %b %Y %T GMT', time.gmtime(session['exp']) 283 | ) 284 | response.cookies['session']['samesite'] = 'Lax' 285 | response.cookies['session']['httponly'] = True 286 | response.cookies['session']['secure'] = True 287 | ``` 288 | 289 | ### Multipart Forms 290 | 291 | Support for multipart forms. Depends on [python-multipart](https://pypi.org/project/python-multipart/). 292 | 293 | ```python 294 | from multipart.multipart import FormParser, parse_options_header 295 | from multipart.exceptions import FormParserError 296 | from uhttp import Application, MultiDict, Response 297 | 298 | app = Application() 299 | 300 | def parse_form(request): 301 | form = MultiDict() 302 | 303 | def on_field(field): 304 | form[field.field_name.decode()] = field.value.decode() 305 | def on_file(file): 306 | if file.field_name: 307 | form[file.field_name.decode()] = file.file_object 308 | content_type, options = parse_options_header( 309 | request.headers.get('content-type', '') 310 | ) 311 | try: 312 | parser = FormParser( 313 | content_type.decode(), 314 | on_field, 315 | on_file, 316 | boundary=options.get(b'boundary'), 317 | config={'MAX_MEMORY_FILE_SIZE': float('inf')} # app._max_content 318 | ) 319 | parser.write(request.body) 320 | parser.finalize() 321 | except FormParserError: 322 | raise Response(400) 323 | return form 324 | 325 | @app.before 326 | def handle_multipart(request): 327 | if 'multipart/form-data' in request.headers.get('content-type'): 328 | request.form = parse_form(request) 329 | ``` 330 | 331 | ### Static Files 332 | 333 | Static files for development. 334 | 335 | ```python 336 | import os 337 | from mimetypes import guess_type 338 | from uhttp import Application, Response 339 | 340 | app = Application() 341 | 342 | def send_file(path): 343 | if not os.path.isfile(path): 344 | raise RuntimeError('Invalid file') 345 | mime_type = guess_type(path)[0] or 'application/octet-stream' 346 | with open(path, 'rb') as file: 347 | content = file.read() 348 | return Response( 349 | status=200, 350 | headers={'content-type': mime_type}, 351 | body=content 352 | ) 353 | 354 | @app.get('/assets/(?P.*)') 355 | def assets(request): 356 | directory = 'assets' 357 | path = os.path.realpath( 358 | os.path.join(directory, request.params['path']) 359 | ) 360 | if os.path.commonpath([directory, path]) == directory: 361 | if os.path.isfile(path): 362 | return send_file(path) 363 | if os.path.isdir(path): 364 | index = os.path.join(path, 'index.html') 365 | if os.path.isfile(index): 366 | return send_file(index) 367 | return 404 368 | ``` 369 | 370 | ## Contributing 371 | 372 | All contributions are welcomed. 373 | 374 | ## License 375 | 376 | Released under the MIT license. 377 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "uhttp" 3 | version = "2.0.0" 4 | description = "Pythonic Web Development" 5 | authors = ["gus <0x67757300@gmail.com>"] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/0x67757300/uHTTP" 9 | keywords = ["asgi", "web", "http"] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.9" 13 | 14 | [tool.pytest.ini_options] 15 | asyncio_mode = "auto" 16 | addopts = ["tests.py"] 17 | 18 | [build-system] 19 | requires = ["poetry-core"] 20 | build-backend = "poetry.core.masonry.api" 21 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from uhttp import Application, MultiDict, Response 2 | 3 | 4 | class TestApplication(Application): 5 | async def test( 6 | self, method, path, query_string=b'', headers=None, body=b'' 7 | ): 8 | response = {} 9 | state = {} 10 | http_scope = { 11 | 'type': 'http', 12 | 'method': method, 13 | 'path': path, 14 | 'query_string': query_string, 15 | 'headers': headers or [], 16 | 'state': state 17 | } 18 | 19 | async def http_receive(): 20 | return {'body': body, 'more_body': False} 21 | 22 | async def http_send(event): 23 | if event['type'] == 'http.response.start': 24 | response['status'] = event['status'] 25 | response['headers'] = MultiDict([ 26 | [k.decode(), v.decode()] for k, v in event['headers'] 27 | ]) 28 | elif event['type'] == 'http.response.body': 29 | response['body'] = event['body'] 30 | 31 | lifespan_scope = {'type': 'lifespan', 'state': state} 32 | 33 | async def lifespan_receive(): 34 | if not response: 35 | return {'type': 'lifespan.startup'} 36 | elif 'body' in response: 37 | return {'type': 'lifespan.shutdown'} 38 | else: 39 | return {'type': ''} 40 | 41 | async def lifespan_send(event): 42 | if event['type'] == 'lifespan.startup.complete': 43 | await self(http_scope, http_receive, http_send) 44 | elif 'message' in event: 45 | message = event['message'].encode() 46 | response['status'] = 500 47 | response['headers'] = MultiDict({ 48 | 'content-length': str(len(message)) 49 | }) 50 | response['body'] = message 51 | 52 | await self(lifespan_scope, lifespan_receive, lifespan_send) 53 | 54 | return response 55 | 56 | 57 | async def test_lifespan_startup_fail(): 58 | app = TestApplication() 59 | 60 | @app.startup 61 | def fail(state): 62 | 1 / 0 63 | 64 | response = await app.test('GET', '/') 65 | assert response['status'] == 500 66 | assert response['body'] == b'ZeroDivisionError: division by zero' 67 | 68 | 69 | async def test_lifespan_shutdown_fail(): 70 | app = TestApplication() 71 | 72 | @app.shutdown 73 | def fail(state): 74 | 1 / 0 75 | 76 | response = await app.test('GET', '/') 77 | assert response['status'] == 500 78 | assert response['body'] == b'ZeroDivisionError: division by zero' 79 | 80 | 81 | async def test_lifespan_startup(): 82 | app = TestApplication() 83 | 84 | @app.startup 85 | def startup(state): 86 | state['msg'] = 'HI!' 87 | 88 | @app.get('/') 89 | def say_hi(request): 90 | return request.state.get('msg') 91 | 92 | response = await app.test('GET', '/') 93 | assert response['body'] == b'HI!' 94 | 95 | 96 | async def test_lifespan_shutdown(): 97 | app = TestApplication() 98 | msgs = ['HI!'] 99 | 100 | @app.startup 101 | def startup(state): 102 | state['msgs'] = msgs 103 | 104 | @app.shutdown 105 | def shutdown(state): 106 | state['msgs'].append('BYE!') 107 | 108 | await app.test('GET', '/') 109 | assert msgs[-1] == 'BYE!' 110 | 111 | 112 | async def test_204(): 113 | app = TestApplication() 114 | 115 | @app.get('/') 116 | def nop(request): 117 | pass 118 | 119 | response = await app.test('GET', '/') 120 | assert response['status'] == 204 121 | assert response['body'] == b'' 122 | 123 | 124 | async def test_404(): 125 | app = TestApplication() 126 | response = await app.test('GET', '/') 127 | assert response['status'] == 404 128 | 129 | 130 | async def test_405(): 131 | app = TestApplication() 132 | 133 | @app.route('/', methods=('GET', 'POST')) 134 | def index(request): 135 | pass 136 | 137 | response = await app.test('PUT', '/') 138 | assert response['status'] == 405 139 | assert response['headers'].get('allow') == 'GET, POST' 140 | 141 | 142 | async def test_413(): 143 | app = TestApplication() 144 | response = await app.test( 145 | 'POST', '/', body=b' '*(app._max_content + 1) 146 | ) 147 | assert response['status'] == 413 148 | 149 | 150 | async def test_methods(): 151 | app = TestApplication() 152 | methods = ('GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS') 153 | 154 | @app.route('/', methods=methods) 155 | def index(request): 156 | return request.method 157 | 158 | for method in methods: 159 | response = await app.test(method, '/') 160 | assert response['body'] == method.encode() 161 | 162 | 163 | async def test_path_parameters(): 164 | app = TestApplication() 165 | 166 | @app.get(r'/hello/(?P\w+)') 167 | def hello(request): 168 | return f'Hello, {request.params.get("name")}!' 169 | 170 | response = await app.test('GET', '/hello/john') 171 | assert response['status'] == 200 172 | assert response['body'] == b'Hello, john!' 173 | 174 | 175 | async def test_query_args(): 176 | app = TestApplication() 177 | args = {} 178 | 179 | @app.get('/') 180 | def index(request): 181 | args.update(request.args) 182 | 183 | await app.test( 184 | 'GET', '/', query_string=b'tag=music&tag=rock&type=book' 185 | ) 186 | assert args == {'tag': ['music', 'rock'], 'type': ['book']} 187 | 188 | 189 | async def test_headers(): 190 | app = TestApplication() 191 | headers = {} 192 | 193 | @app.get('/') 194 | def hello(request): 195 | headers.update(request.headers) 196 | 197 | await app.test('GET', '/', headers=[[b'from', b'test@example.com']]) 198 | assert headers == {'from': ['test@example.com']} 199 | 200 | 201 | async def test_cookie(): 202 | app = TestApplication() 203 | 204 | @app.get('/') 205 | def index(request): 206 | return request.cookies.output(header='Cookie:') 207 | 208 | response = await app.test( 209 | 'GET', '/', headers=[[b'cookie', b'id=1;name=john']] 210 | ) 211 | assert response['body'] == b'Cookie: id=1\r\nCookie: name=john' 212 | 213 | 214 | async def test_set_cookie(): 215 | app = TestApplication() 216 | 217 | @app.get('/') 218 | def index(request): 219 | return Response(status=204, cookies={'id': 2, 'name': 'jane'}) 220 | 221 | response = await app.test('GET', '/') 222 | assert response['headers']._get('set-cookie') == ['id=2', 'name=jane'] 223 | 224 | 225 | async def test_bad_json(): 226 | app = TestApplication() 227 | 228 | response = await app.test( 229 | 'POST', 230 | '/', 231 | headers=[[b'content-type', b'application/json']], 232 | body=b'{"some": 1' 233 | ) 234 | assert response['status'] == 400 235 | 236 | 237 | async def test_good_json(): 238 | app = TestApplication() 239 | json = {} 240 | 241 | @app.post('/') 242 | def index(request): 243 | json.update(request.json) 244 | 245 | await app.test( 246 | 'POST', 247 | '/', 248 | headers=[[b'content-type', b'application/json']], 249 | body=b'{"some": 1}' 250 | ) 251 | assert json == {'some': 1} 252 | 253 | 254 | async def test_json_response(): 255 | app = TestApplication() 256 | 257 | @app.get('/') 258 | def json_hello(request): 259 | return {'hello': 'world'} 260 | 261 | response = await app.test('GET', '/') 262 | assert response['status'] == 200 263 | assert response['headers']['content-type'] == 'application/json' 264 | assert response['body'] == b'{"hello": "world"}' 265 | 266 | 267 | async def test_form(): 268 | app = TestApplication() 269 | form = {} 270 | 271 | @app.post('/') 272 | def submit(request): 273 | form.update(request.form) 274 | 275 | await app.test( 276 | 'POST', 277 | '/', 278 | headers=[[b'content-type', b'application/x-www-form-urlencoded']], 279 | body=b'name=john&age=27' 280 | ) 281 | 282 | assert form == {'name': ['john'], 'age': ['27']} 283 | 284 | 285 | async def test_early_response(): 286 | app = TestApplication() 287 | 288 | @app.before 289 | def early(request): 290 | return "Hi! I'm early!" 291 | 292 | @app.route('/') 293 | def index(request): 294 | return 'Maybe?' 295 | 296 | response = await app.test('GET', '/') 297 | assert response['status'] == 200 298 | assert response['body'] == b"Hi! I'm early!" 299 | 300 | 301 | async def test_late_early_response(): 302 | app = TestApplication() 303 | 304 | @app.after 305 | def early(request, response): 306 | response.status = 200 307 | response.body = b'Am I early?' 308 | 309 | response = await app.test('POST', '/') 310 | assert response['status'] == 200 311 | assert response['body'] == b'Am I early?' 312 | assert response['headers'].get('content-length') == '11' 313 | 314 | 315 | async def test_app_mount(): 316 | app1 = TestApplication() 317 | app2 = TestApplication() 318 | 319 | @app1.route('/') 320 | def app1_index(request): 321 | pass 322 | 323 | @app2.route('/') 324 | def app2_index(request): 325 | pass 326 | 327 | app2.mount(app1, '/app1') 328 | 329 | assert app2._routes == { 330 | '/': {'GET': app2_index}, 331 | '/app1/': {'GET': app1_index} 332 | } 333 | -------------------------------------------------------------------------------- /uhttp.py: -------------------------------------------------------------------------------- 1 | """ASGI micro framework""" 2 | 3 | import re 4 | import json 5 | from http import HTTPStatus 6 | from http.cookies import SimpleCookie, CookieError 7 | from urllib.parse import parse_qs, unquote 8 | from asyncio import to_thread 9 | from inspect import iscoroutinefunction 10 | 11 | 12 | class Application: 13 | def __init__( 14 | self, 15 | *, 16 | routes=None, 17 | startup=None, 18 | shutdown=None, 19 | before=None, 20 | after=None, 21 | max_content=1048576 22 | ): 23 | self._routes = routes or {} 24 | self._startup = startup or [] 25 | self._shutdown = shutdown or [] 26 | self._before = before or [] 27 | self._after = after or [] 28 | self._max_content = max_content 29 | 30 | def mount(self, app, prefix=''): 31 | self._startup += app._startup 32 | self._shutdown += app._shutdown 33 | self._before += app._before 34 | self._after += app._after 35 | self._routes.update({prefix + k: v for k, v in app._routes.items()}) 36 | self._max_content = max(self._max_content, app._max_content) 37 | 38 | def startup(self, func): 39 | self._startup.append(func) 40 | return func 41 | 42 | def shutdown(self, func): 43 | self._shutdown.append(func) 44 | return func 45 | 46 | def before(self, func): 47 | self._before.append(func) 48 | return func 49 | 50 | def after(self, func): 51 | self._after.append(func) 52 | return func 53 | 54 | def route(self, path, methods=('GET',)): 55 | def decorator(func): 56 | self._routes.setdefault(path, {}).update({ 57 | method: func for method in methods 58 | }) 59 | return func 60 | return decorator 61 | 62 | def get(self, path): 63 | return self.route(path, methods=('GET',)) 64 | 65 | def head(self, path): 66 | return self.route(path, methods=('HEAD',)) 67 | 68 | def post(self, path): 69 | return self.route(path, methods=('POST',)) 70 | 71 | def put(self, path): 72 | return self.route(path, methods=('PUT',)) 73 | 74 | def delete(self, path): 75 | return self.route(path, methods=('DELETE',)) 76 | 77 | def connect(self, path): 78 | return self.route(path, methods=('CONNECT',)) 79 | 80 | def options(self, path): 81 | return self.route(path, methods=('OPTIONS',)) 82 | 83 | def trace(self, path): 84 | return self.route(path, methods=('TRACE',)) 85 | 86 | def patch(self, path): 87 | return self.route(path, methods=('PATCH',)) 88 | 89 | async def __call__(self, scope, receive, send): 90 | state = scope.get('state', {}) 91 | 92 | if scope['type'] == 'lifespan': 93 | while True: 94 | event = await receive() 95 | 96 | if event['type'] == 'lifespan.startup': 97 | try: 98 | for func in self._startup: 99 | await asyncfy(func, state) 100 | self._routes = { 101 | re.compile(k): v for k, v in self._routes.items() 102 | } 103 | except Exception as e: 104 | await send({ 105 | 'type': 'lifespan.startup.failed', 106 | 'message': f'{type(e).__name__}: {e}' 107 | }) 108 | break 109 | await send({'type': 'lifespan.startup.complete'}) 110 | 111 | elif event['type'] == 'lifespan.shutdown': 112 | try: 113 | for func in self._shutdown: 114 | await asyncfy(func, state) 115 | except Exception as e: 116 | await send({ 117 | 'type': 'lifespan.shutdown.failed', 118 | 'message': f'{type(e).__name__}: {e}' 119 | }) 120 | break 121 | await send({'type': 'lifespan.shutdown.complete'}) 122 | break 123 | 124 | elif scope['type'] == 'http': 125 | request = Request( 126 | method=scope['method'], 127 | path=scope['path'], 128 | ip=scope.get('client', ('', 0))[0], 129 | args=parse_qs(unquote(scope['query_string'])), 130 | state=state.copy() 131 | ) 132 | 133 | try: 134 | try: 135 | request.headers = MultiDict([ 136 | [k.decode(), v.decode()] for k, v in scope['headers'] 137 | ]) 138 | except UnicodeDecodeError: 139 | raise Response(400) 140 | 141 | try: 142 | request.cookies.load(request.headers.get('cookie', '')) 143 | except CookieError: 144 | raise Response(400) 145 | 146 | while True: 147 | event = await receive() 148 | request.body += event['body'] 149 | if len(request.body) > self._max_content: 150 | raise Response(413) 151 | if not event['more_body']: 152 | break 153 | 154 | content_type = request.headers.get('content-type', '') 155 | if 'application/json' in content_type: 156 | try: 157 | request.json = await to_thread( 158 | json.loads, request.body.decode() 159 | ) 160 | except (UnicodeDecodeError, json.JSONDecodeError): 161 | raise Response(400) 162 | elif 'application/x-www-form-urlencoded' in content_type: 163 | request.form = MultiDict(await to_thread( 164 | parse_qs, unquote(request.body) 165 | )) 166 | 167 | for func in self._before: 168 | if ret := await asyncfy(func, request): 169 | raise Response.from_any(ret) 170 | 171 | for route, methods in self._routes.items(): 172 | if matches := route.fullmatch(request.path): 173 | request.params = matches.groupdict() 174 | if func := methods.get(request.method): 175 | ret = await asyncfy(func, request) 176 | response = Response.from_any(ret) 177 | else: 178 | response = Response(405) 179 | response.headers['allow'] = ', '.join(methods) 180 | break 181 | else: 182 | response = Response(404) 183 | 184 | except Response as early_response: 185 | response = early_response 186 | 187 | try: 188 | for func in self._after: 189 | if ret := await asyncfy(func, request, response): 190 | raise Response.from_any(ret) 191 | except Response as early_response: 192 | response = early_response 193 | 194 | response.headers.setdefault('content-length', len(response.body)) 195 | response.headers._update({ 196 | 'set-cookie': [ 197 | header.split(': ', maxsplit=1)[1] 198 | for header in response.cookies.output().splitlines() 199 | ] 200 | }) 201 | 202 | await send({ 203 | 'type': 'http.response.start', 204 | 'status': response.status, 205 | 'headers': [ 206 | [str(k).encode(), str(v).encode()] 207 | for k, l in response.headers._items() for v in l 208 | ] 209 | }) 210 | await send({ 211 | 'type': 'http.response.body', 212 | 'body': response.body 213 | }) 214 | 215 | else: 216 | raise NotImplementedError(scope['type'], 'is not supported') 217 | 218 | 219 | class Request: 220 | def __init__( 221 | self, 222 | method, 223 | path, 224 | *, 225 | ip='', 226 | params=None, 227 | args=None, 228 | headers=None, 229 | cookies=None, 230 | body=b'', 231 | json=None, 232 | form=None, 233 | state=None 234 | ): 235 | self.method = method 236 | self.path = path 237 | self.ip = ip 238 | self.params = params or {} 239 | self.args = MultiDict(args) 240 | self.headers = MultiDict(headers) 241 | self.cookies = SimpleCookie(cookies) 242 | self.body = body 243 | self.json = json 244 | self.form = MultiDict(form) 245 | self.state = state or {} 246 | 247 | def __repr__(self): 248 | return f'{self.method} {self.path}' 249 | 250 | 251 | class Response(Exception): 252 | def __init__( 253 | self, 254 | status, 255 | *, 256 | headers=None, 257 | cookies=None, 258 | body=b'' 259 | ): 260 | self.status = status 261 | try: 262 | self.description = HTTPStatus(status).phrase 263 | except ValueError: 264 | self.description = '' 265 | super().__init__(f'{self.status} {self.description}') 266 | self.headers = MultiDict(headers) 267 | self.headers.setdefault('content-type', 'text/html; charset=utf-8') 268 | self.cookies = SimpleCookie(cookies) 269 | self.body = body 270 | 271 | @classmethod 272 | def from_any(cls, any): 273 | if isinstance(any, int): 274 | return cls(status=any, body=HTTPStatus(any).phrase.encode()) 275 | elif isinstance(any, str): 276 | return cls(status=200, body=any.encode()) 277 | elif isinstance(any, bytes): 278 | return cls(status=200, body=any) 279 | elif isinstance(any, dict): 280 | return cls( 281 | status=200, 282 | headers={'content-type': 'application/json'}, 283 | body=json.dumps(any).encode() 284 | ) 285 | elif isinstance(any, cls): 286 | return any 287 | elif any is None: 288 | return cls(status=204) 289 | else: 290 | raise TypeError 291 | 292 | 293 | async def asyncfy(func, /, *args, **kwargs): 294 | if iscoroutinefunction(func): 295 | return await func(*args, **kwargs) 296 | else: 297 | return await to_thread(func, *args, **kwargs) 298 | 299 | 300 | class MultiDict(dict): 301 | def __init__(self, mapping=None): 302 | if mapping is None: 303 | super().__init__() 304 | elif isinstance(mapping, MultiDict): 305 | super().__init__({k.lower(): v[:] for k, v in mapping.itemslist()}) 306 | elif isinstance(mapping, dict): 307 | super().__init__({ 308 | k.lower(): [v] if not isinstance(v, list) else v[:] 309 | for k, v in mapping.items() 310 | }) 311 | elif isinstance(mapping, (tuple, list)): 312 | super().__init__() 313 | for key, value in mapping: 314 | self._setdefault(key.lower(), []).append(value) 315 | else: 316 | raise TypeError('Invalid mapping type') 317 | 318 | def __getitem__(self, key): 319 | return super().__getitem__(key.lower())[-1] 320 | 321 | def __setitem__(self, key, value): 322 | super().setdefault(key.lower(), []).append(value) 323 | 324 | def _get(self, key, default=(None,)): 325 | return super().get(key.lower(), list(default)) 326 | 327 | def get(self, key, default=None): 328 | return super().get(key.lower(), [default])[-1] 329 | 330 | def _items(self): 331 | return super().items() 332 | 333 | def items(self): 334 | return {k.lower(): v[-1] for k, v in super().items()}.items() 335 | 336 | def _pop(self, key, default=(None,)): 337 | return super().pop(key.lower(), list(default)) 338 | 339 | def pop(self, key, default=None): 340 | values = super().get(key.lower(), []) 341 | if len(values) > 1: 342 | return values.pop() 343 | else: 344 | return super().pop(key.lower(), default) 345 | 346 | def _setdefault(self, key, default=(None,)): 347 | return super().setdefault(key.lower(), list(default)) 348 | 349 | def setdefault(self, key, default=None): 350 | return super().setdefault(key.lower(), [default])[-1] 351 | 352 | def _values(self): 353 | return super().values() 354 | 355 | def values(self): 356 | return {k.lower(): v[-1] for k, v in super().items()}.values() 357 | 358 | def _update(self, *args, **kwargs): 359 | super().update(*args, **kwargs) 360 | 361 | def update(self, *args, **kwargs): 362 | new = {} 363 | new.update(*args, **kwargs) 364 | super().update(MultiDict(new)) 365 | --------------------------------------------------------------------------------