├── .anylint ├── .github └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── examples ├── esp8266.py ├── hello_world.py ├── rest_api.py ├── static │ ├── css │ │ └── bootstrap.min.css.gz │ ├── images │ │ └── gcat.jpg │ ├── index.html │ ├── index.simple.html │ └── js │ │ └── jquery-3.2.1.min.js.gz └── static_content.py ├── test └── test_server.py └── tinyweb ├── __init__.py └── server.py /.anylint: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["examples/static/", "CONTRIBUTING.md", "ISSUE_TEMPLATE.md", "README.md"] 3 | } -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Check code style (pycodestyle) 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.x 12 | uses: actions/setup-python@v2 13 | with: 14 | # Semantic version range syntax or exact version of a Python version 15 | python-version: '3.x' 16 | - name: Display Python version 17 | run: python -c "import sys; print(sys.version)" 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install pycodestyle 21 | - name: Lint code and tests 22 | run: pycodestyle --ignore=E501,W504 tinyweb/ test/ 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run the tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.x 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: '3.x' 15 | - name: Display Python version 16 | run: python -c "import sys; print(sys.version)" 17 | 18 | - name: Install MicroPython dependencies 19 | run: | 20 | sudo apt-get update 21 | DEBIAN_FRONTEND=noninteractive sudo apt-get install -y build-essential libreadline-dev libffi-dev git pkg-config gcc-arm-none-eabi libnewlib-arm-none-eabi 22 | git clone --recurse-submodules https://github.com/micropython/micropython.git 23 | 24 | - name: Build MicroPython 25 | run: | 26 | cd micropython 27 | git checkout v1.13 28 | sudo make -C mpy-cross 29 | sudo make -C ports/unix axtls install 30 | cd .. 31 | 32 | - name: Run the tests 33 | run: | 34 | micropython -m upip install logging unittest uasyncio uasyncio.core 35 | cp -r tinyweb ~/.micropython/lib/ 36 | micropython test/test_server.py 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | /_test 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: 4 | - python 5 | 6 | services: 7 | - docker 8 | 9 | install: 10 | - sudo apt-get update 11 | - sudo apt-get install --no-install-recommends --no-install-suggests -y build-essential libreadline-dev libffi-dev pkg-config 12 | - sudo pip install pycodestyle 13 | # clone micropython, belyalov's fork of micropython-lib 14 | - git clone --recurse-submodules https://github.com/micropython/micropython.git 15 | - git clone https://github.com/belyalov/micropython-lib.git 16 | # merge required uasyncio modules 17 | - cp micropython-lib/uasyncio.core/uasyncio/core.py micropython-lib/uasyncio/uasyncio/core.py 18 | # make symlinks to required modules 19 | - export MDST=~/.micropython/lib/ 20 | - mkdir -p $MDST 21 | - ln -s `pwd`/micropython-lib/unittest/unittest.py $MDST 22 | - ln -s `pwd`/micropython-lib/logging/logging.py $MDST 23 | - ln -s `pwd`/micropython-lib/uasyncio/uasyncio $MDST/uasyncio 24 | - ln -s `pwd`/tinyweb $MDST/tinyweb 25 | # compile/install micropython. 26 | - cd micropython 27 | - git checkout v1.12 28 | - sudo make -C mpy-cross 29 | - sudo make -C ports/unix axtls install 30 | - cd .. 31 | 32 | script: 33 | # Run style checks 34 | - pycodestyle --ignore=E501,W504 tinyweb/ test/ 35 | # Run unittests 36 | - ./test/test_server.py 37 | # Copy modules to be frozen and compile firmware for esp8266 38 | - export MPORT=micropython/ports/esp8266/modules 39 | - cp -r tinyweb $MPORT 40 | - cp -r micropython-lib/uasyncio/uasyncio $MPORT 41 | - cp micropython-lib/logging/logging.py $MPORT 42 | # Copy examples - to freeze them as well 43 | - mkdir -p $MPORT/examples 44 | - cp examples/*.py $MPORT/examples 45 | # Compile firmware for esp8266 46 | - docker run -v`pwd`/micropython:/micropython arsenicus/esp-open-sdk:latest /bin/bash -c ". /.bashrc && cd /micropython/ports/esp8266 && make" 47 | - cp micropython/ports/esp8266/build-GENERIC/firmware-combined.bin ./firmware_esp8266-$TRAVIS_TAG.bin 48 | # Compile firmware for esp32 49 | - export MPORT=micropython/ports/esp32/modules 50 | - cp -r tinyweb $MPORT 51 | - cp -r micropython-lib/uasyncio/uasyncio $MPORT 52 | - cp micropython-lib/logging/logging.py $MPORT 53 | # Copy examples - to freeze them as well 54 | - mkdir -p $MPORT/examples 55 | - cp examples/*.py $MPORT/examples 56 | # Supported ESP32 SDK 57 | - docker run -v`pwd`/micropython:/micropython arsenicus/esp-open-sdk:latest_esp32 /bin/bash -c ". /.bashrc && cd /micropython/ports/esp32 && make" 58 | - cp micropython/ports/esp32/build-GENERIC/firmware.bin ./firmware_esp32-$TRAVIS_TAG.bin 59 | 60 | deploy: 61 | provider: releases 62 | api_key: 63 | secure: LN9yw25ZRZt+xnD7+0Dy+kJ0EPk5AnrBfvtCsPrCRsRvb8NK+vrNtxxnslim49XpFTDOG712cDAfLnoz+SRZM1ZZn4IPmAsEFmU7FoFNTXWFt44xW6L17ER4bsGFCzLoWDohk7+Ps8V4ZOnbNXa0jm2i8FA8WuLzTBjn/btJSGxnfJei2fTYYenz96/LyaO7c1p7iaq12hcvcd15NQKSOF+JTgJV1NiPSzI8bg7HgqGR/o00Rdm5Gr91iuI97hCCVXhqfFx/VXkvOo5RwR9Ka0nNSzNg4Ijmxwv04uEHJ2pRnCOGqC951ksKu8D6+D2nHMYpNSUKwwkBy+1d3oRwN376oE46sXjX29xzxDf7Iun4F8WPh8bqhS0qfZNM3luHvjFQeXmxPCL633NkaR4P8dhWoZGxP7DECzG7bVEDvraZK1pXyFN/Ihn05AYWwZQxbbdD0t6Y6d5skguN2rGkGK5tZlzMIqrxxaZji5XDupWfJtOYQmcHRWcEsEb2Rhe5692n2AsIeSNHvBfU+rMpZyGSdiawphVo468ANzxoYVyCBcc9ymMORBOmb3Fb8bbMcCQNFs2hjwAZkxV1PyjN9GRyh7CoHoS1xLA2eryO/2UWkih1RSQbV6Ovk/XEW9ahQEBcRCXylAT2fanxdPfpqYadRk/8yToKNBbuDoGjILA= 64 | file: 65 | - firmware_esp8266-$TRAVIS_TAG.bin 66 | - firmware_esp32-$TRAVIS_TAG.bin 67 | skip_cleanup: true 68 | on: 69 | tags: true 70 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Contributing to TinyWeb 2 | 3 | Everybody is welcomed to contribute to TinyWeb. 4 | If you've hit issue or just would to make tinyweb.. more tiny.. or.. more awesome.. :) You're always welcomed. 5 | 6 | The process is straight-forward: 7 | 8 | - Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/devel/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (skip step 0) 9 | - Fork the tinyweb [git repository](https://github.com/belyalov/tinyweb). 10 | - Make desired changes. 11 | - For new functionality - add unittests. 12 | - Ensure tests work. 13 | - Create a Pull Request. 14 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Make sure you are running the latest version of TinyWeb before reporting an issue. 2 | 3 | **Device and/or platform:** 4 | 5 | 6 | **Description of problem:** 7 | 8 | 9 | **Expected:** 10 | 11 | 12 | **Traceback (if applicable):** 13 | ```bash 14 | 15 | ``` 16 | 17 | **Additional info:** 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Konstantin Belyalov 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 | ## TinyWeb [![Build Status](https://travis-ci.org/belyalov/tinyweb.svg?branch=master)](https://travis-ci.org/belyalov/tinyweb) 2 | Simple and lightweight (thus - *tiny*) HTTP server for tiny devices like **ESP8266** / **ESP32** running [micropython](https://github.com/micropython/micropython). 3 | Having simple HTTP server allows developers to create nice and modern UI for their IoT devices. 4 | By itself - *tinyweb* is just simple TCP server running on top of `uasyncio` - library for micropython, therefore *tinyweb* is single threaded server. 5 | 6 | ### Features 7 | * Fully asynchronous when using with [uasyncio](https://github.com/micropython/micropython-lib/tree/v1.0/uasyncio) library for MicroPython. 8 | * [Flask](http://flask.pocoo.org/) / [Flask-RESTful](https://flask-restful.readthedocs.io/en/latest/) like API. 9 | * *Tiny* memory usage. So you can run it on devices like **ESP8266 / ESP32** with 64K/96K of onboard RAM. BTW, there is a huge room for optimizations - so your contributions are warmly welcomed. 10 | * Support for static content serving from filesystem. 11 | * Great unittest coverage. So you can be confident about quality :) 12 | 13 | ### Requirements 14 | 15 | * [logging](https://github.com/micropython/micropython-lib/tree/master/python-stdlib/logging) 16 | 17 | On MicroPython <1.13: 18 | 19 | * [uasyncio](https://github.com/micropython/micropython-lib/tree/v1.0/uasyncio) - micropython version of *async* python library. 20 | * [uasyncio-core](https://github.com/micropython/micropython-lib/tree/v1.0/uasyncio.core) 21 | 22 | ### Quickstart 23 | The easist way to try it - is using pre-compiled firmware for ESP8266 / ESP32. 24 | Instructions below are tested with *NodeMCU* devices. For any other devices instructions could be a bit different, so keep in mind. 25 | **CAUTION**: If you proceed with installation all data on your device will **lost**! 26 | 27 | #### Installation - ESP8266 28 | * Download latest `firmware_esp8266-version.bin` from [releases](https://github.com/belyalov/tinyweb/releases). 29 | * Install `esp-tool` if you haven't done already: `pip install esptool` 30 | * Erase flash: `esptool.py --port --baud 256000 erase_flash` 31 | * Flash firmware: `esptool.py --port --baud 256000 write_flash -fm dio 0 firmware_esp8266-v1.3.2.bin` 32 | 33 | #### Installation - ESP32 34 | * Download latest `firmware_esp32-version.bin` from [releases](https://github.com/belyalov/tinyweb/releases). 35 | * Install `esp-tool` if you haven't done already: `pip install esptool` 36 | * Erase flash: `esptool.py --port --baud 256000 erase_flash` 37 | * Flash firmware: `esptool.py --port --baud 256000 write_flash -fm dio 0x1000 firmware_esp32-v1.3.2.bin` 38 | 39 | #### Hello world 40 | Let's develop [Hello World](https://github.com/belyalov/tinyweb/blob/master/examples/hello_world.py) web app: 41 | ```python 42 | import tinyweb 43 | 44 | 45 | # Create web server application 46 | app = tinyweb.webserver() 47 | 48 | 49 | # Index page 50 | @app.route('/') 51 | async def index(request, response): 52 | # Start HTTP response with content-type text/html 53 | await response.start_html() 54 | # Send actual HTML page 55 | await response.send('

Hello, world! (table)

\n') 56 | 57 | 58 | # Another one, more complicated page 59 | @app.route('/table') 60 | async def table(request, response): 61 | # Start HTTP response with content-type text/html 62 | await response.start_html() 63 | await response.send('

