diff --git a/README.md b/README.md index 7b3fc1b..9ca3f89 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,53 @@ def main() raises -> None: `raise_for_status()` treats any 2xx as success, so `201 Created` and `204 No Content` do not raise. +### Handling request errors + +When a request fails *before* a usable response comes back — a DNS failure, a refused +connection, a timeout, a TLS problem — floki raises a `RequestError`. (This is distinct +from `HTTPError`, which `raise_for_status()` raises when a response *was* received but +carried a non-2xx status.) + +A `RequestError` wraps one of several concrete error types: + +| Concrete type | Raised when | +| --- | --- | +| `TimeoutError` | The request exceeded its configured timeout. | +| `ConnectionError` | The server couldn't be reached (DNS failure, refused/dropped connection). | +| `TLSError` | A TLS/SSL handshake or certificate-verification failure. | +| `TooManyRedirectsError` | The request followed more redirects than allowed. | +| `TransportError` | Any other transport-level failure. | + +Use `error.isa[T]()` to test which type it holds and `error[T]` to pull the concrete +value out: + +```mojo +import floki +from floki import ConnectionError, TimeoutError, TLSError + +def main() raises -> None: + try: + var r = floki.get("https://example.com") + print(r.status.code) + except e: # `e` is a `RequestError` + if e.isa[TimeoutError](): + print("Timed out:", e[TimeoutError]) + elif e.isa[ConnectionError](): + print("Could not connect:", e[ConnectionError]) + elif e.isa[TLSError](): + print("TLS failure:", e[TLSError]) + else: + print("Request failed:", e) +``` + +- `error.isa[T]()` returns `True` when the `RequestError` currently holds a value of + type `T`. +- `error[T]` (the `__getitem_param__` subscript) returns the underlying value of type + `T`. Only call it after `isa[T]()` has confirmed the type. + +If you don't need to branch on the category, a `RequestError` is `Writable`, so you can +print or format it directly for a human-readable message. + ### Authentication Basic and Bearer helpers are built in, and any type implementing the `Auth` trait @@ -250,7 +297,7 @@ def main() raises -> None: var r = session.get("https://example.com") ``` -The delay before retry _n_ is `backoff_factor * 2 ** (n - 1)` seconds. +The delay before retry *n* is `backoff_factor * 2 ** (n - 1)` seconds. ### Proxies @@ -267,7 +314,7 @@ def main() raises -> None: "http://proxy.example:8080", username="user", password="secret", - no_proxy="localhost,127.0.0.1", + no_proxy=["localhost", "127.0.0.1"], ) ) var r = session.get("https://example.com") @@ -298,10 +345,22 @@ def main() raises -> None: ## TODO -- Add an option for streaming responses instead of loading it all into memory. +### TODO: Features + +- Streaming responses — the whole body is buffered into a `List[Byte]`; large downloads and SSE need incremental access. +- `multipart/form-data` file uploads (files=): today only `x-www-form-urlencoded` is supported, so real file uploads aren't possible. +- Response encoding awareness: `as_text()` assumes UTF-8 and raises otherwise; it ignores the charset in `Content-Type`. At minimum, expose a lossy decode fallback. +- `Accept-Encoding` / transparent decompression: ability to set it so gzip/deflate responses come back decoded (libcurl does this if you enable it). +- Multi-value response headers: Headers wraps `Dict[String, String]`, so repeated headers collapse (`Set-Cookie` is handled separately by the jar, but others are lost). +- Add support for passing Dict data to session methods. Just passing a dict literal is a little limiting. I've tried, but it gets very hairy trying to convert it to an emberjson Value object. + +### TODO: Optimizations + - Cleanup cookie parsing code, it seems pretty slow. +- I should update `mojo-curl` bindings to indicate which Easy handle methods mutate the handle. It's deceptive that they're all marked as borrowing self immutably, because it can update the Easy handle's internal state via FFI. + +### TODO: Bugs + - Sus out the myriad of bugs and edge cases that may arise as libcurl and requests can do A LOT of things, that I've never used before. Please open issues and open PRs to help address these gaps where possible. -- Add methods to free Session explicitly, same with Easy handles. -- Add support for passing Dict data to session methods. Just passing a dict literal is a little limiting. I've tried, but it gets very hairy trying to convert it to an emberjson JSON object. Reminder, this is a hobby project! You're free to fork it and make changes as you see fit. diff --git a/examples/error_handling.mojo b/examples/error_handling.mojo new file mode 100644 index 0000000..842bfff --- /dev/null +++ b/examples/error_handling.mojo @@ -0,0 +1,47 @@ +import floki +from floki import ( + ConnectionError, + RequestError, + TimeoutError, + TLSError, + TooManyRedirectsError, + TransportError, +) +from floki.session import Session +from floki.timeout import Timeout + + +def main() raises -> None: + # `floki.get` (and every other request function) raises `RequestError` when the + # transfer fails before a response is received. `RequestError` wraps one of + # several concrete error types; use `isa[T]()` to test which one it holds and + # `error[T]` to pull the concrete value out. + + # A 1ms total timeout guarantees the transfer fails, so we can see the handling. + var session = Session(timeout=Timeout(total=0.001)) + + try: + var r = session.get("https://example.com") + print("Got a response:", r.status.code) + except e: + # `e` is a `RequestError`. Branch on the underlying kind: + if e.isa[TimeoutError](): + print("Request timed out:", e[TimeoutError]) + elif e.isa[ConnectionError](): + print("Could not reach the server:", e[ConnectionError]) + elif e.isa[TLSError](): + print("TLS/SSL failure:", e[TLSError]) + elif e.isa[TooManyRedirectsError](): + print("Too many redirects:", e[TooManyRedirectsError]) + elif e.isa[TransportError](): + print("Transport-level failure:", e[TransportError]) + else: + # Any other low-level error carried by the `RequestError`. + print("Request failed:", e) + + # If you don't care about the category, the `RequestError` is `Writable`, so you + # can print or format it directly for a human-readable message. + try: + _ = session.get("https://example.com") + except e: + print("Request failed:", e) diff --git a/floki/__init__.mojo b/floki/__init__.mojo index 7989f23..c919481 100644 --- a/floki/__init__.mojo +++ b/floki/__init__.mojo @@ -1,5 +1,15 @@ """Floki: An HTTP client library for Mojo!""" from floki.auth import Auth, BasicAuth, BearerAuth, NoAuth +from floki.errors import ( + ConnectionError, + ErrorKind, + FlokiError, + RequestError, + TimeoutError, + TLSError, + TooManyRedirectsError, + TransportError, +) from floki.forms import FormData from floki.functions import delete, get, head, options, patch, post, put from floki.headers import Headers diff --git a/floki/body.mojo b/floki/body.mojo index fd8ac7a..33456e2 100644 --- a/floki/body.mojo +++ b/floki/body.mojo @@ -62,7 +62,7 @@ struct Body(Copyable, Equatable, Sized, Writable): """Deserializes the body into a value of the given type. Use this when you have a struct (or other deserializable type) to parse - the body into. For ad-hoc, untyped access, use `json()` instead. + the body into. For ad-hoc, untyped access, use `as_json()` instead. Parameters: T: The type to deserialize the body into. @@ -79,10 +79,10 @@ struct Body(Copyable, Equatable, Sized, Writable): """Parses the body as a dynamic JSON document for ad-hoc access. Use this to inspect a response without declaring a target type, e.g. - `body.json()["data"]`. To deserialize into a struct, use `as_json[T]()`. + `body.as_json()["data"]`. To deserialize into a struct, use `as_json[T]()`. Returns: - The body content parsed as an `emberjson.JSON` value. + The body content parsed as an `emberjson.Value`. Raises: Error: if the body is empty or cannot be parsed as JSON. diff --git a/floki/errors.mojo b/floki/errors.mojo new file mode 100644 index 0000000..ab20067 --- /dev/null +++ b/floki/errors.mojo @@ -0,0 +1,251 @@ +"""Typed errors raised by floki when an HTTP request fails at the transport level.""" +from std.utils import Variant +from mojo_curl.easy import Result + + +trait FlokiError(Movable, Writable): + """A trait for errors raised by floki when an HTTP request fails before a usable response is received.""" + ... + + +@fieldwise_init +struct ConnectionError(FlokiError): + """An error raised when an HTTP request fails due to a connection-level failure. + + This error is raised when the underlying transport fails to establish a connection + to the server, such as when DNS resolution fails, the connection is refused, or + the connection is dropped mid-transfer. + """ + + def write_to(self, mut writer: Some[Writer]): + """Writes a human-readable representation of the error. + + Args: + writer: The writer to which the error will be written. + """ + writer.write("ConnectionError: A Connection error occurred.") + + +@fieldwise_init +struct TimeoutError(FlokiError): + """An error raised when an HTTP request exceeds its configured timeout. + + This error is raised when the request takes longer than the timeout specified in + the session configuration, indicating that the server did not respond in time. + """ + + def write_to(self, mut writer: Some[Writer]): + """Writes a human-readable representation of the error. + + Args: + writer: The writer to which the error will be written. + """ + writer.write("TimeoutError: The request exceeded its configured timeout.") + + +@fieldwise_init +struct TLSError(FlokiError): + """An error raised when an HTTP request fails due to a TLS/SSL failure. + + This error is raised when the underlying transport encounters a TLS/SSL error, + such as a handshake failure or a certificate verification failure. + """ + + def write_to(self, mut writer: Some[Writer]): + """Writes a human-readable representation of the error. + + Args: + writer: The writer to which the error will be written. + """ + writer.write("TLSError: A TLS/SSL error occurred.") + + +@fieldwise_init +struct TooManyRedirectsError(FlokiError): + """An error raised when an HTTP request follows too many redirects. + + This error is raised when the request exceeds the maximum number of redirects + allowed by libcurl, indicating a potential redirect loop or misconfiguration. + """ + + def write_to(self, mut writer: Some[Writer]): + """Writes a human-readable representation of the error. + + Args: + writer: The writer to which the error will be written. + """ + writer.write("TooManyRedirectsError: The request followed too many redirects.") + + +@fieldwise_init +struct TransportError(FlokiError): + """An error raised when an HTTP request fails due to a transport-level failure. + + This error is raised for transport-level failures that do not fall into more + specific categories, such as connection errors, timeouts, TLS errors, or too many + redirects. + """ + + def write_to(self, mut writer: Some[Writer]): + """Writes a human-readable representation of the error. + + Args: + writer: The writer to which the error will be written. + """ + writer.write("TransportError: A transport-level failure occurred.") + + +@fieldwise_init +struct RequestError(FlokiError): + """An error raised when an HTTP request fails before a usable response is received. + + Unlike `HTTPError` (which represents a response that *was* received but carried a + non-2xx status), a `RequestError` represents a transport-level failure: the request + never completed. Inspect `kind` to branch on the category of the failure. + """ + + comptime _TIMEOUT_CODES = [Result.OPERATION_TIMEDOUT] + comptime _CONNECTION_CODES = [ + Result.COULDNT_CONNECT, + Result.COULDNT_RESOLVE_HOST, + Result.COULDNT_RESOLVE_PROXY, + Result.GOT_NOTHING, + Result.SEND_ERROR, + Result.RECV_ERROR, + ] + comptime _TLS_CODES = [ + Result.SSL_CONNECT_ERROR, + Result.PEER_FAILED_VERIFICATION, + Result.SSL_CERT_PROBLEM, + Result.SSL_CACERT_BAD_FILE, + ] + + comptime _type = Variant[Error, ConnectionError, TimeoutError, TLSError, TooManyRedirectsError, TransportError] + """The error type enum.""" + var value: Self._type + """The underlying error value, which may be one of several specific error types.""" + + def __init__(out self, code: Result): + """Constructs a `RequestError` from a libcurl result code. + + Args: + code: The libcurl result code from a failed transfer. + + Returns: + A `RequestError` representing the failure, with the appropriate kind and details. + """ + if code == Result.OPERATION_TIMEDOUT: + self.value = TimeoutError() + elif code in materialize[Self._CONNECTION_CODES](): + self.value = ConnectionError() + elif code in materialize[Self._TLS_CODES](): + self.value = TLSError() + elif code in materialize[Self._TIMEOUT_CODES](): + self.value = TimeoutError() + else: + self.value = TransportError() + + @implicit + def __init__(out self, var e: Error): + """Constructs a `RequestError` from a low-level `Error`. + + Args: + e: The underlying `Error` returned by the libcurl FFI. + """ + self.value = e^ + + @implicit + def __init__(out self, var e: ConnectionError): + """Constructs a `RequestError` from a low-level `ConnectionError`. + + Args: + e: The underlying `ConnectionError`. + """ + self.value = e^ + + @implicit + def __init__(out self, var e: TimeoutError): + """Constructs a `RequestError` from a low-level `TimeoutError`. + + Args: + e: The underlying `TimeoutError`. + """ + self.value = e^ + + @implicit + def __init__(out self, var e: TLSError): + """Constructs a `RequestError` from a low-level `TLSError`. + + Args: + e: The underlying `TLSError`. + """ + self.value = e^ + + @implicit + def __init__(out self, var e: TooManyRedirectsError): + """Constructs a `RequestError` from a low-level `TooManyRedirectsError`. + + Args: + e: The underlying `TooManyRedirectsError`. + """ + self.value = e^ + + @implicit + def __init__(out self, var e: TransportError): + """Constructs a `RequestError` from a low-level `TransportError`. + + Args: + e: The underlying `TransportError`. + """ + self.value = e^ + + def __getitem_param__[T: FlokiError](ref self) -> ref[self.value] T: + """Returns a reference to the underlying error value of the specified type. + + Parameters: + T: The specific error type to retrieve. + + Returns: + A reference to the underlying error value of type `T`. + """ + return self.value[T] + + def isa[T: FlokiError](self) -> Bool: + """Checks if the underlying error value is of the specified type. + + Parameters: + T: The specific error type to check against. + + Returns: + True if the underlying error value is of type `T`, False otherwise. + """ + return self.value.isa[T]() + + def write_to(self, mut writer: Some[Writer]): + """Writes a human-readable representation of the error. + + Args: + writer: The writer to which the error will be written. + """ + comptime for i in range(len(Self._type.Ts)): + comptime t = Self._type.Ts[i] + comptime if conforms_to(t, Writable): + if self.value.isa[t](): + writer.write(self.value[t]) + return + + writer.write("RequestError: An unknown error occurred.") + + def to_error(deinit self) -> Error: + """Converts the `RequestError` into a low-level `Error`. + + If the underlying error value is already an `Error`, it is returned directly. + Otherwise, a new `Error` is constructed with a message describing the failure. + + Returns: + An `Error` representing the underlying failure. + """ + if self.value.isa[Error](): + return self.value^.take[Error]() + + return Error(self.value) diff --git a/floki/functions.mojo b/floki/functions.mojo index 01272e7..6412b3b 100644 --- a/floki/functions.mojo +++ b/floki/functions.mojo @@ -20,11 +20,11 @@ def get[ var headers: Headers = Headers(), query_parameters: Dict[String, String] = {}, var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends a GET request to the specified URL. Parameters: @@ -44,7 +44,7 @@ def get[ The received response as an `Response` object. Raises: - Error: If the request fails. + RequestError: If the request fails. #### Examples: ```mojo @@ -55,7 +55,8 @@ def get[ var r = floki.get("https://httpbin.org/get", auth=BasicAuth("user", "pass")) ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.GET]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.GET]( url=url, headers=headers, query_parameters=query_parameters, @@ -71,11 +72,12 @@ def post[ var headers: Headers = Headers(), var data: emberjson.Object = {}, var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends a POST request to the specified URL. Parameters: @@ -89,13 +91,14 @@ def post[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to 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. + RequestError: If the data cannot be serialized to JSON or if the request fails. #### Examples: ```mojo @@ -106,9 +109,11 @@ def post[ ``` """ var json_data = emberjson.to_string(data^).as_bytes() - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.POST]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.POST]( url=url, headers=headers, + query_parameters=query_parameters, data=json_data, auth=auth, ) @@ -121,11 +126,12 @@ def post[ data: FormData, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends a POST request with `application/x-www-form-urlencoded` data to the specified URL. Parameters: @@ -139,13 +145,14 @@ def post[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to the request. Returns: The received response as an `Response` object. Raises: - Error: If the request fails. + RequestError: If the request fails. #### Examples: ```mojo @@ -159,9 +166,11 @@ def post[ if "Content-Type" not in headers: headers["Content-Type"] = "application/x-www-form-urlencoded" var encoded = data.encode() - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.POST]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.POST]( url=url, headers=headers, + query_parameters=query_parameters, data=RequestData(encoded.as_bytes()), auth=auth, ) @@ -174,10 +183,11 @@ def post[ data: T, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a POST request to the specified URL. Args: @@ -188,12 +198,13 @@ def post[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be serialized to JSON or if the request fails. + RequestError: If the data cannot be serialized to JSON or if the request fails. #### Examples: ```mojo @@ -210,9 +221,11 @@ def post[ ``` """ var json_data = emberjson.serialize(data) - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.POST]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.POST]( url=url, headers=headers, + query_parameters=query_parameters, data=json_data.as_bytes(), ) @@ -224,10 +237,11 @@ def post[ data: Span[Byte, origin], var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a POST request to the specified URL. Parameters: @@ -241,12 +255,13 @@ def post[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be sent as bytes. + RequestError: If the data cannot be sent as bytes. #### Examples: ```mojo @@ -256,9 +271,11 @@ def post[ var r = floki.post("https://httpbin.org/post", data="hello".as_bytes()) ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.POST]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.POST]( url=url, headers=headers, + query_parameters=query_parameters, data=data, ) @@ -268,10 +285,11 @@ def post( data: FileHandle, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a POST request to the specified URL. Args: @@ -282,12 +300,13 @@ def post( retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be sent from the file handle. + RequestError: If the data cannot be sent from the file handle. #### Examples: ```mojo @@ -298,9 +317,11 @@ def post( var r = floki.post("https://httpbin.org/post", data=file) ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.POST]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.POST]( url=url, headers=headers, + query_parameters=query_parameters, data=Pointer(to=data), ) @@ -312,11 +333,12 @@ def put[ var headers: Headers = Headers(), var data: emberjson.Object = {}, var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends a PUT request to the specified URL. Parameters: @@ -330,13 +352,14 @@ def put[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to 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. + RequestError: If the data cannot be serialized to JSON or if the request fails. #### Examples: ```mojo @@ -347,9 +370,11 @@ def put[ ``` """ var json_data = emberjson.to_string(data^).as_bytes() - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PUT]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PUT]( url=url, headers=headers, + query_parameters=query_parameters, data=json_data, auth=auth, ) @@ -362,10 +387,11 @@ def put[ data: T, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a PUT request to the specified URL. Args: @@ -376,12 +402,13 @@ def put[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be serialized to JSON or if the request fails. + RequestError: If the data cannot be serialized to JSON or if the request fails. #### Examples: ```mojo @@ -398,9 +425,11 @@ def put[ ``` """ var json_data = emberjson.serialize(data) - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PUT]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PUT]( url=url, headers=headers, + query_parameters=query_parameters, data=json_data.as_bytes(), ) @@ -412,10 +441,11 @@ def put[ data: Span[Byte, origin], var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a PUT request to the specified URL. Parameters: @@ -429,12 +459,13 @@ def put[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be sent as bytes. + RequestError: If the data cannot be sent as bytes. #### Examples: ```mojo @@ -444,9 +475,11 @@ def put[ var r = floki.put("https://httpbin.org/put", data="hello".as_bytes()) ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PUT]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PUT]( url=url, headers=headers, + query_parameters=query_parameters, data=data, ) @@ -456,10 +489,11 @@ def put( data: FileHandle, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a PUT request to the specified URL. Args: @@ -470,12 +504,13 @@ def put( retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be sent from the file handle. + RequestError: If the data cannot be sent from the file handle. #### Examples: ```mojo @@ -486,9 +521,11 @@ def put( var r = floki.put("https://httpbin.org/put", data=file) ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PUT]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PUT]( url=url, headers=headers, + query_parameters=query_parameters, data=Pointer(to=data), ) @@ -499,11 +536,12 @@ def delete[ var url: String, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends a DELETE request to the specified URL. Parameters: @@ -516,13 +554,14 @@ def delete[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to the request. Returns: The received response as an `Response` object. Raises: - Error: If the request fails. + RequestError: If the request fails. #### Examples: ```mojo @@ -532,9 +571,11 @@ def delete[ var r = floki.delete("https://httpbin.org/delete") ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.DELETE]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.DELETE]( url=url, headers=headers, + query_parameters=query_parameters, data=RequestData(List[Byte]()), auth=auth, ) @@ -547,11 +588,12 @@ def patch[ var headers: Headers = Headers(), var data: emberjson.Object = {}, var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends a PATCH request to the specified URL. Parameters: @@ -565,13 +607,14 @@ def patch[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to 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. + RequestError: If the data cannot be serialized to JSON or if the request fails. #### Examples: ```mojo @@ -582,9 +625,11 @@ def patch[ ``` """ var json_data = emberjson.to_string(data^).as_bytes() - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PATCH]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PATCH]( url=url, headers=headers, + query_parameters=query_parameters, data=json_data, auth=auth, ) @@ -597,10 +642,11 @@ def patch[ data: T, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a GET request to the specified URL. Args: @@ -611,12 +657,13 @@ def patch[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be serialized to JSON or if the request fails. + RequestError: If the data cannot be serialized to JSON or if the request fails. #### Examples: ```mojo @@ -633,9 +680,11 @@ def patch[ ``` """ var json_data = emberjson.serialize(data) - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PATCH]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PATCH]( url=url, headers=headers, + query_parameters=query_parameters, data=json_data.as_bytes(), ) @@ -647,10 +696,11 @@ def patch[ data: Span[Byte, origin], var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a GET request to the specified URL. Parameters: @@ -664,12 +714,13 @@ def patch[ retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be sent as bytes. + RequestError: If the data cannot be sent as bytes. #### Examples: ```mojo @@ -679,9 +730,11 @@ def patch[ var r = floki.patch("https://httpbin.org/patch", data="hello".as_bytes()) ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PATCH]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PATCH]( url=url, headers=headers, + query_parameters=query_parameters, data=data, ) @@ -691,10 +744,11 @@ def patch( data: FileHandle, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), -) raises -> Response: + query_parameters: Dict[String, String] = {}, +) raises RequestError -> Response: """Sends a GET request to the specified URL. Args: @@ -705,12 +759,13 @@ def patch( retry: An optional retry policy for the request. proxy: An optional proxy configuration for the request. tls: An optional TLS configuration for the request. + query_parameters: Query parameters to include in the request URL. Returns: The received response as an `Response` object. Raises: - Error: If the data cannot be sent from the file handle. + RequestError: If the data cannot be sent from the file handle. #### Examples: ```mojo @@ -721,9 +776,11 @@ def patch( var r = floki.patch("https://httpbin.org/patch", data=file) ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.PATCH]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.PATCH]( url=url, headers=headers, + query_parameters=query_parameters, data=Pointer(to=data), ) @@ -734,11 +791,11 @@ def head[ var url: String, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends a HEAD request to the specified URL. Parameters: @@ -757,7 +814,7 @@ def head[ The received response as an `Response` object. Raises: - Error: If the request fails. + RequestError: If the request fails. #### Examples: ```mojo @@ -767,7 +824,8 @@ def head[ var r = floki.head("https://httpbin.org/get") ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.HEAD]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.HEAD]( url=url, headers=headers, data=RequestData(List[Byte]()), @@ -781,11 +839,11 @@ def options[ var url: String, var headers: Headers = Headers(), var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, var tls: TLS = TLS(), auth: Optional[A] = None, -) raises -> Response: +) raises RequestError -> Response: """Sends an OPTIONS request to the specified URL. Parameters: @@ -804,7 +862,7 @@ def options[ The received response as an `Response` object. Raises: - Error: If the request fails. + RequestError: If the request fails. #### Examples: ```mojo @@ -814,7 +872,8 @@ def options[ var r = floki.options("https://httpbin.org/get") ``` """ - return Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^).send[RequestMethod.OPTIONS]( + var session = Session(timeout=timeout^, retry=retry^, proxy=proxy^, tls=tls^) + return session.send[RequestMethod.OPTIONS]( url=url, headers=headers, data=RequestData(List[Byte]()), diff --git a/floki/http.mojo b/floki/http.mojo index f332aed..19e10a6 100644 --- a/floki/http.mojo +++ b/floki/http.mojo @@ -95,6 +95,12 @@ struct Status(Copyable, Equatable, TrivialRegisterPassable, Writable): """HTTP 301: The resource has been permanently moved to a new URL.""" comptime FOUND = Self(302, "Found") """HTTP 302: The resource resides temporarily under a different URL.""" + comptime SEE_OTHER = Self(303, "See Other") + """HTTP 303: The response to the request can be found under another URL using a GET method.""" + comptime NOT_MODIFIED = Self(304, "Not Modified") + """HTTP 304: The resource has not been modified since the version specified by the request headers.""" + comptime USE_PROXY = Self(305, "Use Proxy") + """HTTP 305: The requested resource must be accessed through the proxy given by the Location header.""" comptime TEMPORARY_REDIRECT = Self(307, "Temporary Redirect") """HTTP 307: The request should be repeated with another URL, but future requests should still use the original URL.""" comptime PERMANENT_REDIRECT = Self(308, "Permanent Redirect") @@ -182,140 +188,157 @@ struct Status(Copyable, Equatable, TrivialRegisterPassable, Writable): comptime NETWORK_AUTHENTICATION_REQUIRED = Self(511, "Network Authentication Required") """HTTP 511: The client needs to authenticate to gain network access.""" - def __init__(out self, code: Int) raises: - """Creates a Status instance from an integer representation. + def __init__(out self, code: Int): + """Creates a Status instance from an integer status code. + + The code is stored as-is; the human-readable `message` is resolved on a + best-effort basis via `_message_for`. Known codes get their standard reason + phrase, and any unrecognized code gets "Unknown". This never raises, so + responses carrying uncommon or vendor-specific status codes remain + representable rather than failing the request. Args: code: The integer representation of the status code. + """ + self.code = UInt16(code) + self.message = Self._message_for(self.code) - Returns: - A Status instance corresponding to the provided integer. + @staticmethod + def _message_for(code: UInt16) -> StaticString: + """Returns the standard reason phrase for a status code, or "Unknown". - Raises: - Error: If the integer does not correspond to a known status code. + Args: + code: The numeric status code to look up. + + Returns: + The reason phrase for a known code, otherwise "Unknown". """ - # For every comptime defined in Status, check if the integer matches - # the value of the alias. - if Self.OK == code: - self = Self.OK - elif Self.CREATED == code: - self = Self.CREATED - elif Self.ACCEPTED == code: - self = Self.ACCEPTED - elif Self.NON_AUTHORITATIVE_INFORMATION == code: - self = Self.NON_AUTHORITATIVE_INFORMATION - elif Self.NO_CONTENT == code: - self = Self.NO_CONTENT - elif Self.RESET_CONTENT == code: - self = Self.RESET_CONTENT - elif Self.PARTIAL_CONTENT == code: - self = Self.PARTIAL_CONTENT - elif Self.MULTI_STATUS == code: - self = Self.MULTI_STATUS - elif Self.ALREADY_REPORTED == code: - self = Self.ALREADY_REPORTED - elif Self.IM_USED == code: - self = Self.IM_USED - elif Self.MULTIPLE_CHOICES == code: - self = Self.MULTIPLE_CHOICES - elif Self.MOVED_PERMANENTLY == code: - self = Self.MOVED_PERMANENTLY - elif Self.FOUND == code: - self = Self.FOUND - elif Self.TEMPORARY_REDIRECT == code: - self = Self.TEMPORARY_REDIRECT - elif Self.PERMANENT_REDIRECT == code: - self = Self.PERMANENT_REDIRECT - elif Self.BAD_REQUEST == code: - self = Self.BAD_REQUEST - elif Self.UNAUTHORIZED == code: - self = Self.UNAUTHORIZED - elif Self.PAYMENT_REQUIRED == code: - self = Self.PAYMENT_REQUIRED - elif Self.FORBIDDEN == code: - self = Self.FORBIDDEN - elif Self.NOT_FOUND == code: - self = Self.NOT_FOUND - elif Self.METHOD_NOT_ALLOWED == code: - self = Self.METHOD_NOT_ALLOWED - elif Self.NOT_ACCEPTABLE == code: - self = Self.NOT_ACCEPTABLE - elif Self.PROXY_AUTHENTICATION_REQUIRED == code: - self = Self.PROXY_AUTHENTICATION_REQUIRED - elif Self.REQUEST_TIMEOUT == code: - self = Self.REQUEST_TIMEOUT - elif Self.CONFLICT == code: - self = Self.CONFLICT - elif Self.GONE == code: - self = Self.GONE - elif Self.LENGTH_REQUIRED == code: - self = Self.LENGTH_REQUIRED - elif Self.PRECONDITION_FAILED == code: - self = Self.PRECONDITION_FAILED - elif Self.PAYLOAD_TOO_LARGE == code: - self = Self.PAYLOAD_TOO_LARGE - elif Self.URI_TOO_LONG == code: - self = Self.URI_TOO_LONG - elif Self.UNSUPPORTED_MEDIA_TYPE == code: - self = Self.UNSUPPORTED_MEDIA_TYPE - elif Self.RANGE_NOT_SATISFIABLE == code: - self = Self.RANGE_NOT_SATISFIABLE - elif Self.EXPECTATION_FAILED == code: - self = Self.EXPECTATION_FAILED - elif Self.IM_A_TEAPOT == code: - self = Self.IM_A_TEAPOT - elif Self.MISDIRECTED_REQUEST == code: - self = Self.MISDIRECTED_REQUEST - elif Self.UNPROCESSABLE_ENTITY == code: - self = Self.UNPROCESSABLE_ENTITY - elif Self.LOCKED == code: - self = Self.LOCKED - elif Self.FAILED_DEPENDENCY == code: - self = Self.FAILED_DEPENDENCY - elif Self.TOO_EARLY == code: - self = Self.TOO_EARLY - elif Self.UPGRADE_REQUIRED == code: - self = Self.UPGRADE_REQUIRED - elif Self.PRECONDITION_REQUIRED == code: - self = Self.PRECONDITION_REQUIRED - elif Self.TOO_MANY_REQUESTS == code: - self = Self.TOO_MANY_REQUESTS - elif Self.REQUEST_HEADER_FIELDS_TOO_LARGE == code: - self = Self.REQUEST_HEADER_FIELDS_TOO_LARGE - elif Self.UNAVAILABLE_FOR_LEGAL_REASONS == code: - self = Self.UNAVAILABLE_FOR_LEGAL_REASONS - elif Self.INTERNAL_ERROR == code: - self = Self.INTERNAL_ERROR - elif Self.NOT_IMPLEMENTED == code: - self = Self.NOT_IMPLEMENTED - elif Self.BAD_GATEWAY == code: - self = Self.BAD_GATEWAY - elif Self.SERVICE_UNAVAILABLE == code: - self = Self.SERVICE_UNAVAILABLE - elif Self.GATEWAY_TIMEOUT == code: - self = Self.GATEWAY_TIMEOUT - elif Self.HTTP_VERSION_NOT_SUPPORTED == code: - self = Self.HTTP_VERSION_NOT_SUPPORTED - elif Self.VARIANT_ALSO_NEGOTIATES == code: - self = Self.VARIANT_ALSO_NEGOTIATES - elif Self.INSUFFICIENT_STORAGE == code: - self = Self.INSUFFICIENT_STORAGE - elif Self.LOOP_DETECTED == code: - self = Self.LOOP_DETECTED - elif Self.NOT_EXTENDED == code: - self = Self.NOT_EXTENDED - elif Self.NETWORK_AUTHENTICATION_REQUIRED == code: - self = Self.NETWORK_AUTHENTICATION_REQUIRED - elif Self.CONTINUE == code: - self = Self.CONTINUE - elif Self.SWITCHING_PROTOCOLS == code: - self = Self.SWITCHING_PROTOCOLS - elif Self.PROCESSING == code: - self = Self.PROCESSING - elif Self.EARLY_HINTS == code: - self = Self.EARLY_HINTS + if code == 100: + return "Continue" + elif code == 101: + return "Switching Protocols" + elif code == 102: + return "Processing" + elif code == 103: + return "Early Hints" + elif code == 200: + return "OK" + elif code == 201: + return "Created" + elif code == 202: + return "Accepted" + elif code == 203: + return "Non-Authoritative Information" + elif code == 204: + return "No Content" + elif code == 205: + return "Reset Content" + elif code == 206: + return "Partial Content" + elif code == 207: + return "Multi-Status" + elif code == 208: + return "Already Reported" + elif code == 226: + return "IM Used" + elif code == 300: + return "Multiple Choices" + elif code == 301: + return "Moved Permanently" + elif code == 302: + return "Found" + elif code == 303: + return "See Other" + elif code == 304: + return "Not Modified" + elif code == 305: + return "Use Proxy" + elif code == 307: + return "Temporary Redirect" + elif code == 308: + return "Permanent Redirect" + elif code == 400: + return "Bad Request" + elif code == 401: + return "Unauthorized" + elif code == 402: + return "Payment Required" + elif code == 403: + return "Forbidden" + elif code == 404: + return "Not Found" + elif code == 405: + return "Method Not Allowed" + elif code == 406: + return "Not Acceptable" + elif code == 407: + return "Proxy Authentication Required" + elif code == 408: + return "Request Timeout" + elif code == 409: + return "Conflict" + elif code == 410: + return "Gone" + elif code == 411: + return "Length Required" + elif code == 412: + return "Precondition Failed" + elif code == 413: + return "Payload Too Large" + elif code == 414: + return "URI Too Long" + elif code == 415: + return "Unsupported Media Type" + elif code == 416: + return "Range Not Satisfiable" + elif code == 417: + return "Expectation Failed" + elif code == 418: + return "I'm a teapot" + elif code == 421: + return "Misdirected Request" + elif code == 422: + return "Unprocessable Entity" + elif code == 423: + return "Locked" + elif code == 424: + return "Failed Dependency" + elif code == 425: + return "Too Early" + elif code == 426: + return "Upgrade Required" + elif code == 428: + return "Precondition Required" + elif code == 429: + return "Too Many Requests" + elif code == 431: + return "Request Header Fields Too Large" + elif code == 451: + return "Unavailable For Legal Reasons" + elif code == 500: + return "Internal Server Error" + elif code == 501: + return "Not Implemented" + elif code == 502: + return "Bad Gateway" + elif code == 503: + return "Service Unavailable" + elif code == 504: + return "Gateway Timeout" + elif code == 505: + return "HTTP Version Not Supported" + elif code == 506: + return "Variant Also Negotiates" + elif code == 507: + return "Insufficient Storage" + elif code == 508: + return "Loop Detected" + elif code == 510: + return "Not Extended" + elif code == 511: + return "Network Authentication Required" else: - raise Error("Unknown status code: ", code) + return "Unknown" def __eq__(self, other: Int) -> Bool: """Compares a Status instance with an integer for equality. diff --git a/floki/proxy.mojo b/floki/proxy.mojo index 7cc2a6d..ad023be 100644 --- a/floki/proxy.mojo +++ b/floki/proxy.mojo @@ -1,7 +1,7 @@ """The `Proxy` type used to configure proxying for requests made by a `Session`.""" -struct Proxy(Boolable, Copyable, Movable): +struct Proxy(Boolable, Movable): """Proxy configuration for requests made by a `Session`. The proxy `url` may include a scheme and port, e.g. `http://proxy.example:8080` @@ -19,15 +19,8 @@ struct Proxy(Boolable, Copyable, Movable): """The username to authenticate with the proxy, or `None`.""" var password: Optional[String] """The password to authenticate with the proxy, or `None`.""" - var no_proxy: Optional[String] - """A comma-separated list of hosts that should bypass the proxy, or `None`.""" - - def __init__(out self): - """Constructs an empty `Proxy` representing no proxy.""" - self.url = "" - self.username = None - self.password = None - self.no_proxy = None + var no_proxy: List[String] + """A list of hosts that should bypass the proxy, or `None`.""" @implicit def __init__( @@ -36,20 +29,26 @@ struct Proxy(Boolable, Copyable, Movable): *, username: Optional[String] = None, password: Optional[String] = None, - no_proxy: Optional[String] = None, - ): + var no_proxy: List[String] = [], + ) raises: """Constructs a `Proxy` from a URL and optional settings. Args: url: The proxy URL, including optional scheme and port. username: The username to authenticate with the proxy, or `None`. password: The password to authenticate with the proxy, or `None`. - no_proxy: A comma-separated list of hosts that should bypass the proxy, or `None`. + no_proxy: A list of hosts that should bypass the proxy. + + Raises: + Error: If the proxy URL is empty. """ + if url.byte_length() <= 0: + raise Error("Proxy URL cannot be empty") + self.url = url^ self.username = username self.password = password - self.no_proxy = no_proxy + self.no_proxy = no_proxy^ def __bool__(self) -> Bool: """Reports whether a proxy is configured. diff --git a/floki/response.mojo b/floki/response.mojo index 384bb99..05e0539 100644 --- a/floki/response.mojo +++ b/floki/response.mojo @@ -18,6 +18,30 @@ struct HTTPError(Movable, Writable): var status: Status """The HTTP status code that caused the error.""" + var url: String + """The URL of the request that produced the error.""" + var body: String + """A snippet of the response body, for debugging.""" + + def write_to(self, mut writer: Some[Writer]) raises: + """Writes a human-readable description of the error to a writer. + + Args: + writer: The writer to which the error description will be written. + + Raises: + Error: If writing to the writer fails. + """ + writer.write( + "HTTPError: ", + self.status.code, + WHITESPACE, + self.status.message, + " for ", + self.url, + CRLF, + self.body, + ) @fieldwise_init @@ -34,6 +58,8 @@ struct Response(Boolable, Movable, Writable): """The HTTP status code of the response.""" var protocol: Protocol """The HTTP protocol used in the response.""" + var url: String + """The final URL of the request, after any redirects.""" def __init__( out self, @@ -42,6 +68,7 @@ struct Response(Boolable, Movable, Writable): status: Status, protocol: Protocol, var headers: Headers = Headers(), + var url: String = String(""), ) raises: """Constructs an Response from its component parts. @@ -51,6 +78,7 @@ struct Response(Boolable, Movable, Writable): status: The HTTP status code of the response. protocol: The HTTP protocol used in the response. headers: The HTTP headers included in the response. + url: The final URL of the request, after any redirects. Raises: Error: If there is a failure in constructing the Body from the provided bytes. @@ -60,6 +88,7 @@ struct Response(Boolable, Movable, Writable): self.status = status self.protocol = protocol self.body = Body(body^) + self.url = url^ def write_to(self, mut writer: Some[Writer]) raises: """Writes the HTTP response to a writer in a standard HTTP format. @@ -152,7 +181,15 @@ struct Response(Boolable, Movable, Writable): HTTPError: If the response status code is not in the 2xx range. """ if not self.is_success(): - raise HTTPError(self.status) + var snippet: String + try: + snippet = String(self.body.as_text()) + except: + snippet = String("") # body not valid UTF-8; leave empty + # cap the snippet length to keep errors readable + if snippet.byte_length() > 512: + snippet = String(snippet[byte=0:512]) + raise HTTPError(status=self.status, url=self.url, body=snippet^) def content_length(self) -> Int: """Returns the length of the response body in bytes. diff --git a/floki/retry.mojo b/floki/retry.mojo index eaaa713..b846df1 100644 --- a/floki/retry.mojo +++ b/floki/retry.mojo @@ -1,8 +1,7 @@ """The `Retry` type used to configure retry behavior for requests made by a `Session`.""" -@fieldwise_init -struct Retry(Copyable, Movable): +struct Retry(Copyable): """A retry policy with exponential backoff for failed requests. A request is retried when the underlying transfer fails (e.g. a connection @@ -20,11 +19,25 @@ struct Retry(Copyable, Movable): var status_forcelist: List[Int] """Response status codes that should trigger a retry.""" - def __init__(out self): - """Constructs a `Retry` policy that performs no retries.""" - self.max_retries = 0 - self.backoff_factor = 0.0 - self.status_forcelist = [408, 429, 500, 502, 503, 504] + def __init__( + out self, + max_retries: Int = 0, + backoff_factor: Float64 = 0.0, + status_forcelist: List[Int] = [408, 429, 500, 502, 503, 504], + ): + """Constructs a `Retry` policy. + + A default-constructed `Retry()` performs no retries, since `max_retries` + defaults to `0`. The `status_forcelist` is inert until `max_retries > 0`. + + Args: + max_retries: Maximum number of retries after the initial request. `0` disables retries. + backoff_factor: Base delay (seconds) used to compute the exponential backoff between retries. + status_forcelist: Response status codes that should trigger a retry. + """ + self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.status_forcelist = status_forcelist.copy() def backoff_time(self, attempt: Int) -> Float64: """Computes the backoff delay before a given retry attempt. diff --git a/floki/session.mojo b/floki/session.mojo index 2240e6d..6377b85 100644 --- a/floki/session.mojo +++ b/floki/session.mojo @@ -5,6 +5,7 @@ from floki.body import Body from floki.callbacks import read_callback, write_callback from floki.cookie.cookie_jar import CookieJar from floki.data import RequestData +from floki.errors import ErrorKind, RequestError, FFIError from floki.forms import FormData from floki.handlers import _handle_delete, _handle_head, _handle_options, _handle_patch, _handle_post, _handle_put from floki.headers import Headers @@ -57,15 +58,15 @@ struct Session(Movable): """Indicates whether libcurl's verbose logging mode is enabled for this session.""" var timeout: Timeout """Timeout configuration applied to every request made with this session.""" - var retry: Retry + var retry: Optional[Retry] """Retry policy applied to every request made with this session.""" - var proxy: Proxy + var proxy: Optional[Proxy] """Proxy configuration applied to every request made with this session.""" - var tls: TLS + var tls: Optional[TLS] """TLS/SSL verification settings applied to every request made with this session.""" comptime DEFAULT_HEADERS = { - "User-Agent": "floki/0.3.3", + "User-Agent": "floki/0.3.4", } """Default headers that are included in every request made with this session, unless overridden by request-specific headers.""" @@ -75,9 +76,9 @@ struct Session(Movable): var headers: Headers = Headers(), verbose: Bool = False, var timeout: Timeout = Timeout(), - var retry: Retry = Retry(), - var proxy: Proxy = Proxy(), - var tls: TLS = TLS(), + var retry: Optional[Retry] = None, + var proxy: Optional[Proxy] = None, + var tls: Optional[TLS] = None, ) raises: """Initialize a new Session. @@ -102,12 +103,31 @@ struct Session(Movable): self.retry = retry^ self.proxy = proxy^ self.tls = tls^ - if self.allow_redirects: - self.raise_if_error(self.easy.follow_location(), "Failed to set follow location to enable redirects: ") if self.verbose: - self.raise_if_error(self.easy.verbose(), "Failed to set libcurl verbose mode: ") + self.raise_if_error(self.easy.verbose(), "Failed to set libcurl verbose mode:") - def raise_if_error(self, code: Result, message: StringSlice) raises: + def __enter__(var self) -> Self: + """Context manager entry point. + + Returns the Session by value for use in a `with` statement. The Session's + resources are automatically cleaned up when exiting the context block. + + Returns: + The Session instance by value. + """ + return self^ + + def close(deinit self): + """Cleans up the resources associated with the Session. + + This method is automatically called when exiting a `with` statement + context. It ensures that the underlying libcurl easy handle is properly + cleaned up to prevent resource leaks. + """ + self.easy.cleanup() + self.easy^.close() + + def raise_if_error(self, code: Result, message: StringSlice) raises Error: """Raises an error if the libcurl result code indicates failure. Args: @@ -118,18 +138,19 @@ struct Session(Movable): Error: If the code does not indicate success, with a message describing the error. """ if code != Result.OK: - raise Error(message, self.easy.describe_error(code)) + raise Error(message, " ", self.easy.describe_error(code)) def send[ origin: ImmutOrigin, //, method: RequestMethod, A: Auth = NoAuth ]( - self, + mut self, mut url: String, mut headers: Headers, data: RequestData[origin], query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, - ) raises -> Response: + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends an HTTP request and returns the corresponding response. The session's `timeout` and `retry` configuration is applied to the request. @@ -143,33 +164,34 @@ struct Session(Movable): url: The URL to which the request is sent. headers: A dictionary of HTTP headers to include in the request. data: An optional `RequestData` variant representing the request body. - query_parameters: An optional dictionary of query parameters to include in the URL. GET requests only. + query_parameters: An optional dictionary of query parameters to include in the URL. Appended to the request URL regardless of HTTP method. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. """ try: # Set the url if query_parameters: # Append the query parameters to the URL. var full_url = _build_url_with_query(url, query_parameters, self.easy) - self.raise_if_error(self.easy.url(full_url), "Failed to set URL with query parameters: ") + self.raise_if_error(self.easy.url(full_url), "Failed to set URL with query parameters:") else: - self.raise_if_error(self.easy.url(url), "Failed to set URL: ") + self.raise_if_error(self.easy.url(url), "Failed to set URL:") # Set the buffer to load the response into var response_body = List[Byte](capacity=8192) self.raise_if_error( self.easy.write_data(UnsafePointer(to=response_body).bitcast[NoneType]()), - "Failed to set write data: ", + "Failed to set write data:", ) # Set the write callback to load the response data into the above buffer. - self.raise_if_error(self.easy.write_function(write_callback), "Failed to set write function: ") + self.raise_if_error(self.easy.write_function(write_callback), "Failed to set write function:") # Set method specific curl options comptime if method == RequestMethod.POST: @@ -194,53 +216,62 @@ struct Session(Movable): elif method == RequestMethod.OPTIONS: _handle_options(self.easy) + # Resolve whether to follow redirects: a per-request override takes + # precedence over the session default. + var follow_redirects = allow_redirects.value() if allow_redirects else self.allow_redirects + self.raise_if_error(self.easy.follow_location(enable=follow_redirects), "Failed to set follow location:") + # Apply the session's timeout configuration. libcurl expects milliseconds. if self.timeout.connect: self.raise_if_error( self.easy.connect_timeout(Int(self.timeout.connect.value() * 1000)), - "Failed to set connect timeout: ", + "Failed to set connect timeout:", ) if self.timeout.total: self.raise_if_error( self.easy.timeout(Int(self.timeout.total.value() * 1000)), - "Failed to set timeout: ", + "Failed to set timeout:", ) # Apply the session's proxy configuration. if self.proxy: - self.raise_if_error(self.easy.proxy(self.proxy.url.copy()), "Failed to set proxy: ") - if self.proxy.username: + ref proxy = self.proxy.value() + self.raise_if_error(self.easy.proxy(proxy.url.copy()), "Failed to set proxy:") + if proxy.username: self.raise_if_error( - self.easy.proxy_username(self.proxy.username.value().copy()), - "Failed to set proxy username: ", + self.easy.proxy_username(proxy.username.value().copy()), + "Failed to set proxy username:", ) - if self.proxy.password: + if proxy.password: self.raise_if_error( - self.easy.proxy_password(self.proxy.password.value().copy()), - "Failed to set proxy password: ", + self.easy.proxy_password(proxy.password.value().copy()), + "Failed to set proxy password:", ) - if self.proxy.no_proxy: + if proxy.no_proxy: self.raise_if_error( - self.easy.no_proxy(self.proxy.no_proxy.value().copy()), - "Failed to set no_proxy: ", + self.easy.no_proxy(",".join(proxy.no_proxy)), + "Failed to set no_proxy:", ) # Apply the session's TLS verification settings. Disabling verification # is dangerous and should only be used for testing or trusted networks. - if not self.tls.verify: - self.raise_if_error( - self.easy.ssl_verify_peer(verify=False), "Failed to disable TLS peer verification: " - ) - self.raise_if_error( - self.easy.ssl_verify_host(verify=False), "Failed to disable TLS host verification: " + if self.tls: + ref tls = self.tls.value() + if not tls.verify: + self.raise_if_error( + self.easy.ssl_verify_peer(verify=False), "Failed to disable TLS peer verification:" + ) + self.raise_if_error( + self.easy.ssl_verify_host(verify=False), "Failed to disable TLS host verification:" ) - if self.tls.ca_bundle: - self.raise_if_error(self.easy.cainfo(self.tls.ca_bundle.value()), "Failed to set TLS CA bundle: ") - if self.tls.ca_path: - self.raise_if_error(self.easy.capath(self.tls.ca_path.value()), "Failed to set TLS CA path: ") - - # Apply the authentication scheme, if one was provided. Headers already - # present on the request take precedence over auth-supplied headers. + if tls.ca_bundle: + self.raise_if_error(self.easy.cainfo(tls.ca_bundle.value()), "Failed to set TLS CA bundle:") + if tls.ca_path: + self.raise_if_error(self.easy.capath(tls.ca_path.value()), "Failed to set TLS CA path:") + + # Apply the authentication scheme. A per-request auth takes precedence + # over the session-level default. Headers already present on the + # request take precedence over auth-supplied headers. if auth: auth.value().apply(headers) @@ -253,10 +284,10 @@ struct Session(Movable): header_list.append(String(t"{header.key}: {header.value}")) # Set headers - self.raise_if_error(self.easy.http_headers(header_list), "Failed to set HTTP headers: ") + self.raise_if_error(self.easy.http_headers(header_list), "Failed to set HTTP headers:") # Enable the cookie engine - self.raise_if_error(self.easy.cookie_file(), "Failed to enable cookie engine: ") + self.raise_if_error(self.easy.cookie_file(), "Failed to enable cookie engine:") # Perform the transfer, retrying per the session's retry policy on # transfer errors or retryable status codes. @@ -265,12 +296,20 @@ struct Session(Movable): response_body.clear() # Discard any partial body from a previous attempt. var perform_result = self.easy.perform() var status_code = Int(self.easy.response_code()) if perform_result == Result.OK else 0 - var should_retry = perform_result != Result.OK or self.retry.should_retry(status_code) - if should_retry and attempt < self.retry.max_retries: - attempt += 1 - sleep(self.retry.backoff_time(attempt)) - continue - self.raise_if_error(perform_result, "Failed to perform the request: ") + + if self.retry: + ref retry = self.retry.value() + var should_retry = perform_result != Result.OK or retry.should_retry(status_code) + if should_retry and attempt < retry.max_retries: + attempt += 1 + sleep(retry.backoff_time(attempt)) + continue + + # Retries (if any) are exhausted. A failed transfer is surfaced as a + # classified `RequestError` so callers can tell a timeout from a + # connection failure from a TLS problem. + if perform_result != Result.OK: + raise RequestError(perform_result) break finally: header_list^.free() # Free headers after performing the request. @@ -281,23 +320,23 @@ struct Session(Movable): protocol=Protocol(self.easy.get_scheme()), status=Status(Int(self.easy.response_code())), cookies=CookieJar(self.easy.cookies()), + url=self.easy.effective_url(), ) 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[ A: Auth = NoAuth, // ]( - self, + mut self, var url: String, var headers: Headers = Headers(), query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, - ) raises -> Response: + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a GET request to the specified URL. Parameters: @@ -308,12 +347,13 @@ struct Session(Movable): headers: HTTP headers to include in the request. query_parameters: Query parameters to include in the request. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -331,17 +371,20 @@ struct Session(Movable): data=RequestData(List[Byte]()), query_parameters=query_parameters, auth=auth, + allow_redirects=allow_redirects, ) def post[ A: Auth = NoAuth, // ]( - self, + mut self, var url: String, var headers: Headers = Headers(), var data: emberjson.Object = {}, + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, - ) raises -> Response: + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a POST request to the specified URL. Parameters: @@ -351,13 +394,15 @@ struct Session(Movable): url: The URL to which the request is sent. headers: HTTP headers to include in the request. data: The data to include in the body of the POST request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -373,18 +418,22 @@ struct Session(Movable): url=url, headers=headers, data=RequestData(json_data), + query_parameters=query_parameters, auth=auth, + allow_redirects=allow_redirects, ) def post[ A: Auth = NoAuth, // ]( - self, + mut self, var url: String, data: FormData, var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, - ) raises -> Response: + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a POST request with `application/x-www-form-urlencoded` data to the specified URL. Parameters: @@ -394,13 +443,15 @@ struct Session(Movable): url: The URL to which the request is sent. data: The form fields to include in the body of the POST request. headers: HTTP headers to include in the request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -419,24 +470,35 @@ struct Session(Movable): url=url, headers=headers, data=RequestData(encoded.as_bytes()), + query_parameters=query_parameters, auth=auth, + allow_redirects=allow_redirects, ) def post[ T: AnyType & ImplicitlyDestructible, // - ](self, var url: String, data: T, var headers: Headers = Headers(),) raises -> Response: + ]( + mut self, + var url: String, + data: T, + var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -457,11 +519,20 @@ struct Session(Movable): url=url, headers=headers, data=json_data.as_bytes(), + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def post[ origin: ImmutOrigin, // - ](self, var url: String, data: Span[Byte, origin], var headers: Headers = Headers(),) raises -> Response: + ]( + mut self, + var url: String, + data: Span[Byte, origin], + var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a POST request to the specified URL. Parameters: @@ -471,12 +542,14 @@ struct Session(Movable): 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -491,26 +564,32 @@ struct Session(Movable): url=url, headers=headers, data=RequestData(data), + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def post( - self, + mut self, var url: String, data: FileHandle, var headers: Headers = Headers(), - ) raises -> Response: + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -526,17 +605,21 @@ struct Session(Movable): url=url, headers=headers, data=RequestData(Pointer(to=data)), + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def put[ A: Auth = NoAuth, // ]( - self, + mut self, var url: String, var headers: Headers = Headers(), var data: emberjson.Object = {}, + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, - ) raises -> Response: + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a PUT request to the specified URL. Parameters: @@ -546,13 +629,15 @@ struct Session(Movable): url: The URL to which the request is sent. headers: HTTP headers to include in the request. data: The data to include in the body of the PUT request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -568,24 +653,35 @@ struct Session(Movable): url=url, headers=headers, data=json_data, + query_parameters=query_parameters, auth=auth, + allow_redirects=allow_redirects, ) def put[ T: AnyType & ImplicitlyDestructible, // - ](self, var url: String, data: T, var headers: Headers = Headers(),) raises -> Response: + ]( + mut self, + var url: String, + data: T, + var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -606,11 +702,20 @@ struct Session(Movable): url=url, headers=headers, data=json_data.as_bytes(), + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def put[ origin: ImmutOrigin, // - ](self, var url: String, data: Span[Byte, origin], var headers: Headers = Headers(),) raises -> Response: + ]( + mut self, + var url: String, + data: Span[Byte, origin], + var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a PUT request to the specified URL. Parameters: @@ -620,12 +725,14 @@ struct Session(Movable): 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -640,26 +747,32 @@ struct Session(Movable): url=url, headers=headers, data=data, + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def put( - self, + mut self, var url: String, data: FileHandle, var headers: Headers = Headers(), - ) raises -> Response: + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -675,11 +788,20 @@ struct Session(Movable): url=url, headers=headers, data=Pointer(to=data), + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def delete[ A: Auth = NoAuth, // - ](self, var url: String, var headers: Headers = Headers(), auth: Optional[A] = None,) raises -> Response: + ]( + mut self, + var url: String, + var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, + auth: Optional[A] = None, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a DELETE request to the specified URL. Parameters: @@ -688,13 +810,15 @@ struct Session(Movable): Args: url: The URL to which the request is sent. headers: HTTP headers to include in the request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -709,18 +833,22 @@ struct Session(Movable): url=url, headers=headers, data=RequestData(List[Byte]()), + query_parameters=query_parameters, auth=auth, + allow_redirects=allow_redirects, ) def patch[ A: Auth = NoAuth, // ]( - self, + mut self, var url: String, var headers: Headers = Headers(), var data: emberjson.Object = {}, + query_parameters: Dict[String, String] = {}, auth: Optional[A] = None, - ) raises -> Response: + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a PATCH request to the specified URL. Parameters: @@ -730,13 +858,15 @@ struct Session(Movable): url: The URL to which the request is sent. headers: HTTP headers to include in the request. data: The data to include in the body of the PATCH request. + query_parameters: Query parameters to include in the request URL. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -752,24 +882,35 @@ struct Session(Movable): url=url, headers=headers, data=json_data, + query_parameters=query_parameters, auth=auth, + allow_redirects=allow_redirects, ) def patch[ T: AnyType & ImplicitlyDestructible, // - ](self, var url: String, data: T, var headers: Headers = Headers(),) raises -> Response: + ]( + mut self, + var url: String, + data: T, + var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -790,11 +931,20 @@ struct Session(Movable): url=url, headers=headers, data=json_data.as_bytes(), + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def patch[ origin: ImmutOrigin, // - ](self, var url: String, data: Span[Byte, origin], var headers: Headers = Headers(),) raises -> Response: + ]( + mut self, + var url: String, + data: Span[Byte, origin], + var headers: Headers = Headers(), + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a PATCH request to the specified URL. Parameters: @@ -804,12 +954,14 @@ struct Session(Movable): 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -824,26 +976,32 @@ struct Session(Movable): url=url, headers=headers, data=data, + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def patch( - self, + mut self, var url: String, data: FileHandle, var headers: Headers = Headers(), - ) raises -> Response: + query_parameters: Dict[String, String] = {}, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> 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. + query_parameters: Query parameters to include in the request URL. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -859,11 +1017,19 @@ struct Session(Movable): url=url, headers=headers, data=Pointer(to=data), + query_parameters=query_parameters, + allow_redirects=allow_redirects, ) def head[ A: Auth = NoAuth, // - ](self, var url: String, var headers: Headers = Headers(), auth: Optional[A] = None,) raises -> Response: + ]( + mut self, + var url: String, + var headers: Headers = Headers(), + auth: Optional[A] = None, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends a HEAD request to the specified URL. Parameters: @@ -873,12 +1039,13 @@ struct Session(Movable): url: The URL to which the request is sent. headers: HTTP headers to include in the request. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -894,11 +1061,18 @@ struct Session(Movable): headers=headers, data=RequestData(List[Byte]()), auth=auth, + allow_redirects=allow_redirects, ) def options[ A: Auth = NoAuth, // - ](self, var url: String, var headers: Headers = Headers(), auth: Optional[A] = None,) raises -> Response: + ]( + mut self, + var url: String, + var headers: Headers = Headers(), + auth: Optional[A] = None, + allow_redirects: Optional[Bool] = None, + ) raises RequestError -> Response: """Sends an OPTIONS request to the specified URL. Parameters: @@ -908,12 +1082,13 @@ struct Session(Movable): url: The URL to which the request is sent. headers: HTTP headers to include in the request. auth: An optional authentication scheme to apply to the request. + allow_redirects: Per-request override for following redirects; falls back to the session default when None. Returns: The received response. Raises: - Error: If there is a failure in sending or receiving the message. + RequestError: If there is a failure in sending or receiving the message. #### Examples: ```mojo @@ -929,4 +1104,5 @@ struct Session(Movable): headers=headers, data=RequestData(List[Byte]()), auth=auth, + allow_redirects=allow_redirects, ) diff --git a/test/test_errors.mojo b/test/test_errors.mojo new file mode 100644 index 0000000..61c5a50 --- /dev/null +++ b/test/test_errors.mojo @@ -0,0 +1,47 @@ +from floki.errors import ErrorKind, RequestError +from mojo_curl.easy import Result +from std.testing import TestSuite, assert_equal, assert_true + + +def test_classify_timeout() raises -> None: + assert_true(ErrorKind.from_result(Result.OPERATION_TIMEDOUT) == ErrorKind.TIMEOUT) + + +def test_classify_connection() raises -> None: + assert_true(ErrorKind.from_result(Result.COULDNT_CONNECT) == ErrorKind.CONNECTION) + assert_true(ErrorKind.from_result(Result.COULDNT_RESOLVE_HOST) == ErrorKind.CONNECTION) + assert_true(ErrorKind.from_result(Result.COULDNT_RESOLVE_PROXY) == ErrorKind.CONNECTION) + assert_true(ErrorKind.from_result(Result.GOT_NOTHING) == ErrorKind.CONNECTION) + assert_true(ErrorKind.from_result(Result.SEND_ERROR) == ErrorKind.CONNECTION) + assert_true(ErrorKind.from_result(Result.RECV_ERROR) == ErrorKind.CONNECTION) + + +def test_classify_tls() raises -> None: + assert_true(ErrorKind.from_result(Result.SSL_CONNECT_ERROR) == ErrorKind.TLS) + assert_true(ErrorKind.from_result(Result.PEER_FAILED_VERIFICATION) == ErrorKind.TLS) + + +def test_classify_too_many_redirects() raises -> None: + assert_true(ErrorKind.from_result(Result.TOO_MANY_REDIRECTS) == ErrorKind.TOO_MANY_REDIRECTS) + + +def test_classify_unknown_falls_back_to_transport() raises -> None: + # A code with no specific classification (OK) falls through to TRANSPORT. + assert_true(ErrorKind.from_result(Result.OK) == ErrorKind.TRANSPORT) + + +def test_error_kind_writes_name() raises -> None: + assert_equal(String.write(ErrorKind.TIMEOUT), "Timeout") + assert_equal(String.write(ErrorKind.CONNECTION), "Connection") + assert_equal(String.write(ErrorKind.TLS), "TLS") + assert_equal(String.write(ErrorKind.TOO_MANY_REDIRECTS), "TooManyRedirects") + assert_equal(String.write(ErrorKind.TRANSPORT), "Transport") + + +# def test_request_error_writes_message() raises -> None: +# var e = RequestError(kind=ErrorKind.TIMEOUT, url="https://example.com", message="timed out", code=28) +# assert_equal(String.write(e), "RequestError [Timeout] for https://example.com: timed out") + + +def main() raises -> None: + TestSuite.discover_tests[__functions_in_module()]().run() diff --git a/test/test_json.mojo b/test/test_json.mojo index 824b0b0..d393550 100644 --- a/test/test_json.mojo +++ b/test/test_json.mojo @@ -19,7 +19,8 @@ struct Todo(Defaultable, Equatable, ImplicitlyDestructible, Movable, Writable): def test_todo_deserialization() raises -> None: - var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") + var session = Session() + 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[Todo](), expected) diff --git a/test/test_proxy_tls.mojo b/test/test_proxy_tls.mojo index 6d185b8..5ad0786 100644 --- a/test/test_proxy_tls.mojo +++ b/test/test_proxy_tls.mojo @@ -11,12 +11,6 @@ import floki # --- Proxy unit tests (no network) --- -def test_proxy_default_is_empty() raises -> None: - var p = Proxy() - assert_false(Bool(p)) - assert_equal(p.url, "") - - def test_proxy_implicit_from_string() raises -> None: var p: Proxy = "http://proxy.example:8080" assert_true(Bool(p)) @@ -29,11 +23,11 @@ def test_proxy_with_credentials_and_bypass() raises -> None: "http://proxy.example:8080", username="user", password="secret", - no_proxy="localhost,127.0.0.1", + no_proxy=["localhost", "127.0.0.1"], ) assert_equal(p.username.value(), "user") assert_equal(p.password.value(), "secret") - assert_equal(p.no_proxy.value(), "localhost,127.0.0.1") + assert_equal(p.no_proxy, ["localhost", "127.0.0.1"]) # --- TLS unit tests (no network) --- @@ -61,18 +55,21 @@ def test_tls_custom_ca_bundle() raises -> None: def test_tls_verification_enabled_succeeds() raises -> None: - var response = Session().get("https://httpbingo.org/get") + var session = Session() + var response = session.get("https://httpbingo.org/get") assert_equal(response.status, Status.OK) def test_tls_verification_disabled_succeeds() raises -> None: - var response = Session(tls=TLS(verify=False)).get("https://httpbingo.org/get") + var session = Session(tls=TLS(verify=False)) + var response = session.get("https://httpbingo.org/get") assert_equal(response.status, Status.OK) def test_proxy_is_bypassed_for_no_proxy_host() raises -> None: # The bogus proxy is never contacted because the target host is in no_proxy. - var response = Session(proxy=Proxy("http://127.0.0.1:9", no_proxy="httpbingo.org")).get("https://httpbingo.org/get") + var session = Session(proxy=Proxy("http://127.0.0.1:9", no_proxy=["httpbingo.org"])) + var response = session.get("https://httpbingo.org/get") assert_equal(response.status, Status.OK) @@ -80,7 +77,8 @@ def test_proxy_is_applied() raises -> None: # A bogus, non-bypassed proxy must cause the request to fail, proving the # proxy is actually routed through rather than ignored. with assert_raises(): - _ = Session(proxy=Proxy("http://127.0.0.1:9")).get("https://httpbingo.org/get") + var session = Session(proxy=Proxy("http://127.0.0.1:9")) + _ = session.get("https://httpbingo.org/get") def test_free_function_forwards_tls() raises -> None: diff --git a/test/test_response.mojo b/test/test_response.mojo index 18b211a..6dff8ff 100644 --- a/test/test_response.mojo +++ b/test/test_response.mojo @@ -28,13 +28,12 @@ def test_status_from_int_500() raises -> None: assert_true(s == Status.INTERNAL_ERROR) -def test_status_from_int_invalid_raises() raises -> None: - var raised = False - try: - var _ = Status(999) - except: - raised = True - assert_true(raised) +def test_status_from_int_unknown_is_best_effort() raises -> None: + # Unrecognized codes must not raise; the raw code is preserved and the + # reason phrase falls back to "Unknown" so uncommon/vendor codes stay usable. + var s = Status(999) + assert_equal(s.code, 999) + assert_equal(String(s.message), "Unknown") def test_status_equality() raises -> None: diff --git a/test/test_session.mojo b/test/test_session.mojo index 7b5039a..4702a45 100644 --- a/test/test_session.mojo +++ b/test/test_session.mojo @@ -40,7 +40,8 @@ struct Todo(Defaultable, Equatable, ImplicitlyDestructible, Movable, Writable): def test_get() raises -> None: - var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") + var session = Session() + var response = session.get("https://jsonplaceholder.typicode.com/todos/1") assert_equal(response.status, Status.OK) var todo = response.body.as[Todo]() @@ -89,7 +90,8 @@ struct ServerPostResponse(Defaultable, Equatable, ImplicitlyDestructible, Movabl def test_post() raises -> None: - var response = Session().post( + var session = Session() + var response = session.post( "https://httpbingo.org/post", headers={ "Content-Type": "application/json", @@ -128,7 +130,8 @@ struct FileContent(Defaultable, Equatable, ImplicitlyDestructible, Movable, Writ def test_post_file() raises -> None: with open("test/data/file.json", "r") as f: - var response = Session().post( + var session = Session() + var response = session.post( "https://jsonplaceholder.typicode.com/todos", headers={ "Content-Type": "application/json", @@ -155,7 +158,8 @@ struct PutResponse(Defaultable, Equatable, ImplicitlyDestructible, Movable, Writ def test_put() raises -> None: - var response = Session().put( + var session = Session() + var response = session.put( "https://jsonplaceholder.typicode.com/posts/1", { "Content-Type": "application/json", @@ -182,7 +186,8 @@ struct PutFileResponse(Defaultable, Equatable, ImplicitlyDestructible, Movable, def test_put_file() raises -> None: with open("test/data/update.json", "r") as f: - var response = Session().put( + var session = Session() + var response = session.put( "https://jsonplaceholder.typicode.com/posts/1", headers={ "Content-Type": "application/json", @@ -214,7 +219,8 @@ struct PatchedTodo(Defaultable, Equatable, ImplicitlyDestructible, Movable, Writ def test_patch() raises -> None: - var response = Session().patch( + var session = Session() + var response = session.patch( "https://jsonplaceholder.typicode.com/posts/1", { "Content-Type": "application/json", @@ -240,7 +246,8 @@ 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 session = Session() + var response = session.patch( "https://jsonplaceholder.typicode.com/posts/1", headers={ "Content-Type": "application/json", @@ -265,23 +272,27 @@ def test_patch_file() raises -> None: def test_delete() raises -> None: - var response = Session().delete("https://jsonplaceholder.typicode.com/posts/1") + var session = Session() + var response = session.delete("https://jsonplaceholder.typicode.com/posts/1") assert_equal(response.status, Status.OK) def test_head() raises -> None: - var response = Session().head("https://httpbingo.org/head") + var session = Session() + var response = session.head("https://httpbingo.org/head") assert_equal(response.status, Status.OK) def test_options() raises -> None: - var response = Session().options("https://jsonplaceholder.typicode.com/posts") + var session = Session() + var response = session.options("https://jsonplaceholder.typicode.com/posts") assert_equal(response.status, Status.NO_CONTENT) assert_equal(response.headers["access-control-allow-methods"], "GET,HEAD,PUT,PATCH,POST,DELETE") def test_cookie_parsing() raises -> None: - var response = Session().get( + var session = Session() + var response = session.get( "https://httpbin.org/cookies/set", query_parameters={"freeform": "my_val"}, ) @@ -312,7 +323,8 @@ struct ServerGetResponse(Defaultable, ImplicitlyDestructible, Movable): def test_session_level_headers() raises -> None: - var response = Session(headers={"X-Floki-Test": "session-headers"}).get( + var session = Session(headers={"X-Floki-Test": "session-headers"}) + var response = session.get( "https://httpbin.org/get", ) assert_equal(response.status, Status.OK) @@ -323,27 +335,32 @@ def test_session_level_headers() raises -> None: def test_session_no_redirects() raises -> None: - var response = Session(allow_redirects=False).get("https://httpbin.org/redirect/1") + var session = Session(allow_redirects=False) + var response = session.get("https://httpbin.org/redirect/1") assert_true(response.is_redirect()) def test_response_is_ok() raises -> None: - var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") + var session = Session() + var response = session.get("https://jsonplaceholder.typicode.com/todos/1") assert_true(response.is_ok()) def test_response_body_as_bytes() raises -> None: - var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") + var session = Session() + var response = session.get("https://jsonplaceholder.typicode.com/todos/1") assert_true(len(response.body.as_bytes()) > 0) def test_response_protocol_is_https() raises -> None: - var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") + var session = Session() + var response = session.get("https://jsonplaceholder.typicode.com/todos/1") assert_true(response.protocol == Protocol.HTTPS) def test_response_raise_for_status_passes_on_200() raises -> None: - var response = Session().get("https://jsonplaceholder.typicode.com/todos/1") + var session = Session() + var response = session.get("https://jsonplaceholder.typicode.com/todos/1") try: response.raise_for_status() # must not raise except e: @@ -352,7 +369,8 @@ def test_response_raise_for_status_passes_on_200() raises -> None: def test_response_raise_for_status_raises_on_4xx() raises -> None: var raised = False - var response = Session().get("https://httpbingo.org/status/404") + var session = Session() + var response = session.get("https://httpbingo.org/status/404") try: response.raise_for_status() except: @@ -361,7 +379,8 @@ def test_response_raise_for_status_raises_on_4xx() raises -> None: def test_post_struct() raises -> None: - var response = Session().post( + var session = Session() + var response = session.post( "https://httpbingo.org/post", data=Record(userId=1, body="bar", title="booggg", active=True), headers={ @@ -388,7 +407,8 @@ struct PutStructData(Defaultable, Equatable, ImplicitlyDestructible, Movable, Wr def test_put_struct() raises -> None: - var response = Session().put( + var session = Session() + var response = session.put( "https://jsonplaceholder.typicode.com/posts/1", data=PutStructData(key1="updated_value1", key2="updated_value2"), headers={ @@ -412,7 +432,8 @@ struct PatchStructData(Defaultable, Equatable, ImplicitlyDestructible, Movable, def test_patch_struct() raises -> None: - var response = Session().patch( + var session = Session() + var response = session.patch( "https://jsonplaceholder.typicode.com/posts/1", data=PatchStructData(key1="patched_value"), headers={