Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ DEVICE ?= u0

SRC_DIR := src
TEST_DIR := tests
EXAMPLE_DIR := example/mip_repo
EXAMPLE_DIR := example/demo_app
BUILD_DIR := build
DIST_DIR := dist
TLS_DIR := tls
Expand Down
103 changes: 76 additions & 27 deletions assets/www/examples.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,44 +64,77 @@ <h2>Server configuration</h2>

<hr>

<h2>Simple Server Application</h2>
<p>The below example demonstrates how to set up a simple application, exposed at /app.</p>
<textarea readonly="true" rows="37">
<h2>Demo Application</h2>
<p>The below application demonstrates common use cases for handling headers, status codes, query parameters, and wilcard URLs.</p>
<ol>
<li><b>/version</b> returns the version of the application.
<br>Optionally, the server version is also included in the response if the 'detailed' query parameter is set to true.
</li>
<li><b>/app/version</b> or <b>/server/version</b> returns the designated version string, handled by a single endpoint definition with a wildcard URL.
</li>
</ol>
<textarea readonly="true" rows="62">
# /app.py

import asyncio
from gc import mem_free, mem_alloc

from pyrobusta.server.http_server import HttpServer
from pyrobusta.server import http_server
from pyrobusta.protocol.http import HttpEngine
from pyrobusta.utils import logging
from pyrobusta.utils.config import PYROBUSTA_VERSION

APP_VERSION = "v0.0.1"

@HttpEngine.route("/app", "GET")
def app(http_ctx, payload):
free = mem_free()
value_format = "bytes"

@HttpEngine.route("/version", "GET")
def version(http_ctx, _):
include_server_version = False

if http_ctx.query:
value_format = http_ctx.get_url_encoded_query_param(
http_ctx.query, "format", default="bytes"
)
if value_format not in ("%", "bytes"):
raise ValueError("invalid format")
is_detailed = http_ctx.get_url_encoded_query_param(
http_ctx.query, "detailed", default="false"
).lower()

if is_detailed not in ("true", "false"):
http_ctx.terminate(400, True)
return "text/plain", "Invalid query"

include_server_version = is_detailed.lower() == "true"

if http_ctx.headers.get("accept") == "application/json":
version_dict = {"app_version": APP_VERSION}
if include_server_version:
version_dict["server_version"] = PYROBUSTA_VERSION
return "application/json", version_dict

version_text = f"app_version: {APP_VERSION}\n"
if include_server_version:
version_text += f"server_version: {PYROBUSTA_VERSION}\n"
return "text/plain", version_text


@HttpEngine.route("/{app_or_server}/version", "GET")
def version(http_ctx, _):
include_server_version = False
resource = http_ctx.url.split(b"/")[1]

if resource not in (b"app", b"server"):
http_ctx.terminate(404, True)
return "text/plain", "Not found"

if value_format == "%":
free = round(100 * free / (free + mem_alloc()), 2)
version_string = APP_VERSION if resource == b"app" else PYROBUSTA_VERSION

return "text/plain", f"Free memory [{value_format}]: {free}\n"
if http_ctx.headers.get("accept") == "application/json":
return "application/json", {"version": version_string}

async def run_server():
server = HttpServer()
return "text/plain", f"{version_string}\n"


async def main():
server = http_server.HttpServer()
asyncio.create_task(server.start_socket_server())
while True:
await asyncio.sleep(1)

def main():
asyncio.run(run_server())
</textarea>
<textarea readonly="true" rows="15">
# /boot.py
Expand Down Expand Up @@ -143,12 +176,28 @@ <h2>Simple Server Application</h2>
</textarea>

<p>Use curl to test your application.</p>
<textarea readonly="true" rows="6">
$ curl "http://192.168.1.101/app"
Free memory [bytes]: 115456
<textarea readonly="true" rows="22">
$ curl "http://192.168.1.101/version"
app_version: v0.0.1

$ curl "http://192.168.1.101/version?detailed=True"
app_version: v0.0.1
server_version: v0.5.0

$ curl -H "Accept: application/json" "http://192.168.1.101/version?detailed=True"
{"server_version": "v0.5.0", "app_version": "v0.0.1"}

$ curl "http://192.168.1.101/app/version"
v0.0.1

