From 98046ec7b4d16fe8ba8f064781f1d31ef3a5ee68 Mon Sep 17 00:00:00 2001 From: Mikhail Tavarez Date: Sun, 31 May 2026 10:54:52 -0500 Subject: [PATCH 1/3] Using struct deserialization --- floki/body.mojo | 44 ++----- floki/functions.mojo | 54 ++++---- floki/response.mojo | 21 ++- floki/session.mojo | 62 ++++----- pixi.toml | 2 +- test/test_free_functions.mojo | 40 +++++- test/test_json.mojo | 31 +++++ test/test_response.mojo | 49 +++---- test/test_session.mojo | 232 +++++++++++++++++++++++++--------- 9 files changed, 350 insertions(+), 185 deletions(-) create mode 100644 test/test_json.mojo diff --git a/floki/body.mojo b/floki/body.mojo index fd66861..9c1e16f 100644 --- a/floki/body.mojo +++ b/floki/body.mojo @@ -3,7 +3,7 @@ from std.collections.string._utf8 import _is_valid_utf8 import emberjson -struct Body(Copyable, Sized): +struct Body(Copyable, Sized, Writable, Equatable): """Represents the body of an HTTP request or response. At the moment, this only supports JSON serialization and deserialization. @@ -11,25 +11,16 @@ struct Body(Copyable, Sized): var body: List[Byte] """The raw body content as a list of bytes.""" - var _json_cache: Optional[emberjson.Value] - """An optional cache for the parsed JSON value, to avoid redundant parsing on multiple accesses.""" - def __init__(out self, var body: List[Byte]) raises: + def __init__(out self, var body: List[Byte]): """Constructs a Body instance from a list of bytes. Args: body: The body content as a list of bytes. - - Raises: - * Error: if the body is not valid UTF-8. """ - if not _is_valid_utf8(body): - raise Error("Body must be valid UTF-8") - self.body = body^ - self._json_cache = None - def __init__[origin: ImmutOrigin, //](out self, body: Span[Byte, origin]) raises: + def __init__[origin: ImmutOrigin, //](out self, body: Span[Byte, origin]): """Alternate constructor that accepts a Span[Byte] for the body content. Parameters: @@ -37,14 +28,8 @@ struct Body(Copyable, Sized): Args: body: The body content as a span of bytes. - - Raises: - * Error: if the body is not valid UTF-8. """ - if not _is_valid_utf8(body): - raise Error("Body must be valid UTF-8") self.body = List[Byte](body) - self._json_cache = None def __len__(self) -> Int: """Returns the length of the body in bytes. @@ -62,15 +47,15 @@ struct Body(Copyable, Sized): """ return Span(self.body) - def as_string_slice(self) -> StringSlice[origin_of(self.body)]: + def as_text(self) raises -> StringSlice[origin_of(self.body)]: """Creates and returns a `StringSlice` view of the body content. Returns: The body content as a string slice. """ - return StringSlice(unsafe_from_utf8=Span(self.body)) - - def as_json(mut self) raises -> ref [origin_of(self._json_cache._value)] emberjson.Value: + return StringSlice(from_utf8=Span(self.body)) + + def as_json[T: Movable & ImplicitlyDestructible & Defaultable](mut self, out result: T) raises: """Converts the response body to a JSON object. Returns: @@ -79,24 +64,17 @@ struct Body(Copyable, Sized): Raises: Error: if the body is empty or cannot be parsed as JSON. """ - if not self.body: - raise Error("Body is empty; cannot parse as JSON.") - - if self._json_cache: - return self._json_cache.value() - - self._json_cache = emberjson.parse(StringSlice(from_utf8=self.body)) - return self._json_cache.value() + return emberjson.deserialize[T](emberjson.Parser(self.as_text())) - def write_to(self, mut writer: Some[Writer]): + def write_to(self, mut writer: Some[Writer]) raises: """Writes the body to a writer. Args: writer: The writer to which the body will be written. """ - writer.write(StringSlice(unsafe_from_utf8=self.body)) + writer.write(self.as_text()) - def consume(deinit self) -> List[Byte]: + def take_bytes(deinit self) -> List[Byte]: """Consumes the body and returns it as List[Byte]. Returns: diff --git a/floki/functions.mojo b/floki/functions.mojo index 6b5b004..261ba67 100644 --- a/floki/functions.mojo +++ b/floki/functions.mojo @@ -1,5 +1,5 @@ from floki.session import Session, RequestData -from floki.response import HTTPResponse +from floki.response import Response from floki.http import RequestMethod from floki.body import Body import emberjson @@ -10,7 +10,7 @@ def get( var headers: Dict[String, String] = {}, query_parameters: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a GET request to the specified URL. Args: @@ -20,7 +20,7 @@ def get( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the request fails. @@ -47,7 +47,7 @@ def post( var headers: Dict[String, String] = {}, var data: emberjson.Object = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a POST request to the specified URL. Args: @@ -57,7 +57,7 @@ def post( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be serialized to JSON or if the request fails. @@ -84,7 +84,7 @@ def post[origin: ImmutOrigin, //]( data: Span[Byte, origin], var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a POST request to the specified URL. Parameters: @@ -97,7 +97,7 @@ def post[origin: ImmutOrigin, //]( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be sent as bytes. @@ -123,7 +123,7 @@ def post( data: FileHandle, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a POST request to the specified URL. Args: @@ -133,7 +133,7 @@ def post( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be sent from the file handle. @@ -160,7 +160,7 @@ def put( var headers: Dict[String, String] = {}, var data: emberjson.Object = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a PUT request to the specified URL. Args: @@ -170,7 +170,7 @@ def put( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be serialized to JSON or if the request fails. @@ -197,7 +197,7 @@ def put[origin: ImmutOrigin, //]( data: Span[Byte, origin], var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a PUT request to the specified URL. Parameters: @@ -210,7 +210,7 @@ def put[origin: ImmutOrigin, //]( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be sent as bytes. @@ -236,7 +236,7 @@ def put( data: FileHandle, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a PUT request to the specified URL. Args: @@ -246,7 +246,7 @@ def put( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be sent from the file handle. @@ -272,7 +272,7 @@ def delete( var url: String, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a DELETE request to the specified URL. Args: @@ -281,7 +281,7 @@ def delete( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the request fails. @@ -307,7 +307,7 @@ def patch( var headers: Dict[String, String] = {}, var data: emberjson.Object = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a GET request to the specified URL. Args: @@ -317,7 +317,7 @@ def patch( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be serialized to JSON or if the request fails. @@ -343,7 +343,7 @@ def patch[origin: ImmutOrigin, //]( data: Span[Byte, origin], var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a GET request to the specified URL. Parameters: @@ -356,7 +356,7 @@ def patch[origin: ImmutOrigin, //]( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be sent as bytes. @@ -382,7 +382,7 @@ def patch( data: FileHandle, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a GET request to the specified URL. Args: @@ -392,7 +392,7 @@ def patch( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the data cannot be sent from the file handle. @@ -418,7 +418,7 @@ def head( var url: String, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends a HEAD request to the specified URL. Args: @@ -427,7 +427,7 @@ def head( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the request fails. @@ -452,7 +452,7 @@ def options( var url: String, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, -) raises -> HTTPResponse: +) raises -> Response: """Sends an OPTIONS request to the specified URL. Args: @@ -461,7 +461,7 @@ def options( timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If the request fails. diff --git a/floki/response.mojo b/floki/response.mojo index a41c2e0..f25a220 100644 --- a/floki/response.mojo +++ b/floki/response.mojo @@ -15,7 +15,7 @@ struct HTTPError(Movable, Writable): @fieldwise_init -struct HTTPResponse(Movable, Writable): +struct Response(Movable, Writable): """Represents an HTTP response received from the server.""" var headers: Dict[String, String] """The HTTP headers included in the response.""" @@ -36,7 +36,7 @@ struct HTTPResponse(Movable, Writable): protocol: Protocol, var headers: Dict[String, String] = {}, ) raises: - """Constructs an HTTPResponse from its component parts. + """Constructs an Response from its component parts. Args: body: The raw response body as a list of bytes. @@ -54,7 +54,7 @@ struct HTTPResponse(Movable, Writable): self.protocol = protocol self.body = Body(body^) - def write_to(self, mut writer: Some[Writer]): + def write_to(self, mut writer: Some[Writer]) raises: """Writes the HTTP response to a writer in a standard HTTP format. Args: @@ -69,7 +69,7 @@ struct HTTPResponse(Movable, Writable): CRLF, self.headers, CRLF, - self.body.as_string_slice() + self.body.as_text() ) @always_inline @@ -103,3 +103,16 @@ struct HTTPResponse(Movable, Writable): """ if not self.is_ok(): raise HTTPError(self.status) + + def content_length(self) -> Int: + """Returns the length of the response body in bytes. + + This does not necessarily correspond to the Content-Length header, + but rather the actual length of the body content. + + Returns: + The number of bytes in the response body. + """ + return len(self.body) + + diff --git a/floki/session.mojo b/floki/session.mojo index 240bd1a..3e61eff 100644 --- a/floki/session.mojo +++ b/floki/session.mojo @@ -3,7 +3,7 @@ from std.utils import Variant from mojo_curl.easy import Easy, Result from mojo_curl.list import CurlList from floki.callbacks import read_callback, fd_read_callback, write_callback -from floki.response import HTTPResponse +from floki.response import Response from floki.http import RequestMethod from floki.body import Body from floki.cookie.cookie_jar import CookieJar @@ -288,7 +288,7 @@ struct Session(Movable): """Indicates whether libcurl's verbose logging mode is enabled for this session.""" comptime DEFAULT_HEADERS = { - "User-Agent": "floki/0.2.0", + "User-Agent": "floki/0.3.2", } """Default headers that are included in every request made with this session, unless overridden by request-specific headers.""" @@ -338,7 +338,7 @@ struct Session(Movable): data: RequestData[origin], timeout: Optional[Int] = None, query_parameters: Dict[String, String] = {}, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends an HTTP request and returns the corresponding response. Parameters: @@ -353,7 +353,7 @@ struct Session(Movable): query_parameters: An optional dictionary of query parameters to include in the URL. GET requests only. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -429,7 +429,7 @@ struct Session(Movable): finally: list^.free() # Free headers after performing the request. - return HTTPResponse( + return Response( body=response_body^, headers=self.easy.headers(), protocol=Protocol(self.easy.get_scheme()), @@ -445,7 +445,7 @@ struct Session(Movable): var headers: Dict[String, String] = {}, query_parameters: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a GET request to the specified URL. Args: @@ -455,7 +455,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -483,7 +483,7 @@ struct Session(Movable): var headers: Dict[String, String] = {}, var data: emberjson.Object = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a POST request to the specified URL. Args: @@ -493,7 +493,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -521,7 +521,7 @@ struct Session(Movable): data: Span[Byte, origin], var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a POST request to the specified URL. Parameters: @@ -534,7 +534,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -561,7 +561,7 @@ struct Session(Movable): data: FileHandle, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a POST request to the specified URL. Args: @@ -571,7 +571,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -599,7 +599,7 @@ struct Session(Movable): var headers: Dict[String, String] = {}, var data: emberjson.Object = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a PUT request to the specified URL. Args: @@ -609,7 +609,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -637,7 +637,7 @@ struct Session(Movable): data: Span[Byte, origin], var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a PUT request to the specified URL. Parameters: @@ -650,7 +650,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -677,7 +677,7 @@ struct Session(Movable): data: FileHandle, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a PUT request to the specified URL. Args: @@ -687,7 +687,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -714,7 +714,7 @@ struct Session(Movable): var url: String, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a DELETE request to the specified URL. Args: @@ -723,7 +723,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -750,7 +750,7 @@ struct Session(Movable): var headers: Dict[String, String] = {}, var data: emberjson.Object = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a PATCH request to the specified URL. Args: @@ -760,7 +760,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -788,7 +788,7 @@ struct Session(Movable): data: Span[Byte, origin], var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a PATCH request to the specified URL. Parameters: @@ -801,7 +801,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -828,7 +828,7 @@ struct Session(Movable): data: FileHandle, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a PATCH request to the specified URL. Args: @@ -838,7 +838,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -865,7 +865,7 @@ struct Session(Movable): var url: String, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends a HEAD request to the specified URL. Args: @@ -874,7 +874,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. @@ -900,7 +900,7 @@ struct Session(Movable): var url: String, var headers: Dict[String, String] = {}, timeout: Optional[Int] = None, - ) raises -> HTTPResponse: + ) raises -> Response: """Sends an OPTIONS request to the specified URL. Args: @@ -909,7 +909,7 @@ struct Session(Movable): timeout: An optional timeout in seconds for the request. Returns: - The received response as an `HTTPResponse` object. + The received response as an `Response` object. Raises: Error: If there is a failure in sending or receiving the message. diff --git a/pixi.toml b/pixi.toml index 1de2171..36eb941 100644 --- a/pixi.toml +++ b/pixi.toml @@ -28,7 +28,7 @@ api_server = "fastapi run utils/test_server.py" [package] name = "floki" -version = "0.3.1" +version = "0.3.2" [package.build] backend = { name = "pixi-build-mojo", version = "0.*" } diff --git a/test/test_free_functions.mojo b/test/test_free_functions.mojo index 5451af5..96b4695 100644 --- a/test/test_free_functions.mojo +++ b/test/test_free_functions.mojo @@ -59,13 +59,49 @@ def test_options() raises -> None: assert_equal(response.headers["access-control-allow-methods"], "GET,HEAD,PUT,PATCH,POST,DELETE") +@fieldwise_init +struct QueryParameters(Movable, Defaultable, ImplicitlyDestructible): + var foo: String + + def __init__(out self): + self.foo = "" + + +@fieldwise_init +struct ArgResponse(Movable, Defaultable, ImplicitlyDestructible): + var args: QueryParameters + var headers: Dict[String, String] + var origin: String + var url: String + + def __init__(out self): + self.args = QueryParameters() + self.headers = Dict[String, String]() + self.origin = "" + self.url = "" + + def test_get_with_query_parameters() raises -> None: var response = floki.get( "https://httpbin.org/get", query_parameters={"foo": "bar"}, ) assert_equal(response.status, Status.OK) - assert_equal(response.body.as_json()["args"]["foo"].string(), "bar") + assert_equal(response.body.as_json[ArgResponse]().args.foo, "bar") + + +@fieldwise_init +struct CustomHeaderResponse(Movable, Defaultable, ImplicitlyDestructible): + var args: Dict[String, String] + var headers: Dict[String, String] + var origin: String + var url: String + + def __init__(out self): + self.args = Dict[String, String]() + self.headers = Dict[String, String]() + self.origin = "" + self.url = "" def test_get_with_custom_headers() raises -> None: @@ -75,7 +111,7 @@ def test_get_with_custom_headers() raises -> None: ) assert_equal(response.status, Status.OK) assert_equal( - response.body.as_json()["headers"]["X-Custom-Header"].string(), + response.body.as_json[CustomHeaderResponse]().headers["X-Custom-Header"], "floki-test", ) diff --git a/test/test_json.mojo b/test/test_json.mojo new file mode 100644 index 0000000..05e7f10 --- /dev/null +++ b/test/test_json.mojo @@ -0,0 +1,31 @@ +from std.utils import Variant +from std.testing import TestSuite, assert_equal, assert_true +import emberjson +from floki.session import Session + + +@fieldwise_init +struct Todo(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var userId: Int + var id: Int + var title: String + var completed: Bool + + def __init__(out self): + self.userId = 0 + self.id = 0 + self.title = "" + self.completed = False + + +def test_todo_deserialization() raises -> None: + var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") + var expected = Todo(userId=1, id=1, title="delectus aut autem", completed=False) + assert_equal(response.body.as_json[Todo](), expected) + + +def main() raises -> None: + TestSuite.discover_tests[__functions_in_module()]().run() + # var suite = TestSuite() + # suite.test[test_options]() + # suite^.run() diff --git a/test/test_response.mojo b/test/test_response.mojo index d4bf1f7..0a6ebc4 100644 --- a/test/test_response.mojo +++ b/test/test_response.mojo @@ -1,7 +1,7 @@ from std.testing import TestSuite, assert_equal, assert_true from floki.http import Status, Protocol from floki.body import Body -from floki.response import HTTPResponse +from floki.response import Response from floki.cookie.cookie_jar import CookieJar @@ -106,9 +106,9 @@ def test_body_invalid_utf8_raises() raises -> None: assert_true(raised) -def test_body_as_string_slice() raises -> None: +def test_body_as_text() raises -> None: var body = Body("hello world".as_bytes()) - assert_equal(String(body.as_string_slice()), "hello world") + assert_equal(String(body.as_text()), "hello world") def test_body_as_bytes_len() raises -> None: @@ -121,22 +121,23 @@ def test_body_len() raises -> None: assert_equal(len(body), 5) -def test_body_as_json_object() raises -> None: - var body = Body('{"name": "floki"}'.as_bytes()) - assert_equal(body.as_json()["name"].string(), "floki") +struct TestJSON(Movable, Defaultable, ImplicitlyDestructible): + var name: String + + def __init__(out self): + self.name = "" -def test_body_as_json_cached() raises -> None: - var body = Body('{"x": 1}'.as_bytes()) - _ = body.as_json() # prime cache - assert_equal(body.as_json()["x"].int(), 1) # should reuse cache +def test_body_as_json_object() raises -> None: + var body = Body('{"name": "floki"}'.as_bytes()) + assert_equal(body.as_json[TestJSON]().name, "floki") def test_body_as_json_empty_raises() raises -> None: var raised = False var body = Body(List[Byte]()) try: - _ = body.as_json() + var _ = body.as_json[TestJSON]() except: raised = True assert_true(raised) @@ -144,14 +145,14 @@ def test_body_as_json_empty_raises() raises -> None: def test_body_consume() raises -> None: var body = Body("hello".as_bytes()) - var bytes = body^.consume() + var bytes = body^.take_bytes() assert_equal(len(bytes), 5) -# === HTTPResponse === +# === Response === def test_http_response_is_ok_true() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.OK, protocol=Protocol.HTTPS, ) @@ -160,7 +161,7 @@ def test_http_response_is_ok_true() raises -> None: def test_http_response_is_ok_false_for_201() raises -> None: # is_ok() only matches status 200 exactly — 201 Created also returns False - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.CREATED, protocol=Protocol.HTTPS, ) @@ -168,7 +169,7 @@ def test_http_response_is_ok_false_for_201() raises -> None: def test_http_response_is_ok_false_for_404() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.NOT_FOUND, protocol=Protocol.HTTPS, ) @@ -176,7 +177,7 @@ def test_http_response_is_ok_false_for_404() raises -> None: def test_http_response_is_redirect_301() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.MOVED_PERMANENTLY, protocol=Protocol.HTTP, ) @@ -184,7 +185,7 @@ def test_http_response_is_redirect_301() raises -> None: def test_http_response_is_redirect_302() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.FOUND, protocol=Protocol.HTTP, ) @@ -192,7 +193,7 @@ def test_http_response_is_redirect_302() raises -> None: def test_http_response_is_redirect_307() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.TEMPORARY_REDIRECT, protocol=Protocol.HTTP, ) @@ -200,7 +201,7 @@ def test_http_response_is_redirect_307() raises -> None: def test_http_response_is_redirect_308() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.PERMANENT_REDIRECT, protocol=Protocol.HTTP, ) @@ -208,7 +209,7 @@ def test_http_response_is_redirect_308() raises -> None: def test_http_response_is_not_redirect_200() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.OK, protocol=Protocol.HTTPS, ) @@ -216,7 +217,7 @@ def test_http_response_is_not_redirect_200() raises -> None: def test_http_response_raise_for_status_passes_on_200() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.OK, protocol=Protocol.HTTPS, ) @@ -227,7 +228,7 @@ def test_http_response_raise_for_status_passes_on_200() raises -> None: def test_http_response_raise_for_status_raises_on_404() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.NOT_FOUND, protocol=Protocol.HTTPS, ) @@ -240,7 +241,7 @@ def test_http_response_raise_for_status_raises_on_404() raises -> None: def test_http_response_raise_for_status_raises_on_500() raises -> None: - var response = HTTPResponse( + var response = Response( body=List[Byte](), cookies=CookieJar(), status=Status.INTERNAL_ERROR, protocol=Protocol.HTTP, ) diff --git a/test/test_session.mojo b/test/test_session.mojo index daea607..590a16b 100644 --- a/test/test_session.mojo +++ b/test/test_session.mojo @@ -23,14 +23,67 @@ def assert_variant_equal2(expected: Variant[Int, String], actual: emberjson.Valu assert_equal(expected[String], actual.string()) +@fieldwise_init +struct Todo(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var userId: Int + var id: Int + var title: String + var completed: Bool + + def __init__(out self): + self.userId = 0 + self.id = 0 + self.title = "" + self.completed = False + + def test_get() raises -> None: var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") assert_equal(response.status, Status.OK) - var expected: Dict[String, Variant[Int, String, Bool]] = { - "userId": 1, "id": 1, "title": "delectus aut autem", "completed": False - } - for node in response.body.as_json().object().items(): - assert_variant_equal(expected[node.key], node.value) + + var todo = response.body.as_json[Todo]() + assert_equal(todo.userId, 1) + assert_equal(todo.id, 1) + assert_equal(todo.title, "delectus aut autem") + assert_equal(todo.completed, False) + + +@fieldwise_init +struct Record(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var userId: Int + var body: String + var title: String + var active: Bool + + def __init__(out self): + self.userId = 0 + self.body = "" + self.title = "" + self.active = False + + +@fieldwise_init +struct ServerPostResponse(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var args: Dict[String, String] + var headers: Dict[String, emberjson.Value] + var method: String + var origin: String + var url: String + var data: String + var files: Dict[String, String] + var form: Dict[String, String] + var json: Record + + def __init__(out self): + self.args = Dict[String, String]() + self.headers = Dict[String, emberjson.Value]() + self.method = "" + self.origin = "" + self.url = "" + self.data = "" + self.files = Dict[String, String]() + self.form = Dict[String, String]() + self.json = Record() def test_post() raises -> None: @@ -45,23 +98,33 @@ def test_post() raises -> None: ) assert_equal(response.status, Status.OK) # Should be 201, but httpbingo returns 200? - var expected: Dict[String, Variant[Int, String, Bool]] = { - "title": "booggg", "body": "bar", "userId": 1, "active": True - } - for node in response.body.as_json()["json"].object().items(): - assert_variant_equal(expected[node.key], node.value) - + + var post_response = response.body.as_json[ServerPostResponse]() + assert_equal(post_response.json.userId, 1) + assert_equal(post_response.json.body, "bar") + assert_equal(post_response.json.title, "booggg") + assert_equal(post_response.json.active, True) + + +struct Content(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var recently_edited: List[String] + + def __init__(out self): + self.recently_edited = List[String]() + + +struct FileContent(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var id: Int + var name: String + var content: Content + + def __init__(out self): + self.id = 0 + self.name = "" + self.content = Content() + def test_post_file() raises -> None: - var expected: Dict[String, Variant[String, Dict[String, List[String]]]] = { - "name": "file.json", - } - var content = { - "recently_edited": [ - "floki/session.mojo" - ] - } - expected["content"] = content^ with open("test/data/file.json", "r") as f: var response = Session().post( "https://jsonplaceholder.typicode.com/todos", @@ -72,14 +135,22 @@ def test_post_file() raises -> None: data=f, ) assert_equal(response.status, Status.CREATED) - for node in response.body.as_json().object().items(): - if node.value.is_string(): - assert_equal(expected[node.key][String], node.value.string()) - elif node.value.is_object(): - for subnode in node.value.object().items(): - for item in subnode.value.array(): - assert_equal(expected[node.key][Dict[String, List[String]]][subnode.key][0], item.string()) - + + file_content = response.body.as_json[FileContent]() + assert_equal(file_content.name, "file.json") + assert_equal(file_content.content.recently_edited, ["floki/session.mojo"]) + + +struct PutResponse(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var id: Int + var key1: String + var key2: String + + def __init__(out self): + self.id = 0 + self.key1 = "" + self.key2 = "" + def test_put() raises -> None: var response = Session().put( @@ -91,16 +162,23 @@ def test_put() raises -> None: data={"key1": "updated_value1", "key2": "updated_value2"}, ) assert_equal(response.status, Status.OK) - var expected: List[String] = ["key1", "key2", "id"] - for node in response.body.as_json().object().items(): - assert_true(node.key in expected) + + var put_response = response.body.as_json[PutResponse]() + assert_equal(put_response.id, 1) + assert_equal(put_response.key1, "updated_value1") + assert_equal(put_response.key2, "updated_value2") + + +struct PutFileResponse(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var id: Int + var key1: String + + def __init__(out self): + self.id = 0 + self.key1 = "" def test_put_file() raises -> None: - var expected: Dict[String, Variant[Int, String]] = { - "id": 1, - "key1": "patched_value", - } with open("test/data/update.json", "r") as f: var response = Session().put( "https://jsonplaceholder.typicode.com/posts/1", @@ -111,18 +189,29 @@ def test_put_file() raises -> None: data=f, ) assert_equal(response.status, Status.OK) - for node in response.body.as_json().object().items(): - assert_variant_equal2(expected[node.key], node.value) - - -def test_patch() raises -> None: - var expected: Dict[String, Variant[Int, String]] = { - "userId": 1, - "id": 1, - "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", - "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", - "key1": "patched_value", - } + + var put_file_response = response.body.as_json[PutFileResponse]() + assert_equal(put_file_response.id, 1) + assert_equal(put_file_response.key1, "patched_value") + + +@fieldwise_init +struct PatchedTodo(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var userId: Int + var id: Int + var title: String + var body: String + var key1: String + + def __init__(out self): + self.userId = 0 + self.id = 0 + self.title = "" + self.body = "" + self.key1 = "" + + +def test_patch() raises -> None: var response = Session().patch( "https://jsonplaceholder.typicode.com/posts/1", { @@ -132,18 +221,16 @@ def test_patch() raises -> None: data={"key1": "patched_value"}, ) assert_equal(response.status, Status.OK) - for node in response.body.as_json().object().items(): - assert_variant_equal2(expected[node.key], node.value) + + var patched_todo = response.body.as_json[PatchedTodo]() + assert_equal(patched_todo.userId, 1) + assert_equal(patched_todo.id, 1) + assert_equal(patched_todo.title, "sunt aut facere repellat provident occaecati excepturi optio reprehenderit") + assert_equal(patched_todo.body, "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto") + assert_equal(patched_todo.key1, "patched_value") def test_patch_file() raises -> None: - var expected: Dict[String, Variant[Int, String]] = { - "userId": 1, - "id": 1, - "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", - "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto", - "key1": "patched_value", - } with open("test/data/update.json", "r") as f: var response = Session().patch( "https://jsonplaceholder.typicode.com/posts/1", @@ -154,8 +241,13 @@ def test_patch_file() raises -> None: data=f, ) assert_equal(response.status, Status.OK) - for node in response.body.as_json().object().items(): - assert_variant_equal2(expected[node.key], node.value) + print(response.body.as_text()) + var patched_todo = response.body.as_json[PatchedTodo]() + assert_equal(patched_todo.userId, 1) + assert_equal(patched_todo.id, 1) + assert_equal(patched_todo.title, "sunt aut facere repellat provident occaecati excepturi optio reprehenderit") + assert_equal(patched_todo.body, "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto") + assert_equal(patched_todo.key1, "patched_value") def test_delete() raises -> None: @@ -191,13 +283,27 @@ def test_session_reuse() raises -> None: assert_equal(r2.status, Status.OK) +@fieldwise_init +struct ServerGetResponse(Movable, Defaultable, ImplicitlyDestructible): + var args: Dict[String, String] + var headers: Dict[String, String] + var origin: String + var url: String + + def __init__(out self): + self.args = Dict[String, String]() + self.headers = Dict[String, String]() + self.origin = "" + self.url = "" + + def test_session_level_headers() raises -> None: var response = Session(headers={"X-Floki-Test": "session-headers"}).get( "https://httpbin.org/get", ) assert_equal(response.status, Status.OK) assert_equal( - response.body.as_json()["headers"]["X-Floki-Test"].string(), + response.body.as_json[ServerGetResponse]().headers["X-Floki-Test"], "session-headers", ) @@ -243,7 +349,7 @@ def test_response_raise_for_status_raises_on_4xx() raises -> None: def main() raises -> None: - TestSuite.discover_tests[__functions_in_module()]().run() - # var suite = TestSuite() - # suite.test[test_options]() - # suite^.run() + # TestSuite.discover_tests[__functions_in_module()]().run() + var suite = TestSuite() + suite.test[test_patch_file]() + suite^.run() From 9180e8ce5673b6402f7de8872710d0b8f8c9c00a Mon Sep 17 00:00:00 2001 From: Mikhail Tavarez Date: Sun, 31 May 2026 13:05:03 -0500 Subject: [PATCH 2/3] fix patch_file test --- floki/callbacks.mojo | 7 +- floki/functions.mojo | 131 ++++++++++++++++++++++++++++++++- floki/session.mojo | 133 ++++++++++++++++++++++++++++++++++ test/test_free_functions.mojo | 57 +++++++++++++++ test/test_session.mojo | 79 ++++++++++++++++++-- 5 files changed, 396 insertions(+), 11 deletions(-) diff --git a/floki/callbacks.mojo b/floki/callbacks.mojo index 3384701..2abd13c 100644 --- a/floki/callbacks.mojo +++ b/floki/callbacks.mojo @@ -1,7 +1,7 @@ from std.memory import memcpy from std.ffi import c_char, c_size_t, get_errno from std.sys import stderr -from mojo_curl.c.types import ImmutExternalPointer, MutExternalPointer +from mojo_curl.c.types import ImmutExternalPointer, MutExternalPointer, CURL_READFUNC_ABORT # To read HTTP response data into a list of bytes. @@ -101,6 +101,5 @@ def fd_read_callback( var fd = FileDescriptor(file[]._get_raw_fd()) return fd.read_bytes(Span(ptr=ptr.bitcast[UInt8](), length=Int(buffer_size))) except e: - print("fd_read_callback: Error reading from file descriptor: ", e, " errno: ", get_errno(), file=stderr) - # TODO: Add READ_FUNC_ABORT constant to mojo-curl and return it here to signal an error. - return 0x10000000 + print(t"fd_read_callback: Error reading from file descriptor: {e}. Errno: {get_errno()}", file=stderr) + return CURL_READFUNC_ABORT diff --git a/floki/functions.mojo b/floki/functions.mojo index 261ba67..20193a1 100644 --- a/floki/functions.mojo +++ b/floki/functions.mojo @@ -79,6 +79,49 @@ def post( ) +def post[T: AnyType & ImplicitlyDestructible, //]( + var url: String, + data: T, + var headers: Dict[String, String] = {}, + timeout: Optional[Int] = None, +) raises -> Response: + """Sends a POST request to the specified URL. + + Args: + url: The URL to which the request is sent. + data: The data to include in the body of the POST request. + headers: HTTP headers to include in the request. + timeout: An optional timeout in seconds for the request. + + Returns: + The received response as an `Response` object. + + Raises: + Error: If the data cannot be serialized to JSON or if the request fails. + + #### Examples: + ```mojo + from floki.session import Session + + @fieldwise_init + struct Point: + var x: Int + var y: Int + + def main() raises: + var session = Session() + var r = session.post("https://httpbin.org/post", data=Point(0, 1)) + ``` + """ + var json_data = emberjson.serialize(data) + return Session().send[RequestMethod.POST]( + url=url, + headers=headers^, + data=json_data.as_bytes(), + timeout=timeout, + ) + + def post[origin: ImmutOrigin, //]( var url: String, data: Span[Byte, origin], @@ -192,6 +235,49 @@ def put( ) +def put[T: AnyType & ImplicitlyDestructible, //]( + var url: String, + data: T, + var headers: Dict[String, String] = {}, + timeout: Optional[Int] = None, +) raises -> Response: + """Sends a PUT request to the specified URL. + + Args: + url: The URL to which the request is sent. + data: The data to include in the body of the PUT request. + headers: HTTP headers to include in the request. + timeout: An optional timeout in seconds for the request. + + Returns: + The received response as an `Response` object. + + Raises: + Error: If the data cannot be serialized to JSON or if the request fails. + + #### Examples: + ```mojo + from floki.session import Session + + @fieldwise_init + struct Point: + var x: Int + var y: Int + + def main() raises: + var session = Session() + var r = session.put("https://httpbin.org/put", data=Point(0, 1)) + ``` + """ + var json_data = emberjson.serialize(data) + return Session().send[RequestMethod.PUT]( + url=url, + headers=headers^, + data=json_data.as_bytes(), + timeout=timeout, + ) + + def put[origin: ImmutOrigin, //]( var url: String, data: Span[Byte, origin], @@ -338,6 +424,50 @@ def patch( timeout=timeout, ) + +def patch[T: AnyType & ImplicitlyDestructible, //]( + var url: String, + var data: T, + var headers: Dict[String, String] = {}, + timeout: Optional[Int] = None, +) raises -> Response: + """Sends a GET request to the specified URL. + + Args: + url: The URL to which the request is sent. + data: The data to include in the body of the PATCH request. + headers: HTTP headers to include in the request. + timeout: An optional timeout in seconds for the request. + + Returns: + The received response as an `Response` object. + + Raises: + Error: If the data cannot be serialized to JSON or if the request fails. + + #### Examples: + ```mojo + from floki.session import Session + + @fieldwise_init + struct Point: + var x: Int + var y: Int + + def main() raises: + var session = Session() + var r = session.patch("https://httpbin.org/patch", data=Point(0, 1)) + ``` + """ + var json_data = emberjson.serialize(data) + return Session().send[RequestMethod.PATCH]( + url=url, + headers=headers^, + data=json_data.as_bytes(), + timeout=timeout, + ) + + def patch[origin: ImmutOrigin, //]( var url: String, data: Span[Byte, origin], @@ -366,7 +496,6 @@ def patch[origin: ImmutOrigin, //]( import floki def main() raises: - var data = String("hello").as_bytes() var r = floki.patch("https://httpbin.org/patch", data="hello".as_bytes()) ``` """ diff --git a/floki/session.mojo b/floki/session.mojo index 3e61eff..5bfca8f 100644 --- a/floki/session.mojo +++ b/floki/session.mojo @@ -243,6 +243,10 @@ def _handle_patch[origin: ImmutOrigin, //](easy: Easy, data: Pointer[FileHandle, if result != Result.OK: raise Error("_handle_patch: Failed to set PATCH method: ", easy.describe_error(result)) + result = easy.post() + if result != Result.OK: + raise Error("_handle_patch: Failed to set POST method: ", easy.describe_error(result)) + result = easy.read_function(fd_read_callback) if result != Result.OK: raise Error("_handle_patch: Failed to set read function: ", easy.describe_error(result)) @@ -514,6 +518,49 @@ struct Session(Movable): data=RequestData(json_data), timeout=timeout, ) + + def post[T: AnyType & ImplicitlyDestructible, //]( + self, + var url: String, + data: T, + var headers: Dict[String, String] = {}, + timeout: Optional[Int] = None, + ) raises -> Response: + """Sends a POST request to the specified URL. + + Args: + url: The URL to which the request is sent. + data: The data to include in the body of the POST request. + headers: HTTP headers to include in the request. + timeout: An optional timeout in seconds for the request. + + Returns: + The received response as an `Response` object. + + Raises: + Error: If there is a failure in sending or receiving the message. + + #### Examples: + ```mojo + from floki.session import Session + + @fieldwise_init + struct Point: + var x: Int + var y: Int + + def main() raises: + var session = Session() + var r = session.post("https://httpbin.org/post", data=Point(0, 1)) + ``` + """ + var json_data = emberjson.serialize(data) + return self.send[RequestMethod.POST]( + url=url, + headers=headers^, + data=json_data.as_bytes(), + timeout=timeout, + ) def post[origin: ImmutOrigin, //]( self, @@ -631,6 +678,49 @@ struct Session(Movable): timeout=timeout, ) + def put[T: AnyType & ImplicitlyDestructible, //]( + self, + var url: String, + data: T, + var headers: Dict[String, String] = {}, + timeout: Optional[Int] = None, + ) raises -> Response: + """Sends a PUT request to the specified URL. + + Args: + url: The URL to which the request is sent. + data: The data to include in the body of the PUT request. + headers: HTTP headers to include in the request. + timeout: An optional timeout in seconds for the request. + + Returns: + The received response as an `Response` object. + + Raises: + Error: If there is a failure in sending or receiving the message. + + #### Examples: + ```mojo + from floki.session import Session + + @fieldwise_init + struct Point: + var x: Int + var y: Int + + def main() raises: + var session = Session() + var r = session.put("https://httpbin.org/put", data=Point(0, 1)) + ``` + """ + var json_data = emberjson.serialize(data) + return self.send[RequestMethod.PUT]( + url=url, + headers=headers^, + data=json_data.as_bytes(), + timeout=timeout, + ) + def put[origin: ImmutOrigin, //]( self, var url: String, @@ -781,6 +871,49 @@ struct Session(Movable): data=json_data, timeout=timeout, ) + + def patch[T: AnyType & ImplicitlyDestructible, //]( + self, + var url: String, + data: T, + var headers: Dict[String, String] = {}, + timeout: Optional[Int] = None, + ) raises -> Response: + """Sends a PATCH request to the specified URL. + + Args: + url: The URL to which the request is sent. + data: The data to include in the body of the PATCH request. + headers: HTTP headers to include in the request. + timeout: An optional timeout in seconds for the request. + + Returns: + The received response as an `Response` object. + + Raises: + Error: If there is a failure in sending or receiving the message. + + #### Examples: + ```mojo + from floki.session import Session + + @fieldwise_init + struct Point: + var x: Int + var y: Int + + def main() raises: + var session = Session() + var r = session.patch("https://httpbin.org/patch", data=Point(0, 1)) + ``` + """ + var json_data = emberjson.serialize(data) + return self.send[RequestMethod.PATCH]( + url=url, + headers=headers^, + data=json_data.as_bytes(), + timeout=timeout, + ) def patch[origin: ImmutOrigin, //]( self, diff --git a/test/test_free_functions.mojo b/test/test_free_functions.mojo index 96b4695..bbb5500 100644 --- a/test/test_free_functions.mojo +++ b/test/test_free_functions.mojo @@ -146,6 +146,63 @@ def test_patch_raw_bytes() raises -> None: assert_equal(response.status, Status.OK) +@fieldwise_init +struct PostPayload(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var title: String + var body: String + var userId: Int + + def __init__(out self): + self.title = "" + self.body = "" + self.userId = 0 + + +def test_post_struct() raises -> None: + var response = floki.post( + "https://jsonplaceholder.typicode.com/todos", + data=PostPayload(title="test title", body="test body", userId=1), + headers={"Content-Type": "application/json"}, + ) + assert_equal(response.status, Status.CREATED) + + +@fieldwise_init +struct UpdatePayload(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var key1: String + var key2: String + + def __init__(out self): + self.key1 = "" + self.key2 = "" + + +def test_put_struct() raises -> None: + var response = floki.put( + "https://jsonplaceholder.typicode.com/posts/1", + data=UpdatePayload(key1="updated_value1", key2="updated_value2"), + headers={"Content-Type": "application/json"}, + ) + assert_equal(response.status, Status.OK) + + +@fieldwise_init +struct PatchPayload(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var key1: String + + def __init__(out self): + self.key1 = "" + + +def test_patch_struct() raises -> None: + var response = floki.patch( + "https://jsonplaceholder.typicode.com/todos/1", + data=PatchPayload(key1="patched_value"), + headers={"Content-Type": "application/json"}, + ) + assert_equal(response.status, Status.OK) + + def main() raises -> None: TestSuite.discover_tests[__functions_in_module()]().run() # var suite = TestSuite() diff --git a/test/test_session.mojo b/test/test_session.mojo index 590a16b..993df0c 100644 --- a/test/test_session.mojo +++ b/test/test_session.mojo @@ -232,7 +232,7 @@ def test_patch() raises -> None: def test_patch_file() raises -> None: with open("test/data/update.json", "r") as f: - var response = Session().patch( + var response = Session(verbose=True).patch( "https://jsonplaceholder.typicode.com/posts/1", headers={ "Content-Type": "application/json", @@ -241,7 +241,7 @@ def test_patch_file() raises -> None: data=f, ) assert_equal(response.status, Status.OK) - print(response.body.as_text()) + var patched_todo = response.body.as_json[PatchedTodo]() assert_equal(patched_todo.userId, 1) assert_equal(patched_todo.id, 1) @@ -348,8 +348,75 @@ def test_response_raise_for_status_raises_on_4xx() raises -> None: assert_true(raised) +def test_post_struct() raises -> None: + var response = Session().post( + "https://httpbingo.org/post", + data=Record(userId=1, body="bar", title="booggg", active=True), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + assert_equal(response.status, Status.OK) + var post_response = response.body.as_json[ServerPostResponse]() + assert_equal(post_response.json.userId, 1) + assert_equal(post_response.json.body, "bar") + assert_equal(post_response.json.title, "booggg") + assert_equal(post_response.json.active, True) + + +@fieldwise_init +struct PutStructData(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var key1: String + var key2: String + + def __init__(out self): + self.key1 = "" + self.key2 = "" + + +def test_put_struct() raises -> None: + var response = Session().put( + "https://jsonplaceholder.typicode.com/posts/1", + data=PutStructData(key1="updated_value1", key2="updated_value2"), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + assert_equal(response.status, Status.OK) + var put_response = response.body.as_json[PutResponse]() + assert_equal(put_response.id, 1) + assert_equal(put_response.key1, "updated_value1") + assert_equal(put_response.key2, "updated_value2") + + +@fieldwise_init +struct PatchStructData(Movable, Defaultable, ImplicitlyDestructible, Equatable, Writable): + var key1: String + + def __init__(out self): + self.key1 = "" + + +def test_patch_struct() raises -> None: + var response = Session().patch( + "https://jsonplaceholder.typicode.com/posts/1", + data=PatchStructData(key1="patched_value"), + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + assert_equal(response.status, Status.OK) + var patched_todo = response.body.as_json[PatchedTodo]() + assert_equal(patched_todo.userId, 1) + assert_equal(patched_todo.id, 1) + assert_equal(patched_todo.key1, "patched_value") + + def main() raises -> None: - # TestSuite.discover_tests[__functions_in_module()]().run() - var suite = TestSuite() - suite.test[test_patch_file]() - suite^.run() + TestSuite.discover_tests[__functions_in_module()]().run() + # var suite = TestSuite() + # suite.test[test_patch_file]() + # suite^.run() From 892ff8cc3f5e12c2b4bb62a35d918ed2dc3d2db4 Mon Sep 17 00:00:00 2001 From: Mikhail Tavarez Date: Sun, 31 May 2026 14:11:49 -0500 Subject: [PATCH 3/3] clean up tests --- floki/session.mojo | 21 +++++++++++++++------ test/test_response.mojo | 11 ----------- test/test_session.mojo | 2 +- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/floki/session.mojo b/floki/session.mojo index 5bfca8f..0f3b118 100644 --- a/floki/session.mojo +++ b/floki/session.mojo @@ -135,10 +135,6 @@ def _handle_put[origin: ImmutOrigin, //](easy: Easy, data: Span[Byte, origin]) r if result != Result.OK: raise Error("_handle_put: Failed to set PUT method: ", easy.describe_error(result)) - result = easy.upload() - if result != Result.OK: - raise Error("_handle_put: Failed to set PUT method: ", easy.describe_error(result)) - if data: var data_size = len(data) # libcurl dictates the usage of the large post field size option over 2GB. @@ -229,6 +225,11 @@ def _handle_patch[origin: ImmutOrigin, //](easy: Easy, data: Span[Byte, origin]) result = easy.post_fields(data) if result != Result.OK: raise Error("_handle_patch: Failed to set PATCH request post fields: ", easy.describe_error(result)) + else: + # Set PATCH with zero-length body + var result = easy.post() + if result != Result.OK: + raise Error("_handle_patch: Failed to set zero-length PATCH body: ", easy.describe_error(result)) def _handle_patch[origin: ImmutOrigin, //](easy: Easy, data: Pointer[FileHandle, origin]) raises: @@ -263,7 +264,7 @@ def _handle_head(easy: Easy) raises: easy: The libcurl easy handle to configure. """ # Set NOBODY to true to avoid downloading the body, also tells libcurl to use HEAD. - result = easy.nobody() + var result = easy.nobody() if result != Result.OK: raise Error("_handle_head: Failed to set NOBODY option: ", easy.describe_error(result)) @@ -370,8 +371,9 @@ struct Session(Movable): # references to the values in the dictionary as we iterate rn. var params: List[String] = [] for pair in query_parameters.items(): + var key = pair.key var value = pair.value - params.append(String(pair.key, "=", self.easy.escape(value))) + params.append(String(self.easy.escape(key), "=", self.easy.escape(value))) # Append the query parameters to the URL. Thi var full_url = String(url, "?", "&".join(params)) @@ -412,6 +414,9 @@ struct Session(Movable): elif method == RequestMethod.OPTIONS: _handle_options(self.easy) + if timeout: + self.raise_if_error(self.easy.timeout(timeout.value()), "Failed to set timeout: ") + var list = CurlList(headers) try: # If there's any headers set on the session, add them too. @@ -442,6 +447,10 @@ struct Session(Movable): ) finally: self.easy.reset() # Reset the easy handle to clear any state for the next request. + if self.allow_redirects: + _ = self.easy.follow_location() + if self.verbose: + _ = self.easy.verbose() def get( self, diff --git a/test/test_response.mojo b/test/test_response.mojo index 0a6ebc4..7bbd053 100644 --- a/test/test_response.mojo +++ b/test/test_response.mojo @@ -95,17 +95,6 @@ def test_body_empty() raises -> None: assert_equal(len(body), 0) -def test_body_invalid_utf8_raises() raises -> None: - var raised = False - var invalid = List[Byte]() - invalid.append(0xFF) - try: - var _ = Body(invalid^) - except: - raised = True - assert_true(raised) - - def test_body_as_text() raises -> None: var body = Body("hello world".as_bytes()) assert_equal(String(body.as_text()), "hello world") diff --git a/test/test_session.mojo b/test/test_session.mojo index 993df0c..ee7ab70 100644 --- a/test/test_session.mojo +++ b/test/test_session.mojo @@ -232,7 +232,7 @@ def test_patch() raises -> None: def test_patch_file() raises -> None: with open("test/data/update.json", "r") as f: - var response = Session(verbose=True).patch( + var response = Session().patch( "https://jsonplaceholder.typicode.com/posts/1", headers={ "Content-Type": "application/json",