Simple table

' 64 | '' 65 | '') 66 | for i in range(10): 67 | await response.send(''.format(i, i)) 68 | await response.send('
NameSome Value
Name{}Value{}
' 69 | '') 70 | 71 | 72 | def run(): 73 | app.run(host='0.0.0.0', port=8081) 74 | 75 | ``` 76 | Simple? Let's try it! 77 | Flash your device with firmware, open REPL and type: 78 | ```python 79 | >>> import network 80 | 81 | # Connect to WiFi 82 | >>> sta_if = network.WLAN(network.STA_IF) 83 | >>> sta_if.active(True) 84 | >>> sta_if.connect('', '') 85 | 86 | # Run Hello World! :) 87 | >>> import examples.hello_world as hello 88 | >>> hello.run() 89 | ``` 90 | 91 | That's it! :) Try it by open page `http://:8081` 92 | 93 | Like it? Check more [examples](https://github.com/belyalov/tinyweb/tree/master/examples) then :) 94 | 95 | ### Limitations 96 | * HTTP protocol support - due to memory constrains only **HTTP/1.0** is supported (with exception for REST API - it uses HTTP/1.1 with `Connection: close`). Support of HTTP/1.1 may be added when `esp8266` platform will be completely deprecated. 97 | 98 | ### Reference 99 | #### class `webserver` 100 | Main tinyweb app class. 101 | 102 | * `__init__(self, request_timeout=3, max_concurrency=None)` - Create instance of webserver class. 103 | * `request_timeout` - Specifies timeout for client to send complete HTTP request (without HTTP body, if any), after that connection will be closed. Since `uasyncio` has very short queue (about 42 items) *Avoid* using values > 5 to prevent events queue overflow. 104 | * `max_concurrency` - How many connections can be processed concurrently. It is very important to limit it mostly because of memory constrain. Default value depends on platform, **3** for `esp8266`, **6** for `esp32` and **10** for others. 105 | * `backlog` - Parameter to socket.listen() function. Defines size of pending to be accepted connections queue. Must be greater than `max_concurrency`. 106 | * `debug` - Whether send exception info (text + backtrace) to client together with HTTP 500 or not. 107 | 108 | * `add_route(self, url, f, **kwargs)` - Map `url` into function `f`. Additional keyword arguments are supported: 109 | * `methods` - List of allowed methods. Defaults to `['GET', 'POST']` 110 | * `save_headers` - Due to memory constrains you most likely want to minimze memory usage by saving only headers 111 | which you really need in. E.g. for POST requests it is make sense to save at least 'Content-Length' header. 112 | Defaults to empty list - `[]`. 113 | * `max_body_size` - Max HTTP body size (e.g. POST form data). Be careful with large forms due to memory constrains (especially with esp8266 which has 64K RAM). Defaults to `1024`. 114 | * `allowed_access_control_headers` - Whenever you're using xmlHttpRequest (send JSON from browser) these headers are required to do access control. Defaults to `*` 115 | * `allowed_access_control_origins` - The same idea as for header above. Defaults to `*`. 116 | 117 | * `@route` - simple and useful decorator (inspired by *Flask*). Instead of using `add_route()` directly - just decorate your function with `@route`, like this: 118 | ```python 119 | @app.route('/index.html') 120 | async def index(req, resp): 121 | await resp.send_file('static/index.simple.html') 122 | ``` 123 | * `add_resource(self, cls, url, **kwargs)` - RestAPI: Map resource class `cls` to `url`. Class `cls` is arbitrary class with with implementation of HTTP methods: 124 | ```python 125 | class CustomersList(): 126 | def get(self, data): 127 | """Return list of all customers""" 128 | return {'1': {'name': 'Jack'}, '2': {'name': 'Bob'}} 129 | 130 | def post(self, data): 131 | """Add customer""" 132 | db[str(next_id)] = data 133 | return {'message': 'created'}, 201 134 | ``` 135 | `**kwargs` are optional and will be passed to handler directly. 136 | **Note**: only `GET`, `POST`, `PUT` and `DELETE` methods are supported. Check [restapi full example](https://github.com/belyalov/tinyweb/blob/master/examples/rest_api.py) as well. 137 | 138 | * `@resource` - the same idea as for `route` but for resource: 139 | ```python 140 | # Regular version 141 | @app.resource('/user/') 142 | def user(data, id): 143 | return {'id': id, 'name': 'foo'} 144 | 145 | # Generator based / different HTTP method 146 | @app.resource('/user/', method='POST') 147 | async def user(data, id): 148 | yield '{' 149 | yield '"id": "{}",'.format(id) 150 | yield '"name": "test",' 151 | yield '}' 152 | ``` 153 | 154 | * `run(self, host="127.0.0.1", port=8081, loop_forever=True, backlog=10)` - run web server. Since *tinyweb* is fully async server by default it is blocking call assuming that you've added other tasks before. 155 | * `host` - host to listen on 156 | * `port` - port to listen on 157 | * `loop_forever` - run `async.loop_forever()`. Set to `False` if you don't want `run` to be blocking call. Be sure to call `async.loop_forever()` by yourself. 158 | * `backlog` - size of pending connections queue (basically argument to `listen()` function) 159 | 160 | * `shutdown(self)` - gracefully shutdown web server. Meaning close all active connections / server socket and cancel all started coroutines. **NOTE** be sure to it in event loop or run event loop at least once, like: 161 | ```python 162 | async def all_shutdown(): 163 | await asyncio.sleep_ms(100) 164 | 165 | try: 166 | web = tinyweb.webserver() 167 | web.run() 168 | except KeyboardInterrupt as e: 169 | print(' CTRL+C pressed - terminating...') 170 | web.shutdown() 171 | uasyncio.get_event_loop().run_until_complete(all_shutdown()) 172 | ``` 173 | 174 | 175 | #### class `request` 176 | This class contains everything about *HTTP request*. Use it to get HTTP headers / query string / etc. 177 | ***Warning*** - to improve memory / CPU usage strings in `request` class are *binary strings*. This means that you **must** use `b` prefix when accessing items, e.g. 178 | 179 | >>> print(req.method) 180 | b'GET' 181 | 182 | So be sure to check twice your code which interacts with `request` class. 183 | 184 | * `method` - HTTP request method. 185 | * `path` - URL path. 186 | * `query_string` - URL path. 187 | * `headers` - `dict` of saved HTTP headers from request. **Only if enabled by `save_headers`. 188 | ```python 189 | if b'Content-Length' in self.headers: 190 | print(self.headers[b'Content-Length']) 191 | ``` 192 | 193 | * `read_parse_form_data()` - By default (again, to save CPU/memory) *tinyweb* doesn't read form data. You have to call it manually unless you're using RESTApi. Returns `dict` of key / value pairs. 194 | 195 | #### class `response` 196 | Use this class to generate HTTP response. Please be noticed that `response` class is using *regular strings*, not binary strings as `request` class does. 197 | 198 | * `code` - HTTP response code. By default set to `200` which means OK, no error. 199 | * `version` - HTTP version. Defaults to `1.0`. Please be note - that only HTTP1.0 is internally supported by `tinyweb`. So if you changing it to `1.1` - be sure to support protocol by yourself. 200 | * `headers` - HTTP response headers dictionary (key / value pairs). 201 | * `add_header(self, key, value)` - Convenient way to add HTTP response header 202 | * `key` - Header name 203 | * `value` - Header value 204 | 205 | * `add_access_control_headers(self)` - Add HTTP headers required for RESTAPI (JSON query) 206 | 207 | * `redirect(self, location)` - Generate HTTP redirection (HTTP 302 Found) to `location`. This *function is coroutine*. 208 | 209 | * `start_html(self)`- Start response with HTML content type. This *function is coroutine*. This function is basically sends response line and headers. Refer to [hello world example](https://github.com/belyalov/tinyweb/blob/master/examples/hello_world.py). 210 | 211 | * `send(self, payload)` - Sends your string/bytes `payload` to client. Be sure to start your response with `start_html()` or manually. This *function is coroutine*. 212 | 213 | * `send_file(self, filename)`: Send local file as HTTP response. File type will be detected automatically unless you explicitly change it. If file doesn't exists - HTTP Error `404` will be generated. 214 | Additional keyword arguments 215 | * `content_type` - MIME filetype. By default - `None` which means autodetect. 216 | * `content_encoding` - Specifies used compression type, e.g. `gzip`. By default - `None` which means don't add this header. 217 | * `max_age` - Cache control. How long browser can keep this file on disk. Value is in `seconds`. By default - 30 days. To disable caching, set it to `0`. 218 | 219 | * `error(self, code)` - Generate HTTP error response with error `code`. This *function is coroutine*. 220 | -------------------------------------------------------------------------------- /examples/esp8266.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env micropython 2 | 3 | import machine 4 | import network 5 | import tinyweb 6 | import gc 7 | 8 | 9 | # PINs available for use 10 | pins = {4: 'D2', 11 | 5: 'D1', 12 | 12: 'D6', 13 | 13: 'D7', 14 | 14: 'D5', 15 | 15: 'D8', 16 | 16: 'D0'} 17 | 18 | 19 | # Create web server 20 | app = tinyweb.server.webserver() 21 | 22 | 23 | # Index page 24 | @app.route('/') 25 | @app.route('/index.html') 26 | async def index(req, resp): 27 | await resp.send_file('static/index.html') 28 | 29 | 30 | # JS files. 31 | # Since ESP8266 is low memory platform - it totally make sense to 32 | # pre-gzip all large files (>1k) and then send gzipped version 33 | @app.route('/js/') 34 | async def files_js(req, resp, fn): 35 | await resp.send_file('static/js/{}.gz'.format(fn), 36 | content_type='application/javascript', 37 | content_encoding='gzip') 38 | 39 | 40 | # The same for css files - e.g. 41 | # Raw version of bootstrap.min.css is about 146k, compare to gzipped version - 20k 42 | @app.route('/css/') 43 | async def files_css(req, resp, fn): 44 | await resp.send_file('static/css/{}.gz'.format(fn), 45 | content_type='text/css', 46 | content_encoding='gzip') 47 | 48 | 49 | # Images 50 | @app.route('/images/') 51 | async def files_images(req, resp, fn): 52 | await resp.send_file('static/images/{}'.format(fn), 53 | content_type='image/jpeg') 54 | 55 | 56 | # RESTAPI: System status 57 | class Status(): 58 | 59 | def get(self, data): 60 | mem = {'mem_alloc': gc.mem_alloc(), 61 | 'mem_free': gc.mem_free(), 62 | 'mem_total': gc.mem_alloc() + gc.mem_free()} 63 | sta_if = network.WLAN(network.STA_IF) 64 | ifconfig = sta_if.ifconfig() 65 | net = {'ip': ifconfig[0], 66 | 'netmask': ifconfig[1], 67 | 'gateway': ifconfig[2], 68 | 'dns': ifconfig[3] 69 | } 70 | return {'memory': mem, 'network': net} 71 | 72 | 73 | # RESTAPI: GPIO status 74 | class GPIOList(): 75 | 76 | def get(self, data): 77 | res = [] 78 | for p, d in pins.items(): 79 | val = machine.Pin(p).value() 80 | res.append({'gpio': p, 'nodemcu': d, 'value': val}) 81 | return {'pins': res} 82 | 83 | 84 | # RESTAPI: GPIO controller: turn PINs on/off 85 | class GPIO(): 86 | 87 | def put(self, data, pin): 88 | # Check input parameters 89 | if 'value' not in data: 90 | return {'message': '"value" is requred'}, 400 91 | # Check pin 92 | pin = int(pin) 93 | if pin not in pins: 94 | return {'message': 'no such pin'}, 404 95 | # Change state 96 | val = int(data['value']) 97 | machine.Pin(pin).value(val) 98 | return {'message': 'changed', 'value': val} 99 | 100 | 101 | def run(): 102 | # Set all pins to OUT mode 103 | for p, d in pins.items(): 104 | machine.Pin(p, machine.Pin.OUT) 105 | 106 | app.add_resource(Status, '/api/status') 107 | app.add_resource(GPIOList, '/api/gpio') 108 | app.add_resource(GPIO, '/api/gpio/') 109 | app.run(host='0.0.0.0', port=8081) 110 | 111 | 112 | if __name__ == '__main__': 113 | run() 114 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env micropython 2 | """ 3 | MIT license 4 | (C) Konstantin Belyalov 2017-2018 5 | """ 6 | import tinyweb 7 | 8 | 9 | # Create web server application 10 | app = tinyweb.webserver() 11 | 12 | 13 | # Index page 14 | @app.route('/') 15 | async def index(request, response): 16 | # Start HTTP response with content-type text/html 17 | await response.start_html() 18 | # Send actual HTML page 19 | await response.send('

Hello, world! (table)

\n') 20 | 21 | 22 | # HTTP redirection 23 | @app.route('/redirect') 24 | async def redirect(request, response): 25 | # Start HTTP response with content-type text/html 26 | await response.redirect('/') 27 | 28 | 29 | # Another one, more complicated page 30 | @app.route('/table') 31 | async def table(request, response): 32 | # Start HTTP response with content-type text/html 33 | await response.start_html() 34 | await response.send('

Simple table

' 35 | '' 36 | '') 37 | for i in range(10): 38 | await response.send(''.format(i, i)) 39 | await response.send('
NameSome Value
Name{}Value{}
' 40 | '') 41 | 42 | 43 | def run(): 44 | app.run(host='0.0.0.0', port=8081) 45 | 46 | 47 | if __name__ == '__main__': 48 | run() 49 | # To test your server: 50 | # - Terminal: 51 | # $ curl http://localhost:8081 52 | # or 53 | # $ curl http://localhost:8081/table 54 | # 55 | # - Browser: 56 | # http://localhost:8081 57 | # http://localhost:8081/table 58 | # 59 | # - To test HTTP redirection: 60 | # curl http://localhost:8081/redirect -v 61 | -------------------------------------------------------------------------------- /examples/rest_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env micropython 2 | """ 3 | MIT license 4 | (C) Konstantin Belyalov 2017-2018 5 | """ 6 | import tinyweb 7 | 8 | # Init our customers DB with some fake values 9 | db = {'1': {'firstname': 'Alex', 'lastname': 'River'}, 10 | '2': {'firstname': 'Lannie', 'lastname': 'Fox'}} 11 | next_id = 3 12 | 13 | 14 | # If you're familiar with FLaskRestful - you're almost all set! :) 15 | # Tinyweb have only basic functionality comparing to Flask due to 16 | # environment where it intended to run on. 17 | class CustomersList(): 18 | 19 | def get(self, data): 20 | """Return list of all customers""" 21 | return db 22 | 23 | def post(self, data): 24 | """Add customer""" 25 | global next_id 26 | db[str(next_id)] = data 27 | next_id += 1 28 | # Return message AND set HTTP response code to "201 Created" 29 | return {'message': 'created'}, 201 30 | 31 | 32 | # Simple helper to return message and error code 33 | def not_found(): 34 | # Return message and HTTP response "404 Not Found" 35 | return {'message': 'no such customer'}, 404 36 | 37 | 38 | # Detailed information about given customer 39 | class Customer(): 40 | 41 | def not_exists(self): 42 | return {'message': 'no such customer'}, 404 43 | 44 | def get(self, data, user_id): 45 | """Get detailed information about given customer""" 46 | if user_id not in db: 47 | return not_found() 48 | return db[user_id] 49 | 50 | def put(self, data, user_id): 51 | """Update given customer""" 52 | if user_id not in db: 53 | return not_found() 54 | db[user_id] = data 55 | return {'message': 'updated'} 56 | 57 | def delete(self, data, user_id): 58 | """Delete customer""" 59 | if user_id not in db: 60 | return not_found() 61 | del db[user_id] 62 | return {'message': 'successfully deleted'} 63 | 64 | 65 | def run(): 66 | # Create web server application 67 | app = tinyweb.webserver() 68 | # Add our resources 69 | app.add_resource(CustomersList, '/customers') 70 | app.add_resource(Customer, '/customers/') 71 | app.run(host='0.0.0.0', port=8081) 72 | 73 | 74 | if __name__ == '__main__': 75 | run() 76 | # To test your server run in terminal: 77 | # - Get all customers: 78 | # curl http://localhost:8081/customers 79 | # - Get detailed information about particular customer: 80 | # curl http://localhost:8081/customers/1 81 | # - Add customer: 82 | # curl http://localhost:8081/customers -X POST -d "firstname=Maggie&lastname=Stone" 83 | # - Update customer: 84 | # curl http://localhost:8081/customers/2 -X PUT -d "firstname=Margo" 85 | # - Delete customer: 86 | # curl http://localhost:8081/customers/1 -X DELETE 87 | -------------------------------------------------------------------------------- /examples/static/css/bootstrap.min.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/belyalov/tinyweb/7669f03cdcbb62a847e7d4917673be52ad2f7e79/examples/static/css/bootstrap.min.css.gz -------------------------------------------------------------------------------- /examples/static/images/gcat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/belyalov/tinyweb/7669f03cdcbb62a847e7d4917673be52ad2f7e79/examples/static/images/gcat.jpg -------------------------------------------------------------------------------- /examples/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | TinyWeb Static content example 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 27 | 28 | 29 | 30 | 31 | 32 | 49 | 50 |
51 |
52 | 53 |
54 |
55 |
56 |
Memory
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 |
Free...
Allocated...
Total...
73 |
74 |
75 |
76 | 77 |
78 |
79 |
80 |
Network
81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 |
IP...
NetMask...
Gateway...
DNS...
101 |
102 |
103 |
104 | 105 |
106 |
107 |
108 |
Cat :)
109 |