$ curl "http://192.168.1.101/server/version"
v0.5.0

$ curl -H "Accept: application/json" "http://192.168.1.101/server/version"
{"version": "v0.5.0"}

$ curl "http://192.168.1.101/app?format=%"
Free memory [%]: 71.76
$ curl 192.168.1.101/application/version
Not found
</textarea>

<div class="footer">
Expand Down
4 changes: 2 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Device Setup

Use [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) command-line tool to access your device over a serial connection.\
Use [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) to access your device over a serial connection.\
You can install mpremote via pip. It is also included in the project's requirements.txt

```bash
Expand All @@ -22,7 +22,7 @@ Type "help()" for more information.

# Connect to Wi-Fi

During the initial setup, you’ll need to connect your device to a Wi-Fi network in order to install PyRobusta using the mip tool.\
During the initial setup, you’ll need to connect your device to a Wi-Fi network in order to install PyRobusta using the mip package manager.\
After connecting to your device with mpremote, run the following script:

```python
Expand Down
54 changes: 41 additions & 13 deletions example/demo_app/app.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,54 @@
import asyncio
from gc import mem_free, mem_alloc

from pyrobusta.server import http_server
from pyrobusta.protocol.http import HttpEngine
from pyrobusta.utils.config import PYROBUSTA_VERSION

APP_VERSION = "v0.0.1"

@HttpEngine.route("/app", "GET")
def app(http_ctx, payload):
free = mem_free()
value_format = "bytes"

@HttpEngine.route("/version", "GET")
def version(http_ctx, _):
include_server_version = False

if http_ctx.query:
value_format = http_ctx.get_url_encoded_query_param(
http_ctx.query, "format", default="bytes"
)
if value_format not in ("%", "bytes"):
raise ValueError("invalid format")
is_detailed = http_ctx.get_url_encoded_query_param(
http_ctx.query, "detailed", default="false"
).lower()

if is_detailed not in ("true", "false"):
http_ctx.terminate(400, True)
return "text/plain", "Invalid query"

include_server_version = is_detailed.lower() == "true"

if http_ctx.headers.get("accept") == "application/json":
version_dict = {"app_version": APP_VERSION}
if include_server_version:
version_dict["server_version"] = PYROBUSTA_VERSION
return "application/json", version_dict

version_text = f"app_version: {APP_VERSION}\n"
if include_server_version:
version_text += f"server_version: {PYROBUSTA_VERSION}\n"
return "text/plain", version_text


@HttpEngine.route("/{app_or_server}/version", "GET")
def version(http_ctx, _):
include_server_version = False
resource = http_ctx.url.split(b"/")[1]

if resource not in (b"app", b"server"):
http_ctx.terminate(404, True)
return "text/plain", "Not found"

version_string = APP_VERSION if resource == b"app" else PYROBUSTA_VERSION

if value_format == "%":
free = round(100 * free / (free + mem_alloc()), 2)
if http_ctx.headers.get("accept") == "application/json":
return "application/json", {"version": version_string}

return "text/plain", f"Free memory [{value_format}]: {free}\n"
return "text/plain", f"{version_string}\n"


async def main():
Expand Down
2 changes: 1 addition & 1 deletion src/pyrobusta/bindings/http_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async def _run_state_machine(self):
await sleep_ms(self.STATE_MACHINE_SLEEP_MS)

# [3] write response
if self._engine.is_started() and self._engine.is_terminated():
if not self._engine.is_request_empty() and self._engine.is_terminated():
self._engine.write_response_head(self._send_buf)
await self._flush_response()
if self._engine.resp_handler is not None:
Expand Down
61 changes: 41 additions & 20 deletions src/pyrobusta/protocol/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

from ..utils.config import (
get_config,
CONF_HTTP_SERVED_PATHS,
CONF_HTTP_MULTIPART,
CONF_HTTP_SERVE_FILES,
)
Expand Down Expand Up @@ -62,6 +61,7 @@ class HttpEngine:
"query",
"content_len_cnt",
"recv_chunk_size",
"is_req_empty",
"mp_boundary",
"mp_is_first",
"mp_is_last",
Expand Down Expand Up @@ -120,6 +120,7 @@ def __init__(self):
self.query = None
self.content_len_cnt = 0
self.recv_chunk_size = 0
self.is_req_empty = True

