Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "oxapy"
version = "0.7.9"
edition = "2021"
edition = "2024"
authors = ["FITAHIANA Nomeniavo Joe <24nomeniavo@gmail.com>"]
repository = "https://github.com/j03-dev/oxapy"

Expand Down Expand Up @@ -43,7 +43,6 @@ tera = "1.20"
ahash = "0.8.12"
ctrlc = "3.5.1"
glob = "0.3.3"
once_cell = "1.21.3"
rand = "0.10.0-rc.5"
url = "2.5.7"

Expand Down
24 changes: 19 additions & 5 deletions oxapy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
from .oxapy import *

import os
import mimetypes


def secure_join(base: str, *paths: str) -> str:
base = os.path.realpath(base)
target = os.path.realpath(os.path.join(base, *paths))

if target != base and not target.startswith(base + os.sep):
raise exceptions.ForbiddenError("Access denied")

return target


def static_file(path: str = "/static", directory: str = "./static"):
r"""
Create a route for serving static files.
Expand All @@ -22,11 +33,8 @@ def static_file(path: str = "/static", directory: str = "./static"):

@get(f"{path}/{{*path}}")
def handler(_request, path: str):
file_path = f"{directory}/{path}"
try:
return send_file(file_path)
except FileNotFoundError:
return Response("File not found", Status.NOT_FOUND)
file_path = secure_join(directory, path)
return send_file(file_path)

return handler

Expand All @@ -40,6 +48,12 @@ def send_file(path: str) -> Response:
Returns:
Response: A Response with file content
"""
if not os.path.exists(path):
raise exceptions.NotFoundError("Requested file not found")

if not os.path.isfile(path):
raise exceptions.ForbiddenError("Not a file")

with open(path, "rb") as f:
content = f.read()
content_type, _ = mimetypes.guess_type(path)
Expand Down
4 changes: 2 additions & 2 deletions oxapy/serializer/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,9 @@ class Serializer(Field):
@raw_data.setter
def raw_data(self, value: typing.Optional[builtins.str]) -> None: ...
@property
def context(self) -> typing.Optional[dict]: ...
def context(self) -> dict: ...
@context.setter
def context(self, value: typing.Optional[dict]) -> None: ...
def context(self, value: dict) -> None: ...
Comment on lines +270 to +272
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat oxapy/serializer/__init__.pyi | head -n 280 | tail -n 20

Repository: j03-dev/oxapy

Length of output: 830


🏁 Script executed:

# Find documentation files mentioning context or breaking changes
find . -type f \( -name "*.md" -o -name "*.rst" -o -name "CHANGELOG*" -o -name "RELEASE*" \) | head -20

Repository: j03-dev/oxapy

Length of output: 68


🏁 Script executed:

# Search for migration guides or documentation about this change
rg -l "context" --type=md --type=rst | head -10

Repository: j03-dev/oxapy

Length of output: 39


🏁 Script executed:

# Look for examples using the context property
fd -e py | xargs rg -l "\.context" --type=py | head -10

Repository: j03-dev/oxapy

Length of output: 39


🏁 Script executed:

cat README.md

Repository: j03-dev/oxapy

Length of output: 5574


🏁 Script executed:

# Find the actual Python source file for the serializer
find . -path "./oxapy/serializer/*.py" -not -path "*/.*"

Repository: j03-dev/oxapy

Length of output: 39


🏁 Script executed:

# Check if there's a git history we can examine
git log --oneline oxapy/serializer/__init__.pyi 2>/dev/null | head -10

Repository: j03-dev/oxapy

Length of output: 85


🏁 Script executed:

# Look for version information or changelog
rg -l "version|changelog|breaking|release" --type=md --type=txt | head -10

Repository: j03-dev/oxapy

Length of output: 66


🏁 Script executed:

# Find the actual Python source file for serializer
find . -name "*.py" -path "*/serializer/*" -type f

Repository: j03-dev/oxapy

Length of output: 39


🏁 Script executed:

# Check setup.py or pyproject.toml for changelog/version info
find . -maxdepth 2 \( -name "setup.py" -o -name "pyproject.toml" -o -name "setup.cfg" \) -type f

