From f42fd9aac2529c1bda6b81fe9cecce0986dadbf3 Mon Sep 17 00:00:00 2001 From: Shubhendra Kushwaha Date: Tue, 3 Sep 2024 21:35:19 +0530 Subject: [PATCH 01/20] =?UTF-8?q?=F0=9F=93=9D=20Add=20External=20Link:=20T?= =?UTF-8?q?echniques=20and=20applications=20of=20SQLAlchemy=20global=20fil?= =?UTF-8?q?ters=20in=20FastAPI=20(#12109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/data/external_links.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/en/data/external_links.yml b/docs/en/data/external_links.yml index 15f6169ee..63fd3d0cf 100644 --- a/docs/en/data/external_links.yml +++ b/docs/en/data/external_links.yml @@ -264,6 +264,14 @@ Articles: author_link: https://devonray.com link: https://devonray.com/blog/deploying-a-fastapi-project-using-aws-lambda-aurora-cdk title: Deployment using Docker, Lambda, Aurora, CDK & GH Actions + - author: Shubhendra Kushwaha + author_link: https://www.linkedin.com/in/theshubhendra/ + link: https://theshubhendra.medium.com/mastering-soft-delete-advanced-sqlalchemy-techniques-4678f4738947 + title: 'Mastering Soft Delete: Advanced SQLAlchemy Techniques' + - author: Shubhendra Kushwaha + author_link: https://www.linkedin.com/in/theshubhendra/ + link: https://theshubhendra.medium.com/role-based-row-filtering-advanced-sqlalchemy-techniques-733e6b1328f6 + title: 'Role based row filtering: Advanced SQLAlchemy Techniques' German: - author: Marcel Sander (actidoo) author_link: https://www.actidoo.com From 9b2a9333b3b9820082deb35605c0619bd578baee Mon Sep 17 00:00:00 2001 From: github-actions Date: Tue, 3 Sep 2024 16:05:42 +0000 Subject: [PATCH 02/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index df4927752..70dbce539 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -17,6 +17,7 @@ hide: ### Docs +* 📝 Add External Link: Techniques and applications of SQLAlchemy global filters in FastAPI. PR [#12109](https://github.com/fastapi/fastapi/pull/12109) by [@TheShubhendra](https://github.com/TheShubhendra). * 📝 Add note about `time.perf_counter()` in middlewares. PR [#12095](https://github.com/fastapi/fastapi/pull/12095) by [@tiangolo](https://github.com/tiangolo). * 📝 Tweak middleware code sample `time.time()` to `time.perf_counter()`. PR [#11957](https://github.com/fastapi/fastapi/pull/11957) by [@domdent](https://github.com/domdent). * 🔧 Update sponsors: Coherence. PR [#12093](https://github.com/fastapi/fastapi/pull/12093) by [@tiangolo](https://github.com/tiangolo). From 1f64a1bb551829b57f0b8403ce4aab641a6ee11d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:07:32 +0200 Subject: [PATCH 03/20] =?UTF-8?q?=E2=AC=86=20[pre-commit.ci]=20pre-commit?= =?UTF-8?q?=20autoupdate=20(#12115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ⬆ [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.2 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.2...v0.6.3) * bump ruff as well --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sofie Van Landeghem Co-authored-by: svlandeg --- .pre-commit-config.yaml | 2 +- requirements-tests.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 317514062..7e58afd4b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.2 + rev: v0.6.3 hooks: - id: ruff args: diff --git a/requirements-tests.txt b/requirements-tests.txt index 08561d23a..de5fdb8a2 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -3,7 +3,7 @@ pytest >=7.1.3,<8.0.0 coverage[toml] >= 6.5.0,< 8.0 mypy ==1.8.0 -ruff ==0.6.1 +ruff ==0.6.3 dirty-equals ==0.6.0 # TODO: once removing databases from tutorial, upgrade SQLAlchemy # probably when including SQLModel From 68e5ef6968f8a2799d9db927b5db5b8e86d8b5f0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 5 Sep 2024 09:07:55 +0000 Subject: [PATCH 04/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 70dbce539..c54971b73 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -39,6 +39,7 @@ hide: ### Internal +* ⬆ [pre-commit.ci] pre-commit autoupdate. PR [#12115](https://github.com/fastapi/fastapi/pull/12115) by [@pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci). * ⬆ Bump pypa/gh-action-pypi-publish from 1.10.0 to 1.10.1. PR [#12120](https://github.com/fastapi/fastapi/pull/12120) by [@dependabot[bot]](https://github.com/apps/dependabot). * ⬆ Bump pillow from 10.3.0 to 10.4.0. PR [#12105](https://github.com/fastapi/fastapi/pull/12105) by [@dependabot[bot]](https://github.com/apps/dependabot). * 💚 Set `include-hidden-files` to `True` when using the `upload-artifact` GH action. PR [#12118](https://github.com/fastapi/fastapi/pull/12118) by [@svlandeg](https://github.com/svlandeg). From 7213d421f5bf0a4b9b8815e69a141550b4fc3f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 11:13:32 +0200 Subject: [PATCH 05/20] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.112.?= =?UTF-8?q?3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 ++++ fastapi/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index c54971b73..cdd6cdc90 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +## 0.112.3 + +This release is mainly internal refactors, it shouldn't affect apps using FastAPI in any way. You don't even have to upgrade to this version yet. There are a few bigger releases coming right after. 🚀 + ### Refactors * ♻️ Refactor internal `check_file_field()`, rename to `ensure_multipart_is_installed()` to clarify its purpose. PR [#12106](https://github.com/fastapi/fastapi/pull/12106) by [@tiangolo](https://github.com/tiangolo). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index ac2508d89..1bc1bfd82 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.112.2" +__version__ = "0.112.3" from starlette import status as status From aa21814a89853c17c139054a5c51f0bb1ea68a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 13:24:36 +0200 Subject: [PATCH 06/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20deciding?= =?UTF-8?q?=20if=20`embed`=20body=20fields,=20do=20not=20overwrite=20field?= =?UTF-8?q?s,=20compute=20once=20per=20router,=20refactor=20internals=20in?= =?UTF-8?q?=20preparation=20for=20Pydantic=20models=20in=20`Form`,=20`Quer?= =?UTF-8?q?y`=20and=20others=20(#12117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/_compat.py | 15 ++ fastapi/dependencies/utils.py | 300 ++++++++++++++++++------------- fastapi/param_functions.py | 4 +- fastapi/params.py | 3 +- fastapi/routing.py | 26 ++- tests/test_compat.py | 13 +- tests/test_forms_single_param.py | 99 ++++++++++ 7 files changed, 324 insertions(+), 136 deletions(-) create mode 100644 tests/test_forms_single_param.py diff --git a/fastapi/_compat.py b/fastapi/_compat.py index 06b847b4f..f940d6597 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -279,6 +279,12 @@ if PYDANTIC_V2: BodyModel: Type[BaseModel] = create_model(model_name, **field_params) # type: ignore[call-overload] return BodyModel + def get_model_fields(model: Type[BaseModel]) -> List[ModelField]: + return [ + ModelField(field_info=field_info, name=name) + for name, field_info in model.model_fields.items() + ] + else: from fastapi.openapi.constants import REF_PREFIX as REF_PREFIX from pydantic import AnyUrl as Url # noqa: F401 @@ -513,6 +519,9 @@ else: BodyModel.__fields__[f.name] = f # type: ignore[index] return BodyModel + def get_model_fields(model: Type[BaseModel]) -> List[ModelField]: + return list(model.__fields__.values()) # type: ignore[attr-defined] + def _regenerate_error_with_loc( *, errors: Sequence[Any], loc_prefix: Tuple[Union[str, int], ...] @@ -532,6 +541,12 @@ def _annotation_is_sequence(annotation: Union[Type[Any], None]) -> bool: def field_annotation_is_sequence(annotation: Union[Type[Any], None]) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + for arg in get_args(annotation): + if field_annotation_is_sequence(arg): + return True + return False return _annotation_is_sequence(annotation) or _annotation_is_sequence( get_origin(annotation) ) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 0dcba62f1..7ac18d941 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -59,7 +59,13 @@ from fastapi.utils import create_model_field, get_path_param_names from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks as StarletteBackgroundTasks from starlette.concurrency import run_in_threadpool -from starlette.datastructures import FormData, Headers, QueryParams, UploadFile +from starlette.datastructures import ( + FormData, + Headers, + ImmutableMultiDict, + QueryParams, + UploadFile, +) from starlette.requests import HTTPConnection, Request from starlette.responses import Response from starlette.websockets import WebSocket @@ -282,7 +288,7 @@ def get_dependant( ), f"Cannot specify multiple FastAPI annotations for {param_name!r}" continue assert param_details.field is not None - if is_body_param(param_field=param_details.field, is_path_param=is_path_param): + if isinstance(param_details.field.field_info, params.Body): dependant.body_params.append(param_details.field) else: add_param_to_fields(field=param_details.field, dependant=dependant) @@ -466,29 +472,16 @@ def analyze_param( required=field_info.default in (Required, Undefined), field_info=field_info, ) + if is_path_param: + assert is_scalar_field( + field=field + ), "Path params must be of one of the supported types" + elif isinstance(field_info, params.Query): + assert is_scalar_field(field) or is_scalar_sequence_field(field) return ParamDetails(type_annotation=type_annotation, depends=depends, field=field) -def is_body_param(*, param_field: ModelField, is_path_param: bool) -> bool: - if is_path_param: - assert is_scalar_field( - field=param_field - ), "Path params must be of one of the supported types" - return False - elif is_scalar_field(field=param_field): - return False - elif isinstance( - param_field.field_info, (params.Query, params.Header) - ) and is_scalar_sequence_field(param_field): - return False - else: - assert isinstance( - param_field.field_info, params.Body - ), f"Param: {param_field.name} can only be a request body, using Body()" - return True - - def add_param_to_fields(*, field: ModelField, dependant: Dependant) -> None: field_info = field.field_info field_info_in = getattr(field_info, "in_", None) @@ -557,6 +550,7 @@ async def solve_dependencies( dependency_overrides_provider: Optional[Any] = None, dependency_cache: Optional[Dict[Tuple[Callable[..., Any], Tuple[str]], Any]] = None, async_exit_stack: AsyncExitStack, + embed_body_fields: bool, ) -> SolvedDependency: values: Dict[str, Any] = {} errors: List[Any] = [] @@ -598,6 +592,7 @@ async def solve_dependencies( dependency_overrides_provider=dependency_overrides_provider, dependency_cache=dependency_cache, async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, ) background_tasks = solved_result.background_tasks dependency_cache.update(solved_result.dependency_cache) @@ -640,7 +635,9 @@ async def solve_dependencies( body_values, body_errors, ) = await request_body_to_args( # body_params checked above - required_params=dependant.body_params, received_body=body + body_fields=dependant.body_params, + received_body=body, + embed_body_fields=embed_body_fields, ) values.update(body_values) errors.extend(body_errors) @@ -669,138 +666,185 @@ async def solve_dependencies( ) +def _validate_value_with_model_field( + *, field: ModelField, value: Any, values: Dict[str, Any], loc: Tuple[str, ...] +) -> Tuple[Any, List[Any]]: + if value is None: + if field.required: + return None, [get_missing_field_error(loc=loc)] + else: + return deepcopy(field.default), [] + v_, errors_ = field.validate(value, values, loc=loc) + if isinstance(errors_, ErrorWrapper): + return None, [errors_] + elif isinstance(errors_, list): + new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=()) + return None, new_errors + else: + return v_, [] + + +def _get_multidict_value(field: ModelField, values: Mapping[str, Any]) -> Any: + if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): + value = values.getlist(field.alias) + else: + value = values.get(field.alias, None) + if ( + value is None + or ( + isinstance(field.field_info, params.Form) + and isinstance(value, str) # For type checks + and value == "" + ) + or (is_sequence_field(field) and len(value) == 0) + ): + if field.required: + return + else: + return deepcopy(field.default) + return value + + def request_params_to_args( - required_params: Sequence[ModelField], + fields: Sequence[ModelField], received_params: Union[Mapping[str, Any], QueryParams, Headers], ) -> Tuple[Dict[str, Any], List[Any]]: - values = {} + values: Dict[str, Any] = {} errors = [] - for field in required_params: - if is_scalar_sequence_field(field) and isinstance( - received_params, (QueryParams, Headers) - ): - value = received_params.getlist(field.alias) or field.default - else: - value = received_params.get(field.alias) + for field in fields: + value = _get_multidict_value(field, received_params) field_info = field.field_info assert isinstance( field_info, params.Param ), "Params must be subclasses of Param" loc = (field_info.in_.value, field.alias) - if value is None: - if field.required: - errors.append(get_missing_field_error(loc=loc)) - else: - values[field.name] = deepcopy(field.default) - continue - v_, errors_ = field.validate(value, values, loc=loc) - if isinstance(errors_, ErrorWrapper): - errors.append(errors_) - elif isinstance(errors_, list): - new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=()) - errors.extend(new_errors) + v_, errors_ = _validate_value_with_model_field( + field=field, value=value, values=values, loc=loc + ) + if errors_: + errors.extend(errors_) else: values[field.name] = v_ return values, errors +def _should_embed_body_fields(fields: List[ModelField]) -> bool: + if not fields: + return False + # More than one dependency could have the same field, it would show up as multiple + # fields but it's the same one, so count them by name + body_param_names_set = {field.name for field in fields} + # A top level field has to be a single field, not multiple + if len(body_param_names_set) > 1: + return True + first_field = fields[0] + # If it explicitly specifies it is embedded, it has to be embedded + if getattr(first_field.field_info, "embed", None): + return True + # If it's a Form (or File) field, it has to be a BaseModel to be top level + # otherwise it has to be embedded, so that the key value pair can be extracted + if isinstance(first_field.field_info, params.Form): + return True + return False + + +async def _extract_form_body( + body_fields: List[ModelField], + received_body: FormData, +) -> Dict[str, Any]: + values = {} + first_field = body_fields[0] + first_field_info = first_field.field_info + + for field in body_fields: + value = _get_multidict_value(field, received_body) + if ( + isinstance(first_field_info, params.File) + and is_bytes_field(field) + and isinstance(value, UploadFile) + ): + value = await value.read() + elif ( + is_bytes_sequence_field(field) + and isinstance(first_field_info, params.File) + and value_is_sequence(value) + ): + # For types + assert isinstance(value, sequence_types) # type: ignore[arg-type] + results: List[Union[bytes, str]] = [] + + async def process_fn( + fn: Callable[[], Coroutine[Any, Any, Any]], + ) -> None: + result = await fn() + results.append(result) # noqa: B023 + + async with anyio.create_task_group() as tg: + for sub_value in value: + tg.start_soon(process_fn, sub_value.read) + value = serialize_sequence_value(field=field, value=results) + values[field.name] = value + return values + + async def request_body_to_args( - required_params: List[ModelField], + body_fields: List[ModelField], received_body: Optional[Union[Dict[str, Any], FormData]], + embed_body_fields: bool, ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: - values = {} + values: Dict[str, Any] = {} errors: List[Dict[str, Any]] = [] - if required_params: - field = required_params[0] - field_info = field.field_info - embed = getattr(field_info, "embed", None) - field_alias_omitted = len(required_params) == 1 and not embed - if field_alias_omitted: - received_body = {field.alias: received_body} - - for field in required_params: - loc: Tuple[str, ...] - if field_alias_omitted: - loc = ("body",) - else: - loc = ("body", field.alias) - - value: Optional[Any] = None - if received_body is not None: - if (is_sequence_field(field)) and isinstance(received_body, FormData): - value = received_body.getlist(field.alias) - else: - try: - value = received_body.get(field.alias) - except AttributeError: - errors.append(get_missing_field_error(loc)) - continue - if ( - value is None - or (isinstance(field_info, params.Form) and value == "") - or ( - isinstance(field_info, params.Form) - and is_sequence_field(field) - and len(value) == 0 - ) - ): - if field.required: - errors.append(get_missing_field_error(loc)) - else: - values[field.name] = deepcopy(field.default) + assert body_fields, "request_body_to_args() should be called with fields" + single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields + first_field = body_fields[0] + body_to_process = received_body + if isinstance(received_body, FormData): + body_to_process = await _extract_form_body(body_fields, received_body) + + if single_not_embedded_field: + loc: Tuple[str, ...] = ("body",) + v_, errors_ = _validate_value_with_model_field( + field=first_field, value=body_to_process, values=values, loc=loc + ) + return {first_field.name: v_}, errors_ + for field in body_fields: + loc = ("body", field.alias) + value: Optional[Any] = None + if body_to_process is not None: + try: + value = body_to_process.get(field.alias) + # If the received body is a list, not a dict + except AttributeError: + errors.append(get_missing_field_error(loc)) continue - if ( - isinstance(field_info, params.File) - and is_bytes_field(field) - and isinstance(value, UploadFile) - ): - value = await value.read() - elif ( - is_bytes_sequence_field(field) - and isinstance(field_info, params.File) - and value_is_sequence(value) - ): - # For types - assert isinstance(value, sequence_types) # type: ignore[arg-type] - results: List[Union[bytes, str]] = [] - - async def process_fn( - fn: Callable[[], Coroutine[Any, Any, Any]], - ) -> None: - result = await fn() - results.append(result) # noqa: B023 - - async with anyio.create_task_group() as tg: - for sub_value in value: - tg.start_soon(process_fn, sub_value.read) - value = serialize_sequence_value(field=field, value=results) - - v_, errors_ = field.validate(value, values, loc=loc) - - if isinstance(errors_, list): - errors.extend(errors_) - elif errors_: - errors.append(errors_) - else: - values[field.name] = v_ + v_, errors_ = _validate_value_with_model_field( + field=field, value=value, values=values, loc=loc + ) + if errors_: + errors.extend(errors_) + else: + values[field.name] = v_ return values, errors -def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: - flat_dependant = get_flat_dependant(dependant) +def get_body_field( + *, flat_dependant: Dependant, name: str, embed_body_fields: bool +) -> Optional[ModelField]: + """ + Get a ModelField representing the request body for a path operation, combining + all body parameters into a single field if necessary. + + Used to check if it's form data (with `isinstance(body_field, params.Form)`) + or JSON and to generate the JSON Schema for a request body. + + This is **not** used to validate/parse the request body, that's done with each + individual body parameter. + """ if not flat_dependant.body_params: return None first_param = flat_dependant.body_params[0] - field_info = first_param.field_info - embed = getattr(field_info, "embed", None) - body_param_names_set = {param.name for param in flat_dependant.body_params} - if len(body_param_names_set) == 1 and not embed: + if not embed_body_fields: return first_param - # If one field requires to embed, all have to be embedded - # in case a sub-dependency is evaluated with a single unique body field - # That is combined (embedded) with other body fields - for param in flat_dependant.body_params: - setattr(param.field_info, "embed", True) # noqa: B010 model_name = "Body_" + name BodyModel = create_body_model( fields=flat_dependant.body_params, model_name=model_name diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index 0d5f27af4..7ddaace25 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -1282,7 +1282,7 @@ def Body( # noqa: N802 ), ] = _Unset, embed: Annotated[ - bool, + Union[bool, None], Doc( """ When `embed` is `True`, the parameter will be expected in a JSON body as a @@ -1294,7 +1294,7 @@ def Body( # noqa: N802 [FastAPI docs for Body - Multiple Parameters](https://fastapi.tiangolo.com/tutorial/body-multiple-params/#embed-a-single-body-parameter). """ ), - ] = False, + ] = None, media_type: Annotated[ str, Doc( diff --git a/fastapi/params.py b/fastapi/params.py index cc2a5c13c..3dfa5a1a3 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -479,7 +479,7 @@ class Body(FieldInfo): *, default_factory: Union[Callable[[], Any], None] = _Unset, annotation: Optional[Any] = None, - embed: bool = False, + embed: Union[bool, None] = None, media_type: str = "application/json", alias: Optional[str] = None, alias_priority: Union[int, None] = _Unset, @@ -642,7 +642,6 @@ class Form(Body): default=default, default_factory=default_factory, annotation=annotation, - embed=True, media_type=media_type, alias=alias, alias_priority=alias_priority, diff --git a/fastapi/routing.py b/fastapi/routing.py index 61a112fc4..86e303602 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -33,8 +33,10 @@ from fastapi._compat import ( from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( + _should_embed_body_fields, get_body_field, get_dependant, + get_flat_dependant, get_parameterless_sub_dependant, get_typed_return_annotation, solve_dependencies, @@ -225,6 +227,7 @@ def get_request_handler( response_model_exclude_defaults: bool = False, response_model_exclude_none: bool = False, dependency_overrides_provider: Optional[Any] = None, + embed_body_fields: bool = False, ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" is_coroutine = asyncio.iscoroutinefunction(dependant.call) @@ -291,6 +294,7 @@ def get_request_handler( body=body, dependency_overrides_provider=dependency_overrides_provider, async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, ) errors = solved_result.errors if not errors: @@ -354,7 +358,9 @@ def get_request_handler( def get_websocket_app( - dependant: Dependant, dependency_overrides_provider: Optional[Any] = None + dependant: Dependant, + dependency_overrides_provider: Optional[Any] = None, + embed_body_fields: bool = False, ) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: async def app(websocket: WebSocket) -> None: async with AsyncExitStack() as async_exit_stack: @@ -367,6 +373,7 @@ def get_websocket_app( dependant=dependant, dependency_overrides_provider=dependency_overrides_provider, async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, ) if solved_result.errors: raise WebSocketRequestValidationError( @@ -399,11 +406,15 @@ class APIWebSocketRoute(routing.WebSocketRoute): 0, get_parameterless_sub_dependant(depends=depends, path=self.path_format), ) - + self._flat_dependant = get_flat_dependant(self.dependant) + self._embed_body_fields = _should_embed_body_fields( + self._flat_dependant.body_params + ) self.app = websocket_session( get_websocket_app( dependant=self.dependant, dependency_overrides_provider=dependency_overrides_provider, + embed_body_fields=self._embed_body_fields, ) ) @@ -544,7 +555,15 @@ class APIRoute(routing.Route): 0, get_parameterless_sub_dependant(depends=depends, path=self.path_format), ) - self.body_field = get_body_field(dependant=self.dependant, name=self.unique_id) + self._flat_dependant = get_flat_dependant(self.dependant) + self._embed_body_fields = _should_embed_body_fields( + self._flat_dependant.body_params + ) + self.body_field = get_body_field( + flat_dependant=self._flat_dependant, + name=self.unique_id, + embed_body_fields=self._embed_body_fields, + ) self.app = request_response(self.get_route_handler()) def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: @@ -561,6 +580,7 @@ class APIRoute(routing.Route): response_model_exclude_defaults=self.response_model_exclude_defaults, response_model_exclude_none=self.response_model_exclude_none, dependency_overrides_provider=self.dependency_overrides_provider, + embed_body_fields=self._embed_body_fields, ) def matches(self, scope: Scope) -> Tuple[Match, Scope]: diff --git a/tests/test_compat.py b/tests/test_compat.py index bf268b860..270475bf3 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,11 +1,13 @@ -from typing import List, Union +from typing import Any, Dict, List, Union from fastapi import FastAPI, UploadFile from fastapi._compat import ( ModelField, Undefined, _get_model_config, + get_model_fields, is_bytes_sequence_annotation, + is_scalar_field, is_uploadfile_sequence_annotation, ) from fastapi.testclient import TestClient @@ -91,3 +93,12 @@ def test_is_uploadfile_sequence_annotation(): # and other types, but I'm not even sure it's a good idea to support it as a first # class "feature" assert is_uploadfile_sequence_annotation(Union[List[str], List[UploadFile]]) + + +def test_is_pv1_scalar_field(): + # For coverage + class Model(BaseModel): + foo: Union[str, Dict[str, Any]] + + fields = get_model_fields(Model) + assert not is_scalar_field(fields[0]) diff --git a/tests/test_forms_single_param.py b/tests/test_forms_single_param.py new file mode 100644 index 000000000..3bb951441 --- /dev/null +++ b/tests/test_forms_single_param.py @@ -0,0 +1,99 @@ +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +app = FastAPI() + + +@app.post("/form/") +def post_form(username: Annotated[str, Form()]): + return username + + +client = TestClient(app) + + +def test_single_form_field(): + response = client.post("/form/", data={"username": "Rick"}) + assert response.status_code == 200, response.text + assert response.json() == "Rick" + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/form/": { + "post": { + "summary": "Post Form", + "operationId": "post_form_form__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_post_form_form__post" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "Body_post_form_form__post": { + "properties": {"username": {"type": "string", "title": "Username"}}, + "type": "object", + "required": ["username"], + "title": "Body_post_form_form__post", + }, + "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", + }, + } + }, + } From 832e634fd4b8c4e1a5714b5ad73f2cdc04e05e43 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 5 Sep 2024 11:25:02 +0000 Subject: [PATCH 07/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index cdd6cdc90..2fe884615 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Refactors + +* ♻️ Refactor deciding if `embed` body fields, do not overwrite fields, compute once per router, refactor internals in preparation for Pydantic models in `Form`, `Query` and others. PR [#12117](https://github.com/fastapi/fastapi/pull/12117) by [@tiangolo](https://github.com/tiangolo). + ## 0.112.3 This release is mainly internal refactors, it shouldn't affect apps using FastAPI in any way. You don't even have to upgrade to this version yet. There are a few bigger releases coming right after. 🚀 From 0f3e65b00712a59d763cf9c7715cde353bb94b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 16:40:48 +0200 Subject: [PATCH 08/20] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20Pydanti?= =?UTF-8?q?c=20models=20in=20`Form`=20parameters=20(#12127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tutorial/request-form-models/image01.png | Bin 0 -> 44487 bytes docs/en/docs/tutorial/request-form-models.md | 65 +++++ docs/en/mkdocs.yml | 1 + docs_src/request_form_models/tutorial001.py | 14 + .../request_form_models/tutorial001_an.py | 15 ++ .../tutorial001_an_py39.py | 16 ++ fastapi/dependencies/utils.py | 17 +- .../playwright/request_form_models/image01.py | 36 +++ tests/test_forms_single_model.py | 129 ++++++++++ .../test_request_form_models/__init__.py | 0 .../test_tutorial001.py | 232 +++++++++++++++++ .../test_tutorial001_an.py | 232 +++++++++++++++++ .../test_tutorial001_an_py39.py | 240 ++++++++++++++++++ 13 files changed, 994 insertions(+), 3 deletions(-) create mode 100644 docs/en/docs/img/tutorial/request-form-models/image01.png create mode 100644 docs/en/docs/tutorial/request-form-models.md create mode 100644 docs_src/request_form_models/tutorial001.py create mode 100644 docs_src/request_form_models/tutorial001_an.py create mode 100644 docs_src/request_form_models/tutorial001_an_py39.py create mode 100644 scripts/playwright/request_form_models/image01.py create mode 100644 tests/test_forms_single_model.py create mode 100644 tests/test_tutorial/test_request_form_models/__init__.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py diff --git a/docs/en/docs/img/tutorial/request-form-models/image01.png b/docs/en/docs/img/tutorial/request-form-models/image01.png new file mode 100644 index 0000000000000000000000000000000000000000..3fe32c03d589e76abec5e9c6b71374ebd4e8cd2c GIT binary patch literal 44487 zcmeFZcT|(j_b-b2Dz8{DDj*>68l^Ys(k&DL0RgF@_uhL8ib&{6RjSe3GerJ&n@emd;U6qoOQFZ);yV+XJ+p`lX>?3?7g3`_bT#tDCj82$jI&} zyp>TWBfCz#ygKyvRbugyN%0D?xZ(r=sS4^yATZ0)ghX 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 CMaG{1 literal 0 HcmV?d00001 diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md new file mode 100644 index 000000000..8bb1ffb1f --- /dev/null +++ b/docs/en/docs/tutorial/request-form-models.md @@ -0,0 +1,65 @@ +# Form Models + +You can use Pydantic models to declare form fields in FastAPI. + +/// info + +To use forms, first install `python-multipart`. + +Make sure you create a [virtual environment](../virtual-environments.md){.internal-link target=_blank}, activate it, and then install it, for example: + +```console +$ pip install python-multipart +``` + +/// + +/// note + +This is supported since FastAPI version `0.113.0`. 🤓 + +/// + +## Pydantic Models for Forms + +You just need to declare a Pydantic model with the fields you want to receive as form fields, and then declare the parameter as `Form`: + +//// tab | Python 3.9+ + +```Python hl_lines="9-11 15" +{!> ../../../docs_src/request_form_models/tutorial001_an_py39.py!} +``` + +//// + +//// tab | Python 3.8+ + +```Python hl_lines="8-10 14" +{!> ../../../docs_src/request_form_models/tutorial001_an.py!} +``` + +//// + +//// tab | Python 3.8+ non-Annotated + +/// tip + +Prefer to use the `Annotated` version if possible. + +/// + +```Python hl_lines="7-9 13" +{!> ../../../docs_src/request_form_models/tutorial001.py!} +``` + +//// + +FastAPI will extract the data for each field from the form data in the request and give you the Pydantic model you defined. + +## Check the Docs + +You can verify it in the docs UI at `/docs`: + +
+ +
diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 528c80b8e..7c810c2d7 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -129,6 +129,7 @@ nav: - tutorial/extra-models.md - tutorial/response-status-code.md - tutorial/request-forms.md + - tutorial/request-form-models.md - tutorial/request-files.md - tutorial/request-forms-and-files.md - tutorial/handling-errors.md diff --git a/docs_src/request_form_models/tutorial001.py b/docs_src/request_form_models/tutorial001.py new file mode 100644 index 000000000..98feff0b9 --- /dev/null +++ b/docs_src/request_form_models/tutorial001.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI, Form +from pydantic import BaseModel + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + + +@app.post("/login/") +async def login(data: FormData = Form()): + return data diff --git a/docs_src/request_form_models/tutorial001_an.py b/docs_src/request_form_models/tutorial001_an.py new file mode 100644 index 000000000..30483d445 --- /dev/null +++ b/docs_src/request_form_models/tutorial001_an.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI, Form +from pydantic import BaseModel +from typing_extensions import Annotated + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + + +@app.post("/login/") +async def login(data: Annotated[FormData, Form()]): + return data diff --git a/docs_src/request_form_models/tutorial001_an_py39.py b/docs_src/request_form_models/tutorial001_an_py39.py new file mode 100644 index 000000000..7cc81aae9 --- /dev/null +++ b/docs_src/request_form_models/tutorial001_an_py39.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from fastapi import FastAPI, Form +from pydantic import BaseModel + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + + +@app.post("/login/") +async def login(data: Annotated[FormData, Form()]): + return data diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 7ac18d941..98ce17b55 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -33,6 +33,7 @@ from fastapi._compat import ( field_annotation_is_scalar, get_annotation_from_field_info, get_missing_field_error, + get_model_fields, is_bytes_field, is_bytes_sequence_field, is_scalar_field, @@ -56,6 +57,7 @@ from fastapi.security.base import SecurityBase from fastapi.security.oauth2 import OAuth2, SecurityScopes from fastapi.security.open_id_connect_url import OpenIdConnect from fastapi.utils import create_model_field, get_path_param_names +from pydantic import BaseModel from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks as StarletteBackgroundTasks from starlette.concurrency import run_in_threadpool @@ -743,7 +745,9 @@ def _should_embed_body_fields(fields: List[ModelField]) -> bool: return True # If it's a Form (or File) field, it has to be a BaseModel to be top level # otherwise it has to be embedded, so that the key value pair can be extracted - if isinstance(first_field.field_info, params.Form): + if isinstance(first_field.field_info, params.Form) and not lenient_issubclass( + first_field.type_, BaseModel + ): return True return False @@ -783,7 +787,8 @@ async def _extract_form_body( for sub_value in value: tg.start_soon(process_fn, sub_value.read) value = serialize_sequence_value(field=field, value=results) - values[field.name] = value + if value is not None: + values[field.name] = value return values @@ -798,8 +803,14 @@ async def request_body_to_args( single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields first_field = body_fields[0] body_to_process = received_body + + fields_to_extract: List[ModelField] = body_fields + + if single_not_embedded_field and lenient_issubclass(first_field.type_, BaseModel): + fields_to_extract = get_model_fields(first_field.type_) + if isinstance(received_body, FormData): - body_to_process = await _extract_form_body(body_fields, received_body) + body_to_process = await _extract_form_body(fields_to_extract, received_body) if single_not_embedded_field: loc: Tuple[str, ...] = ("body",) diff --git a/scripts/playwright/request_form_models/image01.py b/scripts/playwright/request_form_models/image01.py new file mode 100644 index 000000000..15bd3858c --- /dev/null +++ b/scripts/playwright/request_form_models/image01.py @@ -0,0 +1,36 @@ +import subprocess +import time + +import httpx +from playwright.sync_api import Playwright, sync_playwright + + +# Run playwright codegen to generate the code below, copy paste the sections in run() +def run(playwright: Playwright) -> None: + browser = playwright.chromium.launch(headless=False) + context = browser.new_context() + page = context.new_page() + page.goto("http://localhost:8000/docs") + page.get_by_role("button", name="POST /login/ Login").click() + page.get_by_role("button", name="Try it out").click() + page.screenshot(path="docs/en/docs/img/tutorial/request-form-models/image01.png") + + # --------------------- + context.close() + browser.close() + + +process = subprocess.Popen( + ["fastapi", "run", "docs_src/request_form_models/tutorial001.py"] +) +try: + for _ in range(3): + try: + response = httpx.get("http://localhost:8000/docs") + except httpx.ConnectError: + time.sleep(1) + break + with sync_playwright() as playwright: + run(playwright) +finally: + process.terminate() diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py new file mode 100644 index 000000000..7ed3ba3a2 --- /dev/null +++ b/tests/test_forms_single_model.py @@ -0,0 +1,129 @@ +from typing import List, Optional + +from dirty_equals import IsDict +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel +from typing_extensions import Annotated + +app = FastAPI() + + +class FormModel(BaseModel): + username: str + lastname: str + age: Optional[int] = None + tags: List[str] = ["foo", "bar"] + + +@app.post("/form/") +def post_form(user: Annotated[FormModel, Form()]): + return user + + +client = TestClient(app) + + +def test_send_all_data(): + response = client.post( + "/form/", + data={ + "username": "Rick", + "lastname": "Sanchez", + "age": "70", + "tags": ["plumbus", "citadel"], + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "username": "Rick", + "lastname": "Sanchez", + "age": 70, + "tags": ["plumbus", "citadel"], + } + + +def test_defaults(): + response = client.post("/form/", data={"username": "Rick", "lastname": "Sanchez"}) + assert response.status_code == 200, response.text + assert response.json() == { + "username": "Rick", + "lastname": "Sanchez", + "age": None, + "tags": ["foo", "bar"], + } + + +def test_invalid_data(): + response = client.post( + "/form/", + data={ + "username": "Rick", + "lastname": "Sanchez", + "age": "seventy", + "tags": ["plumbus", "citadel"], + }, + ) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["body", "age"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "seventy", + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "age"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_no_data(): + response = client.post("/form/") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"tags": ["foo", "bar"]}, + }, + { + "type": "missing", + "loc": ["body", "lastname"], + "msg": "Field required", + "input": {"tags": ["foo", "bar"]}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "lastname"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) diff --git a/tests/test_tutorial/test_request_form_models/__init__.py b/tests/test_tutorial/test_request_form_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001.py b/tests/test_tutorial/test_request_form_models/test_tutorial001.py new file mode 100644 index 000000000..46c130ee8 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001.py @@ -0,0 +1,232 @@ +import pytest +from dirty_equals import IsDict +from fastapi.testclient import TestClient + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_form_models.tutorial001 import app + + client = TestClient(app) + return client + + +def test_post_body_form(client: TestClient): + response = client.post("/login/", data={"username": "Foo", "password": "secret"}) + assert response.status_code == 200 + assert response.json() == {"username": "Foo", "password": "secret"} + + +def test_post_body_form_no_password(client: TestClient): + response = client.post("/login/", data={"username": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {"username": "Foo"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_username(client: TestClient): + response = client.post("/login/", data={"password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"password": "secret"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_data(client: TestClient): + response = client.post("/login/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_post_body_json(client: TestClient): + response = client.post("/login/", json={"username": "Foo", "password": "secret"}) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/login/": { + "post": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Login", + "operationId": "login_login__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": {"$ref": "#/components/schemas/FormData"} + } + }, + "required": True, + }, + } + } + }, + "components": { + "schemas": { + "FormData": { + "properties": { + "username": {"type": "string", "title": "Username"}, + "password": {"type": "string", "title": "Password"}, + }, + "type": "object", + "required": ["username", "password"], + "title": "FormData", + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + } + }, + } diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py new file mode 100644 index 000000000..4e14d89c8 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py @@ -0,0 +1,232 @@ +import pytest +from dirty_equals import IsDict +from fastapi.testclient import TestClient + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_form_models.tutorial001_an import app + + client = TestClient(app) + return client + + +def test_post_body_form(client: TestClient): + response = client.post("/login/", data={"username": "Foo", "password": "secret"}) + assert response.status_code == 200 + assert response.json() == {"username": "Foo", "password": "secret"} + + +def test_post_body_form_no_password(client: TestClient): + response = client.post("/login/", data={"username": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {"username": "Foo"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_username(client: TestClient): + response = client.post("/login/", data={"password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"password": "secret"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_data(client: TestClient): + response = client.post("/login/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_post_body_json(client: TestClient): + response = client.post("/login/", json={"username": "Foo", "password": "secret"}) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/login/": { + "post": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Login", + "operationId": "login_login__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": {"$ref": "#/components/schemas/FormData"} + } + }, + "required": True, + }, + } + } + }, + "components": { + "schemas": { + "FormData": { + "properties": { + "username": {"type": "string", "title": "Username"}, + "password": {"type": "string", "title": "Password"}, + }, + "type": "object", + "required": ["username", "password"], + "title": "FormData", + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + } + }, + } diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py new file mode 100644 index 000000000..2e6426aa7 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py @@ -0,0 +1,240 @@ +import pytest +from dirty_equals import IsDict +from fastapi.testclient import TestClient + +from tests.utils import needs_py39 + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_form_models.tutorial001_an_py39 import app + + client = TestClient(app) + return client + + +@needs_py39 +def test_post_body_form(client: TestClient): + response = client.post("/login/", data={"username": "Foo", "password": "secret"}) + assert response.status_code == 200 + assert response.json() == {"username": "Foo", "password": "secret"} + + +@needs_py39 +def test_post_body_form_no_password(client: TestClient): + response = client.post("/login/", data={"username": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {"username": "Foo"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@needs_py39 +def test_post_body_form_no_username(client: TestClient): + response = client.post("/login/", data={"password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"password": "secret"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@needs_py39 +def test_post_body_form_no_data(client: TestClient): + response = client.post("/login/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_post_body_json(client: TestClient): + response = client.post("/login/", json={"username": "Foo", "password": "secret"}) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/login/": { + "post": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Login", + "operationId": "login_login__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": {"$ref": "#/components/schemas/FormData"} + } + }, + "required": True, + }, + } + } + }, + "components": { + "schemas": { + "FormData": { + "properties": { + "username": {"type": "string", "title": "Username"}, + "password": {"type": "string", "title": "Password"}, + }, + "type": "object", + "required": ["username", "password"], + "title": "FormData", + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + } + }, + } From ccb19c4c3506d7cb05218076d0e3527cb21eed81 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 5 Sep 2024 14:41:11 +0000 Subject: [PATCH 09/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 2fe884615..8fe8be6a7 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Features + +* ✨ Add support for Pydantic models in `Form` parameters. PR [#12127](https://github.com/fastapi/fastapi/pull/12127) by [@tiangolo](https://github.com/tiangolo). + ### Refactors * ♻️ Refactor deciding if `embed` body fields, do not overwrite fields, compute once per router, refactor internals in preparation for Pydantic models in `Form`, `Query` and others. PR [#12117](https://github.com/fastapi/fastapi/pull/12117) by [@tiangolo](https://github.com/tiangolo). From 8e6cf9ee9c9d87b6b658cc240146121c80f71476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 16:55:44 +0200 Subject: [PATCH 10/20] =?UTF-8?q?=E2=8F=AA=EF=B8=8F=20Temporarily=20revert?= =?UTF-8?q?=20"=E2=9C=A8=20Add=20support=20for=20Pydantic=20models=20in=20?= =?UTF-8?q?`Form`=20parameters"=20to=20make=20a=20checkpoint=20release=20(?= =?UTF-8?q?#12128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "✨ Add support for Pydantic models in `Form` parameters (#12127)" This reverts commit 0f3e65b00712a59d763cf9c7715cde353bb94b02. --- .../tutorial/request-form-models/image01.png | Bin 44487 -> 0 bytes docs/en/docs/tutorial/request-form-models.md | 65 ----- docs/en/mkdocs.yml | 1 - docs_src/request_form_models/tutorial001.py | 14 - .../request_form_models/tutorial001_an.py | 15 -- .../tutorial001_an_py39.py | 16 -- fastapi/dependencies/utils.py | 17 +- .../playwright/request_form_models/image01.py | 36 --- tests/test_forms_single_model.py | 129 ---------- .../test_request_form_models/__init__.py | 0 .../test_tutorial001.py | 232 ----------------- .../test_tutorial001_an.py | 232 ----------------- .../test_tutorial001_an_py39.py | 240 ------------------ 13 files changed, 3 insertions(+), 994 deletions(-) delete mode 100644 docs/en/docs/img/tutorial/request-form-models/image01.png delete mode 100644 docs/en/docs/tutorial/request-form-models.md delete mode 100644 docs_src/request_form_models/tutorial001.py delete mode 100644 docs_src/request_form_models/tutorial001_an.py delete mode 100644 docs_src/request_form_models/tutorial001_an_py39.py delete mode 100644 scripts/playwright/request_form_models/image01.py delete mode 100644 tests/test_forms_single_model.py delete mode 100644 tests/test_tutorial/test_request_form_models/__init__.py delete mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001.py delete mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an.py delete mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py diff --git a/docs/en/docs/img/tutorial/request-form-models/image01.png b/docs/en/docs/img/tutorial/request-form-models/image01.png deleted file mode 100644 index 3fe32c03d589e76abec5e9c6b71374ebd4e8cd2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 44487 zcmeFZcT|(j_b-b2Dz8{DDj*>68l^Ys(k&DL0RgF@_uhL8ib&{6RjSe3GerJ&n@emd;U6qoOQFZ);yV+XJ+p`lX>?3?7g3`_bT#tDCj82$jI&} zyp>TWBfCz#ygKyvRbugyN%0D?xZ(r=sS4^yATZ0)ghX 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 CMaG{1 diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md deleted file mode 100644 index 8bb1ffb1f..000000000 --- a/docs/en/docs/tutorial/request-form-models.md +++ /dev/null @@ -1,65 +0,0 @@ -# Form Models - -You can use Pydantic models to declare form fields in FastAPI. - -/// info - -To use forms, first install `python-multipart`. - -Make sure you create a [virtual environment](../virtual-environments.md){.internal-link target=_blank}, activate it, and then install it, for example: - -```console -$ pip install python-multipart -``` - -/// - -/// note - -This is supported since FastAPI version `0.113.0`. 🤓 - -/// - -## Pydantic Models for Forms - -You just need to declare a Pydantic model with the fields you want to receive as form fields, and then declare the parameter as `Form`: - -//// tab | Python 3.9+ - -```Python hl_lines="9-11 15" -{!> ../../../docs_src/request_form_models/tutorial001_an_py39.py!} -``` - -//// - -//// tab | Python 3.8+ - -```Python hl_lines="8-10 14" -{!> ../../../docs_src/request_form_models/tutorial001_an.py!} -``` - -//// - -//// tab | Python 3.8+ non-Annotated - -/// tip - -Prefer to use the `Annotated` version if possible. - -/// - -```Python hl_lines="7-9 13" -{!> ../../../docs_src/request_form_models/tutorial001.py!} -``` - -//// - -FastAPI will extract the data for each field from the form data in the request and give you the Pydantic model you defined. - -## Check the Docs - -You can verify it in the docs UI at `/docs`: - -
- -
diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 7c810c2d7..528c80b8e 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -129,7 +129,6 @@ nav: - tutorial/extra-models.md - tutorial/response-status-code.md - tutorial/request-forms.md - - tutorial/request-form-models.md - tutorial/request-files.md - tutorial/request-forms-and-files.md - tutorial/handling-errors.md diff --git a/docs_src/request_form_models/tutorial001.py b/docs_src/request_form_models/tutorial001.py deleted file mode 100644 index 98feff0b9..000000000 --- a/docs_src/request_form_models/tutorial001.py +++ /dev/null @@ -1,14 +0,0 @@ -from fastapi import FastAPI, Form -from pydantic import BaseModel - -app = FastAPI() - - -class FormData(BaseModel): - username: str - password: str - - -@app.post("/login/") -async def login(data: FormData = Form()): - return data diff --git a/docs_src/request_form_models/tutorial001_an.py b/docs_src/request_form_models/tutorial001_an.py deleted file mode 100644 index 30483d445..000000000 --- a/docs_src/request_form_models/tutorial001_an.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi import FastAPI, Form -from pydantic import BaseModel -from typing_extensions import Annotated - -app = FastAPI() - - -class FormData(BaseModel): - username: str - password: str - - -@app.post("/login/") -async def login(data: Annotated[FormData, Form()]): - return data diff --git a/docs_src/request_form_models/tutorial001_an_py39.py b/docs_src/request_form_models/tutorial001_an_py39.py deleted file mode 100644 index 7cc81aae9..000000000 --- a/docs_src/request_form_models/tutorial001_an_py39.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Annotated - -from fastapi import FastAPI, Form -from pydantic import BaseModel - -app = FastAPI() - - -class FormData(BaseModel): - username: str - password: str - - -@app.post("/login/") -async def login(data: Annotated[FormData, Form()]): - return data diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 98ce17b55..7ac18d941 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -33,7 +33,6 @@ from fastapi._compat import ( field_annotation_is_scalar, get_annotation_from_field_info, get_missing_field_error, - get_model_fields, is_bytes_field, is_bytes_sequence_field, is_scalar_field, @@ -57,7 +56,6 @@ from fastapi.security.base import SecurityBase from fastapi.security.oauth2 import OAuth2, SecurityScopes from fastapi.security.open_id_connect_url import OpenIdConnect from fastapi.utils import create_model_field, get_path_param_names -from pydantic import BaseModel from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks as StarletteBackgroundTasks from starlette.concurrency import run_in_threadpool @@ -745,9 +743,7 @@ def _should_embed_body_fields(fields: List[ModelField]) -> bool: return True # If it's a Form (or File) field, it has to be a BaseModel to be top level # otherwise it has to be embedded, so that the key value pair can be extracted - if isinstance(first_field.field_info, params.Form) and not lenient_issubclass( - first_field.type_, BaseModel - ): + if isinstance(first_field.field_info, params.Form): return True return False @@ -787,8 +783,7 @@ async def _extract_form_body( for sub_value in value: tg.start_soon(process_fn, sub_value.read) value = serialize_sequence_value(field=field, value=results) - if value is not None: - values[field.name] = value + values[field.name] = value return values @@ -803,14 +798,8 @@ async def request_body_to_args( single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields first_field = body_fields[0] body_to_process = received_body - - fields_to_extract: List[ModelField] = body_fields - - if single_not_embedded_field and lenient_issubclass(first_field.type_, BaseModel): - fields_to_extract = get_model_fields(first_field.type_) - if isinstance(received_body, FormData): - body_to_process = await _extract_form_body(fields_to_extract, received_body) + body_to_process = await _extract_form_body(body_fields, received_body) if single_not_embedded_field: loc: Tuple[str, ...] = ("body",) diff --git a/scripts/playwright/request_form_models/image01.py b/scripts/playwright/request_form_models/image01.py deleted file mode 100644 index 15bd3858c..000000000 --- a/scripts/playwright/request_form_models/image01.py +++ /dev/null @@ -1,36 +0,0 @@ -import subprocess -import time - -import httpx -from playwright.sync_api import Playwright, sync_playwright - - -# Run playwright codegen to generate the code below, copy paste the sections in run() -def run(playwright: Playwright) -> None: - browser = playwright.chromium.launch(headless=False) - context = browser.new_context() - page = context.new_page() - page.goto("http://localhost:8000/docs") - page.get_by_role("button", name="POST /login/ Login").click() - page.get_by_role("button", name="Try it out").click() - page.screenshot(path="docs/en/docs/img/tutorial/request-form-models/image01.png") - - # --------------------- - context.close() - browser.close() - - -process = subprocess.Popen( - ["fastapi", "run", "docs_src/request_form_models/tutorial001.py"] -) -try: - for _ in range(3): - try: - response = httpx.get("http://localhost:8000/docs") - except httpx.ConnectError: - time.sleep(1) - break - with sync_playwright() as playwright: - run(playwright) -finally: - process.terminate() diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py deleted file mode 100644 index 7ed3ba3a2..000000000 --- a/tests/test_forms_single_model.py +++ /dev/null @@ -1,129 +0,0 @@ -from typing import List, Optional - -from dirty_equals import IsDict -from fastapi import FastAPI, Form -from fastapi.testclient import TestClient -from pydantic import BaseModel -from typing_extensions import Annotated - -app = FastAPI() - - -class FormModel(BaseModel): - username: str - lastname: str - age: Optional[int] = None - tags: List[str] = ["foo", "bar"] - - -@app.post("/form/") -def post_form(user: Annotated[FormModel, Form()]): - return user - - -client = TestClient(app) - - -def test_send_all_data(): - response = client.post( - "/form/", - data={ - "username": "Rick", - "lastname": "Sanchez", - "age": "70", - "tags": ["plumbus", "citadel"], - }, - ) - assert response.status_code == 200, response.text - assert response.json() == { - "username": "Rick", - "lastname": "Sanchez", - "age": 70, - "tags": ["plumbus", "citadel"], - } - - -def test_defaults(): - response = client.post("/form/", data={"username": "Rick", "lastname": "Sanchez"}) - assert response.status_code == 200, response.text - assert response.json() == { - "username": "Rick", - "lastname": "Sanchez", - "age": None, - "tags": ["foo", "bar"], - } - - -def test_invalid_data(): - response = client.post( - "/form/", - data={ - "username": "Rick", - "lastname": "Sanchez", - "age": "seventy", - "tags": ["plumbus", "citadel"], - }, - ) - assert response.status_code == 422, response.text - assert response.json() == IsDict( - { - "detail": [ - { - "type": "int_parsing", - "loc": ["body", "age"], - "msg": "Input should be a valid integer, unable to parse string as an integer", - "input": "seventy", - } - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "age"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] - } - ) - - -def test_no_data(): - response = client.post("/form/") - assert response.status_code == 422, response.text - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {"tags": ["foo", "bar"]}, - }, - { - "type": "missing", - "loc": ["body", "lastname"], - "msg": "Field required", - "input": {"tags": ["foo", "bar"]}, - }, - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "lastname"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } - ) diff --git a/tests/test_tutorial/test_request_form_models/__init__.py b/tests/test_tutorial/test_request_form_models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001.py b/tests/test_tutorial/test_request_form_models/test_tutorial001.py deleted file mode 100644 index 46c130ee8..000000000 --- a/tests/test_tutorial/test_request_form_models/test_tutorial001.py +++ /dev/null @@ -1,232 +0,0 @@ -import pytest -from dirty_equals import IsDict -from fastapi.testclient import TestClient - - -@pytest.fixture(name="client") -def get_client(): - from docs_src.request_form_models.tutorial001 import app - - client = TestClient(app) - return client - - -def test_post_body_form(client: TestClient): - response = client.post("/login/", data={"username": "Foo", "password": "secret"}) - assert response.status_code == 200 - assert response.json() == {"username": "Foo", "password": "secret"} - - -def test_post_body_form_no_password(client: TestClient): - response = client.post("/login/", data={"username": "Foo"}) - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {"username": "Foo"}, - } - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } - ) - - -def test_post_body_form_no_username(client: TestClient): - response = client.post("/login/", data={"password": "secret"}) - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {"password": "secret"}, - } - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } - ) - - -def test_post_body_form_no_data(client: TestClient): - response = client.post("/login/") - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {}, - }, - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {}, - }, - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } - ) - - -def test_post_body_json(client: TestClient): - response = client.post("/login/", json={"username": "Foo", "password": "secret"}) - assert response.status_code == 422, response.text - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {}, - }, - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {}, - }, - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } - ) - - -def test_openapi_schema(client: TestClient): - response = client.get("/openapi.json") - assert response.status_code == 200, response.text - assert response.json() == { - "openapi": "3.1.0", - "info": {"title": "FastAPI", "version": "0.1.0"}, - "paths": { - "/login/": { - "post": { - "responses": { - "200": { - "description": "Successful Response", - "content": {"application/json": {"schema": {}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - "summary": "Login", - "operationId": "login_login__post", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": {"$ref": "#/components/schemas/FormData"} - } - }, - "required": True, - }, - } - } - }, - "components": { - "schemas": { - "FormData": { - "properties": { - "username": {"type": "string", "title": "Username"}, - "password": {"type": "string", "title": "Password"}, - }, - "type": "object", - "required": ["username", "password"], - "title": "FormData", - }, - "ValidationError": { - "title": "ValidationError", - "required": ["loc", "msg", "type"], - "type": "object", - "properties": { - "loc": { - "title": "Location", - "type": "array", - "items": { - "anyOf": [{"type": "string"}, {"type": "integer"}] - }, - }, - "msg": {"title": "Message", "type": "string"}, - "type": {"title": "Error Type", "type": "string"}, - }, - }, - "HTTPValidationError": { - "title": "HTTPValidationError", - "type": "object", - "properties": { - "detail": { - "title": "Detail", - "type": "array", - "items": {"$ref": "#/components/schemas/ValidationError"}, - } - }, - }, - } - }, - } diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py deleted file mode 100644 index 4e14d89c8..000000000 --- a/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py +++ /dev/null @@ -1,232 +0,0 @@ -import pytest -from dirty_equals import IsDict -from fastapi.testclient import TestClient - - -@pytest.fixture(name="client") -def get_client(): - from docs_src.request_form_models.tutorial001_an import app - - client = TestClient(app) - return client - - -def test_post_body_form(client: TestClient): - response = client.post("/login/", data={"username": "Foo", "password": "secret"}) - assert response.status_code == 200 - assert response.json() == {"username": "Foo", "password": "secret"} - - -def test_post_body_form_no_password(client: TestClient): - response = client.post("/login/", data={"username": "Foo"}) - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {"username": "Foo"}, - } - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } - ) - - -def test_post_body_form_no_username(client: TestClient): - response = client.post("/login/", data={"password": "secret"}) - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {"password": "secret"}, - } - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } - ) - - -def test_post_body_form_no_data(client: TestClient): - response = client.post("/login/") - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {}, - }, - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {}, - }, - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } - ) - - -def test_post_body_json(client: TestClient): - response = client.post("/login/", json={"username": "Foo", "password": "secret"}) - assert response.status_code == 422, response.text - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {}, - }, - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {}, - }, - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } - ) - - -def test_openapi_schema(client: TestClient): - response = client.get("/openapi.json") - assert response.status_code == 200, response.text - assert response.json() == { - "openapi": "3.1.0", - "info": {"title": "FastAPI", "version": "0.1.0"}, - "paths": { - "/login/": { - "post": { - "responses": { - "200": { - "description": "Successful Response", - "content": {"application/json": {"schema": {}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - "summary": "Login", - "operationId": "login_login__post", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": {"$ref": "#/components/schemas/FormData"} - } - }, - "required": True, - }, - } - } - }, - "components": { - "schemas": { - "FormData": { - "properties": { - "username": {"type": "string", "title": "Username"}, - "password": {"type": "string", "title": "Password"}, - }, - "type": "object", - "required": ["username", "password"], - "title": "FormData", - }, - "ValidationError": { - "title": "ValidationError", - "required": ["loc", "msg", "type"], - "type": "object", - "properties": { - "loc": { - "title": "Location", - "type": "array", - "items": { - "anyOf": [{"type": "string"}, {"type": "integer"}] - }, - }, - "msg": {"title": "Message", "type": "string"}, - "type": {"title": "Error Type", "type": "string"}, - }, - }, - "HTTPValidationError": { - "title": "HTTPValidationError", - "type": "object", - "properties": { - "detail": { - "title": "Detail", - "type": "array", - "items": {"$ref": "#/components/schemas/ValidationError"}, - } - }, - }, - } - }, - } diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py deleted file mode 100644 index 2e6426aa7..000000000 --- a/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py +++ /dev/null @@ -1,240 +0,0 @@ -import pytest -from dirty_equals import IsDict -from fastapi.testclient import TestClient - -from tests.utils import needs_py39 - - -@pytest.fixture(name="client") -def get_client(): - from docs_src.request_form_models.tutorial001_an_py39 import app - - client = TestClient(app) - return client - - -@needs_py39 -def test_post_body_form(client: TestClient): - response = client.post("/login/", data={"username": "Foo", "password": "secret"}) - assert response.status_code == 200 - assert response.json() == {"username": "Foo", "password": "secret"} - - -@needs_py39 -def test_post_body_form_no_password(client: TestClient): - response = client.post("/login/", data={"username": "Foo"}) - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {"username": "Foo"}, - } - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } - ) - - -@needs_py39 -def test_post_body_form_no_username(client: TestClient): - response = client.post("/login/", data={"password": "secret"}) - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {"password": "secret"}, - } - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } - ) - - -@needs_py39 -def test_post_body_form_no_data(client: TestClient): - response = client.post("/login/") - assert response.status_code == 422 - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {}, - }, - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {}, - }, - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } - ) - - -@needs_py39 -def test_post_body_json(client: TestClient): - response = client.post("/login/", json={"username": "Foo", "password": "secret"}) - assert response.status_code == 422, response.text - assert response.json() == IsDict( - { - "detail": [ - { - "type": "missing", - "loc": ["body", "username"], - "msg": "Field required", - "input": {}, - }, - { - "type": "missing", - "loc": ["body", "password"], - "msg": "Field required", - "input": {}, - }, - ] - } - ) | IsDict( - # TODO: remove when deprecating Pydantic v1 - { - "detail": [ - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } - ) - - -@needs_py39 -def test_openapi_schema(client: TestClient): - response = client.get("/openapi.json") - assert response.status_code == 200, response.text - assert response.json() == { - "openapi": "3.1.0", - "info": {"title": "FastAPI", "version": "0.1.0"}, - "paths": { - "/login/": { - "post": { - "responses": { - "200": { - "description": "Successful Response", - "content": {"application/json": {"schema": {}}}, - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - }, - }, - "summary": "Login", - "operationId": "login_login__post", - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": {"$ref": "#/components/schemas/FormData"} - } - }, - "required": True, - }, - } - } - }, - "components": { - "schemas": { - "FormData": { - "properties": { - "username": {"type": "string", "title": "Username"}, - "password": {"type": "string", "title": "Password"}, - }, - "type": "object", - "required": ["username", "password"], - "title": "FormData", - }, - "ValidationError": { - "title": "ValidationError", - "required": ["loc", "msg", "type"], - "type": "object", - "properties": { - "loc": { - "title": "Location", - "type": "array", - "items": { - "anyOf": [{"type": "string"}, {"type": "integer"}] - }, - }, - "msg": {"title": "Message", "type": "string"}, - "type": {"title": "Error Type", "type": "string"}, - }, - }, - "HTTPValidationError": { - "title": "HTTPValidationError", - "type": "object", - "properties": { - "detail": { - "title": "Detail", - "type": "array", - "items": {"$ref": "#/components/schemas/ValidationError"}, - } - }, - }, - } - }, - } From b69e8b24af305ddceaa9c63c8d2eebf80672caed Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 5 Sep 2024 14:56:10 +0000 Subject: [PATCH 11/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 8fe8be6a7..3c5fbb731 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -15,6 +15,10 @@ hide: * ♻️ Refactor deciding if `embed` body fields, do not overwrite fields, compute once per router, refactor internals in preparation for Pydantic models in `Form`, `Query` and others. PR [#12117](https://github.com/fastapi/fastapi/pull/12117) by [@tiangolo](https://github.com/tiangolo). +### Internal + +* ⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` parameters" to make a checkpoint release. PR [#12128](https://github.com/fastapi/fastapi/pull/12128) by [@tiangolo](https://github.com/tiangolo). + ## 0.112.3 This release is mainly internal refactors, it shouldn't affect apps using FastAPI in any way. You don't even have to upgrade to this version yet. There are a few bigger releases coming right after. 🚀 From 8224addd8f31325ad465af994da8421a69f494ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 16:57:57 +0200 Subject: [PATCH 12/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 3c5fbb731..22a224a5a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,7 +9,7 @@ hide: ### Features -* ✨ Add support for Pydantic models in `Form` parameters. PR [#12127](https://github.com/fastapi/fastapi/pull/12127) by [@tiangolo](https://github.com/tiangolo). +## 0.112.4 ### Refactors @@ -18,6 +18,7 @@ hide: ### Internal * ⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` parameters" to make a checkpoint release. PR [#12128](https://github.com/fastapi/fastapi/pull/12128) by [@tiangolo](https://github.com/tiangolo). +* ✨ Add support for Pydantic models in `Form` parameters. PR [#12127](https://github.com/fastapi/fastapi/pull/12127) by [@tiangolo](https://github.com/tiangolo). Reverted to make a checkpoint release with only refactors. ## 0.112.3 From 96c7e7e0f34730e6f6333ced9476bfd62f384cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 17:00:13 +0200 Subject: [PATCH 13/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 22a224a5a..43ce86c99 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,10 +7,12 @@ hide: ## Latest Changes -### Features - ## 0.112.4 +This release is mainly a big internal refactor to enable adding support for Pydantic models for `Form` fields, but that feature comes in the next release. + +This release shouldn't affect apps using FastAPI in any way. You don't even have to upgrade to this version yet. It's just a checkpoint. 🤓 + ### Refactors * ♻️ Refactor deciding if `embed` body fields, do not overwrite fields, compute once per router, refactor internals in preparation for Pydantic models in `Form`, `Query` and others. PR [#12117](https://github.com/fastapi/fastapi/pull/12117) by [@tiangolo](https://github.com/tiangolo). From 999eeb6c76ff37f94612dd140ce8091932f56c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 17:00:33 +0200 Subject: [PATCH 14/20] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.112.?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 1bc1bfd82..1e10bf557 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.112.3" +__version__ = "0.112.4" from starlette import status as status From 965fc8301e8fa7a7228bee33873387f4852a30df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 17:09:31 +0200 Subject: [PATCH 15/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 43ce86c99..9b44bc9a8 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -19,8 +19,8 @@ This release shouldn't affect apps using FastAPI in any way. You don't even have ### Internal -* ⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` parameters" to make a checkpoint release. PR [#12128](https://github.com/fastapi/fastapi/pull/12128) by [@tiangolo](https://github.com/tiangolo). -* ✨ Add support for Pydantic models in `Form` parameters. PR [#12127](https://github.com/fastapi/fastapi/pull/12127) by [@tiangolo](https://github.com/tiangolo). Reverted to make a checkpoint release with only refactors. +* ⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` parameters" to make a checkpoint release. PR [#12128](https://github.com/fastapi/fastapi/pull/12128) by [@tiangolo](https://github.com/tiangolo). Restored by PR [#12129](https://github.com/fastapi/fastapi/pull/12129). +* ✨ Add support for Pydantic models in `Form` parameters. PR [#12127](https://github.com/fastapi/fastapi/pull/12127) by [@tiangolo](https://github.com/tiangolo). Reverted by PR [#12128](https://github.com/fastapi/fastapi/pull/12128) to make a checkpoint release with only refactors. Restored by PR [#12129](https://github.com/fastapi/fastapi/pull/12129). ## 0.112.3 From 7bad7c09757f8a06cf62cc0838082a766065883e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 17:16:50 +0200 Subject: [PATCH 16/20] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20Pydanti?= =?UTF-8?q?c=20models=20in=20`Form`=20parameters=20(#12129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` pa…" This reverts commit 8e6cf9ee9c9d87b6b658cc240146121c80f71476. --- .../tutorial/request-form-models/image01.png | Bin 0 -> 44487 bytes docs/en/docs/tutorial/request-form-models.md | 65 +++++ docs/en/mkdocs.yml | 1 + docs_src/request_form_models/tutorial001.py | 14 + .../request_form_models/tutorial001_an.py | 15 ++ .../tutorial001_an_py39.py | 16 ++ fastapi/dependencies/utils.py | 17 +- .../playwright/request_form_models/image01.py | 36 +++ tests/test_forms_single_model.py | 129 ++++++++++ .../test_request_form_models/__init__.py | 0 .../test_tutorial001.py | 232 +++++++++++++++++ .../test_tutorial001_an.py | 232 +++++++++++++++++ .../test_tutorial001_an_py39.py | 240 ++++++++++++++++++ 13 files changed, 994 insertions(+), 3 deletions(-) create mode 100644 docs/en/docs/img/tutorial/request-form-models/image01.png create mode 100644 docs/en/docs/tutorial/request-form-models.md create mode 100644 docs_src/request_form_models/tutorial001.py create mode 100644 docs_src/request_form_models/tutorial001_an.py create mode 100644 docs_src/request_form_models/tutorial001_an_py39.py create mode 100644 scripts/playwright/request_form_models/image01.py create mode 100644 tests/test_forms_single_model.py create mode 100644 tests/test_tutorial/test_request_form_models/__init__.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py diff --git a/docs/en/docs/img/tutorial/request-form-models/image01.png b/docs/en/docs/img/tutorial/request-form-models/image01.png new file mode 100644 index 0000000000000000000000000000000000000000..3fe32c03d589e76abec5e9c6b71374ebd4e8cd2c GIT binary patch literal 44487 zcmeFZcT|(j_b-b2Dz8{DDj*>68l^Ys(k&DL0RgF@_uhL8ib&{6RjSe3GerJ&n@emd;U6qoOQFZ);yV+XJ+p`lX>?3?7g3`_bT#tDCj82$jI&} zyp>TWBfCz#ygKyvRbugyN%0D?xZ(r=sS4^yATZ0)ghX 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 CMaG{1 literal 0 HcmV?d00001 diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md new file mode 100644 index 000000000..8bb1ffb1f --- /dev/null +++ b/docs/en/docs/tutorial/request-form-models.md @@ -0,0 +1,65 @@ +# Form Models + +You can use Pydantic models to declare form fields in FastAPI. + +/// info + +To use forms, first install `python-multipart`. + +Make sure you create a [virtual environment](../virtual-environments.md){.internal-link target=_blank}, activate it, and then install it, for example: + +```console +$ pip install python-multipart +``` + +/// + +/// note + +This is supported since FastAPI version `0.113.0`. 🤓 + +/// + +## Pydantic Models for Forms + +You just need to declare a Pydantic model with the fields you want to receive as form fields, and then declare the parameter as `Form`: + +//// tab | Python 3.9+ + +```Python hl_lines="9-11 15" +{!> ../../../docs_src/request_form_models/tutorial001_an_py39.py!} +``` + +//// + +//// tab | Python 3.8+ + +```Python hl_lines="8-10 14" +{!> ../../../docs_src/request_form_models/tutorial001_an.py!} +``` + +//// + +//// tab | Python 3.8+ non-Annotated + +/// tip + +Prefer to use the `Annotated` version if possible. + +/// + +```Python hl_lines="7-9 13" +{!> ../../../docs_src/request_form_models/tutorial001.py!} +``` + +//// + +FastAPI will extract the data for each field from the form data in the request and give you the Pydantic model you defined. + +## Check the Docs + +You can verify it in the docs UI at `/docs`: + +
+ +
diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 528c80b8e..7c810c2d7 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -129,6 +129,7 @@ nav: - tutorial/extra-models.md - tutorial/response-status-code.md - tutorial/request-forms.md + - tutorial/request-form-models.md - tutorial/request-files.md - tutorial/request-forms-and-files.md - tutorial/handling-errors.md diff --git a/docs_src/request_form_models/tutorial001.py b/docs_src/request_form_models/tutorial001.py new file mode 100644 index 000000000..98feff0b9 --- /dev/null +++ b/docs_src/request_form_models/tutorial001.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI, Form +from pydantic import BaseModel + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + + +@app.post("/login/") +async def login(data: FormData = Form()): + return data diff --git a/docs_src/request_form_models/tutorial001_an.py b/docs_src/request_form_models/tutorial001_an.py new file mode 100644 index 000000000..30483d445 --- /dev/null +++ b/docs_src/request_form_models/tutorial001_an.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI, Form +from pydantic import BaseModel +from typing_extensions import Annotated + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + + +@app.post("/login/") +async def login(data: Annotated[FormData, Form()]): + return data diff --git a/docs_src/request_form_models/tutorial001_an_py39.py b/docs_src/request_form_models/tutorial001_an_py39.py new file mode 100644 index 000000000..7cc81aae9 --- /dev/null +++ b/docs_src/request_form_models/tutorial001_an_py39.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from fastapi import FastAPI, Form +from pydantic import BaseModel + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + + +@app.post("/login/") +async def login(data: Annotated[FormData, Form()]): + return data diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 7ac18d941..98ce17b55 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -33,6 +33,7 @@ from fastapi._compat import ( field_annotation_is_scalar, get_annotation_from_field_info, get_missing_field_error, + get_model_fields, is_bytes_field, is_bytes_sequence_field, is_scalar_field, @@ -56,6 +57,7 @@ from fastapi.security.base import SecurityBase from fastapi.security.oauth2 import OAuth2, SecurityScopes from fastapi.security.open_id_connect_url import OpenIdConnect from fastapi.utils import create_model_field, get_path_param_names +from pydantic import BaseModel from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks as StarletteBackgroundTasks from starlette.concurrency import run_in_threadpool @@ -743,7 +745,9 @@ def _should_embed_body_fields(fields: List[ModelField]) -> bool: return True # If it's a Form (or File) field, it has to be a BaseModel to be top level # otherwise it has to be embedded, so that the key value pair can be extracted - if isinstance(first_field.field_info, params.Form): + if isinstance(first_field.field_info, params.Form) and not lenient_issubclass( + first_field.type_, BaseModel + ): return True return False @@ -783,7 +787,8 @@ async def _extract_form_body( for sub_value in value: tg.start_soon(process_fn, sub_value.read) value = serialize_sequence_value(field=field, value=results) - values[field.name] = value + if value is not None: + values[field.name] = value return values @@ -798,8 +803,14 @@ async def request_body_to_args( single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields first_field = body_fields[0] body_to_process = received_body + + fields_to_extract: List[ModelField] = body_fields + + if single_not_embedded_field and lenient_issubclass(first_field.type_, BaseModel): + fields_to_extract = get_model_fields(first_field.type_) + if isinstance(received_body, FormData): - body_to_process = await _extract_form_body(body_fields, received_body) + body_to_process = await _extract_form_body(fields_to_extract, received_body) if single_not_embedded_field: loc: Tuple[str, ...] = ("body",) diff --git a/scripts/playwright/request_form_models/image01.py b/scripts/playwright/request_form_models/image01.py new file mode 100644 index 000000000..15bd3858c --- /dev/null +++ b/scripts/playwright/request_form_models/image01.py @@ -0,0 +1,36 @@ +import subprocess +import time + +import httpx +from playwright.sync_api import Playwright, sync_playwright + + +# Run playwright codegen to generate the code below, copy paste the sections in run() +def run(playwright: Playwright) -> None: + browser = playwright.chromium.launch(headless=False) + context = browser.new_context() + page = context.new_page() + page.goto("http://localhost:8000/docs") + page.get_by_role("button", name="POST /login/ Login").click() + page.get_by_role("button", name="Try it out").click() + page.screenshot(path="docs/en/docs/img/tutorial/request-form-models/image01.png") + + # --------------------- + context.close() + browser.close() + + +process = subprocess.Popen( + ["fastapi", "run", "docs_src/request_form_models/tutorial001.py"] +) +try: + for _ in range(3): + try: + response = httpx.get("http://localhost:8000/docs") + except httpx.ConnectError: + time.sleep(1) + break + with sync_playwright() as playwright: + run(playwright) +finally: + process.terminate() diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py new file mode 100644 index 000000000..7ed3ba3a2 --- /dev/null +++ b/tests/test_forms_single_model.py @@ -0,0 +1,129 @@ +from typing import List, Optional + +from dirty_equals import IsDict +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel +from typing_extensions import Annotated + +app = FastAPI() + + +class FormModel(BaseModel): + username: str + lastname: str + age: Optional[int] = None + tags: List[str] = ["foo", "bar"] + + +@app.post("/form/") +def post_form(user: Annotated[FormModel, Form()]): + return user + + +client = TestClient(app) + + +def test_send_all_data(): + response = client.post( + "/form/", + data={ + "username": "Rick", + "lastname": "Sanchez", + "age": "70", + "tags": ["plumbus", "citadel"], + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "username": "Rick", + "lastname": "Sanchez", + "age": 70, + "tags": ["plumbus", "citadel"], + } + + +def test_defaults(): + response = client.post("/form/", data={"username": "Rick", "lastname": "Sanchez"}) + assert response.status_code == 200, response.text + assert response.json() == { + "username": "Rick", + "lastname": "Sanchez", + "age": None, + "tags": ["foo", "bar"], + } + + +def test_invalid_data(): + response = client.post( + "/form/", + data={ + "username": "Rick", + "lastname": "Sanchez", + "age": "seventy", + "tags": ["plumbus", "citadel"], + }, + ) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["body", "age"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "seventy", + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "age"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_no_data(): + response = client.post("/form/") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"tags": ["foo", "bar"]}, + }, + { + "type": "missing", + "loc": ["body", "lastname"], + "msg": "Field required", + "input": {"tags": ["foo", "bar"]}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "lastname"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) diff --git a/tests/test_tutorial/test_request_form_models/__init__.py b/tests/test_tutorial/test_request_form_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001.py b/tests/test_tutorial/test_request_form_models/test_tutorial001.py new file mode 100644 index 000000000..46c130ee8 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001.py @@ -0,0 +1,232 @@ +import pytest +from dirty_equals import IsDict +from fastapi.testclient import TestClient + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_form_models.tutorial001 import app + + client = TestClient(app) + return client + + +def test_post_body_form(client: TestClient): + response = client.post("/login/", data={"username": "Foo", "password": "secret"}) + assert response.status_code == 200 + assert response.json() == {"username": "Foo", "password": "secret"} + + +def test_post_body_form_no_password(client: TestClient): + response = client.post("/login/", data={"username": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {"username": "Foo"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_username(client: TestClient): + response = client.post("/login/", data={"password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"password": "secret"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_data(client: TestClient): + response = client.post("/login/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_post_body_json(client: TestClient): + response = client.post("/login/", json={"username": "Foo", "password": "secret"}) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/login/": { + "post": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Login", + "operationId": "login_login__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": {"$ref": "#/components/schemas/FormData"} + } + }, + "required": True, + }, + } + } + }, + "components": { + "schemas": { + "FormData": { + "properties": { + "username": {"type": "string", "title": "Username"}, + "password": {"type": "string", "title": "Password"}, + }, + "type": "object", + "required": ["username", "password"], + "title": "FormData", + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + } + }, + } diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py new file mode 100644 index 000000000..4e14d89c8 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py @@ -0,0 +1,232 @@ +import pytest +from dirty_equals import IsDict +from fastapi.testclient import TestClient + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_form_models.tutorial001_an import app + + client = TestClient(app) + return client + + +def test_post_body_form(client: TestClient): + response = client.post("/login/", data={"username": "Foo", "password": "secret"}) + assert response.status_code == 200 + assert response.json() == {"username": "Foo", "password": "secret"} + + +def test_post_body_form_no_password(client: TestClient): + response = client.post("/login/", data={"username": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {"username": "Foo"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_username(client: TestClient): + response = client.post("/login/", data={"password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"password": "secret"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_body_form_no_data(client: TestClient): + response = client.post("/login/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_post_body_json(client: TestClient): + response = client.post("/login/", json={"username": "Foo", "password": "secret"}) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/login/": { + "post": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Login", + "operationId": "login_login__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": {"$ref": "#/components/schemas/FormData"} + } + }, + "required": True, + }, + } + } + }, + "components": { + "schemas": { + "FormData": { + "properties": { + "username": {"type": "string", "title": "Username"}, + "password": {"type": "string", "title": "Password"}, + }, + "type": "object", + "required": ["username", "password"], + "title": "FormData", + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + } + }, + } diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py new file mode 100644 index 000000000..2e6426aa7 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py @@ -0,0 +1,240 @@ +import pytest +from dirty_equals import IsDict +from fastapi.testclient import TestClient + +from tests.utils import needs_py39 + + +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_form_models.tutorial001_an_py39 import app + + client = TestClient(app) + return client + + +@needs_py39 +def test_post_body_form(client: TestClient): + response = client.post("/login/", data={"username": "Foo", "password": "secret"}) + assert response.status_code == 200 + assert response.json() == {"username": "Foo", "password": "secret"} + + +@needs_py39 +def test_post_body_form_no_password(client: TestClient): + response = client.post("/login/", data={"username": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {"username": "Foo"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@needs_py39 +def test_post_body_form_no_username(client: TestClient): + response = client.post("/login/", data={"password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {"password": "secret"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@needs_py39 +def test_post_body_form_no_data(client: TestClient): + response = client.post("/login/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_post_body_json(client: TestClient): + response = client.post("/login/", json={"username": "Foo", "password": "secret"}) + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": {}, + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/login/": { + "post": { + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + "summary": "Login", + "operationId": "login_login__post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": {"$ref": "#/components/schemas/FormData"} + } + }, + "required": True, + }, + } + } + }, + "components": { + "schemas": { + "FormData": { + "properties": { + "username": {"type": "string", "title": "Username"}, + "password": {"type": "string", "title": "Password"}, + }, + "type": "object", + "required": ["username", "password"], + "title": "FormData", + }, + "ValidationError": { + "title": "ValidationError", + "required": ["loc", "msg", "type"], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}] + }, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + } + }, + } From e787f854ddbe12d08ae6b13298b6d5eda7e20928 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 5 Sep 2024 15:17:13 +0000 Subject: [PATCH 17/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 9b44bc9a8..c6cbc7658 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Features + +* ✨ Add support for Pydantic models in `Form` parameters. PR [#12129](https://github.com/fastapi/fastapi/pull/12129) by [@tiangolo](https://github.com/tiangolo). + ## 0.112.4 This release is mainly a big internal refactor to enable adding support for Pydantic models for `Form` fields, but that feature comes in the next release. From afdda4e50ba002c951233f5bedcc64068d59d212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 17:21:35 +0200 Subject: [PATCH 18/20] =?UTF-8?q?=F0=9F=94=A7=20Update=20sponsors:=20Coher?= =?UTF-8?q?ence=20link=20(#12130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/en/data/sponsors.yml | 2 +- docs/en/docs/deployment/cloud.md | 2 +- docs/en/overrides/main.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5554f71d4..3b01b713a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The key features are: - + diff --git a/docs/en/data/sponsors.yml b/docs/en/data/sponsors.yml index 3a767b6b1..d96646fb3 100644 --- a/docs/en/data/sponsors.yml +++ b/docs/en/data/sponsors.yml @@ -17,7 +17,7 @@ gold: - url: https://www.propelauth.com/?utm_source=fastapi&utm_campaign=1223&utm_medium=mainbadge title: Auth, user management and more for your B2B product img: https://fastapi.tiangolo.com/img/sponsors/propelauth.png - - url: https://docs.withcoherence.com/coherence-templates/full-stack-template/#fastapi?utm_medium=advertising&utm_source=fastapi&utm_campaign=docs + - url: https://www.withcoherence.com/?utm_medium=advertising&utm_source=fastapi&utm_campaign=website title: Coherence img: https://fastapi.tiangolo.com/img/sponsors/coherence.png - url: https://www.mongodb.com/developer/languages/python/python-quickstart-fastapi/?utm_campaign=fastapi_framework&utm_source=fastapi_sponsorship&utm_medium=web_referral diff --git a/docs/en/docs/deployment/cloud.md b/docs/en/docs/deployment/cloud.md index 3ea5087f8..41ada859d 100644 --- a/docs/en/docs/deployment/cloud.md +++ b/docs/en/docs/deployment/cloud.md @@ -14,4 +14,4 @@ You might want to try their services and follow their guides: * Platform.sh * Porter -* Coherence +* Coherence diff --git a/docs/en/overrides/main.html b/docs/en/overrides/main.html index 47e46c4bf..463c5af3b 100644 --- a/docs/en/overrides/main.html +++ b/docs/en/overrides/main.html @@ -59,7 +59,7 @@
- + From 179f838c366b1d9ac74e114949c5c6cfe713ec03 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 5 Sep 2024 15:23:05 +0000 Subject: [PATCH 19/20] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index c6cbc7658..acf53e3de 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -11,6 +11,10 @@ hide: * ✨ Add support for Pydantic models in `Form` parameters. PR [#12129](https://github.com/fastapi/fastapi/pull/12129) by [@tiangolo](https://github.com/tiangolo). +### Internal + +* 🔧 Update sponsors: Coherence link. PR [#12130](https://github.com/fastapi/fastapi/pull/12130) by [@tiangolo](https://github.com/tiangolo). + ## 0.112.4 This release is mainly a big internal refactor to enable adding support for Pydantic models for `Form` fields, but that feature comes in the next release. From d86f6603029def91e0798ca42f5fd12eff13c87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 17:25:29 +0200 Subject: [PATCH 20/20] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.113.?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 25 +++++++++++++++++++++++++ fastapi/__init__.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index acf53e3de..0571523bf 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,31 @@ hide: ## Latest Changes +## 0.113.0 + +Now you can declare form fields with Pydantic models: + +```python +from typing import Annotated + +from fastapi import FastAPI, Form +from pydantic import BaseModel + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + + +@app.post("/login/") +async def login(data: Annotated[FormData, Form()]): + return data +``` + +Read the new docs: [Form Models](https://fastapi.tiangolo.com/tutorial/request-form-models/). + ### Features * ✨ Add support for Pydantic models in `Form` parameters. PR [#12129](https://github.com/fastapi/fastapi/pull/12129) by [@tiangolo](https://github.com/tiangolo). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 1e10bf557..f785f81cd 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.112.4" +__version__ = "0.113.0" from starlette import status as status