Skip to content
Closed
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
18 changes: 18 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## Summary

- What this PR does (1–3 sentences).
- If it closes a ticket: `Closes #N` (replace N).

## Base branch

- [ ] This PR targets **`dev`**, not `main` (unless maintainers asked for a hotfix).

## Checklist

- [ ] `cargo build` (and `cargo clippy` if you touched Rust)
- [ ] `maturin develop` + tests as in [docs/development.md](docs/development.md) (e.g. pytest from a clean cwd / installed wheel)
- [ ] No unrelated drive-by refactors; commits are [atomic and scoped](https://github.com/QueryaHub/OxyRoute/blob/main/docs/development-workflow.md)

## Note on `.github/ISSUE_BACKLOG/`

If you only touch issue template files under [`.github/ISSUE_BACKLOG/`](.github/ISSUE_BACKLOG/) (not application code), say so in the summary. Prefer a **separate** PR for backlog-only edits when possible.
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: ci

on:
push:
branches: [main, master]
branches: [main, master, dev]
pull_request:
branches: [main, master]
branches: [main, master, dev]

jobs:
test:
Expand All @@ -28,7 +28,7 @@ jobs:
- name: Install build deps
run: |
python -m pip install --upgrade pip
python -m pip install maturin pytest oxyjwt httpx
python -m pip install maturin pytest oxyjwt httpx granian
- name: Build and install oxyroute
run: |
maturin build --release
Expand Down
10 changes: 6 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,13 @@ cargo build
cargo clippy
```

## Branches and commits
## Branches, PRs, and atomic commits

- Use **topic branches** off `main` (e.g. `fix/query-decode`, `feat/patch-decorator`).
- Prefer **conventional commits** where possible: `feat:`, `fix:`, `docs:`, `ci:`, `chore:`, `test:`.
- For larger work, open a **draft PR** early and link a GitHub **issue** if one exists.
- **Integration branch is `dev`:** create your branch from `dev`, open PRs with **base = `dev`**. The maintainer merges to `dev` and later promotes to `main` for releases as needed.
- **One issue, one PR (when possible):** name the branch with the issue number, e.g. `issue-2-query-decode` or `feat/2-query-decode`.
- Use **atomic commits** (one logical change per commit). **Do not** put `.github/ISSUE_BACKLOG/` changes in the same commit as product code—backlog/templating updates should be a separate `docs:` (or `chore:`) commit, or a separate PR.
- Prefer **conventional commits**: `feat:`, `fix:`, `docs:`, `ci:`, `chore:`, `test:`.
- Full step-by-step: [docs/development-workflow.md](docs/development-workflow.md).

## Creating GitHub issues from the template backlog

Expand Down
23 changes: 23 additions & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ thiserror = "1"
jsonwebtoken = "9"
base64 = "0.22"
once_cell = "1.19"
# RFC 1738 / WHATWG: percent-decode and + as space in application/x-www-form-urlencoded query
form_urlencoded = "1.2"
log = "0.4"

[features]
default = []
Expand Down
53 changes: 53 additions & 0 deletions docs/development-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Development workflow: branches and pull requests

OxyRoute uses **`dev` as the integration branch**. `main` (or the default production branch) is updated from `dev` when maintainers cut a release or merge a stable snapshot—follow what your team documents for release tagging.

## Branching model

1. **Always branch from the latest `dev`:**

```bash
git fetch origin
git checkout dev
git pull origin dev
```

2. **Create a branch for one GitHub issue** (one topic per PR when possible):

```bash
git checkout -b issue-12-rsgi-e2e
# or: feat/12-rsgi-e2e — use a short, kebab-case slug
```

Prefer including the **issue number** in the name so the PR and issue stay linked (e.g. #12).

3. **Implement the issue** with **small, atomic commits** (one logical change per commit: `fix: …`, `test: …`, `docs: …`).

4. **Do not mix** code changes and **`.github/ISSUE_BACKLOG/`** (issue template bodies) in the same commit. The backlog is reference material for maintainers; if you need to update it, use a **separate** commit, e.g. `docs: update issue backlog description`.

5. **Push and open a pull request** with **base = `dev`** and **compare = your branch**.

```bash
git push -u origin issue-12-rsgi-e2e
```

On GitHub: **New pull request** → base repository `QueryaHub/OxyRoute`, **base: `dev`**, **compare: `issue-12-rsgi-e2e`**.

6. In the PR description, use **`Closes #N`** (or `Fixes #N`) if the work fully finishes issue **N**—GitHub will close the issue when the PR is merged.

7. After merge into `dev`, delete the remote branch (GitHub can do this on merge) and locally:
`git branch -d issue-12-rsgi-e2e` , then continue with the next issue from a fresh `dev`.

## What not to do

- Do **not** run `scripts/create-github-issues.sh` again unless you want **duplicate** issues on GitHub.
- Do **not** open a feature PR with base `main` unless the maintainers explicitly ask for a hotfix.

## `gh` CLI (optional)

```bash
gh pr create --base dev --head YOUR_BRANCH --title "..." --body "Closes #12"
gh pr list --base dev
```

[← Back to documentation index](index.md) · [Contributing (short)](../CONTRIBUTING.md)
8 changes: 7 additions & 1 deletion docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ handler(**kwargs)
| Name | When present | Meaning |
|------|----------------|--------|
| Path parameters | Matched from the route, e.g. `id` for `/items/:id` | String-like values, with coercion for `int` / `float` / `bool` / `str` in the Rust layer where applicable |
| `query` | Request has a query string | A Python `dict` of string keys/values (see implementation for parsing rules) |
| `query` | Request has a query string | A Python `dict` of string keys and values, **percent-decoded**; `+` in values is treated as a space, matching `application/x-www-form-urlencoded` / URLSearchParams ([WHATWG](https://url.spec.whatwg.org/#urlencoded-parsing)). **Duplicate keys** are last-wins (a plain `dict`, not a multimap) |
| `json` | `read_json_body` is true and body parses as JSON | `dict`/list/values as converted from `serde_json` to Python |
| `body` | Raw body bytes, when JSON is not used or empty | `bytes` |
| `claims` | `require_jwt` is true and the JWT validates | The decoded JSON claims as a Python object (typically a `dict`) |
Expand All @@ -38,6 +38,12 @@ The Rust layer maps the return value to an HTTP response:

For precise behavior and edge cases, refer to the implementation in the repository’s `src/dispatch.rs` and `src/response.rs`.

## Errors in handlers and dependencies

If a **dependency factory** or the **route handler** raises a Python exception (or building the response fails), the server answers with **500** and a small **JSON** body: `{"error":"internal server error"}`. Exception text and tracebacks are **not** included in the response by default (to avoid leaking internals to clients).

Set the environment variable **`OXYROUTE_DEBUG=1`** (or `true`) to include a **`detail`** string in that JSON for the same error and to log more at the `log` crate target **`oxyroute`** (see `RUST_LOG`, e.g. `RUST_LOG=oxyroute=error`).

## See also

- [Routing](routing.md)
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Granian still invokes a Python `App` object; the “win” is doing routing, bod
| [OpenAPI](openapi.md) | `openapi.json` route, title, `openapi_json()` |
| [ASGI bridge](asgi.md) | Optional `__call__` for ASGI 3, limitations |
| [Development](development.md) | Tests, CI, clippy, running pytest safely |
| [Branching and PRs](development-workflow.md) | `dev` as base, issue branches, `Closes #N`, no mixing code with `ISSUE_BACKLOG` in one commit |
| [Contributing](../CONTRIBUTING.md) | Local setup, issue backlog, GitHub `gh` workflow |

[← Back to project README](../README.md)
6 changes: 3 additions & 3 deletions docs/rsgi.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ The Python `oxyroute.app.App` class implements the async RSGI entry that Granian

## Lifespan (optional)

`App` defines no-op coroutines for servers that expect them:
`App` defines no-op coroutines for servers that expect them. Implementations use `*args, **kwargs` so **Granian** (and any server that passes extra parameters to worker lifespan hooks) can call them without a `TypeError`:

- `async def __rsgi_init__(self) -> None`
- `async def __rsgi_del__(self) -> None`
- `async def __rsgi_init__(self, *args, **kwargs) -> None`
- `async def __rsgi_del__(self, *args, **kwargs) -> None`

You can override these in a subclass if you need startup/shutdown hooks; the default does nothing.

Expand Down
8 changes: 4 additions & 4 deletions oxyroute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,12 @@ def wrap(handler: F) -> F:

return wrap

async def __rsgi_init__(self) -> None: # noqa: D401
"""Lifespan hook (no-op) for RSGI servers that call it."""
async def __rsgi_init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
"""Lifespan hook (no-op). Granian may pass extra positional args; accept **kwargs."""
return None

async def __rsgi_del__(self) -> None: # noqa: D401
"""Lifespan teardown hook (no-op) for RSGI servers that call it."""
async def __rsgi_del__(self, *args: Any, **kwargs: Any) -> None: # noqa: D401
"""Lifespan teardown (no-op). Accept extra args for Granian compatibility."""
return None

async def __rsgi__(self, scope: Any, protocol: Any) -> None:
Expand Down
108 changes: 94 additions & 14 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,46 @@ use crate::schema::json_to_py;
use crate::state::{map_method_router, AppState};
use crate::token::extract_bearer;

fn oxyroute_debug() -> bool {
std::env::var("OXYROUTE_DEBUG")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}

/// Map Python `PyErr` to HTTP 500 with a JSON body (no exception text unless `OXYROUTE_DEBUG=1`).
async fn send_internal_error(
protocol: &Py<PyAny>,
method: &str,
path: &str,
err: PyErr,
) -> PyResult<PyObject> {
let detail_opt = if oxyroute_debug() {
let d = Python::with_gil(|_py| format!("{err}"));
let d = if d.len() > 4000 {
format!("{}…", &d[..4000])
} else {
d
};
log::error!(target: "oxyroute", "{method} {path} — {d}");
Some(d)
} else {
log::error!(target: "oxyroute", "{method} {path}: internal error (set OXYROUTE_DEBUG=1 for detail)");
None
};
let body = if let Some(d) = detail_opt {
serde_json::json!({ "error": "internal server error", "detail": d }).to_string()
} else {
r#"{"error":"internal server error"}"#.to_string()
};
response::send_text(
protocol,
500,
&body,
"application/json; charset=utf-8",
)
.await
}

pub async fn run_rsgi(
state: Arc<Mutex<AppState>>,
scope: Py<PyAny>,
Expand Down Expand Up @@ -218,23 +258,43 @@ pub async fn run_rsgi(
let mut dep_out: Vec<PyObject> = Vec::with_capacity(dep_factories.len());
for (i, fact) in dep_factories.iter().enumerate() {
if dep_is_async.get(i) == Some(&true) {
let r = Python::with_gil(|py| -> PyResult<PyObject> {
let r = match Python::with_gil(|py| -> PyResult<PyObject> {
Ok(fact.bind(py).call((), None)?.unbind())
})?;
let fut = Python::with_gil(|py| {
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
};
let fut = match Python::with_gil(|py| {
let b = r.bind(py).clone();
pyo3_asyncio_0_21::tokio::into_future(b)
})?;
let o = fut.await?;
}) {
Ok(f) => f,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
};
let o = match fut.await {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
};
dep_out.push(o);
} else {
let o = Python::with_gil(|py| -> PyResult<PyObject> {
let o = match Python::with_gil(|py| -> PyResult<PyObject> {
Ok(fact.bind(py).call((), None)?.unbind())
})?;
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
};
dep_out.push(o);
}
}
let (res, run_async) = Python::with_gil(|py| -> PyResult<(PyObject, bool)> {
let (res, run_async) = match Python::with_gil(|py| -> PyResult<(PyObject, bool)> {
let kwargs = PyDict::new_bound(py);
for (k, v) in param_map {
let vpy = value_for_path_param(py, &v);
Expand Down Expand Up @@ -268,17 +328,32 @@ pub async fn run_rsgi(
.call((), Some(&kwargs))?
.unbind();
Ok((res, is_async))
})?;
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
};
let handler_out: PyObject = if run_async {
let fut = Python::with_gil(|py| {
let fut = match Python::with_gil(|py| {
let b = res.bind(py).clone();
pyo3_asyncio_0_21::tokio::into_future(b)
})?;
fut.await?
}) {
Ok(f) => f,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
};
match fut.await {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
}
} else {
res
};
let (status, bytes, content_type) = Python::with_gil(|py| -> PyResult<(u16, Vec<u8>, String)> {
let (status, bytes, content_type) = match Python::with_gil(|py| -> PyResult<(u16, Vec<u8>, String)> {
let b = handler_out.bind(py);
if let Ok(s) = b.extract::<String>() {
return Ok((200, s.into_bytes(), "text/plain; charset=utf-8".to_string()));
Expand All @@ -302,6 +377,11 @@ pub async fn run_rsgi(
let dumped = jmod.call_method1("dumps", (b.clone().unbind(),))?;
let s: String = dumped.extract()?;
Ok((200, s.into_bytes(), "application/json; charset=utf-8".to_string()))
})?;
}) {
Ok(x) => x,
Err(e) => {
return send_internal_error(&protocol, &method, &path, e).await;
}
};
response::send_bytes(&protocol, status, &bytes, &content_type).await
}
Loading
Loading