110 |
111 |
112 |
113 | 114 |
115 | 116 |
117 | 118 | 119 |
120 |
121 | 122 |
123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 |
GPIO #MCU #State
............
141 |
142 | 143 |
144 | 145 |
146 | 147 | 148 | 149 | 150 | 151 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /examples/static/index.simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | TinyWeb Static content example 11 | 12 | 13 | 14 | 23 | 24 | 25 | 26 | 27 | 51 | 52 |
53 | 54 |
55 |

Bootstrap starter template

56 |

Use this document as a way to quickly start any new project.
All you get is this text and a mostly barebones HTML document.

57 |

58 |
59 | 60 |
61 | 62 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /examples/static/js/jquery-3.2.1.min.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/belyalov/tinyweb/7669f03cdcbb62a847e7d4917673be52ad2f7e79/examples/static/js/jquery-3.2.1.min.js.gz -------------------------------------------------------------------------------- /examples/static_content.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env micropython 2 | """ 3 | MIT license 4 | (C) Konstantin Belyalov 2017-2018 5 | """ 6 | import tinyweb 7 | 8 | 9 | # Create web server application 10 | app = tinyweb.webserver() 11 | 12 | 13 | # Index page 14 | @app.route('/') 15 | @app.route('/index.html') 16 | async def index(req, resp): 17 | # Just send file 18 | await resp.send_file('static/index.simple.html') 19 | 20 | 21 | # Images 22 | @app.route('/images/') 23 | async def images(req, resp, fn): 24 | # Send picture. Filename - in parameter 25 | await resp.send_file('static/images/{}'.format(fn), 26 | content_type='image/jpeg') 27 | 28 | 29 | if __name__ == '__main__': 30 | app.run(host='0.0.0.0', port=8081) 31 | # To test your server just open page in browser: 32 | # http://localhost:8081 33 | # or 34 | # http://localhost:8081/index.html 35 | -------------------------------------------------------------------------------- /test/test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env micropython 2 | """ 3 | Unittests for Tiny Web 4 | MIT license 5 | (C) Konstantin Belyalov 2017-2018 6 | """ 7 | 8 | import unittest 9 | import uos as os 10 | import uerrno as errno 11 | import uasyncio as asyncio 12 | from tinyweb import webserver 13 | from tinyweb.server import urldecode_plus, parse_query_string 14 | from tinyweb.server import request, HTTPException 15 | 16 | 17 | # Helper to delete file 18 | def delete_file(fn): 19 | # "unlink" gets renamed to "remove" in micropython, 20 | # so support both 21 | if hasattr(os, 'unlink'): 22 | os.unlink(fn) 23 | else: 24 | os.remove(fn) 25 | 26 | 27 | # HTTP headers helpers 28 | def HDR(str): 29 | return '{}\r\n'.format(str) 30 | 31 | 32 | HDRE = '\r\n' 33 | 34 | 35 | class mockReader(): 36 | """Mock for coroutine reader class""" 37 | 38 | def __init__(self, lines): 39 | if type(lines) is not list: 40 | lines = [lines] 41 | self.lines = lines 42 | self.idx = 0 43 | 44 | async def readline(self): 45 | self.idx += 1 46 | # Convert and return str to bytes 47 | return self.lines[self.idx - 1].encode() 48 | 49 | def readexactly(self, n): 50 | return self.readline() 51 | 52 | 53 | class mockWriter(): 54 | """Mock for coroutine writer class""" 55 | 56 | def __init__(self, generate_expection=None): 57 | """ 58 | keyword arguments: 59 | generate_expection - raise exception when calling send() 60 | """ 61 | self.s = 1 62 | self.history = [] 63 | self.closed = False 64 | self.generate_expection = generate_expection 65 | 66 | async def awrite(self, buf, off=0, sz=-1): 67 | if sz == -1: 68 | sz = len(buf) - off 69 | if self.generate_expection: 70 | raise self.generate_expection 71 | # Save biffer into history - so to be able to assert then 72 | self.history.append(buf[:sz]) 73 | 74 | async def aclose(self): 75 | self.closed = True 76 | 77 | 78 | async def mock_wait_for(coro, timeout): 79 | await coro 80 | 81 | 82 | def run_coro(coro): 83 | # Mock wait_for() function with simple dummy 84 | asyncio.wait_for = (lambda c, t: await c) 85 | """Simple helper to run coroutine""" 86 | for i in coro: 87 | pass 88 | 89 | 90 | # Tests 91 | 92 | class Utils(unittest.TestCase): 93 | 94 | def testUrldecode(self): 95 | runs = [('abc%20def', 'abc def'), 96 | ('abc%%20def', 'abc% def'), 97 | ('%%%', '%%%'), 98 | ('%20%20', ' '), 99 | ('abc', 'abc'), 100 | ('a%25%25%25c', 'a%%%c'), 101 | ('a++b', 'a b'), 102 | ('+%25+', ' % '), 103 | ('+%2B+', ' + '), 104 | ('%20+%2B+%41', ' + A'), 105 | ] 106 | 107 | for r in runs: 108 | self.assertEqual(urldecode_plus(r[0]), r[1]) 109 | 110 | def testParseQueryString(self): 111 | runs = [('k1=v2', {'k1': 'v2'}), 112 | ('k1=v2&k11=v11', {'k1': 'v2', 113 | 'k11': 'v11'}), 114 | ('k1=v2&k11=', {'k1': 'v2', 115 | 'k11': ''}), 116 | ('k1=+%20', {'k1': ' '}), 117 | ('%6b1=+%20', {'k1': ' '}), 118 | ('k1=%3d1', {'k1': '=1'}), 119 | ('11=22%26&%3d=%3d', {'11': '22&', 120 | '=': '='}), 121 | ] 122 | for r in runs: 123 | self.assertEqual(parse_query_string(r[0]), r[1]) 124 | 125 | 126 | class ServerParts(unittest.TestCase): 127 | 128 | def testRequestLine(self): 129 | runs = [('GETT / HTTP/1.1', 'GETT', '/'), 130 | ('TTEG\t/blah\tHTTP/1.1', 'TTEG', '/blah'), 131 | ('POST /qq/?q=q HTTP', 'POST', '/qq/', 'q=q'), 132 | ('POST /?q=q BSHT', 'POST', '/', 'q=q'), 133 | ('POST /?q=q&a=a JUNK', 'POST', '/', 'q=q&a=a')] 134 | 135 | for r in runs: 136 | try: 137 | req = request(mockReader(r[0])) 138 | run_coro(req.read_request_line()) 139 | self.assertEqual(r[1].encode(), req.method) 140 | self.assertEqual(r[2].encode(), req.path) 141 | if len(r) > 3: 142 | self.assertEqual(r[3].encode(), req.query_string) 143 | except Exception: 144 | self.fail('exception on payload --{}--'.format(r[0])) 145 | 146 | def testRequestLineEmptyLinesBefore(self): 147 | req = request(mockReader(['\n', '\r\n', 'GET /?a=a HTTP/1.1'])) 148 | run_coro(req.read_request_line()) 149 | self.assertEqual(b'GET', req.method) 150 | self.assertEqual(b'/', req.path) 151 | self.assertEqual(b'a=a', req.query_string) 152 | 153 | def testRequestLineNegative(self): 154 | runs = ['', 155 | '\t\t', 156 | ' ', 157 | ' / HTTP/1.1', 158 | 'GET', 159 | 'GET /', 160 | 'GET / ' 161 | ] 162 | 163 | for r in runs: 164 | with self.assertRaises(HTTPException): 165 | req = request(mockReader(r)) 166 | run_coro(req.read_request_line()) 167 | 168 | def testHeadersSimple(self): 169 | req = request(mockReader([HDR('Host: google.com'), 170 | HDRE])) 171 | run_coro(req.read_headers([b'Host'])) 172 | self.assertEqual(req.headers, {b'Host': b'google.com'}) 173 | 174 | def testHeadersSpaces(self): 175 | req = request(mockReader([HDR('Host: \t google.com \t '), 176 | HDRE])) 177 | run_coro(req.read_headers([b'Host'])) 178 | self.assertEqual(req.headers, {b'Host': b'google.com'}) 179 | 180 | def testHeadersEmptyValue(self): 181 | req = request(mockReader([HDR('Host:'), 182 | HDRE])) 183 | run_coro(req.read_headers([b'Host'])) 184 | self.assertEqual(req.headers, {b'Host': b''}) 185 | 186 | def testHeadersMultiple(self): 187 | req = request(mockReader([HDR('Host: google.com'), 188 | HDR('Junk: you blah'), 189 | HDR('Content-type: file'), 190 | HDRE])) 191 | hdrs = {b'Host': b'google.com', 192 | b'Junk': b'you blah', 193 | b'Content-type': b'file'} 194 | run_coro(req.read_headers([b'Host', b'Junk', b'Content-type'])) 195 | self.assertEqual(req.headers, hdrs) 196 | 197 | def testUrlFinderExplicit(self): 198 | urls = [('/', 1), 199 | ('/%20', 2), 200 | ('/a/b', 3), 201 | ('/aac', 5)] 202 | junk = ['//', '', '/a', '/aa', '/a/fhhfhfhfhfhf'] 203 | # Create server, add routes 204 | srv = webserver() 205 | for u in urls: 206 | srv.add_route(u[0], u[1]) 207 | # Search them all 208 | for u in urls: 209 | # Create mock request object with "pre-parsed" url path 210 | rq = request(mockReader([])) 211 | rq.path = u[0].encode() 212 | f, args = srv._find_url_handler(rq) 213 | self.assertEqual(u[1], f) 214 | # Some simple negative cases 215 | for j in junk: 216 | rq = request(mockReader([])) 217 | rq.path = j.encode() 218 | f, args = srv._find_url_handler(rq) 219 | self.assertIsNone(f) 220 | self.assertIsNone(args) 221 | 222 | def testUrlFinderParameterized(self): 223 | srv = webserver() 224 | # Add few routes 225 | srv.add_route('/', 0) 226 | srv.add_route('/', 1) 227 | srv.add_route('/a/', 2) 228 | # Check first url (non param) 229 | rq = request(mockReader([])) 230 | rq.path = b'/' 231 | f, args = srv._find_url_handler(rq) 232 | self.assertEqual(f, 0) 233 | # Check second url 234 | rq.path = b'/user1' 235 | f, args = srv._find_url_handler(rq) 236 | self.assertEqual(f, 1) 237 | self.assertEqual(args['_param_name'], 'user_name') 238 | self.assertEqual(rq._param, 'user1') 239 | # Check third url 240 | rq.path = b'/a/123456' 241 | f, args = srv._find_url_handler(rq) 242 | self.assertEqual(f, 2) 243 | self.assertEqual(args['_param_name'], 'id') 244 | self.assertEqual(rq._param, '123456') 245 | # When param is empty and there is no non param endpoint 246 | rq.path = b'/a/' 247 | f, args = srv._find_url_handler(rq) 248 | self.assertEqual(f, 2) 249 | self.assertEqual(rq._param, '') 250 | 251 | def testUrlFinderNegative(self): 252 | srv = webserver() 253 | # empty URL is not allowed 254 | with self.assertRaises(ValueError): 255 | srv.add_route('', 1) 256 | # Query string is not allowed 257 | with self.assertRaises(ValueError): 258 | srv.add_route('/?a=a', 1) 259 | # Duplicate urls 260 | srv.add_route('/duppp', 1) 261 | with self.assertRaises(ValueError): 262 | srv.add_route('/duppp', 1) 263 | 264 | 265 | # We want to test decorators as well 266 | server_for_decorators = webserver() 267 | 268 | 269 | @server_for_decorators.route('/uid/') 270 | @server_for_decorators.route('/uid2/') 271 | async def route_for_decorator(req, resp, user_id): 272 | await resp.start_html() 273 | await resp.send('YO, {}'.format(user_id)) 274 | 275 | 276 | @server_for_decorators.resource('/rest1/') 277 | def resource_for_decorator1(data, user_id): 278 | return {'name': user_id} 279 | 280 | 281 | @server_for_decorators.resource('/rest2/') 282 | async def resource_for_decorator2(data, user_id): 283 | yield '{"name": user_id}' 284 | 285 | 286 | class ServerFull(unittest.TestCase): 287 | 288 | def setUp(self): 289 | self.dummy_called = False 290 | self.data = {} 291 | # "Register" one connection into map for dedicated decor server 292 | server_for_decorators.conns[id(1)] = None 293 | self.hello_world_history = ['HTTP/1.0 200 MSG\r\n' + 294 | 'Content-Type: text/html\r\n\r\n', 295 | '

