{"id":13671542,"url":"https://github.com/hzlmn/diy-async-web-framework","last_synced_at":"2025-04-07T13:07:18.693Z","repository":{"id":47467741,"uuid":"161787559","full_name":"hzlmn/diy-async-web-framework","owner":"hzlmn","description":"Learn how modern async web frameworks work, by writing simple clone from scratch","archived":false,"fork":false,"pushed_at":"2022-05-30T08:01:03.000Z","size":75,"stargazers_count":344,"open_issues_count":2,"forks_count":26,"subscribers_count":10,"default_branch":"master","last_synced_at":"2025-03-31T11:06:13.425Z","etag":null,"topics":["aiohttp","asyncio","python3","sanic"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hzlmn.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2018-12-14T13:22:43.000Z","updated_at":"2025-02-18T22:24:10.000Z","dependencies_parsed_at":"2022-09-16T09:40:52.784Z","dependency_job_id":null,"html_url":"https://github.com/hzlmn/diy-async-web-framework","commit_stats":null,"previous_names":[],"tags_count":1,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hzlmn%2Fdiy-async-web-framework","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hzlmn%2Fdiy-async-web-framework/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hzlmn%2Fdiy-async-web-framework/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hzlmn%2Fdiy-async-web-framework/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hzlmn","download_url":"https://codeload.github.com/hzlmn/diy-async-web-framework/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247657281,"owners_count":20974345,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aiohttp","asyncio","python3","sanic"],"created_at":"2024-08-02T09:01:12.519Z","updated_at":"2025-04-07T13:07:18.668Z","avatar_url":"https://github.com/hzlmn.png","language":"Python","funding_links":[],"categories":["Python"],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n  \u003cimg src=\"media/logo.png\"/\u003e\n\u003c/div\u003e\n\n### Translations\n\u003e Feel free to create an issue if you have translated this guide or want to request a translation.\n- :ukraine: [Ukrainian](https://codeguida.com/post/2020) by Codeguida\n- 🇨🇳 [Chinese](https://github.com/hzlmn/diy-async-web-framework/blob/master/README_CN.md) thanks [@Amaindex](https://github.com/Amaindex)\n\n  \n### Introduction\n\n   Asynchronous programming became much popular in the last few years in the Python community. Libraries like `aiohttp` show incredible growth in usage. They handle a large amount of concurrent connections while still maintain good code readability and simplicity. Not a long time ago, Django [committed](https://docs.djangoproject.com/en/dev/releases/3.0/#asgi-support) on adding async support in a next major version. So future of asynchronous python is pretty bright as you may realise. However, for a large number of developers, who came from a standard blocking model, the working mechanism of these tools may seem confusing. In this short guide, I tried to go behind the scene and clarify the process, by re-building a  little  `aiohttp`  clone from scratch. We will start just with a basic sample from official documentation and progressively add all necessary functionality that we all like. So let's start.\n   \n  I assume that you already have a basic understanding of [asyncio](https://docs.python.org/3/library/asyncio.html) to follow this guide, but if you need a refresher here are few articles that may help\n  \n   - [Intro to asyncio](https://www.blog.pythonlibrary.org/2016/07/26/python-3-an-intro-to-asyncio)\n   - [Understanding asynchronous programming in Python](https://dbader.org/blog/understanding-asynchronous-programming-in-python)\n\nFor impatients, final source code available at [`hzlmn/sketch`](https://github.com/hzlmn/sketch)\n\n## Related projects\n- [500 Lines or Less](https://github.com/aosabook/500lines)\n   \n\n## Table of contents :book:\n\n* [Asyncio low-level APIs, Transports \u0026 Protocols](#asyncio-low-level-apis-transports--protocols)\n\n* [Making server protocol](#making-server-protocol)\n\n* [Request/Response objects](#requestresponse-objects)\n\n* [Application \u0026 UrlDispatcher](#application--urldispatcher)\n    \n * [Going further](#going-further)\n      * [Route params](#route-params)\n    \n      * [Middlewares](#middlewares)\n      \n      * [App lifecycle hooks](#app-lifecycle-hooks)\n  \n      * [Better exceptions](#better-exceptions)\n      \n      * [Graceful shutdown](#graceful-shutdown)\n\n  * [Sample application](#sample-application)\n      \n  * [Conclusion](#conclusion)\n      \n## Asyncio low-level APIs, Transports \u0026 Protocols\nAsyncio has come a long journey to become what it looks like now. Back in those days, it was created as a lower-level tool called a \"tulip\", and writing higher-level applications was not as enjoyable as it is today.\n\nRight now for most usecases `asyncio` is pretty high-level API, but it also provides set of low-level helpers for library authors to manage event loops, and implement networking/ipc protocols.\n\nOut of the box it only supports `TCP`, `UDP`, `SSL` and subprocesses. Libraries implement their own higher level (HTTP, FTP, etc.) based on base transports and available APIs.\n\nAll communications done over chaining `Transport` and `Protocols`. In simple words `Transport` describes how we can exchange data and `Protocol` is responsible for choosing which data specifically.\n\n`Asyncio` has a pretty great official docs so you can read more about it [here](https://docs.python.org/3.8/library/asyncio-protocol.html#asyncio-transport)\n\nTo get a first grasp let's write simple `TCP` server that will echo messages.\n\n\n`server.py`\n\n```python\nimport asyncio\n\nclass Server(asyncio.Protocol):\n    def connection_made(self, transport):\n        self._transport = transport\n\n    def data_received(self, data):\n        message = data.decode()\n\n        self._transport.write(data)\n\n        self._transport.close()\n\nloop = asyncio.get_event_loop()\n\ncoro = loop.create_server(Server, '127.0.0.1', 8080)\nserver = loop.run_until_complete(coro)\n\ntry:\n    loop.run_forever()\nexcept KeyboardInterrupt:\n    pass\n\nserver.close()\nloop.run_until_complete(server.wait_closed())\nloop.close()\n```\n\n```shell\n$ curl http://127.0.0.1:8080\nGET / HTTP/1.1\nHost: 127.0.0.1:8080\nUser-Agent: curl/7.54.0\nAccept: */*\n```\n\nAs you can see from the example above, the code is pretty simple, but as you may realize it is not scalable for writing a high-level application yet. \n\nAs `HTTP` works over `TCP` transport we already can send `HTTP` requests to our server, however, we receive them in raw format and working with it will be annoying as you may guess. So next step we need to add better `HTTP` handling mechanism.\n\n\n## Making server protocol\n\nLet's add request parsing so we can extract some useful info like headers, body, path and work with them instead of raw text. Parsing is a complex topic and it is certainly out of the scope of this guide, thats why we will use [httptools](https://github.com/MagicStack/httptools) by MagicStack for this as it rapidly fast, standard compatible and pretty flexible. \n\n`aiohttp`, on the other hand, has own hand-written Python based parser as well as binding to Node's [`http-parser`](https://github.com/nodejs/http-parser/tree/77310eeb839c4251c07184a5db8885a572a08352).\n\nLets write our parsing class, that will be used as mixin for our main `Server` class.\n\n`http_parser.py`\n```python\nclass HttpParserMixin:\n    def on_body(self, data):\n        self._body = data\n\n    def on_url(self, url):\n        self._url = url\n\n    def on_message_complete(self):\n        print(f\"Received request to {self._url.decode(self._encoding)}\")\n\n    def on_header(self, header, value):\n        header = header.decode(self._encoding)\n        self._headers[header] = value.decode(self._encoding)\n```\nNow when we have working `HttpParserMixin`, lets modify a bit our `Server` and apply mixin.\n\n`server.py`\n```python\nimport asyncio\n\nfrom httptools import HttpRequestParser\n\nfrom .http_parser import HttpParserMixin\n\nclass Server(asyncio.Protocol, HttpParserMixin):\n    def __init__(self, loop):\n        self._loop = loop\n        self._encoding = \"utf-8\"\n        self._url = None\n        self._headers = {}\n        self._body = None\n        self._transport = None\n        self._request_parser = HttpRequestParser(self)\n\n    def connection_made(self, transport):\n        self._transport = transport\n\n    def connection_lost(self, *args):\n        self._transport = None\n\n    def data_received(self, data):\n        # Pass data to our parser\n        self._request_parser.feed_data(data)\n```\n\n\u003c!-- Now when we have actual server lets try to run in --\u003e\nSo far, we have our server that can understand incoming `HTTP` requests and obtain some important information from it. Now let's try to add simple runner to it.\n\n`server.py`\n```python\nif __name__ == \"__main__\":\n    loop = asyncio.get_event_loop()\n    serv = Server(loop)\n    server = loop.run_until_complete(loop.create_server(lambda: serv, port=8080))\n\n    try:\n        print(\"Started server on ::8080\")\n        loop.run_until_complete(server.serve_forever())\n    except KeyboardInterrupt:\n        server.close()\n        loop.run_until_complete(server.wait_closed())\n        loop.stop()\n\n```\n```sh\n\u003e python server.py\nStarted server on ::8080\n```\n```sh\n\u003e curl http://127.0.0.1:8080/hello\n```\n\n## Request/Response objects\nAt this moment we have working server that can parse `HTTP` calls, but for our apps we need better abstractions to work with. \n\nLet's create base `Request` class that will group together all incoming `HTTP` request information. We will use `yarl` library for dealing with urls, make sure you installed it with pip.\n\n`request.py`\n```python\nimport json\n\nfrom yarl import URL\n\nclass Request:\n    _encoding = \"utf_8\"\n\n    def __init__(self, method, url, headers, version=None, body=None, app=None):\n        self._version = version\n        self._method = method.decode(self._encoding)\n        self._url = URL(url.decode(self._encoding))\n        self._headers = headers\n        self._body = body\n\n    @property\n    def method(self):\n        return self._method\n\n    @property\n    def url(self):\n        return self._url\n\n    @property\n    def headers(self):\n        return self._headers\n\n    def text(self):\n        if self._body is not None:\n            return self._body.decode(self._encoding)\n\n    def json(self):\n        text = self.text()\n        if text is not None:\n            return json.loads(text)\n\n    def __repr__(self):\n        return f\"\u003cRequest at 0x{id(self)}\u003e\"\n```\n\nAs a next step, we also need a structure, that will helps us to describe outgoing `HTTP` response in programmer-friendly manner and convert it to raw `HTTP`, which can be processed by `asyncio.Transport`.\n\n`response.py`\n```python\nimport http.server\n\nweb_responses = http.server.BaseHTTPRequestHandler.responses\n\nclass Response:\n    _encoding = \"utf-8\"\n\n    def __init__(\n        self,\n        body=None,\n        status=200,\n        content_type=\"text/plain\",\n        headers=None,\n        version=\"1.1\",\n    ):\n        self._version = version\n        self._status = status\n        self._body = body\n        self._content_type = content_type\n        if headers is None:\n            headers = {}\n        self._headers = headers\n\n    @property\n    def body(self):\n        return self._body\n\n    @property\n    def status(self):\n        return self._status\n\n    @property\n    def content_type(self):\n        return self._content_type\n\n    @property\n    def headers(self):\n        return self._headers\n    \n    def add_body(self, data):\n        self._body = data\n\n    def add_header(self, key, value):\n        self._headers[key] = value\n    \n    def __str__(self):\n        \"\"\"We will use this in our handlers, it is actually generation of raw HTTP response,\n        that will be passed to our TCP transport\n        \"\"\"\n        status_msg, _ = web_responses.get(self._status)\n        \n        messages = [\n            f\"HTTP/{self._version} {self._status} {status_msg}\",\n            f\"Content-Type: {self._content_type}\",\n            f\"Content-Length: {len(self._body)}\",\n        ]\n\n        if self.headers:\n            for header, value in self.headers.items():\n                messages.append(f\"{header}: {value}\")\n\n        if self._body is not None:\n            messages.append(\"\\r\\n\" + self._body)\n\n        return \"\\r\\n\".join(messages)\n\n    def __repr__(self):\n        return f\"\u003cResponse at 0x{id(self)}\u003e\"\n```\n\nAs you can see code is pretty straight forward we incapsulate all our data and provide proper getters. Also we have few helpers for reading body `text` and `json`, that will be used later. We also need to update our `Server` to actually construct `Request` object from message.\n\nIt should be created, when a whole request processed, so we need to add it to `on_message_complete` event handler in our parser mixin.\n\n`http_parser.py`\n```python\nclass HttpParserMixin:\n    ...\n\n    def on_message_complete(self):\n        self._request = self._request_class(\n            version=self._request_parser.get_http_version(),\n            method=self._request_parser.get_method(),\n            url=self._url,\n            headers=self._headers,\n            body=self._body,\n        )\n\n    ...\n```\n\nServer also need a little modification to create `Response` object and\npass encoded value to `asyncio.Transport`.\n\n`server.py`\n```python\nfrom .response import Response\n...\n\nclass Server(asyncio.Protocol, HttpParserMixin):\n    ...\n\n    def __init__(self, loop):\n        ...\n        self._request = None\n        self._request_class = Request\n\n    ...\n\n    def data_received(self, data):\n        self._request_parser.feed_data(data)\n\n        resp = Response(body=f\"Received request on {self._request.url}\")\n        self._transport.write(str(resp).encode(self._encoding))\n\n        self._transport.close()\n```\n\nNow running our `server.py` we will be able to see `Received request on /path` in response to curl call `http://localhost:8080/path`.\n\n## Application \u0026 UrlDispatcher\n\nAt this stage we already have simple working server that can process HTTP requests and Request/Response objects for dealing with request cycles. However, our hand-crafted toolkit still miss few important concepts. First of all right now we have only one main request handler, in large applications we have lots of them for different routes so we certainly need mechanism for registring multiple route handlers.\n\nSo, let's try to build simplest possible `UrlDispatcher`, just object with internal dict, that store as a key method and path tuple and actual handler as a value. We also need a handler for situation where user try to reach unrecognized route.\n\n`router.py`\n```python\nfrom .response import Response\n\nclass UrlDispatcher:\n    def __init__(self):\n        self._routes = {}\n\n    async def _not_found(self, request):\n         return Response(f\"Not found {request.url} on this server\", status=404)\n\n    def add_route(self, method, path, handler):\n        self._routes[(method, path)] = handler\n\n    def resolve(self, request):\n        key = (request.method, request.url.path)\n        if key not in self._routes:\n            return self._not_found\n        return self._routes[key]\n```\n\nSure thing, we miss lots of stuff like parameterized routes but we will add them later on. For now let keep it simple as it is.\n\n\u003c!-- Now when we have our server up and running lets lets combine together `Application` container and it's runner. --\u003e\n\nNext things we need an `Application` container, that will actually combine together all app related information, because dealing with underlaying `Server` will be annoying for us.\n\n```python\nimport asyncio\n\nfrom .router import UrlDispatcher\nfrom .server import Server\nfrom .response import Response\n\nclass Application:\n    def __init__(self, loop=None):\n        if loop is None:\n            loop = asyncio.get_event_loop()\n\n        self._loop = loop\n        self._router = UrlDispatcher()\n\n    @property\n    def loop(self):\n        return self._loop\n\n    @property\n    def router(self):\n        return self._router\n\n    def _make_server(self):\n        return Server(loop=self._loop, handler=self._handler, app=self)\n\n    async def _handler(self, request, response_writer):\n        \"\"\"Process incoming request\"\"\"\n        handler = self._router.resolve(request)\n        resp = await handler(request)\n\n        if not isinstance(resp, Response):\n            raise RuntimeError(f\"expect Response instance but got {type(resp)}\")\n\n        response_writer(resp)\n\n```\n\nWe need to modify our `Server` a bit and add `response_writer` method, that will be responsible for passing data to transport. Also initializer should be changed to add `handler` and `app` properties that will be used to call corresponding handlers. \n\n`server.py`\n```python\n\nclass Server(asyncio.Protocol, HttpParserMixin):\n    ...\n\n    def __init__(self, loop, handler, app):\n        self._loop = loop\n        self._url = None\n        self._headers = {}\n        self._body = None\n        self._transport = None\n        self._request_parser = HttpRequestParser(self)\n        self._request = None\n        self._request_class = Request\n        self._request_handler = handler\n        self._request_handler_task = None\n\n    def response_writer(self, response):\n        self._transport.write(str(response).encode(self._encoding))\n        self._transport.close()\n    \n    ...\n\n```\n\n`http_parser.py`\n```python\nclass HttpParserMixin:\n    def on_body(self, data):\n        self._body = data\n\n    def on_url(self, url):\n        self._url = url\n\n    def on_message_complete(self):\n        self._request = self._request_class(\n            version=self._request_parser.get_http_version(),\n            method=self._request_parser.get_method(),\n            url=self._url,\n            headers=self._headers,\n            body=self._body,\n        )\n\n        self._request_handler_task = self._loop.create_task(\n            self._request_handler(self._request, self.response_writer)\n        )\n\n    def on_header(self, header, value):\n        header = header.decode(self._encoding)\n        self._headers[header] = value.decode(self._encoding)\n```\n\nFinally, when we have basic functionality ready, can register new routes and handlers, let's add simple helper for actually running our app instance (similar to `web.run_app` in `aiohttp`).\n\n`application.py`\n```python\ndef run_app(app, host=\"127.0.0.1\", port=8080, loop=None):\n    if loop is None:\n        loop = asyncio.get_event_loop()\n\n    serv = app._make_server()\n    server = loop.run_until_complete(\n        loop.create_server(lambda: serv, host=host, port=port)\n    )\n\n    try:\n        print(f\"Started server on {host}:{port}\")\n        loop.run_until_complete(server.serve_forever())\n    except KeyboardInterrupt:\n        server.close()\n        loop.run_until_complete(server.wait_closed())\n        loop.stop()\n```\n\nAnd now, time to make simple app with our fresh toolkit.\n\n`app.py`\n```python\nimport asyncio\n\nfrom .response import Response\nfrom .application import Application, run_app\n\napp = Application()\n\nasync def handler(request):\n    return Response(f\"Hello at {request.url}\")\n\napp.router.add_route(\"GET\", \"/\", handler)\n\nif __name__ == \"__main__\":\n    run_app(app)\n\n```\nIf you will run it and then make `GET` request to `/`, you will be able to see `Hello at /` and `404` response for all other routes. Hooray, we done it however there are still big room for improvements.\n\n```shell\n$ curl 127.0.0.1:8080/\nHello at /\n\n$ curl 127.0.0.1:8080/invalid\nNot found /invalid on this server\n\n```\n\n## Going further\n\nSo far, we have all basic functionality up \u0026 running, but we still need to change certain things in our \"framework\". First of all, as we discussed earlier our router are missing parametrized-routes, it is \"must have\" feature of all modern libraries. Next we need to add support for middlewares, it is also very common and powerfull concept. Great thing about `aiohttp` that i am pretty much in love with is application lifecycle hooks (eg. `on_startup`, `on_shutdown`, `on_cleanup`) so we certainly should try to implement it as well.\n\n## Route params \nCurrently, our `UrlDispatcher` is pretty lean and it works with registered url pathes as a string. First thing, that we need is actually add support for patterns like `/user/{username}` to our `resolve` method. Also we need `_format_pattern` helper that will be responsible for generating actual regular expression from parametrized string. Also as you may noted we have another helper `_method_not_allowed` and methods for simpler definition of `GET`, `POST`, etc. routes.\n\n\n`router.py`\n```python\nimport re\n\nfrom functools import partialmethod\n\nfrom .response import Response\n\nclass UrlDispatcher:\n    _param_regex = r\"{(?P\u003cparam\u003e\\w+)}\"\n\n    def __init__(self):\n        self._routes = {}\n\n    async def _not_found(self, request):\n        return Response(f\"Could not find {request.url.raw_path}\")\n\n    async def _method_not_allowed(self, request):\n        return Response(f\"{request.method} not allowed for {request.url.raw_path}\")\n\n    def resolve(self, request):\n        for (method, pattern), handler in self._routes.items():\n            match = re.match(pattern, request.url.raw_path)\n\n            if match is None:\n                return None, self._not_found\n\n            if method != request.method:\n                return None, self._method_not_allowed\n\n            return match.groupdict(), handler\n\n    def _format_pattern(self, path):\n        if not re.search(self._param_regex, path):\n            return path\n\n        regex = r\"\"\n        last_pos = 0\n\n        for match in re.finditer(self._param_regex, path):\n            regex += path[last_pos: match.start()]\n            param = match.group(\"param\")\n            regex += r\"(?P\u003c%s\u003e\\w+)\" % param\n            last_pos = match.end()\n\n        return regex\n\n    def add_route(self, method, path, handler):\n        pattern = self._format_pattern(path)\n        self._routes[(method, pattern)] = handler\n\n    add_get = partialmethod(add_route, \"GET\")\n\n    add_post = partialmethod(add_route, \"POST\")\n\n    add_put = partialmethod(add_route, \"PUT\")\n\n    add_head = partialmethod(add_route, \"HEAD\")\n\n    add_options = partialmethod(add_route, \"OPTIONS\")\n```\n\nWe also need to modify our application container, right now `resolve` method of `UrlDispatcher` returns `match_info` and `handler`. So inside `Application._handler` change the following lines.\n\n`application.py`\n```python\nclass Application:\n    ...\n    async def _handler(self, request, response_writer):\n        \"\"\"Process incoming request\"\"\"\n        match_info, handler = self._router.resolve(request)\n\n        request.match_info = match_info\n            \n        ...\n\n```\n## Middlewares\n\nFor those, who aren't familiar with a this concept, in simple words `middleware` is just a coroutine, that can modify incoming request object or change response of a handler. It will be fired before each request to the server. Implementation is pretty trivial for our needs. First of all we need to add list of registered middlewares inside our `Application` object and change a little bit `Application._handler` to run through them. Each middleware should work with result of previous one in chain.\n\n`application.py`\n```python\nfrom functools import partial\n...\n\nclass Application:\n    def __init__(self, loop=None, middlewares=None):\n        ...\n        if middlewares is None:\n            self._middlewares = []\n\n    ...\n\n    async def _handler(self, request, response_writer):\n        \"\"\"Process incoming request\"\"\"\n        match_info, handler = self._router.resolve(request)\n        \n        request.match_info = match_info\n\n        if self._middlewares:\n            for md in self._middlewares:\n                handler = partial(md, handler=handler)\n\n        resp = await handler(request)\n\n        ...\n```\n\nNow lets try to add request logging middleware to our simple application.\n\n`app.py`\n```python\nimport asyncio\n\nfrom .response import Response\nfrom .application import Application, run_app\n\nasync def log_middleware(request, handler):\n    print(f\"Received request to {request.url.raw_path}\")\n    return await handler(request)\n\napp = Application(middlewares=[log_middleware])\n\nasync def handler(request):\n    return Response(f\"Hello at {request.url}\")\n\napp.router.add_route(\"GET\", \"/\", handler)\n\nif __name__ == \"__main__\":\n    run_app(app)\n\n```\nIf we try to run it, we should see `Received request to /` message in response to incoming request.\n\n\n## App lifecycle hooks\n\nNext step let's add support for running certain coroutines in response to events like starting server and stopping it. It is pretty neat feature of `aiohttp`. There are many signals like `on_startup`, `on_shutdown`, `on_response_prepared` to name a few, but for our need let's keep it simple as possible and just implement `startup` \u0026 `shutdown` helpers.\n\nInside `Application` we need to add list of actual handlers for each event with proper encapsulation and provide getters. Then actual `startup` and `shutdown` coroutines and add corresponding calls to `run_app` helper.\n\n`application.py`\n```python\nclass Application:\n    def __init__(self, loop=None, middlewares=None):\n        ...\n        self._on_startup = []\n        self._on_shutdown = []\n\n    ... \n\n    @property\n    def on_startup(self):\n        return self._on_startup\n\n    @property\n    def on_shutdown(self):\n        return self._on_shutdown\n\n    async def startup(self):\n        coros = [func(self) for func in self._on_startup]\n        await asyncio.gather(*coros, loop=self._loop)\n\n    async def shutdown(self):\n        coros = [func(self) for func in self._on_shutdown]\n        await asyncio.gather(*coros, loop=self._loop)\n\n    ...\n\ndef run_app(app, host=\"127.0.0.1\", port=8080, loop=None):\n    if loop is None:\n        loop = asyncio.get_event_loop()\n\n    serv = app._make_server()\n\n    loop.run_until_complete(app.startup())\n\n    server = loop.run_until_complete(\n        loop.create_server(lambda: serv, host=host, port=port)\n    )\n\n    try:\n        print(f\"Started server on {host}:{port}\")\n        loop.run_until_complete(server.serve_forever())\n    except KeyboardInterrupt:\n        loop.run_until_complete(app.shutdown())\n        server.close()\n        loop.run_until_complete(server.wait_closed())\n        loop.stop()\n```\n\n## Better exceptions\n\nAt this steps we have most of the core features added, however, we still have lack of exceptions handling. Great feature about `aiohttp` is that it allows you to work with web exceptions as a native python exceptions.\nIt's done with implementing both `Exception` and `Response` classes and it's really flexible mechanism we would like to have as well. \n\nSo, first thing lets create our base `HTTPException` class and few helpers based on it that we might need like `HTTPNotFound` for unrecognized pathes `HTTPBadRequest` for user side issues and `HTTPFound` for redirecting.\n\n```python\nfrom .response import Response\n\nclass HTTPException(Response, Exception):\n    status_code = None\n\n    def __init__(self, reason=None, content_type=None):\n        self._reason = reason\n        self._content_type = content_type\n\n        Response.__init__(\n            self,\n            body=self._reason,\n            status=self.status_code,\n            content_type=self._content_type or \"text/plain\",\n        )\n\n        Exception.__init__(self, self._reason)\n\n\nclass HTTPNotFound(HTTPException):\n    status_code = 404\n\n\nclass HTTPBadRequest(HTTPException):\n    status_code = 400\n\n\nclass HTTPFound(HTTPException):\n    status_code = 302\n\n    def __init__(self, location, reason=None, content_type=None):\n        super().__init__(reason=reason, content_type=content_type)\n        self.add_header(\"Location\", location)\n```\n\nThen we need modify a bit our `Application._handler` to actually catch web exceptions.\n\n`application.py`\n```python\nclass Application:\n    ...\n    async def _handler(self, request, response_writer):\n        \"\"\"Process incoming request\"\"\"\n        try:\n            match_info, handler = self._router.resolve(request)\n\n            request.match_info = match_info\n\n            if self._middlewares:\n                for md in self._middlewares:\n                    handler = partial(md, handler=handler)\n\n            resp = await handler(request)\n        except HTTPException as exc:\n            resp = exc\n\n        ...\n```\n\nAlso now we can drop `_not_found` \u0026 `_method_not_allowed` helpers from our `UrlDispatcher` and instead just raise proper exceptions.\n\n`router.py`\n```python\nclass UrlDispatcher:\n    ...\n    def resolve(self, request):\n        for (method, pattern), handler in self._routes.items():\n            match = re.match(pattern, request.url.raw_path)\n\n            if match is None:\n                raise HTTPNotFound(reason=f\"Could not find {request.url.raw_path}\")\n\n            if method != request.method:\n                raise HTTPBadRequest(reason=f\"{request.method} not allowed for {request.url.raw_path}\")\n\n            return match.groupdict(), handler\n\n        ...\n```\n\nAnother thing that might be a good addition is a standard formatted response for internal server errors, because we don't want to break actual app in some inconsistent situations. Let's add just a simple html template as well as tiny helper for formatting exceptions.\n\n`helpers.py`\n```python\nimport traceback\n\nfrom .response import Response\n\nserver_exception_templ = \"\"\"\n\u003cdiv\u003e\n    \u003ch1\u003e500 Internal server error\u003c/h1\u003e\n    \u003cspan\u003eServer got itself in trouble : \u003cb\u003e{exc}\u003c/b\u003e\u003cspan\u003e\n    \u003cp\u003e{traceback}\u003c/p\u003e\n\u003c/div\u003e\n\"\"\"\n\n\ndef format_exception(exc):\n    resp = Response(status=500, content_type=\"text/html\")\n    trace = traceback.format_exc().replace(\"\\n\", \"\u003c/br\u003e\")\n    msg = server_exception_templ.format(exc=str(exc), traceback=trace)\n    resp.add_body(msg)\n    return resp\n```\n\nAs simple as it is, and now just catch all `Exception` inside our `Application._handler` and generate actual html response with our helper.\n\n`application.py`\n```python\nclass Application:\n    ...\n    async def _handler(self, request, response_writer):\n        \"\"\"Process incoming request\"\"\"\n        try:\n            match_info, handler = self._router.resolve(request)\n\n            request.match_info = match_info\n\n            if self._middlewares:\n                for md in self._middlewares:\n                    handler = partial(md, handler=handler)\n\n            resp = await handler(request)\n        except HTTPException as exc:\n            resp = exc\n        except Exception as exc:\n            resp = format_exception(exc)\n        ...\n```\n\n## Graceful shutdown\nAs a final touch, we need to add signal processing for the proper process of shutting down our application. So, let's change `run_app` to the following lines.\n\n`application.py`\n```python\n...\n\ndef run_app(app, host=\"127.0.0.1\", port=8080, loop=None):\n    if loop is None:\n        loop = asyncio.get_event_loop()\n\n    serv = app._make_server()\n\n    loop.run_until_complete(app.startup())\n\n    server = loop.run_until_complete(\n        loop.create_server(lambda: serv, host=host, port=port)\n    )\n\n    loop.add_signal_handler(\n        signal.SIGTERM, lambda: asyncio.ensure_future(app.shutdown())\n    )\n\n    ...\n```\n\n## Sample application\nNow when we have our toolkit ready, let's try to complete our previous sample application lifecycle hooks and exceptions that we just added.\n\n`app.py`\n```python\nfrom .application import Application, run_app\n\nasync def on_startup(app):\n    # you may query here actual db, but for an example let's just use simple set.\n    app.db = {\"john_doe\",}\n\nasync def log_middleware(request, handler):\n    print(f\"Received request to {request.url.raw_path}\")\n    return await handler(request)\n\nasync def handler(request):\n    username = request.match_info[\"username\"]\n    if username not in request.app.db:\n        raise HTTPNotFound(reason=f\"No such user with as {username} :(\")\n      \n    return Response(f\"Welcome, {username}!\")\n\napp = Application(middlewares=[log_middleware])\n\napp.on_startup.append(on_startup)\n\napp.router.add_get(\"/{username}\", handler)\n\nif __name__ == \"__main__\":\n    run_app(app)\n```\n\nIf we done all properly you will see log messages on each request, welcome message in response to registered user and `HTTPNotFound` for unregistered users and unrecognized path.\n\n## Conclusion\n\nSumming it up, in ~500 lines we hand-crafted pretty simple yet powerfull micro framework inspired by `aiohttp` \u0026 `sanic`. Of course, it is not production ready software as it still miss lot's of usefull \u0026 important features like more robust server, better HTTP support to fully correlate with specification, web sockets to name a few. However, i belive that through this process we developed better understanding how such tools built. As a famous physicist Richard Feynman said “What I cannot create, I do not understand”. So I hope you enjoyed this guide, see ya! :wave:\n\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhzlmn%2Fdiy-async-web-framework","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhzlmn%2Fdiy-async-web-framework","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhzlmn%2Fdiy-async-web-framework/lists"}