diff --git a/Cargo.lock b/Cargo.lock index 7dd3bea..b80038e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1247,12 +1247,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memo-map" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" - [[package]] name = "micromap" version = "0.3.0" @@ -1265,16 +1259,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minijinja" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39" -dependencies = [ - "memo-map", - "serde", -] - [[package]] name = "mio" version = "1.2.2" @@ -1485,7 +1469,7 @@ checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "oxapy" -version = "0.8.8" +version = "0.10.0" dependencies = [ "ahash", "ctrlc", @@ -1497,7 +1481,6 @@ dependencies = [ "jsonschema", "jsonwebtoken", "matchit", - "minijinja", "multer", "pyo3", "pyo3-async-runtimes", diff --git a/Cargo.toml b/Cargo.toml index 1fdb745..c30e2ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxapy" -version = "0.9.1" +version = "0.10.0" edition = "2024" authors = ["FITAHIANA Nomeniavo Joe <24nomeniavo@gmail.com>"] repository = "https://github.com/j03-dev/oxapy" @@ -44,7 +44,6 @@ serde_json = "1.0.145" jsonschema = { version = "0.46.4", default-features = false } # Template engines -minijinja = "2.14.0" tera = "1.20" # Utilities diff --git a/README.md b/README.md index a928ead..2240c59 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ OxAPY is Python HTTP server library build in Rust - a fast, safe and featureementation.

- + PyPI Downloads

diff --git a/build.sh b/build.sh index 77cd63f..ef58702 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -cargo run --bin stub_gen +cargo run --bin stub_gen --features stub_gen ./.venv/bin/maturin dev --release diff --git a/oxapy/__init__.py b/oxapy/__init__.py index 3aec8f7..6268c64 100644 --- a/oxapy/__init__.py +++ b/oxapy/__init__.py @@ -216,7 +216,7 @@ def _session_middleware(request, next, secret, max_age, **kwargs): request.session = session_data initial_state = json.dumps(session_data) - response = convert_to_response(next(request, **kwargs)) # ty:ignore + response = convert_to_response(next(request, **kwargs)) current_state = json.dumps(request.session) if current_state != initial_state: diff --git a/oxapy/__init__.pyi b/oxapy/__init__.pyi index 1445823..68a6150 100644 --- a/oxapy/__init__.pyi +++ b/oxapy/__init__.pyi @@ -8,10 +8,7 @@ import typing_extensions from . import exceptions from . import jwt from . import serializer -from . import templating __all__ = [ - "Catcher", - "CatcherBuilder", "Cors", "File", "FileStreaming", @@ -24,7 +21,7 @@ __all__ = [ "Router", "Session", "Status", - "catcher", + "Template", "convert_to_response", "delete", "exceptions", @@ -40,51 +37,8 @@ __all__ = [ "send_file", "serializer", "static_file", - "templating", ] -@typing.final -class Catcher: - r""" - A catcher for handling specific HTTP status codes. - - Catchers allow you to provide custom responses for specific HTTP status codes. - They are typically created using the `catcher` decorator function. - - Args: - status (Status): The HTTP status code this catcher will handle. - handler (callable): The handler function that will be called when this status occurs. - - Example: - ```python - from oxapy import catcher, Status - - @catcher(Status.NOT_FOUND) - def handle_not_found(request, response): - return Response("

Custom 404 Page

", content_type="text/html") - ``` - """ - ... - -@typing.final -class CatcherBuilder: - r""" - Internal builder class for creating catchers. - - This class is returned by the `catcher` function and is used to create - a Catcher when called with a handler function. - """ - def __call__(self, handler: typing.Any) -> Catcher: - r""" - Create a Catcher when called with a handler function. - - Args: - handler (callable): The handler function to call when the status occurs. - - Returns: - Catcher: A new catcher for the specified status. - """ - @typing.final class Cors: r""" @@ -491,7 +445,7 @@ class HttpServer: server.attach(router) ``` """ - def template(self, template: templating.Template) -> HttpServer: + def template(self, template: Template) -> HttpServer: r""" Enable template rendering for the server. @@ -558,7 +512,7 @@ class HttpServer: server.channel_capacity(200) ``` """ - def catchers(self, catchers: typing.Sequence[Catcher]) -> HttpServer: + def wrap(self, wrapper: typing.Any) -> HttpServer: r""" Add status code catchers to the server. @@ -570,11 +524,12 @@ class HttpServer: Example: ```python - @catcher(Status.NOT_FOUND) - def not_found(request, response): - return Response("

Page Not Found

", content_type="text/html") + def global_middleware(request, response): + if response.status.code == 200: + return Response("

Page Not Found

", content_type="text/html") + return response - server.catchers([not_found]) + server.wrap(global_middleware) ``` """ def async_mode(self) -> HttpServer: @@ -1147,6 +1102,87 @@ class Router: """ def __repr__(self) -> builtins.str: ... +@typing.final +class Template: + r""" + Template engine for rendering HTML templates. + + This class provides a unified interface for different template engines, + currently supporting both Jinja and Tera templates. + + Args: + dir (str, optional): Directory pattern to search for templates (default: "./templates/**/*.html"). + engine (str, optional): Template engine to use, either "jinja" or "tera" (default: "jinja"). + + Returns: + Template: A new template engine instance. + + Raises: + PyException: If an invalid engine type is specified. + + Example: + ```python + from oxapy import HttpServer, templating + + app = HttpServer(("127.0.0.1", 8000)) + + # Configure templates with default settings (Jinja) + app.template(templating.Template()) + + # Or use Tera with custom template directory + app.template(templating.Template("./views/**/*.html", "tera")) + ``` + """ + def __new__(cls, dir: builtins.str = './templates/**/*.html') -> typing_extensions.Self: + r""" + Create a new Template instance. + + Args: + dir (str, optional): Directory pattern to search for templates (default: "./templates/**/*.html"). + + Returns: + Template: A new template engine instance. + + Raises: + PyException: If an invalid engine type is specified. + + Example: + ```python + from oxapy import templating + + # Use Jinja with default template directory + template = templating.Template() + + # Use Tera with custom template directory + template = templating.Template("./views/**/*.html") + ``` + """ + def render(self, template_name: builtins.str, context: typing.Optional[dict] = None) -> builtins.str: ... + def register_function(self, name: builtins.str, callable: typing.Any) -> None: + r""" + Register a Python function as a custom template function. + + This method allows you to expose Python callables to be used within Tera templates. + The function will receive keyword arguments from the template call and should return + a value that can be serialized to JSON. + + Args: + name (str): The name used to call the function from templates (e.g., `{{ my_function(key=value) }}`). + callable (Callable): A Python callable that accepts keyword arguments and returns a value. + + Returns: + None: This method does not return a value. + + Raises: + RuntimeError: If the template engine has been cloned and is shared across multiple references. + + Example: + ```python + template.register_function("add", lambda a, b: a + b) + # In template: {{ add(a=1, b=2) }} -> 3 + ``` + """ + @typing.final class Status(enum.Enum): r""" @@ -1451,31 +1487,6 @@ class Status(enum.Enum): def Session(secret: bytes, max_age: builtins.int = 604800) -> Response: ... -def catcher(status: Status) -> CatcherBuilder: - r""" - Decorator for creating status code catchers. - - A catcher allows you to provide custom responses for specific HTTP status codes. - - Args: - status (Status): The HTTP status code to catch. - - Returns: - CatcherBuilder: A builder that creates a Catcher when called with a handler function. - - Example: - ```python - from oxapy import catcher, Status, Response - - @catcher(Status.NOT_FOUND) - def handle_404(request, response): - return Response("

Page Not Found

", content_type="text/html") - - # Add the catcher to your server - app.catchers([handle_404]) - ``` - """ - def convert_to_response(result: typing.Any) -> Response: r""" Convert a Python object into an OxAPY `Response`. diff --git a/src/catcher.rs b/src/catcher.rs deleted file mode 100644 index 6681257..0000000 --- a/src/catcher.rs +++ /dev/null @@ -1,83 +0,0 @@ -use pyo3::prelude::*; -use pyo3_stub_gen::derive::*; - -use crate::Status; - -/// A catcher for handling specific HTTP status codes. -/// -/// Catchers allow you to provide custom responses for specific HTTP status codes. -/// They are typically created using the `catcher` decorator function. -/// -/// Args: -/// status (Status): The HTTP status code this catcher will handle. -/// handler (callable): The handler function that will be called when this status occurs. -/// -/// Example: -/// ```python -/// from oxapy import catcher, Status -/// -/// @catcher(Status.NOT_FOUND) -/// def handle_not_found(request, response): -/// return Response("

Custom 404 Page

", content_type="text/html") -/// ``` -#[gen_stub_pyclass] -#[pyclass] -pub struct Catcher { - pub status: Status, - pub handler: Py, -} - -/// Internal builder class for creating catchers. -/// -/// This class is returned by the `catcher` function and is used to create -/// a Catcher when called with a handler function. -#[gen_stub_pyclass] -#[pyclass] -pub struct CatcherBuilder { - status: Status, -} - -#[gen_stub_pymethods] -#[pymethods] -impl CatcherBuilder { - /// Create a Catcher when called with a handler function. - /// - /// Args: - /// handler (callable): The handler function to call when the status occurs. - /// - /// Returns: - /// Catcher: A new catcher for the specified status. - fn __call__(&self, handler: Py) -> Catcher { - Catcher { - status: self.status, - handler, - } - } -} - -/// Decorator for creating status code catchers. -/// -/// A catcher allows you to provide custom responses for specific HTTP status codes. -/// -/// Args: -/// status (Status): The HTTP status code to catch. -/// -/// Returns: -/// CatcherBuilder: A builder that creates a Catcher when called with a handler function. -/// -/// Example: -/// ```python -/// from oxapy import catcher, Status, Response -/// -/// @catcher(Status.NOT_FOUND) -/// def handle_404(request, response): -/// return Response("

Page Not Found

", content_type="text/html") -/// -/// # Add the catcher to your server -/// app.catchers([handle_404]) -/// ``` -#[gen_stub_pyfunction] -#[pyfunction] -pub fn catcher(status: Status) -> CatcherBuilder { - CatcherBuilder { status } -} diff --git a/src/json.rs b/src/json.rs index 59c5a15..f2fd488 100644 --- a/src/json.rs +++ b/src/json.rs @@ -22,7 +22,7 @@ pub fn loads(data: &str, py: Python<'_>) -> PyResult> { Ok(deserialized_data.extract(py)?) } -pub fn from_pydict2rstruct(dict: &Bound<'_, PyDict>) -> PyResult +pub fn from_pydict2rstruct(dict: &Bound<'_, PyAny>) -> PyResult where T: for<'de> Deserialize<'de>, { diff --git a/src/lib.rs b/src/lib.rs index 123ca34..da5cbeb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,6 @@ use std::ops::Deref; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use ahash::HashMap; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyInt, PyString}; @@ -15,7 +14,6 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::sync::Semaphore; use tokio::sync::mpsc::{Receiver, Sender, channel}; -use catcher::Catcher; use cors::Cors; use exceptions::IntoPyException; use into_response::convert_to_response; @@ -27,7 +25,6 @@ use routing::*; use status::Status; use templating::Template; -mod catcher; mod cors; #[macro_use] mod exceptions; @@ -47,8 +44,7 @@ pyo3_stub_gen::export_verbatim!("oxapy", "from typing_extensions import Self"); pyo3_stub_gen::define_stub_info_gatherer!(stub_info); struct ProcessRequest { - catchers: Option>>>, - cors: Option>, + wrapper: Option>>, router: Option>, match_route: Option>, request: Arc, @@ -58,9 +54,8 @@ struct ProcessRequest { #[derive(Clone)] struct RequestContext { app_data: Option>>, - catchers: Option>>>, + wrapper: Option>>, channel_capacity: usize, - cors: Option>, routers: Vec>, request_sender: Sender, template: Option>, @@ -117,7 +112,7 @@ struct RequestContext { struct HttpServer { addr: SocketAddr, app_data: Option>>, - catchers: Option>>>, + wrapper: Option>>, channel_capacity: usize, cors: Option>, is_async: bool, @@ -176,7 +171,7 @@ impl HttpServer { Ok(Self { addr: SocketAddr::new(ip.parse()?, port), app_data: None, - catchers: None, + wrapper: None, channel_capacity: 100, cors: None, is_async: false, @@ -347,22 +342,19 @@ impl HttpServer { /// /// Example: /// ```python - /// @catcher(Status.NOT_FOUND) - /// def not_found(request, response): - /// return Response("

Page Not Found

", content_type="text/html") + /// def global_middleware(request, response): + /// if response.status.code == 200: + /// return Response("

Page Not Found

", content_type="text/html") + /// return response /// - /// server.catchers([not_found]) + /// server.wrap(global_middleware) /// ``` - fn catchers<'py>( + fn wrap<'py>( mut slf: PyRefMut<'py, Self>, - catchers: Vec>, + wrapper: Py, py: Python<'py>, ) -> PyRefMut<'py, Self> { - let map = catchers - .into_iter() - .map(|c| (c.status, c.handler.clone_ref(py))) - .collect(); - slf.catchers = Some(Arc::new(map)); + slf.wrapper = Some(Arc::new(wrapper)); slf } @@ -455,9 +447,8 @@ impl HttpServer { let (tx, rx) = channel::(self.channel_capacity); let ctx = RequestContext { app_data: self.app_data.clone(), - catchers: self.catchers.clone(), + wrapper: self.wrapper.clone(), channel_capacity: self.channel_capacity, - cors: self.cors.clone(), routers: self.routers.clone(), request_sender: tx, template: self.template.clone(), @@ -530,8 +521,7 @@ impl HttpServer { call_python_handler(&req.router, &req.match_route, &req.request, self.is_async) .await .unwrap_or_else(Response::from) - .apply_catcher(&req) - .apply_cors(&req.cors)?; + .call_wrapper(&req); let _ = req.tx.send(response).await; Ok(()) } @@ -589,7 +579,7 @@ fn execute_route_handler( if router.middlewares.is_empty() { route.handler.call(py, (request.clone(),), Some(&kwargs)) } else { - let chain = MiddlewareChain::new(router.middlewares.clone()); + let chain = MiddlewareChain::new(&router.middlewares); chain.execute( py, route.sequence, @@ -669,7 +659,6 @@ fn oxapy(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; - m.add_function(wrap_pyfunction!(catcher::catcher, m)?)?; m.add_function(wrap_pyfunction!(convert_to_response, m)?)?; m.add_function(wrap_pyfunction!(delete, m)?)?; m.add_function(wrap_pyfunction!(get, m)?)?; diff --git a/src/middleware.rs b/src/middleware.rs index 01ce50c..0396faa 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -17,12 +17,12 @@ impl Middleware { } } -pub struct MiddlewareChain { - middlewares: Vec, +pub struct MiddlewareChain<'l> { + middlewares: &'l [Middleware], } -impl MiddlewareChain { - pub fn new(middlewares: Vec) -> Self { +impl<'l> MiddlewareChain<'l> { + pub fn new(middlewares: &'l [Middleware]) -> Self { Self { middlewares } } diff --git a/src/request.rs b/src/request.rs index 7bcb691..a8c4a6b 100644 --- a/src/request.rs +++ b/src/request.rs @@ -284,8 +284,7 @@ impl Request { router: Some(router), match_route: Some(transmutate_route), tx, - cors: ctx.cors.clone(), - catchers: ctx.catchers.clone(), + wrapper: ctx.wrapper.clone(), }; Self::send_and_wait_response(ctx, process_request, rx).await @@ -302,8 +301,7 @@ impl Request { router: None, match_route: None, tx, - cors: ctx.cors.clone(), - catchers: ctx.catchers.clone(), + wrapper: ctx.wrapper.clone(), }; Self::send_and_wait_response(ctx, process_request, rx).await diff --git a/src/response.rs b/src/response.rs index e70e411..15f7392 100644 --- a/src/response.rs +++ b/src/response.rs @@ -17,7 +17,7 @@ use pyo3::prelude::*; use pyo3::types::{PyBytes, PyString}; use pyo3_stub_gen::derive::*; -use crate::{Cors, IntoPyException, ProcessRequest, Status, convert_to_response, json}; +use crate::{IntoPyException, ProcessRequest, Status, convert_to_response, json}; pub type Body = BoxBody; @@ -231,26 +231,17 @@ impl Response { }) } - pub(crate) fn apply_catcher(mut self, req: &ProcessRequest) -> Self { - if let Some(catchers) = &req.catchers - && let Some(handler) = catchers.get(&self.status) - { + pub(crate) fn call_wrapper(mut self, req: &ProcessRequest) -> Self { + if let Some(wrapper) = &req.wrapper { let request = req.request.as_ref().clone(); self = Python::attach(|py| { - let result = handler.call(py, (request, self), None)?; + let result = wrapper.call(py, (request, self), None)?; convert_to_response(result, py) }) .unwrap_or_else(Response::from); } self } - - pub(crate) fn apply_cors(mut self, cors: &Option>) -> PyResult { - if let Some(cors) = cors { - self = cors.apply_to_response(self)?; - } - Ok(self) - } } /// HTTP redirect response. diff --git a/src/templating/mod.rs b/src/templating.rs similarity index 53% rename from src/templating/mod.rs rename to src/templating.rs index aa8183c..20087db 100644 --- a/src/templating/mod.rs +++ b/src/templating.rs @@ -1,20 +1,47 @@ +use std::sync::Arc; + +use ahash::HashMap; use hyper::{HeaderMap, header::CONTENT_TYPE}; use pyo3::{ Bound, PyResult, - exceptions::{PyException, PyValueError}, + exceptions::PyValueError, prelude::*, types::{PyDict, PyModule, PyModuleMethods}, }; use pyo3_stub_gen::derive::*; +use tera::{Function, Result as TeraResult, Value}; use crate::{ + exceptions::IntoPyException, + json, request::Request, response::{Response, ResponseBody}, status::Status, }; -mod minijinja; -mod tera; +struct PyTeraFunction { + callable: Py, +} + +impl Function for PyTeraFunction { + fn call(&self, args: &std::collections::HashMap) -> TeraResult { + Python::attach(|py| { + let py_kwargs = json::from_rstruct2pydict(args, py) + .map_err(tera::Error::msg)? + .into_bound(py); + let result = self + .callable + .call(py, (), Some(&py_kwargs)) + .map_err(tera::Error::msg)? + .into_bound(py); + json::from_pydict2rstruct(&result).map_err(tera::Error::msg) + }) + } + + fn is_safe(&self) -> bool { + true + } +} /// Template engine for rendering HTML templates. /// @@ -43,12 +70,11 @@ mod tera; /// # Or use Tera with custom template directory /// app.template(templating.Template("./views/**/*.html", "tera")) /// ``` -#[gen_stub_pyclass_enum] #[pyclass(from_py_object, module = "oxapy.templating")] +#[gen_stub_pyclass] #[derive(Clone, Debug)] -pub enum Template { - Jinja(minijinja::Jinja), - Tera(tera::Tera), +pub struct Template { + engine: Arc, } #[gen_stub_pymethods] @@ -58,7 +84,6 @@ impl Template { /// /// Args: /// dir (str, optional): Directory pattern to search for templates (default: "./templates/**/*.html"). - /// engine (str, optional): Template engine to use, either "jinja" or "tera" (default: "jinja"). /// /// Returns: /// Template: A new template engine instance. @@ -74,18 +99,67 @@ impl Template { /// template = templating.Template() /// /// # Use Tera with custom template directory - /// template = templating.Template("./views/**/*.html", "tera") + /// template = templating.Template("./views/**/*.html") /// ``` #[new] - #[pyo3(signature=(dir="./templates/**/*.html", engine="jinja"))] + #[pyo3(signature=(dir="./templates/**/*.html"))] #[gen_stub(override_return_type(type_repr = "typing_extensions.Self", imports = ("typing_extensions",)))] - fn new(dir: &str, engine: &str) -> PyResult