Hello world

'] 296 | # Create one more server - to simplify bunch of tests 297 | self.srv = webserver() 298 | self.srv.conns[id(1)] = None 299 | 300 | def testRouteDecorator1(self): 301 | """Test @.route() decorator""" 302 | # First decorator 303 | rdr = mockReader(['GET /uid/man1 HTTP/1.1\r\n', 304 | HDRE]) 305 | wrt = mockWriter() 306 | # "Send" request 307 | run_coro(server_for_decorators._handler(rdr, wrt)) 308 | # Ensure that proper response "sent" 309 | expected = ['HTTP/1.0 200 MSG\r\n' + 310 | 'Content-Type: text/html\r\n\r\n', 311 | 'YO, man1'] 312 | self.assertEqual(wrt.history, expected) 313 | self.assertTrue(wrt.closed) 314 | 315 | def testRouteDecorator2(self): 316 | # Second decorator 317 | rdr = mockReader(['GET /uid2/man2 HTTP/1.1\r\n', 318 | HDRE]) 319 | wrt = mockWriter() 320 | # Re-register connection 321 | server_for_decorators.conns[id(1)] = None 322 | # "Send" request 323 | run_coro(server_for_decorators._handler(rdr, wrt)) 324 | # Ensure that proper response "sent" 325 | expected = ['HTTP/1.0 200 MSG\r\n' + 326 | 'Content-Type: text/html\r\n\r\n', 327 | 'YO, man2'] 328 | self.assertEqual(wrt.history, expected) 329 | self.assertTrue(wrt.closed) 330 | 331 | def testResourceDecorator1(self): 332 | """Test @.resource() decorator""" 333 | rdr = mockReader(['GET /rest1/man1 HTTP/1.1\r\n', 334 | HDRE]) 335 | wrt = mockWriter() 336 | run_coro(server_for_decorators._handler(rdr, wrt)) 337 | expected = ['HTTP/1.0 200 MSG\r\n' 338 | 'Access-Control-Allow-Origin: *\r\n' + 339 | 'Access-Control-Allow-Headers: *\r\n' + 340 | 'Content-Length: 16\r\n' + 341 | 'Access-Control-Allow-Methods: GET\r\n' + 342 | 'Content-Type: application/json\r\n\r\n', 343 | '{"name": "man1"}'] 344 | self.assertEqual(wrt.history, expected) 345 | self.assertTrue(wrt.closed) 346 | 347 | def testResourceDecorator2(self): 348 | rdr = mockReader(['GET /rest2/man2 HTTP/1.1\r\n', 349 | HDRE]) 350 | wrt = mockWriter() 351 | run_coro(server_for_decorators._handler(rdr, wrt)) 352 | expected = ['HTTP/1.1 200 MSG\r\n' + 353 | 'Access-Control-Allow-Methods: GET\r\n' + 354 | 'Connection: close\r\n' + 355 | 'Access-Control-Allow-Headers: *\r\n' + 356 | 'Content-Type: application/json\r\n' + 357 | 'Transfer-Encoding: chunked\r\n' + 358 | 'Access-Control-Allow-Origin: *\r\n\r\n', 359 | '11\r\n', 360 | '{"name": user_id}', 361 | '\r\n', 362 | '0\r\n\r\n' 363 | ] 364 | self.assertEqual(wrt.history, expected) 365 | self.assertTrue(wrt.closed) 366 | 367 | def testCatchAllDecorator(self): 368 | # A fresh server for the catchall handler 369 | server_for_catchall_decorator = webserver() 370 | 371 | # Catchall decorator and handler 372 | @server_for_catchall_decorator.catchall() 373 | async def route_for_catchall_decorator(req, resp): 374 | await resp.start_html() 375 | await resp.send('my404') 376 | 377 | rdr = mockReader(['GET /this/is/an/invalid/url HTTP/1.1\r\n', 378 | HDRE]) 379 | wrt = mockWriter() 380 | server_for_catchall_decorator.conns[id(1)] = None 381 | run_coro(server_for_catchall_decorator._handler(rdr, wrt)) 382 | expected = ['HTTP/1.0 200 MSG\r\n' + 383 | 'Content-Type: text/html\r\n\r\n', 384 | 'my404'] 385 | self.assertEqual(wrt.history, expected) 386 | self.assertTrue(wrt.closed) 387 | 388 | async def dummy_handler(self, req, resp): 389 | """Dummy URL handler. It just records the fact - it has been called""" 390 | self.dummy_req = req 391 | self.dummy_resp = resp 392 | self.dummy_called = True 393 | 394 | async def dummy_post_handler(self, req, resp): 395 | self.data = await req.read_parse_form_data() 396 | 397 | async def hello_world_handler(self, req, resp): 398 | await resp.start_html() 399 | await resp.send('

