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! {
|