|
| 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] |
0 commit comments