diff options
author | Quentin Gliech <quenting@element.io> | 2024-04-18 12:20:30 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-18 12:20:30 +0200 |
commit | 09f0957b36cf1b4e9a89f5594df51a853d0dfffe (patch) | |
tree | 57ad6eb65d833ed18183a19bbf442d341e6ce37d /rust | |
parent | Fix remote receipts for events we don't have (#17096) (diff) | |
download | synapse-09f0957b36cf1b4e9a89f5594df51a853d0dfffe.tar.xz |
Helpers to transform Twisted requests to Rust http Requests/Responses (#17081)
This adds functions to transform a Twisted request to the `http::Request`, and then to send back an `http::Response` through it. It also imports the SynapseError exception so that we can throw that from Rust code directly Example usage of this would be: ```rust use crate::http::{http_request_from_twisted, http_response_to_twisted, HeaderMapPyExt}; fn handler(twisted_request: &PyAny) -> PyResult<()> { let request = http_request_from_twisted(twisted_request)?; let ua: headers::UserAgent = request.headers().typed_get_required()?; if whatever { return Err((crate::errors::SynapseError::new( StatusCode::UNAUTHORIZED, "Whatever".to_owned "M_UNAUTHORIZED", None, None, ))); } let response = Response::new("hello".as_bytes()); http_response_to_twisted(twisted_request, response)?; Ok(()) } ```
Diffstat (limited to 'rust')
-rw-r--r-- | rust/Cargo.toml | 3 | ||||
-rw-r--r-- | rust/src/errors.rs | 60 | ||||
-rw-r--r-- | rust/src/http.rs | 165 | ||||
-rw-r--r-- | rust/src/lib.rs | 2 |
4 files changed, 230 insertions, 0 deletions
diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ba293f8d4f..9ac766182b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -23,6 +23,9 @@ name = "synapse.synapse_rust" [dependencies] anyhow = "1.0.63" +bytes = "1.6.0" +headers = "0.4.0" +http = "1.1.0" lazy_static = "1.4.0" log = "0.4.17" pyo3 = { version = "0.20.0", features = [ diff --git a/rust/src/errors.rs b/rust/src/errors.rs new file mode 100644 index 0000000000..4e580e3e8c --- /dev/null +++ b/rust/src/errors.rs @@ -0,0 +1,60 @@ +/* + * This file is licensed under the Affero General Public License (AGPL) version 3. + * + * Copyright (C) 2024 New Vector, Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * See the GNU Affero General Public License for more details: + * <https://www.gnu.org/licenses/agpl-3.0.html>. + */ + +#![allow(clippy::new_ret_no_self)] + +use std::collections::HashMap; + +use http::{HeaderMap, StatusCode}; +use pyo3::{exceptions::PyValueError, import_exception}; + +import_exception!(synapse.api.errors, SynapseError); + +impl SynapseError { + pub fn new( + code: StatusCode, + message: String, + errcode: &'static str, + additional_fields: Option<HashMap<String, String>>, + headers: Option<HeaderMap>, + ) -> pyo3::PyErr { + // Transform the HeaderMap into a HashMap<String, String> + let headers = if let Some(headers) = headers { + let mut map = HashMap::with_capacity(headers.len()); + for (key, value) in headers.iter() { + let Ok(value) = value.to_str() else { + // This should never happen, but we don't want to panic in case it does + return PyValueError::new_err( + "Could not construct SynapseError: header value is not valid ASCII", + ); + }; + + map.insert(key.as_str().to_owned(), value.to_owned()); + } + Some(map) + } else { + None + }; + + SynapseError::new_err((code.as_u16(), message, errcode, additional_fields, headers)) + } +} + +import_exception!(synapse.api.errors, NotFoundError); + +impl NotFoundError { + pub fn new() -> pyo3::PyErr { + NotFoundError::new_err(()) + } +} diff --git a/rust/src/http.rs b/rust/src/http.rs new file mode 100644 index 0000000000..74098f4c8b --- /dev/null +++ b/rust/src/http.rs @@ -0,0 +1,165 @@ +/* + * This file is licensed under the Affero General Public License (AGPL) version 3. + * + * Copyright (C) 2024 New Vector, Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * See the GNU Affero General Public License for more details: + * <https://www.gnu.org/licenses/agpl-3.0.html>. + */ + +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use headers::{Header, HeaderMapExt}; +use http::{HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri}; +use pyo3::{ + exceptions::PyValueError, + types::{PyBytes, PySequence, PyTuple}, + PyAny, PyResult, +}; + +use crate::errors::SynapseError; + +/// Read a file-like Python object by chunks +/// +/// # Errors +/// +/// Returns an error if calling the `read` on the Python object failed +fn read_io_body(body: &PyAny, chunk_size: usize) -> PyResult<Bytes> { + let mut buf = BytesMut::new(); + loop { + let bytes: &PyBytes = body.call_method1("read", (chunk_size,))?.downcast()?; + if bytes.as_bytes().is_empty() { + return Ok(buf.into()); + } + buf.put(bytes.as_bytes()); + } +} + +/// Transform a Twisted `IRequest` to an [`http::Request`] +/// +/// It uses the following members of `IRequest`: +/// - `content`, which is expected to be a file-like object with a `read` method +/// - `uri`, which is expected to be a valid URI as `bytes` +/// - `method`, which is expected to be a valid HTTP method as `bytes` +/// - `requestHeaders`, which is expected to have a `getAllRawHeaders` method +/// +/// # Errors +/// +/// Returns an error if the Python object doesn't properly implement `IRequest` +pub fn http_request_from_twisted(request: &PyAny) -> PyResult<Request<Bytes>> { + let content = request.getattr("content")?; + let body = read_io_body(content, 4096)?; + + let mut req = Request::new(body); + + let uri: &PyBytes = request.getattr("uri")?.downcast()?; + *req.uri_mut() = + Uri::try_from(uri.as_bytes()).map_err(|_| PyValueError::new_err("invalid uri"))?; + + let method: &PyBytes = request.getattr("method")?.downcast()?; + *req.method_mut() = Method::from_bytes(method.as_bytes()) + .map_err(|_| PyValueError::new_err("invalid method"))?; + + let headers_iter = request + .getattr("requestHeaders")? + .call_method0("getAllRawHeaders")? + .iter()?; + + for header in headers_iter { + let header = header?; + let header: &PyTuple = header.downcast()?; + let name: &PyBytes = header.get_item(0)?.downcast()?; + let name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| PyValueError::new_err("invalid header name"))?; + + let values: &PySequence = header.get_item(1)?.downcast()?; + for index in 0..values.len()? { + let value: &PyBytes = values.get_item(index)?.downcast()?; + let value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_| PyValueError::new_err("invalid header value"))?; + req.headers_mut().append(name.clone(), value); + } + } + + Ok(req) +} + +/// Send an [`http::Response`] through a Twisted `IRequest` +/// +/// It uses the following members of `IRequest`: +/// +/// - `responseHeaders`, which is expected to have a `addRawHeader(bytes, bytes)` method +/// - `setResponseCode(int)` method +/// - `write(bytes)` method +/// - `finish()` method +/// +/// # Errors +/// +/// Returns an error if the Python object doesn't properly implement `IRequest` +pub fn http_response_to_twisted<B>(request: &PyAny, response: Response<B>) -> PyResult<()> +where + B: Buf, +{ + let (parts, mut body) = response.into_parts(); + + request.call_method1("setResponseCode", (parts.status.as_u16(),))?; + + let response_headers = request.getattr("responseHeaders")?; + for (name, value) in parts.headers.iter() { + response_headers.call_method1("addRawHeader", (name.as_str(), value.as_bytes()))?; + } + + while body.remaining() != 0 { + let chunk = body.chunk(); + request.call_method1("write", (chunk,))?; + body.advance(chunk.len()); + } + + request.call_method0("finish")?; + + Ok(()) +} + +/// An extension trait for [`HeaderMap`] that provides typed access to headers, and throws the +/// right python exceptions when the header is missing or fails to parse. +/// +/// [`HeaderMap`]: headers::HeaderMap +pub trait HeaderMapPyExt: HeaderMapExt { + /// Get a header from the map, returning an error if it is missing or invalid. + fn typed_get_required<H>(&self) -> PyResult<H> + where + H: Header, + { + self.typed_get_optional::<H>()?.ok_or_else(|| { + SynapseError::new( + StatusCode::BAD_REQUEST, + format!("Missing required header: {}", H::name()), + "M_MISSING_PARAM", + None, + None, + ) + }) + } + + /// Get a header from the map, returning `None` if it is missing and an error if it is invalid. + fn typed_get_optional<H>(&self) -> PyResult<Option<H>> + where + H: Header, + { + self.typed_try_get::<H>().map_err(|_| { + SynapseError::new( + StatusCode::BAD_REQUEST, + format!("Invalid header: {}", H::name()), + "M_INVALID_PARAM", + None, + None, + ) + }) + } +} + +impl<T: HeaderMapExt> HeaderMapPyExt for T {} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7b3b579e55..36a3d64528 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,7 +3,9 @@ use pyo3::prelude::*; use pyo3_log::ResetHandle; pub mod acl; +pub mod errors; pub mod events; +pub mod http; pub mod push; lazy_static! { |