Hello world

') 400 | 401 | async def redirect_handler(self, req, resp): 402 | await resp.redirect('/blahblah', msg='msg:)') 403 | 404 | def testStartHTML(self): 405 | """Verify that request.start_html() works well""" 406 | self.srv.add_route('/', self.hello_world_handler) 407 | rdr = mockReader(['GET / HTTP/1.1\r\n', 408 | HDR('Host: blah.com'), 409 | HDRE]) 410 | wrt = mockWriter() 411 | # "Send" request 412 | run_coro(self.srv._handler(rdr, wrt)) 413 | # Ensure that proper response "sent" 414 | self.assertEqual(wrt.history, self.hello_world_history) 415 | self.assertTrue(wrt.closed) 416 | 417 | def testRedirect(self): 418 | """Verify that request.start_html() works well""" 419 | self.srv.add_route('/', self.redirect_handler) 420 | rdr = mockReader(['GET / HTTP/1.1\r\n', 421 | HDR('Host: blah.com'), 422 | HDRE]) 423 | wrt = mockWriter() 424 | # "Send" request 425 | run_coro(self.srv._handler(rdr, wrt)) 426 | # Ensure that proper response "sent" 427 | exp = ['HTTP/1.0 302 MSG\r\n' + 428 | 'Location: /blahblah\r\nContent-Length: 5\r\n\r\n', 429 | 'msg:)'] 430 | self.assertEqual(wrt.history, exp) 431 | 432 | def testRequestBodyUnknownType(self): 433 | """Unknow HTTP body test - empty dict expected""" 434 | self.srv.add_route('/', self.dummy_post_handler, methods=['POST']) 435 | rdr = mockReader(['POST / HTTP/1.1\r\n', 436 | HDR('Host: blah.com'), 437 | HDR('Content-Length: 5'), 438 | HDRE, 439 | '12345']) 440 | wrt = mockWriter() 441 | run_coro(self.srv._handler(rdr, wrt)) 442 | # Check extracted POST body 443 | self.assertEqual(self.data, {}) 444 | 445 | def testRequestBodyJson(self): 446 | """JSON encoded POST body""" 447 | self.srv.add_route('/', 448 | self.dummy_post_handler, 449 | methods=['POST'], 450 | save_headers=['Content-Type', 'Content-Length']) 451 | rdr = mockReader(['POST / HTTP/1.1\r\n', 452 | HDR('Content-Type: application/json'), 453 | HDR('Content-Length: 10'), 454 | HDRE, 455 | '{"a": "b"}']) 456 | wrt = mockWriter() 457 | run_coro(self.srv._handler(rdr, wrt)) 458 | # Check parsed POST body 459 | self.assertEqual(self.data, {'a': 'b'}) 460 | 461 | def testRequestBodyUrlencoded(self): 462 | """Regular HTML form""" 463 | self.srv.add_route('/', 464 | self.dummy_post_handler, 465 | methods=['POST'], 466 | save_headers=['Content-Type', 'Content-Length']) 467 | rdr = mockReader(['POST / HTTP/1.1\r\n', 468 | HDR('Content-Type: application/x-www-form-urlencoded; charset=UTF-8'), 469 | HDR('Content-Length: 10'), 470 | HDRE, 471 | 'a=b&c=%20d']) 472 | wrt = mockWriter() 473 | run_coro(self.srv._handler(rdr, wrt)) 474 | # Check parsed POST body 475 | self.assertEqual(self.data, {'a': 'b', 'c': ' d'}) 476 | 477 | def testRequestBodyNegative(self): 478 | """Regular HTML form""" 479 | self.srv.add_route('/', 480 | self.dummy_post_handler, 481 | methods=['POST'], 482 | save_headers=['Content-Type', 'Content-Length']) 483 | rdr = mockReader(['POST / HTTP/1.1\r\n', 484 | HDR('Content-Type: application/json'), 485 | HDR('Content-Length: 9'), 486 | HDRE, 487 | 'some junk']) 488 | wrt = mockWriter() 489 | run_coro(self.srv._handler(rdr, wrt)) 490 | # payload broken - HTTP 400 expected 491 | self.assertEqual(wrt.history, ['HTTP/1.0 400 MSG\r\n\r\n']) 492 | 493 | def testRequestLargeBody(self): 494 | """Max Body size check""" 495 | self.srv.add_route('/', 496 | self.dummy_post_handler, 497 | methods=['POST'], 498 | save_headers=['Content-Type', 'Content-Length'], 499 | max_body_size=5) 500 | rdr = mockReader(['POST / HTTP/1.1\r\n', 501 | HDR('Content-Type: application/json'), 502 | HDR('Content-Length: 9'), 503 | HDRE, 504 | 'some junk']) 505 | wrt = mockWriter() 506 | run_coro(self.srv._handler(rdr, wrt)) 507 | # payload broken - HTTP 400 expected 508 | self.assertEqual(wrt.history, ['HTTP/1.0 413 MSG\r\n\r\n']) 509 | 510 | async def route_parameterized_handler(self, req, resp, user_name): 511 | await resp.start_html() 512 | await resp.send('Hello, {}'.format(user_name)) 513 | 514 | def testRouteParameterized(self): 515 | """Verify that route with params works fine""" 516 | self.srv.add_route('/db/', self.route_parameterized_handler) 517 | rdr = mockReader(['GET /db/user1 HTTP/1.1\r\n', 518 | HDR('Host: junk.com'), 519 | HDRE]) 520 | wrt = mockWriter() 521 | # "Send" request 522 | run_coro(self.srv._handler(rdr, wrt)) 523 | # Ensure that proper response "sent" 524 | expected = ['HTTP/1.0 200 MSG\r\n' + 525 | 'Content-Type: text/html\r\n\r\n', 526 | 'Hello, user1'] 527 | self.assertEqual(wrt.history, expected) 528 | self.assertTrue(wrt.closed) 529 | 530 | def testParseHeadersOnOff(self): 531 | """Verify parameter parse_headers works""" 532 | self.srv.add_route('/', self.dummy_handler, save_headers=['H1', 'H2']) 533 | rdr = mockReader(['GET / HTTP/1.1\r\n', 534 | HDR('H1: blah.com'), 535 | HDR('H2: lalalla'), 536 | HDR('Junk: fsdfmsdjfgjsdfjunk.com'), 537 | HDRE]) 538 | # "Send" request 539 | wrt = mockWriter() 540 | run_coro(self.srv._handler(rdr, wrt)) 541 | self.assertTrue(self.dummy_called) 542 | # Check for headers - only 2 of 3 should be collected, others - ignore 543 | hdrs = {b'H1': b'blah.com', 544 | b'H2': b'lalalla'} 545 | self.assertEqual(self.dummy_req.headers, hdrs) 546 | self.assertTrue(wrt.closed) 547 | 548 | def testDisallowedMethod(self): 549 | """Verify that server respects allowed methods""" 550 | self.srv.add_route('/', self.hello_world_handler) 551 | self.srv.add_route('/post_only', self.dummy_handler, methods=['POST']) 552 | rdr = mockReader(['GET / HTTP/1.0\r\n', 553 | HDRE]) 554 | # "Send" GET request, by default GET is enabled 555 | wrt = mockWriter() 556 | run_coro(self.srv._handler(rdr, wrt)) 557 | self.assertEqual(wrt.history, self.hello_world_history) 558 | self.assertTrue(wrt.closed) 559 | 560 | # "Send" GET request to POST only location 561 | self.srv.conns[id(1)] = None 562 | self.dummy_called = False 563 | rdr = mockReader(['GET /post_only HTTP/1.1\r\n', 564 | HDRE]) 565 | wrt = mockWriter() 566 | run_coro(self.srv._handler(rdr, wrt)) 567 | # Hanlder should not be called - method not allowed 568 | self.assertFalse(self.dummy_called) 569 | exp = ['HTTP/1.0 405 MSG\r\n\r\n'] 570 | self.assertEqual(wrt.history, exp) 571 | # Connection must be closed 572 | self.assertTrue(wrt.closed) 573 | 574 | def testAutoOptionsMethod(self): 575 | """Test auto implementation of OPTIONS method""" 576 | self.srv.add_route('/', self.hello_world_handler, methods=['POST', 'PUT', 'DELETE']) 577 | self.srv.add_route('/disabled', self.hello_world_handler, auto_method_options=False) 578 | rdr = mockReader(['OPTIONS / HTTP/1.0\r\n', 579 | HDRE]) 580 | wrt = mockWriter() 581 | run_coro(self.srv._handler(rdr, wrt)) 582 | 583 | exp = ['HTTP/1.0 200 MSG\r\n' + 584 | 'Access-Control-Allow-Headers: *\r\n' 585 | 'Content-Length: 0\r\n' 586 | 'Access-Control-Allow-Origin: *\r\n' 587 | 'Access-Control-Allow-Methods: POST, PUT, DELETE\r\n\r\n'] 588 | self.assertEqual(wrt.history, exp) 589 | self.assertTrue(wrt.closed) 590 | 591 | def testPageNotFound(self): 592 | """Verify that malformed request generates proper response""" 593 | rdr = mockReader(['GET /not_existing HTTP/1.1\r\n', 594 | HDR('Host: blah.com'), 595 | HDRE]) 596 | wrt = mockWriter() 597 | run_coro(self.srv._handler(rdr, wrt)) 598 | exp = ['HTTP/1.0 404 MSG\r\n\r\n'] 599 | self.assertEqual(wrt.history, exp) 600 | # Connection must be closed 601 | self.assertTrue(wrt.closed) 602 | 603 | def testMalformedRequest(self): 604 | """Verify that malformed request generates proper response""" 605 | rdr = mockReader(['GET /\r\n', 606 | HDR('Host: blah.com'), 607 | HDRE]) 608 | wrt = mockWriter() 609 | run_coro(self.srv._handler(rdr, wrt)) 610 | exp = ['HTTP/1.0 400 MSG\r\n\r\n'] 611 | self.assertEqual(wrt.history, exp) 612 | # Connection must be closed 613 | self.assertTrue(wrt.closed) 614 | 615 | 616 | class ResourceGetPost(): 617 | """Simple REST API resource class with just two methods""" 618 | 619 | def get(self, data): 620 | return {'data1': 'junk'} 621 | 622 | def post(self, data): 623 | return data 624 | 625 | 626 | class ResourceGetParam(): 627 | """Parameterized REST API resource""" 628 | 629 | def __init__(self): 630 | self.user_id = 'user_id' 631 | 632 | def get(self, data, user_id): 633 | return {self.user_id: user_id} 634 | 635 | 636 | class ResourceGetArgs(): 637 | """REST API resource with additional arguments""" 638 | 639 | def get(self, data, arg1, arg2): 640 | return {'arg1': arg1, 'arg2': arg2} 641 | 642 | 643 | class ResourceGenerator(): 644 | """REST API with generator as result""" 645 | 646 | async def get(self, data): 647 | yield 'longlongchunkchunk1' 648 | yield 'chunk2' 649 | # unicode support 650 | yield '\u265E' 651 | 652 | 653 | class ResourceNegative(): 654 | """To cover negative test cases""" 655 | 656 | def delete(self, data): 657 | # Broken pipe emulation 658 | raise OSError(32, '', '') 659 | 660 | def put(self, data): 661 | # Simple unhandled expection 662 | raise Exception('something') 663 | 664 | 665 | class ServerResource(unittest.TestCase): 666 | 667 | def setUp(self): 668 | self.srv = webserver() 669 | self.srv.conns[id(1)] = None 670 | self.srv.add_resource(ResourceGetPost, '/') 671 | self.srv.add_resource(ResourceGetParam, '/param/') 672 | self.srv.add_resource(ResourceGetArgs, '/args', arg1=1, arg2=2) 673 | self.srv.add_resource(ResourceGenerator, '/gen') 674 | self.srv.add_resource(ResourceNegative, '/negative') 675 | 676 | def testOptions(self): 677 | # Ensure that only GET/POST methods are allowed: 678 | rdr = mockReader(['OPTIONS / HTTP/1.0\r\n', 679 | HDRE]) 680 | wrt = mockWriter() 681 | run_coro(self.srv._handler(rdr, wrt)) 682 | exp = ['HTTP/1.0 200 MSG\r\n' + 683 | 'Access-Control-Allow-Headers: *\r\n' 684 | 'Content-Length: 0\r\n' 685 | 'Access-Control-Allow-Origin: *\r\n' 686 | 'Access-Control-Allow-Methods: GET, POST\r\n\r\n'] 687 | self.assertEqual(wrt.history, exp) 688 | 689 | def testGet(self): 690 | rdr = mockReader(['GET / HTTP/1.0\r\n', 691 | HDRE]) 692 | wrt = mockWriter() 693 | run_coro(self.srv._handler(rdr, wrt)) 694 | exp = ['HTTP/1.0 200 MSG\r\n' + 695 | 'Access-Control-Allow-Origin: *\r\n' 696 | 'Access-Control-Allow-Headers: *\r\n' 697 | 'Content-Length: 17\r\n' 698 | 'Access-Control-Allow-Methods: GET, POST\r\n' 699 | 'Content-Type: application/json\r\n\r\n', 700 | '{"data1": "junk"}'] 701 | self.assertEqual(wrt.history, exp) 702 | 703 | def testGetWithParam(self): 704 | rdr = mockReader(['GET /param/123 HTTP/1.0\r\n', 705 | HDRE]) 706 | wrt = mockWriter() 707 | run_coro(self.srv._handler(rdr, wrt)) 708 | exp = ['HTTP/1.0 200 MSG\r\n' + 709 | 'Access-Control-Allow-Origin: *\r\n' 710 | 'Access-Control-Allow-Headers: *\r\n' 711 | 'Content-Length: 18\r\n' 712 | 'Access-Control-Allow-Methods: GET\r\n' 713 | 'Content-Type: application/json\r\n\r\n', 714 | '{"user_id": "123"}'] 715 | self.assertEqual(wrt.history, exp) 716 | 717 | def testGetWithArgs(self): 718 | rdr = mockReader(['GET /args HTTP/1.0\r\n', 719 | HDRE]) 720 | wrt = mockWriter() 721 | run_coro(self.srv._handler(rdr, wrt)) 722 | exp = ['HTTP/1.0 200 MSG\r\n' + 723 | 'Access-Control-Allow-Origin: *\r\n' 724 | 'Access-Control-Allow-Headers: *\r\n' 725 | 'Content-Length: 22\r\n' 726 | 'Access-Control-Allow-Methods: GET\r\n' 727 | 'Content-Type: application/json\r\n\r\n', 728 | '{"arg1": 1, "arg2": 2}'] 729 | self.assertEqual(wrt.history, exp) 730 | 731 | def testGenerator(self): 732 | rdr = mockReader(['GET /gen HTTP/1.0\r\n', 733 | HDRE]) 734 | wrt = mockWriter() 735 | run_coro(self.srv._handler(rdr, wrt)) 736 | exp = ['HTTP/1.1 200 MSG\r\n' + 737 | 'Access-Control-Allow-Methods: GET\r\n' + 738 | 'Connection: close\r\n' + 739 | 'Access-Control-Allow-Headers: *\r\n' + 740 | 'Content-Type: application/json\r\n' + 741 | 'Transfer-Encoding: chunked\r\n' + 742 | 'Access-Control-Allow-Origin: *\r\n\r\n', 743 | '13\r\n', 744 | 'longlongchunkchunk1', 745 | '\r\n', 746 | '6\r\n', 747 | 'chunk2', 748 | '\r\n', 749 | # next chunk is 1 char len UTF-8 string 750 | '3\r\n', 751 | '\u265E', 752 | '\r\n', 753 | '0\r\n\r\n'] 754 | self.assertEqual(wrt.history, exp) 755 | 756 | def testPost(self): 757 | # Ensure that parameters from query string / body will be combined as well 758 | rdr = mockReader(['POST /?qs=qs1 HTTP/1.0\r\n', 759 | HDR('Content-Length: 17'), 760 | HDR('Content-Type: application/json'), 761 | HDRE, 762 | '{"body": "body1"}']) 763 | wrt = mockWriter() 764 | run_coro(self.srv._handler(rdr, wrt)) 765 | exp = ['HTTP/1.0 200 MSG\r\n' + 766 | 'Access-Control-Allow-Origin: *\r\n' 767 | 'Access-Control-Allow-Headers: *\r\n' 768 | 'Content-Length: 30\r\n' 769 | 'Access-Control-Allow-Methods: GET, POST\r\n' 770 | 'Content-Type: application/json\r\n\r\n', 771 | '{"qs": "qs1", "body": "body1"}'] 772 | self.assertEqual(wrt.history, exp) 773 | 774 | def testInvalidMethod(self): 775 | rdr = mockReader(['PUT / HTTP/1.0\r\n', 776 | HDRE]) 777 | wrt = mockWriter() 778 | run_coro(self.srv._handler(rdr, wrt)) 779 | exp = ['HTTP/1.0 405 MSG\r\n\r\n'] 780 | self.assertEqual(wrt.history, exp) 781 | 782 | def testException(self): 783 | rdr = mockReader(['PUT /negative HTTP/1.0\r\n', 784 | HDRE]) 785 | wrt = mockWriter() 786 | run_coro(self.srv._handler(rdr, wrt)) 787 | exp = ['HTTP/1.0 500 MSG\r\n\r\n'] 788 | self.assertEqual(wrt.history, exp) 789 | 790 | def testBrokenPipe(self): 791 | rdr = mockReader(['DELETE /negative HTTP/1.0\r\n', 792 | HDRE]) 793 | wrt = mockWriter() 794 | run_coro(self.srv._handler(rdr, wrt)) 795 | self.assertEqual(wrt.history, []) 796 | 797 | 798 | class StaticContent(unittest.TestCase): 799 | 800 | def setUp(self): 801 | self.srv = webserver() 802 | self.srv.conns[id(1)] = None 803 | self.tempfn = '__tmp.html' 804 | self.ctype = None 805 | self.etype = None 806 | self.max_age = 2592000 807 | with open(self.tempfn, 'wb') as f: 808 | f.write('someContent blah blah') 809 | 810 | def tearDown(self): 811 | try: 812 | delete_file(self.tempfn) 813 | except OSError: 814 | pass 815 | 816 | async def send_file_handler(self, req, resp): 817 | await resp.send_file(self.tempfn, 818 | content_type=self.ctype, 819 | content_encoding=self.etype, 820 | max_age=self.max_age) 821 | 822 | def testSendFileManual(self): 823 | """Verify send_file works great with manually defined parameters""" 824 | self.ctype = 'text/plain' 825 | self.etype = 'gzip' 826 | self.max_age = 100 827 | self.srv.add_route('/', self.send_file_handler) 828 | rdr = mockReader(['GET / HTTP/1.0\r\n', 829 | HDRE]) 830 | wrt = mockWriter() 831 | run_coro(self.srv._handler(rdr, wrt)) 832 | 833 | exp = ['HTTP/1.0 200 MSG\r\n' + 834 | 'Cache-Control: max-age=100, public\r\n' 835 | 'Content-Type: text/plain\r\n' 836 | 'Content-Length: 21\r\n' 837 | 'Content-Encoding: gzip\r\n\r\n', 838 | bytearray(b'someContent blah blah')] 839 | self.assertEqual(wrt.history, exp) 840 | self.assertTrue(wrt.closed) 841 | 842 | def testSendFileNotFound(self): 843 | """Verify 404 error for non existing files""" 844 | self.srv.add_route('/', self.send_file_handler) 845 | rdr = mockReader(['GET / HTTP/1.0\r\n', 846 | HDRE]) 847 | wrt = mockWriter() 848 | 849 | # Intentionally delete file before request 850 | delete_file(self.tempfn) 851 | run_coro(self.srv._handler(rdr, wrt)) 852 | 853 | exp = ['HTTP/1.0 404 MSG\r\n\r\n'] 854 | self.assertEqual(wrt.history, exp) 855 | self.assertTrue(wrt.closed) 856 | 857 | def testSendFileConnectionReset(self): 858 | self.srv.add_route('/', self.send_file_handler) 859 | rdr = mockReader(['GET / HTTP/1.0\r\n', 860 | HDRE]) 861 | # tell mockWrite to raise error during send() 862 | wrt = mockWriter(generate_expection=OSError(errno.ECONNRESET)) 863 | 864 | run_coro(self.srv._handler(rdr, wrt)) 865 | 866 | # there should be no payload due to connected reset 867 | self.assertEqual(wrt.history, []) 868 | self.assertTrue(wrt.closed) 869 | 870 | 871 | if __name__ == '__main__': 872 | unittest.main() 873 | -------------------------------------------------------------------------------- /tinyweb/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tiny Web - pretty simple and powerful web server for tiny platforms like ESP8266 / ESP32 3 | MIT license 4 | (C) Konstantin Belyalov 2017-2018 5 | """ 6 | from .server import webserver 7 | -------------------------------------------------------------------------------- /tinyweb/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tiny Web - pretty simple and powerful web server for tiny platforms like ESP8266 / ESP32 3 | MIT license 4 | (C) Konstantin Belyalov 2017-2018 5 | """ 6 | import logging 7 | import asyncio 8 | import ujson as json 9 | import gc 10 | import uos as os 11 | import sys 12 | import uerrno as errno 13 | import usocket as socket 14 | 15 | 16 | log = logging.getLogger('WEB') 17 | 18 | type_gen = type((lambda: (yield))()) 19 | 20 | # with v1.21.0 release all u-modules where renamend without the u prefix 21 | # -> uasyncio no named asyncio 22 | # asyncio v3 is shipped with MicroPython 1.13, and contains some subtle 23 | # but breaking changes. See also https://github.com/peterhinch/micropython-async/blob/master/v3/README.md 24 | IS_ASYNCIO_V3 = hasattr(asyncio, "__version__") and asyncio.__version__ >= (3,) 25 | 26 | 27 | def urldecode_plus(s): 28 | """Decode urlencoded string (including '+' char). 29 | 30 | Returns decoded string 31 | """ 32 | s = s.replace('+', ' ') 33 | arr = s.split('%') 34 | res = arr[0] 35 | for it in arr[1:]: 36 | if len(it) >= 2: 37 | res += chr(int(it[:2], 16)) + it[2:] 38 | elif len(it) == 0: 39 | res += '%' 40 | else: 41 | res += it 42 | return res 43 | 44 | 45 | def parse_query_string(s): 46 | """Parse urlencoded string into dict. 47 | 48 | Returns dict 49 | """ 50 | res = {} 51 | pairs = s.split('&') 52 | for p in pairs: 53 | vals = [urldecode_plus(x) for x in p.split('=', 1)] 54 | if len(vals) == 1: 55 | res[vals[0]] = '' 56 | else: 57 | res[vals[0]] = vals[1] 58 | return res 59 | 60 | 61 | class HTTPException(Exception): 62 | """HTTP protocol exceptions""" 63 | 64 | def __init__(self, code=400): 65 | self.code = code 66 | 67 | 68 | class request: 69 | """HTTP Request class""" 70 | 71 | def __init__(self, _reader): 72 | self.reader = _reader 73 | self.headers = {} 74 | self.method = b'' 75 | self.path = b'' 76 | self.query_string = b'' 77 | 78 | async def read_request_line(self): 79 | """Read and parse first line (AKA HTTP Request Line). 80 | Function is generator. 81 | 82 | Request line is something like: 83 | GET /something/script?param1=val1 HTTP/1.1 84 | """ 85 | while True: 86 | rl = await self.reader.readline() 87 | # skip empty lines 88 | if rl == b'\r\n' or rl == b'\n': 89 | continue 90 | break 91 | rl_frags = rl.split() 92 | if len(rl_frags) != 3: 93 | raise HTTPException(400) 94 | self.method = rl_frags[0] 95 | url_frags = rl_frags[1].split(b'?', 1) 96 | self.path = url_frags[0] 97 | if len(url_frags) > 1: 98 | self.query_string = url_frags[1] 99 | 100 | async def read_headers(self, save_headers=[]): 101 | """Read and parse HTTP headers until \r\n\r\n: 102 | Optional argument 'save_headers' controls which headers to save. 103 | This is done mostly to deal with memory constrains. 104 | 105 | Function is generator. 106 | 107 | HTTP headers could be like: 108 | Host: google.com 109 | Content-Type: blah 110 | \r\n 111 | """ 112 | while True: 113 | gc.collect() 114 | line = await self.reader.readline() 115 | if line == b'\r\n': 116 | break 117 | frags = line.split(b':', 1) 118 | if len(frags) != 2: 119 | raise HTTPException(400) 120 | if frags[0].lower() in save_headers: 121 | self.headers[frags[0]] = frags[1].strip() 122 | 123 | async def read_parse_form_data(self): 124 | """Read HTTP form data (payload), if any. 125 | Function is generator. 126 | 127 | Returns: 128 | - dict of key / value pairs 129 | - None in case of no form data present 130 | """ 131 | # TODO: Probably there is better solution how to handle 132 | # request body, at least for simple urlencoded forms - by processing 133 | # chunks instead of accumulating payload. 134 | gc.collect() 135 | if b'Content-Length' not in self.headers: 136 | return {} 137 | # Parse payload depending on content type 138 | if b'Content-Type' not in self.headers: 139 | # Unknown content type, return unparsed, raw data 140 | return {} 141 | size = int(self.headers[b'Content-Length']) 142 | if size > self.params['max_body_size'] or size < 0: 143 | raise HTTPException(413) 144 | data = await self.reader.readexactly(size) 145 | # Use only string before ';', e.g: 146 | # application/x-www-form-urlencoded; charset=UTF-8 147 | ct = self.headers[b'Content-Type'].split(b';', 1)[0] 148 | try: 149 | if ct == b'application/json': 150 | return json.loads(data) 151 | elif ct == b'application/x-www-form-urlencoded': 152 | return parse_query_string(data.decode()) 153 | except ValueError: 154 | # Re-generate exception for malformed form data 155 | raise HTTPException(400) 156 | 157 | 158 | class response: 159 | """HTTP Response class""" 160 | 161 | def __init__(self, _writer): 162 | self.writer = _writer 163 | self.send = _writer.awrite 164 | self.code = 200 165 | self.version = '1.0' 166 | self.headers = {} 167 | 168 | async def _send_headers(self): 169 | """Compose and send: 170 | - HTTP request line 171 | - HTTP headers following by \r\n. 172 | This function is generator. 173 | 174 | P.S. 175 | Because of usually we have only a few HTTP headers (2-5) it doesn't make sense 176 | to send them separately - sometimes it could increase latency. 177 | So combining headers together and send them as single "packet". 178 | """ 179 | # Request line 180 | hdrs = 'HTTP/{} {} MSG\r\n'.format(self.version, self.code) 181 | # Headers 182 | for k, v in self.headers.items(): 183 | hdrs += '{}: {}\r\n'.format(k, v) 184 | hdrs += '\r\n' 185 | # Collect garbage after small mallocs 186 | gc.collect() 187 | await self.send(hdrs) 188 | 189 | async def error(self, code, msg=None): 190 | """Generate HTTP error response 191 | This function is generator. 192 | 193 | Arguments: 194 | code - HTTP response code 195 | 196 | Example: 197 | # Not enough permissions. Send HTTP 403 - Forbidden 198 | await resp.error(403) 199 | """ 200 | self.code = code 201 | if msg: 202 | self.add_header('Content-Length', len(msg)) 203 | await self._send_headers() 204 | if msg: 205 | await self.send(msg) 206 | 207 | async def redirect(self, location, msg=None): 208 | """Generate HTTP redirect response to 'location'. 209 | Basically it will generate HTTP 302 with 'Location' header 210 | 211 | Arguments: 212 | location - URL to redirect to 213 | 214 | Example: 215 | # Redirect to /something 216 | await resp.redirect('/something') 217 | """ 218 | self.code = 302 219 | self.add_header('Location', location) 220 | if msg: 221 | self.add_header('Content-Length', len(msg)) 222 | await self._send_headers() 223 | if msg: 224 | await self.send(msg) 225 | 226 | def add_header(self, key, value): 227 | """Add HTTP response header 228 | 229 | Arguments: 230 | key - header name 231 | value - header value 232 | 233 | Example: 234 | resp.add_header('Content-Encoding', 'gzip') 235 | """ 236 | self.headers[key] = value 237 | 238 | def add_access_control_headers(self): 239 | """Add Access Control related HTTP response headers. 240 | This is required when working with RestApi (JSON requests) 241 | """ 242 | self.add_header('Access-Control-Allow-Origin', self.params['allowed_access_control_origins']) 243 | self.add_header('Access-Control-Allow-Methods', self.params['allowed_access_control_methods']) 244 | self.add_header('Access-Control-Allow-Headers', self.params['allowed_access_control_headers']) 245 | 246 | async def start_html(self): 247 | """Start response with HTML content type. 248 | This function is generator. 249 | 250 | Example: 251 | await resp.start_html() 252 | await resp.send('

