9c4927f
commit 9c4927f
7 files changed
+760 -131
Showing 7 changed files+760-131
docs_site/src/content/docs/advanced/custom-response.mddocs_site/src/content/docs/tutorials/body-nested-models.mddocs_site/src/content/docs/tutorials/error-handling.mddocs_site/src/content/docs/tutorials/query-params-str-validations.mddocs_site/src/content/docs/tutorials/response-model.mddocs_site/src/content/docs/tutorials/security/first-steps/index.mddocs_site/src/content/docs/tutorials/testing.md
▼docs_site/src/content/docs/advanced/custom-response.md
+89-5
| @@ -1,22 +1,42 @@ | ||
1 | 1 | --- |
2 | 2 | title: Custom Response Classes |
3 | 3 | description: Use HTML, streaming, file, and other custom response types in JustAPI. |
4 | -keywords: [JustAPI, custom response, HTML, streaming, file response, redirect] | |
4 | +keywords: [JustAPI, custom response, HTML, streaming, file response, redirect, response_class] | |
5 | 5 | --- |
6 | 6 | |
7 | -## HTML Response | |
7 | +## Using response_class in Decorator | |
8 | + | |
9 | +Set the response class directly in the decorator: | |
8 | 10 | |
9 | 11 | ```python |
10 | -from starlette.responses import HTMLResponse | |
12 | +from starlette.responses import HTMLResponse, PlainTextResponse | |
11 | 13 | from justapi import JustAPIApp |
12 | 14 | |
13 | 15 | app = JustAPIApp() |
14 | 16 | |
17 | +@app.get("/", response_class=PlainTextResponse) | |
18 | +def root(): | |
19 | + return "Hello, plain text!" | |
20 | +``` | |
21 | + | |
22 | +This also documents the correct Content-Type in the OpenAPI schema. | |
23 | + | |
24 | +## HTML Response | |
25 | + | |
26 | +```python | |
15 | 27 | @app.get("/html") |
16 | 28 | def html_page(): |
17 | 29 | return HTMLResponse(content="<h1>Hello</h1><p>This is HTML</p>") |
18 | 30 | ``` |
19 | 31 | |
32 | +Or using `response_class`: | |
33 | + | |
34 | +```python | |
35 | +@app.get("/html", response_class=HTMLResponse) | |
36 | +def html_page(): | |
37 | + return "<h1>Hello</h1>" | |
38 | +``` | |
39 | + | |
20 | 40 | ## Streaming Response |
21 | 41 | |
22 | 42 | ```python |
| @@ -33,6 +53,28 @@ def stream(): | ||
33 | 53 | return StreamingResponse(generate_tokens(), media_type="text/event-stream") |
34 | 54 | ``` |
35 | 55 | |
56 | +## Redirect | |
57 | + | |
58 | +```python | |
59 | +from starlette.responses import RedirectResponse | |
60 | + | |
61 | +@app.get("/old-path") | |
62 | +def old_path(): | |
63 | + return RedirectResponse(url="/new-path", status_code=302) | |
64 | + | |
65 | +@app.get("/new-path") | |
66 | +def new_path(): | |
67 | + return {"message": "you are here"} | |
68 | +``` | |
69 | + | |
70 | +Or using `response_class`: | |
71 | + | |
72 | +```python | |
73 | +@app.get("/old-path", response_class=RedirectResponse, status_code=302) | |
74 | +def old_path(): | |
75 | + return "/new-path" | |
76 | +``` | |
77 | + | |
36 | 78 | ## File Response |
37 | 79 | |
38 | 80 | ```python |
| @@ -43,11 +85,22 @@ def download(): | ||
43 | 85 | return FileResponse("report.pdf", filename="report.pdf") |
44 | 86 | ``` |
45 | 87 | |
46 | -## Plain Text Response | |
88 | +## JSON Response with Custom Headers | |
47 | 89 | |
48 | 90 | ```python |
49 | -from starlette.responses import PlainTextResponse | |
91 | +from starlette.responses import JSONResponse | |
92 | + | |
93 | +@app.get("/custom") | |
94 | +def custom(): | |
95 | + return JSONResponse( | |
96 | + content={"data": "value"}, | |
97 | + headers={"X-Custom": "header-value"}, | |
98 | + ) | |
99 | +``` | |
100 | + | |
101 | +## Plain Text Response | |
50 | 102 | |
103 | +```python | |
51 | 104 | @app.get("/text") |
52 | 105 | def text(): |
53 | 106 | return PlainTextResponse("Hello, plain text!") |
| @@ -67,6 +120,37 @@ app = JustAPIApp(default_response_class=ORJSONResponse) | ||
67 | 120 | `ORJSONResponse` is faster than `JSONResponse` for large payloads. Install with `pip install orjson`. |
68 | 121 | ::: |
69 | 122 | |
123 | +## Custom Response Class | |
124 | + | |
125 | +Create your own response class: | |
126 | + | |
127 | +```python | |
128 | +from starlette.responses import JSONResponse | |
129 | + | |
130 | +class PrettyJSONResponse(JSONResponse): | |
131 | + def render(self, content) -> bytes: | |
132 | + import json | |
133 | + return json.dumps( | |
134 | + content, | |
135 | + indent=2, | |
136 | + ensure_ascii=False, | |
137 | + ).encode("utf-8") | |
138 | + | |
139 | +@app.get("/pretty") | |
140 | +def pretty(): | |
141 | + return PrettyJSONResponse(content={"data": "value"}) | |
142 | +``` | |
143 | + | |
144 | +## Response Class + Status Code | |
145 | + | |
146 | +Combine `response_class` with `status_code`: | |
147 | + | |
148 | +```python | |
149 | +@app.post("/items", response_class=JSONResponse, status_code=201) | |
150 | +def create_item(): | |
151 | + return {"message": "created"} | |
152 | +``` | |
153 | + | |
70 | 154 | ## See Also |
71 | 155 | |
72 | 156 | - [Return a Response Directly](/advanced/response-directly/) — basic response object usage |
73 | 157 | |
▼docs_site/src/content/docs/tutorials/body-nested-models.md
+103-2
| @@ -1,7 +1,7 @@ | ||
1 | 1 | --- |
2 | 2 | title: Body — Nested Models |
3 | -description: Use nested Pydantic models for complex JSON structures in JustAPI. | |
4 | -keywords: [JustAPI, nested models, Pydantic, complex JSON, deeply nested] | |
3 | +description: Use nested Pydantic models for complex JSON structures in JustAPI — sets, lists, dicts, special types. | |
4 | +keywords: [JustAPI, nested models, Pydantic, complex JSON, sets, lists, dicts, HttpUrl] | |
5 | 5 | --- |
6 | 6 | |
7 | 7 | ## Basic Nested Models |
| @@ -71,6 +71,107 @@ def create_item(item: Item): | ||
71 | 71 | } |
72 | 72 | ``` |
73 | 73 | |
74 | +## Set Types | |
75 | + | |
76 | +Use `set` to deduplicate values: | |
77 | + | |
78 | +```python | |
79 | +class Item(BaseModel): | |
80 | + name: str | |
81 | + tags: set[str] = set() | |
82 | + | |
83 | +@app.post("/items") | |
84 | +def create_item(item: Item): | |
85 | + return item.model_dump() | |
86 | +``` | |
87 | + | |
88 | +```json | |
89 | +{ | |
90 | + "name": "Widget", | |
91 | + "tags": ["hardware", "sale", "hardware"] | |
92 | +} | |
93 | +``` | |
94 | + | |
95 | +The response deduplicates: | |
96 | + | |
97 | +```json | |
98 | +{ | |
99 | + "name": "Widget", | |
100 | + "tags": ["hardware", "sale"] | |
101 | +} | |
102 | +``` | |
103 | + | |
104 | +## Bodies of Pure Lists | |
105 | + | |
106 | +The entire request body can be a list: | |
107 | + | |
108 | +```python | |
109 | +class Image(BaseModel): | |
110 | + url: str | |
111 | + caption: str = "" | |
112 | + | |
113 | +@app.post("/images") | |
114 | +def create_images(images: list[Image]): | |
115 | + return {"count": len(images), "images": images} | |
116 | +``` | |
117 | + | |
118 | +```json | |
119 | +[ | |
120 | + {"url": "https://example.com/img1.jpg", "caption": "Photo 1"}, | |
121 | + {"url": "https://example.com/img2.jpg"} | |
122 | +] | |
123 | +``` | |
124 | + | |
125 | +## Bodies of Arbitrary Dicts | |
126 | + | |
127 | +Use `dict` for dynamic keys: | |
128 | + | |
129 | +```python | |
130 | +@app.post("/metrics") | |
131 | +def record_metrics(metrics: dict[str, float]): | |
132 | + return {"metrics": metrics} | |
133 | +``` | |
134 | + | |
135 | +```json | |
136 | +{ | |
137 | + "cpu": 75.5, | |
138 | + "memory": 62.3, | |
139 | + "disk": 45.1 | |
140 | +} | |
141 | +``` | |
142 | + | |
143 | +Typed keys: | |
144 | + | |
145 | +```python | |
146 | +@app.post("/config") | |
147 | +def update_config(config: dict[str, str]): | |
148 | + return {"config": config} | |
149 | +``` | |
150 | + | |
151 | +## Special Types | |
152 | + | |
153 | +Pydantic includes types for common formats: | |
154 | + | |
155 | +```python | |
156 | +from pydantic import BaseModel, HttpUrl, EmailStr | |
157 | + | |
158 | +class Website(BaseModel): | |
159 | + url: HttpUrl | |
160 | + contact_email: EmailStr | |
161 | + | |
162 | +@app.post("/websites") | |
163 | +def create_website(website: Website): | |
164 | + return {"url": str(website.url), "email": str(website.contact_email)} | |
165 | +``` | |
166 | + | |
167 | +| Type | Validates | | |
168 | +|------|-----------| | |
169 | +| `HttpUrl` | Valid HTTP/HTTPS URL | | |
170 | +| `EmailStr` | Valid email address | | |
171 | +| `AnyUrl` | Any valid URL | | |
172 | +| `IPvAnyAddress` | Valid IP address | | |
173 | +| `AnyHttpUrl` | HTTP or HTTPS URL | | |
174 | + | |
74 | 175 | ## Deeply Nested Structures |
75 | 176 | |
76 | 177 | ```python |
77 | 178 | |
▼docs_site/src/content/docs/tutorials/error-handling.md
+108-101
| @@ -1,149 +1,156 @@ | ||
1 | 1 | --- |
2 | -title: Error Handling | |
3 | -description: Handle errors gracefully in JustAPI with HTTPException, custom exception handlers, and validation error responses — a FastAPI alternative. | |
4 | -keywords: error handling, HTTPException, exception handlers, JustAPI, FastAPI alternative, validation errors | |
2 | +title: Handling Errors | |
3 | +description: Handle HTTP errors, validation errors, and custom exception handlers in JustAPI. | |
4 | +keywords: [JustAPI, error handling, HTTPException, RequestValidationError, exception handlers] | |
5 | 5 | --- |
6 | 6 | |
7 | -JustAPI provides a structured error-handling system that lets you return consistent error responses across your API. | |
7 | +## HTTPException | |
8 | 8 | |
9 | -## Raising HTTP Errors | |
10 | - | |
11 | -Use `HTTPException` to return error responses from any handler or dependency: | |
9 | +Raise an `HTTPException` to return an error response: | |
12 | 10 | |
13 | 11 | ```python |
14 | 12 | from justapi import JustAPIApp, HTTPException |
15 | 13 | |
16 | 14 | app = JustAPIApp() |
17 | 15 | |
16 | +@app.get("/items/{item_id}") | |
17 | +def get_item(item_id: int): | |
18 | + if item_id == 0: | |
19 | + raise HTTPException(status_code=404, detail="Item not found") | |
20 | + return {"item_id": item_id} | |
21 | +``` | |
22 | + | |
23 | +### Adding Headers | |
18 | 24 | |
25 | +```python | |
19 | 26 | @app.get("/items/{item_id}") |
20 | -def read_item(request, item_id: int): | |
27 | +def get_item(item_id: int): | |
21 | 28 | if item_id == 0: |
22 | - raise HTTPException(status_code=400, detail="Item ID must be positive") | |
23 | - if item_id == 42: | |
24 | 29 | raise HTTPException( |
25 | - status_code=418, | |
26 | - detail="I'm a teapot", | |
27 | - headers={"X-Error": "teapot"}, | |
30 | + status_code=404, | |
31 | + detail="Item not found", | |
32 | + headers={"X-Error": "item-not-found"}, | |
28 | 33 | ) |
29 | 34 | return {"item_id": item_id} |
30 | 35 | ``` |
31 | 36 | |
32 | -```bash | |
33 | -curl http://127.0.0.1:8000/items/0 | |
34 | -# Output: {"detail":"Item ID must be positive"} | |
37 | +## RequestValidationError | |
35 | 38 | |
36 | -curl http://127.0.0.1:8000/items/42 | |
37 | -# Output: {"detail":"I'm a teapot"} | |
38 | -``` | |
39 | +When request data doesn't match the schema, JustAPI returns a `422` error automatically: | |
39 | 40 | |
40 | -### `HTTPException` Parameters | |
41 | +```json | |
42 | +{ | |
43 | + "detail": [ | |
44 | + { | |
45 | + "type": "int_parsing", | |
46 | + "loc": ["path", "item_id"], | |
47 | + "msg": "Input should be a valid integer", | |
48 | + "input": "foo" | |
49 | + } | |
50 | + ] | |
51 | +} | |
52 | +``` | |
41 | 53 | |
42 | -| Parameter | Type | Default | Description | | |
43 | -|---|---|---|---| | |
44 | -| `status_code` | int | required | HTTP status code | | |
45 | -| `detail` | str | required | Error message | | |
46 | -| `headers` | dict | `None` | Additional response headers | | |
54 | +The `detail` array contains every validation error with: | |
55 | +- `type` — error category | |
56 | +- `loc` — where the error occurred (`["path", "body", "query", "header"]`) | |
57 | +- `msg` — human-readable message | |
58 | +- `input` — the value that was rejected | |
47 | 59 | |
48 | -## Custom Exception Handlers | |
60 | +## Override Validation Error Handler | |
49 | 61 | |
50 | -Override the default error format for specific exception types: | |
62 | +Customize the validation error response: | |
51 | 63 | |
52 | 64 | ```python |
53 | -from justapi import RequestValidationError | |
54 | - | |
55 | - | |
56 | -@app.exception_handler(HTTPException) | |
57 | -def http_exception_handler(request, exc: HTTPException): | |
58 | - return { | |
59 | - "status": exc.status_code, | |
60 | - "body": { | |
61 | - "error": "Request Failed", | |
62 | - "code": exc.status_code, | |
63 | - "message": exc.detail, | |
64 | - }, | |
65 | - } | |
65 | +from justapi import JustAPIApp | |
66 | +from starlette.requests import Request | |
67 | +from starlette.responses import JSONResponse | |
66 | 68 | |
69 | +app = JustAPIApp() | |
67 | 70 | |
68 | 71 | @app.exception_handler(RequestValidationError) |
69 | -def validation_handler(request, exc: RequestValidationError): | |
70 | - return { | |
71 | - "status": 422, | |
72 | - "body": { | |
73 | - "error": "Validation Failed", | |
74 | - "issues": exc.errors(), | |
72 | +async def validation_error_handler(request: Request, exc): | |
73 | + return JSONResponse( | |
74 | + status_code=422, | |
75 | + content={ | |
76 | + "error": "validation_error", | |
77 | + "message": "Invalid request data", | |
78 | + "details": exc.errors(), | |
75 | 79 | }, |
76 | - } | |
80 | + ) | |
77 | 81 | ``` |
78 | 82 | |
79 | -## Validation Errors | |
83 | +## Override HTTPException Handler | |
80 | 84 | |
81 | -When a request fails type validation (e.g., invalid path parameter type), JustAPI returns a structured 422 response: | |
85 | +Change the default HTTP error format: | |
82 | 86 | |
83 | -```bash | |
84 | -curl http://127.0.0.1:8000/items/abc | |
85 | -# Output: {"detail":"validation error"} | |
86 | -``` | |
87 | - | |
88 | -With a custom handler, you can expand this to include field-level details. | |
89 | - | |
90 | -## Error Response Format | |
91 | - | |
92 | -By default, all error responses follow the format: | |
87 | +```python | |
88 | +from fastapi.exception_handlers import http_exception_handler | |
93 | 89 | |
94 | -```json | |
95 | -{ | |
96 | - "detail": "<error message>" | |
97 | -} | |
90 | +@app.exception_handler(HTTPException) | |
91 | +async def custom_http_exception(request, exc): | |
92 | + return JSONResponse( | |
93 | + status_code=exc.status_code, | |
94 | + content={ | |
95 | + "error": "http_error", | |
96 | + "message": exc.detail, | |
97 | + "status_code": exc.status_code, | |
98 | + }, | |
99 | + ) | |
98 | 100 | ``` |
99 | 101 | |
100 | -This applies to: | |
101 | -- **400** — Bad request | |
102 | -- **401** — Unauthorized | |
103 | -- **403** — Forbidden | |
104 | -- **404** — Not found (unmatched route) | |
105 | -- **413** — Payload too large | |
106 | -- **422** — Validation error | |
107 | -- **429** — Rate limit exceeded | |
108 | -- **500** — Internal server error | |
109 | -- **503** — Service unavailable (pool saturation) | |
110 | -- **504** — Gateway timeout | |
111 | - | |
112 | -## Handling Errors in Dependencies | |
102 | +## Custom Exception | |
113 | 103 | |
114 | -Dependencies can raise `HTTPException` to reject requests: | |
104 | +Create your own exception and handler: | |
115 | 105 | |
116 | 106 | ```python |
117 | -from justapi import Depends, HTTPException, Header | |
118 | - | |
119 | - | |
120 | -def require_admin(authorization: str = Header(...)): | |
121 | - if authorization != "Bearer admin-token": | |
122 | - raise HTTPException(status_code=403, detail="Admin access required") | |
107 | +class InsufficientFundsError(Exception): | |
108 | + def __init__(self, balance: float, amount: float): | |
109 | + self.balance = balance | |
110 | + self.amount = amount | |
111 | + | |
112 | +@app.exception_handler(InsufficientFundsError) | |
113 | +async def insufficient_funds_handler(request, exc): | |
114 | + return JSONResponse( | |
115 | + status_code=402, | |
116 | + content={ | |
117 | + "error": "insufficient_funds", | |
118 | + "message": f"Need {exc.amount}, have {exc.balance}", | |
119 | + }, | |
120 | + ) | |
121 | + | |
122 | +@app.post("/purchase") | |
123 | +def purchase(amount: float): | |
124 | + balance = 100.0 | |
125 | + if amount > balance: | |
126 | + raise InsufficientFundsError(balance, amount) | |
127 | + return {"status": "ok"} | |
128 | +``` | |
123 | 129 | |
130 | +## Debug Mode | |
124 | 131 | |
125 | -@app.get("/admin") | |
126 | -def admin_panel(request, admin: None = Depends(require_admin)): | |
127 | - return {"secret": "classified"} | |
132 | +```python | |
133 | +app = JustAPIApp(debug=True) | |
128 | 134 | ``` |
129 | 135 | |
130 | -## Global Error Catchers | |
136 | +Debug mode returns detailed tracebacks in responses. | |
131 | 137 | |
132 | -You can register a catch-all exception handler: | |
138 | +:::warning | |
139 | +Never use `debug=True` in production. It exposes internal details. | |
140 | +::: | |
133 | 141 | |
134 | -```python | |
135 | -@app.exception_handler(Exception) | |
136 | -def global_handler(request, exc: Exception): | |
137 | - return { | |
138 | - "status": 500, | |
139 | - "body": {"detail": "An unexpected error occurred"}, | |
140 | - } | |
141 | -``` | |
142 | +## Common Errors | |
142 | 143 | |
143 | -In production, JustAPI's `panic = "abort"` mode ensures that unexpected Rust panics abort the process cleanly, and the supervisor restarts it. See [Production Checklist](/deployment/production-checklist/). | |
144 | +| Status Code | Meaning | Cause | | |
145 | +|-------------|---------|-------| | |
146 | +| 404 | Not Found | Route doesn't exist | | |
147 | +| 405 | Method Not Allowed | Wrong HTTP method | | |
148 | +| 422 | Validation Error | Request data doesn't match schema | | |
149 | +| 401 | Unauthorized | Missing or invalid auth | | |
150 | +| 403 | Forbidden | Authenticated but not authorized | | |
144 | 151 | |
145 | -## Next Steps | |
152 | +## See Also | |
146 | 153 | |
147 | -- [Middleware](/tutorials/middleware/) — Request/response interception | |
148 | -- [File Uploads](/tutorials/file-uploads/) — Handle multipart form data | |
149 | -- [Production Checklist](/deployment/production-checklist/) — Error handling in production | |
154 | +- [Status Codes](/tutorials/response-status-code/) — custom status codes | |
155 | +- [Error Codes Reference](/reference/error-codes/) — complete error code list | |
156 | +- [Advanced Security](/advanced/advanced-security/) — auth errors | |
150 | 157 | |
▼docs_site/src/content/docs/tutorials/query-params-str-validations.md
+114-5
| @@ -1,6 +1,6 @@ | ||
1 | 1 | --- |
2 | 2 | title: Query Parameters and String Validations |
3 | -description: Add string validation constraints to query parameters — min_length, max_length, regex patterns. | |
3 | +description: Add string validation constraints to query parameters — min_length, max_length, regex patterns, metadata. | |
4 | 4 | keywords: [JustAPI, query parameters, validation, min_length, max_length, regex, Query] |
5 | 5 | --- |
6 | 6 | |
| @@ -21,6 +21,31 @@ def search( | ||
21 | 21 | return {"q": q, "tag": tag} |
22 | 22 | ``` |
23 | 23 | |
24 | +`Query(...)` means the parameter is required (the `...` is Python's Ellipsis, meaning "no default"). `Query(None)` makes it optional. | |
25 | + | |
26 | +## Required vs Optional | |
27 | + | |
28 | +```python | |
29 | +# Required — no default value | |
30 | +@app.get("/search") | |
31 | +def search(q: str = Query(...)): | |
32 | + return {"q": q} | |
33 | + | |
34 | +# Optional — default value provided | |
35 | +@app.get("/search") | |
36 | +def search(q: str = Query(None)): | |
37 | + return {"q": q} | |
38 | + | |
39 | +# Optional with explicit default | |
40 | +@app.get("/search") | |
41 | +def search(q: str = Query("default")): | |
42 | + return {"q": q} | |
43 | +``` | |
44 | + | |
45 | +:::note | |
46 | +`Query(...)` and `Query(min_length=1)` are both required. The `...` just means "no default value" — the constraint is separate. | |
47 | +::: | |
48 | + | |
24 | 49 | ## Validation Options |
25 | 50 | |
26 | 51 | | Parameter | Type | Description | |
| @@ -32,6 +57,7 @@ def search( | ||
32 | 57 | | `description` | `str` | Description shown in the docs | |
33 | 58 | | `deprecated` | `bool` | Mark as deprecated in the docs | |
34 | 59 | | `alias` | `str` | Alternative parameter name | |
60 | +| `include_in_schema` | `bool` | Set `False` to hide from docs | | |
35 | 61 | |
36 | 62 | ## Regex Patterns |
37 | 63 | |
| @@ -44,10 +70,69 @@ def search_users( | ||
44 | 70 | return {"email": email, "phone": phone} |
45 | 71 | ``` |
46 | 72 | |
73 | +If the pattern doesn't match, the API returns a validation error: | |
74 | + | |
75 | +```json | |
76 | +{ | |
77 | + "detail": [ | |
78 | + { | |
79 | + "type": "string_pattern_mismatch", | |
80 | + "loc": ["query", "email"], | |
81 | + "msg": "String should match pattern '^[\\w.-]+@[\\w.-]+\\.\\w+$'", | |
82 | + "input": "not-an-email" | |
83 | + } | |
84 | + ] | |
85 | +} | |
86 | +``` | |
87 | + | |
47 | 88 | :::tip |
48 | -Use raw strings (`r"..."`) for regex patterns to avoid escaping issues. | |
89 | +Use raw strings (`r"..."`) for regex patterns to avoid escaping issues with backslashes. | |
49 | 90 | ::: |
50 | 91 | |
92 | +## Multiple Values for One Parameter | |
93 | + | |
94 | +Repeat the parameter in the query string: | |
95 | + | |
96 | +```python | |
97 | +@app.get("/filter") | |
98 | +def filter_items(tags: list[str] = Query(None)): | |
99 | + return {"tags": tags} | |
100 | +``` | |
101 | + | |
102 | +`/filter?tags=a&tags=b&tags=c` returns `{"tags": ["a", "b", "c"]}`. | |
103 | + | |
104 | +With validation: | |
105 | + | |
106 | +```python | |
107 | +@app.get("/filter") | |
108 | +def filter_items( | |
109 | + tags: list[str] = Query(None, min_length=1, max_length=10), | |
110 | +): | |
111 | + return {"tags": tags} | |
112 | +``` | |
113 | + | |
114 | +Each tag must be between 1 and 10 characters. | |
115 | + | |
116 | +## Metadata in Docs | |
117 | + | |
118 | +Add title and description to show in the OpenAPI schema: | |
119 | + | |
120 | +```python | |
121 | +@app.get("/search") | |
122 | +def search( | |
123 | + q: str = Query( | |
124 | + ..., | |
125 | + title="Search Query", | |
126 | + description="The search term to look for", | |
127 | + min_length=1, | |
128 | + max_length=200, | |
129 | + ), | |
130 | +): | |
131 | + return {"q": q} | |
132 | +``` | |
133 | + | |
134 | +The Swagger UI will show the title and description next to the parameter. | |
135 | + | |
51 | 136 | ## Aliasing |
52 | 137 | |
53 | 138 | Use `alias` when the parameter name differs from the Python variable name: |
| @@ -64,12 +149,36 @@ The client sends `?item-name=foo` but your handler receives `item_name`. | ||
64 | 149 | |
65 | 150 | ```python |
66 | 151 | @app.get("/search") |
67 | -def search(q: str = Query(..., deprecated=True)): | |
68 | - """Use the new search endpoint instead.""" | |
152 | +def search( | |
153 | + q: str = Query(...), | |
154 | + old_format: bool = Query(False, deprecated=True), | |
155 | +): | |
156 | + return {"q": q, "old_format": old_format} | |
157 | +``` | |
158 | + | |
159 | +The `old_format` parameter appears in the docs with a deprecation notice. | |
160 | + | |
161 | +## Hiding Parameters from Docs | |
162 | + | |
163 | +```python | |
164 | +@app.get("/search") | |
165 | +def search( | |
166 | + q: str = Query(...), | |
167 | + internal_flag: str = Query(None, include_in_schema=False), | |
168 | +): | |
69 | 169 | return {"q": q} |
70 | 170 | ``` |
71 | 171 | |
72 | -The parameter appears in the docs with a deprecation notice. | |
172 | +`internal_flag` works but doesn't appear in the OpenAPI schema. | |
173 | + | |
174 | +## Recap | |
175 | + | |
176 | +- `Query(...)` makes a parameter required, `Query(None)` makes it optional | |
177 | +- Use `min_length`, `max_length`, `pattern` for string validation | |
178 | +- Use `title` and `description` for better docs | |
179 | +- Use `alias` for alternative parameter names | |
180 | +- Use `deprecated=True` to mark parameters as deprecated | |
181 | +- Use `list[str]` for repeating query parameters | |
73 | 182 | |
74 | 183 | ## See Also |
75 | 184 | |
76 | 185 | |
▼docs_site/src/content/docs/tutorials/response-model.md
+85-2
| @@ -1,7 +1,7 @@ | ||
1 | 1 | --- |
2 | 2 | title: Response Model — Return Type |
3 | 3 | description: Control the response schema and filter output fields in JustAPI with response_model. |
4 | -keywords: [JustAPI, response model, return type, filtering, schema] | |
4 | +keywords: [JustAPI, response model, return type, filtering, schema, exclude_unset] | |
5 | 5 | --- |
6 | 6 | |
7 | 7 | ## Basic Response Model |
| @@ -25,10 +25,12 @@ def get_user(user_id: int): | ||
25 | 25 | return {"id": user_id, "name": "Alice", "email": "[email protected]", "password": "secret"} |
26 | 26 | ``` |
27 | 27 | |
28 | -The `password` field is automatically excluded from the response because it's not declared in the response model. | |
28 | +The `password` field is automatically excluded from the response because it's not in the response model. This is a security feature — you don't accidentally leak sensitive data. | |
29 | 29 | |
30 | 30 | ## Filtering Response Fields |
31 | 31 | |
32 | +Use a separate model for output: | |
33 | + | |
32 | 34 | ```python |
33 | 35 | class UserOut(BaseModel): |
34 | 36 | id: int |
| @@ -53,6 +55,75 @@ def get_user(user_id: int): | ||
53 | 55 | return {"id": user_id, "name": "Alice", "email": "[email protected]", "password": "secret"} |
54 | 56 | ``` |
55 | 57 | |
58 | +## Include Fields | |
59 | + | |
60 | +```python | |
61 | +@app.get( | |
62 | + "/users/{user_id}", | |
63 | + response_model=UserOut, | |
64 | + response_model_include={"id", "name"}, | |
65 | +) | |
66 | +def get_user(user_id: int): | |
67 | + return {"id": user_id, "name": "Alice"} | |
68 | +``` | |
69 | + | |
70 | +## response_model_exclude_unset | |
71 | + | |
72 | +This is useful when working with databases that return optional fields. It excludes fields that were not explicitly set: | |
73 | + | |
74 | +```python | |
75 | +from pydantic import BaseModel | |
76 | +from typing import Optional | |
77 | + | |
78 | +class User(BaseModel): | |
79 | + id: int | |
80 | + name: str | |
81 | + email: Optional[str] = None | |
82 | + bio: Optional[str] = None | |
83 | + | |
84 | +app = JustAPIApp() | |
85 | + | |
86 | +@app.get("/users/{user_id}", response_model=User, response_model_exclude_unset=True) | |
87 | +def get_user(user_id: int): | |
88 | + # Database returns only set fields | |
89 | + return {"id": 1, "name": "Alice"} | |
90 | + # email and bio are excluded because they weren't set | |
91 | +``` | |
92 | + | |
93 | +Without `exclude_unset`, the response would include `"email": null, "bio": null`. With it, only `id` and `name` appear. | |
94 | + | |
95 | +## response_model_exclude_defaults | |
96 | + | |
97 | +Excludes fields that have their default value: | |
98 | + | |
99 | +```python | |
100 | +@app.get("/users/{user_id}", response_model=User, response_model_exclude_defaults=True) | |
101 | +def get_user(user_id: int): | |
102 | + return {"id": 1, "name": "Alice", "email": None, "bio": None} | |
103 | + # email and bio are excluded because None is the default | |
104 | +``` | |
105 | + | |
106 | +## response_model_exclude_none | |
107 | + | |
108 | +Excludes fields that are `None`: | |
109 | + | |
110 | +```python | |
111 | +@app.get("/users/{user_id}", response_model=User, response_model_exclude_none=True) | |
112 | +def get_user(user_id: int): | |
113 | + return {"id": 1, "name": "Alice", "email": None, "bio": None} | |
114 | + # email and bio are excluded because they're None | |
115 | +``` | |
116 | + | |
117 | +## Disable Response Model | |
118 | + | |
119 | +```python | |
120 | +@app.get("/raw", response_model=None) | |
121 | +def raw(): | |
122 | + return {"anything": "goes"} | |
123 | +``` | |
124 | + | |
125 | +With `response_model=None`, no response filtering is applied. | |
126 | + | |
56 | 127 | ## Multiple Response Models |
57 | 128 | |
58 | 129 | ```python |
| @@ -69,6 +140,18 @@ def get_user(user_id: int): | ||
69 | 140 | return {"id": user_id, "name": "Alice"} |
70 | 141 | ``` |
71 | 142 | |
143 | +## Return Type Annotation | |
144 | + | |
145 | +You can also use the Python return type annotation: | |
146 | + | |
147 | +```python | |
148 | +@app.get("/users/{user_id}") | |
149 | +def get_user(user_id: int) -> UserOut: | |
150 | + return {"id": user_id, "name": "Alice"} | |
151 | +``` | |
152 | + | |
153 | +JustAPI uses the return type as the response model automatically. | |
154 | + | |
72 | 155 | ## See Also |
73 | 156 | |
74 | 157 | - [Extra Models](/tutorials/extra-models/) — multiple related models |
75 | 158 | |
▼docs_site/src/content/docs/tutorials/security/first-steps/index.md
+156-10
| @@ -1,36 +1,182 @@ | ||
1 | 1 | --- |
2 | 2 | title: Security — First Steps |
3 | -description: Add basic authentication to a JustAPI route. | |
4 | -keywords: [JustAPI, security, authentication, OAuth2, bearer token] | |
3 | +description: Add authentication to a JustAPI route using OAuth2 and Security dependencies. | |
4 | +keywords: [JustAPI, security, OAuth2, authentication, bearer token, password flow] | |
5 | 5 | --- |
6 | 6 | |
7 | -## Adding Security to a Route | |
7 | +This tutorial builds a simple authentication system step by step. By the end you'll understand the full OAuth2 password flow and how to protect routes. | |
8 | 8 | |
9 | -Use `Security()` (like `Depends()` but for security) to protect routes: | |
9 | +## The Problem | |
10 | + | |
11 | +You need to protect a route so only authenticated users can access it. Unauthenticated requests should get a `401` error. | |
12 | + | |
13 | +## Step 1: Add a Security Dependency | |
14 | + | |
15 | +```python | |
16 | +from justapi import JustAPIApp, Security | |
17 | +from fastapi.security import OAuth2PasswordBearer | |
18 | + | |
19 | +app = JustAPIApp() | |
20 | + | |
21 | +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") | |
22 | + | |
23 | +@app.get("/users/me") | |
24 | +def read_users_me(token: str = Security(oauth2_scheme)): | |
25 | + return {"token": token} | |
26 | +``` | |
27 | + | |
28 | +### What happens here? | |
29 | + | |
30 | +1. `OAuth2PasswordBearer(tokenUrl="token")` creates a dependency that expects an `Authorization: Bearer <token>` header. | |
31 | +2. `Security(oauth2_scheme)` tells JustAPI this is a security requirement — it appears in the OpenAPI docs with an "Authorize" button. | |
32 | +3. The `token` parameter receives the raw token string from the header. | |
33 | + | |
34 | +If the request has no `Authorization` header, JustAPI returns a `401` error automatically — no code needed. | |
35 | + | |
36 | +### Try it | |
37 | + | |
38 | +Start the server and go to `http://localhost:8000/docs`. You'll see an **Authorize** button at the top. | |
39 | + | |
40 | +Click it, enter any value, and then call `/users/me`. The request includes the header automatically. | |
41 | + | |
42 | +Without the header: | |
43 | + | |
44 | +```json | |
45 | +{ | |
46 | + "detail": "Not authenticated" | |
47 | +} | |
48 | +``` | |
49 | + | |
50 | +## Step 2: Validate the Token | |
51 | + | |
52 | +The token is just a string. We need to verify it: | |
10 | 53 | |
11 | 54 | ```python |
12 | -from justapi import JustAPIApp, Security, Depends | |
55 | +from justapi import JustAPIApp, Security, HTTPException | |
13 | 56 | from fastapi.security import OAuth2PasswordBearer |
14 | 57 | |
15 | 58 | app = JustAPIApp() |
59 | +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") | |
60 | + | |
61 | +fake_users_db = { | |
62 | + "alice": {"user_id": 1, "name": "Alice"}, | |
63 | + "bob": {"user_id": 2, "name": "Bob"}, | |
64 | +} | |
65 | + | |
66 | +def get_current_user(token: str = Security(oauth2_scheme)): | |
67 | + if token not in fake_users_db: | |
68 | + raise HTTPException(status_code=401, detail="Invalid token") | |
69 | + return fake_users_db[token] | |
70 | + | |
71 | +@app.get("/users/me") | |
72 | +def read_users_me(current_user: dict = Security(get_current_user)): | |
73 | + return current_user | |
74 | +``` | |
75 | + | |
76 | +Now the flow is: | |
77 | + | |
78 | +1. Client sends `Authorization: Bearer alice` | |
79 | +2. `oauth2_scheme` extracts `"alice"` as the token | |
80 | +3. `get_current_user` looks up `"alice"` in the database | |
81 | +4. If found, returns the user dict — which becomes `current_user` in the handler | |
82 | +5. If not found, raises `401` | |
16 | 83 | |
84 | +:::note | |
85 | +The token `"alice"` is the actual value after `Bearer` in the header. In production this would be a JWT or opaque token. | |
86 | +::: | |
87 | + | |
88 | +## Step 3: Add a Token Endpoint | |
89 | + | |
90 | +The frontend needs a way to log in and get a token. Create a `/token` endpoint: | |
91 | + | |
92 | +```python | |
93 | +from justapi import JustAPIApp, Security, HTTPException | |
94 | +from fastapi.security import OAuth2PasswordBearer | |
95 | +from pydantic import BaseModel | |
96 | + | |
97 | +app = JustAPIApp() | |
17 | 98 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") |
18 | 99 | |
100 | +class TokenRequest(BaseModel): | |
101 | + username: str | |
102 | + password: str | |
103 | + | |
104 | +fake_users_db = { | |
105 | + "alice": {"user_id": 1, "name": "Alice", "password": "secret123"}, | |
106 | + "bob": {"user_id": 2, "name": "Bob", "password": "pass456"}, | |
107 | +} | |
108 | + | |
19 | 109 | def get_current_user(token: str = Security(oauth2_scheme)): |
20 | - # In production, decode the token | |
21 | - return {"user_id": 1, "name": "Alice"} | |
110 | + if token not in fake_users_db: | |
111 | + raise HTTPException(status_code=401, detail="Invalid token") | |
112 | + return fake_users_db[token] | |
113 | + | |
114 | +@app.post("/token") | |
115 | +def login(request: TokenRequest): | |
116 | + user = fake_users_db.get(request.username) | |
117 | + if not user or user["password"] != request.password: | |
118 | + raise HTTPException(status_code=401, detail="Wrong username or password") | |
119 | + return {"access_token": request.username, "token_type": "bearer"} | |
22 | 120 | |
23 | 121 | @app.get("/users/me") |
24 | 122 | def read_users_me(current_user: dict = Security(get_current_user)): |
25 | 123 | return current_user |
26 | 124 | ``` |
27 | 125 | |
28 | -## OAuth2PasswordBearer | |
126 | +### The full flow | |
127 | + | |
128 | +1. Client sends `POST /token` with `{"username": "alice", "password": "secret123"}` | |
129 | +2. Server returns `{"access_token": "alice", "token_type": "bearer"}` | |
130 | +3. Client sends `GET /users/me` with header `Authorization: Bearer alice` | |
131 | +4. Server returns `{"user_id": 1, "name": "Alice"}` | |
132 | + | |
133 | +:::tip | |
134 | +In the Swagger UI (`/docs`), you can now click **Authorize**, enter `alice` / `secret123`, and all subsequent requests will include the token automatically. | |
135 | +::: | |
136 | + | |
137 | +## Step 4: Add User Roles | |
138 | + | |
139 | +Extend the dependency to check permissions: | |
140 | + | |
141 | +```python | |
142 | +def get_current_user(token: str = Security(oauth2_scheme)): | |
143 | + if token not in fake_users_db: | |
144 | + raise HTTPException(status_code=401, detail="Invalid token") | |
145 | + return fake_users_db[token] | |
146 | + | |
147 | +def require_admin(current_user: dict = Security(get_current_user)): | |
148 | + if current_user.get("role") != "admin": | |
149 | + raise HTTPException(status_code=403, detail="Admin access required") | |
150 | + return current_user | |
151 | + | |
152 | +@app.get("/admin/dashboard") | |
153 | +def admin_dashboard(admin: dict = Security(require_admin)): | |
154 | + return {"message": f"Welcome admin {admin['name']}"} | |
155 | +``` | |
156 | + | |
157 | +Now `/admin/dashboard` requires both a valid token AND admin role. | |
158 | + | |
159 | +## What JustAPI Does Under the Hood | |
160 | + | |
161 | +When you use `Security(oauth2_scheme)`: | |
162 | + | |
163 | +1. **Before the request**: JustAPI checks for the `Authorization` header | |
164 | +2. **Missing header**: Returns `401` with `{"detail": "Not authenticated"}` | |
165 | +3. **Invalid header format**: Returns `401` with details about the expected format | |
166 | +4. **Valid header**: Extracts the token and passes it to your dependency | |
167 | +5. **In the docs**: Adds an "Authorize" button to Swagger UI | |
168 | + | |
169 | +## Recap | |
29 | 170 | |
30 | -The `tokenUrl` tells the docs where to send login requests. It appears as an "Authorize" button in Swagger UI. | |
171 | +- `OAuth2PasswordBearer(tokenUrl="token")` creates the auth dependency | |
172 | +- `Security()` marks it as a security requirement in the docs | |
173 | +- Your dependency validates the token and returns the user | |
174 | +- Unauthenticated requests get a `401` automatically | |
175 | +- Chain dependencies for role-based access control | |
31 | 176 | |
32 | 177 | ## See Also |
33 | 178 | |
34 | 179 | - [Get Current User](/tutorials/security/get-current-user/) — extracting user from token |
35 | 180 | - [Simple OAuth2](/tutorials/security/simple-oauth2/) — password-based OAuth2 |
36 | -- [OAuth2 + JWT](/tutorials/security/oauth2-jwt/) — full JWT implementation | |
181 | +- [OAuth2 + JWT Tokens](/tutorials/security/oauth2-jwt/) — production JWT implementation | |
182 | +- [Advanced Security](/advanced/advanced-security/) — OAuth2 scopes, API keys | |
37 | 183 | |
▼docs_site/src/content/docs/tutorials/testing.md
+105-6
| @@ -1,7 +1,7 @@ | ||
1 | 1 | --- |
2 | 2 | title: Testing |
3 | 3 | description: Test JustAPI applications using the built-in JustAPITestClient for unit and integration tests. |
4 | -keywords: [JustAPI, testing, test client, pytest, assertions] | |
4 | +keywords: [JustAPI, testing, test client, pytest, assertions, CRUD] | |
5 | 5 | --- |
6 | 6 | |
7 | 7 | ## JustAPITestClient |
| @@ -21,7 +21,7 @@ client = JustAPITestClient(app) | ||
21 | 21 | |
22 | 22 | def test_hello(): |
23 | 23 | response = client.get("/hello") |
24 | - assert response.status == 200 | |
24 | + assert response.status_code == 200 | |
25 | 25 | assert response.json() == {"message": "Hello!"} |
26 | 26 | ``` |
27 | 27 | |
| @@ -30,20 +30,34 @@ def test_hello(): | ||
30 | 30 | ```python |
31 | 31 | def test_create_item(): |
32 | 32 | response = client.post("/items", body={"name": "Widget", "price": 9.99}) |
33 | - assert response.status == 200 | |
33 | + assert response.status_code == 200 | |
34 | 34 | assert response.json()["name"] == "Widget" |
35 | 35 | ``` |
36 | 36 | |
37 | +## Testing with Headers | |
38 | + | |
39 | +```python | |
40 | +@app.get("/protected") | |
41 | +def protected(x_token: str = Header(...)): | |
42 | + return {"token": x_token} | |
43 | + | |
44 | +def test_with_header(): | |
45 | + response = client.get("/protected", headers={"X-Token": "secret"}) | |
46 | + assert response.status_code == 200 | |
47 | + assert response.json() == {"token": "secret"} | |
48 | +``` | |
49 | + | |
37 | 50 | ## Testing Error Responses |
38 | 51 | |
39 | 52 | ```python |
40 | 53 | def test_not_found(): |
41 | 54 | response = client.get("/nonexistent") |
42 | - assert response.status == 404 | |
55 | + assert response.status_code == 404 | |
43 | 56 | |
44 | 57 | def test_validation_error(): |
45 | 58 | response = client.post("/items", body={"name": "x"}) |
46 | - assert response.status == 422 # validation error | |
59 | + assert response.status_code == 422 | |
60 | + assert "detail" in response.json() | |
47 | 61 | ``` |
48 | 62 | |
49 | 63 | ## Testing with Path Parameters |
| @@ -74,6 +88,78 @@ def test_search_with_custom_limit(): | ||
74 | 88 | assert response.json() == {"q": "foo", "limit": 5} |
75 | 89 | ``` |
76 | 90 | |
91 | +## Full CRUD Example | |
92 | + | |
93 | +Here's a complete example with a mini CRUD API and tests: | |
94 | + | |
95 | +```python | |
96 | +from justapi import JustAPIApp, JustAPITestClient, HTTPException | |
97 | + | |
98 | +app = JustAPIApp() | |
99 | +items_db = {} | |
100 | + | |
101 | +@app.post("/items", status_code=201) | |
102 | +def create_item(item: dict): | |
103 | + item_id = len(items_db) + 1 | |
104 | + items_db[item_id] = item | |
105 | + return {"id": item_id, **item} | |
106 | + | |
107 | +@app.get("/items/{item_id}") | |
108 | +def get_item(item_id: int): | |
109 | + if item_id not in items_db: | |
110 | + raise HTTPException(status_code=404, detail="Not found") | |
111 | + return {"id": item_id, **items_db[item_id]} | |
112 | + | |
113 | +@app.get("/items") | |
114 | +def list_items(): | |
115 | + return [{"id": k, **v} for k, v in items_db.items()] | |
116 | + | |
117 | +@app.delete("/items/{item_id}", status_code=204) | |
118 | +def delete_item(item_id: int): | |
119 | + if item_id not in items_db: | |
120 | + raise HTTPException(status_code=404, detail="Not found") | |
121 | + del items_db[item_id] | |
122 | +``` | |
123 | + | |
124 | +Tests: | |
125 | + | |
126 | +```python | |
127 | +client = JustAPITestClient(app) | |
128 | + | |
129 | +def test_create_and_get(): | |
130 | + # Create | |
131 | + response = client.post("/items", body={"name": "Widget", "price": 9.99}) | |
132 | + assert response.status_code == 201 | |
133 | + item_id = response.json()["id"] | |
134 | + | |
135 | + # Get | |
136 | + response = client.get(f"/items/{item_id}") | |
137 | + assert response.status_code == 200 | |
138 | + assert response.json()["name"] == "Widget" | |
139 | + | |
140 | +def test_list_items(): | |
141 | + response = client.get("/items") | |
142 | + assert response.status_code == 200 | |
143 | + assert isinstance(response.json(), list) | |
144 | + | |
145 | +def test_get_nonexistent(): | |
146 | + response = client.get("/items/99999") | |
147 | + assert response.status_code == 404 | |
148 | + | |
149 | +def test_delete_item(): | |
150 | + # Create first | |
151 | + response = client.post("/items", body={"name": "To Delete"}) | |
152 | + item_id = response.json()["id"] | |
153 | + | |
154 | + # Delete | |
155 | + response = client.delete(f"/items/{item_id}") | |
156 | + assert response.status_code == 204 | |
157 | + | |
158 | + # Confirm deleted | |
159 | + response = client.get(f"/items/{item_id}") | |
160 | + assert response.status_code == 404 | |
161 | +``` | |
162 | + | |
77 | 163 | ## Running Tests |
78 | 164 | |
79 | 165 | ```bash |
| @@ -85,10 +171,23 @@ uv run pytest tests/ -v | ||
85 | 171 | |
86 | 172 | # Single test file |
87 | 173 | pytest tests/test_app.py -v |
174 | + | |
175 | +# Run with coverage | |
176 | +pytest --cov=myapp tests/ | |
177 | +``` | |
178 | + | |
179 | +## Test File Layout | |
180 | + | |
181 | +``` | |
182 | +tests/ | |
183 | +├── test_items.py | |
184 | +├── test_users.py | |
185 | +├── test_auth.py | |
186 | +└── conftest.py | |
88 | 187 | ``` |
89 | 188 | |
90 | 189 | ## See Also |
91 | 190 | |
92 | 191 | - [Testing Client API](/api-reference/testing-client/) — full client reference |
93 | 192 | - [Debugging](/tutorials/debugging/) — debugging techniques |
94 | -- [Testing Guide](/contributing/testing-guide/) — writing and running tests | |
193 | +- [Testing a Database](/how-to/testing-database/) — database testing patterns | |
95 | 194 | |