Skip to content

Commit 374375c

Browse files
authored
Fixed some minor issues (#15)
1 parent c4b2a12 commit 374375c

24 files changed

Lines changed: 219 additions & 88 deletions

docs/changelog.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
2222
#### Webhooks
2323
- `examples/fastapi/webhooks.py` — fully self-contained runnable example demonstrating all three webhook formatters (`default_formatter`, `SlackWebhookFormatter`, and a custom formatter) with in-app receivers and a live `/webhook-log` HTML viewer that auto-refreshes every 5 seconds
2424

25+
#### Dependency Injection
26+
- `@deprecated` now works as a `Depends()` dependency — injects `Deprecation`, `Sunset`, and `Link` response headers directly without requiring the middleware, using FastAPI's `Response` injection
27+
- `_REQUEST_RESPONSE_SIGNATURE` added internally so FastAPI correctly injects both `Request` and `Response` into the deprecated dep
28+
- `_ShieldCallable` extended with an optional `signature=` override and updated `__call__` to forward the `response` kwarg to `dep_raise` when present — fully backward compatible with all existing decorators
29+
- `examples/fastapi/dependency_injection.py` updated to include `@deprecated` as a `Depends()` example and a clear explanation of why `@force_active` cannot be used as a dependency
30+
2531
#### Documentation & Communication
2632
- Early Access notice added to README and docs homepage — communicates that the library is fully functional and actively developed, and invites community feedback via GitHub Issues
2733
- Webhooks and Custom Responses added to the Key Features table in the docs homepage
2834
- Key Features section added to `README.md`
2935

36+
### Changed
37+
- `@deprecated` docstring updated — no longer described as decorator-only; documents the `Depends()` usage pattern
38+
- `@force_active` docstring updated — clearly explains *why* it cannot be a `Depends()` (middleware completes before dependencies are resolved)
39+
- **Default `SHIELD_ENV` changed from `"production"` to `"dev"`**`ShieldEngine`, `make_engine()`, and all examples now default to the `dev` environment. Set `SHIELD_ENV=production` (or pass `current_env=` explicitly) in production deployments. This makes the out-of-the-box experience work correctly for local development where `@env_only("dev")` routes should be accessible by default.
40+
3041
---
3142

3243
## [0.2.0] — Admin Redesign & Docs

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ That's it. Routes respond immediately:
7070
```
7171
GET /payments → 503 {"error": {"code": "MAINTENANCE_MODE", ...}}
7272
GET /health → 200 always
73-
GET /debug → 404 in production, 200 in dev/staging
73+
GET /debug → 200 in dev (default), 404 in production/staging
7474
GET /v1/users → 200 + Deprecation / Sunset / Link headers
7575
```
7676

@@ -117,7 +117,7 @@ shield enable GET:/payments
117117

118118
## Next steps
119119

120-
- [**Tutorial: Installation**](tutorial/installation.md) — get up and running in 5 minutes
120+
- [**Tutorial: Installation**](tutorial/installation.md) — get up and running in seconds
121121
- [**Tutorial: First Decorator**](tutorial/first-decorator.md) — put your first route in maintenance mode
122122
- [**Reference: Decorators**](reference/decorators.md) — full decorator API
123123
- [**Reference: ShieldEngine**](reference/engine.md) — programmatic control

docs/pr-custom-responses.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# feat: custom responses for blocked routes
2+
3+
## Summary
4+
5+
- Added `response=` parameter to `@maintenance`, `@disabled`, and `@env_only` — pass a sync or async factory to return any response (HTML, redirect, plain text, custom JSON) instead of the default JSON error body
6+
- Added `responses=` dict to `ShieldMiddleware` — set app-wide response defaults for all maintenance, disabled, or env-gated routes in one place
7+
- Resolution order: per-route `response=` → global `responses[...]` → built-in JSON
8+
9+
## Why
10+
11+
The default JSON error body works well for pure API clients but falls short for apps that serve browser users. Teams needed a way to show a branded maintenance page, redirect to a status page, or return a custom error envelope — without forking the library or wrapping the middleware.
12+
13+
## How it works
14+
15+
**Per-route** — the response override lives next to the route definition:
16+
17+
```python
18+
@router.get("/payments")
19+
@maintenance(reason="DB migration — back at 04:00 UTC", response=maintenance_page)
20+
async def payments(): ...
21+
```
22+
23+
**Global default** — set once on the middleware, applies to every route without a per-route factory:
24+
25+
```python
26+
app.add_middleware(
27+
ShieldMiddleware,
28+
engine=engine,
29+
responses={
30+
"maintenance": maintenance_page,
31+
"disabled": lambda req, exc: HTMLResponse("<h1>Gone</h1>", status_code=503),
32+
},
33+
)
34+
```
35+
36+
The factory signature is `(request: Request, exc: Exception) -> Response`. Both sync and async callables are supported, and any Starlette `Response` subclass is valid.
37+
38+
## Files changed
39+
40+
| File | Change |
41+
|---|---|
42+
| `shield/fastapi/decorators.py` | `response=` param on `@maintenance`, `@disabled`, `@env_only` |
43+
| `shield/fastapi/middleware.py` | `responses=` dict on `ShieldMiddleware`; per-route → global → built-in resolution in `dispatch()` |
44+
| `shield/fastapi/__init__.py` | `ResponseFactory` type alias exported |
45+
| `examples/fastapi/custom_responses.py` | New runnable example covering all patterns |
46+
| `docs/reference/decorators.md` | Per-route and global response docs with examples |
47+
| `README.md` | Custom responses section updated |
48+
| `docs/changelog.md` | Added to `[Unreleased]` |

docs/reference/backends.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ engine = ShieldEngine(backend=FileBackend(path="shield-state.json"))
5252
- File lock (`asyncio.Lock`) prevents concurrent write corruption.
5353
- `subscribe()` raises `NotImplementedError` — dashboard falls back to polling.
5454

55-
File format:
55+
**File format:**
5656

5757
```json
5858
{

docs/reference/decorators.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,11 @@ Accepts one or more positional `str` arguments — the environment names where t
120120
### Setting the current environment
121121

122122
```python
123-
engine = ShieldEngine(current_env="production")
123+
engine = ShieldEngine(current_env="dev")
124124
# or
125125
engine = make_engine(current_env="staging")
126126
# or via env var:
127-
# SHIELD_ENV=production
127+
# SHIELD_ENV=dev
128128
```
129129

130130
### Response (wrong environment)

docs/reference/engine.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from shield.core.engine import ShieldEngine
77
from shield.core.backends.memory import MemoryBackend
88

9-
engine = ShieldEngine(backend=MemoryBackend(), current_env="production")
9+
engine = ShieldEngine(backend=MemoryBackend(), current_env="dev")
1010
```
1111

1212
Or use `make_engine()` to read configuration from environment variables and the `.shield` file:
@@ -24,15 +24,15 @@ engine = make_engine()
2424
```python
2525
ShieldEngine(
2626
backend: ShieldBackend | None = None,
27-
current_env: str = "production",
27+
current_env: str = "dev",
2828
webhooks: list[str] | None = None,
2929
)
3030
```
3131

3232
| Parameter | Default | Description |
3333
|---|---|---|
3434
| `backend` | `MemoryBackend()` | Storage backend for route state and audit log |
35-
| `current_env` | `"production"` | Current environment name — used by `@env_only` checks |
35+
| `current_env` | `"dev"` | Current environment name — used by `@env_only` checks |
3636
| `webhooks` | `[]` | List of webhook URLs to notify on state changes |
3737

3838
---

docs/tutorial/installation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ api-shield can be configured through environment variables so no code changes ar
6363
| Variable | Default | Description |
6464
|---|---|---|
6565
| `SHIELD_BACKEND` | `memory` | Backend type: `memory`, `file`, or `redis` |
66-
| `SHIELD_ENV` | `production` | Current environment name (used by `@env_only`) |
66+
| `SHIELD_ENV` | `dev` | Current environment name (used by `@env_only`) |
6767
| `SHIELD_FILE_PATH` | `shield-state.json` | Path for `FileBackend` |
6868
| `SHIELD_REDIS_URL` | `redis://localhost:6379/0` | URL for `RedisBackend` |
6969

@@ -73,7 +73,7 @@ Or commit a `.shield` file in your project root — both the app and the CLI dis
7373
# .shield
7474
SHIELD_BACKEND=file
7575
SHIELD_FILE_PATH=shield-state.json
76-
SHIELD_ENV=production
76+
SHIELD_ENV=dev
7777
SHIELD_SERVER_URL=http://localhost:8000/shield
7878
```
7979

examples/fastapi/basic.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717
shield disable /payments --reason "hotfix"
1818
shield enable /payments
1919
20-
Expected behaviour (production env):
20+
Expected behaviour (dev env — set APP_ENV=production to see /debug return 404):
2121
GET /health → 200 always (@force_active)
2222
GET /payments → 503 MAINTENANCE_MODE (@maintenance)
23-
GET /debug → 404 silent (@env_only("dev"))
23+
GET /debug → 200 (@env_only("dev"), allowed in dev)
2424
GET /old-endpoint → 503 ROUTE_DISABLED (@disabled)
2525
GET /v1/users → 200 + deprecation headers (@deprecated)
2626
@@ -45,7 +45,7 @@
4545
maintenance,
4646
)
4747

48-
CURRENT_ENV = os.getenv("APP_ENV", "production")
48+
CURRENT_ENV = os.getenv("APP_ENV", "dev")
4949
engine = make_engine(current_env=CURRENT_ENV)
5050

5151
router = ShieldRouter(engine=engine)

examples/fastapi/custom_responses.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
maintenance,
4646
)
4747

48-
CURRENT_ENV = os.getenv("APP_ENV", "production")
48+
CURRENT_ENV = os.getenv("APP_ENV", "dev")
4949
engine = make_engine(current_env=CURRENT_ENV)
5050
router = ShieldRouter(engine=engine)
5151

examples/fastapi/dependency_injection.py

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,21 @@
33
Shows how to use shield decorators as FastAPI ``Depends()`` dependencies
44
instead of (or alongside) the middleware model.
55
6-
Call ``configure_shield(app, engine)`` once and all decorator deps
7-
(``maintenance``, ``disabled``, ``env_only``) find the engine automatically
8-
via ``request.app.state.shield_engine`` — no ``engine=`` argument per route.
9-
10-
``ShieldMiddleware`` calls ``configure_shield`` automatically at ASGI startup,
11-
so if you use middleware you don't need to call it manually.
12-
13-
Three patterns shown side by side:
14-
15-
1. **Decorator only** — ``@maintenance(reason="...")`` stamps ``__shield_meta__``
16-
on the function; ``ShieldRouter`` registers the state at startup;
17-
``ShieldMiddleware`` enforces it globally.
18-
19-
2. **Dep (zero-config)** — ``Depends(maintenance(reason="..."))`` with
20-
``configure_shield`` called once. Engine resolved from ``app.state``
21-
automatically. Toggle at runtime via CLI or dashboard without redeploying.
22-
23-
3. **Dep (explicit engine)** — ``Depends(maintenance(reason="...", engine=engine))``.
24-
Targets a specific engine; useful when running multiple engines side by side.
6+
Call ``configure_shield(app, engine)`` once and all decorator deps find the
7+
engine automatically via ``request.app.state.shield_engine`` — no ``engine=``
8+
argument per route. ``ShieldMiddleware`` calls ``configure_shield``
9+
automatically at ASGI startup, so if you use middleware you don't need to
10+
call it manually.
11+
12+
Decorator support as ``Depends()``:
13+
14+
✅ maintenance — raises 503 when route is in maintenance
15+
✅ disabled — raises 503 when route is disabled
16+
✅ env_only — raises 404 when accessed from the wrong environment
17+
✅ deprecated — injects Deprecation / Sunset / Link headers on the response
18+
❌ force_active — decorator-only; shield checks run in the middleware, which
19+
completes before any dependency is resolved. A dependency
20+
has no mechanism to retroactively bypass that check.
2521
2622
Run:
2723
uv run uvicorn examples.fastapi.dependency_injection:app --reload
@@ -37,18 +33,14 @@
3733
3834
Try these requests:
3935
40-
curl http://localhost:8000/payments # → 503 MAINTENANCE_MODE
41-
shield enable /payments # toggle off without redeploy
42-
curl http://localhost:8000/payments # → 200
36+
curl -i http://localhost:8000/payments # → 503 MAINTENANCE_MODE
37+
shield enable /payments # toggle off without redeploy
38+
curl -i http://localhost:8000/payments # → 200
4339
44-
curl http://localhost:8000/old-endpoint # → 503 ROUTE_DISABLED
45-
shield enable /old-endpoint # re-enable
46-
curl http://localhost:8000/old-endpoint # → 200
47-
48-
curl http://localhost:8000/debug # → 404 (production env)
49-
APP_ENV=dev uv run uvicorn ... # → 200
50-
51-
curl http://localhost:8000/health # → 200 always
40+
curl -i http://localhost:8000/old-endpoint # → 503 ROUTE_DISABLED
41+
curl -i http://localhost:8000/debug # → 404 in production env; set APP_ENV=production
42+
curl -i http://localhost:8000/v1/users # → 200 + Deprecation headers
43+
curl -i http://localhost:8000/health # → 200 always
5244
"""
5345

5446
import os
@@ -61,13 +53,14 @@
6153
ShieldMiddleware,
6254
ShieldRouter,
6355
apply_shield_to_openapi,
56+
deprecated,
6457
disabled,
6558
env_only,
6659
force_active,
6760
maintenance,
6861
)
6962

70-
CURRENT_ENV = os.getenv("APP_ENV", "production")
63+
CURRENT_ENV = os.getenv("APP_ENV", "dev")
7164
engine = make_engine(current_env=CURRENT_ENV)
7265
router = ShieldRouter(engine=engine)
7366

@@ -138,6 +131,37 @@ async def debug():
138131
return {"env": CURRENT_ENV}
139132

140133

134+
# @deprecated as a Depends() — injects Deprecation, Sunset, and Link headers
135+
# directly on the response without needing the middleware to do it.
136+
# Use this when you want header injection at the handler level rather than
137+
# globally via middleware.
138+
@router.get(
139+
"/v1/users",
140+
dependencies=[
141+
Depends(
142+
deprecated(
143+
sunset="Sat, 01 Jan 2027 00:00:00 GMT",
144+
use_instead="/v2/users",
145+
)
146+
)
147+
],
148+
)
149+
@deprecated(sunset="Sat, 01 Jan 2027 00:00:00 GMT", use_instead="/v2/users")
150+
async def v1_users():
151+
"""200 always, but carries Deprecation + Sunset + Link response headers."""
152+
return {"users": [{"id": 1, "name": "Alice"}]}
153+
154+
155+
@router.get("/v2/users")
156+
async def v2_users():
157+
"""Active successor to /v1/users."""
158+
return {"users": [{"id": 1, "name": "Alice"}]}
159+
160+
161+
# @force_active cannot be used as a Depends() — see module docstring for why.
162+
# It is applied as a decorator only.
163+
164+
141165
app.include_router(router)
142166
apply_shield_to_openapi(app, engine)
143167

0 commit comments

Comments
 (0)