Repository: j03-dev/oxapy

Length of output: 73


🏁 Script executed:

# Search for any docstrings or comments about context in Python files
rg -C5 "def context" --type=py

Repository: j03-dev/oxapy

Length of output: 39


🏁 Script executed:

cat pyproject.toml

Repository: j03-dev/oxapy

Length of output: 637


🏁 Script executed:

# Search for any mention of breaking changes, context, or migration in any file type
rg -i "breaking|context|migration" --max-count=20

Repository: j03-dev/oxapy

Length of output: 3772


🏁 Script executed:

# Look for any documentation files in the repo
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "docs" -type d \) | head -20

Repository: j03-dev/oxapy

Length of output: 68


The type annotation change is intentional and correctly reflects the Rust implementation, but document this breaking change in release notes.

The context property type change from Optional[dict] to dict is backed by the Rust implementation in src/serializer/mod.rs, which explicitly converts None to an empty dict via context.unwrap_or_else(|| PyDict::new(py).into()).

However, this creates a breaking change for code that checks if serializer.context is None, which will now always be False. While code that treats context as a dict continues to work, the absence of migration documentation, release notes, or changelog entry leaves users unaware of this behavioral shift. Add these details to the next release documentation.

🤖 Prompt for AI Agents
In @oxapy/serializer/__init__.pyi around lines 270 - 272, Document the breaking
change that the Serializer.context property type was changed from Optional[dict]
to dict (i.e., it will never be None) by updating the release notes/changelog
and migration guide; mention that this mirrors the Rust implementation in
src/serializer/mod.rs which converts None to an empty dict
(context.unwrap_or_else(|| PyDict::new(py).into())), include examples of
affected checks like "if serializer.context is None" and recommend replacing
them with truthiness checks or explicit emptiness checks (e.g., if not
serializer.context or if serializer.context == {}), and add a short note in the
Python API section referencing the context property and the rationale for the
change.

