From c49905dbc74fa3274681fb2d0dada5e0d721421c Mon Sep 17 00:00:00 2001 From: Brian Grenier Date: Sat, 30 Nov 2024 11:58:17 -0700 Subject: [PATCH] Allow mutation in server-side service implementation --- README.md | 32 +++++++++++++++---- lightbug_http/__init__.mojo | 2 +- lightbug_http/cookie/cookie.mojo | 7 ++-- lightbug_http/cookie/duration.mojo | 10 ++---- lightbug_http/cookie/expiration.mojo | 14 ++++++-- lightbug_http/cookie/request_cookie_jar.mojo | 2 +- lightbug_http/cookie/response_cookie_jar.mojo | 11 +++---- lightbug_http/cookie/same_site.mojo | 4 +-- lightbug_http/http/request.mojo | 3 +- lightbug_http/http/response.mojo | 2 +- lightbug_http/server.mojo | 6 ++-- lightbug_http/service.mojo | 22 ++++++++++--- lightbug_http/strings.mojo | 8 ++--- testutils/utils.mojo | 2 +- 14 files changed, 77 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 0ca92d7b..6d6e4905 100644 --- a/README.md +++ b/README.md @@ -51,44 +51,57 @@ Learn how to get up and running with Mojo on the [Modular website](https://www.m Once you have a Mojo project set up locally, 1. Add the `mojo-community` channel to your `mojoproject.toml`, e.g: + ```toml [project] channels = ["conda-forge", "https://conda.modular.com/max", "https://repo.prefix.dev/mojo-community"] ``` + 2. Add `lightbug_http` as a dependency: + ```toml [dependencies] lightbug_http = ">=0.1.5" ``` + 3. Run `magic install` at the root of your project, where `mojoproject.toml` is located 4. Lightbug should now be installed as a dependency. You can import all the default imports at once, e.g: + ```mojo from lightbug_http import * ``` + or import individual structs and functions, e.g. + ```mojo from lightbug_http.service import HTTPService from lightbug_http.http import HTTPRequest, HTTPResponse, OK, NotFound ``` + there are some default handlers you can play with: + ```mojo from lightbug_http.service import Printer # prints request details to console from lightbug_http.service import Welcome # serves an HTML file with an image (currently requires manually adding files to static folder, details below) from lightbug_http.service import ExampleRouter # serves /, /first, /second, and /echo routes ``` + 5. Add your handler in `lightbug.🔥` by passing a struct that satisfies the following trait: + ```mojo trait HTTPService: - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: ... ``` + For example, to make a `Printer` service that prints some details about the request to console: + ```mojo from lightbug_http import * @value struct Printer(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var uri = req.uri print("Request URI: ", to_string(uri.request_uri)) @@ -104,7 +117,9 @@ Once you have a Mojo project set up locally, return OK(body) ``` -6. Start a server listening on a port with your service like so. + +6. Start a server listening on a port with your service like so. + ```mojo from lightbug_http import Welcome, Server @@ -113,20 +128,22 @@ Once you have a Mojo project set up locally, var handler = Welcome() server.listen_and_serve("0.0.0.0:8080", handler) ``` + Feel free to change the settings in `listen_and_serve()` to serve on a particular host and port. Now send a request `0.0.0.0:8080`. You should see some details about the request printed out to the console. - + Congrats 🥳 You're using Lightbug! Routing is not in scope for this library, but you can easily set up routes yourself: + ```mojo from lightbug_http import * @value struct ExampleRouter(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var body = req.body_raw var uri = req.uri @@ -156,7 +173,7 @@ from lightbug_http import * @value struct Welcome(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var uri = req.uri if uri.path == "/": @@ -216,10 +233,13 @@ fn main() -> None: Pure Mojo-based client is available by default. This client is also used internally for testing the server. ## Switching between pure Mojo and Python implementations + By default, Lightbug uses the pure Mojo implementation for networking. To use Python's `socket` library instead, just import the `PythonServer` instead of the `Server` with the following line: + ```mojo from lightbug_http.python.server import PythonServer ``` + You can then use all the regular server commands in the same way as with the default server. Note: as of September, 2024, `PythonServer` and `PythonClient` throw a compilation error when starting. There's an open [issue](https://github.com/saviorand/lightbug_http/issues/41) to fix this - contributions welcome! diff --git a/lightbug_http/__init__.mojo b/lightbug_http/__init__.mojo index 67cc39c9..8839a808 100644 --- a/lightbug_http/__init__.mojo +++ b/lightbug_http/__init__.mojo @@ -2,7 +2,7 @@ from lightbug_http.http import HTTPRequest, HTTPResponse, OK, NotFound, StatusCo from lightbug_http.uri import URI from lightbug_http.header import Header, Headers, HeaderKey from lightbug_http.cookie import Cookie, RequestCookieJar, ResponseCookieJar -from lightbug_http.service import HTTPService, Welcome +from lightbug_http.service import HTTPService, Welcome, Counter from lightbug_http.server import Server from lightbug_http.strings import to_string diff --git a/lightbug_http/cookie/cookie.mojo b/lightbug_http/cookie/cookie.mojo index 3486f3c3..f255d96c 100644 --- a/lightbug_http/cookie/cookie.mojo +++ b/lightbug_http/cookie/cookie.mojo @@ -1,6 +1,7 @@ from collections import Optional from lightbug_http.header import HeaderKey + struct Cookie(CollectionElement): alias EXPIRES = "Expires" alias MAX_AGE = "Max-Age" @@ -25,7 +26,6 @@ struct Cookie(CollectionElement): var path: Optional[String] var max_age: Optional[Duration] - @staticmethod fn from_set_header(header_str: String) raises -> Self: var parts = header_str.split(Cookie.SEPERATOR) @@ -55,7 +55,7 @@ struct Cookie(CollectionElement): elif part.startswith(Cookie.MAX_AGE): cookie.max_age = Duration.from_string(part.removeprefix(Cookie.MAX_AGE + Cookie.EQUAL)) elif part.startswith(Cookie.EXPIRES): - var expires = Expiration.from_string(part.removeprefix(Cookie.EXPIRES + Cookie.EQUAL)) + var expires = Expiration.from_string(part.removeprefix(Cookie.EXPIRES + Cookie.EQUAL)) if expires: cookie.expires = expires.value() @@ -86,7 +86,7 @@ struct Cookie(CollectionElement): self.partitioned = partitioned fn __str__(self) -> String: - return "Name: " + self.name + " Value: " + self.value + return "Name: " + self.name + " Value: " + self.value fn __copyinit__(inout self: Cookie, existing: Cookie): self.name = existing.name @@ -120,7 +120,6 @@ struct Cookie(CollectionElement): return Header(HeaderKey.SET_COOKIE, self.build_header_value()) fn build_header_value(self) -> String: - var header_value = self.name + Cookie.EQUAL + self.value if self.expires.is_datetime(): var v = self.expires.http_date_timestamp() diff --git a/lightbug_http/cookie/duration.mojo b/lightbug_http/cookie/duration.mojo index 4c14cb1d..7230f4ff 100644 --- a/lightbug_http/cookie/duration.mojo +++ b/lightbug_http/cookie/duration.mojo @@ -1,14 +1,8 @@ @value -struct Duration(): +struct Duration: var total_seconds: Int - fn __init__( - inout self, - seconds: Int = 0, - minutes: Int = 0, - hours: Int = 0, - days: Int = 0 - ): + fn __init__(inout self, seconds: Int = 0, minutes: Int = 0, hours: Int = 0, days: Int = 0): self.total_seconds = seconds self.total_seconds += minutes * 60 self.total_seconds += hours * 60 * 60 diff --git a/lightbug_http/cookie/expiration.mojo b/lightbug_http/cookie/expiration.mojo index e2204215..bc13d74f 100644 --- a/lightbug_http/cookie/expiration.mojo +++ b/lightbug_http/cookie/expiration.mojo @@ -1,8 +1,11 @@ alias HTTP_DATE_FORMAT = "ddd, DD MMM YYYY HH:mm:ss ZZZ" alias TZ_GMT = TimeZone(0, "GMT") +from small_time import SmallTime + + @value -struct Expiration: +struct Expiration(CollectionElement): var variant: UInt8 var datetime: Optional[SmallTime] @@ -15,7 +18,7 @@ struct Expiration: return Self(variant=1, datetime=time) @staticmethod - fn from_string(str: String) -> Optional[Self]: + fn from_string(str: String) -> Optional[Expiration]: try: return Self.from_datetime(strptime(str, HTTP_DATE_FORMAT, TZ_GMT)) except: @@ -44,5 +47,10 @@ struct Expiration: if self.variant != other.variant: return False if self.variant == 1: - return self.datetime == other.datetime + if bool(self.datetime) != bool(other.datetime): + return False + elif not bool(self.datetime) and not bool(other.datetime): + return True + return self.datetime.value().isoformat() == other.datetime.value().isoformat() + return True diff --git a/lightbug_http/cookie/request_cookie_jar.mojo b/lightbug_http/cookie/request_cookie_jar.mojo index 5b0ff1f8..ccb0f63c 100644 --- a/lightbug_http/cookie/request_cookie_jar.mojo +++ b/lightbug_http/cookie/request_cookie_jar.mojo @@ -5,6 +5,7 @@ from lightbug_http.strings import to_string, lineBreak from lightbug_http.header import HeaderKey, write_header from lightbug_http.utils import ByteReader, ByteWriter, is_newline, is_space + @value struct RequestCookieJar(Formattable, Stringable): var _inner: Dict[String, String] @@ -34,7 +35,6 @@ struct RequestCookieJar(Formattable, Stringable): # TODO value must be "unquoted" self._inner[key] = value - @always_inline fn empty(self) -> Bool: return len(self._inner) == 0 diff --git a/lightbug_http/cookie/response_cookie_jar.mojo b/lightbug_http/cookie/response_cookie_jar.mojo index f10f2f17..61919dcf 100644 --- a/lightbug_http/cookie/response_cookie_jar.mojo +++ b/lightbug_http/cookie/response_cookie_jar.mojo @@ -14,7 +14,7 @@ struct ResponseCookieKey(KeyElement): inout self, name: String, domain: Optional[String] = Optional[String](None), - path: Optional[String] = Optional[String](None) + path: Optional[String] = Optional[String](None), ): self.name = name self.domain = domain.or_else("") @@ -39,6 +39,7 @@ struct ResponseCookieKey(KeyElement): fn __hash__(self: Self) -> UInt: return hash(self.name + "~" + self.domain + "~" + self.path) + @value struct ResponseCookieJar(Formattable, Stringable): var _inner: Dict[ResponseCookieKey, Cookie] @@ -76,7 +77,7 @@ struct ResponseCookieJar(Formattable, Stringable): return to_string(self) fn __len__(self) -> Int: - return len(self._inner) + return len(self._inner) @always_inline fn set_cookie(inout self, cookie: Cookie): @@ -86,7 +87,6 @@ struct ResponseCookieJar(Formattable, Stringable): fn empty(self) -> Bool: return len(self) == 0 - fn from_headers(inout self, headers: List[String]) raises: for header in headers: try: @@ -97,10 +97,9 @@ struct ResponseCookieJar(Formattable, Stringable): fn encode_to(inout self, inout writer: ByteWriter): for cookie in self._inner.values(): var v = cookie[].build_header_value() - write_header(writer, HeaderKey.SET_COOKIE , v) - + write_header(writer, HeaderKey.SET_COOKIE, v) fn format_to(self, inout writer: Formatter): for cookie in self._inner.values(): var v = cookie[].build_header_value() - write_header(writer, HeaderKey.SET_COOKIE , v) + write_header(writer, HeaderKey.SET_COOKIE, v) diff --git a/lightbug_http/cookie/same_site.mojo b/lightbug_http/cookie/same_site.mojo index 4e32083c..ad97eef3 100644 --- a/lightbug_http/cookie/same_site.mojo +++ b/lightbug_http/cookie/same_site.mojo @@ -1,6 +1,6 @@ @value -struct SameSite(): - var value : UInt8 +struct SameSite: + var value: UInt8 alias none = SameSite(0) alias lax = SameSite(1) diff --git a/lightbug_http/http/request.mojo b/lightbug_http/http/request.mojo index b75bf036..402dc0a3 100644 --- a/lightbug_http/http/request.mojo +++ b/lightbug_http/http/request.mojo @@ -40,7 +40,7 @@ struct HTTPRequest(Formattable, Stringable): var protocol: String var uri_str: String try: - var rest = headers.parse_raw(reader) + var rest = headers.parse_raw(reader) method, uri_str, protocol = rest[0], rest[1], rest[2] except e: raise Error("Failed to parse request headers: " + e.__str__()) @@ -89,7 +89,6 @@ struct HTTPRequest(Formattable, Stringable): if HeaderKey.HOST not in self.headers: self.headers[HeaderKey.HOST] = uri.host - fn set_connection_close(inout self): self.headers[HeaderKey.CONNECTION] = "close" diff --git a/lightbug_http/http/response.mojo b/lightbug_http/http/response.mojo index 4a7ec9c5..8f32c380 100644 --- a/lightbug_http/http/response.mojo +++ b/lightbug_http/http/response.mojo @@ -47,7 +47,7 @@ struct HTTPResponse(Formattable, Stringable): var status_text: String try: - var properties = headers.parse_raw(reader) + var properties = headers.parse_raw(reader) protocol, status_code, status_text = properties[0], properties[1], properties[2] cookies.from_headers(properties[3]) reader.skip_newlines() diff --git a/lightbug_http/server.mojo b/lightbug_http/server.mojo index 851ed04e..e1c95efa 100644 --- a/lightbug_http/server.mojo +++ b/lightbug_http/server.mojo @@ -119,7 +119,7 @@ struct Server: concurrency = DefaultConcurrency return concurrency - fn listen_and_serve[T: HTTPService](inout self, address: String, handler: T) raises -> None: + fn listen_and_serve[T: HTTPService](inout self, address: String, inout handler: T) raises -> None: """ Listen for incoming connections and serve HTTP requests. @@ -132,7 +132,7 @@ struct Server: _ = self.set_address(address) self.serve(listener, handler) - fn serve[T: HTTPService](inout self, ln: NoTLSListener, handler: T) raises -> None: + fn serve[T: HTTPService](inout self, ln: NoTLSListener, inout handler: T) raises -> None: """ Serve HTTP requests. @@ -149,7 +149,7 @@ struct Server: var conn = self.ln.accept() self.serve_connection(conn, handler) - fn serve_connection[T: HTTPService](inout self, conn: SysConnection, handler: T) raises -> None: + fn serve_connection[T: HTTPService](inout self, conn: SysConnection, inout handler: T) raises -> None: """ Serve a single connection. diff --git a/lightbug_http/service.mojo b/lightbug_http/service.mojo index 5df31ccc..c6aaee62 100644 --- a/lightbug_http/service.mojo +++ b/lightbug_http/service.mojo @@ -5,13 +5,13 @@ from lightbug_http.header import HeaderKey trait HTTPService: - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: ... @value struct Printer(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var uri = req.uri print("Request URI: ", to_string(uri.request_uri)) @@ -28,7 +28,7 @@ struct Printer(HTTPService): @value struct Welcome(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var uri = req.uri if uri.path == "/": @@ -48,7 +48,7 @@ struct Welcome(HTTPService): @value struct ExampleRouter(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var body = req.body_raw var uri = req.uri @@ -66,7 +66,7 @@ struct ExampleRouter(HTTPService): @value struct TechEmpowerRouter(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var uri = req.uri if uri.path == "/plaintext": @@ -75,3 +75,15 @@ struct TechEmpowerRouter(HTTPService): return OK('{"message": "Hello, World!"}', "application/json") return OK("Hello world!") # text/plain is the default + + +@value +struct Counter(HTTPService): + var counter: Int + + fn __init__(inout self): + self.counter = 0 + + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: + self.counter += 1 + return OK("I have been called: " + str(self.counter) + " times") diff --git a/lightbug_http/strings.mojo b/lightbug_http/strings.mojo index d082cc7c..e56848d1 100644 --- a/lightbug_http/strings.mojo +++ b/lightbug_http/strings.mojo @@ -1,4 +1,4 @@ -from utils import Span +from utils import Span, StringSlice from lightbug_http.io.bytes import Bytes from lightbug_http.io.bytes import Bytes, bytes, byte @@ -107,12 +107,10 @@ fn to_string(b: Span[UInt8]) -> String: Args: b: The Span of bytes to convert to a String. """ - var bytes = List[UInt8, True](b) - bytes.append(0) - return String(bytes^) + return String(StringSlice(unsafe_from_utf8=b)) -fn to_string(owned bytes: List[UInt8, True]) -> String: +fn to_string(owned bytes: Bytes) -> String: """Creates a String from the provided List of bytes. If you do not transfer ownership of the List, the List will be copied. diff --git a/testutils/utils.mojo b/testutils/utils.mojo index c2131e4b..34e932e3 100644 --- a/testutils/utils.mojo +++ b/testutils/utils.mojo @@ -134,7 +134,7 @@ struct FakeServer(ServerTrait): @value struct FakeResponder(HTTPService): - fn func(self, req: HTTPRequest) raises -> HTTPResponse: + fn func(inout self, req: HTTPRequest) raises -> HTTPResponse: var method = req.method if method != "GET": raise Error("Did not expect a non-GET request! Got: " + method)