Compare commits

..

1 Commits

Author SHA1 Message Date
Marcelo Trylesinski
c07ff9234a Add realm to HTTP Basic Authentication 2025-03-20 11:10:41 +00:00
18 changed files with 18 additions and 476 deletions

View File

@@ -7,15 +7,8 @@ hide:
## Latest Changes
## 0.115.12
### Fixes
* 🐛 Fix `convert_underscores=False` for header Pydantic models. PR [#13515](https://github.com/fastapi/fastapi/pull/13515) by [@tiangolo](https://github.com/tiangolo).
### Docs
* 📝 Update `docs/en/docs/tutorial/middleware.md`. PR [#13444](https://github.com/fastapi/fastapi/pull/13444) by [@Rishat-F](https://github.com/Rishat-F).
* 👥 Update FastAPI People - Experts. PR [#13493](https://github.com/fastapi/fastapi/pull/13493) by [@tiangolo](https://github.com/tiangolo).
### Translations

View File

@@ -51,22 +51,6 @@ For example, if the client tries to send a `tool` header with a value of `plumbu
}
```
## Disable Convert Underscores
The same way as with regular header parameters, when you have underscore characters in the parameter names, they are **automatically converted to hyphens**.
For example, if you have a header parameter `save_data` in the code, the expected HTTP header will be `save-data`, and it will show up like that in the docs.
If for some reason you need to disable this automatic conversion, you can do it as well for Pydantic models for header parameters.
{* ../../docs_src/header_param_models/tutorial003_an_py310.py hl[19] *}
/// warning
Before setting `convert_underscores` to `False`, bear in mind that some HTTP proxies and servers disallow the usage of headers with underscores.
///
## Summary
You can use **Pydantic models** to declare **headers** in **FastAPI**. 😎

View File

@@ -15,7 +15,7 @@ A "middleware" is a function that works with every **request** before it is proc
If you have dependencies with `yield`, the exit code will run *after* the middleware.
If there were any background tasks (covered in the [Background Tasks](background-tasks.md){.internal-link target=_blank} section, you will see it later), they will run *after* all the middleware.
If there were any background tasks (documented later), they will run *after* all the middleware.
///

View File

@@ -1,19 +0,0 @@
from typing import List, Union
from fastapi import FastAPI, Header
from pydantic import BaseModel
app = FastAPI()
class CommonHeaders(BaseModel):
host: str
save_data: bool
if_modified_since: Union[str, None] = None
traceparent: Union[str, None] = None
x_tag: List[str] = []
@app.get("/items/")
async def read_items(headers: CommonHeaders = Header(convert_underscores=False)):
return headers

View File

@@ -1,22 +0,0 @@
from typing import List, Union
from fastapi import FastAPI, Header
from pydantic import BaseModel
from typing_extensions import Annotated
app = FastAPI()
class CommonHeaders(BaseModel):
host: str
save_data: bool
if_modified_since: Union[str, None] = None
traceparent: Union[str, None] = None
x_tag: List[str] = []
@app.get("/items/")
async def read_items(
headers: Annotated[CommonHeaders, Header(convert_underscores=False)],
):
return headers

View File

@@ -1,21 +0,0 @@
from typing import Annotated
from fastapi import FastAPI, Header
from pydantic import BaseModel
app = FastAPI()
class CommonHeaders(BaseModel):
host: str
save_data: bool
if_modified_since: str | None = None
traceparent: str | None = None
x_tag: list[str] = []
@app.get("/items/")
async def read_items(
headers: Annotated[CommonHeaders, Header(convert_underscores=False)],
):
return headers

View File

@@ -1,21 +0,0 @@
from typing import Annotated, Union
from fastapi import FastAPI, Header
from pydantic import BaseModel
app = FastAPI()
class CommonHeaders(BaseModel):
host: str
save_data: bool
if_modified_since: Union[str, None] = None
traceparent: Union[str, None] = None
x_tag: list[str] = []
@app.get("/items/")
async def read_items(
headers: Annotated[CommonHeaders, Header(convert_underscores=False)],
):
return headers

View File

@@ -1,17 +0,0 @@
from fastapi import FastAPI, Header
from pydantic import BaseModel
app = FastAPI()
class CommonHeaders(BaseModel):
host: str
save_data: bool
if_modified_since: str | None = None
traceparent: str | None = None
x_tag: list[str] = []
@app.get("/items/")
async def read_items(headers: CommonHeaders = Header(convert_underscores=False)):
return headers

View File

@@ -1,19 +0,0 @@
from typing import Union
from fastapi import FastAPI, Header
from pydantic import BaseModel
app = FastAPI()
class CommonHeaders(BaseModel):
host: str
save_data: bool
if_modified_since: Union[str, None] = None
traceparent: Union[str, None] = None
x_tag: list[str] = []
@app.get("/items/")
async def read_items(headers: CommonHeaders = Header(convert_underscores=False)):
return headers

View File

@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.115.12"
__version__ = "0.115.11"
from starlette import status as status

View File

@@ -750,15 +750,9 @@ def request_params_to_args(
first_field = fields[0]
fields_to_extract = fields
single_not_embedded_field = False
default_convert_underscores = True
if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel):
fields_to_extract = get_cached_model_fields(first_field.type_)
single_not_embedded_field = True
# If headers are in a Pydantic model, the way to disable convert_underscores
# would be with Header(convert_underscores=False) at the Pydantic model level
default_convert_underscores = getattr(
first_field.field_info, "convert_underscores", True
)
params_to_process: Dict[str, Any] = {}
@@ -769,9 +763,7 @@ def request_params_to_args(
if isinstance(received_params, Headers):
# Handle fields extracted from a Pydantic Model for a header, each field
# doesn't have a FieldInfo of type Header with the default convert_underscores=True
convert_underscores = getattr(
field.field_info, "convert_underscores", default_convert_underscores
)
convert_underscores = getattr(field.field_info, "convert_underscores", True)
if convert_underscores:
alias = (
field.alias

View File

@@ -32,7 +32,6 @@ from fastapi.utils import (
generate_operation_id_for_path,
is_body_allowed_for_status_code,
)
from pydantic import BaseModel
from starlette.responses import JSONResponse
from starlette.routing import BaseRoute
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
@@ -114,13 +113,6 @@ def _get_openapi_operation_parameters(
(ParamTypes.header, header_params),
(ParamTypes.cookie, cookie_params),
]
default_convert_underscores = True
if len(flat_dependant.header_params) == 1:
first_field = flat_dependant.header_params[0]
if lenient_issubclass(first_field.type_, BaseModel):
default_convert_underscores = getattr(
first_field.field_info, "convert_underscores", True
)
for param_type, param_group in parameter_groups:
for param in param_group:
field_info = param.field_info
@@ -134,21 +126,8 @@ def _get_openapi_operation_parameters(
field_mapping=field_mapping,
separate_input_output_schemas=separate_input_output_schemas,
)
name = param.alias
convert_underscores = getattr(
param.field_info,
"convert_underscores",
default_convert_underscores,
)
if (
param_type == ParamTypes.header
and param.alias == param.name
and convert_underscores
):
name = param.name.replace("_", "-")
parameter = {
"name": name,
"name": param.alias,
"in": param_type.value,
"required": param.required,
"schema": param_schema,

View File

@@ -181,7 +181,7 @@ class HTTPBasic(HTTPBase):
):
self.model = HTTPBaseModel(scheme="basic", description=description)
self.scheme_name = scheme_name or self.__class__.__name__
self.realm = realm
self.realm = realm or ""
self.auto_error = auto_error
async def __call__( # type: ignore
@@ -189,10 +189,8 @@ class HTTPBasic(HTTPBase):
) -> Optional[HTTPBasicCredentials]:
authorization = request.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(authorization)
if self.realm:
unauthorized_headers = {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
else:
unauthorized_headers = {"WWW-Authenticate": "Basic"}
# The "realm" is required, as per https://datatracker.ietf.org/doc/html/rfc7617#section-2.
unauthorized_headers = {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
if not authorization or scheme.lower() != "basic":
if self.auto_error:
raise HTTPException(

View File

@@ -37,7 +37,7 @@ def test_security_http_basic_invalid_credentials():
"/users/me", headers={"Authorization": "Basic notabase64token"}
)
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
assert response.headers["WWW-Authenticate"] == 'Basic realm=""'
assert response.json() == {"detail": "Invalid authentication credentials"}
@@ -46,7 +46,7 @@ def test_security_http_basic_non_basic_credentials():
auth_header = f"Basic {payload}"
response = client.get("/users/me", headers={"Authorization": auth_header})
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
assert response.headers["WWW-Authenticate"] == 'Basic realm=""'
assert response.json() == {"detail": "Invalid authentication credentials"}

View File

@@ -129,13 +129,13 @@ def test_openapi_schema(client: TestClient):
"schema": {"type": "string", "title": "Host"},
},
{
"name": "save-data",
"name": "save_data",
"in": "header",
"required": True,
"schema": {"type": "boolean", "title": "Save Data"},
},
{
"name": "if-modified-since",
"name": "if_modified_since",
"in": "header",
"required": False,
"schema": IsDict(
@@ -171,7 +171,7 @@ def test_openapi_schema(client: TestClient):
),
},
{
"name": "x-tag",
"name": "x_tag",
"in": "header",
"required": False,
"schema": {

View File

@@ -140,13 +140,13 @@ def test_openapi_schema(client: TestClient):
"schema": {"type": "string", "title": "Host"},
},
{
"name": "save-data",
"name": "save_data",
"in": "header",
"required": True,
"schema": {"type": "boolean", "title": "Save Data"},
},
{
"name": "if-modified-since",
"name": "if_modified_since",
"in": "header",
"required": False,
"schema": IsDict(
@@ -182,7 +182,7 @@ def test_openapi_schema(client: TestClient):
),
},
{
"name": "x-tag",
"name": "x_tag",
"in": "header",
"required": False,
"schema": {

View File

@@ -1,285 +0,0 @@
import importlib
import pytest
from dirty_equals import IsDict
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from tests.utils import needs_py39, needs_py310
@pytest.fixture(
name="client",
params=[
"tutorial003",
pytest.param("tutorial003_py39", marks=needs_py39),
pytest.param("tutorial003_py310", marks=needs_py310),
"tutorial003_an",
pytest.param("tutorial003_an_py39", marks=needs_py39),
pytest.param("tutorial003_an_py310", marks=needs_py310),
],
)
def get_client(request: pytest.FixtureRequest):
mod = importlib.import_module(f"docs_src.header_param_models.{request.param}")
client = TestClient(mod.app)
return client
def test_header_param_model(client: TestClient):
response = client.get(
"/items/",
headers=[
("save_data", "true"),
("if_modified_since", "yesterday"),
("traceparent", "123"),
("x_tag", "one"),
("x_tag", "two"),
],
)
assert response.status_code == 200
assert response.json() == {
"host": "testserver",
"save_data": True,
"if_modified_since": "yesterday",
"traceparent": "123",
"x_tag": ["one", "two"],
}
def test_header_param_model_no_underscore(client: TestClient):
response = client.get(
"/items/",
headers=[
("save-data", "true"),
("if-modified-since", "yesterday"),
("traceparent", "123"),
("x-tag", "one"),
("x-tag", "two"),
],
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
IsDict(
{
"type": "missing",
"loc": ["header", "save_data"],
"msg": "Field required",
"input": {
"host": "testserver",
"traceparent": "123",
"x_tag": [],
"accept": "*/*",
"accept-encoding": "gzip, deflate",
"connection": "keep-alive",
"user-agent": "testclient",
"save-data": "true",
"if-modified-since": "yesterday",
"x-tag": "two",
},
}
)
| IsDict(
# TODO: remove when deprecating Pydantic v1
{
"type": "value_error.missing",
"loc": ["header", "save_data"],
"msg": "field required",
}
)
]
}
)
def test_header_param_model_defaults(client: TestClient):
response = client.get("/items/", headers=[("save_data", "true")])
assert response.status_code == 200
assert response.json() == {
"host": "testserver",
"save_data": True,
"if_modified_since": None,
"traceparent": None,
"x_tag": [],
}
def test_header_param_model_invalid(client: TestClient):
response = client.get("/items/")
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
IsDict(
{
"type": "missing",
"loc": ["header", "save_data"],
"msg": "Field required",
"input": {
"x_tag": [],
"host": "testserver",
"accept": "*/*",
"accept-encoding": "gzip, deflate",
"connection": "keep-alive",
"user-agent": "testclient",
},
}
)
| IsDict(
# TODO: remove when deprecating Pydantic v1
{
"type": "value_error.missing",
"loc": ["header", "save_data"],
"msg": "field required",
}
)
]
}
)
def test_header_param_model_extra(client: TestClient):
response = client.get(
"/items/", headers=[("save_data", "true"), ("tool", "plumbus")]
)
assert response.status_code == 200, response.text
assert response.json() == snapshot(
{
"host": "testserver",
"save_data": True,
"if_modified_since": None,
"traceparent": None,
"x_tag": [],
}
)
def test_openapi_schema(client: TestClient):
response = client.get("/openapi.json")
assert response.status_code == 200, response.text
assert response.json() == snapshot(
{
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/items/": {
"get": {
"summary": "Read Items",
"operationId": "read_items_items__get",
"parameters": [
{
"name": "host",
"in": "header",
"required": True,
"schema": {"type": "string", "title": "Host"},
},
{
"name": "save_data",
"in": "header",
"required": True,
"schema": {"type": "boolean", "title": "Save Data"},
},
{
"name": "if_modified_since",
"in": "header",
"required": False,
"schema": IsDict(
{
"anyOf": [{"type": "string"}, {"type": "null"}],
"title": "If Modified Since",
}
)
| IsDict(
# TODO: remove when deprecating Pydantic v1
{
"type": "string",
"title": "If Modified Since",
}
),
},
{
"name": "traceparent",
"in": "header",
"required": False,
"schema": IsDict(
{
"anyOf": [{"type": "string"}, {"type": "null"}],
"title": "Traceparent",
}
)
| IsDict(
# TODO: remove when deprecating Pydantic v1
{
"type": "string",
"title": "Traceparent",
}
),
},
{
"name": "x_tag",
"in": "header",
"required": False,
"schema": {
"type": "array",
"items": {"type": "string"},
"default": [],
"title": "X Tag",
},
},
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
}
},
"components": {
"schemas": {
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail",
}
},
"type": "object",
"title": "HTTPValidationError",
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [{"type": "string"}, {"type": "integer"}]
},
"type": "array",
"title": "Location",
},
"msg": {"type": "string", "title": "Message"},
"type": {"type": "string", "title": "Error Type"},
},
"type": "object",
"required": ["loc", "msg", "type"],
"title": "ValidationError",
},
}
},
}
)

View File

@@ -32,7 +32,7 @@ def test_security_http_basic_no_credentials(client: TestClient):
response = client.get("/users/me")
assert response.json() == {"detail": "Not authenticated"}
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
assert response.headers["WWW-Authenticate"] == 'Basic realm=""'
def test_security_http_basic_invalid_credentials(client: TestClient):
@@ -40,7 +40,7 @@ def test_security_http_basic_invalid_credentials(client: TestClient):
"/users/me", headers={"Authorization": "Basic notabase64token"}
)
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
assert response.headers["WWW-Authenticate"] == 'Basic realm=""'
assert response.json() == {"detail": "Invalid authentication credentials"}
@@ -49,7 +49,7 @@ def test_security_http_basic_non_basic_credentials(client: TestClient):
auth_header = f"Basic {payload}"
response = client.get("/users/me", headers={"Authorization": auth_header})
assert response.status_code == 401, response.text
assert response.headers["WWW-Authenticate"] == "Basic"
assert response.headers["WWW-Authenticate"] == 'Basic realm=""'
assert response.json() == {"detail": "Invalid authentication credentials"}