@property
def data(self) -> typing.Any:
r"""
Expand Down
20 changes: 18 additions & 2 deletions src/cors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,19 @@ pub struct Cors {
/// List of allowed origins, default is ["*"] (all origins)
#[pyo3(get, set)]
pub origins: Vec<String>,

/// List of allowed HTTP methods, default includes common methods
#[pyo3(get, set)]
pub methods: Vec<String>,

/// List of allowed HTTP headers, default includes common headers
#[pyo3(get, set)]
pub headers: Vec<String>,

/// Whether to allow credentials (cookies, authorization headers), default is true
#[pyo3(get, set)]
pub allow_credentials: bool,

/// Maximum age of preflight requests in seconds, default is 86400 (1 day)
#[pyo3(get, set)]
pub max_age: u32,
Expand All @@ -52,8 +56,20 @@ impl Default for Cors {
fn default() -> Self {
Self {
origins: vec!["*".to_string()],
methods: vec!["GET, POST, PUT, DELETE, PATCH, OPTIONS".to_string()],
headers: vec!["Content-Type, Authorization, X-Requested-With, Accept".to_string()],
methods: vec![
"DELETE".to_string(),
"GET".to_string(),
"OPTIONS".to_string(),
"PATCH".to_string(),
"POST".to_string(),
"PUT".to_string(),
],
headers: vec![
"Accept".to_string(),
"Authorization".to_string(),
"Content-Type".to_string(),
"X-Requested-With".to_string(),
],
allow_credentials: true,
max_age: 86400,
}
Expand Down
51 changes: 18 additions & 33 deletions src/exceptions.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use pyo3::exceptions::PyException;
use pyo3::{impl_exception_boilerplate, prelude::*};
use pyo3::prelude::*;
use pyo3_stub_gen::derive::*;

pub trait IntoPyException<T> {
Expand All @@ -13,7 +13,22 @@ impl<T, E: ToString> IntoPyException<T> for Result<T, E> {
}

macro_rules! extend_exception {
($name:ident) => {
pyo3::impl_exception_boilerplate!($name);

#[pyo3_stub_gen::derive::gen_stub_pymethods]
#[pyo3::prelude::pymethods]
impl $name {
#[new]
fn new(e: pyo3::Py<pyo3::PyAny>) -> $name {
Self(e)
}
}
};

($name:ident, $extend:ident) => {
pyo3::impl_exception_boilerplate!($name);

#[pyo3_stub_gen::derive::gen_stub_pymethods]
#[pyo3::prelude::pymethods]
impl $name {
Expand All @@ -36,17 +51,7 @@ macro_rules! extend_exception {
#[gen_stub_pyclass]
#[pyclass(subclass, extends=PyException, module="oxapy.exceptions")]
pub struct ClientError(pub Py<PyAny>);

impl_exception_boilerplate!(ClientError);

#[gen_stub_pymethods]
#[pymethods]
impl ClientError {
#[new]
fn new(e: Py<PyAny>) -> ClientError {
Self(e)
}
}
extend_exception!(ClientError);

/// HTTP 400 Bad Request error exception.
///
Expand All @@ -58,8 +63,6 @@ impl ClientError {
#[gen_stub_pyclass]
#[pyclass(extends=ClientError, module="oxapy.exceptions")]
pub struct BadRequestError;

impl_exception_boilerplate!(BadRequestError);
extend_exception!(BadRequestError, ClientError);

/// HTTP 401 Unauthorized error exception.
Expand All @@ -71,8 +74,6 @@ extend_exception!(BadRequestError, ClientError);
#[gen_stub_pyclass]
#[pyclass(extends=ClientError, module="oxapy.exceptions")]
pub struct UnauthorizedError;

impl_exception_boilerplate!(UnauthorizedError);
extend_exception!(UnauthorizedError, ClientError);

/// HTTP 403 Forbidden error exception.
Expand All @@ -85,8 +86,6 @@ extend_exception!(UnauthorizedError, ClientError);
#[gen_stub_pyclass]
#[pyclass(extends=ClientError, module="oxapy.exceptions")]
pub struct ForbiddenError;

impl_exception_boilerplate!(ForbiddenError);
extend_exception!(ForbiddenError, ClientError);

/// HTTP 404 Not Found error exception.
Expand All @@ -99,8 +98,6 @@ extend_exception!(ForbiddenError, ClientError);
#[gen_stub_pyclass]
#[pyclass(extends=ClientError, module="oxapy.exceptions")]
pub struct NotFoundError;

impl_exception_boilerplate!(NotFoundError);
extend_exception!(NotFoundError, ClientError);

/// HTTP 409 Conflict error exception.
Expand All @@ -114,8 +111,6 @@ extend_exception!(NotFoundError, ClientError);
#[gen_stub_pyclass]
#[pyclass(extends=ClientError, module="oxapy.exceptions")]
pub struct ConflictError;

impl_exception_boilerplate!(ConflictError);
extend_exception!(ConflictError, ClientError);

/// HTTP 500 Internal Server Error exception.
Expand All @@ -129,17 +124,7 @@ extend_exception!(ConflictError, ClientError);
#[pyclass(extends=PyException, module="oxapy.exceptions")]
#[repr(transparent)]
pub struct InternalError(Py<PyAny>);

impl_exception_boilerplate!(InternalError);

#[gen_stub_pymethods]
#[pymethods]
impl InternalError {
#[new]
fn new(e: Py<PyAny>) -> InternalError {
Self(e)
}
}
extend_exception!(InternalError);

pub fn exceptions(m: &Bound<'_, PyModule>) -> PyResult<()> {
let exceptions = PyModule::new(m.py(), "exceptions")?;
Expand Down
8 changes: 4 additions & 4 deletions src/into_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ impl From<PyErr> for Response {
Status::INTERNAL_SERVER_ERROR
}
};
let response: Response = status.into();
let response = Response::from(status);
response.set_body(format!(
r#"{{"detail": "{}"}}"#,
value.value(py).to_string().replace('"', "'")
Expand All @@ -106,9 +106,9 @@ impl From<PyErr> for Response {
}

impl From<Cors> for Response {
fn from(val: Cors) -> Self {
let mut response = Status::NO_CONTENT.into();
val.apply_headers(&mut response);
fn from(cors: Cors) -> Self {
let mut response = Response::from(Status::NO_CONTENT);
cors.apply_headers(&mut response);
response
}
}
Expand Down
19 changes: 9 additions & 10 deletions src/json.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
use once_cell::sync::OnceCell;
use pyo3::{prelude::*, types::PyDict};
use pyo3::{prelude::*, sync::PyOnceLock, types::PyDict};
use serde::{Deserialize, Serialize};

static ORJSON: OnceCell<Py<PyModule>> = OnceCell::new();
static ORJSON: PyOnceLock<Py<PyModule>> = PyOnceLock::new();

#[inline]
pub fn dumps(data: &Py<PyAny>) -> PyResult<String> {
Python::attach(|py| {
let orjson = ORJSON.get_or_init(|| PyModule::import(py, "orjson").unwrap().into());
let serialized_data =
orjson
.call_method1(py, "dumps", (data,))?
.call_method1(py, "decode", ("utf-8",))?;
let serialized_data = ORJSON
.get_or_try_init(py, || PyModule::import(py, "orjson").map(|m| m.into()))?
.call_method1(py, "dumps", (data,))?
.call_method1(py, "decode", ("utf-8",))?;
Ok(serialized_data.extract(py)?)
})
}

#[inline]
pub fn loads(data: &str) -> PyResult<Py<PyDict>> {
Python::attach(|py| {
let orjson = ORJSON.get_or_init(|| PyModule::import(py, "orjson").unwrap().into());
let deserialized_data = orjson.call_method1(py, "loads", (data,))?;
let deserialized_data = ORJSON
.get_or_try_init(py, || PyModule::import(py, "orjson").map(|m| m.into()))?
.call_method1(py, "loads", (data,))?;
Ok(deserialized_data.extract(py)?)
})
}
Expand Down
20 changes: 2 additions & 18 deletions src/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use pyo3::exceptions::PyException;
use pyo3::types::PyDict;
use pyo3::{exceptions::PyValueError, impl_exception_boilerplate, prelude::*};
use pyo3::{exceptions::PyValueError, prelude::*};
use pyo3_stub_gen::derive::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
Expand All @@ -18,46 +18,30 @@ use crate::json::Wrap;
#[pyclass(subclass, extends=PyException, module="oxapy.jwt")]
#[repr(transparent)]
pub struct JwtError(Py<PyAny>);

impl_exception_boilerplate!(JwtError);

#[gen_stub_pymethods]
#[pymethods]
impl JwtError {
#[new]
fn new(e: Py<PyAny>) -> JwtError {
Self(e)
}
}
extend_exception!(JwtError);

/// Occurs when there's an error during JWT encoding.
#[gen_stub_pyclass]
#[pyclass(extends=JwtError, module="oxapy.jwt")]
pub struct JwtEncodingError;

impl_exception_boilerplate!(JwtEncodingError);
extend_exception!(JwtEncodingError, JwtError);

/// Occurs when there's an error during JWT decoding/verification.
#[gen_stub_pyclass]
#[pyclass(extends=JwtError, module="oxapy.jwt")]
pub struct JwtDecodingError;

impl_exception_boilerplate!(JwtDecodingError);
extend_exception!(JwtDecodingError, JwtError);

/// Occurs when the JWT algorithm is invalid or not supported.
#[gen_stub_pyclass]
#[pyclass(extends=JwtError, module="oxapy.jwt")]
pub struct JwtInvalidAlgorithm;
impl_exception_boilerplate!(JwtInvalidAlgorithm);
extend_exception!(JwtInvalidAlgorithm, JwtError);

/// Occurs when a JWT claim is invalid (e.g., wrong format).
#[gen_stub_pyclass]
#[pyclass(extends=JwtError, module="oxapy.jwt")]
pub struct JwtInvalidClaim;
impl_exception_boilerplate!(JwtInvalidClaim);
extend_exception!(JwtInvalidClaim, JwtError);

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
Loading