Hello, world!

') 253 | """ 254 | self.add_header('Content-Type', 'text/html') 255 | await self._send_headers() 256 | 257 | async def send_file(self, filename, content_type=None, content_encoding=None, max_age=2592000, buf_size=128): 258 | """Send local file as HTTP response. 259 | This function is generator. 260 | 261 | Arguments: 262 | filename - Name of file which exists in local filesystem 263 | Keyword arguments: 264 | content_type - Filetype. By default - None means auto-detect. 265 | max_age - Cache control. How long browser can keep this file on disk. 266 | By default - 30 days 267 | Set to 0 - to disable caching. 268 | 269 | Example 1: Default use case: 270 | await resp.send_file('images/cat.jpg') 271 | 272 | Example 2: Disable caching: 273 | await resp.send_file('static/index.html', max_age=0) 274 | 275 | Example 3: Override content type: 276 | await resp.send_file('static/file.bin', content_type='application/octet-stream') 277 | """ 278 | try: 279 | # Get file size 280 | stat = os.stat(filename) 281 | slen = str(stat[6]) 282 | self.add_header('Content-Length', slen) 283 | # Find content type 284 | if content_type: 285 | self.add_header('Content-Type', content_type) 286 | # Add content-encoding, if any 287 | if content_encoding: 288 | self.add_header('Content-Encoding', content_encoding) 289 | # Since this is static content is totally make sense 290 | # to tell browser to cache it, however, you can always 291 | # override it by setting max_age to zero 292 | self.add_header('Cache-Control', 'max-age={}, public'.format(max_age)) 293 | with open(filename) as f: 294 | await self._send_headers() 295 | gc.collect() 296 | buf = bytearray(min(stat[6], buf_size)) 297 | while True: 298 | size = f.readinto(buf) 299 | if size == 0: 300 | break 301 | await self.send(buf, sz=size) 302 | except OSError as e: 303 | # special handling for ENOENT / EACCESS 304 | if e.args[0] in (errno.ENOENT, errno.EACCES): 305 | raise HTTPException(404) 306 | else: 307 | raise 308 | 309 | 310 | async def restful_resource_handler(req, resp, param=None): 311 | """Handler for RESTful API endpoins""" 312 | # Gather data - query string, JSON in request body... 313 | data = await req.read_parse_form_data() 314 | # Add parameters from URI query string as well 315 | # This one is actually for simply development of RestAPI 316 | if req.query_string != b'': 317 | data.update(parse_query_string(req.query_string.decode())) 318 | # Call actual handler 319 | _handler, _kwargs = req.params['_callmap'][req.method] 320 | # Collect garbage before / after handler execution 321 | gc.collect() 322 | if param: 323 | res = _handler(data, param, **_kwargs) 324 | else: 325 | res = _handler(data, **_kwargs) 326 | gc.collect() 327 | # Handler result could be: 328 | # 1. generator - in case of large payload 329 | # 2. string - just string :) 330 | # 2. dict - meaning client what tinyweb to convert it to JSON 331 | # it can also return error code together with str / dict 332 | # res = {'blah': 'blah'} 333 | # res = {'blah': 'blah'}, 201 334 | if isinstance(res, type_gen): 335 | # Result is generator, use chunked response 336 | # NOTICE: HTTP 1.0 by itself does not support chunked responses, so, making workaround: 337 | # Response is HTTP/1.1 with Connection: close 338 | resp.version = '1.1' 339 | resp.add_header('Connection', 'close') 340 | resp.add_header('Content-Type', 'application/json') 341 | resp.add_header('Transfer-Encoding', 'chunked') 342 | resp.add_access_control_headers() 343 | await resp._send_headers() 344 | # Drain generator 345 | for chunk in res: 346 | chunk_len = len(chunk.encode('utf-8')) 347 | await resp.send('{:x}\r\n'.format(chunk_len)) 348 | await resp.send(chunk) 349 | await resp.send('\r\n') 350 | gc.collect() 351 | await resp.send('0\r\n\r\n') 352 | else: 353 | if type(res) == tuple: 354 | resp.code = res[1] 355 | res = res[0] 356 | elif res is None: 357 | raise Exception('Result expected') 358 | # Send response 359 | if type(res) is dict: 360 | res_str = json.dumps(res) 361 | else: 362 | res_str = res 363 | resp.add_header('Content-Type', 'application/json') 364 | resp.add_header('Content-Length', str(len(res_str))) 365 | resp.add_access_control_headers() 366 | await resp._send_headers() 367 | await resp.send(res_str) 368 | 369 | 370 | class webserver: 371 | 372 | def __init__(self, request_timeout=3, max_concurrency=3, backlog=16, debug=False): 373 | """Tiny Web Server class. 374 | Keyword arguments: 375 | request_timeout - Time for client to send complete request 376 | after that connection will be closed. 377 | max_concurrency - How many connections can be processed concurrently. 378 | It is very important to limit this number because of 379 | memory constrain. 380 | Default value depends on platform 381 | backlog - Parameter to socket.listen() function. Defines size of 382 | pending to be accepted connections queue. 383 | Must be greater than max_concurrency 384 | debug - Whether send exception info (text + backtrace) 385 | to client together with HTTP 500 or not. 386 | """ 387 | self.loop = asyncio.get_event_loop() 388 | self.request_timeout = request_timeout 389 | self.max_concurrency = max_concurrency 390 | self.backlog = backlog 391 | self.debug = debug 392 | self.explicit_url_map = {} 393 | self.catch_all_handler = None 394 | self.parameterized_url_map = {} 395 | # Currently opened connections 396 | self.conns = {} 397 | # Statistics 398 | self.processed_connections = 0 399 | 400 | def _find_url_handler(self, req): 401 | """Helper to find URL handler. 402 | Returns tuple of (function, opts, param) or (None, None) if not found. 403 | """ 404 | # First try - lookup in explicit (non parameterized URLs) 405 | if req.path in self.explicit_url_map: 406 | return self.explicit_url_map[req.path] 407 | # Second try - strip last path segment and lookup in another map 408 | idx = req.path.rfind(b'/') + 1 409 | path2 = req.path[:idx] 410 | if len(path2) > 0 and path2 in self.parameterized_url_map: 411 | # Save parameter into request 412 | req._param = req.path[idx:].decode() 413 | return self.parameterized_url_map[path2] 414 | 415 | if self.catch_all_handler: 416 | return self.catch_all_handler 417 | 418 | # No handler found 419 | return (None, None) 420 | 421 | async def _handle_request(self, req, resp): 422 | await req.read_request_line() 423 | # Find URL handler 424 | req.handler, req.params = self._find_url_handler(req) 425 | if not req.handler: 426 | # No URL handler found - read response and issue HTTP 404 427 | await req.read_headers() 428 | raise HTTPException(404) 429 | # req.params = params 430 | # req.handler = han 431 | resp.params = req.params 432 | # Read / parse headers 433 | await req.read_headers(req.params['save_headers']) 434 | 435 | async def _handler(self, reader, writer): 436 | """Handler for TCP connection with 437 | HTTP/1.0 protocol implementation 438 | """ 439 | gc.collect() 440 | 441 | try: 442 | req = request(reader) 443 | resp = response(writer) 444 | # Read HTTP Request with timeout 445 | await asyncio.wait_for(self._handle_request(req, resp), 446 | self.request_timeout) 447 | 448 | # OPTIONS method is handled automatically 449 | if req.method == b'OPTIONS': 450 | resp.add_access_control_headers() 451 | # Since we support only HTTP 1.0 - it is important 452 | # to tell browser that there is no payload expected 453 | # otherwise some webkit based browsers (Chrome) 454 | # treat this behavior as an error 455 | resp.add_header('Content-Length', '0') 456 | await resp._send_headers() 457 | return 458 | 459 | # Ensure that HTTP method is allowed for this path 460 | if req.method not in req.params['methods']: 461 | raise HTTPException(405) 462 | 463 | # Handle URL 464 | gc.collect() 465 | if hasattr(req, '_param'): 466 | await req.handler(req, resp, req._param) 467 | else: 468 | await req.handler(req, resp) 469 | # Done here 470 | except (asyncio.CancelledError, asyncio.TimeoutError): 471 | pass 472 | except OSError as e: 473 | # Do not send response for connection related errors - too late :) 474 | # P.S. code 32 - is possible BROKEN PIPE error (TODO: is it true?) 475 | if e.args[0] not in (errno.ECONNABORTED, errno.ECONNRESET, 32): 476 | try: 477 | await resp.error(500) 478 | except Exception as e: 479 | log.exception(f"Failed to send 500 error after OSError. Original error: {e}") 480 | except HTTPException as e: 481 | try: 482 | await resp.error(e.code) 483 | except Exception as e: 484 | log.exception(f"Failed to send error after HTTPException. Original error: {e}") 485 | except Exception as e: 486 | # Unhandled expection in user's method 487 | log.error(req.path.decode()) 488 | log.exception(f"Unhandled exception in user's method. Original error: {e}") 489 | try: 490 | await resp.error(500) 491 | # Send exception info if desired 492 | if self.debug: 493 | sys.print_exception(e, resp.writer.s) 494 | except Exception as e: 495 | pass 496 | finally: 497 | await writer.aclose() 498 | # Max concurrency support - 499 | # if queue is full schedule resume of TCP server task 500 | if len(self.conns) == self.max_concurrency: 501 | self.loop.create_task(self._server_coro) 502 | # Delete connection, using socket as a key 503 | del self.conns[id(writer.s)] 504 | 505 | def add_route(self, url, f, **kwargs): 506 | """Add URL to function mapping. 507 | 508 | Arguments: 509 | url - url to map function with 510 | f - function to map 511 | 512 | Keyword arguments: 513 | methods - list of allowed methods. Defaults to ['GET', 'POST'] 514 | save_headers - contains list of HTTP headers to be saved. Case sensitive. Default - empty. 515 | max_body_size - Max HTTP body size (e.g. POST form data). Defaults to 1024 516 | allowed_access_control_headers - Default value for the same name header. Defaults to * 517 | allowed_access_control_origins - Default value for the same name header. Defaults to * 518 | """ 519 | if url == '' or '?' in url: 520 | raise ValueError('Invalid URL') 521 | # Initial params for route 522 | params = {'methods': ['GET'], 523 | 'save_headers': [], 524 | 'max_body_size': 1024, 525 | 'allowed_access_control_headers': '*', 526 | 'allowed_access_control_origins': '*', 527 | } 528 | params.update(kwargs) 529 | params['allowed_access_control_methods'] = ', '.join(params['methods']) 530 | # Convert methods/headers to bytestring 531 | params['methods'] = [x.encode().upper() for x in params['methods']] 532 | params['save_headers'] = [x.encode().lower() for x in params['save_headers']] 533 | # If URL has a parameter 534 | if url.endswith('>'): 535 | idx = url.rfind('<') 536 | path = url[:idx] 537 | idx += 1 538 | param = url[idx:-1] 539 | if path.encode() in self.parameterized_url_map: 540 | raise ValueError('URL exists') 541 | params['_param_name'] = param 542 | self.parameterized_url_map[path.encode()] = (f, params) 543 | 544 | if url.encode() in self.explicit_url_map: 545 | raise ValueError('URL exists') 546 | self.explicit_url_map[url.encode()] = (f, params) 547 | 548 | def add_resource(self, cls, url, **kwargs): 549 | """Map resource (RestAPI) to URL 550 | 551 | Arguments: 552 | cls - Resource class to map to 553 | url - url to map to class 554 | kwargs - User defined key args to pass to the handler. 555 | 556 | Example: 557 | class myres(): 558 | def get(self, data): 559 | return {'hello': 'world'} 560 | 561 | 562 | app.add_resource(myres, '/api/myres') 563 | """ 564 | methods = [] 565 | callmap = {} 566 | # Create instance of resource handler, if passed as just class (not instance) 567 | try: 568 | obj = cls() 569 | except TypeError: 570 | obj = cls 571 | # Get all implemented HTTP methods and make callmap 572 | for m in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']: 573 | fn = m.lower() 574 | if hasattr(obj, fn): 575 | methods.append(m) 576 | callmap[m.encode()] = (getattr(obj, fn), kwargs) 577 | self.add_route(url, restful_resource_handler, 578 | methods=methods, 579 | save_headers=['Content-Length', 'Content-Type'], 580 | _callmap=callmap) 581 | 582 | def catchall(self): 583 | """Decorator for catchall() 584 | 585 | Example: 586 | @app.catchall() 587 | def catchall_handler(req, resp): 588 | response.code = 404 589 | await response.start_html() 590 | await response.send('