# [Multipart state]
self.mp_boundary = None
Expand All @@ -140,6 +141,7 @@ def reset(self):
self.query = None
self.content_len_cnt = 0
self.recv_chunk_size = 0
self.is_req_empty = True
self.mp_boundary = None

# =========================================
Expand Down Expand Up @@ -222,20 +224,38 @@ def get_url_encoded_query_param(query: str, key: str, default: str = None):
return query[idx_start + len(key) + 1 : idx_end]
return query[idx_start + len(key) + 1 :]

@classmethod
def is_norm_path_served(cls, path: str):
"""
Returns true if a normalized path is configured to be served.
"""
served_paths = get_config(CONF_HTTP_SERVED_PATHS)
parts = path.split("/")
for i, _ in enumerate(parts):
current_path = "/".join(parts[: i + 1])
if not current_path:
current_path = "/"
if current_path in served_paths:
return True
return False
@staticmethod
def _is_matching_url_path(path: bytes, pattern: bytes) -> bool:
"""
Match a URL path against a pattern that can contain wildcard segments
e.g. /path/{wildcard}/resource where {wildcard} matches any non-empty
string in that segment.
"""
if path == pattern:
return True
i = j = 0
n, m = len(path), len(pattern)
while i < n and j < m:
# Find next segment boundaries
ni = path.find(b"/", i)
nj = pattern.find(b"/", j)
if ni == -1:
ni = n
if nj == -1:
nj = m
path_seg = path[i:ni]
pat_seg = pattern[j:nj]
if path_seg != pat_seg:
if not (
len(pat_seg) >= 2
and pat_seg[0] == 123 # {
and pat_seg[-1] == 125 # }
and len(path_seg) > 0
):
return False
i = ni + 1
j = nj + 1
return i >= n and j >= m

@staticmethod
def _lookup(tuple_, key):
Expand All @@ -245,13 +265,13 @@ def _lookup(tuple_, key):
@classmethod
def _get_callback(cls, endpoint, method: bytes):
for e in cls.ENDPOINTS:
if endpoint == e[0] and method == e[2]:
if cls._is_matching_url_path(endpoint, e[0]) and method == e[2]:
return e[1]

@classmethod
def _has_endpoint(cls, endpoint: bytes):
for e in cls.ENDPOINTS:
if endpoint == e[0]:
if cls._is_matching_url_path(endpoint, e[0]):
return True
return False

Expand Down Expand Up @@ -466,11 +486,11 @@ def abort(self, status_code: int):
self.resp_handler = None
self.terminate(status_code, False)

def is_started(self):
def is_request_empty(self):
"""
Returns true if the state machine has received any input.
Returns false if the state machine has received any input.
"""
return self.state != self._start_parser # pylint: disable=W0143
return self.is_req_empty

def is_terminated(self):
"""
Expand Down Expand Up @@ -538,6 +558,7 @@ def _start_parser(self, rx):
Initial state.
"""
if rx.size():
self.is_req_empty = False
self.state = self._parse_request_line_st

def _parse_request_line_st(self, rx):
Expand Down
21 changes: 21 additions & 0 deletions src/pyrobusta/protocol/http_file_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
from pyrobusta.protocol import http
from pyrobusta.utils.helpers import normalize_path, add_method

from ..utils.config import (
get_config,
CONF_HTTP_SERVED_PATHS,
)

CONTENT_TYPES = (
b"raw",
b"application/octet-stream",
Expand All @@ -35,6 +40,21 @@
)


def is_norm_path_served(path: str):
"""
Returns true if a normalized path is configured to be served.
"""
served_paths = get_config(CONF_HTTP_SERVED_PATHS)
parts = path.split("/")
for i, _ in enumerate(parts):
current_path = "/".join(parts[: i + 1])
if not current_path:
current_path = "/"
if current_path in served_paths:
return True
return False


def _send_file_st(self, _, file_path: bytes):
"""
State for returning a file. By default, /www is prepended to the path.
Expand Down Expand Up @@ -85,3 +105,4 @@ def apply_patches():
"""

add_method(http.HttpEngine, _send_file_st)
add_method(http.HttpEngine, is_norm_path_served, "static")
Loading
Loading