Skip to content

Commit b271d30

Browse files
committed
add streaming-p3 example
Signed-off-by: Joel Dice <joel.dice@akamai.com>
1 parent 62f54c6 commit b271d30

5 files changed

Lines changed: 232 additions & 0 deletions

File tree

examples/streaming-p3/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Example: Streaming with WASIp3
2+
3+
This is an example showcasing the use of HTTP request and response body
4+
streaming within a guest component using WASIp3.
5+
6+
## Preparing the Environment
7+
8+
Run the following commands to setup a virtual environment with Python.
9+
10+
```bash
11+
python3 -m venv venv
12+
source venv/bin/activate
13+
```
14+
15+
Install the required packages specified in the `requirements.txt` using the
16+
command:
17+
18+
```bash
19+
pip3 install -r requirements.txt
20+
```
21+
22+
## Building and Running the Examples
23+
24+
```bash
25+
spin build --up
26+
```
27+
28+
## Testing the App
29+
30+
```
31+
curl -i -H 'content-type: text/plain' --data-binary @- http://127.0.0.1:8080/echo <<EOF
32+
’Twas brillig, and the slithy toves
33+
Did gyre and gimble in the wabe:
34+
All mimsy were the borogoves,
35+
And the mome raths outgrabe.
36+
EOF
37+
```
38+
39+
The above should echo the request body in the response.
40+
41+
In addition to the `/echo` endpoint, the app supports a `/hash-all` endpoint
42+
which concurrently downloads one or more URLs and streams the SHA-256 hashes of
43+
their contents. You can test it with e.g.:
44+
45+
```
46+
curl -i \
47+
-H 'url: https://webassembly.github.io/spec/core/' \
48+
-H 'url: https://www.w3.org/groups/wg/wasm/' \
49+
-H 'url: https://bytecodealliance.org/' \
50+
http://127.0.0.1:8080/hash-all
51+
```
52+
53+
If you run into any problems, please file an issue!

