api-shield 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,744 @@
1
+ Metadata-Version: 2.4
2
+ Name: api-shield
3
+ Version: 0.1.0
4
+ Summary: Route lifecycle management for APIs — maintenance mode, env gating, deprecation, and more
5
+ Project-URL: Homepage, https://github.com/Attakay78/api-shield
6
+ Project-URL: Repository, https://github.com/Attakay78/api-shield
7
+ Project-URL: Issues, https://github.com/Attakay78/api-shield/issues
8
+ Author: Richard Quaicoe
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,fastapi,lifecycle,maintenance,middleware,route
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: anyio>=4.0
24
+ Requires-Dist: pydantic>=2.0
25
+ Requires-Dist: starlette>=0.27
26
+ Provides-Extra: all
27
+ Requires-Dist: aiofiles>=23.0; extra == 'all'
28
+ Requires-Dist: fastapi>=0.100; extra == 'all'
29
+ Requires-Dist: jinja2>=3.1; extra == 'all'
30
+ Requires-Dist: redis[asyncio]>=5.0; extra == 'all'
31
+ Requires-Dist: typer>=0.12; extra == 'all'
32
+ Provides-Extra: cli
33
+ Requires-Dist: typer>=0.12; extra == 'cli'
34
+ Provides-Extra: dashboard
35
+ Requires-Dist: aiofiles>=23.0; extra == 'dashboard'
36
+ Requires-Dist: jinja2>=3.1; extra == 'dashboard'
37
+ Provides-Extra: dev
38
+ Requires-Dist: aiofiles>=23.0; extra == 'dev'
39
+ Requires-Dist: anyio[trio]; extra == 'dev'
40
+ Requires-Dist: fastapi>=0.100; extra == 'dev'
41
+ Requires-Dist: httpx>=0.27; extra == 'dev'
42
+ Requires-Dist: mypy; extra == 'dev'
43
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
44
+ Requires-Dist: pytest>=8.0; extra == 'dev'
45
+ Requires-Dist: ruff; extra == 'dev'
46
+ Provides-Extra: fastapi
47
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
48
+ Provides-Extra: redis
49
+ Requires-Dist: redis[asyncio]>=5.0; extra == 'redis'
50
+ Description-Content-Type: text/markdown
51
+
52
+ # api-shield
53
+
54
+ **Route lifecycle management for FastAPI — maintenance mode, environment gating, deprecation, canary rollouts, and more. No restarts required.**
55
+
56
+ Most "maintenance mode" tools are blunt instruments: shut everything down or nothing at all. `api-shield` treats each route as a first-class entity with its own lifecycle. State changes take effect immediately through an ASGI middleware — no redeployment, no server restart.
57
+
58
+ ---
59
+
60
+ ## Contents
61
+
62
+ - [Quick Start](#quick-start)
63
+ - [How It Works](#how-it-works)
64
+ - [Decorators](#decorators)
65
+ - [Global Maintenance Mode](#global-maintenance-mode)
66
+ - [Backends](#backends)
67
+ - [OpenAPI & Docs Integration](#openapi--docs-integration)
68
+ - [CLI Reference](#cli-reference)
69
+ - [Audit Log](#audit-log)
70
+ - [Configuration File](#configuration-file)
71
+ - [Architecture](#architecture)
72
+ - [Testing](#testing)
73
+
74
+ ---
75
+
76
+ ## Quick Start
77
+
78
+ ```bash
79
+ uv add api-shield
80
+ # or: pip install api-shield
81
+ ```
82
+
83
+ ```python
84
+ from fastapi import FastAPI
85
+ from shield.core.config import make_engine
86
+ from shield.fastapi import (
87
+ ShieldMiddleware,
88
+ apply_shield_to_openapi,
89
+ setup_shield_docs,
90
+ maintenance,
91
+ env_only,
92
+ disabled,
93
+ force_active,
94
+ deprecated,
95
+ )
96
+
97
+ engine = make_engine() # reads SHIELD_BACKEND, SHIELD_ENV, etc.
98
+
99
+ app = FastAPI(title="My API")
100
+ app.add_middleware(ShieldMiddleware, engine=engine)
101
+
102
+ @app.get("/payments")
103
+ @maintenance(reason="Database migration — back at 04:00 UTC")
104
+ async def get_payments():
105
+ return {"payments": []}
106
+
107
+ @app.get("/health")
108
+ @force_active # always 200, immune to all shield checks
109
+ async def health():
110
+ return {"status": "ok"}
111
+
112
+ @app.get("/debug")
113
+ @env_only("dev", "staging") # silent 404 in production
114
+ async def debug():
115
+ return {"debug": True}
116
+
117
+ @app.get("/old-endpoint")
118
+ @disabled(reason="Use /v2/endpoint")
119
+ async def old_endpoint():
120
+ return {}
121
+
122
+ @app.get("/v1/users")
123
+ @deprecated(sunset="Sat, 01 Jan 2027 00:00:00 GMT", use_instead="/v2/users")
124
+ async def v1_users():
125
+ return {"users": []}
126
+
127
+ apply_shield_to_openapi(app, engine) # filter /docs and /redoc
128
+ setup_shield_docs(app, engine) # inject maintenance banners into UI
129
+ ```
130
+
131
+ ```
132
+ GET /payments → 503 {"error": {"code": "MAINTENANCE_MODE", "reason": "..."}}
133
+ GET /health → 200 always (force_active)
134
+ GET /debug → 404 in production (env_only)
135
+ GET /old-endpoint → 503 {"error": {"code": "ROUTE_DISABLED", "reason": "..."}}
136
+ GET /v1/users → 200 + Deprecation/Sunset/Link response headers
137
+ ```
138
+
139
+ ---
140
+
141
+ ## How It Works
142
+
143
+ ```
144
+ Incoming HTTP request
145
+
146
+
147
+ ShieldMiddleware.dispatch()
148
+
149
+ ├─ /docs, /redoc, /openapi.json ──────────────────────→ pass through
150
+
151
+ ├─ Lazy-scan app routes for __shield_meta__ (once only)
152
+
153
+ ├─ @force_active route? ──────────────────────────────→ pass through
154
+ │ (unless global maintenance overrides — see below)
155
+
156
+ ├─ engine.check(path, method)
157
+ │ │
158
+ │ ├─ Global maintenance ON + path not exempt? → 503
159
+ │ ├─ MAINTENANCE → 503 + Retry-After header
160
+ │ ├─ DISABLED → 503
161
+ │ ├─ ENV_GATED → 404 (silent — path existence not revealed)
162
+ │ ├─ DEPRECATED → pass through + inject response headers
163
+ │ └─ ACTIVE → pass through ✓
164
+
165
+ └─ call_next(request)
166
+ ```
167
+
168
+ ### Route Registration
169
+
170
+ Shield decorators stamp `__shield_meta__` on the endpoint function. This metadata is registered with the engine at startup via two mechanisms:
171
+
172
+ 1. **ASGI lifespan interception** — `ShieldMiddleware` hooks into `lifespan.startup.complete` to scan all app routes before the first request. This works with any `APIRouter` (plain or `ShieldRouter`).
173
+ 2. **Lazy fallback** — on the first HTTP request if no lifespan was triggered (e.g. test environments).
174
+
175
+ State registration is **persistence-first**: if the backend already has a state for a route (written by a previous CLI command or earlier server run), the decorator default is ignored and the persisted state wins. This means runtime changes survive restarts.
176
+
177
+ ---
178
+
179
+ ## Decorators
180
+
181
+ All decorators work on any router type — plain `APIRouter`, `ShieldRouter`, or routes added directly to the `FastAPI` app instance.
182
+
183
+ ### `@maintenance(reason, start, end)`
184
+
185
+ Puts a route into maintenance mode. Returns 503 with a structured JSON body. If `start`/`end` are provided, the maintenance window is also stored for scheduling.
186
+
187
+ ```python
188
+ from shield.fastapi import maintenance
189
+ from datetime import datetime, UTC
190
+
191
+ @router.get("/payments")
192
+ @maintenance(reason="DB migration in progress")
193
+ async def get_payments():
194
+ ...
195
+
196
+ # With a scheduled window
197
+ @router.post("/orders")
198
+ @maintenance(
199
+ reason="Order system upgrade",
200
+ start=datetime(2025, 6, 1, 2, 0, tzinfo=UTC),
201
+ end=datetime(2025, 6, 1, 4, 0, tzinfo=UTC),
202
+ )
203
+ async def create_order():
204
+ ...
205
+ ```
206
+
207
+ Response:
208
+ ```json
209
+ {
210
+ "error": {
211
+ "code": "MAINTENANCE_MODE",
212
+ "message": "This endpoint is temporarily unavailable",
213
+ "reason": "DB migration in progress",
214
+ "path": "/payments",
215
+ "retry_after": "2025-06-01T04:00:00Z"
216
+ }
217
+ }
218
+ ```
219
+
220
+ ---
221
+
222
+ ### `@disabled(reason)`
223
+
224
+ Permanently disables a route. Returns 503. Use for routes that should never be called again (migrations, removed features).
225
+
226
+ ```python
227
+ from shield.fastapi import disabled
228
+
229
+ @router.get("/legacy/report")
230
+ @disabled(reason="Replaced by /v2/reports — update your clients")
231
+ async def legacy_report():
232
+ ...
233
+ ```
234
+
235
+ ---
236
+
237
+ ### `@env_only(*envs)`
238
+
239
+ Restricts a route to specific environment names. In any other environment the route returns a **silent 404** — it does not reveal that the path exists.
240
+
241
+ ```python
242
+ from shield.fastapi import env_only
243
+
244
+ @router.get("/internal/metrics")
245
+ @env_only("dev", "staging")
246
+ async def internal_metrics():
247
+ ...
248
+ ```
249
+
250
+ The current environment is set via `SHIELD_ENV` or when constructing the engine:
251
+
252
+ ```python
253
+ engine = ShieldEngine(current_env="production")
254
+ # or
255
+ engine = make_engine(current_env="staging")
256
+ ```
257
+
258
+ ---
259
+
260
+ ### `@force_active`
261
+
262
+ Bypasses all shield checks. Use for health checks, status endpoints, and any route that must always be reachable.
263
+
264
+ ```python
265
+ from shield.fastapi import force_active
266
+
267
+ @router.get("/health")
268
+ @force_active
269
+ async def health():
270
+ return {"status": "ok"}
271
+ ```
272
+
273
+ `@force_active` routes are also **immune to runtime changes** — you cannot disable or put them in maintenance via the CLI or engine. This is intentional: health check routes must be trustworthy.
274
+
275
+ The only exception is when global maintenance mode is enabled with `include_force_active=True` (see [Global Maintenance Mode](#global-maintenance-mode)).
276
+
277
+ ---
278
+
279
+ ### `@deprecated(sunset, use_instead)`
280
+
281
+ Marks a route as deprecated. Requests still succeed, but the middleware injects RFC-compliant response headers:
282
+
283
+ ```python
284
+ from shield.fastapi import deprecated
285
+
286
+ @router.get("/v1/users")
287
+ @deprecated(
288
+ sunset="Sat, 01 Jan 2027 00:00:00 GMT",
289
+ use_instead="/v2/users",
290
+ )
291
+ async def v1_users():
292
+ return {"users": []}
293
+ ```
294
+
295
+ Response headers added automatically:
296
+ ```
297
+ Deprecation: true
298
+ Sunset: Sat, 01 Jan 2027 00:00:00 GMT
299
+ Link: </v2/users>; rel="successor-version"
300
+ ```
301
+
302
+ The route is also marked `deprecated: true` in the OpenAPI schema and shown with a visual indicator in `/docs`.
303
+
304
+ ---
305
+
306
+ ## Global Maintenance Mode
307
+
308
+ Global maintenance blocks **every route** with a single call, without requiring per-route decorators. Use it for full deployments, infrastructure work, or emergency stops.
309
+
310
+ ### Programmatic (lifespan or runtime)
311
+
312
+ ```python
313
+ from contextlib import asynccontextmanager
314
+ from fastapi import FastAPI
315
+
316
+ @asynccontextmanager
317
+ async def lifespan(app: FastAPI):
318
+ # Enable global maintenance at startup
319
+ await engine.enable_global_maintenance(
320
+ reason="Scheduled deployment — back in 15 minutes",
321
+ exempt_paths=["/health", "GET:/admin/status"],
322
+ include_force_active=False, # @force_active routes still bypass (default)
323
+ )
324
+ yield
325
+ await engine.disable_global_maintenance()
326
+ ```
327
+
328
+ Or toggle at runtime via any async context:
329
+
330
+ ```python
331
+ # Enable — all non-exempt routes return 503 immediately
332
+ await engine.enable_global_maintenance(reason="Emergency patch")
333
+
334
+ # Disable — routes return to their per-route state
335
+ await engine.disable_global_maintenance()
336
+
337
+ # Check current state
338
+ cfg = await engine.get_global_maintenance()
339
+ print(cfg.enabled, cfg.reason, cfg.exempt_paths)
340
+
341
+ # Add/remove individual exemptions without toggling the mode
342
+ await engine.set_global_exempt_paths(["/health", "/status"])
343
+ ```
344
+
345
+ ### Via CLI
346
+
347
+ ```bash
348
+ # Enable with exemptions
349
+ shield global enable \
350
+ --reason "Scheduled deployment" \
351
+ --exempt /health \
352
+ --exempt GET:/admin/status
353
+
354
+ # Block even force_active routes
355
+ shield global enable --reason "Hard lockdown" --include-force-active
356
+
357
+ # Add/remove exemptions while maintenance is already active
358
+ shield global exempt-add /monitoring/ping
359
+ shield global exempt-remove /monitoring/ping
360
+
361
+ # Check current state
362
+ shield global status
363
+
364
+ # Disable
365
+ shield global disable
366
+ ```
367
+
368
+ ### Options
369
+
370
+ | Option | Default | Description |
371
+ |---|---|---|
372
+ | `reason` | `""` | Shown in every 503 response body |
373
+ | `exempt_paths` | `[]` | Bare paths (`/health`) or method-prefixed (`GET:/health`) |
374
+ | `include_force_active` | `False` | When `True`, `@force_active` routes are also blocked |
375
+
376
+ ---
377
+
378
+ ## Backends
379
+
380
+ The backend determines where route state and the audit log are persisted.
381
+
382
+ ### `MemoryBackend` (default)
383
+
384
+ In-process dict. No persistence across restarts. CLI cannot share state with the running server.
385
+
386
+ ```python
387
+ from shield.core.backends.memory import MemoryBackend
388
+ engine = ShieldEngine(backend=MemoryBackend())
389
+ ```
390
+
391
+ Best for: development, single-process testing.
392
+
393
+ ---
394
+
395
+ ### `FileBackend`
396
+
397
+ JSON file on disk. Survives restarts. CLI shares state with the running server when both point to the same file.
398
+
399
+ ```python
400
+ from shield.core.backends.file import FileBackend
401
+ engine = ShieldEngine(backend=FileBackend(path="shield-state.json"))
402
+ ```
403
+
404
+ Or via environment variable:
405
+ ```bash
406
+ SHIELD_BACKEND=file SHIELD_FILE_PATH=./shield-state.json uvicorn app:app
407
+ ```
408
+
409
+ Best for: single-instance deployments, simple setups, CLI-driven workflows.
410
+
411
+ ---
412
+
413
+ ### `RedisBackend`
414
+
415
+ Redis via `redis-py` async. Supports multi-instance deployments. CLI changes reflect immediately on all running instances.
416
+
417
+ ```python
418
+ from shield.core.backends.redis import RedisBackend
419
+ engine = ShieldEngine(backend=RedisBackend(url="redis://localhost:6379/0"))
420
+ ```
421
+
422
+ Or via environment variable:
423
+ ```bash
424
+ SHIELD_BACKEND=redis SHIELD_REDIS_URL=redis://localhost:6379/0 uvicorn app:app
425
+ ```
426
+
427
+ Key schema:
428
+ - `shield:state:{path}` — route state
429
+ - `shield:audit` — audit log (LPUSH, capped at 1000 entries)
430
+ - `shield:global` — global maintenance configuration
431
+
432
+ Best for: multi-instance / load-balanced deployments, production.
433
+
434
+ ---
435
+
436
+ ### Config file (`.shield`)
437
+
438
+ Both the app and CLI auto-discover a `.shield` file by walking up from the current directory:
439
+
440
+ ```ini
441
+ # .shield
442
+ SHIELD_BACKEND=file
443
+ SHIELD_FILE_PATH=shield-state.json
444
+ SHIELD_ENV=production
445
+ ```
446
+
447
+ Priority order (highest wins):
448
+ 1. Explicit constructor arguments
449
+ 2. `os.environ`
450
+ 3. `.shield` file
451
+ 4. Built-in defaults
452
+
453
+ Pass a specific config file to the CLI:
454
+ ```bash
455
+ shield --config /etc/myapp/.shield status
456
+ ```
457
+
458
+ ---
459
+
460
+ ## OpenAPI & Docs Integration
461
+
462
+ ### Schema filtering
463
+
464
+ ```python
465
+ from shield.fastapi import apply_shield_to_openapi
466
+
467
+ apply_shield_to_openapi(app, engine)
468
+ ```
469
+
470
+ Effect on `/docs` and `/redoc`:
471
+
472
+ | Route status | Schema behaviour |
473
+ |---|---|
474
+ | `DISABLED` | Hidden from all schemas |
475
+ | `ENV_GATED` (wrong env) | Hidden from all schemas |
476
+ | `MAINTENANCE` | Visible; operation summary prefixed with `🔧`; description shows warning block; `x-shield-status` extension added |
477
+ | `DEPRECATED` | Marked `deprecated: true`; successor path shown |
478
+ | `ACTIVE` | No change |
479
+
480
+ Schema is computed fresh on every request — runtime state changes (CLI, engine calls) reflect immediately without restarting.
481
+
482
+ ---
483
+
484
+ ### Docs UI customisation
485
+
486
+ ```python
487
+ from shield.fastapi import setup_shield_docs
488
+
489
+ apply_shield_to_openapi(app, engine) # must come first
490
+ setup_shield_docs(app, engine)
491
+ ```
492
+
493
+ Replaces both `/docs` and `/redoc` with enhanced versions:
494
+
495
+ **Global maintenance ON:**
496
+ - Full-width pulsing red sticky banner at the top of the page
497
+ - Reason text and exempt paths displayed
498
+ - Refreshes automatically every 15 seconds — no page reload needed
499
+
500
+ **Global maintenance OFF:**
501
+ - Small green "All systems operational" chip in the bottom-right corner
502
+
503
+ **Per-route maintenance:**
504
+ - Orange left-border on the operation block
505
+ - `🔧 MAINTENANCE` badge appended to the summary bar
506
+
507
+ ---
508
+
509
+ ## CLI Reference
510
+
511
+ The `shield` CLI operates on the same backend as the running server. Requires `SHIELD_BACKEND=file` or `SHIELD_BACKEND=redis` to share state (the default `memory` backend is process-local).
512
+
513
+ ```bash
514
+ # Install entry point
515
+ uv pip install -e ".[cli]"
516
+ ```
517
+
518
+ ### Route commands
519
+
520
+ ```bash
521
+ # Show all registered routes
522
+ shield status
523
+
524
+ # Show one route
525
+ shield status GET:/payments
526
+
527
+ # Enable a route
528
+ shield enable GET:/payments
529
+
530
+ # Disable with a reason
531
+ shield disable GET:/payments --reason "Security patch"
532
+
533
+ # Put in maintenance (immediate)
534
+ shield maintenance GET:/payments --reason "DB swap"
535
+
536
+ # Put in maintenance with a time window
537
+ shield maintenance GET:/payments \
538
+ --reason "DB migration" \
539
+ --start 2025-06-01T02:00Z \
540
+ --end 2025-06-01T04:00Z
541
+
542
+ # Schedule a future maintenance window (auto-activates and deactivates)
543
+ shield schedule GET:/payments \
544
+ --start 2025-06-01T02:00Z \
545
+ --end 2025-06-01T04:00Z \
546
+ --reason "Planned migration"
547
+ ```
548
+
549
+ ### Global maintenance commands
550
+
551
+ ```bash
552
+ shield global status
553
+ shield global enable --reason "Deploying v2" --exempt /health
554
+ shield global disable
555
+ shield global exempt-add /monitoring
556
+ shield global exempt-remove /monitoring
557
+ ```
558
+
559
+ ### Audit log
560
+
561
+ ```bash
562
+ shield log # last 20 entries across all routes
563
+ shield log --route GET:/payments # filter by route
564
+ shield log --limit 100
565
+ ```
566
+
567
+ ### Notes on route keys
568
+
569
+ Routes are stored with method-prefixed keys:
570
+
571
+ | What you type | What gets stored |
572
+ |---|---|
573
+ | `@router.get("/payments")` | `GET:/payments` |
574
+ | `@router.post("/payments")` | `POST:/payments` |
575
+ | `@router.get("/api/v1/users")` | `GET:/api/v1/users` |
576
+
577
+ Use the same format with the CLI:
578
+ ```bash
579
+ shield disable "GET:/payments"
580
+ shield enable "/payments" # applies to all methods
581
+ ```
582
+
583
+ ---
584
+
585
+ ## Audit Log
586
+
587
+ Every state change writes an immutable audit entry:
588
+
589
+ ```python
590
+ # Via engine
591
+ entries = await engine.get_audit_log(limit=50)
592
+ entries = await engine.get_audit_log(path="GET:/payments", limit=20)
593
+
594
+ for e in entries:
595
+ print(e.timestamp, e.actor, e.action, e.path,
596
+ e.previous_status, "→", e.new_status, e.reason)
597
+ ```
598
+
599
+ Fields: `id`, `timestamp`, `path`, `action`, `actor`, `reason`, `previous_status`, `new_status`.
600
+
601
+ The CLI uses `getpass.getuser()` (the logged-in OS username) as the default actor — no `--actor` flag needed for accountability:
602
+
603
+ ```bash
604
+ shield disable GET:/payments --reason "Security patch"
605
+ # audit entry: actor="alice", action="disable", path="GET:/payments"
606
+ ```
607
+
608
+ ---
609
+
610
+ ## Architecture
611
+
612
+ ```
613
+ shield/
614
+ ├── core/ # Framework-agnostic — zero FastAPI imports
615
+ │ ├── models.py # RouteState, AuditEntry, GlobalMaintenanceConfig
616
+ │ ├── engine.py # ShieldEngine — all business logic
617
+ │ ├── scheduler.py # MaintenanceScheduler (asyncio.Task based)
618
+ │ ├── config.py # Backend/engine factory + .shield file loading
619
+ │ ├── exceptions.py # MaintenanceException, EnvGatedException, ...
620
+ │ └── backends/
621
+ │ ├── base.py # ShieldBackend ABC
622
+ │ ├── memory.py # In-process dict
623
+ │ ├── file.py # JSON file via aiofiles
624
+ │ └── redis.py # Redis via redis-py async
625
+
626
+ ├── fastapi/ # FastAPI adapter layer
627
+ │ ├── middleware.py # ShieldMiddleware (ASGI, BaseHTTPMiddleware)
628
+ │ ├── decorators.py # @maintenance, @disabled, @env_only, ...
629
+ │ ├── router.py # ShieldRouter + scan_routes()
630
+ │ └── openapi.py # Schema filter + docs UI customisation
631
+
632
+ └── cli/
633
+ └── main.py # Typer CLI app
634
+ ```
635
+
636
+ ### Key design rules
637
+
638
+ 1. **`shield.core` never imports from `shield.fastapi`** — the core is framework-agnostic and can power future adapters (Flask, Litestar, Django).
639
+ 2. **All business logic lives in `ShieldEngine`** — middleware and decorators are transport layers that call `engine.check()`, never make policy decisions themselves.
640
+ 3. **`engine.check()` is the single chokepoint** — every request, regardless of router type, goes through this one method.
641
+ 4. **Fail-open on backend errors** — if the backend is unreachable, requests pass through. Shield never takes down an API due to its own failures.
642
+ 5. **Persistence-first registration** — if a route already has persisted state, the decorator default is ignored. Runtime changes survive restarts.
643
+
644
+ ---
645
+
646
+ ## Testing
647
+
648
+ ```bash
649
+ # Run all tests
650
+ uv run pytest
651
+
652
+ # Run with verbose output
653
+ uv run pytest -v
654
+
655
+ # Run a specific test file
656
+ uv run pytest tests/fastapi/test_middleware.py
657
+
658
+ # Run only core tests (no FastAPI dependency)
659
+ uv run pytest tests/core/
660
+ ```
661
+
662
+ ### Writing tests with shield
663
+
664
+ ```python
665
+ import pytest
666
+ from fastapi import FastAPI
667
+ from httpx import ASGITransport, AsyncClient
668
+
669
+ from shield.core.backends.memory import MemoryBackend
670
+ from shield.core.engine import ShieldEngine
671
+ from shield.fastapi.decorators import maintenance, force_active
672
+ from shield.fastapi.middleware import ShieldMiddleware
673
+ from shield.fastapi.router import ShieldRouter
674
+
675
+
676
+ async def test_maintenance_returns_503():
677
+ engine = ShieldEngine(backend=MemoryBackend())
678
+ app = FastAPI()
679
+ app.add_middleware(ShieldMiddleware, engine=engine)
680
+ router = ShieldRouter(engine=engine)
681
+
682
+ @router.get("/payments")
683
+ @maintenance(reason="DB migration")
684
+ async def get_payments():
685
+ return {"ok": True}
686
+
687
+ app.include_router(router)
688
+ await app.router.startup() # trigger shield route registration
689
+
690
+ async with AsyncClient(
691
+ transport=ASGITransport(app=app), base_url="http://test"
692
+ ) as client:
693
+ resp = await client.get("/payments")
694
+
695
+ assert resp.status_code == 503
696
+ assert resp.json()["error"]["code"] == "MAINTENANCE_MODE"
697
+
698
+
699
+ async def test_runtime_enable_via_engine():
700
+ engine = ShieldEngine(backend=MemoryBackend())
701
+ # ... set up app ...
702
+
703
+ # Put a route in maintenance at runtime (no decorator needed)
704
+ await engine.set_maintenance("GET:/orders", reason="Upgrade")
705
+
706
+ # Re-enable it
707
+ await engine.enable("GET:/orders")
708
+
709
+ state = await engine.get_state("GET:/orders")
710
+ assert state.status.value == "active"
711
+ ```
712
+
713
+ ### Test configuration
714
+
715
+ `pyproject.toml` includes:
716
+ ```toml
717
+ [tool.pytest.ini_options]
718
+ asyncio_mode = "auto" # all async tests work without @pytest.mark.asyncio
719
+ ```
720
+
721
+ ---
722
+
723
+ ## Error Response Format
724
+
725
+ All shield-generated error responses follow a consistent JSON structure:
726
+
727
+ ```json
728
+ {
729
+ "error": {
730
+ "code": "MAINTENANCE_MODE",
731
+ "message": "This endpoint is temporarily unavailable",
732
+ "reason": "Database migration in progress",
733
+ "path": "/api/payments",
734
+ "retry_after": "2025-06-01T04:00:00Z"
735
+ }
736
+ }
737
+ ```
738
+
739
+ | Scenario | HTTP status | `code` |
740
+ |---|---|---|
741
+ | Route in maintenance | 503 | `MAINTENANCE_MODE` |
742
+ | Route disabled | 503 | `ROUTE_DISABLED` |
743
+ | Route env-gated (wrong env) | 404 | *(no body — silent)* |
744
+ | Global maintenance active | 503 | `MAINTENANCE_MODE` |