diff --git a/.gitignore b/.gitignore index 7ecf9b0..7edd53f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -venv/** +venv/ __pycache__ pyrobusta.env build/ runtime/ runtime-test/ -tls/ \ No newline at end of file +tls/ +tmp/ \ No newline at end of file diff --git a/Makefile b/Makefile index 9628d50..47adcb9 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,11 @@ DEVICE ?= u0 SRC_DIR := src TEST_DIR := tests -EXAMPLE_DIR := example/demo_app +APP_DIR := example/demo_app BUILD_DIR := build DIST_DIR := dist TLS_DIR := tls -ASSETS_DIR := assets +DOCS_DIR := docs PKG := pyrobusta @@ -30,7 +30,7 @@ DEVICE_NAME := # e.g. ESP32-C3, will be used for report generation PT_DIR := tests/system .PHONY: all -all: clean toolchain static-checkers unit-test build test-unix deploy deploy-config tls-cert deploy-cert deploy-example +all: clean toolchain static-checkers unit-test build test-unix deploy deploy-config tls-cert deploy-cert deploy-app # ================================================ # Build @@ -55,10 +55,7 @@ toolchain: .PHONY: build build: $(MPY_TARGETS) $(INIT_TARGETS) @mkdir -p $(BUILD_DIR) - @if [ -d assets ]; then \ - echo "Copying assets/ -> $(BUILD_DIR)"; \ - cp -r assets $(BUILD_DIR)/${PKG}/; \ - fi + $(MAKE) docs # Compile .py -> .mpy $(BUILD_DIR)/%.mpy: $(SRC_DIR)/%.py @@ -126,6 +123,28 @@ redeploy: clean build clean-device deploy # Rules for release # ================================================ +# ----------------------------- +# Pre-process documentation +# ----------------------------- +.PHONY: docs_preprocess +docs_preprocess: + @find docs -type f -name "*.md" -exec \ + sed -E -i.bak \ + 's/(PyRobusta[[:space:]]).+([[:space:]]Web Server)/\1$(PYROBUSTA_VERSION)\2/' {} \; \ + && find docs -name "*.bak" -delete + + +# ----------------------------- +# Build documentation +# ----------------------------- +.PHONY: docs +docs: docs_preprocess + python3 scripts/generate_docs.py \ + $(DOCS_DIR)/application_development \ + $(BUILD_DIR)/$(PKG)/assets/www \ + --css $(DOCS_DIR)/application_development/styles/style.css + + # ----------------------------- # Prepare distribution # ----------------------------- @@ -133,27 +152,28 @@ redeploy: clean build clean-device deploy publish: test -n "$(DIST_DIR)" && rm -rf "$(PWD)/$(DIST_DIR)" mkdir -p "$(PWD)/$(DIST_DIR)" - @sed -E -i.bak 's/(PYROBUSTA_VERSION[[:space:]]*=[[:space:]]*)"[^"]*"/\1"$(PYROBUSTA_VERSION)"/' \ + + # Bump version in Python source + @sed -E -i.bak \ + 's/(PYROBUSTA_VERSION[[:space:]]*=[[:space:]]*)"[^"]*"/\1"$(PYROBUSTA_VERSION)"/' \ $(SRC_DIR)/pyrobusta/utils/config.py \ && rm -f $(SRC_DIR)/pyrobusta/utils/config.py.bak - @sed -E -i.bak 's/(PyRobusta[[:space:]]).+([[:space:]]Web Server)/\1$(PYROBUSTA_VERSION)\2/' \ - $(ASSETS_DIR)/www/*.html \ - && rm -f $(ASSETS_DIR)/www/*.html.bak + $(MAKE) clean - $(MAKE) build BUILD_DIR=$(DIST_DIR) - scripts/update_package.bash $(DIST_DIR) package.json $(PYROBUSTA_VERSION) + $(MAKE) build docs BUILD_DIR=$(DIST_DIR) + scripts/update_package.bash $(DIST_DIR) package.json $(PYROBUSTA_VERSION) # ================================================ # Example apps # ================================================ # ----------------------------- -# Prepare unix example runtime +# Prepare UNIX example runtime # ----------------------------- -.PHONY: stage-example -stage-example: - @echo "Preparing unix runtime in $(RUNTIME_DIR)" +.PHONY: stage-app +stage-app: + @echo "Preparing UNIX runtime in $(RUNTIME_DIR)" @rm -rf $(RUNTIME_DIR) @mkdir -p $(RUNTIME_DIR)/lib @@ -161,9 +181,9 @@ stage-example: @cp -r build/pyrobusta $(RUNTIME_DIR)/lib @cp -r build/pyrobusta/assets/www $(RUNTIME_DIR)/ - @echo "Copying example app" - @cp $(EXAMPLE_DIR)/app.py $(RUNTIME_DIR)/ - @cp $(EXAMPLE_DIR)/boot.py $(RUNTIME_DIR)/ + @echo "Copying app" + @cp $(APP_DIR)/app.py $(RUNTIME_DIR)/ + @cp $(APP_DIR)/boot.py $(RUNTIME_DIR)/ @echo "Copying TLS certificate" @cp $(TLS_DIR)/cert.der $(RUNTIME_DIR)/ @@ -174,27 +194,27 @@ stage-example: @echo "https_port=4443" >> $(RUNTIME_DIR)/pyrobusta.env # ----------------------------- -# Run example locally with unix micropython +# Run app locally with UNIX MicroPython # ----------------------------- .PHONY: run-unix -run-unix: stage-example - @echo "Running example with unix micropython" +run-unix: stage-app + @echo "Running app with UNIX MicroPython" cd $(RUNTIME_DIR) && MICROPYPATH=":.frozen:lib" ../$(MICROPYTHON) app.py # ----------------------------- -# Deploy example app +# Deploy application # ----------------------------- -.PHONY: deploy-example -deploy-example: +.PHONY: deploy-app +deploy-app: @echo "Uploading boot.py, app.py" @mpremote $(DEVICE) soft-reset - mpremote $(DEVICE) cp $(EXAMPLE_DIR)/boot.py :boot.py - mpremote $(DEVICE) cp $(EXAMPLE_DIR)/app.py :app.py + mpremote $(DEVICE) cp $(APP_DIR)/boot.py :boot.py + mpremote $(DEVICE) cp $(APP_DIR)/app.py :app.py @echo "Uploading pyrobusta.env" @if [ -f pyrobusta.env ]; then mpremote $(DEVICE) cp pyrobusta.env :pyrobusta.env; fi @mpremote $(DEVICE) reset - @echo "\e[32m$(EXAMPLE_DIR) example is successfully deployed, \n"\ + @echo "\e[32m$(APP_DIR) app is successfully deployed, \n"\ "run 'make DEVICE=$(DEVICE) run-device' to restart the device and check the output.\e[0m" # ----------------------------- @@ -259,7 +279,7 @@ stage-test: @cp tests/functional/*.py $(TEST_RUNTIME)/ # ----------------------------- -# Run functional tests on unix port +# Run functional tests on UNIX port # ----------------------------- .PHONY: test-unix test-unix: TLS_DIR=$(TEST_RUNTIME) diff --git a/README.md b/README.md index 3e2a4d7..11aefb9 100644 --- a/README.md +++ b/README.md @@ -102,13 +102,17 @@ async def main(): asyncio.run(main()) ``` +Check the [Application Development](./docs/application_development/index.md) guide for +more details on supported features and practical examples. + + # Configuration and Optimization To fine-tune heap usage and optimize performance, see: -- [dimensioning guide](./docs/dimensioning/http_dimensioning.md) -- [configuration settings](./docs/configuration.md) +- [Dimensioning](./docs/dimensioning/http_dimensioning.md) +- [Configuration Settings](./docs/application_development/configuration.md) # Development -Check the provided development guide to create and deploy custom builds -to your device: [development guide](./docs/development.md) +Check the provided [Development](./docs/development.md) guide to create and deploy custom builds +to your device, as well as running tests and static code checkers. diff --git a/assets/www/configuration.html b/assets/www/configuration.html deleted file mode 100644 index f777411..0000000 --- a/assets/www/configuration.html +++ /dev/null @@ -1,173 +0,0 @@ - - -
- - -
- This page documents PyRobusta configuration options,
- configuration deployment using mpremote,
- and runtime access to configuration values through the
- configuration API.
-
- Configuration overrides can be provided through pyrobusta.env, using standard .env syntax.
- pyrobusta.env must be stored in the server root. Inline comments are supported using #.
-
Perform a soft reset and upload pyrobusta.env using mpremote.
| Name | -Description | -Default | -
|---|---|---|
wifi_ssid |
- Name of the Wi-Fi network. When empty, Wi-Fi is not initialized by the built-in wifi.py module. |
- None | -
wifi_password |
- Password of the Wi-Fi network. When empty, Wi-Fi is not initialized by the built-in wifi.py module. |
- None | -
http_port |
- Port number for HTTP. | -80 | -
https_port |
- Port number for HTTPS. | -443 | -
http_multipart |
- Enables or disables multipart request and response processing. Enabling multipart support increases memory usage. | -False | -
http_mem_cap |
- - Fraction of available heap memory reserved for stream buffers. Valid range: (0, 1]. - | -0.1 | -
http_served_paths |
- - Space-separated list of filesystem paths that may be served over HTTP. - | -/www /lib/pyrobusta |
-
http_files_api |
-
- Enables or disables the file management API endpoint (/files), allowing upload, download, and listing of files.
- |
- False | -
socket_max_con |
- Maximum number of simultaneous socket connections. | -2 | -
tls |
-
- Enables or disables TLS. When enabled, cert.der and key.der must be installed at the server root.
- |
- False | -
log_level |
- Logging level. Can be one of: warning, info, debug. |
- info | -
- Configuration values can be accessed through the
- pyrobusta.utils.config module.
- Values are loaded from pyrobusta.env during server initialization.
- Configuration values can be retrieved using
- get_config() together with one of the
- predefined CONF_* constants.
-
- After initialization, configuration values are retrieved from an internal cache. - The cached values are normalized to their expected runtime types to avoid repeated - parsing of environment strings. -
- -
- Configuration values are treated as immutable during runtime.
- Changes are applied only when the configuration cache is reloaded.
- The configuration cache can be reloaded by calling
- read_config(), which re-reads pyrobusta.env
- and rebuilds the internal normalized cache.
-
This API provides file management capabilities, allowing clients to upload,
- retrieve, and manage files through various HTTP methods.
- http_files_api must be set to True in
- pyrobusta.env to enable this API.
-
| Method | -Path | -Description | -
|---|---|---|
GET |
- /files/{path} |
- Lists or retrieves metadata about files. | -
PUT |
- /files/{file path} |
- Uploads or overwrites a file at the specified path. | -
POST |
- /files |
- Uploads multiple files in multipart/form-data. | -
DELETE |
- /files/{file path} |
- Deletes a file at the specified path. | -
Endpoint: GET /files/{path}
- This method allows general file system interaction, enabling operations - such as listing directory contents, retrieving metadata, and downloading - files. -
- -GET/files/{path}200 OKEndpoint: PUT /files/{file path}
- This method uploads a file or overwrites an existing file at a specific
- path. The upload path is restricted to
- /www/user_data.
-
PUT/files/{file path}201 Createdtransfer-encoding: chunked is supported.
- Endpoint: POST /files
- This method handles general file uploads, designed for uploading multiple
- files with per-file chunking supported. Only
- multipart/form-data is accepted.
-
- Uploads are restricted to /www/user_data. The
- Content-Disposition header only needs to specify the file
- name; the upload directory is prepended automatically.
-
- http_multipart must be set to True in the
- configuration to use this method.
-
POST/files201 CreatedEndpoint: DELETE /files/{file path}
- This method deletes a file at a specific path. The path is restricted to
- /www/user_data.
-
DELETE/files/{file path}204 No ContentThe server is running correctly and is ready to serve content.
- -This page describes how route handlers receive and process incoming HTTP requests.
- -Handler functions take an HTTP context and payload as arguments.
- While handler functions cannot define additional arguments, extra input can be provided
- through query parameters. Query parameters appear after the path component of a URL and
- are introduced by a question mark (?), for example:
-
- http://192.168.1.101/path/to/resource?param-key=param-value
-
- Query parameters use the same encoding rules as the application/x-www-form-urlencoded format.
- Multiple query parameters are separated by the ampersand (&). Percent-encoded characters
- (for example, %2F representing /) are decoded automatically by the server.
-
-
-
- Query parameters can be retrieved using the get_query_param() function, which accepts two
- arguments: the name of the key and an optional default value. The function returns a string
- that can be further processed by the application.
-
Headers received in a request are available in the headers attribute of the HTTP context.
- The headers attribute is a dictionary of key-value pairs. Header names and values are exposed as strings.
- As a convenience, the Content-Length header is automatically converted to an integer because it is frequently used for
- payload size calculations. Headers are normalized to lower case, so the key "Content-Length" is equivalent to the key
- "content-length".
-
Request headers must contain only a subset of US-ASCII characters:
- -A request payload is passed as the second positional argument of the - route handler. Unless the request uses streaming or multipart encoding, the payload - is provided as a memoryview to avoid unnecessary memory allocations. Deserialization must be done - by the user application. -
- - - -PyRobusta supports streaming requests by processing individual - chunks of the request body as they are received. To enforce bounded memory usage, - request chunks are processed individually by calling registered route handlers - for each chunk received. As a result, the application must process the request body - incrementally rather than assuming the full payload is available at once. -
- - - -Multipart requests allow clients to send composite payloads with - the option of varying content metadata or multiple resources in a single request. - Similar to streamed requests, multipart requests are processed one part at a time. - The route handler is invoked once for each part. Unlike regular request bodies, multipart - parts are parsed by the server before being passed to the application. Peprocessed parts - consist of headers (dictionary) and the raw part body (bytes), passed as a tuple. -
- -mp_is_first and
- mp_is_last
- to identify the first and final part of a multipart request. This allows stateful processing
- of multiple parts belonging to the same request.
- Response processing controls how route handlers construct HTTP responses. - This includes setting status codes, configuring response headers, serializing response bodies, - and generating streamed or multipart responses. -
-Route handlers may optionally set the status code of the HTTP response.
- If unspecified, the server defaults to HTTP 200. The status code can be overridden
- through the terminate() method of the HTTP context.
-
The terminate() method updates the response status code and marks the
- request as complete, but does not interrupt execution. Route handlers should still return
- an appropriate response body.
-
Response headers and response bodies can be configured through methods exposed by the HTTP context
- (set_response_header(), set_response_body()).
- Alternatively, route handlers may return a (content_type, body) tuple.
-
- PyRobusta implements a simple caching policy.
- Unless overridden by the application, all HTTP responses include the
- header Cache-Control: no-store.
-
- Conditional requests and cache validation mechanisms are not supported.
- This includes ETag, Last-Modified,
- If-None-Match, and If-Modified-Since.
-
- This design reduces implementation complexity and avoids additional
- filesystem metadata lookups on resource-constrained devices.
-
PyRobusta can automatically serialize a limited set of built-in types and data structures. - Unsupported types must be serialized by the application before being returned as either a string or a bytes-like - object. The following response body types are currently supported: -
- -strbytes, bytearray, memoryviewdict, tuple, listFor non-streamed responses, the entire response body must exist in memory before it is
- transmitted. As a result, the maximum response size is limited by the available heap. In the meantime, a
- response buffer has a fixed size depending on the configuration. Internally, response bodies are wrapped
- in a BytesIO object so that both fixed-size and streamed responses can be written through the same
- buffer-oriented interface. Each response body returned by a route handler has a known size, allowing the
- content-length header to be filled by the server.
-
- Responses can be streamed in chunks when the application cannot determine the size of the response body in advance.
- Such responses must use chunked transfer encoding, indicated by the Transfer-Encoding header. With chunked encoding,
- the Content-Length header must be omitted; instead the size of each chunk must be indicated as the chunks are sent.
- The server automatically generates the required chunk metadata.
-
- When chunked transfer encoding is enabled, the server automatically generates the required encoding format.
- The application must assign a generator function to http_ctx.resp_handler. The server then invokes the generator
- when data is ready to be transmitted. The generator may be resumed multiple times until the stream is complete.
- The following requirements must be fulfilled by the generator:
-
tx) used to write response dataFalse after producing a chunk while additional data remainsTrue exactly once to indicate that the stream is complete- Multipart responses allow a single HTTP response to contain multiple independently typed payloads, - each with its own headers and body. Similar to streamed responses, PyRobusta uses response producer - functions to generate multipart response parts on demand. Because the total response size is not known - in advance, the server automatically uses chunked transfer encoding for multipart responses. It is - explicitly enabled in the example below for completeness. -
- -- Routes producing streamed multipart responses must satisfy the following requirements: -
- -
- Try the X-Part-Count and X-Part-Size headers to arbitrarily configure the response size.
-
Routing maps incoming HTTP requests to application-defined route handlers. - This page describes how routes are defined, how route handlers receive requests, and how wildcard routes - can be used to match dynamic URL paths. -
- -Routes map HTTP requests to server-side handler functions that - process requests and manage resources. Similar to common web frameworks, PyRobusta - utilizes function decorators to map handler functions to URL paths. A route handler - can only be mapped to a single URL path and HTTP method. The same URL path may be - associated with multiple route handlers provided that each handler uses a different - HTTP method. -
- - - -In PyRobusta, a route handler is a synchronous function registered to a - specific URL path and HTTP method. PyRobusta invokes a route handler whenever it receives a - request whose URL path and HTTP method match the registered route. Route handlers must accept - exactly two positional arguments: -
-HttpEngine class)memoryview)The HTTP context is an instance of the HttpEngine class that exposes the
- public API used to inspect requests and construct responses. The HTTP context provides public
- methods and attributes that enable user applications to process headers and structure responses.
- By convention, non-public attributes and methods are prefixed with an underscore.
-
-
-
- Apart from the public API, the HTTP context also encapsulates the state associated with the current
- request and response exchange. Internally, the HTTP context ensures protocol correctness and assists
- with request routing.
-
Depending on the request type, the body argument may contain either the complete - request body or a partial payload chunk. Partial request bodies are passed to route handlers - when the request uses multipart encoding or chunked transfer encoding, with each chunk of the payload - fed incrementally to the route handler. Such request processing is documented in the - Request Processing guide. -
- -Wildcard routes use placeholders in one or more segments of a URL path.
- These placeholders match varying path values, allowing multiple URL paths to be mapped to
- a single handler function. A placeholder matches a single path segment by default. Alternatively,
- a placeholder can match multiple segments by using the :path suffix. For example:
-
- /path/to/{resource:path}
-
- Placeholders that match multiple path segments are only allowed at the end of a route. For example,
- /path/{to:path}/resource is disallowed because it would result in ambiguous route resolution.
-
By convention, user applications should use decorators
- to register route handlers in most cases. This approach ensures that routes are registered
- during application initialization, and routes remain available for the lifetime of the application.
- The public API exposed by HttpEngine allows route registration and deregistration
- during an application's lifecycle, enabling applications to dynamically expose or
- remove functionality at runtime.
-
- PyRobusta serves static content directly from the filesystem. This page - describes the directory structure, file resolution rules, and MIME type - handling used by the server. -
- -Files stored under /www are served as static content.
- Requests to the server root (/) return the default landing page (index.html).
- For static content requests, the server automatically prepends /www to the requested path before
- resolving the corresponding file on the filesystem.
-
When the file management API is enabled (http_files_api=True),
- additional filesystem locations can be exposed through the http_served_paths
- configuration option. See the Server Configuration
- and File Server API guide for additional details.
-
-root/ -├── www document root for static content -│ ├── index.html -│ ├── introduction.html -│ ├── ... -│ └── user_data root for user uploads -│ └── ... -├── lib root for installed MIP packages -│ ├── pyrobusta -│ │ ├── bindings -│ │ ├── connectivity -│ │ └── ... -│ └── <other packages> -├── cert.der TLS certificate -└── key.der TLS key -- -
PyRobusta automatically determines the Content-Type header for
- static files based on their filename extension. The selected content type depends on the file extension.
- Unknown extensions are mapped to application/octet-stream.
- The following mapping between extensions and content types is maintained by the server:
-
| Extension | -Content-Type header | - -
|---|---|
| .html | -text/html | -
| .css | -text/css | -
| .js | -application/javascript | -
| .json | -application/json | -
| .ico | -image/x-icon | -
| .jpeg | -image/jpeg | -
| .jpg | -image/jpeg | -
| .png | -image/png | -
| .txt | -text/plain | -
| .gif | -image/gif | -
| .raw, unknown extensions | -application/octet-stream | -
This page provides practical examples for using your server.
- -The following application demonstrates common use cases for handling headers, status codes, query parameters, and wildcard routes.
-Perform a soft reset and upload app.py and boot.py using mpremote.
- +``` -Perform a hard reset to start the application and connect to the REPL.
- +``` + +Use curl to test the application. -Use curl to test the application.
- - - - - +``` + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/performance.md b/docs/application_development/performance.md new file mode 100644 index 0000000..55d4115 --- /dev/null +++ b/docs/application_development/performance.md @@ -0,0 +1,30 @@ +# Performance & Memory + +[← Back](index.md) + +--- + +## Table of Contents + +* [Performance & Memory](#performance-memory) + + [Memory Configuration](#memory-configuration) + + [Connection Limits](#connection-limits) + + [Buffer Sizing](#buffer-sizing) + + [Streaming vs Buffered Responses](#streaming-vs-buffered-responses) + + [Multipart Memory Usage](#multipart-memory-usage) + +--- + +## Memory Configuration + +## Connection Limits + +## Buffer Sizing + +## Streaming vs Buffered Responses + +## Multipart Memory Usage + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/reference.md b/docs/application_development/reference.md new file mode 100644 index 0000000..c164cbf --- /dev/null +++ b/docs/application_development/reference.md @@ -0,0 +1,33 @@ +# Reference + +[← Back](index.md) + +--- + +## Table of Contents + +* [Reference](#reference) + + [HttpServer](#httpserver) + + [HttpEngine](#httpengine) + + [HttpContext](#httpcontext) + + [Route Decorators](#route-decorators) + + [Configuration Options](#configuration-options) + + [Environment Variables](#environment-variables) + +--- + +## HttpServer + +## HttpEngine + +## HttpContext + +## Route Decorators + +## Configuration Options + +## Environment Variables + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/request.md b/docs/application_development/request.md new file mode 100644 index 0000000..3350f63 --- /dev/null +++ b/docs/application_development/request.md @@ -0,0 +1,211 @@ +# Request Processing + +[← Back](index.md) + +This page describes how route handlers receive and process incoming HTTP requests. + +--- + +## Table of Contents + +* [Request Processing](#request-processing) + + [Query Parameters](#query-parameters) + + [Request Headers](#request-headers) + + [Request Bodies](#request-bodies) + + [Streamed Requests](#streamed-requests) + + [Multipart Requests](#multipart-requests) + +--- + +## Query Parameters + +Handler functions take an HTTP context and payload as arguments. +While handler functions cannot define additional arguments, extra input can be provided +through query parameters. Query parameters appear after the path component of a URL and +are introduced by a question mark (?), for example: + +`http://192.168.1.101/path/to/resource?param-key=param-value` + +Query parameters use the same encoding rules as the `application/x-www-form-urlencoded` format. +Multiple query parameters are separated by the ampersand (&). Percent-encoded characters +(for example, %2F representing /) are decoded automatically by the server. + +Query parameters can be retrieved using the `get_query_param()` function, which accepts two +arguments: the name of the key and an optional default value. The function returns a string +that can be further processed by the application. + +``` +from pyrobusta.protocol.http import HttpEngine + +@HttpEngine.route("/app/resource", "GET") +def query_param_handler(http_ctx, _): + # Handler for /app/resource?detailed=true/false + + is_detailed = False + + if http_ctx.query: + is_detailed = http_ctx.get_query_param( + "detailed", default="false" + ).lower() + + if is_detailed not in ("true", "false"): + http_ctx.terminate(400) + return "text/plain", "Invalid query" + + resource = "resource content\n" + if is_detailed: + resource = "detailed " + resource + + return "text/plain", resource +``` + +## Request Headers + +Headers received in a request are available in the `headers` attribute of the HTTP context. +The `headers` attribute is a dictionary of key-value pairs. Header names and values are exposed as strings. +As a convenience, the `Content-Length` header is automatically converted to an integer because it is frequently used for +payload size calculations. Headers are normalized to lower case, so the key `"Content-Length"` is equivalent to the key +`"content-length"`. + +Request headers must contain only a subset of US-ASCII characters: + +* header names are restricted to letters, digits, hyphens, and underscores +* header values are limited to US-ASCII characters, excluding control characters + +``` +from pyrobusta.protocol.http import HttpEngine + +@HttpEngine.route("/app", "GET") +def app(http_ctx, _): + if http_ctx.headers.get("accept", "*/*") == "text/plain": + return "text/plain", "App response\n" + elif http_ctx.headers["accept"] == "application/json": + return "application/json", {"response": "App response"} + raise ValueError("Unsupported accept header") +``` + +## Request Bodies + +A request payload is passed as the second positional argument of the +route handler. Unless the request uses streaming or multipart encoding, the payload +is provided as a memoryview to avoid unnecessary memory allocations. Deserialization must be done +by the user application. + +``` +import json + +from pyrobusta.protocol.http import HttpEngine + +@HttpEngine.route("/app", "GET") +def app(http_ctx, _): + return "text/plain", "GET request without payload" + +@HttpEngine.route("/app", "POST") +def app(http_ctx, payload): + data = json.loads(bytes(payload)) + return "text/plain", "POST request with payload" +``` + +## Streamed Requests + +PyRobusta supports streaming requests by processing individual +chunks of the request body as they are received. To enforce bounded memory usage, +request chunks are processed individually by calling registered route handlers +for each chunk received. As a result, the application must process the request body +incrementally rather than assuming the full payload is available at once. + +``` +import pyrobusta.server.http_server as http_server +from pyrobusta.protocol.http import HttpEngine +from pyrobusta.utils.helpers import normalize_path + +@HttpEngine.route("/app/chunks", "POST") +def upload_chunks(http_ctx, payload: bytes): + """ + Route handler for demonstrating chunked transfer encoding. + """ + if not http_ctx.is_chunked(): + http_ctx.terminate(400) + return "text/plain", "Bad request" + + if payload: + # Wait for more chunks before setting response status + with open(normalize_path("/tmp/chunks.txt"), "ab") as f: + f.write(payload) + return + + # Last (empty) chunk received + http_ctx.terminate(201) + return "text/plain", "OK" + +async def main(): + server = http_server.HttpServer() + asyncio.create_task(server.start_socket_server()) + while True: + await asyncio.sleep(1) +``` + +## Multipart Requests + +Multipart requests allow clients to send composite payloads with +the option of varying content metadata or multiple resources in a single request. +Similar to streamed requests, multipart requests are processed one part at a time. +The route handler is invoked once for each part. Unlike regular request bodies, multipart +parts are parsed by the server before being passed to the application. Peprocessed parts +consist of headers (dictionary) and the raw part body (bytes), passed as a tuple. + +**Multipart state tracking** +The HTTP context exposes the boolean attributes +`mp_is_first` and +`mp_is_last` +to identify the first and final part of a multipart request. This allows stateful processing +of multiple parts belonging to the same request. + +``` +from os import listdir, remove, rename, mkdir + +import pyrobusta.server.http_server as http_server +from pyrobusta.protocol.http import HttpEngine +from pyrobusta.utils.helpers import normalize_path + +@HttpEngine.route("/app/parts", "POST") +def handle_parts(http_ctx, payload: tuple): + """ + Route handler for demonstrating multipart processing. + """ + if not http_ctx.is_multipart(): + http_ctx.terminate(400) + return "text/plain", "Bad request" + + part_headers, part_body = payload + + tmp_dir = normalize_path("/tmp") + tmp_path = normalize_path("/tmp/parts.txt") + target_path = normalize_path("/www/user_data/parts.txt") + + # Clean stale partial uploads + if http_ctx.mp_is_first: + if not "tmp" in listdir(normalize_path("/")): + mkdir(tmp_dir) + if "parts.txt" in listdir(tmp_dir): + remove(tmp_path) + + with open(tmp_path, "ab") as f: + f.write(part_body) + + # Finalize uploads + if http_ctx.mp_is_last: + rename(tmp_path, target_path) + http_ctx.terminate(201) + return "text/plain", "OK" + +async def main(): + server = http_server.HttpServer() + asyncio.create_task(server.start_socket_server()) + while True: + await asyncio.sleep(1) +``` + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/response.md b/docs/application_development/response.md new file mode 100644 index 0000000..39cbe67 --- /dev/null +++ b/docs/application_development/response.md @@ -0,0 +1,261 @@ +# Response Processing + +[← Back](index.md) + +Response processing controls how route handlers construct HTTP responses. +This includes setting status codes, configuring response headers, serializing response bodies, +and generating streamed or multipart responses. + +--- + +## Table of Contents + +* [Response Processing](#response-processing) + + [Status Codes](#status-codes) + + [Response Headers](#response-headers) + + [Cache Control](#cache-control) + + [Content Types & Serialization](#content-types-serialization) + + [Streamed Responses](#streamed-responses) + + [Streamed Multipart Responses](#streamed-multipart-responses) + +--- + +## Status Codes + +Route handlers may optionally set the status code of the HTTP response. +If unspecified, the server defaults to HTTP 200. The status code can be overridden +through the `terminate()` method of the HTTP context. + +The `terminate()` method updates the response status code and marks the +request as complete, but does not interrupt execution. Route handlers should still return +an appropriate response body. + +``` +from pyrobusta.protocol.http import HttpEngine + +@HttpEngine.route("/app/{resource}", "GET") +def inventory_manager(http_ctx, _): + resource = http_ctx.path_segment(1) + + if resource == "items": + # Default HTTP 200 + return "application/json", ["item-1", "item-2", "item-3"] + + elif resource == "version": + # Default HTTP 200 + return "text/plain", "v0.1.0" + + else: + # Set 404 status code explicitly + http_ctx.terminate(404) + return "text/plain", "Not found" +``` + +## Response Headers + +Response headers and response bodies can be configured through methods exposed by the HTTP context +(`set_response_header()`, `set_response_body()`). +Alternatively, route handlers may return a `(content_type, body)` tuple. + +``` +import json + +from pyrobusta.protocol.http import HttpEngine + +config = {"max-items": 5} +items = set(["apple", "orange", "grapes"]) + +@HttpEngine.route("/inventory/{resource}", "POST") +def inventory_manager(http_ctx, payload): + resource = http_ctx.path_segment(1) + + if resource == "items": + if http_ctx.headers.get("content-type") != "text/plain": + http_ctx.set_response_header(b"accept-post", b"text/plain") + http_ctx.terminate(415) + return "text/plain", "Unsupported type" + if len(items) >= config["max-items"]: + http_ctx.terminate(400) + return "text/plain", "Inventory full" + items.add(payload.decode()) + return "text/plain", ", ".join(items) + + elif resource == "config": + if http_ctx.headers.get("content-type") != "application/json": + http_ctx.set_response_header(b"accept-post", b"application/json") + http_ctx.terminate(415) + return "text/plain", "Unsupported type" + payload_json = json.loads(payload) + if any([key not in config for key in payload_json]) or \ + any([type(value) != type(config[key]) for key, value in payload_json.items()]): + http_ctx.terminate(400) + return "text/plain", "Invalid config" + config.update(payload_json) + return "application/json", config + + else: + http_ctx.terminate(404) + return "text/plain", "Not found" +``` + +## Cache Control + +PyRobusta implements a simple caching policy. +Unless overridden by the application, all HTTP responses include the +header `Cache-Control: no-store`. +Conditional requests and cache validation mechanisms are not supported. +This includes `ETag`, `Last-Modified`, +`If-None-Match`, and `If-Modified-Since`. +This design reduces implementation complexity and avoids additional +filesystem metadata lookups on resource-constrained devices. + +## Content Types & Serialization + +PyRobusta can automatically serialize a limited set of built-in types and data structures. +Unsupported types must be serialized by the application before being returned as either a string or a bytes-like +object. The following response body types are currently supported: + +* `str` +* bytes-like: `bytes`, `bytearray`, `memoryview` +* data structures: `dict`, `tuple`, `list` + +For non-streamed responses, the entire response body must exist in memory before it is +transmitted. As a result, the maximum response size is limited by the available heap. In the meantime, a +response buffer has a fixed size depending on the configuration. Internally, response bodies are wrapped +in a `BytesIO` object so that both fixed-size and streamed responses can be written through the same +buffer-oriented interface. Each response body returned by a route handler has a known size, allowing the +content-length header to be filled by the server. + +## Streamed Responses + +Responses can be streamed in chunks when the application cannot determine the size of the response body in advance. +Such responses must use chunked transfer encoding, indicated by the `Transfer-Encoding` header. With chunked encoding, +the `Content-Length` header must be omitted; instead the size of each chunk must be indicated as the chunks are sent. +The server automatically generates the required chunk metadata. + +When chunked transfer encoding is enabled, the server automatically generates the required encoding format. +The application must assign a generator function to `http_ctx.resp_handler`. The server then invokes the generator +when data is ready to be transmitted. The generator may be resumed multiple times until the stream is complete. +The following requirements must be fulfilled by the generator: + +1. the generator function must accept a single response buffer argument (`tx`) used to write response data +2. the generator yields `False` after producing a chunk while additional data remains +3. the generator yields `True` exactly once to indicate that the stream is complete +4. the generator verifies the writable capacity of the buffer and + writes at most that much data to the buffer before yielding + +``` +# /app.py +import asyncio + +from pyrobusta.server import http_server +from pyrobusta.protocol.http import HttpEngine + +@HttpEngine.route("/stream", "GET") +def stream(http_ctx, _): + + def generate_chunks(tx): + for i in range(10): + data = b"data: chunk %d\n\n" % i + written = 0 + while written < len(data): + to_write = tx.capacity - tx.size() + # Defensive check; buffer should never be full here + if not to_write: + raise BufferError() + tx.write(data[written : written + to_write]) + written += to_write + yield False + yield True + + http_ctx.set_response_header(b"transfer-encoding", b"chunked") + http_ctx.set_response_header(b"content-type", b"text/event-stream") + http_ctx.resp_handler = generate_chunks + +async def main(): + server = http_server.HttpServer() + asyncio.create_task(server.start_socket_server()) + while True: + await asyncio.sleep(1) +``` + +``` +$ curl 192.168.1.101/stream +data: chunk 0 + +data: chunk 1 + +data: chunk 2 + +... + +data: chunk 9 +``` + +## Streamed Multipart Responses + +Multipart responses allow a single HTTP response to contain multiple independently typed payloads, +each with its own headers and body. Similar to streamed responses, PyRobusta uses response producer +functions to generate multipart response parts on demand. Because the total response size is not known +in advance, the server automatically uses chunked transfer encoding for multipart responses. It is +explicitly enabled in the example below for completeness. + +Routes producing streamed multipart responses must satisfy the following requirements: + +1. the route must return a tuple containing the multipart content type and a callable response producer +2. the response producer must return a tuple containing the content type and payload of each part +3. after producing the final part, the response producer must return None + +``` +# /app.py +import asyncio + +from pyrobusta.server import http_server +from pyrobusta.protocol.http import HttpEngine + +def multipart_response(num_responses, part_size): + i = 0 + def response_producer(): + nonlocal i + i += 1 + if i > num_responses: + return None + return "text/plain", b"X" * part_size + return response_producer + +@HttpEngine.route("/multipart", "GET") +def multipart_handler(http_ctx, _): + http_ctx.set_response_header(b"transfer-encoding", b"chunked") + part_count = int(http_ctx.headers.get("x-part-count", 1)) + part_size = int(http_ctx.headers.get("x-part-size", 1024)) + return "multipart/form-data", multipart_response(part_count, part_size) + +async def main(): + server = http_server.HttpServer() + asyncio.create_task(server.start_socket_server()) + while True: + await asyncio.sleep(1) +``` + +Try the `X-Part-Count` and `X-Part-Size` headers to arbitrarily configure the response size. + +``` +$ curl -H "X-Part-Count: 3" -H "X-Part-Size: 10" 192.168.1.101/multipart +--pyrobusta-boundary +content-type:text/plain + +XXXXXXXXXX +--pyrobusta-boundary +content-type:text/plain + +XXXXXXXXXX +--pyrobusta-boundary +content-type:text/plain + +XXXXXXXXXX +--pyrobusta-boundary-- +``` + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/routing.md b/docs/application_development/routing.md new file mode 100644 index 0000000..1ca4bbe --- /dev/null +++ b/docs/application_development/routing.md @@ -0,0 +1,169 @@ +# Routing + +[← Back](index.md) + +Routing maps incoming HTTP requests to application-defined route handlers. +This page describes how routes are defined, how route handlers receive requests, and how wildcard routes +can be used to match dynamic URL paths. + +--- + +## Table of Contents + +* [Routing](#routing) + + [Route Definitions](#route-definitions) + + [Route Handlers](#route-handlers) + + [Wildcard Routes](#wildcard-routes) + + [Route Registration & Deregistration](#route-registration-deregistration) + +--- + +## Route Definitions + +Routes map HTTP requests to server-side handler functions that +process requests and manage resources. Similar to common web frameworks, PyRobusta +utilizes function decorators to map handler functions to URL paths. A route handler +can only be mapped to a single URL path and HTTP method. The same URL path may be +associated with multiple route handlers provided that each handler uses a different +HTTP method. + +``` +from pyrobusta.protocol.http import HttpEngine + +@HttpEngine.route("/app/resource", "GET") +def get_handler(http_ctx, _): + return "text/plain", "resource content\n" + +@HttpEngine.route("/app/resource", "POST") +def post_handler(http_ctx, payload): + return "text/plain", "payload processed\n" +``` + +## Route Handlers + +In PyRobusta, a route handler is a synchronous function registered to a +specific URL path and HTTP method. PyRobusta invokes a route handler whenever it receives a +request whose URL path and HTTP method match the registered route. Route handlers must accept +exactly two positional arguments: + +* HTTP context (`HttpEngine` class) +* Request body (`memoryview`) + +### HTTP Context + +The HTTP context is an instance of the `HttpEngine` class that exposes the +public API used to inspect requests and construct responses. The HTTP context provides public +methods and attributes that enable user applications to process headers and structure responses. +By convention, non-public attributes and methods are prefixed with an underscore. + +Apart from the public API, the HTTP context also encapsulates the state associated with the current +request and response exchange. Internally, the HTTP context ensures protocol correctness and assists +with request routing. + +### Request Body + +Depending on the request type, the body argument may contain either the complete +request body or a partial payload chunk. Partial request bodies are passed to route handlers +when the request uses multipart encoding or chunked transfer encoding, with each chunk of the payload +fed incrementally to the route handler. Such request processing is documented in the +[Request Processing](./request.md#streamed-requests) guide. + +## Wildcard Routes + +Wildcard routes use placeholders in one or more segments of a URL path. +These placeholders match varying path values, allowing multiple URL paths to be mapped to +a single handler function. A placeholder matches a single path segment by default. Alternatively, +a placeholder can match multiple segments by using the `:path` suffix. For example: + +`/path/to/{resource:path}` + +Placeholders that match multiple path segments are only allowed at the end of a route. For example, +`/path/{to:path}/resource` is disallowed because it would result in ambiguous route resolution. + +``` +from pyrobusta.protocol.http import HttpEngine + +sensor_values = { + "dht22": { + "temperature_c": 22, + "rel_hum": 45 + } +} + +@HttpEngine.route("/sensors/{name}/values/{value_name}", "GET") +def wildcard_url_handler(http_ctx, _): + sensor_name = http_ctx.path_segment(1) + sensor_value = http_ctx.path_segment(3) + + if sensor_name not in sensor_values: + http_ctx.terminate(404) + return "text/plain", "Not found" + + values = sensor_values[sensor_name] + + if sensor_value not in values: + http_ctx.terminate(404) + return "text/plain", "Not found" + + return "text/plain", str(values[sensor_value]) +``` + +## Route Registration & Deregistration + +By convention, user applications should use decorators +to register route handlers in most cases. This approach ensures that routes are registered +during application initialization, and routes remain available for the lifetime of the application. +The public API exposed by `HttpEngine` allows route registration and deregistration +during an application's lifecycle, enabling applications to dynamically expose or +remove functionality at runtime. + +``` +from pyrobusta.protocol.http import HttpEngine + +sensor_values = { + "dht22": { + "temperature_c": 22, + "rel_hum": 45 + }, + "ldr": { + "illuminance_lx": 50 + } +} + +def dht22_handler(http_ctx, _): + return "application/json", sensor_values["dht22"] + +def ldr_handler(http_ctx, _): + return "application/json", sensor_values["ldr"] + +@HttpEngine.route("/sensor/{name}/enabled", "PUT") +def enable_sensor(http_ctx, enabled): + sensor_name = http_ctx.path_segment(1) + + if sensor_name not in sensor_values: + http_ctx.terminate(404) + return "text/plain", "Not found" + + if enabled.decode().lower() not in ("true", "false"): + http_ctx.terminate(400) + return "text/plain", "Invalid value" + + is_enabled = enabled.decode().lower() == "true" + + if sensor_name == "dht22": + route_handler = dht22_handler + + elif sensor_name == "ldr": + route_handler = ldr_handler + + if is_enabled: + HttpEngine.register(f"/sensor/{sensor_name}/value", route_handler, "GET") + else: + HttpEngine.deregister(f"/sensor/{sensor_name}/value", "GET") + + return "text/plain", "OK" +``` + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/security.md b/docs/application_development/security.md new file mode 100644 index 0000000..e935a2b --- /dev/null +++ b/docs/application_development/security.md @@ -0,0 +1,27 @@ +# Authentication & Security + +[← Back](index.md) + +--- + +## Table of Contents + +* [Authentication & Security](#authentication-security) + + [API Key Authentication](#api-key-authentication) + + [Basic Authentication](#basic-authentication) + + [HTTPS / TLS](#https-tls) + + [Certificate Installation](#certificate-installation) + +--- + +## API Key Authentication + +## Basic Authentication + +## HTTPS / TLS + +## Certificate Installation + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/server_lifecycle.md b/docs/application_development/server_lifecycle.md new file mode 100644 index 0000000..abca0c0 --- /dev/null +++ b/docs/application_development/server_lifecycle.md @@ -0,0 +1,30 @@ +# Server Lifecycle + +[← Back](index.md) + +--- + +## Table of Contents + +* [Server Lifecycle](#server-lifecycle) + + [Server Startup](#server-startup) + + [Application Initialization](#application-initialization) + + [Wi-Fi Connectivity](#wi-fi-connectivity) + + [Keep-alive Connections](#keep-alive-connections) + + [Deep Sleep Considerations](#deep-sleep-considerations) + +--- + +## Server Startup + +## Application Initialization + +## Wi-Fi Connectivity + +## Keep-alive Connections + +## Deep Sleep Considerations + +--- + +PyRobusta v0.7.0 Web Server diff --git a/docs/application_development/static_content.md b/docs/application_development/static_content.md new file mode 100644 index 0000000..27238ea --- /dev/null +++ b/docs/application_development/static_content.md @@ -0,0 +1,75 @@ +# Static Content + +[← Back](index.md) + +PyRobusta serves static content directly from the filesystem. This page +describes the directory structure, file resolution rules, and MIME type +handling used by the server. + +--- + +## Table of Contents + +* [Static Content](#static-content) + + [Static File Serving](#static-file-serving) + + [Directory Structure](#directory-structure) + + [MIME Type Handling](#mime-type-handling) + +--- + +## Static File Serving + +Files stored under `/www` are served as static content. +Requests to the server root (`/`) return the default landing page (`index.html`). +For static content requests, the server automatically prepends `/www` to the requested path before +resolving the corresponding file on the filesystem. + +When the file management API is enabled (`http_files_api=True`), +additional filesystem locations can be exposed through the `http_served_paths` +configuration option. See the [Server Configuration](./configuration.md) +and [File Server API](./file_server.md) guide for additional details. + +## Directory Structure + +``` +root/ +├── www document root for static content +│ ├── index.html +│ ├── introduction.html +│ ├── ... +│ └── user_data root for user uploads +│ └── ... +├── lib root for installed MIP packages +│ ├── pyrobusta +│ │ ├── bindings +│ │ ├── connectivity +│ │ └── ... +│ └──