My custom 404!

\n') 591 | """ 592 | params = {'methods': [b'GET'], 'save_headers': [], 'max_body_size': 1024, 'allowed_access_control_headers': '*', 'allowed_access_control_origins': '*'} 593 | 594 | def _route(f): 595 | self.catch_all_handler = (f, params) 596 | return f 597 | return _route 598 | 599 | def route(self, url, **kwargs): 600 | """Decorator for add_route() 601 | 602 | Example: 603 | @app.route('/') 604 | def index(req, resp): 605 | await resp.start_html() 606 | await resp.send('

Hello, world!

\n') 607 | """ 608 | def _route(f): 609 | self.add_route(url, f, **kwargs) 610 | return f 611 | return _route 612 | 613 | def resource(self, url, method='GET', **kwargs): 614 | """Decorator for add_resource() method 615 | 616 | Examples: 617 | @app.resource('/users') 618 | def users(data): 619 | return {'a': 1} 620 | 621 | @app.resource('/messages/') 622 | async def index(data, topic_id): 623 | yield '{' 624 | yield '"topic_id": "{}",'.format(topic_id) 625 | yield '"message": "test",' 626 | yield '}' 627 | """ 628 | def _resource(f): 629 | self.add_route(url, restful_resource_handler, 630 | methods=[method], 631 | save_headers=['Content-Length', 'Content-Type'], 632 | _callmap={method.encode(): (f, kwargs)}) 633 | return f 634 | return _resource 635 | 636 | async def _tcp_server(self, host, port, backlog): 637 | """TCP Server implementation. 638 | Opens socket for accepting connection and 639 | creates task for every new accepted connection 640 | """ 641 | addr = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0][-1] 642 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 643 | sock.setblocking(False) 644 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 645 | sock.bind(addr) 646 | sock.listen(backlog) 647 | try: 648 | while True: 649 | if IS_ASYNCIO_V3: 650 | yield asyncio.core._io_queue.queue_read(sock) 651 | else: 652 | yield asyncio.IORead(sock) 653 | csock, caddr = sock.accept() 654 | csock.setblocking(False) 655 | # Start handler / keep it in the map - to be able to 656 | # shutdown gracefully - by close all connections 657 | self.processed_connections += 1 658 | hid = id(csock) 659 | handler = self._handler(asyncio.StreamReader(csock), 660 | asyncio.StreamWriter(csock, {})) 661 | self.conns[hid] = handler 662 | self.loop.create_task(handler) 663 | # In case of max concurrency reached - temporary pause server: 664 | # 1. backlog must be greater than max_concurrency, otherwise 665 | # client will got "Connection Reset" 666 | # 2. Server task will be resumed whenever one active connection finished 667 | if len(self.conns) == self.max_concurrency: 668 | # Pause 669 | yield False 670 | except asyncio.CancelledError: 671 | return 672 | finally: 673 | sock.close() 674 | 675 | def run(self, host="127.0.0.1", port=8081, loop_forever=True): 676 | """Run Web Server. By default it runs forever. 677 | 678 | Keyword arguments: 679 | host - host to listen on. By default - localhost (127.0.0.1) 680 | port - port to listen on. By default - 8081 681 | loop_forever - run loo.loop_forever(), otherwise caller must run it by itself. 682 | """ 683 | self._server_coro = self._tcp_server(host, port, self.backlog) 684 | self.loop.create_task(self._server_coro) 685 | if loop_forever: 686 | self.loop.run_forever() 687 | 688 | def shutdown(self): 689 | """Gracefully shutdown Web Server""" 690 | asyncio.cancel(self._server_coro) 691 | for hid, coro in self.conns.items(): 692 | asyncio.cancel(coro) 693 | --------------------------------------------------------------------------------