examples/streaming-p3/app.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Demo of a serverless app using `wasi-http` to handle inbound HTTP requests.
2+
3+
This demonstrates how to use WASI's asynchronous capabilities to manage multiple
4+
concurrent requests and streaming bodies. It uses a custom `asyncio` event loop
5+
to thread I/O through coroutines.
6+
"""
7+
8+
import asyncio
9+
import hashlib
10+
import componentize_py_async_support
11+
12+
from typing import Optional
13+
from componentize_py_types import Ok, Result
14+
from componentize_py_async_support.streams import ByteStreamWriter
15+
from componentize_py_async_support.futures import FutureReader
16+
from spin_sdk import wit
17+
from spin_sdk.wit import exports
18+
from spin_sdk.wit.imports import wasi_http_client_0_3_0_rc_2026_03_15 as client
19+
from spin_sdk.wit.imports.wasi_http_types_0_3_0_rc_2026_03_15 import (
20+
Method_Get,
21+
Method_Post,
22+
Scheme,
23+
Scheme_Http,
24+
Scheme_Https,
25+
Scheme_Other,
26+
Request,
27+
Response,
28+
Fields,
29+
ErrorCode
30+
)
31+
from urllib import parse
32+
33+
34+
class WasiHttpHandler030Rc20260315(exports.WasiHttpHandler030Rc20260315):
35+
"""Implements the `export`ed portion of the `wasi-http` `proxy` world."""
36+
37+
async def handle(self, request: Request) -> Response:
38+
"""Handle the specified `request`, returning a `Response`."""
39+
40+
method = request.get_method()
41+
path = request.get_path_with_query()
42+
headers = request.get_headers().copy_all()
43+
44+
if isinstance(method, Method_Get) and path == "/hash-all":
45+
# Collect one or more "url" headers, download their contents
46+
# concurrently, compute their SHA-256 hashes incrementally (i.e. without
47+
# buffering the response bodies), and stream the results back to the
48+
# client as they become available.
49+
50+
urls = list(map(
51+
lambda pair: str(pair[1], "utf-8"),
52+
filter(lambda pair: pair[0] == "url", headers),
53+
))
54+
55+
tx, rx = wit.byte_stream()
56+
componentize_py_async_support.spawn(hash_all(urls, tx))
57+
58+
return Response.new(
59+
Fields.from_list([("content-type", b"text/plain")]),
60+
rx,
61+
trailers_future()
62+
)[0]
63+
64+
elif isinstance(method, Method_Post) and path == "/echo":
65+
# Echo the request body back to the client without buffering.
66+
67+
rx, trailers = Request.consume_body(request, unit_future())
68+
69+
return Response.new(
70+
Fields.from_list(
71+
list(filter(lambda pair: pair[0] == "content-type", headers))
72+
),
73+
rx,
74+
trailers
75+
)[0]
76+
77+
else:
78+
response = Response.new(Fields(), None, trailers_future())[0]
79+
response.set_status_code(400)
80+
return response
81+
82+
83+
async def hash_all(urls: list[str], tx: ByteStreamWriter) -> None:
84+
with tx:
85+
for result in asyncio.as_completed(map(sha256, urls)):
86+
url, sha = await result
87+
await tx.write_all(bytes(f"{url}: {sha}\n", "utf-8"))
88+
89+
90+
async def sha256(url: str) -> tuple[str, str]:
91+
"""Download the contents of the specified URL, computing the SHA-256
92+
incrementally as the response body arrives.
93+
94+
This returns a tuple of the original URL and either the hex-encoded hash or
95+
an error message.
96+
"""
97+
98+
url_parsed = parse.urlparse(url)
99+
100+
match url_parsed.scheme:
101+
case "http":
102+
scheme: Scheme = Scheme_Http()
103+
case "https":
104+
scheme = Scheme_Https()
105+
case _:
106+
scheme = Scheme_Other(url_parsed.scheme)
107+
108+
request = Request.new(Fields(), None, trailers_future(), None)[0]
109+
request.set_scheme(scheme)
110+
request.set_authority(url_parsed.netloc)
111+
request.set_path_with_query(url_parsed.path)
112+
113+
response = await client.send(request)
114+
status = response.get_status_code()
115+
if status < 200 or status > 299:
116+
return url, f"unexpected status: {status}"
117+
118+
rx = Response.consume_body(response, unit_future())[0]
119+
120+
hasher = hashlib.sha256()
121+
with rx:
122+
while not rx.writer_dropped:
123+
chunk = await rx.read(16 * 1024)
124+
hasher.update(chunk)
125+
126+
return url, hasher.hexdigest()
127+
128+
129+
def trailers_future() -> FutureReader[Result[Optional[Fields], ErrorCode]]:
130+
return wit.result_option_wasi_http_types_0_3_0_rc_2026_03_15_fields_wasi_http_types_0_3_0_rc_2026_03_15_error_code_future(lambda: Ok(None))[1]
131+
132+
133+
def unit_future() -> FutureReader[Result[None, ErrorCode]]:
134+
return wit.result_unit_wasi_http_types_0_3_0_rc_2026_03_15_error_code_future(lambda: Ok(None))[1]
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
spin-sdk == 4.0.0
2+
componentize-py == 0.22.0

examples/streaming-p3/spin.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
spin_manifest_version = 2
2+
3+
[application]
4+
name = "test"
5+
version = "0.1.0"
6+
authors = ["Fermyon Engineering <engineering@fermyon.com>"]
7+
description = ""
8+
9+
[[trigger.http]]
10+
route = "/..."
11+
component = "test"
12+
13+
[component.test]
14+
source = "app.wasm"
15+
allowed_outbound_hosts = ["http://*:*", "https://*:*"]
16+
[component.test.build]
17+
command = "componentize-py -w wasi:http/service@0.3.0-rc-2026-03-15 componentize app -o app.wasm"

run_tests.sh

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,30 @@ then
3838
fi
3939
popd
4040

41+
for example in examples/streaming examples/streaming-p3
42+
do
43+
pushd $example
44+
spin up &
45+
spin_pid=$!
46+
47+
for x in $(seq 1 10)
48+
do
49+
message="$(curl -s -d 'Hello from Python!' localhost:3000/echo)"
50+
if [ "$message" = "Hello from Python!" ]
51+
then
52+
result=success
53+
break
54+
fi
55+
sleep 1
56+
done
57+
58+
kill "$spin_pid"
59+
60+
if [ "$result" != "success" ]
61+
then
62+
exit 1
63+
fi
64+
popd
65+
done
66+
4167
# TODO: run more examples

0 commit comments

Comments
 (0)