From bd32fecaf65ecf750fabfe9d8951d887c0c73101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Fri, 16 Jun 2023 16:30:28 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Pydantic=20v2=20migration,=20initia?= =?UTF-8?q?l=20implementation=20(#9500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP * ✨ Add compat layer, for Pydantic v1 and v2 * ✨ Re-export Pydantic needed internals from compat, to later patch them for v1 * ♻️ Refactor internals to use new compatibility layers and run with Pydantic v2 * 📝 Update examples to run with Pydantic v2 * ✅ Update tests to use Pydantic v2 * 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks * ✅ Temporarily disable Peewee tests, afterwards I'll enable them only for Pydantic v1 * 🐛 Fix JSON Schema generation and OpenAPI ref template * 🐛 Fix model field creation with defaults from Pydantic v2 * 🐛 Fix body field creation, with new FieldInfo * ✨ Use and check new ResponseValidationError for server validation errors * ✅ Fix test_schema_extra_examples tests with ResponseValidationError * ✅ Add dirty-equals to tests for compatibility with Pydantic v1 and v2 * ✨ Add util to regenerate errors with custom loc * ✨ Generate validation errors with loc * ✅ Update tests for compatibility with Pydantic v1 and v2 * ✅ Update tests for Pydantic v2 in tests/test_filter_pydantic_sub_model.py * ✅ Refactor tests in tests/test_dependency_overrides.py for Pydantic v2, separate parameterized into independent tests to use insert_assert * ✅ Refactor OpenAPI test for tests/test_infer_param_optionality.py for consistency, and make it compatible with Pydantic v1 and v2 * ✅ Update tests for tests/test_multi_query_errors.py for Pydantic v1 and v2 * ✅ Update tests for tests/test_multi_body_errors.py for Pydantic v1 and v2 * ✅ Update tests for tests/test_multi_body_errors.py for Pydantic v1 and v2 * 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks * ♻️ Refactor tests for tests/test_path.py to inline pytest parameters, to make it easier to make them compatible with Pydantic v2 * ✅ Refactor and udpate tests for tests/test_path.py for Pydantic v1 and v2 * ♻️ Refactor and update tests for tests/test_query.py with compatibility for Pydantic v1 and v2 * ✅ Fix test with optional field without default None * ✅ Update tests for compatibility with Pydantic v2 * ✅ Update tutorial tests for Pydantic v2 * ♻️ Update OAuth2 dependencies for Pydantic v2 * ♻️ Refactor str check when checking for sequence types * ♻️ Rename regex to pattern to keep in sync with Pydantic v2 * ♻️ Refactor _compat.py, start moving conditional imports and declarations to specifics of Pydantic v1 or v2 * ✅ Update tests for OAuth2 security optional * ✅ Refactor tests for OAuth2 optional for Pydantic v2 * ✅ Refactor tests for OAuth2 security for compatibility with Pydantic v2 * 🐛 Fix location in compat layer for Pydantic v2 ModelField * ✅ Refactor tests for Pydantic v2 in tests/test_tutorial/test_bigger_applications/test_main_an_py39.py * 🐛 Add missing markers in Python 3.9 tests * ✅ Refactor tests for bigger apps for consistency with annotated ones and with support for Pydantic v2 * 🐛 Fix jsonable_encoder with new Pydantic v2 data types and Url * 🐛 Fix invalid JSON error for compatibility with Pydantic v2 * ✅ Update tests for behind_a_proxy for Pydantic v2 * ✅ Update tests for tests/test_tutorial/test_body/test_tutorial001_py310.py for Pydantic v2 * ✅ Update tests for tests/test_tutorial/test_body/test_tutorial001.py with Pydantic v2 and consistency with Python 3.10 tests * ✅ Fix tests for tutorial/body_fields for Pydantic v2 * ✅ Refactor tests for tutorial/body_multiple_params with Pydantic v2 * ✅ Update tests for tutorial/body_nested_models for Pydantic v2 * ✅ Update tests for tutorial/body_updates for Pydantic v2 * ✅ Update test for tutorial/cookie_params for Pydantic v2 * ✅ Fix tests for tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py for Pydantic v2 * ✅ Update tests for tutorial/dataclasses for Pydantic v2 * ✅ Update tests for tutorial/dependencies for Pydantic v2 * ✅ Update tests for tutorial/extra_data_types for Pydantic v2 * ✅ Update tests for tutorial/handling_errors for Pydantic v2 * ✅ Fix test markers for Python 3.9 * ✅ Update tests for tutorial/header_params for Pydantic v2 * ✅ Update tests for Pydantic v2 in tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py * ✅ Fix extra tests for Pydantic v2 * ✅ Refactor test for parameters, to later fix Pydantic v2 * ✅ Update tests for tutorial/query_params for Pydantic v2 * ♻️ Update examples in docs to use new pattern instead of the old regex * ✅ Fix several tests for Pydantic v2 * ✅ Update and fix test for ResponseValidationError * 🐛 Fix check for sequences vs scalars, include bytes as scalar * 🐛 Fix check for complex data types, include UploadFile * 🐛 Add list to sequence annotation types * 🐛 Fix checks for uploads and add utils to find if an annotation is an upload (or bytes) * ✨ Add UnionType and NoneType to compat layer * ✅ Update tests for request_files for compatibility with Pydantic v2 and consistency with other tests * ✅ Fix testsw for request_forms for Pydantic v2 * ✅ Fix tests for request_forms_and_files for Pydantic v2 * ✅ Fix tests in tutorial/security for compatibility with Pydantic v2 * ⬆️ Upgrade required version of email_validator * ✅ Fix tests for params repr * ✅ Add Pydantic v2 pytest markers * Use match_pydantic_error_url * 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks * Use field_serializer instead of encoders in some tests * Show Undefined as ... in repr * Mark custom encoders test with xfail * Update test to reflect new serialization of Decimal as str * Use `model_validate` instead of `from_orm` * Update JSON schema to reflect required nullable * Add dirty-equals to pyproject.toml * Fix locs and error creation for use with pydantic 2.0a4 * Use the type adapter for serialization. This is hacky. * 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks * ✅ Refactor test_multi_body_errors for compatibility with Pydantic v1 and v2 * ✅ Refactor test_custom_encoder for Pydantic v1 and v2 * ✅ Set input to None for now, for compatibility with current tests * 🐛 Fix passing serialization params to model field when handling the response * ♻️ Refactor exceptions to not depend on Pydantic ValidationError class * ♻️ Revert/refactor params to simplify repr * ✅ Tweak tests for custom class encoders for Pydantic v1 and v2 * ✅ Tweak tests for jsonable_encoder for Pydantic v1 and v2 * ✅ Tweak test for compatibility with Pydantic v1 and v2 * 🐛 Fix filtering data with subclasses * 🐛 Workaround examples in OpenAPI schema * ✅ Add skip marker for SQL tutorial, needs to be updated either way * ✅ Update test for broken JSON * ✅ Fix test for broken JSON * ✅ Update tests for timedeltas * ✅ Fix test for plain text validation errors * ✅ Add markers for Pydantic v1 exclusive tests (for now) * ✅ Update test for path_params with enums for compatibility with Pydantic v1 and v2 * ✅ Update tests for extra examples in OpenAPI * ✅ Fix tests for response_model with compatibility with Pydantic v1 and v2 * 🐛 Fix required double serialization for different types of models * ✅ Fix tests for response model with compatibility with new Pydantic v2 * 🐛 Import Undefined from compat layer * ✅ Fix tests for response_model for Pydantic v2 * ✅ Fix tests for schema_extra for Pydantic v2 * ✅ Add markers and update tests for Pydantic v2 * 💡 Comment out logic for double encoding that breaks other usecases * ✅ Update errors for int parsing * ♻️ Refactor re-enabling compatibility for Pydantic v1 * ♻️ Refactor OpenAPI utils to re-enable support for Pydantic v1 * ♻️ Refactor dependencies/utils and _compat for compatibility with Pydantic v1 * 🐛 Fix and tweak compatibility with Pydantic v1 and v2 in dependencies/utils * ✅ Tweak tests and examples for Pydantic v1 * ♻️ Tweak call to ModelField.validate for compatibility with Pydantic v1 * ✨ Use new global override TypeAdapter from_attributes * ✅ Update tests after updating from_attributes * 🔧 Update pytest config to avoid collecting tests from docs, useful for editor-integrated tests * ✅ Add test for data filtering, including inheritance and models in fields or lists of models * ♻️ Make OpenAPI models compatible with both Pydantic v1 and v2 * ♻️ Fix compatibility for Pydantic v1 and v2 in jsonable_encoder * ♻️ Fix compatibility in params with Pydantic v1 and v2 * ♻️ Fix compatibility when creating a FieldInfo in Pydantic v1 and v2 in utils.py * ♻️ Fix generation of flat_models and JSON Schema definitions in _compat.py for Pydantic v1 and v2 * ♻️ Update handling of ErrorWrappers for Pydantic v1 * ♻️ Refactor checks and handling of types an sequences * ♻️ Refactor and cleanup comments with compatibility for Pydantic v1 and v2 * ♻️ Update UploadFile for compatibility with both Pydantic v1 and v2 * 🔥 Remove commented out unneeded code * 🐛 Fix mock of get_annotation_from_field_info for Pydantic v1 and v2 * 🐛 Fix params with compatibility for Pydantic v1 and v2, with schemas and new pattern vs regex * 🐛 Fix check if field is sequence for Pydantic v1 * ✅ Fix tests for custom_schema_fields, for compatibility with Pydantic v1 and v2 * ✅ Simplify and fix tests for jsonable_encoder with compatibility for Pydantic v1 and v2 * ✅ Fix tests for orm_mode with Pydantic v1 and compatibility with Pydantic v2 * ♻️ Refactor logic for normalizing Pydantic v1 ErrorWrappers * ♻️ Workaround for params with examples, before defining what to deprecate in Pydantic v1 and v2 for examples with JSON Schema vs OpenAPI * ✅ Fix tests for Pydantic v1 and v2 for response_by_alias * ✅ Fix test for schema_extra with compatibility with Pydantic v1 and v2 * ♻️ Tweak error regeneration with loc * ♻️ Update error handling and serializationwith compatibility for Pydantic v1 and v2 * ♻️ Re-enable custom encoders for Pydantic v1 * ♻️ Update ErrorWrapper reserialization in Pydantic v1, do it outside of FastAPI ValidationExceptions * ✅ Update test for filter_submodel, re-structure to simplify testing while keeping division of Pydantic v1 and v2 * ✅ Refactor Pydantic v1 only test that requires modifying environment variables * 🔥 Update test for plaintext error responses, for Pydantic v1 and v2 * ⏪️ Revert changes in DB tutorial to use Pydantic v1 (the new guide will have SQLModel) * ✅ Mark current SQL DB tutorial tests as Pydantic only * ♻️ Update datastructures for compatibility with Pydantic v1, not requiring pydantic-core * ♻️ Update encoders.py for compatibility with Pydantic v1 * ⏪️ Revert changes to Peewee, the docs for that are gonna live in a new HowTo section, not in the main tutorials * ♻️ Simplify response body kwargs generation * 🔥 Clean up comments * 🔥 Clean some tests and comments * ✅ Refactor tests to match new Pydantic error string URLs * ✅ Refactor tests for recursive models for Pydantic v1 and v2 * ✅ Update tests for Peewee, re-enable, Pydantic-v1-only * ♻️ Update FastAPI params to take regex and pattern arguments * ⏪️ Revert tutorial examples for pattern, it will be done in a subsequent PR * ⏪️ Revert changes in schema extra examples, it will be added later in a docs-specific PR * 💡 Add TODO comment to document str validations with pattern * 🔥 Remove unneeded comment * 📌 Upgrade Pydantic pin dependency * ⬆️ Upgrade email_validator dependency * 🐛 Tweak type annotations in _compat.py * 🔇 Tweak mypy errors for compat, for Pydantic v1 re-imports * 🐛 Tweak and fix type annotations * ➕ Update requirements-test.txt, re-add dirty-equals * 🔥 Remove unnecessary config * 🐛 Tweak type annotations * 🔥 Remove unnecessary type in dependencies/utils.py * 💡 Update comment in routing.py --------- Co-authored-by: David Montague <35119617+dmontagu@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- docs_src/extra_models/tutorial003.py | 4 +- docs_src/extra_models/tutorial003_py310.py | 4 +- fastapi/_compat.py | 614 ++++++++ fastapi/applications.py | 43 +- fastapi/datastructures.py | 35 +- fastapi/dependencies/models.py | 2 +- fastapi/dependencies/utils.py | 205 +-- fastapi/encoders.py | 103 +- fastapi/exceptions.py | 28 +- fastapi/openapi/constants.py | 1 + fastapi/openapi/models.py | 216 ++- fastapi/openapi/utils.py | 101 +- fastapi/param_functions.py | 16 +- fastapi/params.py | 65 +- fastapi/routing.py | 125 +- fastapi/security/oauth2.py | 31 +- fastapi/types.py | 10 +- fastapi/utils.py | 121 +- pyproject.toml | 5 +- requirements-tests.txt | 4 +- ...onal_responses_custom_model_in_callback.py | 26 +- tests/test_annotated.py | 49 +- tests/test_application.py | 23 +- tests/test_custom_schema_fields.py | 15 +- tests/test_datetime_custom_encoder.py | 53 +- tests/test_dependency_duplicates.py | 35 +- tests/test_dependency_overrides.py | 666 +++++--- tests/test_extra_routes.py | 10 +- .../__init__.py | 0 .../test_filter_pydantic_sub_model/app_pv1.py | 35 + .../test_filter_pydantic_sub_model_pv1.py} | 52 +- tests/test_filter_pydantic_sub_model_pv2.py | 182 +++ tests/test_infer_param_optionality.py | 277 +++- tests/test_inherited_custom_class.py | 86 +- tests/test_jsonable_encoder.py | 139 +- tests/test_multi_body_errors.py | 160 +- tests/test_multi_query_errors.py | 55 +- .../test_openapi_query_parameter_extension.py | 21 +- tests/test_openapi_servers.py | 15 +- tests/test_params_repr.py | 135 +- tests/test_path.py | 1362 ++++++++++++++--- tests/test_query.py | 460 +++++- tests/test_read_with_orm_mode.py | 83 +- ...test_request_body_parameters_media_type.py | 1 - tests/test_response_by_alias.py | 28 +- ...est_response_model_as_return_annotation.py | 16 +- tests/test_response_model_data_filter.py | 79 + ...sponse_model_data_filter_no_inheritance.py | 81 + tests/test_schema_extra_examples.py | 113 +- tests/test_security_oauth2.py | 209 ++- tests/test_security_oauth2_optional.py | 209 ++- ...st_security_oauth2_optional_description.py | 209 ++- tests/test_skip_defaults.py | 2 +- tests/test_sub_callbacks.py | 43 +- tests/test_tuples.py | 91 +- .../test_tutorial002.py | 12 +- .../test_tutorial004.py | 12 +- .../test_tutorial001.py | 4 + .../test_behind_a_proxy/test_tutorial003.py | 18 +- .../test_behind_a_proxy/test_tutorial004.py | 18 +- .../test_bigger_applications/test_main.py | 476 ++++-- .../test_bigger_applications/test_main_an.py | 476 ++++-- .../test_main_an_py39.py | 470 ++++-- .../test_body/test_tutorial001.py | 459 ++++-- .../test_body/test_tutorial001_py310.py | 432 ++++-- .../test_body_fields/test_tutorial001.py | 153 +- .../test_body_fields/test_tutorial001_an.py | 153 +- .../test_tutorial001_an_py310.py | 145 +- .../test_tutorial001_an_py39.py | 145 +- .../test_tutorial001_py310.py | 145 +- .../test_tutorial001.py | 147 +- .../test_tutorial001_an.py | 147 +- .../test_tutorial001_an_py310.py | 140 +- .../test_tutorial001_an_py39.py | 140 +- .../test_tutorial001_py310.py | 140 +- .../test_tutorial003.py | 250 ++- .../test_tutorial003_an.py | 250 ++- .../test_tutorial003_an_py310.py | 242 ++- .../test_tutorial003_an_py39.py | 242 ++- .../test_tutorial003_py310.py | 242 ++- .../test_tutorial009.py | 50 +- .../test_tutorial009_py39.py | 35 +- .../test_body_updates/test_tutorial001.py | 49 +- .../test_tutorial001_py310.py | 34 +- .../test_tutorial001_py39.py | 34 +- .../test_tutorial001.py | 26 +- .../test_cookie_params/test_tutorial001.py | 12 +- .../test_cookie_params/test_tutorial001_an.py | 12 +- .../test_tutorial001_an_py310.py | 12 +- .../test_tutorial001_an_py39.py | 12 +- .../test_tutorial001_py310.py | 12 +- .../test_tutorial002.py | 43 +- .../test_dataclasses/test_tutorial001.py | 57 +- .../test_dataclasses/test_tutorial002.py | 47 +- .../test_dataclasses/test_tutorial003.py | 33 +- .../test_dependencies/test_tutorial001.py | 23 +- .../test_dependencies/test_tutorial001_an.py | 23 +- .../test_tutorial001_an_py310.py | 23 +- .../test_tutorial001_an_py39.py | 23 +- .../test_tutorial001_py310.py | 23 +- .../test_dependencies/test_tutorial004.py | 12 +- .../test_dependencies/test_tutorial004_an.py | 12 +- .../test_tutorial004_an_py310.py | 12 +- .../test_tutorial004_an_py39.py | 12 +- .../test_tutorial004_py310.py | 12 +- .../test_dependencies/test_tutorial006.py | 52 +- .../test_dependencies/test_tutorial006_an.py | 52 +- .../test_tutorial006_an_py39.py | 52 +- .../test_dependencies/test_tutorial012.py | 102 +- .../test_dependencies/test_tutorial012_an.py | 102 +- .../test_tutorial012_an_py39.py | 102 +- .../test_extra_data_types/test_tutorial001.py | 108 +- .../test_tutorial001_an.py | 108 +- .../test_tutorial001_an_py310.py | 108 +- .../test_tutorial001_an_py39.py | 108 +- .../test_tutorial001_py310.py | 108 +- .../test_handling_errors/test_tutorial004.py | 18 +- .../test_handling_errors/test_tutorial005.py | 38 +- .../test_handling_errors/test_tutorial006.py | 35 +- .../test_header_params/test_tutorial001.py | 14 +- .../test_header_params/test_tutorial001_an.py | 12 +- .../test_tutorial001_an_py310.py | 12 +- .../test_tutorial001_py310.py | 12 +- .../test_header_params/test_tutorial002.py | 12 +- .../test_header_params/test_tutorial002_an.py | 12 +- .../test_tutorial002_an_py310.py | 12 +- .../test_tutorial002_an_py39.py | 12 +- .../test_tutorial002_py310.py | 12 +- .../test_header_params/test_tutorial003.py | 24 +- .../test_header_params/test_tutorial003_an.py | 24 +- .../test_tutorial003_an_py310.py | 24 +- .../test_tutorial003_an_py39.py | 30 +- .../test_tutorial003_py310.py | 24 +- .../test_tutorial001.py | 43 +- .../test_tutorial004.py | 23 +- .../test_tutorial007.py | 27 +- .../test_tutorial005.py | 23 +- .../test_tutorial005_py310.py | 23 +- .../test_tutorial005_py39.py | 23 +- .../test_path_params/test_tutorial005.py | 110 +- .../test_query_params/test_tutorial005.py | 55 +- .../test_query_params/test_tutorial006.py | 129 +- .../test_tutorial006_py310.py | 123 +- .../test_tutorial010.py | 124 +- .../test_tutorial010_an.py | 126 +- .../test_tutorial010_an_py310.py | 119 +- .../test_tutorial010_an_py39.py | 119 +- .../test_tutorial010_py310.py | 119 +- .../test_tutorial011.py | 23 +- .../test_tutorial011_an.py | 23 +- .../test_tutorial011_an_py310.py | 23 +- .../test_tutorial011_an_py39.py | 23 +- .../test_tutorial011_py310.py | 23 +- .../test_tutorial011_py39.py | 23 +- .../test_request_files/test_tutorial001.py | 52 +- .../test_request_files/test_tutorial001_02.py | 67 +- .../test_tutorial001_02_an.py | 67 +- .../test_tutorial001_02_an_py310.py | 67 +- .../test_tutorial001_02_an_py39.py | 67 +- .../test_tutorial001_02_py310.py | 67 +- .../test_request_files/test_tutorial001_an.py | 63 +- .../test_tutorial001_an_py39.py | 63 +- .../test_request_files/test_tutorial002.py | 63 +- .../test_request_files/test_tutorial002_an.py | 63 +- .../test_tutorial002_an_py39.py | 63 +- .../test_tutorial002_py39.py | 52 +- .../test_request_forms/test_tutorial001.py | 190 ++- .../test_request_forms/test_tutorial001_an.py | 190 ++- .../test_tutorial001_an_py39.py | 183 ++- .../test_tutorial001.py | 248 ++- .../test_tutorial001_an.py | 248 ++- .../test_tutorial001_an_py39.py | 227 ++- .../test_response_model/test_tutorial003.py | 23 +- .../test_tutorial003_01.py | 23 +- .../test_tutorial003_01_py310.py | 23 +- .../test_tutorial003_py310.py | 23 +- .../test_response_model/test_tutorial004.py | 12 +- .../test_tutorial004_py310.py | 12 +- .../test_tutorial004_py39.py | 12 +- .../test_response_model/test_tutorial005.py | 12 +- .../test_tutorial005_py310.py | 12 +- .../test_response_model/test_tutorial006.py | 12 +- .../test_tutorial006_py310.py | 12 +- .../test_tutorial004.py | 23 +- .../test_tutorial004_an.py | 23 +- .../test_tutorial004_an_py310.py | 23 +- .../test_tutorial004_an_py39.py | 23 +- .../test_tutorial004_py310.py | 23 +- .../test_security/test_tutorial003.py | 45 +- .../test_security/test_tutorial003_an.py | 45 +- .../test_tutorial003_an_py310.py | 45 +- .../test_security/test_tutorial003_an_py39.py | 45 +- .../test_security/test_tutorial003_py310.py | 45 +- .../test_security/test_tutorial005.py | 78 +- .../test_security/test_tutorial005_an.py | 78 +- .../test_tutorial005_an_py310.py | 78 +- .../test_security/test_tutorial005_an_py39.py | 78 +- .../test_security/test_tutorial005_py310.py | 78 +- .../test_security/test_tutorial005_py39.py | 78 +- .../test_tutorial/test_settings/test_app02.py | 13 +- .../test_sql_databases/test_sql_databases.py | 41 +- .../test_sql_databases_middleware.py | 41 +- .../test_sql_databases_middleware_py310.py | 41 +- .../test_sql_databases_middleware_py39.py | 41 +- .../test_sql_databases_py310.py | 41 +- .../test_sql_databases_py39.py | 41 +- .../test_testing_databases.py | 4 + .../test_testing_databases_py310.py | 4 +- .../test_testing_databases_py39.py | 4 +- .../test_sql_databases_peewee.py | 10 + tests/test_union_body.py | 14 +- tests/test_union_inherited_body.py | 25 +- tests/test_validate_response.py | 11 +- tests/test_validate_response_dataclass.py | 8 +- .../__init__.py | 0 .../app_pv1.py} | 30 - .../app_pv2.py | 51 + .../test_validate_response_recursive_pv1.py | 33 + .../test_validate_response_recursive_pv2.py | 33 + tests/utils.py | 3 + 220 files changed, 14246 insertions(+), 4374 deletions(-) create mode 100644 fastapi/_compat.py create mode 100644 tests/test_filter_pydantic_sub_model/__init__.py create mode 100644 tests/test_filter_pydantic_sub_model/app_pv1.py rename tests/{test_filter_pydantic_sub_model.py => test_filter_pydantic_sub_model/test_filter_pydantic_sub_model_pv1.py} (81%) create mode 100644 tests/test_filter_pydantic_sub_model_pv2.py create mode 100644 tests/test_response_model_data_filter.py create mode 100644 tests/test_response_model_data_filter_no_inheritance.py create mode 100644 tests/test_validate_response_recursive/__init__.py rename tests/{test_validate_response_recursive.py => test_validate_response_recursive/app_pv1.py} (58%) create mode 100644 tests/test_validate_response_recursive/app_pv2.py create mode 100644 tests/test_validate_response_recursive/test_validate_response_recursive_pv1.py create mode 100644 tests/test_validate_response_recursive/test_validate_response_recursive_pv2.py diff --git a/docs_src/extra_models/tutorial003.py b/docs_src/extra_models/tutorial003.py index 065439acc..06675cbc0 100644 --- a/docs_src/extra_models/tutorial003.py +++ b/docs_src/extra_models/tutorial003.py @@ -12,11 +12,11 @@ class BaseItem(BaseModel): class CarItem(BaseItem): - type = "car" + type: str = "car" class PlaneItem(BaseItem): - type = "plane" + type: str = "plane" size: int diff --git a/docs_src/extra_models/tutorial003_py310.py b/docs_src/extra_models/tutorial003_py310.py index 065439acc..06675cbc0 100644 --- a/docs_src/extra_models/tutorial003_py310.py +++ b/docs_src/extra_models/tutorial003_py310.py @@ -12,11 +12,11 @@ class BaseItem(BaseModel): class CarItem(BaseItem): - type = "car" + type: str = "car" class PlaneItem(BaseItem): - type = "plane" + type: str = "plane" size: int diff --git a/fastapi/_compat.py b/fastapi/_compat.py new file mode 100644 index 000000000..61db2c83d --- /dev/null +++ b/fastapi/_compat.py @@ -0,0 +1,614 @@ +from collections import deque +from copy import copy +from dataclasses import dataclass, is_dataclass +from enum import Enum +from typing import ( + Any, + Callable, + Deque, + Dict, + FrozenSet, + List, + Mapping, + Sequence, + Set, + Tuple, + Type, + Union, +) + +from fastapi.exceptions import RequestErrorModel +from fastapi.types import IncEx, ModelNameMap, UnionType +from pydantic import BaseModel, create_model +from pydantic.version import VERSION as PYDANTIC_VERSION +from starlette.datastructures import UploadFile +from typing_extensions import Annotated, Literal, get_args, get_origin + +PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.") + + +sequence_annotation_to_type = { + Sequence: list, + List: list, + list: list, + Tuple: tuple, + tuple: tuple, + Set: set, + set: set, + FrozenSet: frozenset, + frozenset: frozenset, + Deque: deque, + deque: deque, +} + +sequence_types = tuple(sequence_annotation_to_type.keys()) + +if PYDANTIC_V2: + from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError + from pydantic import TypeAdapter + from pydantic import ValidationError as ValidationError + from pydantic._internal._fields import Undefined as Undefined + from pydantic._internal._fields import _UndefinedType + from pydantic._internal._schema_generation_shared import ( # type: ignore[attr-defined] + GetJsonSchemaHandler as GetJsonSchemaHandler, + ) + from pydantic._internal._typing_extra import eval_type_lenient + from pydantic._internal._utils import lenient_issubclass as lenient_issubclass + from pydantic.fields import FieldInfo + from pydantic.json_schema import GenerateJsonSchema as GenerateJsonSchema + from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue + from pydantic_core import CoreSchema as CoreSchema + from pydantic_core import ErrorDetails + from pydantic_core import MultiHostUrl as MultiHostUrl + from pydantic_core import Url as Url + from pydantic_core.core_schema import ( + general_plain_validator_function as general_plain_validator_function, + ) + + Required = Undefined + UndefinedType = _UndefinedType + evaluate_forwardref = eval_type_lenient + Validator = Any + + class BaseConfig: + pass + + class ErrorWrapper(Exception): + pass + + @dataclass + class ModelField: + field_info: FieldInfo + name: str + + @property + def alias(self) -> str: + a = self.field_info.alias + return a if a is not None else self.name + + @property + def required(self) -> bool: + return self.field_info.is_required() + + @property + def default(self) -> Any: + return self.get_default() + + @property + def type_(self) -> Any: + return self.field_info.annotation + + def __post_init__(self) -> None: + self._type_adapter: TypeAdapter[Any] = TypeAdapter( + Annotated[self.field_info.annotation, self.field_info] + ) + + def get_default(self) -> Any: + if self.field_info.is_required(): + return Undefined + return self.field_info.get_default(call_default_factory=True) + + def validate( + self, + value: Any, + values: Dict[str, Any] = {}, # noqa: B006 + *, + loc: Union[Tuple[Union[int, str], ...], str] = "", + ) -> tuple[Any, Union[List[ValidationError], None]]: + try: + return ( + self._type_adapter.validate_python(value, from_attributes=True), + None, + ) + except ValidationError as exc: + if isinstance(loc, tuple): + use_loc = loc + elif loc == "": + use_loc = () + else: + use_loc = (loc,) + return None, _regenerate_error_with_loc( + errors=exc.errors(), loc_prefix=use_loc + ) + + def serialize( + self, + value: Any, + *, + mode: Literal["json", "python"] = "json", + include: Union[IncEx, None] = None, + exclude: Union[IncEx, None] = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + ) -> Any: + # What calls this code passes a value that already called + # self._type_adapter.validate_python(value) + return self._type_adapter.dump_python( + value, + mode=mode, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + def __hash__(self) -> int: + # Each ModelField is unique for our purposes, to allow making a dict from + # ModelField to its JSON Schema. + return id(self) + + def get_model_definitions( + *, + flat_models: Set[Union[Type[BaseModel], Type[Enum]]], + model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str], + ) -> Dict[str, Any]: + return {} + + def get_annotation_from_field_info( + annotation: Any, field_info: FieldInfo, field_name: str + ) -> Any: + return annotation + + def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]: + return errors # type: ignore[return-value] + +else: + from fastapi.openapi.constants import REF_PREFIX as REF_PREFIX + from pydantic import AnyUrl as Url # noqa: F401 + from pydantic import ( # type: ignore[assignment] + BaseConfig as BaseConfig, # noqa: F401 + ) + from pydantic import ValidationError as ValidationError # noqa: F401 + from pydantic.class_validators import ( # type: ignore[no-redef] + Validator as Validator, # noqa: F401 + ) + from pydantic.error_wrappers import ( # type: ignore[no-redef] + ErrorWrapper as ErrorWrapper, # noqa: F401 + ) + from pydantic.errors import MissingError + from pydantic.fields import ( # type: ignore[attr-defined] + SHAPE_FROZENSET, + SHAPE_LIST, + SHAPE_SEQUENCE, + SHAPE_SET, + SHAPE_SINGLETON, + SHAPE_TUPLE, + SHAPE_TUPLE_ELLIPSIS, + ) + from pydantic.fields import FieldInfo as FieldInfo + from pydantic.fields import ( # type: ignore[no-redef,attr-defined] + ModelField as ModelField, # noqa: F401 + ) + from pydantic.fields import ( # type: ignore[no-redef,attr-defined] + Required as Required, # noqa: F401 + ) + from pydantic.fields import ( # type: ignore[no-redef,attr-defined] + Undefined as Undefined, + ) + from pydantic.fields import ( # type: ignore[no-redef, attr-defined] + UndefinedType as UndefinedType, # noqa: F401 + ) + from pydantic.networks import ( # type: ignore[no-redef] + MultiHostDsn as MultiHostUrl, # noqa: F401 + ) + from pydantic.schema import ( + field_schema, + get_flat_models_from_fields, + get_model_name_map, + model_process_schema, + ) + from pydantic.schema import ( # type: ignore[no-redef] # noqa: F401 + get_annotation_from_field_info as get_annotation_from_field_info, + ) + from pydantic.typing import ( # type: ignore[no-redef] + evaluate_forwardref as evaluate_forwardref, # noqa: F401 + ) + from pydantic.utils import ( # type: ignore[no-redef] + lenient_issubclass as lenient_issubclass, # noqa: F401 + ) + + ErrorDetails = Dict[str, Any] # type: ignore[assignment,misc] + GetJsonSchemaHandler = Any # type: ignore[assignment,misc] + JsonSchemaValue = Dict[str, Any] # type: ignore[misc] + CoreSchema = Any # type: ignore[assignment,misc] + + sequence_shapes = { + SHAPE_LIST, + SHAPE_SET, + SHAPE_FROZENSET, + SHAPE_TUPLE, + SHAPE_SEQUENCE, + SHAPE_TUPLE_ELLIPSIS, + } + sequence_shape_to_type = { + SHAPE_LIST: list, + SHAPE_SET: set, + SHAPE_TUPLE: tuple, + SHAPE_SEQUENCE: list, + SHAPE_TUPLE_ELLIPSIS: list, + } + + @dataclass + class GenerateJsonSchema: # type: ignore[no-redef] + ref_template: str + + class PydanticSchemaGenerationError(Exception): # type: ignore[no-redef] + pass + + def general_plain_validator_function( # type: ignore[misc] + function: Callable[..., Any], + *, + ref: Union[str, None] = None, + metadata: Any = None, + serialization: Any = None, + ) -> Any: + return {} + + def get_model_definitions( + *, + flat_models: Set[Union[Type[BaseModel], Type[Enum]]], + model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str], + ) -> Dict[str, Any]: + definitions: Dict[str, Dict[str, Any]] = {} + for model in flat_models: + m_schema, m_definitions, m_nested_models = model_process_schema( + model, model_name_map=model_name_map, ref_prefix=REF_PREFIX + ) + definitions.update(m_definitions) + model_name = model_name_map[model] + if "description" in m_schema: + m_schema["description"] = m_schema["description"].split("\f")[0] + definitions[model_name] = m_schema + return definitions + + def is_pv1_scalar_field(field: ModelField) -> bool: + from fastapi import params + + field_info = field.field_info + if not ( + field.shape == SHAPE_SINGLETON # type: ignore[attr-defined] + and not lenient_issubclass(field.type_, BaseModel) + and not lenient_issubclass(field.type_, dict) + and not field_annotation_is_sequence(field.type_) + and not is_dataclass(field.type_) + and not isinstance(field_info, params.Body) + ): + return False + if field.sub_fields: # type: ignore[attr-defined] + if not all( + is_pv1_scalar_field(f) + for f in field.sub_fields # type: ignore[attr-defined] + ): + return False + return True + + def is_pv1_scalar_sequence_field(field: ModelField) -> bool: + if (field.shape in sequence_shapes) and not lenient_issubclass( # type: ignore[attr-defined] + field.type_, BaseModel + ): + if field.sub_fields is not None: # type: ignore[attr-defined] + for sub_field in field.sub_fields: # type: ignore[attr-defined] + if not is_pv1_scalar_field(sub_field): + return False + return True + if _annotation_is_sequence(field.type_): + return True + return False + + def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]: + use_errors: List[Any] = [] + for error in errors: + if isinstance(error, ErrorWrapper): + new_errors = ValidationError( # type: ignore[call-arg] + errors=[error], model=RequestErrorModel + ).errors() + use_errors.extend(new_errors) + elif isinstance(error, list): + use_errors.extend(_normalize_errors(error)) + else: + use_errors.append(error) + return use_errors + + +def _regenerate_error_with_loc( + *, errors: Sequence[Any], loc_prefix: Tuple[Union[str, int], ...] +) -> List[ValidationError]: + updated_loc_errors: List[Any] = [ + {**err, "loc": loc_prefix + err.get("loc", ())} + for err in _normalize_errors(errors) + ] + + return updated_loc_errors + + +def _model_rebuild(model: Type[BaseModel]) -> None: + if PYDANTIC_V2: + model.model_rebuild() + else: + model.update_forward_refs() + + +def _model_dump( + model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any +) -> Any: + if PYDANTIC_V2: + return model.model_dump(mode=mode, **kwargs) + else: + return model.dict(**kwargs) + + +def _get_model_config(model: BaseModel) -> Any: + if PYDANTIC_V2: + return model.model_config + else: + return model.__config__ # type: ignore[attr-defined] + + +def get_schema_from_model_field( + *, + field: ModelField, + schema_generator: GenerateJsonSchema, + model_name_map: ModelNameMap, +) -> Dict[str, Any]: + # This expects that GenerateJsonSchema was already used to generate the definitions + if PYDANTIC_V2: + json_schema = schema_generator.generate_inner(field._type_adapter.core_schema) + if "$ref" not in json_schema: + # TODO remove when deprecating Pydantic v1 + # Ref: https://github.com/pydantic/pydantic/blob/d61792cc42c80b13b23e3ffa74bc37ec7c77f7d1/pydantic/schema.py#L207 + json_schema[ + "title" + ] = field.field_info.title or field.alias.title().replace("_", " ") + return json_schema + else: + return field_schema( # type: ignore[no-any-return] + field, model_name_map=model_name_map, ref_prefix=REF_PREFIX + )[0] + + +def get_compat_model_name_map(fields: List[ModelField]) -> ModelNameMap: + if PYDANTIC_V2: + return {} + else: + models = get_flat_models_from_fields(fields, known_models=set()) + return get_model_name_map(models) # type: ignore[no-any-return] + + +def get_definitions( + *, + fields: List[ModelField], + schema_generator: GenerateJsonSchema, + model_name_map: ModelNameMap, +) -> Dict[str, Dict[str, Any]]: + if PYDANTIC_V2: + inputs = [ + (field, "validation", field._type_adapter.core_schema) for field in fields + ] + _, definitions = schema_generator.generate_definitions(inputs=inputs) # type: ignore[arg-type] + return definitions # type: ignore[return-value] + else: + models = get_flat_models_from_fields(fields, known_models=set()) + return get_model_definitions(flat_models=models, model_name_map=model_name_map) + + +def _annotation_is_sequence(annotation: type[Any] | None) -> bool: + if lenient_issubclass(annotation, (str, bytes)): + return False + return lenient_issubclass(annotation, sequence_types) + + +def field_annotation_is_sequence(annotation: type[Any] | None) -> bool: + return _annotation_is_sequence(annotation) or _annotation_is_sequence( + get_origin(annotation) + ) + + +def value_is_sequence(value: Any) -> bool: + return isinstance(value, sequence_types) and not isinstance(value, (str, bytes)) # type: ignore[arg-type] + + +def _annotation_is_complex(annotation: Type[Any] | None) -> bool: + return ( + lenient_issubclass(annotation, (BaseModel, Mapping, UploadFile)) + or _annotation_is_sequence(annotation) + or is_dataclass(annotation) + ) + + +def field_annotation_is_complex(annotation: type[Any] | None) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + return any(field_annotation_is_complex(arg) for arg in get_args(annotation)) + + return ( + _annotation_is_complex(annotation) + or _annotation_is_complex(origin) + or hasattr(origin, "__pydantic_core_schema__") + or hasattr(origin, "__get_pydantic_core_schema__") + ) + + +def field_annotation_is_scalar(annotation: Any) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + return all(field_annotation_is_scalar(arg) for arg in get_args(annotation)) + + # handle Ellipsis here to make tuple[int, ...] work nicely + return annotation is Ellipsis or not field_annotation_is_complex(annotation) + + +def field_annotation_is_scalar_sequence(annotation: type[Any] | None) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + at_least_one_scalar_sequence = False + for arg in get_args(annotation): + if field_annotation_is_scalar_sequence(arg): + at_least_one_scalar_sequence = True + continue + elif not field_annotation_is_scalar(arg): + return False + return at_least_one_scalar_sequence + return field_annotation_is_sequence(annotation) and all( + field_annotation_is_scalar(sub_annotation) + for sub_annotation in get_args(annotation) + ) + + +def is_scalar_field(field: ModelField) -> bool: + from fastapi import params + + if PYDANTIC_V2: + return field_annotation_is_scalar( + field.field_info.annotation + ) and not isinstance(field.field_info, params.Body) + else: + return is_pv1_scalar_field(field) + + +def is_sequence_field(field: ModelField) -> bool: + if PYDANTIC_V2: + return field_annotation_is_sequence(field.field_info.annotation) + else: + return field.shape in sequence_shapes or _annotation_is_sequence(field.type_) # type: ignore[attr-defined] + + +def is_scalar_sequence_field(field: ModelField) -> bool: + if PYDANTIC_V2: + return field_annotation_is_scalar_sequence(field.field_info.annotation) + else: + return is_pv1_scalar_sequence_field(field) + + +def is_bytes_or_nonable_bytes_annotation(annotation: Any) -> bool: + if lenient_issubclass(annotation, bytes): + return True + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + for arg in get_args(annotation): + if lenient_issubclass(arg, bytes): + return True + return False + + +def is_uploadfile_or_nonable_uploadfile_annotation(annotation: Any) -> bool: + if lenient_issubclass(annotation, UploadFile): + return True + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + for arg in get_args(annotation): + if lenient_issubclass(arg, UploadFile): + return True + return False + + +def is_bytes_sequence_annotation(annotation: type[Any] | None) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + at_least_one_bytes_sequence = False + for arg in get_args(annotation): + if is_bytes_sequence_annotation(arg): + at_least_one_bytes_sequence = True + continue + return at_least_one_bytes_sequence + return field_annotation_is_sequence(annotation) and all( + is_bytes_or_nonable_bytes_annotation(sub_annotation) + for sub_annotation in get_args(annotation) + ) + + +def is_uploadfile_sequence_annotation(annotation: type[Any] | None) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + at_least_one_bytes_sequence = False + for arg in get_args(annotation): + if is_uploadfile_sequence_annotation(arg): + at_least_one_bytes_sequence = True + continue + return at_least_one_bytes_sequence + return field_annotation_is_sequence(annotation) and all( + is_uploadfile_or_nonable_uploadfile_annotation(sub_annotation) + for sub_annotation in get_args(annotation) + ) + + +def is_bytes_field(field: ModelField) -> bool: + if PYDANTIC_V2: + return is_bytes_or_nonable_bytes_annotation(field.type_) + else: + return lenient_issubclass(field.type_, bytes) + + +def is_bytes_sequence_field(field: ModelField) -> bool: + if PYDANTIC_V2: + return is_bytes_sequence_annotation(field.type_) + else: + return field.shape in sequence_shapes and lenient_issubclass(field.type_, bytes) # type: ignore[attr-defined] + + +def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo: + if PYDANTIC_V2: + return type(field_info).from_annotation(annotation) + else: + return copy(field_info) + + +def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: + if PYDANTIC_V2: + origin_type = ( + get_origin(field.field_info.annotation) or field.field_info.annotation + ) + assert issubclass(origin_type, sequence_types) # type: ignore[arg-type] + return sequence_annotation_to_type[origin_type](value) # type: ignore[no-any-return] + else: + return sequence_shape_to_type[field.shape](value) # type: ignore[no-any-return,attr-defined] + + +def get_missing_field_error(loc: Tuple[str, ...]) -> ValidationError: + if PYDANTIC_V2: + error = ValidationError.from_exception_data( + "Field required", [{"type": "missing", "loc": loc, "input": {}}] + ).errors()[0] + error["input"] = None + return error # type: ignore[return-value] + else: + missing_field_error = ErrorWrapper(MissingError(), loc=loc) # type: ignore[call-arg] + new_error = ValidationError([missing_field_error], RequestErrorModel) + return new_error.errors()[0] # type: ignore[return-value] + + +def create_body_model( + *, fields: Sequence[ModelField], model_name: str +) -> Type[BaseModel]: + if PYDANTIC_V2: + field_params = {f.name: (f.field_info.annotation, f.field_info) for f in fields} + BodyModel: Type[BaseModel] = create_model(model_name, **field_params) # type: ignore[call-overload] + return BodyModel + else: + BodyModel = create_model(model_name) + for f in fields: + BodyModel.__fields__[f.name] = f # type: ignore[index] + return BodyModel diff --git a/fastapi/applications.py b/fastapi/applications.py index 298aca921..8d0cfa961 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -15,7 +15,6 @@ from typing import ( from fastapi import routing from fastapi.datastructures import Default, DefaultPlaceholder -from fastapi.encoders import DictIntStrAny, SetIntStr from fastapi.exception_handlers import ( http_exception_handler, request_validation_exception_handler, @@ -31,7 +30,7 @@ from fastapi.openapi.docs import ( ) from fastapi.openapi.utils import get_openapi from fastapi.params import Depends -from fastapi.types import DecoratedCallable +from fastapi.types import DecoratedCallable, IncEx from fastapi.utils import generate_unique_id from starlette.applications import Starlette from starlette.datastructures import State @@ -297,8 +296,8 @@ class FastAPI(Starlette): deprecated: Optional[bool] = None, methods: Optional[List[str]] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -355,8 +354,8 @@ class FastAPI(Starlette): deprecated: Optional[bool] = None, methods: Optional[List[str]] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -476,8 +475,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -531,8 +530,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -586,8 +585,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -641,8 +640,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -696,8 +695,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -751,8 +750,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -806,8 +805,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -861,8 +860,8 @@ class FastAPI(Starlette): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py index b20a25ab6..3c96c56c7 100644 --- a/fastapi/datastructures.py +++ b/fastapi/datastructures.py @@ -1,5 +1,12 @@ -from typing import Any, Callable, Dict, Iterable, Type, TypeVar - +from typing import Any, Callable, Dict, Iterable, Type, TypeVar, cast + +from fastapi._compat import ( + PYDANTIC_V2, + CoreSchema, + GetJsonSchemaHandler, + JsonSchemaValue, + general_plain_validator_function, +) from starlette.datastructures import URL as URL # noqa: F401 from starlette.datastructures import Address as Address # noqa: F401 from starlette.datastructures import FormData as FormData # noqa: F401 @@ -21,8 +28,28 @@ class UploadFile(StarletteUploadFile): return v @classmethod - def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: - field_schema.update({"type": "string", "format": "binary"}) + def _validate(cls, __input_value: Any, _: Any) -> "UploadFile": + if not isinstance(__input_value, StarletteUploadFile): + raise ValueError(f"Expected UploadFile, received: {type(__input_value)}") + return cast(UploadFile, __input_value) + + if not PYDANTIC_V2: + + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None: + field_schema.update({"type": "string", "format": "binary"}) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + return {"type": "string", "format": "binary"} + + @classmethod + def __get_pydantic_core_schema__( + cls, source: Type[Any], handler: Callable[[Any], CoreSchema] + ) -> CoreSchema: + return general_plain_validator_function(cls._validate) class DefaultPlaceholder: diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py index 443590b9c..61ef00638 100644 --- a/fastapi/dependencies/models.py +++ b/fastapi/dependencies/models.py @@ -1,7 +1,7 @@ from typing import Any, Callable, List, Optional, Sequence +from fastapi._compat import ModelField from fastapi.security.base import SecurityBase -from pydantic.fields import ModelField class SecurityRequirement: diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index f131001ce..9d99f45d0 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1,7 +1,6 @@ -import dataclasses import inspect from contextlib import contextmanager -from copy import copy, deepcopy +from copy import deepcopy from typing import ( Any, Callable, @@ -20,6 +19,32 @@ from typing import ( import anyio from fastapi import params +from fastapi._compat import ( + PYDANTIC_V2, + ErrorWrapper, + ModelField, + Required, + Undefined, + ValidationError, + _regenerate_error_with_loc, + copy_field_info, + create_body_model, + evaluate_forwardref, + field_annotation_is_scalar, + get_annotation_from_field_info, + get_missing_field_error, + is_bytes_field, + is_bytes_sequence_field, + is_scalar_field, + is_scalar_sequence_field, + is_sequence_field, + is_uploadfile_or_nonable_uploadfile_annotation, + is_uploadfile_sequence_annotation, + lenient_issubclass, + sequence_types, + serialize_sequence_value, + value_is_sequence, +) from fastapi.concurrency import ( AsyncExitStack, asynccontextmanager, @@ -31,50 +56,14 @@ 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_response_field, get_path_param_names -from pydantic import BaseModel, create_model -from pydantic.error_wrappers import ErrorWrapper -from pydantic.errors import MissingError -from pydantic.fields import ( - SHAPE_FROZENSET, - SHAPE_LIST, - SHAPE_SEQUENCE, - SHAPE_SET, - SHAPE_SINGLETON, - SHAPE_TUPLE, - SHAPE_TUPLE_ELLIPSIS, - FieldInfo, - ModelField, - Required, - Undefined, -) -from pydantic.schema import get_annotation_from_field_info -from pydantic.typing import evaluate_forwardref, get_args, get_origin -from pydantic.utils import lenient_issubclass +from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks from starlette.concurrency import run_in_threadpool from starlette.datastructures import FormData, Headers, QueryParams, UploadFile from starlette.requests import HTTPConnection, Request from starlette.responses import Response from starlette.websockets import WebSocket -from typing_extensions import Annotated - -sequence_shapes = { - SHAPE_LIST, - SHAPE_SET, - SHAPE_FROZENSET, - SHAPE_TUPLE, - SHAPE_SEQUENCE, - SHAPE_TUPLE_ELLIPSIS, -} -sequence_types = (list, set, tuple) -sequence_shape_to_type = { - SHAPE_LIST: list, - SHAPE_SET: set, - SHAPE_TUPLE: tuple, - SHAPE_SEQUENCE: list, - SHAPE_TUPLE_ELLIPSIS: list, -} - +from typing_extensions import Annotated, get_args, get_origin multipart_not_installed_error = ( 'Form data requires "python-multipart" to be installed. \n' @@ -216,36 +205,6 @@ def get_flat_params(dependant: Dependant) -> List[ModelField]: ) -def is_scalar_field(field: ModelField) -> bool: - field_info = field.field_info - if not ( - field.shape == SHAPE_SINGLETON - and not lenient_issubclass(field.type_, BaseModel) - and not lenient_issubclass(field.type_, sequence_types + (dict,)) - and not dataclasses.is_dataclass(field.type_) - and not isinstance(field_info, params.Body) - ): - return False - if field.sub_fields: - if not all(is_scalar_field(f) for f in field.sub_fields): - return False - return True - - -def is_scalar_sequence_field(field: ModelField) -> bool: - if (field.shape in sequence_shapes) and not lenient_issubclass( - field.type_, BaseModel - ): - if field.sub_fields is not None: - for sub_field in field.sub_fields: - if not is_scalar_field(sub_field): - return False - return True - if lenient_issubclass(field.type_, sequence_types): - return True - return False - - def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: signature = inspect.signature(call) globalns = getattr(call, "__globals__", {}) @@ -364,12 +323,11 @@ def analyze_param( is_path_param: bool, ) -> Tuple[Any, Optional[params.Depends], Optional[ModelField]]: field_info = None - used_default_field_info = False depends = None type_annotation: Any = Any if ( annotation is not inspect.Signature.empty - and get_origin(annotation) is Annotated # type: ignore[comparison-overlap] + and get_origin(annotation) is Annotated ): annotated_args = get_args(annotation) type_annotation = annotated_args[0] @@ -384,7 +342,9 @@ def analyze_param( fastapi_annotation = next(iter(fastapi_annotations), None) if isinstance(fastapi_annotation, FieldInfo): # Copy `field_info` because we mutate `field_info.default` below. - field_info = copy(fastapi_annotation) + field_info = copy_field_info( + field_info=fastapi_annotation, annotation=annotation + ) assert field_info.default is Undefined or field_info.default is Required, ( f"`{field_info.__class__.__name__}` default value cannot be set in" f" `Annotated` for {param_name!r}. Set the default value with `=` instead." @@ -415,6 +375,8 @@ def analyze_param( f" together for {param_name!r}" ) field_info = value + if PYDANTIC_V2: + field_info.annotation = type_annotation if depends is not None and depends.dependency is None: depends.dependency = type_annotation @@ -433,10 +395,15 @@ def analyze_param( # We might check here that `default_value is Required`, but the fact is that the same # parameter might sometimes be a path parameter and sometimes not. See # `tests/test_infer_param_optionality.py` for an example. - field_info = params.Path() + field_info = params.Path(annotation=type_annotation) + elif is_uploadfile_or_nonable_uploadfile_annotation( + type_annotation + ) or is_uploadfile_sequence_annotation(type_annotation): + field_info = params.File(annotation=type_annotation, default=default_value) + elif not field_annotation_is_scalar(annotation=type_annotation): + field_info = params.Body(annotation=type_annotation, default=default_value) else: - field_info = params.Query(default=default_value) - used_default_field_info = True + field_info = params.Query(annotation=type_annotation, default=default_value) field = None if field_info is not None: @@ -450,8 +417,8 @@ def analyze_param( and getattr(field_info, "in_", None) is None ): field_info.in_ = params.ParamTypes.query - annotation = get_annotation_from_field_info( - annotation if annotation is not inspect.Signature.empty else Any, + use_annotation = get_annotation_from_field_info( + type_annotation, field_info, param_name, ) @@ -459,19 +426,15 @@ def analyze_param( alias = param_name.replace("_", "-") else: alias = field_info.alias or param_name + field_info.alias = alias field = create_response_field( name=param_name, - type_=annotation, + type_=use_annotation, default=field_info.default, alias=alias, required=field_info.default in (Required, Undefined), field_info=field_info, ) - if used_default_field_info: - if lenient_issubclass(field.type_, UploadFile): - field.field_info = params.File(field_info.default) - elif not is_scalar_field(field=field): - field.field_info = params.Body(field_info.default) return type_annotation, depends, field @@ -554,13 +517,13 @@ async def solve_dependencies( dependency_cache: Optional[Dict[Tuple[Callable[..., Any], Tuple[str]], Any]] = None, ) -> Tuple[ Dict[str, Any], - List[ErrorWrapper], + List[Any], Optional[BackgroundTasks], Response, Dict[Tuple[Callable[..., Any], Tuple[str]], Any], ]: values: Dict[str, Any] = {} - errors: List[ErrorWrapper] = [] + errors: List[Any] = [] if response is None: response = Response() del response.headers["content-length"] @@ -674,7 +637,7 @@ async def solve_dependencies( def request_params_to_args( required_params: Sequence[ModelField], received_params: Union[Mapping[str, Any], QueryParams, Headers], -) -> Tuple[Dict[str, Any], List[ErrorWrapper]]: +) -> Tuple[Dict[str, Any], List[Any]]: values = {} errors = [] for field in required_params: @@ -688,23 +651,25 @@ def request_params_to_args( 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( - ErrorWrapper( - MissingError(), loc=(field_info.in_.value, field.alias) - ) - ) + errors.append(get_missing_field_error(loc=loc)) else: values[field.name] = deepcopy(field.default) continue - v_, errors_ = field.validate( - value, values, loc=(field_info.in_.value, field.alias) - ) - if isinstance(errors_, ErrorWrapper): + v_, errors_ = field.validate(value, values, loc=loc) + if isinstance(errors_, ValidationError): + new_errors = _regenerate_error_with_loc( + errors=errors_.errors(), loc_prefix=loc + ) + new_error = ValidationError(title=errors_.title, errors=new_errors) + errors.append(new_error) + elif isinstance(errors_, ErrorWrapper): errors.append(errors_) elif isinstance(errors_, list): - errors.extend(errors_) + new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=()) + errors.extend(new_errors) else: values[field.name] = v_ return values, errors @@ -713,9 +678,9 @@ def request_params_to_args( async def request_body_to_args( required_params: List[ModelField], received_body: Optional[Union[Dict[str, Any], FormData]], -) -> Tuple[Dict[str, Any], List[ErrorWrapper]]: +) -> Tuple[Dict[str, Any], List[ValidationError]]: values = {} - errors = [] + errors: List[ValidationError] = [] if required_params: field = required_params[0] field_info = field.field_info @@ -733,9 +698,7 @@ async def request_body_to_args( value: Optional[Any] = None if received_body is not None: - if ( - field.shape in sequence_shapes or field.type_ in sequence_types - ) and isinstance(received_body, FormData): + if (is_sequence_field(field)) and isinstance(received_body, FormData): value = received_body.getlist(field.alias) else: try: @@ -748,7 +711,7 @@ async def request_body_to_args( or (isinstance(field_info, params.Form) and value == "") or ( isinstance(field_info, params.Form) - and field.shape in sequence_shapes + and is_sequence_field(field) and len(value) == 0 ) ): @@ -759,16 +722,17 @@ async def request_body_to_args( continue if ( isinstance(field_info, params.File) - and lenient_issubclass(field.type_, bytes) + and is_bytes_field(field) and isinstance(value, UploadFile) ): value = await value.read() elif ( - field.shape in sequence_shapes + is_bytes_sequence_field(field) and isinstance(field_info, params.File) - and lenient_issubclass(field.type_, bytes) - and isinstance(value, sequence_types) + 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( @@ -780,24 +744,19 @@ async def request_body_to_args( async with anyio.create_task_group() as tg: for sub_value in value: tg.start_soon(process_fn, sub_value.read) - value = sequence_shape_to_type[field.shape](results) + value = serialize_sequence_value(field=field, value=results) v_, errors_ = field.validate(value, values, loc=loc) - if isinstance(errors_, ErrorWrapper): - errors.append(errors_) - elif isinstance(errors_, list): + if isinstance(errors_, list): errors.extend(errors_) + elif errors_: + errors.append(errors_) else: values[field.name] = v_ return values, errors -def get_missing_field_error(loc: Tuple[str, ...]) -> ErrorWrapper: - missing_field_error = ErrorWrapper(MissingError(), loc=loc) - return missing_field_error - - def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: flat_dependant = get_flat_dependant(dependant) if not flat_dependant.body_params: @@ -815,12 +774,16 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]: for param in flat_dependant.body_params: setattr(param.field_info, "embed", True) # noqa: B010 model_name = "Body_" + name - BodyModel: Type[BaseModel] = create_model(model_name) - for f in flat_dependant.body_params: - BodyModel.__fields__[f.name] = f + BodyModel = create_body_model( + fields=flat_dependant.body_params, model_name=model_name + ) required = any(True for f in flat_dependant.body_params if f.required) - - BodyFieldInfo_kwargs: Dict[str, Any] = {"default": None} + BodyFieldInfo_kwargs: Dict[str, Any] = { + "annotation": BodyModel, + "alias": "body", + } + if not required: + BodyFieldInfo_kwargs["default"] = None if any(isinstance(f.field_info, params.File) for f in flat_dependant.body_params): BodyFieldInfo: Type[params.Body] = params.File elif any(isinstance(f.field_info, params.Form) for f in flat_dependant.body_params): diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 2f95bcbf6..bcff79630 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -1,15 +1,86 @@ import dataclasses -from collections import defaultdict +import datetime +from collections import defaultdict, deque +from decimal import Decimal from enum import Enum -from pathlib import PurePath +from ipaddress import ( + IPv4Address, + IPv4Interface, + IPv4Network, + IPv6Address, + IPv6Interface, + IPv6Network, +) +from pathlib import Path, PurePath +from re import Pattern from types import GeneratorType -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union +from uuid import UUID +from fastapi.types import IncEx from pydantic import BaseModel -from pydantic.json import ENCODERS_BY_TYPE +from pydantic.color import Color +from pydantic.networks import NameEmail +from pydantic.types import SecretBytes, SecretStr -SetIntStr = Set[Union[int, str]] -DictIntStrAny = Dict[Union[int, str], Any] +from ._compat import PYDANTIC_V2, MultiHostUrl, Url, _model_dump + + +# Taken from Pydantic v1 as is +def isoformat(o: Union[datetime.date, datetime.time]) -> str: + return o.isoformat() + + +# Taken from Pydantic v1 as is +def decimal_encoder(dec_value: Decimal) -> Union[int, float]: + """ + Encodes a Decimal as int of there's no exponent, otherwise float + + This is useful when we use ConstrainedDecimal to represent Numeric(x,0) + where a integer (but not int typed) is used. Encoding this as a float + results in failed round-tripping between encode and parse. + Our Id type is a prime example of this. + + >>> decimal_encoder(Decimal("1.0")) + 1.0 + + >>> decimal_encoder(Decimal("1")) + 1 + """ + if dec_value.as_tuple().exponent >= 0: # type: ignore[operator] + return int(dec_value) + else: + return float(dec_value) + + +ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { + bytes: lambda o: o.decode(), + Color: str, + datetime.date: isoformat, + datetime.datetime: isoformat, + datetime.time: isoformat, + datetime.timedelta: lambda td: td.total_seconds(), + Decimal: decimal_encoder, + Enum: lambda o: o.value, + frozenset: list, + deque: list, + GeneratorType: list, + IPv4Address: str, + IPv4Interface: str, + IPv4Network: str, + IPv6Address: str, + IPv6Interface: str, + IPv6Network: str, + NameEmail: str, + Path: str, + Pattern: lambda o: o.pattern, + SecretBytes: str, + SecretStr: str, + set: list, + UUID: str, + Url: str, + MultiHostUrl: str, +} def generate_encoders_by_class_tuples( @@ -28,8 +99,8 @@ encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) def jsonable_encoder( obj: Any, - include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + include: Optional[IncEx] = None, + exclude: Optional[IncEx] = None, by_alias: bool = True, exclude_unset: bool = False, exclude_defaults: bool = False, @@ -50,10 +121,15 @@ def jsonable_encoder( if exclude is not None and not isinstance(exclude, (set, dict)): exclude = set(exclude) if isinstance(obj, BaseModel): - encoder = getattr(obj.__config__, "json_encoders", {}) - if custom_encoder: - encoder.update(custom_encoder) - obj_dict = obj.dict( + # TODO: remove when deprecating Pydantic v1 + encoders: Dict[Any, Any] = {} + if not PYDANTIC_V2: + encoders = getattr(obj.__config__, "json_encoders", {}) # type: ignore[attr-defined] + if custom_encoder: + encoders.update(custom_encoder) + obj_dict = _model_dump( + obj, + mode="json", include=include, exclude=exclude, by_alias=by_alias, @@ -67,7 +143,8 @@ def jsonable_encoder( obj_dict, exclude_none=exclude_none, exclude_defaults=exclude_defaults, - custom_encoder=encoder, + # TODO: remove when deprecating Pydantic v1 + custom_encoder=encoders, sqlalchemy_safe=sqlalchemy_safe, ) if dataclasses.is_dataclass(obj): diff --git a/fastapi/exceptions.py b/fastapi/exceptions.py index cac5330a2..c1692f396 100644 --- a/fastapi/exceptions.py +++ b/fastapi/exceptions.py @@ -1,7 +1,6 @@ from typing import Any, Dict, Optional, Sequence, Type -from pydantic import BaseModel, ValidationError, create_model -from pydantic.error_wrappers import ErrorList +from pydantic import BaseModel, create_model from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import WebSocketException as WebSocketException # noqa: F401 @@ -26,12 +25,25 @@ class FastAPIError(RuntimeError): """ -class RequestValidationError(ValidationError): - def __init__(self, errors: Sequence[ErrorList], *, body: Any = None) -> None: +class ValidationException(Exception): + def __init__(self, errors: Sequence[Any]) -> None: + self._errors = errors + + def errors(self) -> Sequence[Any]: + return self._errors + + +class RequestValidationError(ValidationException): + def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None: + super().__init__(errors) self.body = body - super().__init__(errors, RequestErrorModel) -class WebSocketRequestValidationError(ValidationError): - def __init__(self, errors: Sequence[ErrorList]) -> None: - super().__init__(errors, WebSocketErrorModel) +class WebSocketRequestValidationError(ValidationException): + pass + + +class ResponseValidationError(ValidationException): + def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None: + super().__init__(errors) + self.body = body diff --git a/fastapi/openapi/constants.py b/fastapi/openapi/constants.py index 1897ad750..d724ee3cf 100644 --- a/fastapi/openapi/constants.py +++ b/fastapi/openapi/constants.py @@ -1,2 +1,3 @@ METHODS_WITH_BODY = {"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"} REF_PREFIX = "#/components/schemas/" +REF_TEMPLATE = "#/components/schemas/{model}" diff --git a/fastapi/openapi/models.py b/fastapi/openapi/models.py index 81a24f389..7284fa5ba 100644 --- a/fastapi/openapi/models.py +++ b/fastapi/openapi/models.py @@ -1,12 +1,13 @@ from enum import Enum from typing import Any, Callable, Dict, Iterable, List, Optional, Union +from fastapi._compat import PYDANTIC_V2, _model_rebuild from fastapi.logger import logger from pydantic import AnyUrl, BaseModel, Field from typing_extensions import Literal try: - import email_validator # type: ignore + import email_validator assert email_validator # make autoflake ignore the unused import from pydantic import EmailStr @@ -31,16 +32,26 @@ class Contact(BaseModel): url: Optional[AnyUrl] = None email: Optional[EmailStr] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class License(BaseModel): name: str url: Optional[AnyUrl] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Info(BaseModel): @@ -51,8 +62,13 @@ class Info(BaseModel): license: Optional[License] = None version: str - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class ServerVariable(BaseModel): @@ -60,8 +76,13 @@ class ServerVariable(BaseModel): default: str description: Optional[str] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Server(BaseModel): @@ -69,8 +90,13 @@ class Server(BaseModel): description: Optional[str] = None variables: Optional[Dict[str, ServerVariable]] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Reference(BaseModel): @@ -89,16 +115,26 @@ class XML(BaseModel): attribute: Optional[bool] = None wrapped: Optional[bool] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class ExternalDocumentation(BaseModel): description: Optional[str] = None url: AnyUrl - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Schema(BaseModel): @@ -139,8 +175,13 @@ class Schema(BaseModel): example: Optional[Any] = None deprecated: Optional[bool] = None - class Config: - extra: str = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Example(BaseModel): @@ -149,8 +190,13 @@ class Example(BaseModel): value: Optional[Any] = None externalValue: Optional[AnyUrl] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class ParameterInType(Enum): @@ -167,8 +213,13 @@ class Encoding(BaseModel): explode: Optional[bool] = None allowReserved: Optional[bool] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class MediaType(BaseModel): @@ -177,8 +228,13 @@ class MediaType(BaseModel): examples: Optional[Dict[str, Union[Example, Reference]]] = None encoding: Optional[Dict[str, Encoding]] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class ParameterBase(BaseModel): @@ -195,8 +251,13 @@ class ParameterBase(BaseModel): # Serialization rules for more complex scenarios content: Optional[Dict[str, MediaType]] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Parameter(ParameterBase): @@ -213,8 +274,13 @@ class RequestBody(BaseModel): content: Dict[str, MediaType] required: Optional[bool] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Link(BaseModel): @@ -225,8 +291,13 @@ class Link(BaseModel): description: Optional[str] = None server: Optional[Server] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Response(BaseModel): @@ -235,8 +306,13 @@ class Response(BaseModel): content: Optional[Dict[str, MediaType]] = None links: Optional[Dict[str, Union[Link, Reference]]] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Operation(BaseModel): @@ -254,8 +330,13 @@ class Operation(BaseModel): security: Optional[List[Dict[str, List[str]]]] = None servers: Optional[List[Server]] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class PathItem(BaseModel): @@ -273,8 +354,13 @@ class PathItem(BaseModel): servers: Optional[List[Server]] = None parameters: Optional[List[Union[Parameter, Reference]]] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class SecuritySchemeType(Enum): @@ -288,8 +374,13 @@ class SecurityBase(BaseModel): type_: SecuritySchemeType = Field(alias="type") description: Optional[str] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class APIKeyIn(Enum): @@ -318,8 +409,13 @@ class OAuthFlow(BaseModel): refreshUrl: Optional[str] = None scopes: Dict[str, str] = {} - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class OAuthFlowImplicit(OAuthFlow): @@ -345,8 +441,13 @@ class OAuthFlows(BaseModel): clientCredentials: Optional[OAuthFlowClientCredentials] = None authorizationCode: Optional[OAuthFlowAuthorizationCode] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class OAuth2(SecurityBase): @@ -376,8 +477,13 @@ class Components(BaseModel): # Using Any for Specification Extensions callbacks: Optional[Dict[str, Union[Dict[str, PathItem], Reference, Any]]] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class Tag(BaseModel): @@ -385,8 +491,13 @@ class Tag(BaseModel): description: Optional[str] = None externalDocs: Optional[ExternalDocumentation] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" class OpenAPI(BaseModel): @@ -400,10 +511,15 @@ class OpenAPI(BaseModel): tags: Optional[List[Tag]] = None externalDocs: Optional[ExternalDocumentation] = None - class Config: - extra = "allow" + if PYDANTIC_V2: + model_config = {"extra": "allow"} + + else: + + class Config: + extra = "allow" -Schema.update_forward_refs() -Operation.update_forward_refs() -Encoding.update_forward_refs() +_model_rebuild(Schema) +_model_rebuild(Operation) +_model_rebuild(Encoding) diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 6d736647b..550c6cb6c 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -1,32 +1,32 @@ import http.client import inspect import warnings -from enum import Enum from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Type, Union, cast from fastapi import routing +from fastapi._compat import ( + GenerateJsonSchema, + ModelField, + Undefined, + get_compat_model_name_map, + get_definitions, + get_schema_from_model_field, + lenient_issubclass, +) from fastapi.datastructures import DefaultPlaceholder from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import get_flat_dependant, get_flat_params from fastapi.encoders import jsonable_encoder -from fastapi.openapi.constants import METHODS_WITH_BODY, REF_PREFIX +from fastapi.openapi.constants import METHODS_WITH_BODY, REF_PREFIX, REF_TEMPLATE from fastapi.openapi.models import OpenAPI from fastapi.params import Body, Param from fastapi.responses import Response +from fastapi.types import ModelNameMap from fastapi.utils import ( deep_dict_update, generate_operation_id_for_path, - get_model_definitions, is_body_allowed_for_status_code, ) -from pydantic import BaseModel -from pydantic.fields import ModelField, Undefined -from pydantic.schema import ( - field_schema, - get_flat_models_from_fields, - get_model_name_map, -) -from pydantic.utils import lenient_issubclass from starlette.responses import JSONResponse from starlette.routing import BaseRoute from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY @@ -88,7 +88,8 @@ def get_openapi_security_definitions( def get_openapi_operation_parameters( *, all_route_params: Sequence[ModelField], - model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str], + schema_generator: GenerateJsonSchema, + model_name_map: ModelNameMap, ) -> List[Dict[str, Any]]: parameters = [] for param in all_route_params: @@ -96,13 +97,16 @@ def get_openapi_operation_parameters( field_info = cast(Param, field_info) if not field_info.include_in_schema: continue + param_schema = get_schema_from_model_field( + field=param, + schema_generator=schema_generator, + model_name_map=model_name_map, + ) parameter = { "name": param.alias, "in": field_info.in_.value, "required": param.required, - "schema": field_schema( - param, model_name_map=model_name_map, ref_prefix=REF_PREFIX - )[0], + "schema": param_schema, } if field_info.description: parameter["description"] = field_info.description @@ -119,13 +123,16 @@ def get_openapi_operation_parameters( def get_openapi_operation_request_body( *, body_field: Optional[ModelField], - model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str], + schema_generator: GenerateJsonSchema, + model_name_map: ModelNameMap, ) -> Optional[Dict[str, Any]]: if not body_field: return None assert isinstance(body_field, ModelField) - body_schema, _, _ = field_schema( - body_field, model_name_map=model_name_map, ref_prefix=REF_PREFIX + body_schema = get_schema_from_model_field( + field=body_field, + schema_generator=schema_generator, + model_name_map=model_name_map, ) field_info = cast(Body, body_field.field_info) request_media_type = field_info.media_type @@ -190,7 +197,11 @@ def get_openapi_operation_metadata( def get_openapi_path( - *, route: routing.APIRoute, model_name_map: Dict[type, str], operation_ids: Set[str] + *, + route: routing.APIRoute, + operation_ids: Set[str], + schema_generator: GenerateJsonSchema, + model_name_map: ModelNameMap, ) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]: path = {} security_schemes: Dict[str, Any] = {} @@ -218,7 +229,9 @@ def get_openapi_path( security_schemes.update(security_definitions) all_route_params = get_flat_params(route.dependant) operation_parameters = get_openapi_operation_parameters( - all_route_params=all_route_params, model_name_map=model_name_map + all_route_params=all_route_params, + schema_generator=schema_generator, + model_name_map=model_name_map, ) parameters.extend(operation_parameters) if parameters: @@ -236,7 +249,9 @@ def get_openapi_path( operation["parameters"] = list(all_parameters.values()) if method in METHODS_WITH_BODY: request_body_oai = get_openapi_operation_request_body( - body_field=route.body_field, model_name_map=model_name_map + body_field=route.body_field, + schema_generator=schema_generator, + model_name_map=model_name_map, ) if request_body_oai: operation["requestBody"] = request_body_oai @@ -250,8 +265,9 @@ def get_openapi_path( cb_definitions, ) = get_openapi_path( route=callback, - model_name_map=model_name_map, operation_ids=operation_ids, + schema_generator=schema_generator, + model_name_map=model_name_map, ) callbacks[callback.name] = {callback.path: cb_path} operation["callbacks"] = callbacks @@ -277,10 +293,10 @@ def get_openapi_path( response_schema = {"type": "string"} if lenient_issubclass(current_response_class, JSONResponse): if route.response_field: - response_schema, _, _ = field_schema( - route.response_field, + response_schema = get_schema_from_model_field( + field=route.response_field, + schema_generator=schema_generator, model_name_map=model_name_map, - ref_prefix=REF_PREFIX, ) else: response_schema = {} @@ -309,8 +325,10 @@ def get_openapi_path( field = route.response_fields.get(additional_status_code) additional_field_schema: Optional[Dict[str, Any]] = None if field: - additional_field_schema, _, _ = field_schema( - field, model_name_map=model_name_map, ref_prefix=REF_PREFIX + additional_field_schema = get_schema_from_model_field( + field=field, + schema_generator=schema_generator, + model_name_map=model_name_map, ) media_type = route_response_media_type or "application/json" additional_schema = ( @@ -356,13 +374,13 @@ def get_openapi_path( return path, security_schemes, definitions -def get_flat_models_from_routes( +def get_fields_from_routes( routes: Sequence[BaseRoute], -) -> Set[Union[Type[BaseModel], Type[Enum]]]: +) -> List[ModelField]: body_fields_from_routes: List[ModelField] = [] responses_from_routes: List[ModelField] = [] request_fields_from_routes: List[ModelField] = [] - callback_flat_models: Set[Union[Type[BaseModel], Type[Enum]]] = set() + callback_flat_models: List[ModelField] = [] for route in routes: if getattr(route, "include_in_schema", None) and isinstance( route, routing.APIRoute @@ -377,13 +395,12 @@ def get_flat_models_from_routes( if route.response_fields: responses_from_routes.extend(route.response_fields.values()) if route.callbacks: - callback_flat_models |= get_flat_models_from_routes(route.callbacks) + callback_flat_models.extend(get_fields_from_routes(route.callbacks)) params = get_flat_params(route.dependant) request_fields_from_routes.extend(params) - flat_models = callback_flat_models | get_flat_models_from_fields( - body_fields_from_routes + responses_from_routes + request_fields_from_routes, - known_models=set(), + flat_models = callback_flat_models + list( + body_fields_from_routes + responses_from_routes + request_fields_from_routes ) return flat_models @@ -416,15 +433,21 @@ def get_openapi( components: Dict[str, Dict[str, Any]] = {} paths: Dict[str, Dict[str, Any]] = {} operation_ids: Set[str] = set() - flat_models = get_flat_models_from_routes(routes) - model_name_map = get_model_name_map(flat_models) - definitions = get_model_definitions( - flat_models=flat_models, model_name_map=model_name_map + all_fields = get_fields_from_routes(routes) + model_name_map = get_compat_model_name_map(all_fields) + schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE) + definitions = get_definitions( + fields=all_fields, + schema_generator=schema_generator, + model_name_map=model_name_map, ) for route in routes: if isinstance(route, routing.APIRoute): result = get_openapi_path( - route=route, model_name_map=model_name_map, operation_ids=operation_ids + route=route, + operation_ids=operation_ids, + schema_generator=schema_generator, + model_name_map=model_name_map, ) if result: path, security_schemes, path_definitions = result diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index 75f054e9d..c12e7c808 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -1,7 +1,7 @@ from typing import Any, Callable, Dict, Optional, Sequence from fastapi import params -from pydantic.fields import Undefined +from fastapi._compat import Undefined def Path( # noqa: N802 @@ -16,6 +16,7 @@ def Path( # noqa: N802 le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -34,6 +35,7 @@ def Path( # noqa: N802 le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, @@ -55,6 +57,7 @@ def Query( # noqa: N802 le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -73,6 +76,7 @@ def Query( # noqa: N802 le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, @@ -95,6 +99,7 @@ def Header( # noqa: N802 le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -114,6 +119,7 @@ def Header( # noqa: N802 le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, @@ -135,6 +141,7 @@ def Cookie( # noqa: N802 le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -153,6 +160,7 @@ def Cookie( # noqa: N802 le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, @@ -176,6 +184,7 @@ def Body( # noqa: N802 le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -194,6 +203,7 @@ def Body( # noqa: N802 le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, @@ -214,6 +224,7 @@ def Form( # noqa: N802 le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -231,6 +242,7 @@ def Form( # noqa: N802 le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, @@ -251,6 +263,7 @@ def File( # noqa: N802 le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -268,6 +281,7 @@ def File( # noqa: N802 le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, diff --git a/fastapi/params.py b/fastapi/params.py index 16c5c309a..e35f7b95d 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -1,7 +1,9 @@ from enum import Enum -from typing import Any, Callable, Dict, Optional, Sequence +from typing import Any, Callable, Dict, Optional, Sequence, Type -from pydantic.fields import FieldInfo, Undefined +from pydantic.fields import FieldInfo + +from ._compat import PYDANTIC_V2, Undefined class ParamTypes(Enum): @@ -18,6 +20,7 @@ class Param(FieldInfo): self, default: Any = Undefined, *, + annotation: Optional[Type[Any]] = None, alias: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, @@ -27,6 +30,7 @@ class Param(FieldInfo): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -36,9 +40,8 @@ class Param(FieldInfo): ): self.deprecated = deprecated self.example = example - self.examples = examples self.include_in_schema = include_in_schema - super().__init__( + kwargs = dict( default=default, alias=alias, title=title, @@ -49,9 +52,19 @@ class Param(FieldInfo): le=le, min_length=min_length, max_length=max_length, - regex=regex, **extra, ) + if PYDANTIC_V2: + kwargs["annotation"] = annotation + kwargs["pattern"] = pattern or regex + else: + # TODO: pv2 figure out how to deprecate regex + kwargs["regex"] = pattern or regex + + super().__init__(**kwargs) + # TODO: pv2 decide how to handle OpenAPI examples vs JSON Schema examples + # and how to deprecate OpenAPI examples + self.examples = examples # type: ignore[assignment] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.default})" @@ -64,6 +77,7 @@ class Path(Param): self, default: Any = ..., *, + annotation: Optional[Type[Any]] = None, alias: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, @@ -73,6 +87,7 @@ class Path(Param): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -84,6 +99,7 @@ class Path(Param): self.in_ = self.in_ super().__init__( default=default, + annotation=annotation, alias=alias, title=title, description=description, @@ -93,6 +109,7 @@ class Path(Param): le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, deprecated=deprecated, example=example, @@ -109,6 +126,7 @@ class Query(Param): self, default: Any = Undefined, *, + annotation: Optional[Type[Any]] = None, alias: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, @@ -118,6 +136,7 @@ class Query(Param): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -127,6 +146,7 @@ class Query(Param): ): super().__init__( default=default, + annotation=annotation, alias=alias, title=title, description=description, @@ -136,6 +156,7 @@ class Query(Param): le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, deprecated=deprecated, example=example, @@ -152,6 +173,7 @@ class Header(Param): self, default: Any = Undefined, *, + annotation: Optional[Type[Any]] = None, alias: Optional[str] = None, convert_underscores: bool = True, title: Optional[str] = None, @@ -162,6 +184,7 @@ class Header(Param): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -172,6 +195,7 @@ class Header(Param): self.convert_underscores = convert_underscores super().__init__( default=default, + annotation=annotation, alias=alias, title=title, description=description, @@ -181,6 +205,7 @@ class Header(Param): le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, deprecated=deprecated, example=example, @@ -197,6 +222,7 @@ class Cookie(Param): self, default: Any = Undefined, *, + annotation: Optional[Type[Any]] = None, alias: Optional[str] = None, title: Optional[str] = None, description: Optional[str] = None, @@ -206,6 +232,7 @@ class Cookie(Param): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -215,6 +242,7 @@ class Cookie(Param): ): super().__init__( default=default, + annotation=annotation, alias=alias, title=title, description=description, @@ -224,6 +252,7 @@ class Cookie(Param): le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, deprecated=deprecated, example=example, @@ -238,6 +267,7 @@ class Body(FieldInfo): self, default: Any = Undefined, *, + annotation: Optional[Type[Any]] = None, embed: bool = False, media_type: str = "application/json", alias: Optional[str] = None, @@ -249,6 +279,7 @@ class Body(FieldInfo): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -257,8 +288,7 @@ class Body(FieldInfo): self.embed = embed self.media_type = media_type self.example = example - self.examples = examples - super().__init__( + kwargs = dict( default=default, alias=alias, title=title, @@ -269,9 +299,20 @@ class Body(FieldInfo): le=le, min_length=min_length, max_length=max_length, - regex=regex, **extra, ) + if PYDANTIC_V2: + kwargs["annotation"] = annotation + kwargs["pattern"] = pattern or regex + else: + # TODO: pv2 figure out how to deprecate regex + kwargs["regex"] = pattern or regex + super().__init__( + **kwargs, + ) + # TODO: pv2 decide how to handle OpenAPI examples vs JSON Schema examples + # and how to deprecate OpenAPI examples + self.examples = examples # type: ignore[assignment] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.default})" @@ -282,6 +323,7 @@ class Form(Body): self, default: Any = Undefined, *, + annotation: Optional[Type[Any]] = None, media_type: str = "application/x-www-form-urlencoded", alias: Optional[str] = None, title: Optional[str] = None, @@ -292,6 +334,7 @@ class Form(Body): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -299,6 +342,7 @@ class Form(Body): ): super().__init__( default=default, + annotation=annotation, embed=True, media_type=media_type, alias=alias, @@ -310,6 +354,7 @@ class Form(Body): le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, @@ -322,6 +367,7 @@ class File(Form): self, default: Any = Undefined, *, + annotation: Optional[Type[Any]] = None, media_type: str = "multipart/form-data", alias: Optional[str] = None, title: Optional[str] = None, @@ -332,6 +378,7 @@ class File(Form): le: Optional[float] = None, min_length: Optional[int] = None, max_length: Optional[int] = None, + pattern: Optional[str] = None, regex: Optional[str] = None, example: Any = Undefined, examples: Optional[Dict[str, Any]] = None, @@ -339,6 +386,7 @@ class File(Form): ): super().__init__( default=default, + annotation=annotation, media_type=media_type, alias=alias, title=title, @@ -349,6 +397,7 @@ class File(Form): le=le, min_length=min_length, max_length=max_length, + pattern=pattern, regex=regex, example=example, examples=examples, diff --git a/fastapi/routing.py b/fastapi/routing.py index ec8af99b3..ce4e88c86 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -20,6 +20,14 @@ from typing import ( ) from fastapi import params +from fastapi._compat import ( + ModelField, + Undefined, + _get_model_config, + _model_dump, + _normalize_errors, + lenient_issubclass, +) from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.dependencies.models import Dependant from fastapi.dependencies.utils import ( @@ -29,13 +37,14 @@ from fastapi.dependencies.utils import ( get_typed_return_annotation, solve_dependencies, ) -from fastapi.encoders import DictIntStrAny, SetIntStr, jsonable_encoder +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import ( FastAPIError, RequestValidationError, + ResponseValidationError, WebSocketRequestValidationError, ) -from fastapi.types import DecoratedCallable +from fastapi.types import DecoratedCallable, IncEx from fastapi.utils import ( create_cloned_field, create_response_field, @@ -44,9 +53,6 @@ from fastapi.utils import ( is_body_allowed_for_status_code, ) from pydantic import BaseModel -from pydantic.error_wrappers import ErrorWrapper, ValidationError -from pydantic.fields import ModelField, Undefined -from pydantic.utils import lenient_issubclass from starlette import routing from starlette.concurrency import run_in_threadpool from starlette.exceptions import HTTPException @@ -73,14 +79,15 @@ def _prepare_response_content( exclude_none: bool = False, ) -> Any: if isinstance(res, BaseModel): - read_with_orm_mode = getattr(res.__config__, "read_with_orm_mode", None) + read_with_orm_mode = getattr(_get_model_config(res), "read_with_orm_mode", None) if read_with_orm_mode: # Let from_orm extract the data from this model instead of converting # it now to a dict. # Otherwise there's no way to extract lazy data that requires attribute # access instead of dict iteration, e.g. lazy relationships. return res - return res.dict( + return _model_dump( + res, by_alias=True, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, @@ -115,8 +122,8 @@ async def serialize_response( *, field: Optional[ModelField] = None, response_content: Any, - include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + include: Optional[IncEx] = None, + exclude: Optional[IncEx] = None, by_alias: bool = True, exclude_unset: bool = False, exclude_defaults: bool = False, @@ -125,24 +132,40 @@ async def serialize_response( ) -> Any: if field: errors = [] - response_content = _prepare_response_content( - response_content, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) + if not hasattr(field, "serialize"): + # pydantic v1 + response_content = _prepare_response_content( + response_content, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) if is_coroutine: value, errors_ = field.validate(response_content, {}, loc=("response",)) else: value, errors_ = await run_in_threadpool( field.validate, response_content, {}, loc=("response",) ) - if isinstance(errors_, ErrorWrapper): - errors.append(errors_) - elif isinstance(errors_, list): + if isinstance(errors_, list): errors.extend(errors_) + elif errors_: + errors.append(errors_) if errors: - raise ValidationError(errors, field.type_) + raise ResponseValidationError( + errors=_normalize_errors(errors), body=response_content + ) + + if hasattr(field, "serialize"): + return field.serialize( + value, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + return jsonable_encoder( value, include=include, @@ -175,8 +198,8 @@ def get_request_handler( status_code: Optional[int] = None, response_class: Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse), response_field: Optional[ModelField] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -220,7 +243,16 @@ def get_request_handler( body = body_bytes except json.JSONDecodeError as e: raise RequestValidationError( - [ErrorWrapper(e, ("body", e.pos))], body=e.doc + [ + { + "type": "json_invalid", + "loc": ("body", e.pos), + "msg": "JSON decode error", + "input": {}, + "ctx": {"error": e.msg}, + } + ], + body=e.doc, ) from e except HTTPException: raise @@ -236,7 +268,7 @@ def get_request_handler( ) values, errors, background_tasks, sub_response, _ = solved_result if errors: - raise RequestValidationError(errors, body=body) + raise RequestValidationError(_normalize_errors(errors), body=body) else: raw_response = await run_endpoint_function( dependant=dependant, values=values, is_coroutine=is_coroutine @@ -287,7 +319,7 @@ def get_websocket_app( ) values, errors, _, _2, _3 = solved_result if errors: - raise WebSocketRequestValidationError(errors) + raise WebSocketRequestValidationError(_normalize_errors(errors)) assert dependant.call is not None, "dependant.call must be a function" await dependant.call(**values) @@ -348,8 +380,8 @@ class APIRoute(routing.Route): name: Optional[str] = None, methods: Optional[Union[Set[str], List[str]]] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -423,6 +455,7 @@ class APIRoute(routing.Route): # would pass the validation and be returned as is. # By being a new field, no inheritance will be passed as is. A new model # will be always created. + # TODO: remove when deprecating Pydantic v1 self.secure_cloned_response_field: Optional[ ModelField ] = create_cloned_field(self.response_field) @@ -569,8 +602,8 @@ class APIRouter(routing.Router): deprecated: Optional[bool] = None, methods: Optional[Union[Set[str], List[str]]] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -650,8 +683,8 @@ class APIRouter(routing.Router): deprecated: Optional[bool] = None, methods: Optional[List[str]] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -877,8 +910,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -933,8 +966,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -989,8 +1022,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -1045,8 +1078,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -1101,8 +1134,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -1157,8 +1190,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -1213,8 +1246,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, @@ -1269,8 +1302,8 @@ class APIRouter(routing.Router): responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None, deprecated: Optional[bool] = None, operation_id: Optional[str] = None, - response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None, - response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None, + response_model_include: Optional[IncEx] = None, + response_model_exclude: Optional[IncEx] = None, response_model_by_alias: bool = True, response_model_exclude_unset: bool = False, response_model_exclude_defaults: bool = False, diff --git a/fastapi/security/oauth2.py b/fastapi/security/oauth2.py index 938dec37c..f8d0d6c6e 100644 --- a/fastapi/security/oauth2.py +++ b/fastapi/security/oauth2.py @@ -1,5 +1,11 @@ +import sys from typing import Any, Dict, List, Optional, Union, cast +if sys.version_info < (3, 9): + from typing_extensions import Annotated +else: + from typing import Annotated + from fastapi.exceptions import HTTPException from fastapi.openapi.models import OAuth2 as OAuth2Model from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel @@ -45,12 +51,13 @@ class OAuth2PasswordRequestForm: def __init__( self, - grant_type: str = Form(default=None, regex="password"), - username: str = Form(), - password: str = Form(), - scope: str = Form(default=""), - client_id: Optional[str] = Form(default=None), - client_secret: Optional[str] = Form(default=None), + *, + grant_type: Annotated[Union[str, None], Form(pattern="password")] = None, + username: Annotated[str, Form()], + password: Annotated[str, Form()], + scope: Annotated[str, Form()] = "", + client_id: Annotated[Union[str, None], Form()] = None, + client_secret: Annotated[Union[str, None], Form()] = None, ): self.grant_type = grant_type self.username = username @@ -95,12 +102,12 @@ class OAuth2PasswordRequestFormStrict(OAuth2PasswordRequestForm): def __init__( self, - grant_type: str = Form(regex="password"), - username: str = Form(), - password: str = Form(), - scope: str = Form(default=""), - client_id: Optional[str] = Form(default=None), - client_secret: Optional[str] = Form(default=None), + grant_type: Annotated[str, Form(pattern="password")], + username: Annotated[str, Form()], + password: Annotated[str, Form()], + scope: Annotated[str, Form()] = "", + client_id: Annotated[Union[str, None], Form()] = None, + client_secret: Annotated[Union[str, None], Form()] = None, ): super().__init__( grant_type=grant_type, diff --git a/fastapi/types.py b/fastapi/types.py index e0bca4632..7adf565a7 100644 --- a/fastapi/types.py +++ b/fastapi/types.py @@ -1,3 +1,11 @@ -from typing import Any, Callable, TypeVar +import types +from enum import Enum +from typing import Any, Callable, Dict, Set, Type, TypeVar, Union + +from pydantic import BaseModel DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any]) +UnionType = getattr(types, "UnionType", Union) +NoneType = getattr(types, "UnionType", None) +ModelNameMap = Dict[Union[Type[BaseModel], Type[Enum]], str] +IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]] diff --git a/fastapi/utils.py b/fastapi/utils.py index 9b9ebcb85..18ae2365d 100644 --- a/fastapi/utils.py +++ b/fastapi/utils.py @@ -1,7 +1,6 @@ import re import warnings from dataclasses import is_dataclass -from enum import Enum from typing import ( TYPE_CHECKING, Any, @@ -16,13 +15,20 @@ from typing import ( from weakref import WeakKeyDictionary import fastapi +from dirty_equals import IsStr +from fastapi._compat import ( + PYDANTIC_V2, + BaseConfig, + ModelField, + PydanticSchemaGenerationError, + Undefined, + UndefinedType, + Validator, + lenient_issubclass, +) from fastapi.datastructures import DefaultPlaceholder, DefaultType -from fastapi.openapi.constants import REF_PREFIX -from pydantic import BaseConfig, BaseModel, create_model -from pydantic.class_validators import Validator -from pydantic.fields import FieldInfo, ModelField, UndefinedType -from pydantic.schema import model_process_schema -from pydantic.utils import lenient_issubclass +from pydantic import BaseModel, create_model +from pydantic.fields import FieldInfo if TYPE_CHECKING: # pragma: nocover from .routing import APIRoute @@ -50,24 +56,6 @@ def is_body_allowed_for_status_code(status_code: Union[int, str, None]) -> bool: return not (current_status_code < 200 or current_status_code in {204, 304}) -def get_model_definitions( - *, - flat_models: Set[Union[Type[BaseModel], Type[Enum]]], - model_name_map: Dict[Union[Type[BaseModel], Type[Enum]], str], -) -> Dict[str, Any]: - definitions: Dict[str, Dict[str, Any]] = {} - for model in flat_models: - m_schema, m_definitions, m_nested_models = model_process_schema( - model, model_name_map=model_name_map, ref_prefix=REF_PREFIX - ) - definitions.update(m_definitions) - model_name = model_name_map[model] - if "description" in m_schema: - m_schema["description"] = m_schema["description"].split("\f")[0] - definitions[model_name] = m_schema - return definitions - - def get_path_param_names(path: str) -> Set[str]: return set(re.findall("{(.*?)}", path)) @@ -76,8 +64,8 @@ def create_response_field( name: str, type_: Type[Any], class_validators: Optional[Dict[str, Validator]] = None, - default: Optional[Any] = None, - required: Union[bool, UndefinedType] = True, + default: Optional[Any] = Undefined, + required: Union[bool, UndefinedType] = Undefined, model_config: Type[BaseConfig] = BaseConfig, field_info: Optional[FieldInfo] = None, alias: Optional[str] = None, @@ -86,20 +74,27 @@ def create_response_field( Create a new response field. Raises if type_ is invalid. """ class_validators = class_validators or {} - field_info = field_info or FieldInfo() - - try: - return ModelField( - name=name, - type_=type_, - class_validators=class_validators, - default=default, - required=required, - model_config=model_config, - alias=alias, - field_info=field_info, + if PYDANTIC_V2: + field_info = field_info or FieldInfo( + annotation=type_, default=default, alias=alias + ) + else: + field_info = field_info or FieldInfo() + kwargs = {"name": name, "field_info": field_info} + if not PYDANTIC_V2: + kwargs.update( + { + "type_": type_, + "class_validators": class_validators, + "default": default, + "required": required, + "model_config": model_config, + "alias": alias, + } ) - except RuntimeError: + try: + return ModelField(**kwargs) # type: ignore[arg-type] + except (RuntimeError, PydanticSchemaGenerationError): raise fastapi.exceptions.FastAPIError( "Invalid args for response field! Hint: " f"check that {type_} is a valid Pydantic field type. " @@ -116,6 +111,8 @@ def create_cloned_field( *, cloned_types: Optional[MutableMapping[Type[BaseModel], Type[BaseModel]]] = None, ) -> ModelField: + if PYDANTIC_V2: + return field # cloned_types caches already cloned types to support recursive models and improve # performance by avoiding unecessary cloning if cloned_types is None: @@ -136,30 +133,30 @@ def create_cloned_field( f, cloned_types=cloned_types ) new_field = create_response_field(name=field.name, type_=use_type) - new_field.has_alias = field.has_alias - new_field.alias = field.alias - new_field.class_validators = field.class_validators - new_field.default = field.default - new_field.required = field.required - new_field.model_config = field.model_config + new_field.has_alias = field.has_alias # type: ignore[attr-defined] + new_field.alias = field.alias # type: ignore[misc] + new_field.class_validators = field.class_validators # type: ignore[attr-defined] + new_field.default = field.default # type: ignore[misc] + new_field.required = field.required # type: ignore[misc] + new_field.model_config = field.model_config # type: ignore[attr-defined] new_field.field_info = field.field_info - new_field.allow_none = field.allow_none - new_field.validate_always = field.validate_always - if field.sub_fields: - new_field.sub_fields = [ + new_field.allow_none = field.allow_none # type: ignore[attr-defined] + new_field.validate_always = field.validate_always # type: ignore[attr-defined] + if field.sub_fields: # type: ignore[attr-defined] + new_field.sub_fields = [ # type: ignore[attr-defined] create_cloned_field(sub_field, cloned_types=cloned_types) - for sub_field in field.sub_fields + for sub_field in field.sub_fields # type: ignore[attr-defined] ] - if field.key_field: - new_field.key_field = create_cloned_field( - field.key_field, cloned_types=cloned_types + if field.key_field: # type: ignore[attr-defined] + new_field.key_field = create_cloned_field( # type: ignore[attr-defined] + field.key_field, cloned_types=cloned_types # type: ignore[attr-defined] ) - new_field.validators = field.validators - new_field.pre_validators = field.pre_validators - new_field.post_validators = field.post_validators - new_field.parse_json = field.parse_json - new_field.shape = field.shape - new_field.populate_validators() + new_field.validators = field.validators # type: ignore[attr-defined] + new_field.pre_validators = field.pre_validators # type: ignore[attr-defined] + new_field.post_validators = field.post_validators # type: ignore[attr-defined] + new_field.parse_json = field.parse_json # type: ignore[attr-defined] + new_field.shape = field.shape # type: ignore[attr-defined] + new_field.populate_validators() # type: ignore[attr-defined] return new_field @@ -220,3 +217,7 @@ def get_value_or_default( if not isinstance(item, DefaultPlaceholder): return item return first_item + + +def match_pydantic_error_url(error_type: str) -> IsStr: + return IsStr(regex=rf"^https://errors\.pydantic\.dev/.*/v/{error_type}") diff --git a/pyproject.toml b/pyproject.toml index 547137144..68c440bde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ classifiers = [ ] dependencies = [ "starlette>=0.27.0,<0.28.0", - "pydantic>=1.7.4,!=1.8,!=1.8.1,<2.0.0", + "pydantic>=1.7.4,!=1.8,!=1.8.1,<3.0.0", ] dynamic = ["version"] @@ -59,7 +59,7 @@ all = [ "pyyaml >=5.3.1", "ujson >=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0", "orjson >=3.2.1", - "email_validator >=1.1.1", + "email_validator >=2.0.0", "uvicorn[standard] >=0.12.0", ] @@ -83,6 +83,7 @@ check_untyped_defs = true addopts = [ "--strict-config", "--strict-markers", + "--ignore=docs_src", ] xfail_strict = true junit_family = "xunit2" diff --git a/requirements-tests.txt b/requirements-tests.txt index 3ef3c4fd9..915766f47 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1,11 +1,13 @@ -e . pytest >=7.1.3,<8.0.0 coverage[toml] >= 6.5.0,< 8.0 +dirty-equals >= 0.6.0 + mypy ==1.3.0 ruff ==0.0.272 black == 23.3.0 httpx >=0.23.0,<0.24.0 -email_validator >=1.1.1,<2.0.0 +email_validator >=2.0.0,<3.0.0 # TODO: once removing databases from tutorial, upgrade SQLAlchemy # probably when including SQLModel sqlalchemy >=1.3.18,<1.4.43 diff --git a/tests/test_additional_responses_custom_model_in_callback.py b/tests/test_additional_responses_custom_model_in_callback.py index 5c08eaa6d..828565b24 100644 --- a/tests/test_additional_responses_custom_model_in_callback.py +++ b/tests/test_additional_responses_custom_model_in_callback.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi import APIRouter, FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel, HttpUrl @@ -42,13 +43,24 @@ def test_openapi_schema(): "parameters": [ { "required": True, - "schema": { - "title": "Callback Url", - "maxLength": 2083, - "minLength": 1, - "type": "string", - "format": "uri", - }, + "schema": IsDict( + { + "title": "Callback Url", + "minLength": 1, + "type": "string", + "format": "uri", + } + ) + # TODO: remove when deprecating Pydantic v1 + | IsDict( + { + "title": "Callback Url", + "maxLength": 2083, + "minLength": 1, + "type": "string", + "format": "uri", + } + ), "name": "callback_url", "in": "query", } diff --git a/tests/test_annotated.py b/tests/test_annotated.py index a4f42b038..2ef7894b4 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -1,6 +1,8 @@ import pytest +from dirty_equals import IsDict from fastapi import APIRouter, FastAPI, Query from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from typing_extensions import Annotated app = FastAPI() @@ -30,21 +32,46 @@ client = TestClient(app) foo_is_missing = { "detail": [ - { - "loc": ["query", "foo"], - "msg": "field required", - "type": "value_error.missing", - } + IsDict( + { + "loc": ["query", "foo"], + "msg": "Field required", + "type": "missing", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ) + # TODO: remove when deprecating Pydantic v1 + | IsDict( + { + "loc": ["query", "foo"], + "msg": "field required", + "type": "value_error.missing", + } + ) ] } foo_is_short = { "detail": [ - { - "ctx": {"limit_value": 1}, - "loc": ["query", "foo"], - "msg": "ensure this value has at least 1 characters", - "type": "value_error.any_str.min_length", - } + IsDict( + { + "ctx": {"min_length": 1}, + "loc": ["query", "foo"], + "msg": "String should have at least 1 characters", + "type": "string_too_short", + "input": "", + "url": match_pydantic_error_url("string_too_short"), + } + ) + # TODO: remove when deprecating Pydantic v1 + | IsDict( + { + "ctx": {"limit_value": 1}, + "loc": ["query", "foo"], + "msg": "ensure this value has at least 1 characters", + "type": "value_error.any_str.min_length", + } + ) ] } diff --git a/tests/test_application.py b/tests/test_application.py index e5f2f4387..f68bb2171 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from .main import app @@ -266,10 +267,17 @@ def test_openapi_schema(): "operationId": "get_path_param_id_path_param__item_id__get", "parameters": [ { - "required": True, - "schema": {"title": "Item Id", "type": "string"}, "name": "item_id", "in": "path", + "required": True, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Item Id", + } + ) + # TODO: remove when deprecating Pydantic v1 + | IsDict({"title": "Item Id", "type": "string"}), } ], } @@ -969,10 +977,17 @@ def test_openapi_schema(): "operationId": "get_query_type_optional_query_int_optional_get", "parameters": [ { - "required": False, - "schema": {"title": "Query", "type": "integer"}, "name": "query", "in": "query", + "required": False, + "schema": IsDict( + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Query", + } + ) + # TODO: remove when deprecating Pydantic v1 + | IsDict({"title": "Query", "type": "integer"}), } ], } diff --git a/tests/test_custom_schema_fields.py b/tests/test_custom_schema_fields.py index 10b02608c..ee51fc7ff 100644 --- a/tests/test_custom_schema_fields.py +++ b/tests/test_custom_schema_fields.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from fastapi._compat import PYDANTIC_V2 from fastapi.testclient import TestClient from pydantic import BaseModel @@ -8,10 +9,18 @@ app = FastAPI() class Item(BaseModel): name: str - class Config: - schema_extra = { - "x-something-internal": {"level": 4}, + if PYDANTIC_V2: + model_config = { + "json_schema_extra": { + "x-something-internal": {"level": 4}, + } } + else: + + class Config: + schema_extra = { + "x-something-internal": {"level": 4}, + } @app.get("/foo", response_model=Item) diff --git a/tests/test_datetime_custom_encoder.py b/tests/test_datetime_custom_encoder.py index 5c1833eb4..3aa77c0b1 100644 --- a/tests/test_datetime_custom_encoder.py +++ b/tests/test_datetime_custom_encoder.py @@ -4,31 +4,54 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel +from .utils import needs_pydanticv1, needs_pydanticv2 -class ModelWithDatetimeField(BaseModel): - dt_field: datetime - class Config: - json_encoders = { - datetime: lambda dt: dt.replace( - microsecond=0, tzinfo=timezone.utc - ).isoformat() - } +@needs_pydanticv2 +def test_pydanticv2(): + from pydantic import field_serializer + class ModelWithDatetimeField(BaseModel): + dt_field: datetime -app = FastAPI() -model = ModelWithDatetimeField(dt_field=datetime(2019, 1, 1, 8)) + @field_serializer("dt_field") + def serialize_datetime(self, dt_field: datetime): + return dt_field.replace(microsecond=0, tzinfo=timezone.utc).isoformat() + app = FastAPI() + model = ModelWithDatetimeField(dt_field=datetime(2019, 1, 1, 8)) -@app.get("/model", response_model=ModelWithDatetimeField) -def get_model(): - return model + @app.get("/model", response_model=ModelWithDatetimeField) + def get_model(): + return model + client = TestClient(app) + with client: + response = client.get("/model") + assert response.json() == {"dt_field": "2019-01-01T08:00:00+00:00"} + + +# TODO: remove when deprecating Pydantic v1 +@needs_pydanticv1 +def test_pydanticv1(): + class ModelWithDatetimeField(BaseModel): + dt_field: datetime + + class Config: + json_encoders = { + datetime: lambda dt: dt.replace( + microsecond=0, tzinfo=timezone.utc + ).isoformat() + } -client = TestClient(app) + app = FastAPI() + model = ModelWithDatetimeField(dt_field=datetime(2019, 1, 1, 8)) + @app.get("/model", response_model=ModelWithDatetimeField) + def get_model(): + return model -def test_dt(): + client = TestClient(app) with client: response = client.get("/model") assert response.json() == {"dt_field": "2019-01-01T08:00:00+00:00"} diff --git a/tests/test_dependency_duplicates.py b/tests/test_dependency_duplicates.py index 285fdf1ab..7d01cb8aa 100644 --- a/tests/test_dependency_duplicates.py +++ b/tests/test_dependency_duplicates.py @@ -1,7 +1,9 @@ from typing import List +from dirty_equals import IsDict from fastapi import Depends, FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from pydantic import BaseModel app = FastAPI() @@ -47,15 +49,30 @@ async def no_duplicates_sub( def test_no_duplicates_invalid(): response = client.post("/no-duplicates", json={"item": {"data": "myitem"}}) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body", "item2"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item2"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item2"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_no_duplicates(): diff --git a/tests/test_dependency_overrides.py b/tests/test_dependency_overrides.py index 8bb307971..21cff998d 100644 --- a/tests/test_dependency_overrides.py +++ b/tests/test_dependency_overrides.py @@ -1,8 +1,10 @@ from typing import Optional import pytest +from dirty_equals import IsDict from fastapi import APIRouter, Depends, FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url app = FastAPI() @@ -50,99 +52,180 @@ async def overrider_dependency_with_sub(msg: dict = Depends(overrider_sub_depend return msg -@pytest.mark.parametrize( - "url,status_code,expected", - [ - ( - "/main-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "q"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ( - "/main-depends/?q=foo", - 200, - {"in": "main-depends", "params": {"q": "foo", "skip": 0, "limit": 100}}, - ), - ( - "/main-depends/?q=foo&skip=100&limit=200", - 200, - {"in": "main-depends", "params": {"q": "foo", "skip": 100, "limit": 200}}, - ), - ( - "/decorator-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "q"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ("/decorator-depends/?q=foo", 200, {"in": "decorator-depends"}), - ( - "/decorator-depends/?q=foo&skip=100&limit=200", - 200, - {"in": "decorator-depends"}, - ), - ( - "/router-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "q"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ( - "/router-depends/?q=foo", - 200, - {"in": "router-depends", "params": {"q": "foo", "skip": 0, "limit": 100}}, - ), - ( - "/router-depends/?q=foo&skip=100&limit=200", - 200, - {"in": "router-depends", "params": {"q": "foo", "skip": 100, "limit": 200}}, - ), - ( - "/router-decorator-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "q"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ("/router-decorator-depends/?q=foo", 200, {"in": "router-decorator-depends"}), - ( - "/router-decorator-depends/?q=foo&skip=100&limit=200", - 200, - {"in": "router-decorator-depends"}, - ), - ], -) -def test_normal_app(url, status_code, expected): - response = client.get(url) - assert response.status_code == status_code - assert response.json() == expected +def test_main_depends(): + response = client.get("/main-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "q"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "q"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_main_depends_q_foo(): + response = client.get("/main-depends/?q=foo") + assert response.status_code == 200 + assert response.json() == { + "in": "main-depends", + "params": {"q": "foo", "skip": 0, "limit": 100}, + } + + +def test_main_depends_q_foo_skip_100_limit_200(): + response = client.get("/main-depends/?q=foo&skip=100&limit=200") + assert response.status_code == 200 + assert response.json() == { + "in": "main-depends", + "params": {"q": "foo", "skip": 100, "limit": 200}, + } + + +def test_decorator_depends(): + response = client.get("/decorator-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "q"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "q"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_decorator_depends_q_foo(): + response = client.get("/decorator-depends/?q=foo") + assert response.status_code == 200 + assert response.json() == {"in": "decorator-depends"} + + +def test_decorator_depends_q_foo_skip_100_limit_200(): + response = client.get("/decorator-depends/?q=foo&skip=100&limit=200") + assert response.status_code == 200 + assert response.json() == {"in": "decorator-depends"} + + +def test_router_depends(): + response = client.get("/router-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "q"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "q"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_router_depends_q_foo(): + response = client.get("/router-depends/?q=foo") + assert response.status_code == 200 + assert response.json() == { + "in": "router-depends", + "params": {"q": "foo", "skip": 0, "limit": 100}, + } + + +def test_router_depends_q_foo_skip_100_limit_200(): + response = client.get("/router-depends/?q=foo&skip=100&limit=200") + assert response.status_code == 200 + assert response.json() == { + "in": "router-depends", + "params": {"q": "foo", "skip": 100, "limit": 200}, + } + + +def test_router_decorator_depends(): + response = client.get("/router-decorator-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "q"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "q"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_router_decorator_depends_q_foo(): + response = client.get("/router-decorator-depends/?q=foo") + assert response.status_code == 200 + assert response.json() == {"in": "router-decorator-depends"} + + +def test_router_decorator_depends_q_foo_skip_100_limit_200(): + response = client.get("/router-decorator-depends/?q=foo&skip=100&limit=200") + assert response.status_code == 200 + assert response.json() == {"in": "router-decorator-depends"} @pytest.mark.parametrize( @@ -190,126 +273,281 @@ def test_override_simple(url, status_code, expected): app.dependency_overrides = {} -@pytest.mark.parametrize( - "url,status_code,expected", - [ - ( - "/main-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ( - "/main-depends/?q=foo", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ("/main-depends/?k=bar", 200, {"in": "main-depends", "params": {"k": "bar"}}), - ( - "/decorator-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ( - "/decorator-depends/?q=foo", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ("/decorator-depends/?k=bar", 200, {"in": "decorator-depends"}), - ( - "/router-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ( - "/router-depends/?q=foo", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ( - "/router-depends/?k=bar", - 200, - {"in": "router-depends", "params": {"k": "bar"}}, - ), - ( - "/router-decorator-depends/", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ( - "/router-decorator-depends/?q=foo", - 422, - { - "detail": [ - { - "loc": ["query", "k"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - ), - ("/router-decorator-depends/?k=bar", 200, {"in": "router-decorator-depends"}), - ], -) -def test_override_with_sub(url, status_code, expected): +def test_override_with_sub_main_depends(): app.dependency_overrides[common_parameters] = overrider_dependency_with_sub - response = client.get(url) - assert response.status_code == status_code - assert response.json() == expected + response = client.get("/main-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub__main_depends_q_foo(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/main-depends/?q=foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub_main_depends_k_bar(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/main-depends/?k=bar") + assert response.status_code == 200 + assert response.json() == {"in": "main-depends", "params": {"k": "bar"}} + app.dependency_overrides = {} + + +def test_override_with_sub_decorator_depends(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/decorator-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub_decorator_depends_q_foo(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/decorator-depends/?q=foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub_decorator_depends_k_bar(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/decorator-depends/?k=bar") + assert response.status_code == 200 + assert response.json() == {"in": "decorator-depends"} + app.dependency_overrides = {} + + +def test_override_with_sub_router_depends(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/router-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub_router_depends_q_foo(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/router-depends/?q=foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub_router_depends_k_bar(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/router-depends/?k=bar") + assert response.status_code == 200 + assert response.json() == {"in": "router-depends", "params": {"k": "bar"}} + app.dependency_overrides = {} + + +def test_override_with_sub_router_decorator_depends(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/router-decorator-depends/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub_router_decorator_depends_q_foo(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/router-decorator-depends/?q=foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "k"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "k"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + app.dependency_overrides = {} + + +def test_override_with_sub_router_decorator_depends_k_bar(): + app.dependency_overrides[common_parameters] = overrider_dependency_with_sub + response = client.get("/router-decorator-depends/?k=bar") + assert response.status_code == 200 + assert response.json() == {"in": "router-decorator-depends"} app.dependency_overrides = {} diff --git a/tests/test_extra_routes.py b/tests/test_extra_routes.py index c0db62c19..d2781e7b6 100644 --- a/tests/test_extra_routes.py +++ b/tests/test_extra_routes.py @@ -1,5 +1,6 @@ from typing import Optional +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.responses import JSONResponse from fastapi.testclient import TestClient @@ -327,7 +328,14 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "price": {"title": "Price", "type": "number"}, + "price": IsDict( + { + "title": "Price", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + # TODO: remove when deprecating Pydantic v1 + | IsDict({"title": "Price", "type": "number"}), }, }, "ValidationError": { diff --git a/tests/test_filter_pydantic_sub_model/__init__.py b/tests/test_filter_pydantic_sub_model/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_filter_pydantic_sub_model/app_pv1.py b/tests/test_filter_pydantic_sub_model/app_pv1.py new file mode 100644 index 000000000..657e8c5d1 --- /dev/null +++ b/tests/test_filter_pydantic_sub_model/app_pv1.py @@ -0,0 +1,35 @@ +from typing import Optional + +from fastapi import Depends, FastAPI +from pydantic import BaseModel, validator + +app = FastAPI() + + +class ModelB(BaseModel): + username: str + + +class ModelC(ModelB): + password: str + + +class ModelA(BaseModel): + name: str + description: Optional[str] = None + model_b: ModelB + + @validator("name") + def lower_username(cls, name: str, values): + if not name.endswith("A"): + raise ValueError("name must end in A") + return name + + +async def get_model_c() -> ModelC: + return ModelC(username="test-user", password="test-password") + + +@app.get("/model/{name}", response_model=ModelA) +async def get_model_a(name: str, model_c=Depends(get_model_c)): + return {"name": name, "description": "model-a-desc", "model_b": model_c} diff --git a/tests/test_filter_pydantic_sub_model.py b/tests/test_filter_pydantic_sub_model/test_filter_pydantic_sub_model_pv1.py similarity index 81% rename from tests/test_filter_pydantic_sub_model.py rename to tests/test_filter_pydantic_sub_model/test_filter_pydantic_sub_model_pv1.py index 15b15f862..9e50f8321 100644 --- a/tests/test_filter_pydantic_sub_model.py +++ b/tests/test_filter_pydantic_sub_model/test_filter_pydantic_sub_model_pv1.py @@ -1,46 +1,20 @@ -from typing import Optional - import pytest -from fastapi import Depends, FastAPI +from fastapi.exceptions import ResponseValidationError from fastapi.testclient import TestClient -from pydantic import BaseModel, ValidationError, validator - -app = FastAPI() - - -class ModelB(BaseModel): - username: str - - -class ModelC(ModelB): - password: str - - -class ModelA(BaseModel): - name: str - description: Optional[str] = None - model_b: ModelB - - @validator("name") - def lower_username(cls, name: str, values): - if not name.endswith("A"): - raise ValueError("name must end in A") - return name - - -async def get_model_c() -> ModelC: - return ModelC(username="test-user", password="test-password") +from ..utils import needs_pydanticv1 -@app.get("/model/{name}", response_model=ModelA) -async def get_model_a(name: str, model_c=Depends(get_model_c)): - return {"name": name, "description": "model-a-desc", "model_b": model_c} +@pytest.fixture(name="client") +def get_client(): + from .app_pv1 import app -client = TestClient(app) + client = TestClient(app) + return client -def test_filter_sub_model(): +@needs_pydanticv1 +def test_filter_sub_model(client: TestClient): response = client.get("/model/modelA") assert response.status_code == 200, response.text assert response.json() == { @@ -50,8 +24,9 @@ def test_filter_sub_model(): } -def test_validator_is_cloned(): - with pytest.raises(ValidationError) as err: +@needs_pydanticv1 +def test_validator_is_cloned(client: TestClient): + with pytest.raises(ResponseValidationError) as err: client.get("/model/modelX") assert err.value.errors() == [ { @@ -62,7 +37,8 @@ def test_validator_is_cloned(): ] -def test_openapi_schema(): +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_filter_pydantic_sub_model_pv2.py b/tests/test_filter_pydantic_sub_model_pv2.py new file mode 100644 index 000000000..4c4d77943 --- /dev/null +++ b/tests/test_filter_pydantic_sub_model_pv2.py @@ -0,0 +1,182 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import Depends, FastAPI +from fastapi.exceptions import ResponseValidationError +from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url + +from .utils import needs_pydanticv2 + + +@pytest.fixture(name="client") +def get_client(): + from pydantic import BaseModel, FieldValidationInfo, field_validator + + app = FastAPI() + + class ModelB(BaseModel): + username: str + + class ModelC(ModelB): + password: str + + class ModelA(BaseModel): + name: str + description: Optional[str] = None + foo: ModelB + + @field_validator("name") + def lower_username(cls, name: str, info: FieldValidationInfo): + if not name.endswith("A"): + raise ValueError("name must end in A") + return name + + async def get_model_c() -> ModelC: + return ModelC(username="test-user", password="test-password") + + @app.get("/model/{name}", response_model=ModelA) + async def get_model_a(name: str, model_c=Depends(get_model_c)): + return {"name": name, "description": "model-a-desc", "foo": model_c} + + client = TestClient(app) + return client + + +@needs_pydanticv2 +def test_filter_sub_model(client: TestClient): + response = client.get("/model/modelA") + assert response.status_code == 200, response.text + assert response.json() == { + "name": "modelA", + "description": "model-a-desc", + "foo": {"username": "test-user"}, + } + + +@needs_pydanticv2 +def test_validator_is_cloned(client: TestClient): + with pytest.raises(ResponseValidationError) as err: + client.get("/model/modelX") + assert err.value.errors() == [ + IsDict( + { + "type": "value_error", + "loc": ("response", "name"), + "msg": "Value error, name must end in A", + "input": "modelX", + "ctx": {"error": "name must end in A"}, + "url": match_pydantic_error_url("value_error"), + } + ) + | IsDict( + # TODO remove when deprecating Pydantic v1 + { + "loc": ("response", "name"), + "msg": "name must end in A", + "type": "value_error", + } + ) + ] + + +@needs_pydanticv2 +def test_openapi_schema(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/model/{name}": { + "get": { + "summary": "Get Model A", + "operationId": "get_model_a_model__name__get", + "parameters": [ + { + "required": True, + "schema": {"title": "Name", "type": "string"}, + "name": "name", + "in": "path", + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/ModelA"} + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "ModelA": { + "title": "ModelA", + "required": ["name", "foo"], + "type": "object", + "properties": { + "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | + # TODO remove when deprecating Pydantic v1 + IsDict({"title": "Description", "type": "string"}), + "foo": {"$ref": "#/components/schemas/ModelB"}, + }, + }, + "ModelB": { + "title": "ModelB", + "required": ["username"], + "type": "object", + "properties": {"username": {"title": "Username", "type": "string"}}, + }, + "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"}, + }, + }, + } + }, + } diff --git a/tests/test_infer_param_optionality.py b/tests/test_infer_param_optionality.py index 5e673d9c4..ba4ebc7dd 100644 --- a/tests/test_infer_param_optionality.py +++ b/tests/test_infer_param_optionality.py @@ -1,5 +1,6 @@ from typing import Optional +from dirty_equals import IsDict from fastapi import APIRouter, FastAPI from fastapi.testclient import TestClient @@ -104,35 +105,253 @@ def test_get_users_item(): assert response.json() == {"item_id": "item01", "user_id": "abc123"} -def test_schema_1(): - """Check that the user_id is a required path parameter under /users""" +def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200, response.text - r = response.json() - - d = { - "required": True, - "schema": {"title": "User Id", "type": "string"}, - "name": "user_id", - "in": "path", + assert response.json() == { + "openapi": "3.0.2", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/users/": { + "get": { + "summary": "Get Users", + "operationId": "get_users_users__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + } + }, + "/users/{user_id}": { + "get": { + "summary": "Get User", + "operationId": "get_user_users__user_id__get", + "parameters": [ + { + "required": True, + "schema": {"title": "User Id", "type": "string"}, + "name": "user_id", + "in": "path", + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/items/": { + "get": { + "summary": "Get Items", + "operationId": "get_items_items__get", + "parameters": [ + { + "required": False, + "name": "user_id", + "in": "query", + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User Id", "type": "string"} + ), + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/items/{item_id}": { + "get": { + "summary": "Get Item", + "operationId": "get_item_items__item_id__get", + "parameters": [ + { + "required": True, + "schema": {"title": "Item Id", "type": "string"}, + "name": "item_id", + "in": "path", + }, + { + "required": False, + "name": "user_id", + "in": "query", + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User Id", "type": "string"} + ), + }, + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/users/{user_id}/items/": { + "get": { + "summary": "Get Items", + "operationId": "get_items_users__user_id__items__get", + "parameters": [ + { + "required": True, + "name": "user_id", + "in": "path", + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User Id", "type": "string"} + ), + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/users/{user_id}/items/{item_id}": { + "get": { + "summary": "Get Item", + "operationId": "get_item_users__user_id__items__item_id__get", + "parameters": [ + { + "required": True, + "schema": {"title": "Item Id", "type": "string"}, + "name": "item_id", + "in": "path", + }, + { + "required": True, + "name": "user_id", + "in": "path", + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User Id", "type": "string"} + ), + }, + ], + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + }, + "components": { + "schemas": { + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": "#/components/schemas/ValidationError"}, + } + }, + }, + "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"}, + }, + }, + } + }, } - - assert d in r["paths"]["/users/{user_id}"]["get"]["parameters"] - assert d in r["paths"]["/users/{user_id}/items/"]["get"]["parameters"] - - -def test_schema_2(): - """Check that the user_id is an optional query parameter under /items""" - response = client.get("/openapi.json") - assert response.status_code == 200, response.text - r = response.json() - - d = { - "required": False, - "schema": {"title": "User Id", "type": "string"}, - "name": "user_id", - "in": "query", - } - - assert d in r["paths"]["/items/{item_id}"]["get"]["parameters"] - assert d in r["paths"]["/items/"]["get"]["parameters"] diff --git a/tests/test_inherited_custom_class.py b/tests/test_inherited_custom_class.py index bac7eec1b..42b249211 100644 --- a/tests/test_inherited_custom_class.py +++ b/tests/test_inherited_custom_class.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel -app = FastAPI() +from .utils import needs_pydanticv1, needs_pydanticv2 class MyUuid: @@ -26,40 +26,78 @@ class MyUuid: raise TypeError("vars() argument must have __dict__ attribute") -@app.get("/fast_uuid") -def return_fast_uuid(): - # I don't want to import asyncpg for this test so I made my own UUID - # Import asyncpg and uncomment the two lines below for the actual bug +@needs_pydanticv2 +def test_pydanticv2(): + from pydantic import field_serializer - # from asyncpg.pgproto import pgproto - # asyncpg_uuid = pgproto.UUID("a10ff360-3b1e-4984-a26f-d3ab460bdb51") + app = FastAPI() - asyncpg_uuid = MyUuid("a10ff360-3b1e-4984-a26f-d3ab460bdb51") - assert isinstance(asyncpg_uuid, uuid.UUID) - assert type(asyncpg_uuid) != uuid.UUID - with pytest.raises(TypeError): - vars(asyncpg_uuid) - return {"fast_uuid": asyncpg_uuid} + @app.get("/fast_uuid") + def return_fast_uuid(): + asyncpg_uuid = MyUuid("a10ff360-3b1e-4984-a26f-d3ab460bdb51") + assert isinstance(asyncpg_uuid, uuid.UUID) + assert type(asyncpg_uuid) != uuid.UUID + with pytest.raises(TypeError): + vars(asyncpg_uuid) + return {"fast_uuid": asyncpg_uuid} + class SomeCustomClass(BaseModel): + model_config = {"arbitrary_types_allowed": True} -class SomeCustomClass(BaseModel): - class Config: - arbitrary_types_allowed = True - json_encoders = {uuid.UUID: str} + a_uuid: MyUuid - a_uuid: MyUuid + @field_serializer("a_uuid") + def serialize_a_uuid(self, v): + return str(v) + @app.get("/get_custom_class") + def return_some_user(): + # Test that the fix also works for custom pydantic classes + return SomeCustomClass(a_uuid=MyUuid("b8799909-f914-42de-91bc-95c819218d01")) -@app.get("/get_custom_class") -def return_some_user(): - # Test that the fix also works for custom pydantic classes - return SomeCustomClass(a_uuid=MyUuid("b8799909-f914-42de-91bc-95c819218d01")) + client = TestClient(app) + with client: + response_simple = client.get("/fast_uuid") + response_pydantic = client.get("/get_custom_class") + + assert response_simple.json() == { + "fast_uuid": "a10ff360-3b1e-4984-a26f-d3ab460bdb51" + } + + assert response_pydantic.json() == { + "a_uuid": "b8799909-f914-42de-91bc-95c819218d01" + } + + +# TODO: remove when deprecating Pydantic v1 +@needs_pydanticv1 +def test_pydanticv1(): + app = FastAPI() + + @app.get("/fast_uuid") + def return_fast_uuid(): + asyncpg_uuid = MyUuid("a10ff360-3b1e-4984-a26f-d3ab460bdb51") + assert isinstance(asyncpg_uuid, uuid.UUID) + assert type(asyncpg_uuid) != uuid.UUID + with pytest.raises(TypeError): + vars(asyncpg_uuid) + return {"fast_uuid": asyncpg_uuid} + + class SomeCustomClass(BaseModel): + class Config: + arbitrary_types_allowed = True + json_encoders = {uuid.UUID: str} + + a_uuid: MyUuid -client = TestClient(app) + @app.get("/get_custom_class") + def return_some_user(): + # Test that the fix also works for custom pydantic classes + return SomeCustomClass(a_uuid=MyUuid("b8799909-f914-42de-91bc-95c819218d01")) + client = TestClient(app) -def test_dt(): with client: response_simple = client.get("/fast_uuid") response_pydantic = client.get("/get_custom_class") diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index f4fdcf601..165dba304 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -5,8 +5,11 @@ from pathlib import PurePath, PurePosixPath, PureWindowsPath from typing import Optional import pytest +from fastapi._compat import PYDANTIC_V2 from fastapi.encoders import jsonable_encoder -from pydantic import BaseModel, Field, ValidationError, create_model +from pydantic import BaseModel, Field, ValidationError + +from .utils import needs_pydanticv1, needs_pydanticv2 class Person: @@ -45,22 +48,6 @@ class Unserializable: raise NotImplementedError() -class ModelWithCustomEncoder(BaseModel): - dt_field: datetime - - class Config: - json_encoders = { - datetime: lambda dt: dt.replace( - microsecond=0, tzinfo=timezone.utc - ).isoformat() - } - - -class ModelWithCustomEncoderSubclass(ModelWithCustomEncoder): - class Config: - pass - - class RoleEnum(Enum): admin = "admin" normal = "normal" @@ -69,8 +56,12 @@ class RoleEnum(Enum): class ModelWithConfig(BaseModel): role: Optional[RoleEnum] = None - class Config: - use_enum_values = True + if PYDANTIC_V2: + model_config = {"use_enum_values": True} + else: + + class Config: + use_enum_values = True class ModelWithAlias(BaseModel): @@ -83,23 +74,6 @@ class ModelWithDefault(BaseModel): bla: str = "bla" -class ModelWithRoot(BaseModel): - __root__: str - - -@pytest.fixture( - name="model_with_path", params=[PurePath, PurePosixPath, PureWindowsPath] -) -def fixture_model_with_path(request): - class Config: - arbitrary_types_allowed = True - - ModelWithPath = create_model( - "ModelWithPath", path=(request.param, ...), __config__=Config # type: ignore - ) - return ModelWithPath(path=request.param("/foo", "bar")) - - def test_encode_dict(): pet = {"name": "Firulais", "owner": {"name": "Foo"}} assert jsonable_encoder(pet) == {"name": "Firulais", "owner": {"name": "Foo"}} @@ -153,14 +127,47 @@ def test_encode_unsupported(): jsonable_encoder(unserializable) -def test_encode_custom_json_encoders_model(): +@needs_pydanticv2 +def test_encode_custom_json_encoders_model_pydanticv2(): + from pydantic import field_serializer + + class ModelWithCustomEncoder(BaseModel): + dt_field: datetime + + @field_serializer("dt_field") + def serialize_dt_field(self, dt): + return dt.replace(microsecond=0, tzinfo=timezone.utc).isoformat() + + class ModelWithCustomEncoderSubclass(ModelWithCustomEncoder): + pass + model = ModelWithCustomEncoder(dt_field=datetime(2019, 1, 1, 8)) assert jsonable_encoder(model) == {"dt_field": "2019-01-01T08:00:00+00:00"} + subclass_model = ModelWithCustomEncoderSubclass(dt_field=datetime(2019, 1, 1, 8)) + assert jsonable_encoder(subclass_model) == {"dt_field": "2019-01-01T08:00:00+00:00"} -def test_encode_custom_json_encoders_model_subclass(): - model = ModelWithCustomEncoderSubclass(dt_field=datetime(2019, 1, 1, 8)) +# TODO: remove when deprecating Pydantic v1 +@needs_pydanticv1 +def test_encode_custom_json_encoders_model_pydanticv1(): + class ModelWithCustomEncoder(BaseModel): + dt_field: datetime + + class Config: + json_encoders = { + datetime: lambda dt: dt.replace( + microsecond=0, tzinfo=timezone.utc + ).isoformat() + } + + class ModelWithCustomEncoderSubclass(ModelWithCustomEncoder): + class Config: + pass + + model = ModelWithCustomEncoder(dt_field=datetime(2019, 1, 1, 8)) assert jsonable_encoder(model) == {"dt_field": "2019-01-01T08:00:00+00:00"} + subclass_model = ModelWithCustomEncoderSubclass(dt_field=datetime(2019, 1, 1, 8)) + assert jsonable_encoder(subclass_model) == {"dt_field": "2019-01-01T08:00:00+00:00"} def test_encode_model_with_config(): @@ -196,6 +203,7 @@ def test_encode_model_with_default(): } +@needs_pydanticv1 def test_custom_encoders(): class safe_datetime(datetime): pass @@ -226,14 +234,55 @@ def test_custom_enum_encoders(): assert encoded_instance == custom_enum_encoder(instance) -def test_encode_model_with_path(model_with_path): - if isinstance(model_with_path.path, PureWindowsPath): - expected = "\\foo\\bar" - else: - expected = "/foo/bar" - assert jsonable_encoder(model_with_path) == {"path": expected} +def test_encode_model_with_pure_path(): + class ModelWithPath(BaseModel): + path: PurePath + + if PYDANTIC_V2: + model_config = {"arbitrary_types_allowed": True} + else: + + class Config: + arbitrary_types_allowed = True + + obj = ModelWithPath(path=PurePath("/foo", "bar")) + assert jsonable_encoder(obj) == {"path": "/foo/bar"} + + +def test_encode_model_with_pure_posix_path(): + class ModelWithPath(BaseModel): + path: PurePosixPath + if PYDANTIC_V2: + model_config = {"arbitrary_types_allowed": True} + else: + class Config: + arbitrary_types_allowed = True + + obj = ModelWithPath(path=PurePosixPath("/foo", "bar")) + assert jsonable_encoder(obj) == {"path": "/foo/bar"} + + +def test_encode_model_with_pure_windows_path(): + class ModelWithPath(BaseModel): + path: PureWindowsPath + + if PYDANTIC_V2: + model_config = {"arbitrary_types_allowed": True} + else: + + class Config: + arbitrary_types_allowed = True + + obj = ModelWithPath(path=PureWindowsPath("/foo", "bar")) + assert jsonable_encoder(obj) == {"path": "\\foo\\bar"} + + +@needs_pydanticv1 def test_encode_root(): + class ModelWithRoot(BaseModel): + __root__: str + model = ModelWithRoot(__root__="Foo") assert jsonable_encoder(model) == "Foo" diff --git a/tests/test_multi_body_errors.py b/tests/test_multi_body_errors.py index 358684bc5..391fbd207 100644 --- a/tests/test_multi_body_errors.py +++ b/tests/test_multi_body_errors.py @@ -1,8 +1,10 @@ from decimal import Decimal from typing import List +from dirty_equals import IsDict, IsOneOf from fastapi import FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from pydantic import BaseModel, condecimal app = FastAPI() @@ -21,59 +23,115 @@ def save_item_no_body(item: List[Item]): client = TestClient(app) -single_error = { - "detail": [ - { - "ctx": {"limit_value": 0.0}, - "loc": ["body", 0, "age"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - } - ] -} - -multiple_errors = { - "detail": [ - { - "loc": ["body", 0, "name"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", 0, "age"], - "msg": "value is not a valid decimal", - "type": "type_error.decimal", - }, - { - "loc": ["body", 1, "name"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", 1, "age"], - "msg": "value is not a valid decimal", - "type": "type_error.decimal", - }, - ] -} - - def test_put_correct_body(): response = client.post("/items/", json=[{"name": "Foo", "age": 5}]) assert response.status_code == 200, response.text - assert response.json() == {"item": [{"name": "Foo", "age": 5}]} + assert response.json() == { + "item": [ + { + "name": "Foo", + "age": IsOneOf( + 5, + # TODO: remove when deprecating Pydantic v1 + "5", + ), + } + ] + } def test_jsonable_encoder_requiring_error(): response = client.post("/items/", json=[{"name": "Foo", "age": -1.0}]) assert response.status_code == 422, response.text - assert response.json() == single_error + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["body", 0, "age"], + "msg": "Input should be greater than 0", + "input": -1.0, + "ctx": {"gt": 0.0}, + "url": match_pydantic_error_url("greater_than"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"limit_value": 0.0}, + "loc": ["body", 0, "age"], + "msg": "ensure this value is greater than 0", + "type": "value_error.number.not_gt", + } + ] + } + ) def test_put_incorrect_body_multiple(): response = client.post("/items/", json=[{"age": "five"}, {"age": "six"}]) assert response.status_code == 422, response.text - assert response.json() == multiple_errors + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", 0, "name"], + "msg": "Field required", + "input": {"age": "five"}, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "decimal_parsing", + "loc": ["body", 0, "age"], + "msg": "Input should be a valid decimal", + "input": "five", + }, + { + "type": "missing", + "loc": ["body", 1, "name"], + "msg": "Field required", + "input": {"age": "six"}, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "decimal_parsing", + "loc": ["body", 1, "age"], + "msg": "Input should be a valid decimal", + "input": "six", + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", 0, "name"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", 0, "age"], + "msg": "value is not a valid decimal", + "type": "type_error.decimal", + }, + { + "loc": ["body", 1, "name"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", 1, "age"], + "msg": "value is not a valid decimal", + "type": "type_error.decimal", + }, + ] + } + ) def test_openapi_schema(): @@ -126,11 +184,23 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "age": { - "title": "Age", - "exclusiveMinimum": 0.0, - "type": "number", - }, + "age": IsDict( + { + "title": "Age", + "anyOf": [ + {"exclusiveMinimum": 0.0, "type": "number"}, + {"type": "string"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Age", + "exclusiveMinimum": 0.0, + "type": "number", + } + ), }, }, "ValidationError": { diff --git a/tests/test_multi_query_errors.py b/tests/test_multi_query_errors.py index e7a833f2b..888c412c8 100644 --- a/tests/test_multi_query_errors.py +++ b/tests/test_multi_query_errors.py @@ -1,7 +1,9 @@ from typing import List +from dirty_equals import IsDict from fastapi import FastAPI, Query from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url app = FastAPI() @@ -14,22 +16,6 @@ def read_items(q: List[int] = Query(default=None)): client = TestClient(app) -multiple_errors = { - "detail": [ - { - "loc": ["query", "q", 0], - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - { - "loc": ["query", "q", 1], - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - ] -} - - def test_multi_query(): response = client.get("/items/?q=5&q=6") assert response.status_code == 200, response.text @@ -39,7 +25,42 @@ def test_multi_query(): def test_multi_query_incorrect(): response = client.get("/items/?q=five&q=six") assert response.status_code == 422, response.text - assert response.json() == multiple_errors + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["query", "q", 0], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "five", + "url": match_pydantic_error_url("int_parsing"), + }, + { + "type": "int_parsing", + "loc": ["query", "q", 1], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "six", + "url": match_pydantic_error_url("int_parsing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "q", 0], + "msg": "value is not a valid integer", + "type": "type_error.integer", + }, + { + "loc": ["query", "q", 1], + "msg": "value is not a valid integer", + "type": "type_error.integer", + }, + ] + } + ) def test_openapi_schema(): diff --git a/tests/test_openapi_query_parameter_extension.py b/tests/test_openapi_query_parameter_extension.py index 8a9086ebe..133ce3327 100644 --- a/tests/test_openapi_query_parameter_extension.py +++ b/tests/test_openapi_query_parameter_extension.py @@ -1,5 +1,6 @@ from typing import Optional +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient @@ -52,11 +53,21 @@ def test_openapi(): "parameters": [ { "required": False, - "schema": { - "title": "Standard Query Param", - "type": "integer", - "default": 50, - }, + "schema": IsDict( + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": 50, + "title": "Standard Query Param", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Standard Query Param", + "type": "integer", + "default": 50, + } + ), "name": "standard_query_param", "in": "query", }, diff --git a/tests/test_openapi_servers.py b/tests/test_openapi_servers.py index 26abeaa12..bd7eb2049 100644 --- a/tests/test_openapi_servers.py +++ b/tests/test_openapi_servers.py @@ -1,3 +1,4 @@ +from dirty_equals import IsOneOf from fastapi import FastAPI from fastapi.testclient import TestClient @@ -35,10 +36,20 @@ def test_openapi_schema(): "servers": [ {"url": "/", "description": "Default, relative server"}, { - "url": "http://staging.localhost.tiangolo.com:8000", + "url": IsOneOf( + "http://staging.localhost.tiangolo.com:8000/", + # TODO: remove when deprecating Pydantic v1 + "http://staging.localhost.tiangolo.com:8000", + ), "description": "Staging but actually localhost still", }, - {"url": "https://prod.example.com"}, + { + "url": IsOneOf( + "https://prod.example.com/", + # TODO: remove when deprecating Pydantic v1 + "https://prod.example.com", + ) + }, ], "paths": { "/foo": { diff --git a/tests/test_params_repr.py b/tests/test_params_repr.py index d8dca1ea4..bfc7bed09 100644 --- a/tests/test_params_repr.py +++ b/tests/test_params_repr.py @@ -1,6 +1,6 @@ from typing import Any, List -import pytest +from dirty_equals import IsOneOf from fastapi.params import Body, Cookie, Depends, Header, Param, Path, Query test_data: List[Any] = ["teststr", None, ..., 1, []] @@ -10,34 +10,137 @@ def get_user(): return {} # pragma: no cover -@pytest.fixture(scope="function", params=test_data) -def params(request): - return request.param +def test_param_repr_str(): + assert repr(Param("teststr")) == "Param(teststr)" -def test_param_repr(params): - assert repr(Param(params)) == "Param(" + str(params) + ")" +def test_param_repr_none(): + assert repr(Param(None)) == "Param(None)" + + +def test_param_repr_ellipsis(): + assert repr(Param(...)) == IsOneOf( + "Param(PydanticUndefined)", + # TODO: remove when deprecating Pydantic v1 + "Param(Ellipsis)", + ) + + +def test_param_repr_number(): + assert repr(Param(1)) == "Param(1)" + + +def test_param_repr_list(): + assert repr(Param([])) == "Param([])" def test_path_repr(): - assert repr(Path()) == "Path(Ellipsis)" - assert repr(Path(...)) == "Path(Ellipsis)" + assert repr(Path()) == IsOneOf( + "Path(PydanticUndefined)", + # TODO: remove when deprecating Pydantic v1 + "Path(Ellipsis)", + ) + assert repr(Path(...)) == IsOneOf( + "Path(PydanticUndefined)", + # TODO: remove when deprecating Pydantic v1 + "Path(Ellipsis)", + ) -def test_query_repr(params): - assert repr(Query(params)) == "Query(" + str(params) + ")" +def test_query_repr_str(): + assert repr(Query("teststr")) == "Query(teststr)" -def test_header_repr(params): - assert repr(Header(params)) == "Header(" + str(params) + ")" +def test_query_repr_none(): + assert repr(Query(None)) == "Query(None)" + + +def test_query_repr_ellipsis(): + assert repr(Query(...)) == IsOneOf( + "Query(PydanticUndefined)", + # TODO: remove when deprecating Pydantic v1 + "Query(Ellipsis)", + ) + + +def test_query_repr_number(): + assert repr(Query(1)) == "Query(1)" + + +def test_query_repr_list(): + assert repr(Query([])) == "Query([])" + + +def test_header_repr_str(): + assert repr(Header("teststr")) == "Header(teststr)" + + +def test_header_repr_none(): + assert repr(Header(None)) == "Header(None)" + + +def test_header_repr_ellipsis(): + assert repr(Header(...)) == IsOneOf( + "Header(PydanticUndefined)", + # TODO: remove when deprecating Pydantic v1 + "Header(Ellipsis)", + ) + + +def test_header_repr_number(): + assert repr(Header(1)) == "Header(1)" + + +def test_header_repr_list(): + assert repr(Header([])) == "Header([])" + + +def test_cookie_repr_str(): + assert repr(Cookie("teststr")) == "Cookie(teststr)" + + +def test_cookie_repr_none(): + assert repr(Cookie(None)) == "Cookie(None)" + + +def test_cookie_repr_ellipsis(): + assert repr(Cookie(...)) == IsOneOf( + "Cookie(PydanticUndefined)", + # TODO: remove when deprecating Pydantic v1 + "Cookie(Ellipsis)", + ) + + +def test_cookie_repr_number(): + assert repr(Cookie(1)) == "Cookie(1)" + + +def test_cookie_repr_list(): + assert repr(Cookie([])) == "Cookie([])" + + +def test_body_repr_str(): + assert repr(Body("teststr")) == "Body(teststr)" + + +def test_body_repr_none(): + assert repr(Body(None)) == "Body(None)" + + +def test_body_repr_ellipsis(): + assert repr(Body(...)) == IsOneOf( + "Body(PydanticUndefined)", + # TODO: remove when deprecating Pydantic v1 + "Body(Ellipsis)", + ) -def test_cookie_repr(params): - assert repr(Cookie(params)) == "Cookie(" + str(params) + ")" +def test_body_repr_number(): + assert repr(Body(1)) == "Body(1)" -def test_body_repr(params): - assert repr(Body(params)) == "Body(" + str(params) + ")" +def test_body_repr_list(): + assert repr(Body([])) == "Body([])" def test_depends_repr(): diff --git a/tests/test_path.py b/tests/test_path.py index 03b93623a..631c3be9d 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -1,5 +1,6 @@ -import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from .main import app @@ -18,235 +19,1192 @@ def test_nonexistent(): assert response.json() == {"detail": "Not Found"} -response_not_valid_bool = { - "detail": [ +def test_path_foobar(): + response = client.get("/path/foobar") + assert response.status_code == 200 + assert response.json() == "foobar" + + +def test_path_str_foobar(): + response = client.get("/path/str/foobar") + assert response.status_code == 200 + assert response.json() == "foobar" + + +def test_path_str_42(): + response = client.get("/path/str/42") + assert response.status_code == 200 + assert response.json() == "42" + + +def test_path_str_True(): + response = client.get("/path/str/True") + assert response.status_code == 200 + assert response.json() == "True" + + +def test_path_int_foobar(): + response = client.get("/path/int/foobar") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foobar", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_int_True(): + response = client.get("/path/int/True") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "True", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_int_42(): + response = client.get("/path/int/42") + assert response.status_code == 200 + assert response.json() == 42 + + +def test_path_int_42_5(): + response = client.get("/path/int/42.5") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "42.5", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_float_foobar(): + response = client.get("/path/float/foobar") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "float_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid number, unable to parse string as an number", + "input": "foobar", + "url": match_pydantic_error_url("float_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid float", + "type": "type_error.float", + } + ] + } + ) + + +def test_path_float_True(): + response = client.get("/path/float/True") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "float_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid number, unable to parse string as an number", + "input": "True", + "url": match_pydantic_error_url("float_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid float", + "type": "type_error.float", + } + ] + } + ) + + +def test_path_float_42(): + response = client.get("/path/float/42") + assert response.status_code == 200 + assert response.json() == 42 + + +def test_path_float_42_5(): + response = client.get("/path/float/42.5") + assert response.status_code == 200 + assert response.json() == 42.5 + + +def test_path_bool_foobar(): + response = client.get("/path/bool/foobar") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "bool_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid boolean, unable to interpret input", + "input": "foobar", + "url": match_pydantic_error_url("bool_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value could not be parsed to a boolean", + "type": "type_error.bool", + } + ] + } + ) + + +def test_path_bool_True(): + response = client.get("/path/bool/True") + assert response.status_code == 200 + assert response.json() is True + + +def test_path_bool_42(): + response = client.get("/path/bool/42") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "bool_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid boolean, unable to interpret input", + "input": "42", + "url": match_pydantic_error_url("bool_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value could not be parsed to a boolean", + "type": "type_error.bool", + } + ] + } + ) + + +def test_path_bool_42_5(): + response = client.get("/path/bool/42.5") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "bool_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid boolean, unable to interpret input", + "input": "42.5", + "url": match_pydantic_error_url("bool_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["path", "item_id"], - "msg": "value could not be parsed to a boolean", - "type": "type_error.bool", + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value could not be parsed to a boolean", + "type": "type_error.bool", + } + ] } - ] -} + ) + + +def test_path_bool_1(): + response = client.get("/path/bool/1") + assert response.status_code == 200 + assert response.json() is True + + +def test_path_bool_0(): + response = client.get("/path/bool/0") + assert response.status_code == 200 + assert response.json() is False + + +def test_path_bool_true(): + response = client.get("/path/bool/true") + assert response.status_code == 200 + assert response.json() is True + + +def test_path_bool_False(): + response = client.get("/path/bool/False") + assert response.status_code == 200 + assert response.json() is False + + +def test_path_bool_false(): + response = client.get("/path/bool/false") + assert response.status_code == 200 + assert response.json() is False + + +def test_path_param_foo(): + response = client.get("/path/param/foo") + assert response.status_code == 200 + assert response.json() == "foo" + + +def test_path_param_minlength_foo(): + response = client.get("/path/param-minlength/foo") + assert response.status_code == 200 + assert response.json() == "foo" -response_not_valid_int = { - "detail": [ + +def test_path_param_minlength_fo(): + response = client.get("/path/param-minlength/fo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "string_too_short", + "loc": ["path", "item_id"], + "msg": "String should have at least 3 characters", + "input": "fo", + "ctx": {"min_length": 3}, + "url": match_pydantic_error_url("string_too_short"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["path", "item_id"], - "msg": "value is not a valid integer", - "type": "type_error.integer", + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value has at least 3 characters", + "type": "value_error.any_str.min_length", + "ctx": {"limit_value": 3}, + } + ] } - ] -} + ) + -response_not_valid_float = { - "detail": [ +def test_path_param_maxlength_foo(): + response = client.get("/path/param-maxlength/foo") + assert response.status_code == 200 + assert response.json() == "foo" + + +def test_path_param_maxlength_foobar(): + response = client.get("/path/param-maxlength/foobar") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "string_too_long", + "loc": ["path", "item_id"], + "msg": "String should have at most 3 characters", + "input": "foobar", + "ctx": {"max_length": 3}, + "url": match_pydantic_error_url("string_too_long"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["path", "item_id"], - "msg": "value is not a valid float", - "type": "type_error.float", + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value has at most 3 characters", + "type": "value_error.any_str.max_length", + "ctx": {"limit_value": 3}, + } + ] } - ] -} + ) -response_at_least_3 = { - "detail": [ + +def test_path_param_gt_42(): + response = client.get("/path/param-gt/42") + assert response.status_code == 200 + assert response.json() == 42 + + +def test_path_param_gt_2(): + response = client.get("/path/param-gt/2") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["path", "item_id"], - "msg": "ensure this value has at least 3 characters", - "type": "value_error.any_str.min_length", - "ctx": {"limit_value": 3}, + "detail": [ + { + "type": "greater_than", + "loc": ["path", "item_id"], + "msg": "Input should be greater than 3", + "input": "2", + "ctx": {"gt": 3.0}, + "url": match_pydantic_error_url("greater_than"), + } + ] } - ] -} + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is greater than 3", + "type": "value_error.number.not_gt", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_gt0_0_05(): + response = client.get("/path/param-gt0/0.05") + assert response.status_code == 200 + assert response.json() == 0.05 -response_at_least_2 = { - "detail": [ +def test_path_param_gt0_0(): + response = client.get("/path/param-gt0/0") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["path", "item_id"], - "msg": "ensure this value has at least 2 characters", - "type": "value_error.any_str.min_length", - "ctx": {"limit_value": 2}, + "detail": [ + { + "type": "greater_than", + "loc": ["path", "item_id"], + "msg": "Input should be greater than 0", + "input": "0", + "ctx": {"gt": 0.0}, + "url": match_pydantic_error_url("greater_than"), + } + ] } - ] -} + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is greater than 0", + "type": "value_error.number.not_gt", + "ctx": {"limit_value": 0}, + } + ] + } + ) + + +def test_path_param_ge_42(): + response = client.get("/path/param-ge/42") + assert response.status_code == 200 + assert response.json() == 42 -response_maximum_3 = { - "detail": [ +def test_path_param_ge_3(): + response = client.get("/path/param-ge/3") + assert response.status_code == 200 + assert response.json() == 3 + + +def test_path_param_ge_2(): + response = client.get("/path/param-ge/2") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than_equal", + "loc": ["path", "item_id"], + "msg": "Input should be greater than or equal to 3", + "input": "2", + "ctx": {"ge": 3.0}, + "url": match_pydantic_error_url("greater_than_equal"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["path", "item_id"], - "msg": "ensure this value has at most 3 characters", - "type": "value_error.any_str.max_length", - "ctx": {"limit_value": 3}, + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is greater than or equal to 3", + "type": "value_error.number.not_ge", + "ctx": {"limit_value": 3}, + } + ] } - ] -} + ) -response_greater_than_3 = { - "detail": [ +def test_path_param_lt_42(): + response = client.get("/path/param-lt/42") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["path", "item_id"], - "msg": "ensure this value is greater than 3", - "type": "value_error.number.not_gt", - "ctx": {"limit_value": 3}, + "detail": [ + { + "type": "less_than", + "loc": ["path", "item_id"], + "msg": "Input should be less than 3", + "input": "42", + "ctx": {"lt": 3.0}, + "url": match_pydantic_error_url("less_than"), + } + ] } - ] -} + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than 3", + "type": "value_error.number.not_lt", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_lt_2(): + response = client.get("/path/param-lt/2") + assert response.status_code == 200 + assert response.json() == 2 -response_greater_than_0 = { - "detail": [ +def test_path_param_lt0__1(): + response = client.get("/path/param-lt0/-1") + assert response.status_code == 200 + assert response.json() == -1 + + +def test_path_param_lt0_0(): + response = client.get("/path/param-lt0/0") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["path", "item_id"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - "ctx": {"limit_value": 0}, + "detail": [ + { + "type": "less_than", + "loc": ["path", "item_id"], + "msg": "Input should be less than 0", + "input": "0", + "ctx": {"lt": 0.0}, + "url": match_pydantic_error_url("less_than"), + } + ] } - ] -} + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than 0", + "type": "value_error.number.not_lt", + "ctx": {"limit_value": 0}, + } + ] + } + ) + + +def test_path_param_le_42(): + response = client.get("/path/param-le/42") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "less_than_equal", + "loc": ["path", "item_id"], + "msg": "Input should be less than or equal to 3", + "input": "42", + "ctx": {"le": 3.0}, + "url": match_pydantic_error_url("less_than_equal"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than or equal to 3", + "type": "value_error.number.not_le", + "ctx": {"limit_value": 3}, + } + ] + } + ) -response_greater_than_1 = { - "detail": [ +def test_path_param_le_3(): + response = client.get("/path/param-le/3") + assert response.status_code == 200 + assert response.json() == 3 + + +def test_path_param_le_2(): + response = client.get("/path/param-le/2") + assert response.status_code == 200 + assert response.json() == 2 + + +def test_path_param_lt_gt_2(): + response = client.get("/path/param-lt-gt/2") + assert response.status_code == 200 + assert response.json() == 2 + + +def test_path_param_lt_gt_4(): + response = client.get("/path/param-lt-gt/4") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "less_than", + "loc": ["path", "item_id"], + "msg": "Input should be less than 3", + "input": "4", + "ctx": {"lt": 3.0}, + "url": match_pydantic_error_url("less_than"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than 3", + "type": "value_error.number.not_lt", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_lt_gt_0(): + response = client.get("/path/param-lt-gt/0") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["path", "item_id"], + "msg": "Input should be greater than 1", + "input": "0", + "ctx": {"gt": 1.0}, + "url": match_pydantic_error_url("greater_than"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is greater than 1", + "type": "value_error.number.not_gt", + "ctx": {"limit_value": 1}, + } + ] + } + ) + + +def test_path_param_le_ge_2(): + response = client.get("/path/param-le-ge/2") + assert response.status_code == 200 + assert response.json() == 2 + + +def test_path_param_le_ge_1(): + response = client.get("/path/param-le-ge/1") + assert response.status_code == 200 + + +def test_path_param_le_ge_3(): + response = client.get("/path/param-le-ge/3") + assert response.status_code == 200 + assert response.json() == 3 + + +def test_path_param_le_ge_4(): + response = client.get("/path/param-le-ge/4") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "less_than_equal", + "loc": ["path", "item_id"], + "msg": "Input should be less than or equal to 3", + "input": "4", + "ctx": {"le": 3.0}, + "url": match_pydantic_error_url("less_than_equal"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than or equal to 3", + "type": "value_error.number.not_le", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_lt_int_2(): + response = client.get("/path/param-lt-int/2") + assert response.status_code == 200 + assert response.json() == 2 + + +def test_path_param_lt_int_42(): + response = client.get("/path/param-lt-int/42") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "less_than", + "loc": ["path", "item_id"], + "msg": "Input should be less than 3", + "input": "42", + "ctx": {"lt": 3}, + "url": match_pydantic_error_url("less_than"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than 3", + "type": "value_error.number.not_lt", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_lt_int_2_7(): + response = client.get("/path/param-lt-int/2.7") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "2.7", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_param_gt_int_42(): + response = client.get("/path/param-gt-int/42") + assert response.status_code == 200 + assert response.json() == 42 + + +def test_path_param_gt_int_2(): + response = client.get("/path/param-gt-int/2") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["path", "item_id"], + "msg": "Input should be greater than 3", + "input": "2", + "ctx": {"gt": 3}, + "url": match_pydantic_error_url("greater_than"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is greater than 3", + "type": "value_error.number.not_gt", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_gt_int_2_7(): + response = client.get("/path/param-gt-int/2.7") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "2.7", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_param_le_int_42(): + response = client.get("/path/param-le-int/42") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "less_than_equal", + "loc": ["path", "item_id"], + "msg": "Input should be less than or equal to 3", + "input": "42", + "ctx": {"le": 3}, + "url": match_pydantic_error_url("less_than_equal"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than or equal to 3", + "type": "value_error.number.not_le", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_le_int_3(): + response = client.get("/path/param-le-int/3") + assert response.status_code == 200 + assert response.json() == 3 + + +def test_path_param_le_int_2(): + response = client.get("/path/param-le-int/2") + assert response.status_code == 200 + assert response.json() == 2 + + +def test_path_param_le_int_2_7(): + response = client.get("/path/param-le-int/2.7") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "2.7", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_param_ge_int_42(): + response = client.get("/path/param-ge-int/42") + assert response.status_code == 200 + assert response.json() == 42 + + +def test_path_param_ge_int_3(): + response = client.get("/path/param-ge-int/3") + assert response.status_code == 200 + assert response.json() == 3 + + +def test_path_param_ge_int_2(): + response = client.get("/path/param-ge-int/2") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than_equal", + "loc": ["path", "item_id"], + "msg": "Input should be greater than or equal to 3", + "input": "2", + "ctx": {"ge": 3}, + "url": match_pydantic_error_url("greater_than_equal"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is greater than or equal to 3", + "type": "value_error.number.not_ge", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_ge_int_2_7(): + response = client.get("/path/param-ge-int/2.7") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "2.7", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_param_lt_gt_int_2(): + response = client.get("/path/param-lt-gt-int/2") + assert response.status_code == 200 + assert response.json() == 2 + + +def test_path_param_lt_gt_int_4(): + response = client.get("/path/param-lt-gt-int/4") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "less_than", + "loc": ["path", "item_id"], + "msg": "Input should be less than 3", + "input": "4", + "ctx": {"lt": 3}, + "url": match_pydantic_error_url("less_than"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than 3", + "type": "value_error.number.not_lt", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_lt_gt_int_0(): + response = client.get("/path/param-lt-gt-int/0") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["path", "item_id"], + "msg": "Input should be greater than 1", + "input": "0", + "ctx": {"gt": 1}, + "url": match_pydantic_error_url("greater_than"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is greater than 1", + "type": "value_error.number.not_gt", + "ctx": {"limit_value": 1}, + } + ] + } + ) + + +def test_path_param_lt_gt_int_2_7(): + response = client.get("/path/param-lt-gt-int/2.7") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "2.7", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_path_param_le_ge_int_2(): + response = client.get("/path/param-le-ge-int/2") + assert response.status_code == 200 + assert response.json() == 2 + + +def test_path_param_le_ge_int_1(): + response = client.get("/path/param-le-ge-int/1") + assert response.status_code == 200 + assert response.json() == 1 + + +def test_path_param_le_ge_int_3(): + response = client.get("/path/param-le-ge-int/3") + assert response.status_code == 200 + assert response.json() == 3 + + +def test_path_param_le_ge_int_4(): + response = client.get("/path/param-le-ge-int/4") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "less_than_equal", + "loc": ["path", "item_id"], + "msg": "Input should be less than or equal to 3", + "input": "4", + "ctx": {"le": 3}, + "url": match_pydantic_error_url("less_than_equal"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "ensure this value is less than or equal to 3", + "type": "value_error.number.not_le", + "ctx": {"limit_value": 3}, + } + ] + } + ) + + +def test_path_param_le_ge_int_2_7(): + response = client.get("/path/param-le-ge-int/2.7") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "2.7", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["path", "item_id"], - "msg": "ensure this value is greater than 1", - "type": "value_error.number.not_gt", - "ctx": {"limit_value": 1}, + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] } - ] -} - - -response_greater_than_equal_3 = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "ensure this value is greater than or equal to 3", - "type": "value_error.number.not_ge", - "ctx": {"limit_value": 3}, - } - ] -} - - -response_less_than_3 = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "ensure this value is less than 3", - "type": "value_error.number.not_lt", - "ctx": {"limit_value": 3}, - } - ] -} - - -response_less_than_0 = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "ensure this value is less than 0", - "type": "value_error.number.not_lt", - "ctx": {"limit_value": 0}, - } - ] -} - - -response_less_than_equal_3 = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "ensure this value is less than or equal to 3", - "type": "value_error.number.not_le", - "ctx": {"limit_value": 3}, - } - ] -} - - -@pytest.mark.parametrize( - "path,expected_status,expected_response", - [ - ("/path/foobar", 200, "foobar"), - ("/path/str/foobar", 200, "foobar"), - ("/path/str/42", 200, "42"), - ("/path/str/True", 200, "True"), - ("/path/int/foobar", 422, response_not_valid_int), - ("/path/int/True", 422, response_not_valid_int), - ("/path/int/42", 200, 42), - ("/path/int/42.5", 422, response_not_valid_int), - ("/path/float/foobar", 422, response_not_valid_float), - ("/path/float/True", 422, response_not_valid_float), - ("/path/float/42", 200, 42), - ("/path/float/42.5", 200, 42.5), - ("/path/bool/foobar", 422, response_not_valid_bool), - ("/path/bool/True", 200, True), - ("/path/bool/42", 422, response_not_valid_bool), - ("/path/bool/42.5", 422, response_not_valid_bool), - ("/path/bool/1", 200, True), - ("/path/bool/0", 200, False), - ("/path/bool/true", 200, True), - ("/path/bool/False", 200, False), - ("/path/bool/false", 200, False), - ("/path/param/foo", 200, "foo"), - ("/path/param-minlength/foo", 200, "foo"), - ("/path/param-minlength/fo", 422, response_at_least_3), - ("/path/param-maxlength/foo", 200, "foo"), - ("/path/param-maxlength/foobar", 422, response_maximum_3), - ("/path/param-min_maxlength/foo", 200, "foo"), - ("/path/param-min_maxlength/foobar", 422, response_maximum_3), - ("/path/param-min_maxlength/f", 422, response_at_least_2), - ("/path/param-gt/42", 200, 42), - ("/path/param-gt/2", 422, response_greater_than_3), - ("/path/param-gt0/0.05", 200, 0.05), - ("/path/param-gt0/0", 422, response_greater_than_0), - ("/path/param-ge/42", 200, 42), - ("/path/param-ge/3", 200, 3), - ("/path/param-ge/2", 422, response_greater_than_equal_3), - ("/path/param-lt/42", 422, response_less_than_3), - ("/path/param-lt/2", 200, 2), - ("/path/param-lt0/-1", 200, -1), - ("/path/param-lt0/0", 422, response_less_than_0), - ("/path/param-le/42", 422, response_less_than_equal_3), - ("/path/param-le/3", 200, 3), - ("/path/param-le/2", 200, 2), - ("/path/param-lt-gt/2", 200, 2), - ("/path/param-lt-gt/4", 422, response_less_than_3), - ("/path/param-lt-gt/0", 422, response_greater_than_1), - ("/path/param-le-ge/2", 200, 2), - ("/path/param-le-ge/1", 200, 1), - ("/path/param-le-ge/3", 200, 3), - ("/path/param-le-ge/4", 422, response_less_than_equal_3), - ("/path/param-lt-int/2", 200, 2), - ("/path/param-lt-int/42", 422, response_less_than_3), - ("/path/param-lt-int/2.7", 422, response_not_valid_int), - ("/path/param-gt-int/42", 200, 42), - ("/path/param-gt-int/2", 422, response_greater_than_3), - ("/path/param-gt-int/2.7", 422, response_not_valid_int), - ("/path/param-le-int/42", 422, response_less_than_equal_3), - ("/path/param-le-int/3", 200, 3), - ("/path/param-le-int/2", 200, 2), - ("/path/param-le-int/2.7", 422, response_not_valid_int), - ("/path/param-ge-int/42", 200, 42), - ("/path/param-ge-int/3", 200, 3), - ("/path/param-ge-int/2", 422, response_greater_than_equal_3), - ("/path/param-ge-int/2.7", 422, response_not_valid_int), - ("/path/param-lt-gt-int/2", 200, 2), - ("/path/param-lt-gt-int/4", 422, response_less_than_3), - ("/path/param-lt-gt-int/0", 422, response_greater_than_1), - ("/path/param-lt-gt-int/2.7", 422, response_not_valid_int), - ("/path/param-le-ge-int/2", 200, 2), - ("/path/param-le-ge-int/1", 200, 1), - ("/path/param-le-ge-int/3", 200, 3), - ("/path/param-le-ge-int/4", 422, response_less_than_equal_3), - ("/path/param-le-ge-int/2.7", 422, response_not_valid_int), - ], -) -def test_get_path(path, expected_status, expected_response): - response = client.get(path) - assert response.status_code == expected_status - assert response.json() == expected_response + ) diff --git a/tests/test_query.py b/tests/test_query.py index 0c73eb665..5bb9995d6 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,62 +1,410 @@ -import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from .main import app client = TestClient(app) -response_missing = { - "detail": [ - { - "loc": ["query", "query"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} - -response_not_valid_int = { - "detail": [ - { - "loc": ["query", "query"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] -} - - -@pytest.mark.parametrize( - "path,expected_status,expected_response", - [ - ("/query", 422, response_missing), - ("/query?query=baz", 200, "foo bar baz"), - ("/query?not_declared=baz", 422, response_missing), - ("/query/optional", 200, "foo bar"), - ("/query/optional?query=baz", 200, "foo bar baz"), - ("/query/optional?not_declared=baz", 200, "foo bar"), - ("/query/int", 422, response_missing), - ("/query/int?query=42", 200, "foo bar 42"), - ("/query/int?query=42.5", 422, response_not_valid_int), - ("/query/int?query=baz", 422, response_not_valid_int), - ("/query/int?not_declared=baz", 422, response_missing), - ("/query/int/optional", 200, "foo bar"), - ("/query/int/optional?query=50", 200, "foo bar 50"), - ("/query/int/optional?query=foo", 422, response_not_valid_int), - ("/query/int/default", 200, "foo bar 10"), - ("/query/int/default?query=50", 200, "foo bar 50"), - ("/query/int/default?query=foo", 422, response_not_valid_int), - ("/query/param", 200, "foo bar"), - ("/query/param?query=50", 200, "foo bar 50"), - ("/query/param-required", 422, response_missing), - ("/query/param-required?query=50", 200, "foo bar 50"), - ("/query/param-required/int", 422, response_missing), - ("/query/param-required/int?query=50", 200, "foo bar 50"), - ("/query/param-required/int?query=foo", 422, response_not_valid_int), - ("/query/frozenset/?query=1&query=1&query=2", 200, "1,2"), - ], -) -def test_get_path(path, expected_status, expected_response): - response = client.get(path) - assert response.status_code == expected_status - assert response.json() == expected_response + +def test_query(): + response = client.get("/query") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "query"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_query_query_baz(): + response = client.get("/query?query=baz") + assert response.status_code == 200 + assert response.json() == "foo bar baz" + + +def test_query_not_declared_baz(): + response = client.get("/query?not_declared=baz") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "query"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_query_optional(): + response = client.get("/query/optional") + assert response.status_code == 200 + assert response.json() == "foo bar" + + +def test_query_optional_query_baz(): + response = client.get("/query/optional?query=baz") + assert response.status_code == 200 + assert response.json() == "foo bar baz" + + +def test_query_optional_not_declared_baz(): + response = client.get("/query/optional?not_declared=baz") + assert response.status_code == 200 + assert response.json() == "foo bar" + + +def test_query_int(): + response = client.get("/query/int") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "query"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_query_int_query_42(): + response = client.get("/query/int?query=42") + assert response.status_code == 200 + assert response.json() == "foo bar 42" + + +def test_query_int_query_42_5(): + response = client.get("/query/int?query=42.5") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["query", "query"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "42.5", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_query_int_query_baz(): + response = client.get("/query/int?query=baz") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["query", "query"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "baz", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_query_int_not_declared_baz(): + response = client.get("/query/int?not_declared=baz") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "query"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_query_int_optional(): + response = client.get("/query/int/optional") + assert response.status_code == 200 + assert response.json() == "foo bar" + + +def test_query_int_optional_query_50(): + response = client.get("/query/int/optional?query=50") + assert response.status_code == 200 + assert response.json() == "foo bar 50" + + +def test_query_int_optional_query_foo(): + response = client.get("/query/int/optional?query=foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["query", "query"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_query_int_default(): + response = client.get("/query/int/default") + assert response.status_code == 200 + assert response.json() == "foo bar 10" + + +def test_query_int_default_query_50(): + response = client.get("/query/int/default?query=50") + assert response.status_code == 200 + assert response.json() == "foo bar 50" + + +def test_query_int_default_query_foo(): + response = client.get("/query/int/default?query=foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["query", "query"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_query_param(): + response = client.get("/query/param") + assert response.status_code == 200 + assert response.json() == "foo bar" + + +def test_query_param_query_50(): + response = client.get("/query/param?query=50") + assert response.status_code == 200 + assert response.json() == "foo bar 50" + + +def test_query_param_required(): + response = client.get("/query/param-required") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "query"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_query_param_required_query_50(): + response = client.get("/query/param-required?query=50") + assert response.status_code == 200 + assert response.json() == "foo bar 50" + + +def test_query_param_required_int(): + response = client.get("/query/param-required/int") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "query"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_query_param_required_int_query_50(): + response = client.get("/query/param-required/int?query=50") + assert response.status_code == 200 + assert response.json() == "foo bar 50" + + +def test_query_param_required_int_query_foo(): + response = client.get("/query/param-required/int?query=foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["query", "query"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "query"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_query_frozenset_query_1_query_1_query_2(): + response = client.get("/query/frozenset/?query=1&query=1&query=2") + assert response.status_code == 200 + assert response.json() == "1,2" diff --git a/tests/test_read_with_orm_mode.py b/tests/test_read_with_orm_mode.py index 360ad2503..b35987443 100644 --- a/tests/test_read_with_orm_mode.py +++ b/tests/test_read_with_orm_mode.py @@ -2,48 +2,83 @@ from typing import Any from fastapi import FastAPI from fastapi.testclient import TestClient -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict +from .utils import needs_pydanticv1, needs_pydanticv2 -class PersonBase(BaseModel): - name: str - lastname: str +@needs_pydanticv2 +def test_read_with_orm_mode() -> None: + class PersonBase(BaseModel): + name: str + lastname: str + + class Person(PersonBase): + @property + def full_name(self) -> str: + return f"{self.name} {self.lastname}" + + model_config = ConfigDict(from_attributes=True) + + class PersonCreate(PersonBase): + pass -class Person(PersonBase): - @property - def full_name(self) -> str: - return f"{self.name} {self.lastname}" + class PersonRead(PersonBase): + full_name: str - class Config: - orm_mode = True - read_with_orm_mode = True + model_config = {"from_attributes": True} + app = FastAPI() -class PersonCreate(PersonBase): - pass + @app.post("/people/", response_model=PersonRead) + def create_person(person: PersonCreate) -> Any: + db_person = Person.model_validate(person) + return db_person + + client = TestClient(app) + + person_data = {"name": "Dive", "lastname": "Wilson"} + response = client.post("/people/", json=person_data) + data = response.json() + assert response.status_code == 200, response.text + assert data["name"] == person_data["name"] + assert data["lastname"] == person_data["lastname"] + assert data["full_name"] == person_data["name"] + " " + person_data["lastname"] -class PersonRead(PersonBase): - full_name: str +@needs_pydanticv1 +def test_read_with_orm_mode_pv1() -> None: + class PersonBase(BaseModel): + name: str + lastname: str - class Config: - orm_mode = True + class Person(PersonBase): + @property + def full_name(self) -> str: + return f"{self.name} {self.lastname}" + class Config: + orm_mode = True + read_with_orm_mode = True -app = FastAPI() + class PersonCreate(PersonBase): + pass + class PersonRead(PersonBase): + full_name: str -@app.post("/people/", response_model=PersonRead) -def create_person(person: PersonCreate) -> Any: - db_person = Person.from_orm(person) - return db_person + class Config: + orm_mode = True + app = FastAPI() -client = TestClient(app) + @app.post("/people/", response_model=PersonRead) + def create_person(person: PersonCreate) -> Any: + db_person = Person.from_orm(person) + return db_person + client = TestClient(app) -def test_read_with_orm_mode() -> None: person_data = {"name": "Dive", "lastname": "Wilson"} response = client.post("/people/", json=person_data) data = response.json() diff --git a/tests/test_request_body_parameters_media_type.py b/tests/test_request_body_parameters_media_type.py index 32f6c6a72..fe8077c81 100644 --- a/tests/test_request_body_parameters_media_type.py +++ b/tests/test_request_body_parameters_media_type.py @@ -39,7 +39,6 @@ client = TestClient(app) def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200, response.text - # insert_assert(response.json()) assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, diff --git a/tests/test_response_by_alias.py b/tests/test_response_by_alias.py index 1861a40fa..d05c08486 100644 --- a/tests/test_response_by_alias.py +++ b/tests/test_response_by_alias.py @@ -1,8 +1,9 @@ from typing import List from fastapi import FastAPI +from fastapi._compat import PYDANTIC_V2 from fastapi.testclient import TestClient -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field app = FastAPI() @@ -14,13 +15,24 @@ class Model(BaseModel): class ModelNoAlias(BaseModel): name: str - class Config: - schema_extra = { - "description": ( - "response_model_by_alias=False is basically a quick hack, to support " - "proper OpenAPI use another model with the correct field names" - ) - } + if PYDANTIC_V2: + model_config = ConfigDict( + json_schema_extra={ + "description": ( + "response_model_by_alias=False is basically a quick hack, to support " + "proper OpenAPI use another model with the correct field names" + ) + } + ) + else: + + class Config: + schema_extra = { + "description": ( + "response_model_by_alias=False is basically a quick hack, to support " + "proper OpenAPI use another model with the correct field names" + ) + } @app.get("/dict", response_model=Model, response_model_by_alias=False) diff --git a/tests/test_response_model_as_return_annotation.py b/tests/test_response_model_as_return_annotation.py index 7decdff7d..1ab93f6a2 100644 --- a/tests/test_response_model_as_return_annotation.py +++ b/tests/test_response_model_as_return_annotation.py @@ -2,10 +2,10 @@ from typing import List, Union import pytest from fastapi import FastAPI -from fastapi.exceptions import FastAPIError +from fastapi.exceptions import FastAPIError, ResponseValidationError from fastapi.responses import JSONResponse, Response from fastapi.testclient import TestClient -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel class BaseUser(BaseModel): @@ -277,12 +277,12 @@ def test_response_model_no_annotation_return_exact_dict(): def test_response_model_no_annotation_return_invalid_dict(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/response_model-no_annotation-return_invalid_dict") def test_response_model_no_annotation_return_invalid_model(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/response_model-no_annotation-return_invalid_model") @@ -313,12 +313,12 @@ def test_no_response_model_annotation_return_exact_dict(): def test_no_response_model_annotation_return_invalid_dict(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/no_response_model-annotation-return_invalid_dict") def test_no_response_model_annotation_return_invalid_model(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/no_response_model-annotation-return_invalid_model") @@ -395,12 +395,12 @@ def test_response_model_model1_annotation_model2_return_exact_dict(): def test_response_model_model1_annotation_model2_return_invalid_dict(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/response_model_model1-annotation_model2-return_invalid_dict") def test_response_model_model1_annotation_model2_return_invalid_model(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/response_model_model1-annotation_model2-return_invalid_model") diff --git a/tests/test_response_model_data_filter.py b/tests/test_response_model_data_filter.py new file mode 100644 index 000000000..358697d6d --- /dev/null +++ b/tests/test_response_model_data_filter.py @@ -0,0 +1,79 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + + +class UserBase(BaseModel): + email: str + + +class UserCreate(UserBase): + password: str + + +class UserDB(UserBase): + hashed_password: str + + +class PetDB(BaseModel): + name: str + owner: UserDB + + +class PetOut(BaseModel): + name: str + owner: UserBase + + +@app.post("/users/", response_model=UserBase) +async def create_user(user: UserCreate): + return user + + +@app.get("/pets/{pet_id}", response_model=PetOut) +async def read_pet(pet_id: int): + user = UserDB( + email="johndoe@example.com", + hashed_password="secrethashed", + ) + pet = PetDB(name="Nibbler", owner=user) + return pet + + +@app.get("/pets/", response_model=list[PetOut]) +async def read_pets(): + user = UserDB( + email="johndoe@example.com", + hashed_password="secrethashed", + ) + pet1 = PetDB(name="Nibbler", owner=user) + pet2 = PetDB(name="Zoidberg", owner=user) + return [pet1, pet2] + + +client = TestClient(app) + + +def test_filter_top_level_model(): + response = client.post( + "/users", json={"email": "johndoe@example.com", "password": "secret"} + ) + assert response.json() == {"email": "johndoe@example.com"} + + +def test_filter_second_level_model(): + response = client.get("/pets/1") + assert response.json() == { + "name": "Nibbler", + "owner": {"email": "johndoe@example.com"}, + } + + +def test_list_of_models(): + response = client.get("/pets/") + assert response.json() == [ + {"name": "Nibbler", "owner": {"email": "johndoe@example.com"}}, + {"name": "Zoidberg", "owner": {"email": "johndoe@example.com"}}, + ] diff --git a/tests/test_response_model_data_filter_no_inheritance.py b/tests/test_response_model_data_filter_no_inheritance.py new file mode 100644 index 000000000..c0c2f3a9d --- /dev/null +++ b/tests/test_response_model_data_filter_no_inheritance.py @@ -0,0 +1,81 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +app = FastAPI() + + +class UserCreate(BaseModel): + email: str + password: str + + +class UserDB(BaseModel): + email: str + hashed_password: str + + +class User(BaseModel): + email: str + + +class PetDB(BaseModel): + name: str + owner: UserDB + + +class PetOut(BaseModel): + name: str + owner: User + + +@app.post("/users/", response_model=User) +async def create_user(user: UserCreate): + return user + + +@app.get("/pets/{pet_id}", response_model=PetOut) +async def read_pet(pet_id: int): + user = UserDB( + email="johndoe@example.com", + hashed_password="secrethashed", + ) + pet = PetDB(name="Nibbler", owner=user) + return pet + + +@app.get("/pets/", response_model=list[PetOut]) +async def read_pets(): + user = UserDB( + email="johndoe@example.com", + hashed_password="secrethashed", + ) + pet1 = PetDB(name="Nibbler", owner=user) + pet2 = PetDB(name="Zoidberg", owner=user) + return [pet1, pet2] + + +client = TestClient(app) + + +def test_filter_top_level_model(): + response = client.post( + "/users", json={"email": "johndoe@example.com", "password": "secret"} + ) + assert response.json() == {"email": "johndoe@example.com"} + + +def test_filter_second_level_model(): + response = client.get("/pets/1") + assert response.json() == { + "name": "Nibbler", + "owner": {"email": "johndoe@example.com"}, + } + + +def test_list_of_models(): + response = client.get("/pets/") + assert response.json() == [ + {"name": "Nibbler", "owner": {"email": "johndoe@example.com"}}, + {"name": "Zoidberg", "owner": {"email": "johndoe@example.com"}}, + ] diff --git a/tests/test_schema_extra_examples.py b/tests/test_schema_extra_examples.py index 74e15d59a..eb9fd474c 100644 --- a/tests/test_schema_extra_examples.py +++ b/tests/test_schema_extra_examples.py @@ -1,8 +1,10 @@ from typing import Union +from dirty_equals import IsDict from fastapi import Body, Cookie, FastAPI, Header, Path, Query +from fastapi._compat import PYDANTIC_V2 from fastapi.testclient import TestClient -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict app = FastAPI() @@ -10,8 +12,14 @@ app = FastAPI() class Item(BaseModel): data: str - class Config: - schema_extra = {"example": {"data": "Data in schema_extra"}} + if PYDANTIC_V2: + model_config = ConfigDict( + json_schema_extra={"example": {"data": "Data in schema_extra"}} + ) + else: + + class Config: + schema_extra = {"example": {"data": "Data in schema_extra"}} @app.post("/schema_extra/") @@ -537,7 +545,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "example": "query1", "name": "data", "in": "query", @@ -568,7 +585,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "examples": { "example1": { "summary": "Query example 1", @@ -605,7 +631,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "examples": { "example1": { "summary": "Query example 1", @@ -642,7 +677,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "example": "header1", "name": "data", "in": "header", @@ -673,7 +717,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "examples": { "example1": { "summary": "header example 1", @@ -710,7 +763,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "examples": { "example1": { "summary": "Query example 1", @@ -747,7 +809,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "example": "cookie1", "name": "data", "in": "cookie", @@ -778,7 +849,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "examples": { "example1": { "summary": "cookie example 1", @@ -815,7 +895,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Data", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Data", + } + ) + | IsDict( + # TODO: Remove this when deprecating Pydantic v1 + {"title": "Data", "type": "string"} + ), "examples": { "example1": { "summary": "Query example 1", diff --git a/tests/test_security_oauth2.py b/tests/test_security_oauth2.py index 23dce7cfa..04760abd7 100644 --- a/tests/test_security_oauth2.py +++ b/tests/test_security_oauth2.py @@ -1,7 +1,8 @@ -import pytest +from dirty_equals import IsDict from fastapi import Depends, FastAPI, Security from fastapi.security import OAuth2, OAuth2PasswordRequestFormStrict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from pydantic import BaseModel app = FastAPI() @@ -59,76 +60,136 @@ def test_security_oauth2_password_bearer_no_header(): assert response.json() == {"detail": "Not authenticated"} -required_params = { - "detail": [ +def test_strict_login_no_data(): + response = client.post("/login") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "grant_type"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "missing", + "loc": ["body", "grant_type"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -grant_type_required = { - "detail": [ + +def test_strict_login_no_grant_type(): + response = client.post("/login", data={"username": "johndoe", "password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "grant_type"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "grant_type"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": "field required", + "type": "value_error.missing", + } + ] } - ] -} + ) -grant_type_incorrect = { - "detail": [ + +def test_strict_login_incorrect_grant_type(): + response = client.post( + "/login", + data={"username": "johndoe", "password": "secret", "grant_type": "incorrect"}, + ) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "grant_type"], - "msg": 'string does not match regex "password"', - "type": "value_error.str.regex", - "ctx": {"pattern": "password"}, + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["body", "grant_type"], + "msg": "String should match pattern 'password'", + "input": "incorrect", + "ctx": {"pattern": "password"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] } - ] -} - - -@pytest.mark.parametrize( - "data,expected_status,expected_response", - [ - (None, 422, required_params), - ({"username": "johndoe", "password": "secret"}, 422, grant_type_required), - ( - {"username": "johndoe", "password": "secret", "grant_type": "incorrect"}, - 422, - grant_type_incorrect, - ), - ( - {"username": "johndoe", "password": "secret", "grant_type": "password"}, - 200, - { - "grant_type": "password", - "username": "johndoe", - "password": "secret", - "scopes": [], - "client_id": None, - "client_secret": None, - }, - ), - ], -) -def test_strict_login(data, expected_status, expected_response): - response = client.post("/login", data=data) - assert response.status_code == expected_status - assert response.json() == expected_response + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": 'string does not match regex "password"', + "type": "value_error.str.regex", + "ctx": {"pattern": "password"}, + } + ] + } + ) + + +def test_strict_login_correct_grant_type(): + response = client.post( + "/login", + data={"username": "johndoe", "password": "secret", "grant_type": "password"}, + ) + assert response.status_code == 200 + assert response.json() == { + "grant_type": "password", + "username": "johndoe", + "password": "secret", + "scopes": [], + "client_id": None, + "client_secret": None, + } def test_openapi_schema(): @@ -199,8 +260,26 @@ def test_openapi_schema(): "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_security_oauth2_optional.py b/tests/test_security_oauth2_optional.py index 3ef9f4a8d..1f5a7017a 100644 --- a/tests/test_security_oauth2_optional.py +++ b/tests/test_security_oauth2_optional.py @@ -1,9 +1,10 @@ from typing import Optional -import pytest +from dirty_equals import IsDict from fastapi import Depends, FastAPI, Security from fastapi.security import OAuth2, OAuth2PasswordRequestFormStrict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from pydantic import BaseModel app = FastAPI() @@ -63,76 +64,136 @@ def test_security_oauth2_password_bearer_no_header(): assert response.json() == {"msg": "Create an account first"} -required_params = { - "detail": [ +def test_strict_login_no_data(): + response = client.post("/login") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "grant_type"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "missing", + "loc": ["body", "grant_type"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -grant_type_required = { - "detail": [ + +def test_strict_login_no_grant_type(): + response = client.post("/login", data={"username": "johndoe", "password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "grant_type"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "grant_type"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": "field required", + "type": "value_error.missing", + } + ] } - ] -} + ) -grant_type_incorrect = { - "detail": [ + +def test_strict_login_incorrect_grant_type(): + response = client.post( + "/login", + data={"username": "johndoe", "password": "secret", "grant_type": "incorrect"}, + ) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "grant_type"], - "msg": 'string does not match regex "password"', - "type": "value_error.str.regex", - "ctx": {"pattern": "password"}, + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["body", "grant_type"], + "msg": "String should match pattern 'password'", + "input": "incorrect", + "ctx": {"pattern": "password"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] } - ] -} - - -@pytest.mark.parametrize( - "data,expected_status,expected_response", - [ - (None, 422, required_params), - ({"username": "johndoe", "password": "secret"}, 422, grant_type_required), - ( - {"username": "johndoe", "password": "secret", "grant_type": "incorrect"}, - 422, - grant_type_incorrect, - ), - ( - {"username": "johndoe", "password": "secret", "grant_type": "password"}, - 200, - { - "grant_type": "password", - "username": "johndoe", - "password": "secret", - "scopes": [], - "client_id": None, - "client_secret": None, - }, - ), - ], -) -def test_strict_login(data, expected_status, expected_response): - response = client.post("/login", data=data) - assert response.status_code == expected_status - assert response.json() == expected_response + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": 'string does not match regex "password"', + "type": "value_error.str.regex", + "ctx": {"pattern": "password"}, + } + ] + } + ) + + +def test_strict_login_correct_data(): + response = client.post( + "/login", + data={"username": "johndoe", "password": "secret", "grant_type": "password"}, + ) + assert response.status_code == 200 + assert response.json() == { + "grant_type": "password", + "username": "johndoe", + "password": "secret", + "scopes": [], + "client_id": None, + "client_secret": None, + } def test_openapi_schema(): @@ -203,8 +264,26 @@ def test_openapi_schema(): "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_security_oauth2_optional_description.py b/tests/test_security_oauth2_optional_description.py index b6425fde4..c795757de 100644 --- a/tests/test_security_oauth2_optional_description.py +++ b/tests/test_security_oauth2_optional_description.py @@ -1,9 +1,10 @@ from typing import Optional -import pytest +from dirty_equals import IsDict from fastapi import Depends, FastAPI, Security from fastapi.security import OAuth2, OAuth2PasswordRequestFormStrict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from pydantic import BaseModel app = FastAPI() @@ -64,76 +65,136 @@ def test_security_oauth2_password_bearer_no_header(): assert response.json() == {"msg": "Create an account first"} -required_params = { - "detail": [ +def test_strict_login_None(): + response = client.post("/login", data=None) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "grant_type"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "missing", + "loc": ["body", "grant_type"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -grant_type_required = { - "detail": [ + +def test_strict_login_no_grant_type(): + response = client.post("/login", data={"username": "johndoe", "password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "grant_type"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "type": "missing", + "loc": ["body", "grant_type"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] } - ] -} + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + -grant_type_incorrect = { - "detail": [ +def test_strict_login_incorrect_grant_type(): + response = client.post( + "/login", + data={"username": "johndoe", "password": "secret", "grant_type": "incorrect"}, + ) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "grant_type"], - "msg": 'string does not match regex "password"', - "type": "value_error.str.regex", - "ctx": {"pattern": "password"}, + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["body", "grant_type"], + "msg": "String should match pattern 'password'", + "input": "incorrect", + "ctx": {"pattern": "password"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] } - ] -} - - -@pytest.mark.parametrize( - "data,expected_status,expected_response", - [ - (None, 422, required_params), - ({"username": "johndoe", "password": "secret"}, 422, grant_type_required), - ( - {"username": "johndoe", "password": "secret", "grant_type": "incorrect"}, - 422, - grant_type_incorrect, - ), - ( - {"username": "johndoe", "password": "secret", "grant_type": "password"}, - 200, - { - "grant_type": "password", - "username": "johndoe", - "password": "secret", - "scopes": [], - "client_id": None, - "client_secret": None, - }, - ), - ], -) -def test_strict_login(data, expected_status, expected_response): - response = client.post("/login", data=data) - assert response.status_code == expected_status - assert response.json() == expected_response + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "grant_type"], + "msg": 'string does not match regex "password"', + "type": "value_error.str.regex", + "ctx": {"pattern": "password"}, + } + ] + } + ) + + +def test_strict_login_correct_correct_grant_type(): + response = client.post( + "/login", + data={"username": "johndoe", "password": "secret", "grant_type": "password"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "grant_type": "password", + "username": "johndoe", + "password": "secret", + "scopes": [], + "client_id": None, + "client_secret": None, + } def test_openapi_schema(): @@ -204,8 +265,26 @@ def test_openapi_schema(): "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_skip_defaults.py b/tests/test_skip_defaults.py index 181fff612..02765291c 100644 --- a/tests/test_skip_defaults.py +++ b/tests/test_skip_defaults.py @@ -12,7 +12,7 @@ class SubModel(BaseModel): class Model(BaseModel): - x: Optional[int] + x: Optional[int] = None sub: SubModel diff --git a/tests/test_sub_callbacks.py b/tests/test_sub_callbacks.py index c5a0237e8..db7f4d40c 100644 --- a/tests/test_sub_callbacks.py +++ b/tests/test_sub_callbacks.py @@ -1,5 +1,6 @@ from typing import Optional +from dirty_equals import IsDict from fastapi import APIRouter, FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel, HttpUrl @@ -98,13 +99,30 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": { - "title": "Callback Url", - "maxLength": 2083, - "minLength": 1, - "type": "string", - "format": "uri", - }, + "schema": IsDict( + { + "title": "Callback Url", + "anyOf": [ + { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2083, + }, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Callback Url", + "maxLength": 2083, + "minLength": 1, + "type": "string", + "format": "uri", + } + ), "name": "callback_url", "in": "query", } @@ -244,7 +262,16 @@ def test_openapi_schema(): "type": "object", "properties": { "id": {"title": "Id", "type": "string"}, - "title": {"title": "Title", "type": "string"}, + "title": IsDict( + { + "title": "Title", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Title", "type": "string"} + ), "customer": {"title": "Customer", "type": "string"}, "total": {"title": "Total", "type": "number"}, }, diff --git a/tests/test_tuples.py b/tests/test_tuples.py index 4fa1f7a13..69df6e9bb 100644 --- a/tests/test_tuples.py +++ b/tests/test_tuples.py @@ -1,5 +1,6 @@ from typing import List, Tuple +from dirty_equals import IsDict from fastapi import FastAPI, Form from fastapi.testclient import TestClient from pydantic import BaseModel @@ -126,16 +127,31 @@ def test_openapi_schema(): "requestBody": { "content": { "application/json": { - "schema": { - "title": "Square", - "maxItems": 2, - "minItems": 2, - "type": "array", - "items": [ - {"$ref": "#/components/schemas/Coordinate"}, - {"$ref": "#/components/schemas/Coordinate"}, - ], - } + "schema": IsDict( + { + "title": "Square", + "maxItems": 2, + "minItems": 2, + "type": "array", + "prefixItems": [ + {"$ref": "#/components/schemas/Coordinate"}, + {"$ref": "#/components/schemas/Coordinate"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Square", + "maxItems": 2, + "minItems": 2, + "type": "array", + "items": [ + {"$ref": "#/components/schemas/Coordinate"}, + {"$ref": "#/components/schemas/Coordinate"}, + ], + } + ) } }, "required": True, @@ -198,13 +214,28 @@ def test_openapi_schema(): "required": ["values"], "type": "object", "properties": { - "values": { - "title": "Values", - "maxItems": 2, - "minItems": 2, - "type": "array", - "items": [{"type": "integer"}, {"type": "integer"}], - } + "values": IsDict( + { + "title": "Values", + "maxItems": 2, + "minItems": 2, + "type": "array", + "prefixItems": [ + {"type": "integer"}, + {"type": "integer"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Values", + "maxItems": 2, + "minItems": 2, + "type": "array", + "items": [{"type": "integer"}, {"type": "integer"}], + } + ) }, }, "Coordinate": { @@ -235,12 +266,26 @@ def test_openapi_schema(): "items": { "title": "Items", "type": "array", - "items": { - "maxItems": 2, - "minItems": 2, - "type": "array", - "items": [{"type": "string"}, {"type": "string"}], - }, + "items": IsDict( + { + "maxItems": 2, + "minItems": 2, + "type": "array", + "prefixItems": [ + {"type": "string"}, + {"type": "string"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "maxItems": 2, + "minItems": 2, + "type": "array", + "items": [{"type": "string"}, {"type": "string"}], + } + ), } }, }, diff --git a/tests/test_tutorial/test_additional_responses/test_tutorial002.py b/tests/test_tutorial/test_additional_responses/test_tutorial002.py index 6182ed507..2073df97f 100644 --- a/tests/test_tutorial/test_additional_responses/test_tutorial002.py +++ b/tests/test_tutorial/test_additional_responses/test_tutorial002.py @@ -1,6 +1,7 @@ import os import shutil +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.additional_responses.tutorial002 import app @@ -64,7 +65,16 @@ def test_openapi_schema(): }, { "required": False, - "schema": {"title": "Img", "type": "boolean"}, + "schema": IsDict( + { + "anyOf": [{"type": "boolean"}, {"type": "null"}], + "title": "Img", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Img", "type": "boolean"} + ), "name": "img", "in": "query", }, diff --git a/tests/test_tutorial/test_additional_responses/test_tutorial004.py b/tests/test_tutorial/test_additional_responses/test_tutorial004.py index 3fbd91e5c..70273cd3a 100644 --- a/tests/test_tutorial/test_additional_responses/test_tutorial004.py +++ b/tests/test_tutorial/test_additional_responses/test_tutorial004.py @@ -1,6 +1,7 @@ import os import shutil +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.additional_responses.tutorial004 import app @@ -67,7 +68,16 @@ def test_openapi_schema(): }, { "required": False, - "schema": {"title": "Img", "type": "boolean"}, + "schema": IsDict( + { + "anyOf": [{"type": "boolean"}, {"type": "null"}], + "title": "Img", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Img", "type": "boolean"} + ), "name": "img", "in": "query", }, diff --git a/tests/test_tutorial/test_async_sql_databases/test_tutorial001.py b/tests/test_tutorial/test_async_sql_databases/test_tutorial001.py index 3da362a50..86f55b083 100644 --- a/tests/test_tutorial/test_async_sql_databases/test_tutorial001.py +++ b/tests/test_tutorial/test_async_sql_databases/test_tutorial001.py @@ -2,7 +2,11 @@ from fastapi.testclient import TestClient from docs_src.async_sql_databases.tutorial001 import app +from ...utils import needs_pydanticv1 + +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_read(): with TestClient(app) as client: note = {"text": "Foo bar", "completed": False} diff --git a/tests/test_tutorial/test_behind_a_proxy/test_tutorial003.py b/tests/test_tutorial/test_behind_a_proxy/test_tutorial003.py index ae8f1a495..5048d8849 100644 --- a/tests/test_tutorial/test_behind_a_proxy/test_tutorial003.py +++ b/tests/test_tutorial/test_behind_a_proxy/test_tutorial003.py @@ -1,3 +1,4 @@ +from dirty_equals import IsOneOf from fastapi.testclient import TestClient from docs_src.behind_a_proxy.tutorial003 import app @@ -11,7 +12,7 @@ def test_main(): assert response.json() == {"message": "Hello World", "root_path": "/api/v1"} -def test_openapi(): +def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { @@ -19,9 +20,20 @@ def test_openapi(): "info": {"title": "FastAPI", "version": "0.1.0"}, "servers": [ {"url": "/api/v1"}, - {"url": "https://stag.example.com", "description": "Staging environment"}, { - "url": "https://prod.example.com", + "url": IsOneOf( + "https://stag.example.com/", + # TODO: remove when deprecating Pydantic v1 + "https://stag.example.com", + ), + "description": "Staging environment", + }, + { + "url": IsOneOf( + "https://prod.example.com/", + # TODO: remove when deprecating Pydantic v1 + "https://prod.example.com", + ), "description": "Production environment", }, ], diff --git a/tests/test_tutorial/test_behind_a_proxy/test_tutorial004.py b/tests/test_tutorial/test_behind_a_proxy/test_tutorial004.py index e67ad1cb1..b46ae1047 100644 --- a/tests/test_tutorial/test_behind_a_proxy/test_tutorial004.py +++ b/tests/test_tutorial/test_behind_a_proxy/test_tutorial004.py @@ -1,3 +1,4 @@ +from dirty_equals import IsOneOf from fastapi.testclient import TestClient from docs_src.behind_a_proxy.tutorial004 import app @@ -11,16 +12,27 @@ def test_main(): assert response.json() == {"message": "Hello World", "root_path": "/api/v1"} -def test_openapi(): +def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, "servers": [ - {"url": "https://stag.example.com", "description": "Staging environment"}, { - "url": "https://prod.example.com", + "url": IsOneOf( + "https://stag.example.com/", + # TODO: remove when deprecating Pydantic v1 + "https://stag.example.com", + ), + "description": "Staging environment", + }, + { + "url": IsOneOf( + "https://prod.example.com/", + # TODO: remove when deprecating Pydantic v1 + "https://prod.example.com", + ), "description": "Production environment", }, ], diff --git a/tests/test_tutorial/test_bigger_applications/test_main.py b/tests/test_tutorial/test_bigger_applications/test_main.py index a13decd75..e341717d5 100644 --- a/tests/test_tutorial/test_bigger_applications/test_main.py +++ b/tests/test_tutorial/test_bigger_applications/test_main.py @@ -1,138 +1,368 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.bigger_applications.app.main import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.bigger_applications.app.main import app + client = TestClient(app) + return client -no_jessica = { - "detail": [ + +def test_users_token_jessica(client: TestClient): + response = client.get("/users?token=jessica") + assert response.status_code == 200 + assert response.json() == [{"username": "Rick"}, {"username": "Morty"}] + + +def test_users_with_no_token(client: TestClient): + response = client.get("/users") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["query", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - - -@pytest.mark.parametrize( - "path,expected_status,expected_response,headers", - [ - ( - "/users?token=jessica", - 200, - [{"username": "Rick"}, {"username": "Morty"}], - {}, - ), - ("/users", 422, no_jessica, {}), - ("/users/foo?token=jessica", 200, {"username": "foo"}, {}), - ("/users/foo", 422, no_jessica, {}), - ("/users/me?token=jessica", 200, {"username": "fakecurrentuser"}, {}), - ("/users/me", 422, no_jessica, {}), - ( - "/users?token=monica", - 400, - {"detail": "No Jessica token provided"}, - {}, - ), - ( - "/items?token=jessica", - 200, - {"plumbus": {"name": "Plumbus"}, "gun": {"name": "Portal Gun"}}, - {"X-Token": "fake-super-secret-token"}, - ), - ("/items", 422, no_jessica, {"X-Token": "fake-super-secret-token"}), - ( - "/items/plumbus?token=jessica", - 200, - {"name": "Plumbus", "item_id": "plumbus"}, - {"X-Token": "fake-super-secret-token"}, - ), - ( - "/items/bar?token=jessica", - 404, - {"detail": "Item not found"}, - {"X-Token": "fake-super-secret-token"}, - ), - ("/items/plumbus", 422, no_jessica, {"X-Token": "fake-super-secret-token"}), - ( - "/items?token=jessica", - 400, - {"detail": "X-Token header invalid"}, - {"X-Token": "invalid"}, - ), - ( - "/items/bar?token=jessica", - 400, - {"detail": "X-Token header invalid"}, - {"X-Token": "invalid"}, - ), - ( - "/items?token=jessica", - 422, - { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - {}, - ), - ( - "/items/plumbus?token=jessica", - 422, - { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - {}, - ), - ("/?token=jessica", 200, {"message": "Hello Bigger Applications!"}, {}), - ("/", 422, no_jessica, {}), - ], -) -def test_get_path(path, expected_status, expected_response, headers): - response = client.get(path, headers=headers) - assert response.status_code == expected_status - assert response.json() == expected_response - - -def test_put_no_header(): - response = client.put("/items/foo") - assert response.status_code == 422, response.text + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_users_foo_token_jessica(client: TestClient): + response = client.get("/users/foo?token=jessica") + assert response.status_code == 200 + assert response.json() == {"username": "foo"} + + +def test_users_foo_with_no_token(client: TestClient): + response = client.get("/users/foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_users_me_token_jessica(client: TestClient): + response = client.get("/users/me?token=jessica") + assert response.status_code == 200 + assert response.json() == {"username": "fakecurrentuser"} + + +def test_users_me_with_no_token(client: TestClient): + response = client.get("/users/me") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_users_token_monica_with_no_jessica(client: TestClient): + response = client.get("/users?token=monica") + assert response.status_code == 400 + assert response.json() == {"detail": "No Jessica token provided"} + + +def test_items_token_jessica(client: TestClient): + response = client.get( + "/items?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 200 assert response.json() == { - "detail": [ - { - "loc": ["query", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] + "plumbus": {"name": "Plumbus"}, + "gun": {"name": "Portal Gun"}, } -def test_put_invalid_header(): +def test_items_with_no_token_jessica(client: TestClient): + response = client.get("/items", headers={"X-Token": "fake-super-secret-token"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_items_plumbus_token_jessica(client: TestClient): + response = client.get( + "/items/plumbus?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 200 + assert response.json() == {"name": "Plumbus", "item_id": "plumbus"} + + +def test_items_bar_token_jessica(client: TestClient): + response = client.get( + "/items/bar?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Item not found"} + + +def test_items_plumbus_with_no_token(client: TestClient): + response = client.get( + "/items/plumbus", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_items_with_invalid_token(client: TestClient): + response = client.get("/items?token=jessica", headers={"X-Token": "invalid"}) + assert response.status_code == 400 + assert response.json() == {"detail": "X-Token header invalid"} + + +def test_items_bar_with_invalid_token(client: TestClient): + response = client.get("/items/bar?token=jessica", headers={"X-Token": "invalid"}) + assert response.status_code == 400 + assert response.json() == {"detail": "X-Token header invalid"} + + +def test_items_with_missing_x_token_header(client: TestClient): + response = client.get("/items?token=jessica") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_items_plumbus_with_missing_x_token_header(client: TestClient): + response = client.get("/items/plumbus?token=jessica") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_root_token_jessica(client: TestClient): + response = client.get("/?token=jessica") + assert response.status_code == 200 + assert response.json() == {"message": "Hello Bigger Applications!"} + + +def test_root_with_no_token(client: TestClient): + response = client.get("/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_put_no_header(client: TestClient): + response = client.put("/items/foo") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_put_invalid_header(client: TestClient): response = client.put("/items/foo", headers={"X-Token": "invalid"}) assert response.status_code == 400, response.text assert response.json() == {"detail": "X-Token header invalid"} -def test_put(): +def test_put(client: TestClient): response = client.put( "/items/plumbus?token=jessica", headers={"X-Token": "fake-super-secret-token"} ) @@ -140,7 +370,7 @@ def test_put(): assert response.json() == {"item_id": "plumbus", "name": "The great Plumbus"} -def test_put_forbidden(): +def test_put_forbidden(client: TestClient): response = client.put( "/items/bar?token=jessica", headers={"X-Token": "fake-super-secret-token"} ) @@ -148,7 +378,7 @@ def test_put_forbidden(): assert response.json() == {"detail": "You can only update the item: plumbus"} -def test_admin(): +def test_admin(client: TestClient): response = client.post( "/admin/?token=jessica", headers={"X-Token": "fake-super-secret-token"} ) @@ -156,13 +386,13 @@ def test_admin(): assert response.json() == {"message": "Admin getting schwifty"} -def test_admin_invalid_header(): +def test_admin_invalid_header(client: TestClient): response = client.post("/admin/", headers={"X-Token": "invalid"}) assert response.status_code == 400, response.text assert response.json() == {"detail": "X-Token header invalid"} -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_bigger_applications/test_main_an.py b/tests/test_tutorial/test_bigger_applications/test_main_an.py index 64e19c3f3..c884f0508 100644 --- a/tests/test_tutorial/test_bigger_applications/test_main_an.py +++ b/tests/test_tutorial/test_bigger_applications/test_main_an.py @@ -1,138 +1,368 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.bigger_applications.app_an.main import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.bigger_applications.app_an.main import app + client = TestClient(app) + return client -no_jessica = { - "detail": [ + +def test_users_token_jessica(client: TestClient): + response = client.get("/users?token=jessica") + assert response.status_code == 200 + assert response.json() == [{"username": "Rick"}, {"username": "Morty"}] + + +def test_users_with_no_token(client: TestClient): + response = client.get("/users") + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["query", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - - -@pytest.mark.parametrize( - "path,expected_status,expected_response,headers", - [ - ( - "/users?token=jessica", - 200, - [{"username": "Rick"}, {"username": "Morty"}], - {}, - ), - ("/users", 422, no_jessica, {}), - ("/users/foo?token=jessica", 200, {"username": "foo"}, {}), - ("/users/foo", 422, no_jessica, {}), - ("/users/me?token=jessica", 200, {"username": "fakecurrentuser"}, {}), - ("/users/me", 422, no_jessica, {}), - ( - "/users?token=monica", - 400, - {"detail": "No Jessica token provided"}, - {}, - ), - ( - "/items?token=jessica", - 200, - {"plumbus": {"name": "Plumbus"}, "gun": {"name": "Portal Gun"}}, - {"X-Token": "fake-super-secret-token"}, - ), - ("/items", 422, no_jessica, {"X-Token": "fake-super-secret-token"}), - ( - "/items/plumbus?token=jessica", - 200, - {"name": "Plumbus", "item_id": "plumbus"}, - {"X-Token": "fake-super-secret-token"}, - ), - ( - "/items/bar?token=jessica", - 404, - {"detail": "Item not found"}, - {"X-Token": "fake-super-secret-token"}, - ), - ("/items/plumbus", 422, no_jessica, {"X-Token": "fake-super-secret-token"}), - ( - "/items?token=jessica", - 400, - {"detail": "X-Token header invalid"}, - {"X-Token": "invalid"}, - ), - ( - "/items/bar?token=jessica", - 400, - {"detail": "X-Token header invalid"}, - {"X-Token": "invalid"}, - ), - ( - "/items?token=jessica", - 422, - { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - {}, - ), - ( - "/items/plumbus?token=jessica", - 422, - { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - {}, - ), - ("/?token=jessica", 200, {"message": "Hello Bigger Applications!"}, {}), - ("/", 422, no_jessica, {}), - ], -) -def test_get_path(path, expected_status, expected_response, headers): - response = client.get(path, headers=headers) - assert response.status_code == expected_status - assert response.json() == expected_response - - -def test_put_no_header(): - response = client.put("/items/foo") - assert response.status_code == 422, response.text + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_users_foo_token_jessica(client: TestClient): + response = client.get("/users/foo?token=jessica") + assert response.status_code == 200 + assert response.json() == {"username": "foo"} + + +def test_users_foo_with_no_token(client: TestClient): + response = client.get("/users/foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_users_me_token_jessica(client: TestClient): + response = client.get("/users/me?token=jessica") + assert response.status_code == 200 + assert response.json() == {"username": "fakecurrentuser"} + + +def test_users_me_with_no_token(client: TestClient): + response = client.get("/users/me") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_users_token_monica_with_no_jessica(client: TestClient): + response = client.get("/users?token=monica") + assert response.status_code == 400 + assert response.json() == {"detail": "No Jessica token provided"} + + +def test_items_token_jessica(client: TestClient): + response = client.get( + "/items?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 200 assert response.json() == { - "detail": [ - { - "loc": ["query", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] + "plumbus": {"name": "Plumbus"}, + "gun": {"name": "Portal Gun"}, } -def test_put_invalid_header(): +def test_items_with_no_token_jessica(client: TestClient): + response = client.get("/items", headers={"X-Token": "fake-super-secret-token"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_items_plumbus_token_jessica(client: TestClient): + response = client.get( + "/items/plumbus?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 200 + assert response.json() == {"name": "Plumbus", "item_id": "plumbus"} + + +def test_items_bar_token_jessica(client: TestClient): + response = client.get( + "/items/bar?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Item not found"} + + +def test_items_plumbus_with_no_token(client: TestClient): + response = client.get( + "/items/plumbus", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_items_with_invalid_token(client: TestClient): + response = client.get("/items?token=jessica", headers={"X-Token": "invalid"}) + assert response.status_code == 400 + assert response.json() == {"detail": "X-Token header invalid"} + + +def test_items_bar_with_invalid_token(client: TestClient): + response = client.get("/items/bar?token=jessica", headers={"X-Token": "invalid"}) + assert response.status_code == 400 + assert response.json() == {"detail": "X-Token header invalid"} + + +def test_items_with_missing_x_token_header(client: TestClient): + response = client.get("/items?token=jessica") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_items_plumbus_with_missing_x_token_header(client: TestClient): + response = client.get("/items/plumbus?token=jessica") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_root_token_jessica(client: TestClient): + response = client.get("/?token=jessica") + assert response.status_code == 200 + assert response.json() == {"message": "Hello Bigger Applications!"} + + +def test_root_with_no_token(client: TestClient): + response = client.get("/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_put_no_header(client: TestClient): + response = client.put("/items/foo") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_put_invalid_header(client: TestClient): response = client.put("/items/foo", headers={"X-Token": "invalid"}) assert response.status_code == 400, response.text assert response.json() == {"detail": "X-Token header invalid"} -def test_put(): +def test_put(client: TestClient): response = client.put( "/items/plumbus?token=jessica", headers={"X-Token": "fake-super-secret-token"} ) @@ -140,7 +370,7 @@ def test_put(): assert response.json() == {"item_id": "plumbus", "name": "The great Plumbus"} -def test_put_forbidden(): +def test_put_forbidden(client: TestClient): response = client.put( "/items/bar?token=jessica", headers={"X-Token": "fake-super-secret-token"} ) @@ -148,7 +378,7 @@ def test_put_forbidden(): assert response.json() == {"detail": "You can only update the item: plumbus"} -def test_admin(): +def test_admin(client: TestClient): response = client.post( "/admin/?token=jessica", headers={"X-Token": "fake-super-secret-token"} ) @@ -156,13 +386,13 @@ def test_admin(): assert response.json() == {"message": "Admin getting schwifty"} -def test_admin_invalid_header(): +def test_admin_invalid_header(client: TestClient): response = client.post("/admin/", headers={"X-Token": "invalid"}) assert response.status_code == 400, response.text assert response.json() == {"detail": "X-Token header invalid"} -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_bigger_applications/test_main_an_py39.py b/tests/test_tutorial/test_bigger_applications/test_main_an_py39.py index 70c86b4d7..134b8957f 100644 --- a/tests/test_tutorial/test_bigger_applications/test_main_an_py39.py +++ b/tests/test_tutorial/test_bigger_applications/test_main_an_py39.py @@ -1,18 +1,10 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 -no_jessica = { - "detail": [ - { - "loc": ["query", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - @pytest.fixture(name="client") def get_client(): @@ -23,116 +15,366 @@ def get_client(): @needs_py39 -@pytest.mark.parametrize( - "path,expected_status,expected_response,headers", - [ - ( - "/users?token=jessica", - 200, - [{"username": "Rick"}, {"username": "Morty"}], - {}, - ), - ("/users", 422, no_jessica, {}), - ("/users/foo?token=jessica", 200, {"username": "foo"}, {}), - ("/users/foo", 422, no_jessica, {}), - ("/users/me?token=jessica", 200, {"username": "fakecurrentuser"}, {}), - ("/users/me", 422, no_jessica, {}), - ( - "/users?token=monica", - 400, - {"detail": "No Jessica token provided"}, - {}, - ), - ( - "/items?token=jessica", - 200, - {"plumbus": {"name": "Plumbus"}, "gun": {"name": "Portal Gun"}}, - {"X-Token": "fake-super-secret-token"}, - ), - ("/items", 422, no_jessica, {"X-Token": "fake-super-secret-token"}), - ( - "/items/plumbus?token=jessica", - 200, - {"name": "Plumbus", "item_id": "plumbus"}, - {"X-Token": "fake-super-secret-token"}, - ), - ( - "/items/bar?token=jessica", - 404, - {"detail": "Item not found"}, - {"X-Token": "fake-super-secret-token"}, - ), - ("/items/plumbus", 422, no_jessica, {"X-Token": "fake-super-secret-token"}), - ( - "/items?token=jessica", - 400, - {"detail": "X-Token header invalid"}, - {"X-Token": "invalid"}, - ), - ( - "/items/bar?token=jessica", - 400, - {"detail": "X-Token header invalid"}, - {"X-Token": "invalid"}, - ), - ( - "/items?token=jessica", - 422, - { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - {}, - ), - ( - "/items/plumbus?token=jessica", - 422, - { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - } - ] - }, - {}, - ), - ("/?token=jessica", 200, {"message": "Hello Bigger Applications!"}, {}), - ("/", 422, no_jessica, {}), - ], -) -def test_get_path( - path, expected_status, expected_response, headers, client: TestClient -): - response = client.get(path, headers=headers) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_users_token_jessica(client: TestClient): + response = client.get("/users?token=jessica") + assert response.status_code == 200 + assert response.json() == [{"username": "Rick"}, {"username": "Morty"}] + + +@needs_py39 +def test_users_with_no_token(client: TestClient): + response = client.get("/users") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_users_foo_token_jessica(client: TestClient): + response = client.get("/users/foo?token=jessica") + assert response.status_code == 200 + assert response.json() == {"username": "foo"} + + +@needs_py39 +def test_users_foo_with_no_token(client: TestClient): + response = client.get("/users/foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_users_me_token_jessica(client: TestClient): + response = client.get("/users/me?token=jessica") + assert response.status_code == 200 + assert response.json() == {"username": "fakecurrentuser"} + + +@needs_py39 +def test_users_me_with_no_token(client: TestClient): + response = client.get("/users/me") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_users_token_monica_with_no_jessica(client: TestClient): + response = client.get("/users?token=monica") + assert response.status_code == 400 + assert response.json() == {"detail": "No Jessica token provided"} + + +@needs_py39 +def test_items_token_jessica(client: TestClient): + response = client.get( + "/items?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 200 + assert response.json() == { + "plumbus": {"name": "Plumbus"}, + "gun": {"name": "Portal Gun"}, + } + + +@needs_py39 +def test_items_with_no_token_jessica(client: TestClient): + response = client.get("/items", headers={"X-Token": "fake-super-secret-token"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_items_plumbus_token_jessica(client: TestClient): + response = client.get( + "/items/plumbus?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 200 + assert response.json() == {"name": "Plumbus", "item_id": "plumbus"} + + +@needs_py39 +def test_items_bar_token_jessica(client: TestClient): + response = client.get( + "/items/bar?token=jessica", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 404 + assert response.json() == {"detail": "Item not found"} + + +@needs_py39 +def test_items_plumbus_with_no_token(client: TestClient): + response = client.get( + "/items/plumbus", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_items_with_invalid_token(client: TestClient): + response = client.get("/items?token=jessica", headers={"X-Token": "invalid"}) + assert response.status_code == 400 + assert response.json() == {"detail": "X-Token header invalid"} + + +@needs_py39 +def test_items_bar_with_invalid_token(client: TestClient): + response = client.get("/items/bar?token=jessica", headers={"X-Token": "invalid"}) + assert response.status_code == 400 + assert response.json() == {"detail": "X-Token header invalid"} + + +@needs_py39 +def test_items_with_missing_x_token_header(client: TestClient): + response = client.get("/items?token=jessica") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@needs_py39 +def test_items_plumbus_with_missing_x_token_header(client: TestClient): + response = client.get("/items/plumbus?token=jessica") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@needs_py39 +def test_root_token_jessica(client: TestClient): + response = client.get("/?token=jessica") + assert response.status_code == 200 + assert response.json() == {"message": "Hello Bigger Applications!"} + + +@needs_py39 +def test_root_with_no_token(client: TestClient): + response = client.get("/") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 def test_put_no_header(client: TestClient): response = client.put("/items/foo") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["query", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_body/test_tutorial001.py b/tests/test_tutorial/test_body/test_tutorial001.py index cd1209ade..3a07f787d 100644 --- a/tests/test_tutorial/test_body/test_tutorial001.py +++ b/tests/test_tutorial/test_body/test_tutorial001.py @@ -1,134 +1,268 @@ from unittest.mock import patch import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body.tutorial001 import app -client = TestClient(app) +@pytest.fixture +def client(): + from docs_src.body.tutorial001 import app + client = TestClient(app) + return client -price_missing = { - "detail": [ + +def test_body_float(client: TestClient): + response = client.post("/items/", json={"name": "Foo", "price": 50.5}) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + } + + +def test_post_with_str_float(client: TestClient): + response = client.post("/items/", json={"name": "Foo", "price": "50.5"}) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + } + + +def test_post_with_str_float_description(client: TestClient): + response = client.post( + "/items/", json={"name": "Foo", "price": "50.5", "description": "Some Foo"} + ) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": "Some Foo", + "tax": None, + } + + +def test_post_with_str_float_description_tax(client: TestClient): + response = client.post( + "/items/", + json={"name": "Foo", "price": "50.5", "description": "Some Foo", "tax": 0.3}, + ) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": "Some Foo", + "tax": 0.3, + } + + +def test_post_with_only_name(client: TestClient): + response = client.post("/items/", json={"name": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "price"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "type": "missing", + "loc": ["body", "price"], + "msg": "Field required", + "input": {"name": "Foo"}, + "url": match_pydantic_error_url("missing"), + } + ] } - ] -} + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "price"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + -price_not_float = { - "detail": [ +def test_post_with_only_name_price(client: TestClient): + response = client.post("/items/", json={"name": "Foo", "price": "twenty"}) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "price"], - "msg": "value is not a valid float", - "type": "type_error.float", + "detail": [ + { + "type": "float_parsing", + "loc": ["body", "price"], + "msg": "Input should be a valid number, unable to parse string as an number", + "input": "twenty", + "url": match_pydantic_error_url("float_parsing"), + } + ] } - ] -} + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "price"], + "msg": "value is not a valid float", + "type": "type_error.float", + } + ] + } + ) -name_price_missing = { - "detail": [ + +def test_post_with_no_data(client: TestClient): + response = client.post("/items/", json={}) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "name"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "missing", + "loc": ["body", "name"], + "msg": "Field required", + "input": {}, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "price"], + "msg": "Field required", + "input": {}, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "price"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - -body_missing = { - "detail": [ - {"loc": ["body"], "msg": "field required", "type": "value_error.missing"} - ] -} - - -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/", - {"name": "Foo", "price": 50.5}, - 200, - {"name": "Foo", "price": 50.5, "description": None, "tax": None}, - ), - ( - "/items/", - {"name": "Foo", "price": "50.5"}, - 200, - {"name": "Foo", "price": 50.5, "description": None, "tax": None}, - ), - ( - "/items/", - {"name": "Foo", "price": "50.5", "description": "Some Foo"}, - 200, - {"name": "Foo", "price": 50.5, "description": "Some Foo", "tax": None}, - ), - ( - "/items/", - {"name": "Foo", "price": "50.5", "description": "Some Foo", "tax": 0.3}, - 200, - {"name": "Foo", "price": 50.5, "description": "Some Foo", "tax": 0.3}, - ), - ("/items/", {"name": "Foo"}, 422, price_missing), - ("/items/", {"name": "Foo", "price": "twenty"}, 422, price_not_float), - ("/items/", {}, 422, name_price_missing), - ("/items/", None, 422, body_missing), - ], -) -def test_post_body(path, body, expected_status, expected_response): - response = client.post(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response - - -def test_post_broken_body(): + "detail": [ + { + "loc": ["body", "name"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "price"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +def test_post_with_none(client: TestClient): + response = client.post("/items/", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +def test_post_broken_body(client: TestClient): response = client.post( "/items/", headers={"content-type": "application/json"}, content="{some broken json}", ) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body", 1], - "msg": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", - "type": "value_error.jsondecode", - "ctx": { - "msg": "Expecting property name enclosed in double quotes", - "doc": "{some broken json}", - "pos": 1, - "lineno": 1, - "colno": 2, - }, - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "json_invalid", + "loc": ["body", 1], + "msg": "JSON decode error", + "input": {}, + "ctx": { + "error": "Expecting property name enclosed in double quotes" + }, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", 1], + "msg": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", + "type": "value_error.jsondecode", + "ctx": { + "msg": "Expecting property name enclosed in double quotes", + "doc": "{some broken json}", + "pos": 1, + "lineno": 1, + "colno": 2, + }, + } + ] + } + ) -def test_post_form_for_json(): +def test_post_form_for_json(client: TestClient): response = client.post("/items/", data={"name": "Foo", "price": 50.5}) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body"], - "msg": "value is not a valid dict", - "type": "type_error.dict", - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": "name=Foo&price=50.5", + "url": match_pydantic_error_url("dict_attributes_type"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) -def test_explicit_content_type(): +def test_explicit_content_type(client: TestClient): response = client.post( "/items/", content='{"name": "Foo", "price": 50.5}', @@ -137,7 +271,7 @@ def test_explicit_content_type(): assert response.status_code == 200, response.text -def test_geo_json(): +def test_geo_json(client: TestClient): response = client.post( "/items/", content='{"name": "Foo", "price": 50.5}', @@ -146,7 +280,7 @@ def test_geo_json(): assert response.status_code == 200, response.text -def test_no_content_type_is_json(): +def test_no_content_type_is_json(client: TestClient): response = client.post( "/items/", content='{"name": "Foo", "price": 50.5}', @@ -160,43 +294,104 @@ def test_no_content_type_is_json(): } -def test_wrong_headers(): +def test_wrong_headers(client: TestClient): data = '{"name": "Foo", "price": 50.5}' - invalid_dict = { - "detail": [ - { - "loc": ["body"], - "msg": "value is not a valid dict", - "type": "type_error.dict", - } - ] - } - response = client.post( "/items/", content=data, headers={"Content-Type": "text/plain"} ) assert response.status_code == 422, response.text - assert response.json() == invalid_dict + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": '{"name": "Foo", "price": 50.5}', + "url": match_pydantic_error_url( + "dict_attributes_type" + ), # "https://errors.pydantic.dev/0.38.0/v/dict_attributes_type", + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) response = client.post( "/items/", content=data, headers={"Content-Type": "application/geo+json-seq"} ) assert response.status_code == 422, response.text - assert response.json() == invalid_dict + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": '{"name": "Foo", "price": 50.5}', + "url": match_pydantic_error_url("dict_attributes_type"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) response = client.post( "/items/", content=data, headers={"Content-Type": "application/not-really-json"} ) assert response.status_code == 422, response.text - assert response.json() == invalid_dict + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": '{"name": "Foo", "price": 50.5}', + "url": match_pydantic_error_url("dict_attributes_type"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) -def test_other_exceptions(): +def test_other_exceptions(client: TestClient): with patch("json.loads", side_effect=Exception): response = client.post("/items/", json={"test": "test2"}) assert response.status_code == 400, response.text -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -243,8 +438,26 @@ def test_openapi_schema(): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_body/test_tutorial001_py310.py b/tests/test_tutorial/test_body/test_tutorial001_py310.py index 5ebcbbf57..dd0207350 100644 --- a/tests/test_tutorial/test_body/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_body/test_tutorial001_py310.py @@ -1,7 +1,9 @@ from unittest.mock import patch import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -14,86 +16,189 @@ def client(): return client -price_missing = { - "detail": [ +@needs_py310 +def test_body_float(client: TestClient): + response = client.post("/items/", json={"name": "Foo", "price": 50.5}) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + } + + +@needs_py310 +def test_post_with_str_float(client: TestClient): + response = client.post("/items/", json={"name": "Foo", "price": "50.5"}) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + } + + +@needs_py310 +def test_post_with_str_float_description(client: TestClient): + response = client.post( + "/items/", json={"name": "Foo", "price": "50.5", "description": "Some Foo"} + ) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": "Some Foo", + "tax": None, + } + + +@needs_py310 +def test_post_with_str_float_description_tax(client: TestClient): + response = client.post( + "/items/", + json={"name": "Foo", "price": "50.5", "description": "Some Foo", "tax": 0.3}, + ) + assert response.status_code == 200 + assert response.json() == { + "name": "Foo", + "price": 50.5, + "description": "Some Foo", + "tax": 0.3, + } + + +@needs_py310 +def test_post_with_only_name(client: TestClient): + response = client.post("/items/", json={"name": "Foo"}) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "price"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "type": "missing", + "loc": ["body", "price"], + "msg": "Field required", + "input": {"name": "Foo"}, + "url": match_pydantic_error_url("missing"), + } + ] } - ] -} - -price_not_float = { - "detail": [ + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "price"], - "msg": "value is not a valid float", - "type": "type_error.float", + "detail": [ + { + "loc": ["body", "price"], + "msg": "field required", + "type": "value_error.missing", + } + ] } - ] -} + ) + -name_price_missing = { - "detail": [ +@needs_py310 +def test_post_with_only_name_price(client: TestClient): + response = client.post("/items/", json={"name": "Foo", "price": "twenty"}) + assert response.status_code == 422 + assert response.json() == IsDict( { - "loc": ["body", "name"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "float_parsing", + "loc": ["body", "price"], + "msg": "Input should be a valid number, unable to parse string as an number", + "input": "twenty", + "url": match_pydantic_error_url("float_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "price"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} + "detail": [ + { + "loc": ["body", "price"], + "msg": "value is not a valid float", + "type": "type_error.float", + } + ] + } + ) -body_missing = { - "detail": [ - {"loc": ["body"], "msg": "field required", "type": "value_error.missing"} - ] -} + +@needs_py310 +def test_post_with_no_data(client: TestClient): + response = client.post("/items/", json={}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "name"], + "msg": "Field required", + "input": {}, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "price"], + "msg": "Field required", + "input": {}, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "name"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "price"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py310 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/", - {"name": "Foo", "price": 50.5}, - 200, - {"name": "Foo", "price": 50.5, "description": None, "tax": None}, - ), - ( - "/items/", - {"name": "Foo", "price": "50.5"}, - 200, - {"name": "Foo", "price": 50.5, "description": None, "tax": None}, - ), - ( - "/items/", - {"name": "Foo", "price": "50.5", "description": "Some Foo"}, - 200, - {"name": "Foo", "price": 50.5, "description": "Some Foo", "tax": None}, - ), - ( - "/items/", - {"name": "Foo", "price": "50.5", "description": "Some Foo", "tax": 0.3}, - 200, - {"name": "Foo", "price": 50.5, "description": "Some Foo", "tax": 0.3}, - ), - ("/items/", {"name": "Foo"}, 422, price_missing), - ("/items/", {"name": "Foo", "price": "twenty"}, 422, price_not_float), - ("/items/", {}, 422, name_price_missing), - ("/items/", None, 422, body_missing), - ], -) -def test_post_body(path, body, expected_status, expected_response, client: TestClient): - response = client.post(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_with_none(client: TestClient): + response = client.post("/items/", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py310 @@ -104,37 +209,69 @@ def test_post_broken_body(client: TestClient): content="{some broken json}", ) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body", 1], - "msg": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", - "type": "value_error.jsondecode", - "ctx": { - "msg": "Expecting property name enclosed in double quotes", - "doc": "{some broken json}", - "pos": 1, - "lineno": 1, - "colno": 2, - }, - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "json_invalid", + "loc": ["body", 1], + "msg": "JSON decode error", + "input": {}, + "ctx": { + "error": "Expecting property name enclosed in double quotes" + }, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", 1], + "msg": "Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", + "type": "value_error.jsondecode", + "ctx": { + "msg": "Expecting property name enclosed in double quotes", + "doc": "{some broken json}", + "pos": 1, + "lineno": 1, + "colno": 2, + }, + } + ] + } + ) @needs_py310 def test_post_form_for_json(client: TestClient): response = client.post("/items/", data={"name": "Foo", "price": 50.5}) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body"], - "msg": "value is not a valid dict", - "type": "type_error.dict", - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": "name=Foo&price=50.5", + "url": match_pydantic_error_url("dict_attributes_type"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) @needs_py310 @@ -175,32 +312,91 @@ def test_no_content_type_is_json(client: TestClient): @needs_py310 def test_wrong_headers(client: TestClient): data = '{"name": "Foo", "price": 50.5}' - invalid_dict = { - "detail": [ - { - "loc": ["body"], - "msg": "value is not a valid dict", - "type": "type_error.dict", - } - ] - } - response = client.post( "/items/", content=data, headers={"Content-Type": "text/plain"} ) assert response.status_code == 422, response.text - assert response.json() == invalid_dict + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": '{"name": "Foo", "price": 50.5}', + "url": match_pydantic_error_url("dict_attributes_type"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) response = client.post( "/items/", content=data, headers={"Content-Type": "application/geo+json-seq"} ) assert response.status_code == 422, response.text - assert response.json() == invalid_dict + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": '{"name": "Foo", "price": 50.5}', + "url": match_pydantic_error_url("dict_attributes_type"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) response = client.post( "/items/", content=data, headers={"Content-Type": "application/not-really-json"} ) assert response.status_code == 422, response.text - assert response.json() == invalid_dict + assert response.json() == IsDict( + { + "detail": [ + { + "type": "dict_attributes_type", + "loc": ["body"], + "msg": "Input should be a valid dictionary or instance to extract fields from", + "input": '{"name": "Foo", "price": 50.5}', + "url": match_pydantic_error_url("dict_attributes_type"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body"], + "msg": "value is not a valid dict", + "type": "type_error.dict", + } + ] + } + ) @needs_py310 @@ -258,8 +454,26 @@ def test_openapi_schema(client: TestClient): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_body_fields/test_tutorial001.py b/tests/test_tutorial/test_body_fields/test_tutorial001.py index a7ea0e949..9a234280d 100644 --- a/tests/test_tutorial/test_body_fields/test_tutorial001.py +++ b/tests/test_tutorial/test_body_fields/test_tutorial001.py @@ -1,66 +1,82 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body_fields.tutorial001 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_fields.tutorial001 import app + client = TestClient(app) + return client -price_not_greater = { - "detail": [ - { - "ctx": {"limit_value": 0}, - "loc": ["body", "item", "price"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - } - ] -} + +def test_items_5(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": 3.0}}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, + } + + +def test_items_6(client: TestClient): + response = client.put( + "/items/6", + json={ + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": "5.4", + } + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 6, + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": 5.4, + }, + } -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - {"item": {"name": "Foo", "price": 3.0}}, - 200, - { - "item_id": 5, - "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, - }, - ), - ( - "/items/6", - { - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": "5.4", +def test_invalid_price(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": -3.0}}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["body", "item", "price"], + "msg": "Input should be greater than 0", + "input": -3.0, + "ctx": {"gt": 0.0}, + "url": match_pydantic_error_url("greater_than"), } - }, - 200, - { - "item_id": 6, - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": 5.4, - }, - }, - ), - ("/items/5", {"item": {"name": "Foo", "price": -3.0}}, 422, price_not_greater), - ], -) -def test(path, body, expected_status, expected_response): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"limit_value": 0}, + "loc": ["body", "item", "price"], + "msg": "ensure this value is greater than 0", + "type": "value_error.number.not_gt", + } + ] + } + ) -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -116,18 +132,39 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": { - "title": "The description of the item", - "maxLength": 300, - "type": "string", - }, + "description": IsDict( + { + "title": "The description of the item", + "anyOf": [ + {"maxLength": 300, "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "The description of the item", + "maxLength": 300, + "type": "string", + } + ), "price": { "title": "Price", "exclusiveMinimum": 0.0, "type": "number", "description": "The price must be greater than zero", }, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_fields/test_tutorial001_an.py b/tests/test_tutorial/test_body_fields/test_tutorial001_an.py index 32f996ecb..6f3e49397 100644 --- a/tests/test_tutorial/test_body_fields/test_tutorial001_an.py +++ b/tests/test_tutorial/test_body_fields/test_tutorial001_an.py @@ -1,66 +1,82 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body_fields.tutorial001_an import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_fields.tutorial001_an import app + client = TestClient(app) + return client -price_not_greater = { - "detail": [ - { - "ctx": {"limit_value": 0}, - "loc": ["body", "item", "price"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - } - ] -} + +def test_items_5(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": 3.0}}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, + } + + +def test_items_6(client: TestClient): + response = client.put( + "/items/6", + json={ + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": "5.4", + } + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 6, + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": 5.4, + }, + } -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - {"item": {"name": "Foo", "price": 3.0}}, - 200, - { - "item_id": 5, - "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, - }, - ), - ( - "/items/6", - { - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": "5.4", +def test_invalid_price(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": -3.0}}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["body", "item", "price"], + "msg": "Input should be greater than 0", + "input": -3.0, + "ctx": {"gt": 0.0}, + "url": match_pydantic_error_url("greater_than"), } - }, - 200, - { - "item_id": 6, - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": 5.4, - }, - }, - ), - ("/items/5", {"item": {"name": "Foo", "price": -3.0}}, 422, price_not_greater), - ], -) -def test(path, body, expected_status, expected_response): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"limit_value": 0}, + "loc": ["body", "item", "price"], + "msg": "ensure this value is greater than 0", + "type": "value_error.number.not_gt", + } + ] + } + ) -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -116,18 +132,39 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": { - "title": "The description of the item", - "maxLength": 300, - "type": "string", - }, + "description": IsDict( + { + "title": "The description of the item", + "anyOf": [ + {"maxLength": 300, "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "The description of the item", + "maxLength": 300, + "type": "string", + } + ), "price": { "title": "Price", "exclusiveMinimum": 0.0, "type": "number", "description": "The price must be greater than zero", }, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_fields/test_tutorial001_an_py310.py b/tests/test_tutorial/test_body_fields/test_tutorial001_an_py310.py index 20e032fcd..41b237421 100644 --- a/tests/test_tutorial/test_body_fields/test_tutorial001_an_py310.py +++ b/tests/test_tutorial/test_body_fields/test_tutorial001_an_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,59 +14,71 @@ def get_client(): return client -price_not_greater = { - "detail": [ - { - "ctx": {"limit_value": 0}, - "loc": ["body", "item", "price"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - } - ] -} +@needs_py310 +def test_items_5(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": 3.0}}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, + } @needs_py310 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - {"item": {"name": "Foo", "price": 3.0}}, - 200, - { - "item_id": 5, - "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, - }, - ), - ( - "/items/6", - { - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": "5.4", +def test_items_6(client: TestClient): + response = client.put( + "/items/6", + json={ + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": "5.4", + } + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 6, + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": 5.4, + }, + } + + +@needs_py310 +def test_invalid_price(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": -3.0}}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["body", "item", "price"], + "msg": "Input should be greater than 0", + "input": -3.0, + "ctx": {"gt": 0.0}, + "url": match_pydantic_error_url("greater_than"), } - }, - 200, - { - "item_id": 6, - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": 5.4, - }, - }, - ), - ("/items/5", {"item": {"name": "Foo", "price": -3.0}}, 422, price_not_greater), - ], -) -def test(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"limit_value": 0}, + "loc": ["body", "item", "price"], + "msg": "ensure this value is greater than 0", + "type": "value_error.number.not_gt", + } + ] + } + ) @needs_py310 @@ -124,18 +138,39 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": { - "title": "The description of the item", - "maxLength": 300, - "type": "string", - }, + "description": IsDict( + { + "title": "The description of the item", + "anyOf": [ + {"maxLength": 300, "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "The description of the item", + "maxLength": 300, + "type": "string", + } + ), "price": { "title": "Price", "exclusiveMinimum": 0.0, "type": "number", "description": "The price must be greater than zero", }, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_fields/test_tutorial001_an_py39.py b/tests/test_tutorial/test_body_fields/test_tutorial001_an_py39.py index e3baf5f2b..7c2d5e4d8 100644 --- a/tests/test_tutorial/test_body_fields/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_body_fields/test_tutorial001_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -12,59 +14,71 @@ def get_client(): return client -price_not_greater = { - "detail": [ - { - "ctx": {"limit_value": 0}, - "loc": ["body", "item", "price"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - } - ] -} +@needs_py39 +def test_items_5(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": 3.0}}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, + } @needs_py39 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - {"item": {"name": "Foo", "price": 3.0}}, - 200, - { - "item_id": 5, - "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, - }, - ), - ( - "/items/6", - { - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": "5.4", +def test_items_6(client: TestClient): + response = client.put( + "/items/6", + json={ + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": "5.4", + } + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 6, + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": 5.4, + }, + } + + +@needs_py39 +def test_invalid_price(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": -3.0}}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["body", "item", "price"], + "msg": "Input should be greater than 0", + "input": -3.0, + "ctx": {"gt": 0.0}, + "url": match_pydantic_error_url("greater_than"), } - }, - 200, - { - "item_id": 6, - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": 5.4, - }, - }, - ), - ("/items/5", {"item": {"name": "Foo", "price": -3.0}}, 422, price_not_greater), - ], -) -def test(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"limit_value": 0}, + "loc": ["body", "item", "price"], + "msg": "ensure this value is greater than 0", + "type": "value_error.number.not_gt", + } + ] + } + ) @needs_py39 @@ -124,18 +138,39 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": { - "title": "The description of the item", - "maxLength": 300, - "type": "string", - }, + "description": IsDict( + { + "title": "The description of the item", + "anyOf": [ + {"maxLength": 300, "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "The description of the item", + "maxLength": 300, + "type": "string", + } + ), "price": { "title": "Price", "exclusiveMinimum": 0.0, "type": "number", "description": "The price must be greater than zero", }, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_fields/test_tutorial001_py310.py b/tests/test_tutorial/test_body_fields/test_tutorial001_py310.py index 4c2f48674..285924f75 100644 --- a/tests/test_tutorial/test_body_fields/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_body_fields/test_tutorial001_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,59 +14,71 @@ def get_client(): return client -price_not_greater = { - "detail": [ - { - "ctx": {"limit_value": 0}, - "loc": ["body", "item", "price"], - "msg": "ensure this value is greater than 0", - "type": "value_error.number.not_gt", - } - ] -} +@needs_py310 +def test_items_5(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": 3.0}}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, + } @needs_py310 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - {"item": {"name": "Foo", "price": 3.0}}, - 200, - { - "item_id": 5, - "item": {"name": "Foo", "price": 3.0, "description": None, "tax": None}, - }, - ), - ( - "/items/6", - { - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": "5.4", +def test_items_6(client: TestClient): + response = client.put( + "/items/6", + json={ + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": "5.4", + } + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 6, + "item": { + "name": "Bar", + "price": 0.2, + "description": "Some bar", + "tax": 5.4, + }, + } + + +@needs_py310 +def test_invalid_price(client: TestClient): + response = client.put("/items/5", json={"item": {"name": "Foo", "price": -3.0}}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "greater_than", + "loc": ["body", "item", "price"], + "msg": "Input should be greater than 0", + "input": -3.0, + "ctx": {"gt": 0.0}, + "url": match_pydantic_error_url("greater_than"), } - }, - 200, - { - "item_id": 6, - "item": { - "name": "Bar", - "price": 0.2, - "description": "Some bar", - "tax": 5.4, - }, - }, - ), - ("/items/5", {"item": {"name": "Foo", "price": -3.0}}, 422, price_not_greater), - ], -) -def test(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"limit_value": 0}, + "loc": ["body", "item", "price"], + "msg": "ensure this value is greater than 0", + "type": "value_error.number.not_gt", + } + ] + } + ) @needs_py310 @@ -124,18 +138,39 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": { - "title": "The description of the item", - "maxLength": 300, - "type": "string", - }, + "description": IsDict( + { + "title": "The description of the item", + "anyOf": [ + {"maxLength": 300, "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "The description of the item", + "maxLength": 300, + "type": "string", + } + ), "price": { "title": "Price", "exclusiveMinimum": 0.0, "type": "number", "description": "The price must be greater than zero", }, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial001.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial001.py index 496ab38fb..5d755b7b4 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial001.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial001.py @@ -1,52 +1,74 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body_multiple_params.tutorial001 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_multiple_params.tutorial001 import app + client = TestClient(app) + return client -item_id_not_int = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] -} +def test_post_body_q_bar_content(client: TestClient): + response = client.put("/items/5?q=bar", json={"name": "Foo", "price": 50.5}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "q": "bar", + } -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5?q=bar", - {"name": "Foo", "price": 50.5}, - 200, - { - "item_id": 5, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "q": "bar", - }, - ), - ("/items/5?q=bar", None, 200, {"item_id": 5, "q": "bar"}), - ("/items/5", None, 200, {"item_id": 5}), - ("/items/foo", None, 422, item_id_not_int), - ], -) -def test_post_body(path, body, expected_status, expected_response): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_no_body_q_bar(client: TestClient): + response = client.put("/items/5?q=bar", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5, "q": "bar"} -def test_openapi_schema(): + +def test_post_no_body(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5} + + +def test_post_id_foo(client: TestClient): + response = client.put("/items/foo", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -87,7 +109,16 @@ def test_openapi_schema(): }, { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -95,7 +126,19 @@ def test_openapi_schema(): "requestBody": { "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/Item"} + "schema": IsDict( + { + "anyOf": [ + {"$ref": "#/components/schemas/Item"}, + {"type": "null"}, + ], + "title": "Item", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"$ref": "#/components/schemas/Item"} + ) } } }, @@ -110,9 +153,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an.py index 74a8a9b2e..377895d59 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an.py @@ -1,52 +1,74 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body_multiple_params.tutorial001_an import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_multiple_params.tutorial001_an import app + client = TestClient(app) + return client -item_id_not_int = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] -} +def test_post_body_q_bar_content(client: TestClient): + response = client.put("/items/5?q=bar", json={"name": "Foo", "price": 50.5}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "q": "bar", + } -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5?q=bar", - {"name": "Foo", "price": 50.5}, - 200, - { - "item_id": 5, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "q": "bar", - }, - ), - ("/items/5?q=bar", None, 200, {"item_id": 5, "q": "bar"}), - ("/items/5", None, 200, {"item_id": 5}), - ("/items/foo", None, 422, item_id_not_int), - ], -) -def test_post_body(path, body, expected_status, expected_response): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_no_body_q_bar(client: TestClient): + response = client.put("/items/5?q=bar", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5, "q": "bar"} -def test_openapi_schema(): + +def test_post_no_body(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5} + + +def test_post_id_foo(client: TestClient): + response = client.put("/items/foo", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) + + +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -87,7 +109,16 @@ def test_openapi_schema(): }, { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -95,7 +126,19 @@ def test_openapi_schema(): "requestBody": { "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/Item"} + "schema": IsDict( + { + "anyOf": [ + {"$ref": "#/components/schemas/Item"}, + {"type": "null"}, + ], + "title": "Item", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"$ref": "#/components/schemas/Item"} + ) } } }, @@ -110,9 +153,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py310.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py310.py index 9c764b6d1..05f4cefa8 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py310.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,45 +14,64 @@ def get_client(): return client -item_id_not_int = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] -} +@needs_py310 +def test_post_body_q_bar_content(client: TestClient): + response = client.put("/items/5?q=bar", json={"name": "Foo", "price": 50.5}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "q": "bar", + } @needs_py310 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5?q=bar", - {"name": "Foo", "price": 50.5}, - 200, - { - "item_id": 5, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "q": "bar", - }, - ), - ("/items/5?q=bar", None, 200, {"item_id": 5, "q": "bar"}), - ("/items/5", None, 200, {"item_id": 5}), - ("/items/foo", None, 422, item_id_not_int), - ], -) -def test_post_body(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_no_body_q_bar(client: TestClient): + response = client.put("/items/5?q=bar", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5, "q": "bar"} + + +@needs_py310 +def test_post_no_body(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5} + + +@needs_py310 +def test_post_id_foo(client: TestClient): + response = client.put("/items/foo", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) @needs_py310 @@ -95,7 +116,16 @@ def test_openapi_schema(client: TestClient): }, { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -103,7 +133,19 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/Item"} + "schema": IsDict( + { + "anyOf": [ + {"$ref": "#/components/schemas/Item"}, + {"type": "null"}, + ], + "title": "Item", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"$ref": "#/components/schemas/Item"} + ) } } }, @@ -118,9 +160,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py39.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py39.py index 0cca29433..4b9b15886 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -12,45 +14,64 @@ def get_client(): return client -item_id_not_int = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] -} +@needs_py39 +def test_post_body_q_bar_content(client: TestClient): + response = client.put("/items/5?q=bar", json={"name": "Foo", "price": 50.5}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "q": "bar", + } @needs_py39 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5?q=bar", - {"name": "Foo", "price": 50.5}, - 200, - { - "item_id": 5, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "q": "bar", - }, - ), - ("/items/5?q=bar", None, 200, {"item_id": 5, "q": "bar"}), - ("/items/5", None, 200, {"item_id": 5}), - ("/items/foo", None, 422, item_id_not_int), - ], -) -def test_post_body(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_no_body_q_bar(client: TestClient): + response = client.put("/items/5?q=bar", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5, "q": "bar"} + + +@needs_py39 +def test_post_no_body(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5} + + +@needs_py39 +def test_post_id_foo(client: TestClient): + response = client.put("/items/foo", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) @needs_py39 @@ -95,7 +116,16 @@ def test_openapi_schema(client: TestClient): }, { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -103,7 +133,19 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/Item"} + "schema": IsDict( + { + "anyOf": [ + {"$ref": "#/components/schemas/Item"}, + {"type": "null"}, + ], + "title": "Item", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"$ref": "#/components/schemas/Item"} + ) } } }, @@ -118,9 +160,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_py310.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_py310.py index 3b61e717e..c249f5ec6 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial001_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,45 +14,64 @@ def get_client(): return client -item_id_not_int = { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] -} +@needs_py310 +def test_post_body_q_bar_content(client: TestClient): + response = client.put("/items/5?q=bar", json={"name": "Foo", "price": 50.5}) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "q": "bar", + } @needs_py310 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5?q=bar", - {"name": "Foo", "price": 50.5}, - 200, - { - "item_id": 5, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "q": "bar", - }, - ), - ("/items/5?q=bar", None, 200, {"item_id": 5, "q": "bar"}), - ("/items/5", None, 200, {"item_id": 5}), - ("/items/foo", None, 422, item_id_not_int), - ], -) -def test_post_body(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_no_body_q_bar(client: TestClient): + response = client.put("/items/5?q=bar", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5, "q": "bar"} + + +@needs_py310 +def test_post_no_body(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 200 + assert response.json() == {"item_id": 5} + + +@needs_py310 +def test_post_id_foo(client: TestClient): + response = client.put("/items/foo", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) @needs_py310 @@ -95,7 +116,16 @@ def test_openapi_schema(client: TestClient): }, { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -103,7 +133,19 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "application/json": { - "schema": {"$ref": "#/components/schemas/Item"} + "schema": IsDict( + { + "anyOf": [ + {"$ref": "#/components/schemas/Item"}, + {"type": "null"}, + ], + "title": "Item", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"$ref": "#/components/schemas/Item"} + ) } } }, @@ -118,9 +160,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial003.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial003.py index b34377a28..4f17bad50 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial003.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial003.py @@ -1,92 +1,147 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body_multiple_params.tutorial003 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_multiple_params.tutorial003 import app + client = TestClient(app) + return client -# Test required and embedded body parameters with no bodies sent -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - { - "importance": 2, - "item": {"name": "Foo", "price": 50.5}, - "user": {"username": "Dave"}, - }, - 200, - { - "item_id": 5, - "importance": 2, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "user": {"username": "Dave", "full_name": None}, - }, - ), - ( - "/items/5", - None, - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ( - "/items/5", - [], - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ], -) -def test_post_body(path, body, expected_status, expected_response): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response + +def test_post_body_valid(client: TestClient): + response = client.put( + "/items/5", + json={ + "importance": 2, + "item": {"name": "Foo", "price": 50.5}, + "user": {"username": "Dave"}, + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "importance": 2, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "user": {"username": "Dave", "full_name": None}, + } + + +def test_post_body_no_data(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_openapi_schema(): +def test_post_body_empty_list(client: TestClient): + response = client.put("/items/5", json=[]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "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() == { @@ -142,9 +197,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "User": { @@ -153,7 +226,16 @@ def test_openapi_schema(): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an.py index 9b8d5e15b..7ee17ef90 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an.py @@ -1,92 +1,147 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body_multiple_params.tutorial003_an import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_multiple_params.tutorial003_an import app + client = TestClient(app) + return client -# Test required and embedded body parameters with no bodies sent -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - { - "importance": 2, - "item": {"name": "Foo", "price": 50.5}, - "user": {"username": "Dave"}, - }, - 200, - { - "item_id": 5, - "importance": 2, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "user": {"username": "Dave", "full_name": None}, - }, - ), - ( - "/items/5", - None, - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ( - "/items/5", - [], - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ], -) -def test_post_body(path, body, expected_status, expected_response): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response + +def test_post_body_valid(client: TestClient): + response = client.put( + "/items/5", + json={ + "importance": 2, + "item": {"name": "Foo", "price": 50.5}, + "user": {"username": "Dave"}, + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "importance": 2, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "user": {"username": "Dave", "full_name": None}, + } + + +def test_post_body_no_data(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_openapi_schema(): +def test_post_body_empty_list(client: TestClient): + response = client.put("/items/5", json=[]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "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() == { @@ -142,9 +197,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "User": { @@ -153,7 +226,16 @@ def test_openapi_schema(): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py310.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py310.py index f8af555fc..0138332f6 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py310.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,85 +14,136 @@ def get_client(): return client -# Test required and embedded body parameters with no bodies sent @needs_py310 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - { - "importance": 2, - "item": {"name": "Foo", "price": 50.5}, - "user": {"username": "Dave"}, - }, - 200, - { - "item_id": 5, - "importance": 2, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "user": {"username": "Dave", "full_name": None}, - }, - ), - ( - "/items/5", - None, - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ( - "/items/5", - [], - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ], -) -def test_post_body(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_body_valid(client: TestClient): + response = client.put( + "/items/5", + json={ + "importance": 2, + "item": {"name": "Foo", "price": 50.5}, + "user": {"username": "Dave"}, + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "importance": 2, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "user": {"username": "Dave", "full_name": None}, + } + + +@needs_py310 +def test_post_body_no_data(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py310 +def test_post_body_empty_list(client: TestClient): + response = client.put("/items/5", json=[]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py310 @@ -150,9 +203,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "User": { @@ -161,7 +232,16 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py39.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py39.py index 06e2c3146..6dae31d6a 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py39.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -12,85 +14,136 @@ def get_client(): return client -# Test required and embedded body parameters with no bodies sent @needs_py39 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - { - "importance": 2, - "item": {"name": "Foo", "price": 50.5}, - "user": {"username": "Dave"}, - }, - 200, - { - "item_id": 5, - "importance": 2, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "user": {"username": "Dave", "full_name": None}, - }, - ), - ( - "/items/5", - None, - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ( - "/items/5", - [], - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ], -) -def test_post_body(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_body_valid(client: TestClient): + response = client.put( + "/items/5", + json={ + "importance": 2, + "item": {"name": "Foo", "price": 50.5}, + "user": {"username": "Dave"}, + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "importance": 2, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "user": {"username": "Dave", "full_name": None}, + } + + +@needs_py39 +def test_post_body_no_data(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py39 +def test_post_body_empty_list(client: TestClient): + response = client.put("/items/5", json=[]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 @@ -150,9 +203,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "User": { @@ -161,7 +232,16 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_py310.py b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_py310.py index 82c5fb101..01edbee84 100644 --- a/tests/test_tutorial/test_body_multiple_params/test_tutorial003_py310.py +++ b/tests/test_tutorial/test_body_multiple_params/test_tutorial003_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,85 +14,136 @@ def get_client(): return client -# Test required and embedded body parameters with no bodies sent @needs_py310 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/items/5", - { - "importance": 2, - "item": {"name": "Foo", "price": 50.5}, - "user": {"username": "Dave"}, - }, - 200, - { - "item_id": 5, - "importance": 2, - "item": { - "name": "Foo", - "price": 50.5, - "description": None, - "tax": None, - }, - "user": {"username": "Dave", "full_name": None}, - }, - ), - ( - "/items/5", - None, - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ( - "/items/5", - [], - 422, - { - "detail": [ - { - "loc": ["body", "item"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "user"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "importance"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - }, - ), - ], -) -def test_post_body(path, body, expected_status, expected_response, client: TestClient): - response = client.put(path, json=body) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_post_body_valid(client: TestClient): + response = client.put( + "/items/5", + json={ + "importance": 2, + "item": {"name": "Foo", "price": 50.5}, + "user": {"username": "Dave"}, + }, + ) + assert response.status_code == 200 + assert response.json() == { + "item_id": 5, + "importance": 2, + "item": { + "name": "Foo", + "price": 50.5, + "description": None, + "tax": None, + }, + "user": {"username": "Dave", "full_name": None}, + } + + +@needs_py310 +def test_post_body_no_data(client: TestClient): + response = client.put("/items/5", json=None) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) + + +@needs_py310 +def test_post_body_empty_list(client: TestClient): + response = client.put("/items/5", json=[]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "item"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "user"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "importance"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "item"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "user"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "importance"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py310 @@ -150,9 +203,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "User": { @@ -161,7 +232,16 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "Body_update_item_items__item_id__put": { diff --git a/tests/test_tutorial/test_body_nested_models/test_tutorial009.py b/tests/test_tutorial/test_body_nested_models/test_tutorial009.py index 378c24197..12ac3cd83 100644 --- a/tests/test_tutorial/test_body_nested_models/test_tutorial009.py +++ b/tests/test_tutorial/test_body_nested_models/test_tutorial009.py @@ -1,33 +1,55 @@ +import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.body_nested_models.tutorial009 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_nested_models.tutorial009 import app + client = TestClient(app) + return client -def test_post_body(): + +def test_post_body(client: TestClient): data = {"2": 2.2, "3": 3.3} response = client.post("/index-weights/", json=data) assert response.status_code == 200, response.text assert response.json() == data -def test_post_invalid_body(): +def test_post_invalid_body(client: TestClient): data = {"foo": 2.2, "3": 3.3} response = client.post("/index-weights/", json=data) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body", "__key__"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["body", "foo", "[key]"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "__key__"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_body_nested_models/test_tutorial009_py39.py b/tests/test_tutorial/test_body_nested_models/test_tutorial009_py39.py index 5ca63a92b..8af75b202 100644 --- a/tests/test_tutorial/test_body_nested_models/test_tutorial009_py39.py +++ b/tests/test_tutorial/test_body_nested_models/test_tutorial009_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -25,15 +27,30 @@ def test_post_invalid_body(client: TestClient): data = {"foo": 2.2, "3": 3.3} response = client.post("/index-weights/", json=data) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body", "__key__"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["body", "foo", "[key]"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "__key__"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_body_updates/test_tutorial001.py b/tests/test_tutorial/test_body_updates/test_tutorial001.py index 939bf44e0..cc56f16ca 100644 --- a/tests/test_tutorial/test_body_updates/test_tutorial001.py +++ b/tests/test_tutorial/test_body_updates/test_tutorial001.py @@ -1,11 +1,17 @@ +import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient -from docs_src.body_updates.tutorial001 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.body_updates.tutorial001 import app + client = TestClient(app) + return client -def test_get(): + +def test_get(client: TestClient): response = client.get("/items/baz") assert response.status_code == 200, response.text assert response.json() == { @@ -17,7 +23,7 @@ def test_get(): } -def test_put(): +def test_put(client: TestClient): response = client.put( "/items/bar", json={"name": "Barz", "price": 3, "description": None} ) @@ -30,7 +36,7 @@ def test_put(): } -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -118,9 +124,36 @@ def test_openapi_schema(): "title": "Item", "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, - "price": {"title": "Price", "type": "number"}, + "name": IsDict( + { + "title": "Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Name", "type": "string"} + ), + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), + "price": IsDict( + { + "title": "Price", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Price", "type": "number"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, "tags": { "title": "Tags", diff --git a/tests/test_tutorial/test_body_updates/test_tutorial001_py310.py b/tests/test_tutorial/test_body_updates/test_tutorial001_py310.py index 5f50f2071..56283184f 100644 --- a/tests/test_tutorial/test_body_updates/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_body_updates/test_tutorial001_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -128,9 +129,36 @@ def test_openapi_schema(client: TestClient): "title": "Item", "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, - "price": {"title": "Price", "type": "number"}, + "name": IsDict( + { + "title": "Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Name", "type": "string"} + ), + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), + "price": IsDict( + { + "title": "Price", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Price", "type": "number"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, "tags": { "title": "Tags", diff --git a/tests/test_tutorial/test_body_updates/test_tutorial001_py39.py b/tests/test_tutorial/test_body_updates/test_tutorial001_py39.py index d4fdabce6..ac5e2b5b5 100644 --- a/tests/test_tutorial/test_body_updates/test_tutorial001_py39.py +++ b/tests/test_tutorial/test_body_updates/test_tutorial001_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -128,9 +129,36 @@ def test_openapi_schema(client: TestClient): "title": "Item", "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, - "price": {"title": "Price", "type": "number"}, + "name": IsDict( + { + "title": "Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Name", "type": "string"} + ), + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), + "price": IsDict( + { + "title": "Price", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Price", "type": "number"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, "tags": { "title": "Tags", diff --git a/tests/test_tutorial/test_conditional_openapi/test_tutorial001.py b/tests/test_tutorial/test_conditional_openapi/test_tutorial001.py index c1d8fd805..dad6f307b 100644 --- a/tests/test_tutorial/test_conditional_openapi/test_tutorial001.py +++ b/tests/test_tutorial/test_conditional_openapi/test_tutorial001.py @@ -2,13 +2,24 @@ import importlib from fastapi.testclient import TestClient -from docs_src.conditional_openapi import tutorial001 +from ...utils import needs_pydanticv1 -def test_disable_openapi(monkeypatch): - monkeypatch.setenv("OPENAPI_URL", "") +def get_client() -> TestClient: + from docs_src.conditional_openapi import tutorial001 + importlib.reload(tutorial001) + client = TestClient(tutorial001.app) + return client + + +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 +def test_disable_openapi(monkeypatch): + monkeypatch.setenv("OPENAPI_URL", "") + # Load the client after setting the env var + client = get_client() response = client.get("/openapi.json") assert response.status_code == 404, response.text response = client.get("/docs") @@ -17,16 +28,19 @@ def test_disable_openapi(monkeypatch): assert response.status_code == 404, response.text +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_root(): - client = TestClient(tutorial001.app) + client = get_client() response = client.get("/") assert response.status_code == 200 assert response.json() == {"message": "Hello World"} +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_default_openapi(): - importlib.reload(tutorial001) - client = TestClient(tutorial001.app) + client = get_client() response = client.get("/docs") assert response.status_code == 200, response.text response = client.get("/redoc") diff --git a/tests/test_tutorial/test_cookie_params/test_tutorial001.py b/tests/test_tutorial/test_cookie_params/test_tutorial001.py index c3511d129..8a14da516 100644 --- a/tests/test_tutorial/test_cookie_params/test_tutorial001.py +++ b/tests/test_tutorial/test_cookie_params/test_tutorial001.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.cookie_params.tutorial001 import app @@ -56,7 +57,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Ads Id", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Ads Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Ads Id", "type": "string"} + ), "name": "ads_id", "in": "cookie", } diff --git a/tests/test_tutorial/test_cookie_params/test_tutorial001_an.py b/tests/test_tutorial/test_cookie_params/test_tutorial001_an.py index f4f94c09d..56a620282 100644 --- a/tests/test_tutorial/test_cookie_params/test_tutorial001_an.py +++ b/tests/test_tutorial/test_cookie_params/test_tutorial001_an.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.cookie_params.tutorial001_an import app @@ -56,7 +57,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Ads Id", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Ads Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Ads Id", "type": "string"} + ), "name": "ads_id", "in": "cookie", } diff --git a/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py310.py b/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py310.py index a80f10f81..310386dd2 100644 --- a/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py310.py +++ b/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -62,7 +63,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Ads Id", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Ads Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Ads Id", "type": "string"} + ), "name": "ads_id", "in": "cookie", } diff --git a/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py39.py b/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py39.py index 1be898c09..db3336041 100644 --- a/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_cookie_params/test_tutorial001_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -62,7 +63,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Ads Id", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Ads Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Ads Id", "type": "string"} + ), "name": "ads_id", "in": "cookie", } diff --git a/tests/test_tutorial/test_cookie_params/test_tutorial001_py310.py b/tests/test_tutorial/test_cookie_params/test_tutorial001_py310.py index 7ba542c90..9b3fcac3a 100644 --- a/tests/test_tutorial/test_cookie_params/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_cookie_params/test_tutorial001_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -62,7 +63,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Ads Id", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Ads Id", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Ads Id", "type": "string"} + ), "name": "ads_id", "in": "cookie", } diff --git a/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py b/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py index d2d27f8a2..ad142ec88 100644 --- a/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py +++ b/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.custom_request_and_route.tutorial002 import app @@ -12,16 +14,33 @@ def test_endpoint_works(): def test_exception_handler_body_access(): response = client.post("/", json={"numbers": [1, 2, 3]}) - - assert response.json() == { - "detail": { - "body": '{"numbers": [1, 2, 3]}', - "errors": [ - { - "loc": ["body"], - "msg": "value is not a valid list", - "type": "type_error.list", - } - ], + assert response.json() == IsDict( + { + "detail": { + "errors": [ + { + "type": "list_type", + "loc": ["body"], + "msg": "Input should be a valid list", + "input": {"numbers": [1, 2, 3]}, + "url": match_pydantic_error_url("list_type"), + } + ], + "body": '{"numbers": [1, 2, 3]}', + } + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": { + "body": '{"numbers": [1, 2, 3]}', + "errors": [ + { + "loc": ["body"], + "msg": "value is not a valid list", + "type": "type_error.list", + } + ], + } } - } + ) diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial001.py b/tests/test_tutorial/test_dataclasses/test_tutorial001.py index 26ae04c51..4a2d9f053 100644 --- a/tests/test_tutorial/test_dataclasses/test_tutorial001.py +++ b/tests/test_tutorial/test_dataclasses/test_tutorial001.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.dataclasses.tutorial001 import app @@ -19,15 +21,30 @@ def test_post_item(): def test_post_invalid_item(): response = client.post("/items/", json={"name": "Foo", "price": "invalid price"}) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "loc": ["body", "price"], - "msg": "value is not a valid float", - "type": "type_error.float", - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "float_parsing", + "loc": ["body", "price"], + "msg": "Input should be a valid number, unable to parse string as an number", + "input": "invalid price", + "url": match_pydantic_error_url("float_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "price"], + "msg": "value is not a valid float", + "type": "type_error.float", + } + ] + } + ) def test_openapi_schema(): @@ -88,8 +105,26 @@ def test_openapi_schema(): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial002.py b/tests/test_tutorial/test_dataclasses/test_tutorial002.py index 675e826b1..9bc85e999 100644 --- a/tests/test_tutorial/test_dataclasses/test_tutorial002.py +++ b/tests/test_tutorial/test_dataclasses/test_tutorial002.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.dataclasses.tutorial002 import app @@ -20,8 +21,7 @@ def test_get_item(): def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200 - data = response.json() - assert data == { + assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, "paths": { @@ -51,13 +51,42 @@ def test_openapi_schema(): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "tags": { - "title": "Tags", - "type": "array", - "items": {"type": "string"}, - }, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tags": IsDict( + { + "title": "Tags", + "type": "array", + "items": {"type": "string"}, + "default": [], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Tags", + "type": "array", + "items": {"type": "string"}, + } + ), + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, } } diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial003.py b/tests/test_tutorial/test_dataclasses/test_tutorial003.py index f3378fe62..3bc061663 100644 --- a/tests/test_tutorial/test_dataclasses/test_tutorial003.py +++ b/tests/test_tutorial/test_dataclasses/test_tutorial003.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.dataclasses.tutorial003 import app @@ -135,11 +136,22 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "items": { - "title": "Items", - "type": "array", - "items": {"$ref": "#/components/schemas/Item"}, - }, + "items": IsDict( + { + "title": "Items", + "type": "array", + "items": {"$ref": "#/components/schemas/Item"}, + "default": [], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Items", + "type": "array", + "items": {"$ref": "#/components/schemas/Item"}, + } + ), }, }, "HTTPValidationError": { @@ -159,7 +171,16 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_dependencies/test_tutorial001.py b/tests/test_tutorial/test_dependencies/test_tutorial001.py index 974b9304f..372722c0a 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial001.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial001.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.dependencies.tutorial001 import app @@ -52,7 +53,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -102,7 +112,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial001_an.py b/tests/test_tutorial/test_dependencies/test_tutorial001_an.py index b1ca27ff8..aa138e43f 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial001_an.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial001_an.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.dependencies.tutorial001_an import app @@ -52,7 +53,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -102,7 +112,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial001_an_py310.py b/tests/test_tutorial/test_dependencies/test_tutorial001_an_py310.py index 70bed03f6..98bc28181 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial001_an_py310.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial001_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -60,7 +61,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -110,7 +120,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial001_an_py39.py b/tests/test_tutorial/test_dependencies/test_tutorial001_an_py39.py index 9c5723be8..bfe4647e8 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial001_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -60,7 +61,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -110,7 +120,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial001_py310.py b/tests/test_tutorial/test_dependencies/test_tutorial001_py310.py index 1bcde4e9f..3ac4d7e23 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial001_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -60,7 +61,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, @@ -110,7 +120,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial004.py b/tests/test_tutorial/test_dependencies/test_tutorial004.py index 298bc290d..66ee87d76 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial004.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial004.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.dependencies.tutorial004 import app @@ -90,7 +91,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial004_an.py b/tests/test_tutorial/test_dependencies/test_tutorial004_an.py index f985be8df..847c7bb18 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial004_an.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial004_an.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.dependencies.tutorial004_an import app @@ -90,7 +91,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial004_an_py310.py b/tests/test_tutorial/test_dependencies/test_tutorial004_an_py310.py index fc0286702..a3be44ef6 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial004_an_py310.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial004_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -98,7 +99,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial004_an_py39.py b/tests/test_tutorial/test_dependencies/test_tutorial004_an_py39.py index 1e37673ed..948c8d322 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial004_an_py39.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial004_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -98,7 +99,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial004_py310.py b/tests/test_tutorial/test_dependencies/test_tutorial004_py310.py index ab936ccdc..be85fc162 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial004_py310.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial004_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -98,7 +99,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Q", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Q", "type": "string"} + ), "name": "q", "in": "query", }, diff --git a/tests/test_tutorial/test_dependencies/test_tutorial006.py b/tests/test_tutorial/test_dependencies/test_tutorial006.py index 2e9c82d71..1949b2a92 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial006.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial006.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.dependencies.tutorial006 import app @@ -8,20 +10,42 @@ client = TestClient(app) def test_get_no_headers(): response = client.get("/items/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) def test_get_invalid_one_header(): diff --git a/tests/test_tutorial/test_dependencies/test_tutorial006_an.py b/tests/test_tutorial/test_dependencies/test_tutorial006_an.py index 919066dca..387f3b7ba 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial006_an.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial006_an.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.dependencies.tutorial006_an import app @@ -8,20 +10,42 @@ client = TestClient(app) def test_get_no_headers(): response = client.get("/items/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) def test_get_invalid_one_header(): diff --git a/tests/test_tutorial/test_dependencies/test_tutorial006_an_py39.py b/tests/test_tutorial/test_dependencies/test_tutorial006_an_py39.py index c23718479..772ec884d 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial006_an_py39.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial006_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -16,20 +18,42 @@ def get_client(): def test_get_no_headers(client: TestClient): response = client.get("/items/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_dependencies/test_tutorial012.py b/tests/test_tutorial/test_dependencies/test_tutorial012.py index b92b96c01..447bd932c 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial012.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial012.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.dependencies.tutorial012 import app @@ -8,39 +10,83 @@ client = TestClient(app) def test_get_no_headers_items(): response = client.get("/items/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) def test_get_no_headers_users(): response = client.get("/users/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) def test_get_invalid_one_header_items(): diff --git a/tests/test_tutorial/test_dependencies/test_tutorial012_an.py b/tests/test_tutorial/test_dependencies/test_tutorial012_an.py index 2ddb7bb53..6946fe93c 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial012_an.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial012_an.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.dependencies.tutorial012_an import app @@ -8,39 +10,83 @@ client = TestClient(app) def test_get_no_headers_items(): response = client.get("/items/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) def test_get_no_headers_users(): response = client.get("/users/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) def test_get_invalid_one_header_items(): diff --git a/tests/test_tutorial/test_dependencies/test_tutorial012_an_py39.py b/tests/test_tutorial/test_dependencies/test_tutorial012_an_py39.py index 595c83a53..40a90198f 100644 --- a/tests/test_tutorial/test_dependencies/test_tutorial012_an_py39.py +++ b/tests/test_tutorial/test_dependencies/test_tutorial012_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -16,40 +18,84 @@ def get_client(): def test_get_no_headers_items(client: TestClient): response = client.get("/items/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 def test_get_no_headers_users(client: TestClient): response = client.get("/users/") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["header", "x-token"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["header", "x-key"], - "msg": "field required", - "type": "value_error.missing", - }, - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "x-token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["header", "x-key"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "x-token"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["header", "x-key"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_extra_data_types/test_tutorial001.py b/tests/test_tutorial/test_extra_data_types/test_tutorial001.py index 07a834990..ca5a3e3e2 100644 --- a/tests/test_tutorial/test_extra_data_types/test_tutorial001.py +++ b/tests/test_tutorial/test_extra_data_types/test_tutorial001.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.extra_data_types.tutorial001 import app @@ -68,9 +69,22 @@ def test_openapi_schema(): "requestBody": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_read_items_items__item_id__put" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ) } } }, @@ -83,26 +97,74 @@ def test_openapi_schema(): "title": "Body_read_items_items__item_id__put", "type": "object", "properties": { - "start_datetime": { - "title": "Start Datetime", - "type": "string", - "format": "date-time", - }, - "end_datetime": { - "title": "End Datetime", - "type": "string", - "format": "date-time", - }, - "repeat_at": { - "title": "Repeat At", - "type": "string", - "format": "time", - }, - "process_after": { - "title": "Process After", - "type": "number", - "format": "time-delta", - }, + "start_datetime": IsDict( + { + "title": "Start Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Start Datetime", + "type": "string", + "format": "date-time", + } + ), + "end_datetime": IsDict( + { + "title": "End Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "End Datetime", + "type": "string", + "format": "date-time", + } + ), + "repeat_at": IsDict( + { + "title": "Repeat At", + "anyOf": [ + {"type": "string", "format": "time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Repeat At", + "type": "string", + "format": "time", + } + ), + "process_after": IsDict( + { + "title": "Process After", + "anyOf": [ + {"type": "string", "format": "duration"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Process After", + "type": "number", + "format": "time-delta", + } + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_extra_data_types/test_tutorial001_an.py b/tests/test_tutorial/test_extra_data_types/test_tutorial001_an.py index 76836d447..032d16230 100644 --- a/tests/test_tutorial/test_extra_data_types/test_tutorial001_an.py +++ b/tests/test_tutorial/test_extra_data_types/test_tutorial001_an.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.extra_data_types.tutorial001_an import app @@ -68,9 +69,22 @@ def test_openapi_schema(): "requestBody": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_read_items_items__item_id__put" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ) } } }, @@ -83,26 +97,74 @@ def test_openapi_schema(): "title": "Body_read_items_items__item_id__put", "type": "object", "properties": { - "start_datetime": { - "title": "Start Datetime", - "type": "string", - "format": "date-time", - }, - "end_datetime": { - "title": "End Datetime", - "type": "string", - "format": "date-time", - }, - "repeat_at": { - "title": "Repeat At", - "type": "string", - "format": "time", - }, - "process_after": { - "title": "Process After", - "type": "number", - "format": "time-delta", - }, + "start_datetime": IsDict( + { + "title": "Start Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Start Datetime", + "type": "string", + "format": "date-time", + } + ), + "end_datetime": IsDict( + { + "title": "End Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "End Datetime", + "type": "string", + "format": "date-time", + } + ), + "repeat_at": IsDict( + { + "title": "Repeat At", + "anyOf": [ + {"type": "string", "format": "time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Repeat At", + "type": "string", + "format": "time", + } + ), + "process_after": IsDict( + { + "title": "Process After", + "anyOf": [ + {"type": "string", "format": "duration"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Process After", + "type": "number", + "format": "time-delta", + } + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py310.py b/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py310.py index 158ee01b3..06ee96758 100644 --- a/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py310.py +++ b/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -77,9 +78,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_read_items_items__item_id__put" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ) } } }, @@ -92,26 +106,74 @@ def test_openapi_schema(client: TestClient): "title": "Body_read_items_items__item_id__put", "type": "object", "properties": { - "start_datetime": { - "title": "Start Datetime", - "type": "string", - "format": "date-time", - }, - "end_datetime": { - "title": "End Datetime", - "type": "string", - "format": "date-time", - }, - "repeat_at": { - "title": "Repeat At", - "type": "string", - "format": "time", - }, - "process_after": { - "title": "Process After", - "type": "number", - "format": "time-delta", - }, + "start_datetime": IsDict( + { + "title": "Start Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Start Datetime", + "type": "string", + "format": "date-time", + } + ), + "end_datetime": IsDict( + { + "title": "End Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "End Datetime", + "type": "string", + "format": "date-time", + } + ), + "repeat_at": IsDict( + { + "title": "Repeat At", + "anyOf": [ + {"type": "string", "format": "time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Repeat At", + "type": "string", + "format": "time", + } + ), + "process_after": IsDict( + { + "title": "Process After", + "anyOf": [ + {"type": "string", "format": "duration"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Process After", + "type": "number", + "format": "time-delta", + } + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py39.py b/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py39.py index 5be6452ee..4679947fd 100644 --- a/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_extra_data_types/test_tutorial001_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -77,9 +78,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_read_items_items__item_id__put" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ) } } }, @@ -92,26 +106,74 @@ def test_openapi_schema(client: TestClient): "title": "Body_read_items_items__item_id__put", "type": "object", "properties": { - "start_datetime": { - "title": "Start Datetime", - "type": "string", - "format": "date-time", - }, - "end_datetime": { - "title": "End Datetime", - "type": "string", - "format": "date-time", - }, - "repeat_at": { - "title": "Repeat At", - "type": "string", - "format": "time", - }, - "process_after": { - "title": "Process After", - "type": "number", - "format": "time-delta", - }, + "start_datetime": IsDict( + { + "title": "Start Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Start Datetime", + "type": "string", + "format": "date-time", + } + ), + "end_datetime": IsDict( + { + "title": "End Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "End Datetime", + "type": "string", + "format": "date-time", + } + ), + "repeat_at": IsDict( + { + "title": "Repeat At", + "anyOf": [ + {"type": "string", "format": "time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Repeat At", + "type": "string", + "format": "time", + } + ), + "process_after": IsDict( + { + "title": "Process After", + "anyOf": [ + {"type": "string", "format": "duration"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Process After", + "type": "number", + "format": "time-delta", + } + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_extra_data_types/test_tutorial001_py310.py b/tests/test_tutorial/test_extra_data_types/test_tutorial001_py310.py index 5413fe428..a1a74eb61 100644 --- a/tests/test_tutorial/test_extra_data_types/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_extra_data_types/test_tutorial001_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -77,9 +78,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Body_read_items_items__item_id__put" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_read_items_items__item_id__put" + } + ) } } }, @@ -92,26 +106,74 @@ def test_openapi_schema(client: TestClient): "title": "Body_read_items_items__item_id__put", "type": "object", "properties": { - "start_datetime": { - "title": "Start Datetime", - "type": "string", - "format": "date-time", - }, - "end_datetime": { - "title": "End Datetime", - "type": "string", - "format": "date-time", - }, - "repeat_at": { - "title": "Repeat At", - "type": "string", - "format": "time", - }, - "process_after": { - "title": "Process After", - "type": "number", - "format": "time-delta", - }, + "start_datetime": IsDict( + { + "title": "Start Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Start Datetime", + "type": "string", + "format": "date-time", + } + ), + "end_datetime": IsDict( + { + "title": "End Datetime", + "anyOf": [ + {"type": "string", "format": "date-time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "End Datetime", + "type": "string", + "format": "date-time", + } + ), + "repeat_at": IsDict( + { + "title": "Repeat At", + "anyOf": [ + {"type": "string", "format": "time"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Repeat At", + "type": "string", + "format": "time", + } + ), + "process_after": IsDict( + { + "title": "Process After", + "anyOf": [ + {"type": "string", "format": "duration"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Process After", + "type": "number", + "format": "time-delta", + } + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial004.py b/tests/test_tutorial/test_handling_errors/test_tutorial004.py index 246f3b94c..f3b1e9c14 100644 --- a/tests/test_tutorial/test_handling_errors/test_tutorial004.py +++ b/tests/test_tutorial/test_handling_errors/test_tutorial004.py @@ -8,12 +8,18 @@ client = TestClient(app) def test_get_validation_error(): response = client.get("/items/foo") assert response.status_code == 400, response.text - validation_error_str_lines = [ - b"1 validation error for Request", - b"path -> item_id", - b" value is not a valid integer (type=type_error.integer)", - ] - assert response.content == b"\n".join(validation_error_str_lines) + # TODO: remove when deprecating Pydantic v1 + assert ( + # TODO: remove when deprecating Pydantic v1 + "path -> item_id" in response.text + or "'loc': ('path', 'item_id')" in response.text + ) + assert ( + # TODO: remove when deprecating Pydantic v1 + "value is not a valid integer" in response.text + or "Input should be a valid integer, unable to parse string as an integer" + in response.text + ) def test_get_http_error(): diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial005.py b/tests/test_tutorial/test_handling_errors/test_tutorial005.py index af47fd1a4..e19047154 100644 --- a/tests/test_tutorial/test_handling_errors/test_tutorial005.py +++ b/tests/test_tutorial/test_handling_errors/test_tutorial005.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.handling_errors.tutorial005 import app @@ -8,16 +10,32 @@ client = TestClient(app) def test_post_validation_error(): response = client.post("/items/", json={"title": "towel", "size": "XL"}) assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["body", "size"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ], - "body": {"title": "towel", "size": "XL"}, - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["body", "size"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "XL", + "url": match_pydantic_error_url("int_parsing"), + } + ], + "body": {"title": "towel", "size": "XL"}, + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "size"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ], + "body": {"title": "towel", "size": "XL"}, + } + ) def test_post(): diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial006.py b/tests/test_tutorial/test_handling_errors/test_tutorial006.py index 4a39bd102..de178a720 100644 --- a/tests/test_tutorial/test_handling_errors/test_tutorial006.py +++ b/tests/test_tutorial/test_handling_errors/test_tutorial006.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.handling_errors.tutorial006 import app @@ -8,15 +10,30 @@ client = TestClient(app) def test_get_validation_error(): response = client.get("/items/foo") assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "loc": ["path", "item_id"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - } - ] - } + assert response.json() == IsDict( + { + "detail": [ + { + "type": "int_parsing", + "loc": ["path", "item_id"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "foo", + "url": match_pydantic_error_url("int_parsing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["path", "item_id"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ] + } + ) def test_get_http_error(): diff --git a/tests/test_tutorial/test_header_params/test_tutorial001.py b/tests/test_tutorial/test_header_params/test_tutorial001.py index 80f502d6a..3e5957130 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial001.py +++ b/tests/test_tutorial/test_header_params/test_tutorial001.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.header_params.tutorial001 import app @@ -20,7 +21,7 @@ def test(path, headers, expected_status, expected_response): assert response.json() == expected_response -def test_openapi(): +def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { @@ -50,7 +51,16 @@ def test_openapi(): "parameters": [ { "required": False, - "schema": {"title": "User-Agent", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User-Agent", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User-Agent", "type": "string"} + ), "name": "user-agent", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial001_an.py b/tests/test_tutorial/test_header_params/test_tutorial001_an.py index f0ad7b816..f0a13e14a 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial001_an.py +++ b/tests/test_tutorial/test_header_params/test_tutorial001_an.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.header_params.tutorial001_an import app @@ -50,7 +51,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "User-Agent", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User-Agent", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User-Agent", "type": "string"} + ), "name": "user-agent", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial001_an_py310.py b/tests/test_tutorial/test_header_params/test_tutorial001_an_py310.py index d095c7123..9e6691ac6 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial001_an_py310.py +++ b/tests/test_tutorial/test_header_params/test_tutorial001_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -58,7 +59,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "User-Agent", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User-Agent", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User-Agent", "type": "string"} + ), "name": "user-agent", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial001_py310.py b/tests/test_tutorial/test_header_params/test_tutorial001_py310.py index bf176bba2..4e3fd14a8 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial001_py310.py +++ b/tests/test_tutorial/test_header_params/test_tutorial001_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -58,7 +59,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "User-Agent", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "User-Agent", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "User-Agent", "type": "string"} + ), "name": "user-agent", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial002.py b/tests/test_tutorial/test_header_params/test_tutorial002.py index 516abda8b..f3db3205e 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial002.py +++ b/tests/test_tutorial/test_header_params/test_tutorial002.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.header_params.tutorial002 import app @@ -61,7 +62,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Strange Header", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Strange Header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Strange Header", "type": "string"} + ), "name": "strange_header", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial002_an.py b/tests/test_tutorial/test_header_params/test_tutorial002_an.py index 97493e604..5da3d83ae 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial002_an.py +++ b/tests/test_tutorial/test_header_params/test_tutorial002_an.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.header_params.tutorial002_an import app @@ -61,7 +62,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Strange Header", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Strange Header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Strange Header", "type": "string"} + ), "name": "strange_header", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial002_an_py310.py b/tests/test_tutorial/test_header_params/test_tutorial002_an_py310.py index e0c60342a..77dd5e30a 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial002_an_py310.py +++ b/tests/test_tutorial/test_header_params/test_tutorial002_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -69,7 +70,16 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": {"title": "Strange Header", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Strange Header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Strange Header", "type": "string"} + ), "name": "strange_header", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial002_an_py39.py b/tests/test_tutorial/test_header_params/test_tutorial002_an_py39.py index c1bc5faf8..ba997bee3 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial002_an_py39.py +++ b/tests/test_tutorial/test_header_params/test_tutorial002_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -72,7 +73,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Strange Header", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Strange Header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Strange Header", "type": "string"} + ), "name": "strange_header", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial002_py310.py b/tests/test_tutorial/test_header_params/test_tutorial002_py310.py index 81871b8c6..b9f233e00 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial002_py310.py +++ b/tests/test_tutorial/test_header_params/test_tutorial002_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -72,7 +73,16 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": {"title": "Strange Header", "type": "string"}, + "schema": IsDict( + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Strange Header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Strange Header", "type": "string"} + ), "name": "strange_header", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial003.py b/tests/test_tutorial/test_header_params/test_tutorial003.py index 99dd9e25f..1eb22120e 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial003.py +++ b/tests/test_tutorial/test_header_params/test_tutorial003.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.header_params.tutorial003 import app @@ -24,7 +25,6 @@ def test(path, headers, expected_status, expected_response): def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200 - # insert_assert(response.json()) assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, @@ -36,11 +36,23 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": { - "title": "X-Token", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "title": "X-Token", + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "X-Token", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "x-token", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial003_an.py b/tests/test_tutorial/test_header_params/test_tutorial003_an.py index 4477da7a8..725fb6dd2 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial003_an.py +++ b/tests/test_tutorial/test_header_params/test_tutorial003_an.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.header_params.tutorial003_an import app @@ -24,7 +25,6 @@ def test(path, headers, expected_status, expected_response): def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200 - # insert_assert(response.json()) assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, @@ -36,11 +36,23 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": { - "title": "X-Token", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "title": "X-Token", + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "X-Token", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "x-token", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial003_an_py310.py b/tests/test_tutorial/test_header_params/test_tutorial003_an_py310.py index b52304a2b..ab3ec5db9 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial003_an_py310.py +++ b/tests/test_tutorial/test_header_params/test_tutorial003_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -32,7 +33,6 @@ def test(path, headers, expected_status, expected_response, client: TestClient): def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 - # insert_assert(response.json()) assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, @@ -44,11 +44,23 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": { - "title": "X-Token", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "title": "X-Token", + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "X-Token", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "x-token", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial003_an_py39.py b/tests/test_tutorial/test_header_params/test_tutorial003_an_py39.py index dffdd1622..48d24ece9 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial003_an_py39.py +++ b/tests/test_tutorial/test_header_params/test_tutorial003_an_py39.py @@ -1,7 +1,8 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient -from ...utils import needs_py310 +from ...utils import needs_py39 @pytest.fixture(name="client") @@ -12,7 +13,7 @@ def get_client(): return client -@needs_py310 +@needs_py39 @pytest.mark.parametrize( "path,headers,expected_status,expected_response", [ @@ -28,11 +29,10 @@ def test(path, headers, expected_status, expected_response, client: TestClient): assert response.json() == expected_response -@needs_py310 +@needs_py39 def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 - # insert_assert(response.json()) assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, @@ -44,11 +44,23 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": { - "title": "X-Token", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "title": "X-Token", + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "X-Token", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "x-token", "in": "header", } diff --git a/tests/test_tutorial/test_header_params/test_tutorial003_py310.py b/tests/test_tutorial/test_header_params/test_tutorial003_py310.py index 64ef7b22a..c7af2b717 100644 --- a/tests/test_tutorial/test_header_params/test_tutorial003_py310.py +++ b/tests/test_tutorial/test_header_params/test_tutorial003_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -32,7 +33,6 @@ def test(path, headers, expected_status, expected_response, client: TestClient): def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 - # insert_assert(response.json()) assert response.json() == { "openapi": "3.0.2", "info": {"title": "FastAPI", "version": "0.1.0"}, @@ -44,11 +44,23 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": { - "title": "X-Token", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "title": "X-Token", + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "X-Token", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "x-token", "in": "header", } diff --git a/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py b/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py index c6cdc6064..3e3c31c5b 100644 --- a/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py +++ b/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.openapi_callbacks.tutorial001 import app, invoice_notification @@ -33,13 +34,30 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": { - "title": "Callback Url", - "maxLength": 2083, - "minLength": 1, - "type": "string", - "format": "uri", - }, + "schema": IsDict( + { + "anyOf": [ + { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2083, + }, + {"type": "null"}, + ], + "title": "Callback Url", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Callback Url", + "maxLength": 2083, + "minLength": 1, + "type": "string", + "format": "uri", + } + ), "name": "callback_url", "in": "query", } @@ -132,7 +150,16 @@ def test_openapi_schema(): "type": "object", "properties": { "id": {"title": "Id", "type": "string"}, - "title": {"title": "Title", "type": "string"}, + "title": IsDict( + { + "title": "Title", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Title", "type": "string"} + ), "customer": {"title": "Customer", "type": "string"}, "total": {"title": "Total", "type": "number"}, }, diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py index f5fd868eb..2d81ffe1f 100644 --- a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.path_operation_advanced_configuration.tutorial004 import app @@ -68,9 +69,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), "tags": { "title": "Tags", "uniqueItems": True, diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py index 470956a77..1a6d6a1bd 100644 --- a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py @@ -1,11 +1,20 @@ +import pytest from fastapi.testclient import TestClient -from docs_src.path_operation_advanced_configuration.tutorial007 import app +from ...utils import needs_pydanticv1 -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.path_operation_advanced_configuration.tutorial007 import app -def test_post(): + client = TestClient(app) + return client + + +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 +def test_post(client: TestClient): yaml_data = """ name: Deadpoolio tags: @@ -21,7 +30,9 @@ def test_post(): } -def test_post_broken_yaml(): +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 +def test_post_broken_yaml(client: TestClient): yaml_data = """ name: Deadpoolio tags: @@ -34,7 +45,9 @@ def test_post_broken_yaml(): assert response.json() == {"detail": "Invalid YAML"} -def test_post_invalid(): +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 +def test_post_invalid(client: TestClient): yaml_data = """ name: Deadpoolio tags: @@ -52,7 +65,9 @@ def test_post_invalid(): } -def test_openapi_schema(): +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_path_operation_configurations/test_tutorial005.py b/tests/test_tutorial/test_path_operation_configurations/test_tutorial005.py index cf8e203a0..c2b07f0a0 100644 --- a/tests/test_tutorial/test_path_operation_configurations/test_tutorial005.py +++ b/tests/test_tutorial/test_path_operation_configurations/test_tutorial005.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.path_operation_configuration.tutorial005 import app @@ -68,9 +69,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), "tags": { "title": "Tags", "uniqueItems": True, diff --git a/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py310.py b/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py310.py index 497fc9024..c5fa697d0 100644 --- a/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py310.py +++ b/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -77,9 +78,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), "tags": { "title": "Tags", "uniqueItems": True, diff --git a/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py39.py b/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py39.py index 09fac44c4..1633882c6 100644 --- a/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py39.py +++ b/tests/test_tutorial/test_path_operation_configurations/test_tutorial005_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -77,9 +78,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), "tags": { "title": "Tags", "uniqueItems": True, diff --git a/tests/test_tutorial/test_path_params/test_tutorial005.py b/tests/test_tutorial/test_path_params/test_tutorial005.py index 3401f2253..b2c3dcf69 100644 --- a/tests/test_tutorial/test_path_params/test_tutorial005.py +++ b/tests/test_tutorial/test_path_params/test_tutorial005.py @@ -1,4 +1,4 @@ -import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.path_params.tutorial005 import app @@ -6,47 +6,55 @@ from docs_src.path_params.tutorial005 import app client = TestClient(app) -@pytest.mark.parametrize( - "url,status_code,expected", - [ - ( - "/models/alexnet", - 200, - {"model_name": "alexnet", "message": "Deep Learning FTW!"}, - ), - ( - "/models/lenet", - 200, - {"model_name": "lenet", "message": "LeCNN all the images"}, - ), - ( - "/models/resnet", - 200, - {"model_name": "resnet", "message": "Have some residuals"}, - ), - ( - "/models/foo", - 422, - { - "detail": [ - { - "ctx": {"enum_values": ["alexnet", "resnet", "lenet"]}, - "loc": ["path", "model_name"], - "msg": "value is not a valid enumeration member; permitted: 'alexnet', 'resnet', 'lenet'", - "type": "type_error.enum", - } - ] - }, - ), - ], -) -def test_get_enums(url, status_code, expected): - response = client.get(url) - assert response.status_code == status_code - assert response.json() == expected +def test_get_enums_alexnet(): + response = client.get("/models/alexnet") + assert response.status_code == 200 + assert response.json() == {"model_name": "alexnet", "message": "Deep Learning FTW!"} + + +def test_get_enums_lenet(): + response = client.get("/models/lenet") + assert response.status_code == 200 + assert response.json() == {"model_name": "lenet", "message": "LeCNN all the images"} -def test_openapi(): +def test_get_enums_resnet(): + response = client.get("/models/resnet") + assert response.status_code == 200 + assert response.json() == {"model_name": "resnet", "message": "Have some residuals"} + + +def test_get_enums_invalid(): + response = client.get("/models/foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "enum", + "loc": ["path", "model_name"], + "msg": "Input should be 'alexnet','resnet' or 'lenet'", + "input": "foo", + "ctx": {"expected": "'alexnet','resnet' or 'lenet'"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"enum_values": ["alexnet", "resnet", "lenet"]}, + "loc": ["path", "model_name"], + "msg": "value is not a valid enumeration member; permitted: 'alexnet', 'resnet', 'lenet'", + "type": "type_error.enum", + } + ] + } + ) + + +def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200, response.text data = response.json() @@ -98,12 +106,22 @@ def test_openapi(): } }, }, - "ModelName": { - "title": "ModelName", - "enum": ["alexnet", "resnet", "lenet"], - "type": "string", - "description": "An enumeration.", - }, + "ModelName": IsDict( + { + "title": "ModelName", + "enum": ["alexnet", "resnet", "lenet"], + "type": "string", + } + ) + | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "title": "ModelName", + "enum": ["alexnet", "resnet", "lenet"], + "type": "string", + "description": "An enumeration.", + } + ), "ValidationError": { "title": "ValidationError", "required": ["loc", "msg", "type"], diff --git a/tests/test_tutorial/test_query_params/test_tutorial005.py b/tests/test_tutorial/test_query_params/test_tutorial005.py index 3c408449b..04f5c0451 100644 --- a/tests/test_tutorial/test_query_params/test_tutorial005.py +++ b/tests/test_tutorial/test_query_params/test_tutorial005.py @@ -1,34 +1,45 @@ -import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.query_params.tutorial005 import app client = TestClient(app) -query_required = { - "detail": [ - { - "loc": ["query", "needy"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} +def test_foo_needy_very(): + response = client.get("/items/foo?needy=very") + assert response.status_code == 200 + assert response.json() == {"item_id": "foo", "needy": "very"} -@pytest.mark.parametrize( - "path,expected_status,expected_response", - [ - ("/items/foo?needy=very", 200, {"item_id": "foo", "needy": "very"}), - ("/items/foo", 422, query_required), - ("/items/foo", 422, query_required), - ], -) -def test(path, expected_status, expected_response): - response = client.get(path) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_foo_no_needy(): + response = client.get("/items/foo") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "needy"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "needy"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_openapi_schema(): diff --git a/tests/test_tutorial/test_query_params/test_tutorial006.py b/tests/test_tutorial/test_query_params/test_tutorial006.py index 7fe58a990..e479e6eb4 100644 --- a/tests/test_tutorial/test_query_params/test_tutorial006.py +++ b/tests/test_tutorial/test_query_params/test_tutorial006.py @@ -1,62 +1,82 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.query_params.tutorial006 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.query_params.tutorial006 import app + c = TestClient(app) + return c -query_required = { - "detail": [ - { - "loc": ["query", "needy"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} + +def test_foo_needy_very(client: TestClient): + response = client.get("/items/foo?needy=very") + assert response.status_code == 200 + assert response.json() == { + "item_id": "foo", + "needy": "very", + "skip": 0, + "limit": None, + } -@pytest.mark.parametrize( - "path,expected_status,expected_response", - [ - ( - "/items/foo?needy=very", - 200, - {"item_id": "foo", "needy": "very", "skip": 0, "limit": None}, - ), - ( - "/items/foo?skip=a&limit=b", - 422, - { - "detail": [ - { - "loc": ["query", "needy"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["query", "skip"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - { - "loc": ["query", "limit"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - ] - }, - ), - ], -) -def test(path, expected_status, expected_response): - response = client.get(path) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_foo_no_needy(client: TestClient): + response = client.get("/items/foo?skip=a&limit=b") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "needy"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "int_parsing", + "loc": ["query", "skip"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "a", + "url": match_pydantic_error_url("int_parsing"), + }, + { + "type": "int_parsing", + "loc": ["query", "limit"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "b", + "url": match_pydantic_error_url("int_parsing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "needy"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["query", "skip"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + }, + { + "loc": ["query", "limit"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + }, + ] + } + ) -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { @@ -108,7 +128,16 @@ def test_openapi_schema(): }, { "required": False, - "schema": {"title": "Limit", "type": "integer"}, + "schema": IsDict( + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Limit", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Limit", "type": "integer"} + ), "name": "limit", "in": "query", }, diff --git a/tests/test_tutorial/test_query_params/test_tutorial006_py310.py b/tests/test_tutorial/test_query_params/test_tutorial006_py310.py index b90c0a6c8..02b70f06d 100644 --- a/tests/test_tutorial/test_query_params/test_tutorial006_py310.py +++ b/tests/test_tutorial/test_query_params/test_tutorial006_py310.py @@ -1,18 +1,10 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 -query_required = { - "detail": [ - { - "loc": ["query", "needy"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} - @pytest.fixture(name="client") def get_client(): @@ -23,43 +15,69 @@ def get_client(): @needs_py310 -@pytest.mark.parametrize( - "path,expected_status,expected_response", - [ - ( - "/items/foo?needy=very", - 200, - {"item_id": "foo", "needy": "very", "skip": 0, "limit": None}, - ), - ( - "/items/foo?skip=a&limit=b", - 422, - { - "detail": [ - { - "loc": ["query", "needy"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["query", "skip"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - { - "loc": ["query", "limit"], - "msg": "value is not a valid integer", - "type": "type_error.integer", - }, - ] - }, - ), - ], -) -def test(path, expected_status, expected_response, client: TestClient): - response = client.get(path) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_foo_needy_very(client: TestClient): + response = client.get("/items/foo?needy=very") + assert response.status_code == 200 + assert response.json() == { + "item_id": "foo", + "needy": "very", + "skip": 0, + "limit": None, + } + + +@needs_py310 +def test_foo_no_needy(client: TestClient): + response = client.get("/items/foo?skip=a&limit=b") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "needy"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "int_parsing", + "loc": ["query", "skip"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "a", + "url": match_pydantic_error_url("int_parsing"), + }, + { + "type": "int_parsing", + "loc": ["query", "limit"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "input": "b", + "url": match_pydantic_error_url("int_parsing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "needy"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["query", "skip"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + }, + { + "loc": ["query", "limit"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + }, + ] + } + ) @needs_py310 @@ -115,7 +133,16 @@ def test_openapi_schema(client: TestClient): }, { "required": False, - "schema": {"title": "Limit", "type": "integer"}, + "schema": IsDict( + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Limit", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Limit", "type": "integer"} + ), "name": "limit", "in": "query", }, diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010.py index c41f554ba..949ff857d 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010.py @@ -1,47 +1,70 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.query_params_str_validations.tutorial010 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.query_params_str_validations.tutorial010 import app + client = TestClient(app) + return client -regex_error = { - "detail": [ - { - "ctx": {"pattern": "^fixedquery$"}, - "loc": ["query", "item-query"], - "msg": 'string does not match regex "^fixedquery$"', - "type": "value_error.str.regex", - } - ] -} + +def test_query_params_str_validations_no_query(client: TestClient): + response = client.get("/items/") + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} + + +def test_query_params_str_validations_item_query_fixedquery(client: TestClient): + response = client.get("/items/", params={"item-query": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == { + "items": [{"item_id": "Foo"}, {"item_id": "Bar"}], + "q": "fixedquery", + } + + +def test_query_params_str_validations_q_fixedquery(client: TestClient): + response = client.get("/items/", params={"q": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} -@pytest.mark.parametrize( - "q_name,q,expected_status,expected_response", - [ - (None, None, 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ( - "item-query", - "fixedquery", - 200, - {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}], "q": "fixedquery"}, - ), - ("q", "fixedquery", 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ("item-query", "nonregexquery", 422, regex_error), - ], -) -def test_query_params_str_validations(q_name, q, expected_status, expected_response): - url = "/items/" - if q_name and q: - url = f"{url}?{q_name}={q}" - response = client.get(url) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_query_params_str_validations_item_query_nonregexquery(client: TestClient): + response = client.get("/items/", params={"item-query": "nonregexquery"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["query", "item-query"], + "msg": "String should match pattern '^fixedquery$'", + "input": "nonregexquery", + "ctx": {"pattern": "^fixedquery$"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"pattern": "^fixedquery$"}, + "loc": ["query", "item-query"], + "msg": 'string does not match regex "^fixedquery$"', + "type": "value_error.str.regex", + } + ] + } + ) -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -73,14 +96,31 @@ def test_openapi_schema(): "description": "Query string for the items to search in the database that have a good match", "required": False, "deprecated": True, - "schema": { - "title": "Query string", - "maxLength": 50, - "minLength": 3, - "pattern": "^fixedquery$", - "type": "string", - "description": "Query string for the items to search in the database that have a good match", - }, + "schema": IsDict( + { + "anyOf": [ + { + "type": "string", + "minLength": 3, + "maxLength": 50, + "pattern": "^fixedquery$", + }, + {"type": "null"}, + ], + "title": "Query string", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Query string", + "maxLength": 50, + "minLength": 3, + "pattern": "^fixedquery$", + "type": "string", + "description": "Query string for the items to search in the database that have a good match", + } + ), "name": "item-query", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an.py index dc8028f81..666064455 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an.py @@ -1,47 +1,72 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.query_params_str_validations.tutorial010_an import app +# TODO: pv2 docs note to mention before it was regex instead of pattern -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.query_params_str_validations.tutorial010_an import app + + client = TestClient(app) + return client + + +def test_query_params_str_validations_no_query(client: TestClient): + response = client.get("/items/") + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} -regex_error = { - "detail": [ - { - "ctx": {"pattern": "^fixedquery$"}, - "loc": ["query", "item-query"], - "msg": 'string does not match regex "^fixedquery$"', - "type": "value_error.str.regex", - } - ] -} + +def test_query_params_str_validations_item_query_fixedquery(client: TestClient): + response = client.get("/items/", params={"item-query": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == { + "items": [{"item_id": "Foo"}, {"item_id": "Bar"}], + "q": "fixedquery", + } -@pytest.mark.parametrize( - "q_name,q,expected_status,expected_response", - [ - (None, None, 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ( - "item-query", - "fixedquery", - 200, - {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}], "q": "fixedquery"}, - ), - ("q", "fixedquery", 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ("item-query", "nonregexquery", 422, regex_error), - ], -) -def test_query_params_str_validations(q_name, q, expected_status, expected_response): - url = "/items/" - if q_name and q: - url = f"{url}?{q_name}={q}" - response = client.get(url) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_query_params_str_validations_q_fixedquery(client: TestClient): + response = client.get("/items/", params={"q": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} -def test_openapi_schema(): +def test_query_params_str_validations_item_query_nonregexquery(client: TestClient): + response = client.get("/items/", params={"item-query": "nonregexquery"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["query", "item-query"], + "msg": "String should match pattern '^fixedquery$'", + "input": "nonregexquery", + "ctx": {"pattern": "^fixedquery$"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"pattern": "^fixedquery$"}, + "loc": ["query", "item-query"], + "msg": 'string does not match regex "^fixedquery$"', + "type": "value_error.str.regex", + } + ] + } + ) + + +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -73,14 +98,31 @@ def test_openapi_schema(): "description": "Query string for the items to search in the database that have a good match", "required": False, "deprecated": True, - "schema": { - "title": "Query string", - "maxLength": 50, - "minLength": 3, - "pattern": "^fixedquery$", - "type": "string", - "description": "Query string for the items to search in the database that have a good match", - }, + "schema": IsDict( + { + "anyOf": [ + { + "type": "string", + "minLength": 3, + "maxLength": 50, + "pattern": "^fixedquery$", + }, + {"type": "null"}, + ], + "title": "Query string", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Query string", + "maxLength": 50, + "minLength": 3, + "pattern": "^fixedquery$", + "type": "string", + "description": "Query string for the items to search in the database that have a good match", + } + ), "name": "item-query", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py310.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py310.py index 496b95b79..3b5134274 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py310.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,42 +14,60 @@ def get_client(): return client -regex_error = { - "detail": [ - { - "ctx": {"pattern": "^fixedquery$"}, - "loc": ["query", "item-query"], - "msg": 'string does not match regex "^fixedquery$"', - "type": "value_error.str.regex", - } - ] -} +@needs_py310 +def test_query_params_str_validations_no_query(client: TestClient): + response = client.get("/items/") + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} + + +@needs_py310 +def test_query_params_str_validations_item_query_fixedquery(client: TestClient): + response = client.get("/items/", params={"item-query": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == { + "items": [{"item_id": "Foo"}, {"item_id": "Bar"}], + "q": "fixedquery", + } + + +@needs_py310 +def test_query_params_str_validations_q_fixedquery(client: TestClient): + response = client.get("/items/", params={"q": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} @needs_py310 -@pytest.mark.parametrize( - "q_name,q,expected_status,expected_response", - [ - (None, None, 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ( - "item-query", - "fixedquery", - 200, - {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}], "q": "fixedquery"}, - ), - ("q", "fixedquery", 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ("item-query", "nonregexquery", 422, regex_error), - ], -) -def test_query_params_str_validations( - q_name, q, expected_status, expected_response, client: TestClient -): - url = "/items/" - if q_name and q: - url = f"{url}?{q_name}={q}" - response = client.get(url) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_query_params_str_validations_item_query_nonregexquery(client: TestClient): + response = client.get("/items/", params={"item-query": "nonregexquery"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["query", "item-query"], + "msg": "String should match pattern '^fixedquery$'", + "input": "nonregexquery", + "ctx": {"pattern": "^fixedquery$"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"pattern": "^fixedquery$"}, + "loc": ["query", "item-query"], + "msg": 'string does not match regex "^fixedquery$"', + "type": "value_error.str.regex", + } + ] + } + ) @needs_py310 @@ -83,14 +103,31 @@ def test_openapi_schema(client: TestClient): "description": "Query string for the items to search in the database that have a good match", "required": False, "deprecated": True, - "schema": { - "title": "Query string", - "maxLength": 50, - "minLength": 3, - "pattern": "^fixedquery$", - "type": "string", - "description": "Query string for the items to search in the database that have a good match", - }, + "schema": IsDict( + { + "anyOf": [ + { + "type": "string", + "minLength": 3, + "maxLength": 50, + "pattern": "^fixedquery$", + }, + {"type": "null"}, + ], + "title": "Query string", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Query string", + "maxLength": 50, + "minLength": 3, + "pattern": "^fixedquery$", + "type": "string", + "description": "Query string for the items to search in the database that have a good match", + } + ), "name": "item-query", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py39.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py39.py index 2005e5043..92e334f7f 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py39.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -12,42 +14,60 @@ def get_client(): return client -regex_error = { - "detail": [ - { - "ctx": {"pattern": "^fixedquery$"}, - "loc": ["query", "item-query"], - "msg": 'string does not match regex "^fixedquery$"', - "type": "value_error.str.regex", - } - ] -} +@needs_py39 +def test_query_params_str_validations_no_query(client: TestClient): + response = client.get("/items/") + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} + + +@needs_py39 +def test_query_params_str_validations_item_query_fixedquery(client: TestClient): + response = client.get("/items/", params={"item-query": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == { + "items": [{"item_id": "Foo"}, {"item_id": "Bar"}], + "q": "fixedquery", + } + + +@needs_py39 +def test_query_params_str_validations_q_fixedquery(client: TestClient): + response = client.get("/items/", params={"q": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} @needs_py39 -@pytest.mark.parametrize( - "q_name,q,expected_status,expected_response", - [ - (None, None, 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ( - "item-query", - "fixedquery", - 200, - {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}], "q": "fixedquery"}, - ), - ("q", "fixedquery", 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ("item-query", "nonregexquery", 422, regex_error), - ], -) -def test_query_params_str_validations( - q_name, q, expected_status, expected_response, client: TestClient -): - url = "/items/" - if q_name and q: - url = f"{url}?{q_name}={q}" - response = client.get(url) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_query_params_str_validations_item_query_nonregexquery(client: TestClient): + response = client.get("/items/", params={"item-query": "nonregexquery"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["query", "item-query"], + "msg": "String should match pattern '^fixedquery$'", + "input": "nonregexquery", + "ctx": {"pattern": "^fixedquery$"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"pattern": "^fixedquery$"}, + "loc": ["query", "item-query"], + "msg": 'string does not match regex "^fixedquery$"', + "type": "value_error.str.regex", + } + ] + } + ) @needs_py39 @@ -83,14 +103,31 @@ def test_openapi_schema(client: TestClient): "description": "Query string for the items to search in the database that have a good match", "required": False, "deprecated": True, - "schema": { - "title": "Query string", - "maxLength": 50, - "minLength": 3, - "pattern": "^fixedquery$", - "type": "string", - "description": "Query string for the items to search in the database that have a good match", - }, + "schema": IsDict( + { + "anyOf": [ + { + "type": "string", + "minLength": 3, + "maxLength": 50, + "pattern": "^fixedquery$", + }, + {"type": "null"}, + ], + "title": "Query string", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Query string", + "maxLength": 50, + "minLength": 3, + "pattern": "^fixedquery$", + "type": "string", + "description": "Query string for the items to search in the database that have a good match", + } + ), "name": "item-query", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_py310.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_py310.py index 8147d768e..4a99ffad8 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_py310.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial010_py310.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py310 @@ -12,42 +14,60 @@ def get_client(): return client -regex_error = { - "detail": [ - { - "ctx": {"pattern": "^fixedquery$"}, - "loc": ["query", "item-query"], - "msg": 'string does not match regex "^fixedquery$"', - "type": "value_error.str.regex", - } - ] -} +@needs_py310 +def test_query_params_str_validations_no_query(client: TestClient): + response = client.get("/items/") + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} + + +@needs_py310 +def test_query_params_str_validations_item_query_fixedquery(client: TestClient): + response = client.get("/items/", params={"item-query": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == { + "items": [{"item_id": "Foo"}, {"item_id": "Bar"}], + "q": "fixedquery", + } + + +@needs_py310 +def test_query_params_str_validations_q_fixedquery(client: TestClient): + response = client.get("/items/", params={"q": "fixedquery"}) + assert response.status_code == 200 + assert response.json() == {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} @needs_py310 -@pytest.mark.parametrize( - "q_name,q,expected_status,expected_response", - [ - (None, None, 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ( - "item-query", - "fixedquery", - 200, - {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}], "q": "fixedquery"}, - ), - ("q", "fixedquery", 200, {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}), - ("item-query", "nonregexquery", 422, regex_error), - ], -) -def test_query_params_str_validations( - q_name, q, expected_status, expected_response, client: TestClient -): - url = "/items/" - if q_name and q: - url = f"{url}?{q_name}={q}" - response = client.get(url) - assert response.status_code == expected_status - assert response.json() == expected_response +def test_query_params_str_validations_item_query_nonregexquery(client: TestClient): + response = client.get("/items/", params={"item-query": "nonregexquery"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "string_pattern_mismatch", + "loc": ["query", "item-query"], + "msg": "String should match pattern '^fixedquery$'", + "input": "nonregexquery", + "ctx": {"pattern": "^fixedquery$"}, + "url": match_pydantic_error_url("string_pattern_mismatch"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "ctx": {"pattern": "^fixedquery$"}, + "loc": ["query", "item-query"], + "msg": 'string does not match regex "^fixedquery$"', + "type": "value_error.str.regex", + } + ] + } + ) @needs_py310 @@ -83,14 +103,31 @@ def test_openapi_schema(client: TestClient): "description": "Query string for the items to search in the database that have a good match", "required": False, "deprecated": True, - "schema": { - "title": "Query string", - "maxLength": 50, - "minLength": 3, - "pattern": "^fixedquery$", - "type": "string", - "description": "Query string for the items to search in the database that have a good match", - }, + "schema": IsDict( + { + "anyOf": [ + { + "type": "string", + "minLength": 3, + "maxLength": 50, + "pattern": "^fixedquery$", + }, + {"type": "null"}, + ], + "title": "Query string", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Query string", + "maxLength": 50, + "minLength": 3, + "pattern": "^fixedquery$", + "type": "string", + "description": "Query string for the items to search in the database that have a good match", + } + ), "name": "item-query", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011.py index d6d69c169..39eccf8c8 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.query_params_str_validations.tutorial011 import app @@ -49,11 +50,23 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": { - "title": "Q", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Q", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "q", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an.py index 3a53d422e..12fac738f 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.query_params_str_validations.tutorial011_an import app @@ -49,11 +50,23 @@ def test_openapi_schema(): "parameters": [ { "required": False, - "schema": { - "title": "Q", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Q", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "q", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py310.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py310.py index f00df2879..afbf07b81 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py310.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -59,11 +60,23 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": { - "title": "Q", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Q", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "q", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py39.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py39.py index 895fb8e9f..f92c99410 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py39.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -59,11 +60,23 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": { - "title": "Q", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Q", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "q", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py310.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py310.py index 4f4b1fd55..f0a02215d 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py310.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -59,11 +60,23 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": { - "title": "Q", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Q", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "q", "in": "query", } diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py39.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py39.py index d85bb6231..2006d3706 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py39.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial011_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -59,11 +60,23 @@ def test_openapi_schema(client: TestClient): "parameters": [ { "required": False, - "schema": { - "title": "Q", - "type": "array", - "items": {"type": "string"}, - }, + "schema": IsDict( + { + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "null"}, + ], + "title": "Q", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Q", + "type": "array", + "items": {"type": "string"}, + } + ), "name": "q", "in": "query", } diff --git a/tests/test_tutorial/test_request_files/test_tutorial001.py b/tests/test_tutorial/test_request_files/test_tutorial001.py index 3269801ef..396873711 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001.py @@ -1,4 +1,6 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.request_files.tutorial001 import app @@ -19,13 +21,59 @@ file_required = { def test_post_form_no_body(): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_body_json(): response = client.post("/files/", json={"file": "Foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_file(tmp_path): diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02.py b/tests/test_tutorial/test_request_files/test_tutorial001_02.py index 4b6edfa06..01913979a 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001_02.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.request_files.tutorial001_02 import app @@ -53,9 +54,22 @@ def test_openapi_schema(): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_file_files__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ) } } }, @@ -84,9 +98,22 @@ def test_openapi_schema(): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ) } } }, @@ -115,14 +142,38 @@ def test_openapi_schema(): "title": "Body_create_file_files__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "Body_create_upload_file_uploadfile__post": { "title": "Body_create_upload_file_uploadfile__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "HTTPValidationError": { diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02_an.py b/tests/test_tutorial/test_request_files/test_tutorial001_02_an.py index 0c34620e3..02075476f 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001_02_an.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02_an.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.request_files.tutorial001_02_an import app @@ -53,9 +54,22 @@ def test_openapi_schema(): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_file_files__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ) } } }, @@ -84,9 +98,22 @@ def test_openapi_schema(): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ) } } }, @@ -115,14 +142,38 @@ def test_openapi_schema(): "title": "Body_create_file_files__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "Body_create_upload_file_uploadfile__post": { "title": "Body_create_upload_file_uploadfile__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "HTTPValidationError": { diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py310.py b/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py310.py index 04442c76f..31fe3ae0f 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py310.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py310.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -65,9 +66,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_file_files__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ) } } }, @@ -96,9 +110,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ) } } }, @@ -127,14 +154,38 @@ def test_openapi_schema(client: TestClient): "title": "Body_create_file_files__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "Body_create_upload_file_uploadfile__post": { "title": "Body_create_upload_file_uploadfile__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "HTTPValidationError": { diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py39.py b/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py39.py index f5249ef5b..d201848be 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py39.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02_an_py39.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -65,9 +66,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_file_files__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ) } } }, @@ -96,9 +110,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ) } } }, @@ -127,14 +154,38 @@ def test_openapi_schema(client: TestClient): "title": "Body_create_file_files__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "Body_create_upload_file_uploadfile__post": { "title": "Body_create_upload_file_uploadfile__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "HTTPValidationError": { diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py b/tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py index f690d107b..faded40b7 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001_02_py310.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -65,9 +66,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_file_files__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_file_files__post" + } + ) } } }, @@ -96,9 +110,22 @@ def test_openapi_schema(client: TestClient): "requestBody": { "content": { "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" - } + "schema": IsDict( + { + "allOf": [ + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ], + "title": "Body", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "$ref": "#/components/schemas/Body_create_upload_file_uploadfile__post" + } + ) } } }, @@ -127,14 +154,38 @@ def test_openapi_schema(client: TestClient): "title": "Body_create_file_files__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "Body_create_upload_file_uploadfile__post": { "title": "Body_create_upload_file_uploadfile__post", "type": "object", "properties": { - "file": {"title": "File", "type": "string", "format": "binary"} + "file": IsDict( + { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "File", "type": "string", "format": "binary"} + ) }, }, "HTTPValidationError": { diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_an.py b/tests/test_tutorial/test_request_files/test_tutorial001_an.py index 80c288ed6..5859a2bce 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001_an.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001_an.py @@ -1,31 +1,68 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.request_files.tutorial001_an import app client = TestClient(app) -file_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} - - def test_post_form_no_body(): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_body_json(): response = client.post("/files/", json={"file": "Foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_file(tmp_path): diff --git a/tests/test_tutorial/test_request_files/test_tutorial001_an_py39.py b/tests/test_tutorial/test_request_files/test_tutorial001_an_py39.py index 4dc1f752c..52ba0850f 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_request_files/test_tutorial001_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -12,29 +14,64 @@ def get_client(): return client -file_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} - - @needs_py39 def test_post_form_no_body(client: TestClient): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py39 def test_post_body_json(client: TestClient): response = client.post("/files/", json={"file": "Foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_request_files/test_tutorial002.py b/tests/test_tutorial/test_request_files/test_tutorial002.py index 6868be328..c625ccac8 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial002.py +++ b/tests/test_tutorial/test_request_files/test_tutorial002.py @@ -1,31 +1,68 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.request_files.tutorial002 import app client = TestClient(app) -file_required = { - "detail": [ - { - "loc": ["body", "files"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} - - def test_post_form_no_body(): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_body_json(): response = client.post("/files/", json={"file": "Foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_files(tmp_path): diff --git a/tests/test_tutorial/test_request_files/test_tutorial002_an.py b/tests/test_tutorial/test_request_files/test_tutorial002_an.py index ca1f62ea3..2d884c593 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial002_an.py +++ b/tests/test_tutorial/test_request_files/test_tutorial002_an.py @@ -1,31 +1,68 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from docs_src.request_files.tutorial002_an import app client = TestClient(app) -file_required = { - "detail": [ - { - "loc": ["body", "files"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} - - def test_post_form_no_body(): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_body_json(): response = client.post("/files/", json={"file": "Foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) def test_post_files(tmp_path): diff --git a/tests/test_tutorial/test_request_files/test_tutorial002_an_py39.py b/tests/test_tutorial/test_request_files/test_tutorial002_an_py39.py index e593ae75d..49a6dee51 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial002_an_py39.py +++ b/tests/test_tutorial/test_request_files/test_tutorial002_an_py39.py @@ -1,6 +1,8 @@ import pytest +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -18,29 +20,64 @@ def get_client(app: FastAPI): return client -file_required = { - "detail": [ - { - "loc": ["body", "files"], - "msg": "field required", - "type": "value_error.missing", - } - ] -} - - @needs_py39 def test_post_form_no_body(client: TestClient): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py39 def test_post_body_json(client: TestClient): response = client.post("/files/", json={"file": "Foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_request_files/test_tutorial002_py39.py b/tests/test_tutorial/test_request_files/test_tutorial002_py39.py index bacd25b97..1ed552ee0 100644 --- a/tests/test_tutorial/test_request_files/test_tutorial002_py39.py +++ b/tests/test_tutorial/test_request_files/test_tutorial002_py39.py @@ -1,6 +1,8 @@ import pytest +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -33,14 +35,60 @@ file_required = { def test_post_form_no_body(client: TestClient): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py39 def test_post_body_json(client: TestClient): response = client.post("/files/", json={"file": "Foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "files"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_request_forms/test_tutorial001.py b/tests/test_tutorial/test_request_forms/test_tutorial001.py index 721c97fb1..64956abcb 100644 --- a/tests/test_tutorial/test_request_forms/test_tutorial001.py +++ b/tests/test_tutorial/test_request_forms/test_tutorial001.py @@ -1,72 +1,164 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.request_forms.tutorial001 import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_forms.tutorial001 import app + client = TestClient(app) + return client -password_required = { - "detail": [ + +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"} + + +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( { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] } - ] -} -username_required = { - "detail": [ + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] } - ] -} -username_and_password_required = { - "detail": [ + ) + + +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( { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/login/", - {"username": "Foo", "password": "secret"}, - 200, - {"username": "Foo"}, - ), - ("/login/", {"username": "Foo"}, 422, password_required), - ("/login/", {"password": "secret"}, 422, username_required), - ("/login/", None, 422, username_and_password_required), - ], -) -def test_post_body_form(path, body, expected_status, expected_response): - response = client.post(path, data=body) - assert response.status_code == expected_status - assert response.json() == expected_response +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": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | 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(): +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() == username_and_password_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | 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(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_request_forms/test_tutorial001_an.py b/tests/test_tutorial/test_request_forms/test_tutorial001_an.py index 6b8abab19..bce08886f 100644 --- a/tests/test_tutorial/test_request_forms/test_tutorial001_an.py +++ b/tests/test_tutorial/test_request_forms/test_tutorial001_an.py @@ -1,72 +1,164 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.request_forms.tutorial001_an import app -client = TestClient(app) +@pytest.fixture(name="client") +def get_client(): + from docs_src.request_forms.tutorial001_an import app + client = TestClient(app) + return client -password_required = { - "detail": [ + +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"} + + +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( { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] } - ] -} -username_required = { - "detail": [ + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] } - ] -} -username_and_password_required = { - "detail": [ + ) + + +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( { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/login/", - {"username": "Foo", "password": "secret"}, - 200, - {"username": "Foo"}, - ), - ("/login/", {"username": "Foo"}, 422, password_required), - ("/login/", {"password": "secret"}, 422, username_required), - ("/login/", None, 422, username_and_password_required), - ], -) -def test_post_body_form(path, body, expected_status, expected_response): - response = client.post(path, data=body) - assert response.status_code == expected_status - assert response.json() == expected_response +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": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | 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(): +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() == username_and_password_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | 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(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_request_forms/test_tutorial001_an_py39.py b/tests/test_tutorial/test_request_forms/test_tutorial001_an_py39.py index 5862524ad..2f5647442 100644 --- a/tests/test_tutorial/test_request_forms/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_request_forms/test_tutorial001_an_py39.py @@ -1,5 +1,7 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -12,68 +14,155 @@ def get_client(): return client -password_required = { - "detail": [ +@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"} + + +@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( { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] } - ] -} -username_required = { - "detail": [ + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", + "detail": [ + { + "loc": ["body", "password"], + "msg": "field required", + "type": "value_error.missing", + } + ] } - ] -} -username_and_password_required = { - "detail": [ + ) + + +@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( { - "loc": ["body", "username"], - "msg": "field required", - "type": "value_error.missing", - }, + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 { - "loc": ["body", "password"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} + "detail": [ + { + "loc": ["body", "username"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) @needs_py39 -@pytest.mark.parametrize( - "path,body,expected_status,expected_response", - [ - ( - "/login/", - {"username": "Foo", "password": "secret"}, - 200, - {"username": "Foo"}, - ), - ("/login/", {"username": "Foo"}, 422, password_required), - ("/login/", {"password": "secret"}, 422, username_required), - ("/login/", None, 422, username_and_password_required), - ], -) -def test_post_body_form( - path, body, expected_status, expected_response, client: TestClient -): - response = client.post(path, data=body) - assert response.status_code == expected_status - assert response.json() == expected_response +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": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | 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() == username_and_password_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "username"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "password"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | 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 diff --git a/tests/test_tutorial/test_request_forms_and_files/test_tutorial001.py b/tests/test_tutorial/test_request_forms_and_files/test_tutorial001.py index dcc44cf09..89f535937 100644 --- a/tests/test_tutorial/test_request_forms_and_files/test_tutorial001.py +++ b/tests/test_tutorial/test_request_forms_and_files/test_tutorial001.py @@ -1,82 +1,171 @@ +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.request_forms_and_files.tutorial001 import app -client = TestClient(app) +@pytest.fixture(name="app") +def get_app(): + from docs_src.request_forms_and_files.tutorial001 import app + return app -file_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - -token_required = { - "detail": [ - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} -# {'detail': [, {'loc': ['body', 'token'], 'msg': 'field required', 'type': 'value_error.missing'}]} - -file_and_token_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} +@pytest.fixture(name="client") +def get_client(app: FastAPI): + client = TestClient(app) + return client -def test_post_form_no_body(): +def test_post_form_no_body(client: TestClient): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_and_token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_form_no_file(): +def test_post_form_no_file(client: TestClient): response = client.post("/files/", data={"token": "foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_body_json(): +def test_post_body_json(client: TestClient): response = client.post("/files/", json={"file": "Foo", "token": "Bar"}) assert response.status_code == 422, response.text - assert response.json() == file_and_token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_file_no_token(tmp_path): +def test_post_file_no_token(tmp_path, app: FastAPI): path = tmp_path / "test.txt" path.write_bytes(b"") @@ -84,10 +173,45 @@ def test_post_file_no_token(tmp_path): with path.open("rb") as file: response = client.post("/files/", files={"file": file}) assert response.status_code == 422, response.text - assert response.json() == token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_files_and_token(tmp_path): +def test_post_files_and_token(tmp_path, app: FastAPI): patha = tmp_path / "test.txt" pathb = tmp_path / "testb.txt" patha.write_text("") @@ -108,7 +232,7 @@ def test_post_files_and_token(tmp_path): } -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an.py b/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an.py index f11e36984..7388ab7b7 100644 --- a/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an.py +++ b/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an.py @@ -1,82 +1,171 @@ +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url -from docs_src.request_forms_and_files.tutorial001_an import app -client = TestClient(app) +@pytest.fixture(name="app") +def get_app(): + from docs_src.request_forms_and_files.tutorial001_an import app + return app -file_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - -token_required = { - "detail": [ - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} -# {'detail': [, {'loc': ['body', 'token'], 'msg': 'field required', 'type': 'value_error.missing'}]} - -file_and_token_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} +@pytest.fixture(name="client") +def get_client(app: FastAPI): + client = TestClient(app) + return client -def test_post_form_no_body(): +def test_post_form_no_body(client: TestClient): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_and_token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_form_no_file(): +def test_post_form_no_file(client: TestClient): response = client.post("/files/", data={"token": "foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_body_json(): +def test_post_body_json(client: TestClient): response = client.post("/files/", json={"file": "Foo", "token": "Bar"}) assert response.status_code == 422, response.text - assert response.json() == file_and_token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_file_no_token(tmp_path): +def test_post_file_no_token(tmp_path, app: FastAPI): path = tmp_path / "test.txt" path.write_bytes(b"") @@ -84,10 +173,45 @@ def test_post_file_no_token(tmp_path): with path.open("rb") as file: response = client.post("/files/", files={"file": file}) assert response.status_code == 422, response.text - assert response.json() == token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) -def test_post_files_and_token(tmp_path): +def test_post_files_and_token(tmp_path, app: FastAPI): patha = tmp_path / "test.txt" pathb = tmp_path / "testb.txt" patha.write_text("") @@ -108,7 +232,7 @@ def test_post_files_and_token(tmp_path): } -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an_py39.py b/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an_py39.py index 9b5e8681c..86e160450 100644 --- a/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an_py39.py +++ b/tests/test_tutorial/test_request_forms_and_files/test_tutorial001_an_py39.py @@ -1,6 +1,8 @@ import pytest +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient +from fastapi.utils import match_pydantic_error_url from ...utils import needs_py39 @@ -18,78 +20,154 @@ def get_client(app: FastAPI): return client -file_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - -token_required = { - "detail": [ - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - -# {'detail': [, {'loc': ['body', 'token'], 'msg': 'field required', 'type': 'value_error.missing'}]} - -file_and_token_required = { - "detail": [ - { - "loc": ["body", "file"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "fileb"], - "msg": "field required", - "type": "value_error.missing", - }, - { - "loc": ["body", "token"], - "msg": "field required", - "type": "value_error.missing", - }, - ] -} - - @needs_py39 def test_post_form_no_body(client: TestClient): response = client.post("/files/") assert response.status_code == 422, response.text - assert response.json() == file_and_token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 def test_post_form_no_file(client: TestClient): response = client.post("/files/", data={"token": "foo"}) assert response.status_code == 422, response.text - assert response.json() == file_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 def test_post_body_json(client: TestClient): response = client.post("/files/", json={"file": "Foo", "token": "Bar"}) assert response.status_code == 422, response.text - assert response.json() == file_and_token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "file"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 @@ -101,7 +179,42 @@ def test_post_file_no_token(tmp_path, app: FastAPI): with path.open("rb") as file: response = client.post("/files/", files={"file": file}) assert response.status_code == 422, response.text - assert response.json() == token_required + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "fileb"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + { + "type": "missing", + "loc": ["body", "token"], + "msg": "Field required", + "input": None, + "url": match_pydantic_error_url("missing"), + }, + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "fileb"], + "msg": "field required", + "type": "value_error.missing", + }, + { + "loc": ["body", "token"], + "msg": "field required", + "type": "value_error.missing", + }, + ] + } + ) @needs_py39 diff --git a/tests/test_tutorial/test_response_model/test_tutorial003.py b/tests/test_tutorial/test_response_model/test_tutorial003.py index 4a95cb2b5..a2c8bee12 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial003.py +++ b/tests/test_tutorial/test_response_model/test_tutorial003.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.response_model.tutorial003 import app @@ -78,7 +79,16 @@ def test_openapi_schema(): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "UserIn": { @@ -93,7 +103,16 @@ def test_openapi_schema(): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_response_model/test_tutorial003_01.py b/tests/test_tutorial/test_response_model/test_tutorial003_01.py index a055bc688..dba7a5f02 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial003_01.py +++ b/tests/test_tutorial/test_response_model/test_tutorial003_01.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.response_model.tutorial003_01 import app @@ -78,7 +79,16 @@ def test_openapi_schema(): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "HTTPValidationError": { @@ -103,7 +113,16 @@ def test_openapi_schema(): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), "password": {"title": "Password", "type": "string"}, }, }, diff --git a/tests/test_tutorial/test_response_model/test_tutorial003_01_py310.py b/tests/test_tutorial/test_response_model/test_tutorial003_01_py310.py index 8cb4ac208..bd7e3dc4f 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial003_01_py310.py +++ b/tests/test_tutorial/test_response_model/test_tutorial003_01_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -87,7 +88,16 @@ def test_openapi_schema(client: TestClient): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "HTTPValidationError": { @@ -112,7 +122,16 @@ def test_openapi_schema(client: TestClient): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), "password": {"title": "Password", "type": "string"}, }, }, diff --git a/tests/test_tutorial/test_response_model/test_tutorial003_py310.py b/tests/test_tutorial/test_response_model/test_tutorial003_py310.py index 1f4ec9057..dc8dcc0bd 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial003_py310.py +++ b/tests/test_tutorial/test_response_model/test_tutorial003_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -87,7 +88,16 @@ def test_openapi_schema(client: TestClient): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "UserIn": { @@ -102,7 +112,16 @@ def test_openapi_schema(client: TestClient): "type": "string", "format": "email", }, - "full_name": {"title": "Full Name", "type": "string"}, + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_response_model/test_tutorial004.py b/tests/test_tutorial/test_response_model/test_tutorial004.py index 8cb7dc9cf..6d86e509d 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial004.py +++ b/tests/test_tutorial/test_response_model/test_tutorial004.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.response_model.tutorial004 import app @@ -83,7 +84,16 @@ def test_openapi_schema(): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, "tags": { "title": "Tags", diff --git a/tests/test_tutorial/test_response_model/test_tutorial004_py310.py b/tests/test_tutorial/test_response_model/test_tutorial004_py310.py index 2ba8143ba..11c077223 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial004_py310.py +++ b/tests/test_tutorial/test_response_model/test_tutorial004_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -91,7 +92,16 @@ def test_openapi_schema(client: TestClient): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, "tags": { "title": "Tags", diff --git a/tests/test_tutorial/test_response_model/test_tutorial004_py39.py b/tests/test_tutorial/test_response_model/test_tutorial004_py39.py index 97df0a238..aef5fbe6b 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial004_py39.py +++ b/tests/test_tutorial/test_response_model/test_tutorial004_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -91,7 +92,16 @@ def test_openapi_schema(client: TestClient): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, "tags": { "title": "Tags", diff --git a/tests/test_tutorial/test_response_model/test_tutorial005.py b/tests/test_tutorial/test_response_model/test_tutorial005.py index 76662f793..53eb5c32d 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial005.py +++ b/tests/test_tutorial/test_response_model/test_tutorial005.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.response_model.tutorial005 import app @@ -106,7 +107,16 @@ def test_openapi_schema(): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, }, }, diff --git a/tests/test_tutorial/test_response_model/test_tutorial005_py310.py b/tests/test_tutorial/test_response_model/test_tutorial005_py310.py index 1b1cf4175..c07bd6100 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial005_py310.py +++ b/tests/test_tutorial/test_response_model/test_tutorial005_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -116,7 +117,16 @@ def test_openapi_schema(client: TestClient): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, }, }, diff --git a/tests/test_tutorial/test_response_model/test_tutorial006.py b/tests/test_tutorial/test_response_model/test_tutorial006.py index 3a759fde4..328928aff 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial006.py +++ b/tests/test_tutorial/test_response_model/test_tutorial006.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.response_model.tutorial006 import app @@ -106,7 +107,16 @@ def test_openapi_schema(): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, }, }, diff --git a/tests/test_tutorial/test_response_model/test_tutorial006_py310.py b/tests/test_tutorial/test_response_model/test_tutorial006_py310.py index 07e84cc82..756d5ede0 100644 --- a/tests/test_tutorial/test_response_model/test_tutorial006_py310.py +++ b/tests/test_tutorial/test_response_model/test_tutorial006_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -116,7 +117,16 @@ def test_openapi_schema(client: TestClient): "properties": { "name": {"title": "Name", "type": "string"}, "price": {"title": "Price", "type": "number"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "tax": {"title": "Tax", "type": "number", "default": 10.5}, }, }, diff --git a/tests/test_tutorial/test_schema_extra_example/test_tutorial004.py b/tests/test_tutorial/test_schema_extra_example/test_tutorial004.py index 784517d69..6c94421b2 100644 --- a/tests/test_tutorial/test_schema_extra_example/test_tutorial004.py +++ b/tests/test_tutorial/test_schema_extra_example/test_tutorial004.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.schema_extra_example.tutorial004 import app @@ -108,9 +109,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an.py b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an.py index 222a4edfe..61b142e54 100644 --- a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an.py +++ b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.schema_extra_example.tutorial004_an import app @@ -108,9 +109,27 @@ def test_openapi_schema(): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py310.py b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py310.py index 1eacd640a..613d43845 100644 --- a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py310.py +++ b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -117,9 +118,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py39.py b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py39.py index 632f2cbe0..3554ed997 100644 --- a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py39.py +++ b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -117,9 +118,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_py310.py b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_py310.py index c99cb75c8..861d6e421 100644 --- a/tests/test_tutorial/test_schema_extra_example/test_tutorial004_py310.py +++ b/tests/test_tutorial/test_schema_extra_example/test_tutorial004_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -117,9 +118,27 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "name": {"title": "Name", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), "price": {"title": "Price", "type": "number"}, - "tax": {"title": "Tax", "type": "number"}, + "tax": IsDict( + { + "title": "Tax", + "anyOf": [{"type": "number"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Tax", "type": "number"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial003.py b/tests/test_tutorial/test_security/test_tutorial003.py index c10f928eb..06b58d21e 100644 --- a/tests/test_tutorial/test_security/test_tutorial003.py +++ b/tests/test_tutorial/test_security/test_tutorial003.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.security.tutorial003 import app @@ -126,16 +127,46 @@ def test_openapi_schema(): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial003_an.py b/tests/test_tutorial/test_security/test_tutorial003_an.py index 41872fda0..d4eba15e8 100644 --- a/tests/test_tutorial/test_security/test_tutorial003_an.py +++ b/tests/test_tutorial/test_security/test_tutorial003_an.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.security.tutorial003_an import app @@ -126,16 +127,46 @@ def test_openapi_schema(): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial003_an_py310.py b/tests/test_tutorial/test_security/test_tutorial003_an_py310.py index 02bd748c8..3ed146e80 100644 --- a/tests/test_tutorial/test_security/test_tutorial003_an_py310.py +++ b/tests/test_tutorial/test_security/test_tutorial003_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -142,16 +143,46 @@ def test_openapi_schema(client: TestClient): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial003_an_py39.py b/tests/test_tutorial/test_security/test_tutorial003_an_py39.py index 7e74afafb..6baac46f1 100644 --- a/tests/test_tutorial/test_security/test_tutorial003_an_py39.py +++ b/tests/test_tutorial/test_security/test_tutorial003_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -142,16 +143,46 @@ def test_openapi_schema(client: TestClient): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial003_py310.py b/tests/test_tutorial/test_security/test_tutorial003_py310.py index a463751f5..77e058a3f 100644 --- a/tests/test_tutorial/test_security/test_tutorial003_py310.py +++ b/tests/test_tutorial/test_security/test_tutorial003_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -142,16 +143,46 @@ def test_openapi_schema(client: TestClient): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial005.py b/tests/test_tutorial/test_security/test_tutorial005.py index ccb5b3c9f..e50c86ad1 100644 --- a/tests/test_tutorial/test_security/test_tutorial005.py +++ b/tests/test_tutorial/test_security/test_tutorial005.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.security.tutorial005 import ( @@ -270,9 +271,36 @@ def test_openapi_schema(): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "email": {"title": "Email", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, - "disabled": {"title": "Disabled", "type": "boolean"}, + "email": IsDict( + { + "title": "Email", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Email", "type": "string"} + ), + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), + "disabled": IsDict( + { + "title": "Disabled", + "anyOf": [{"type": "boolean"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Disabled", "type": "boolean"} + ), }, }, "Token": { @@ -289,16 +317,46 @@ def test_openapi_schema(): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial005_an.py b/tests/test_tutorial/test_security/test_tutorial005_an.py index e851f47fe..585a63c74 100644 --- a/tests/test_tutorial/test_security/test_tutorial005_an.py +++ b/tests/test_tutorial/test_security/test_tutorial005_an.py @@ -1,3 +1,4 @@ +from dirty_equals import IsDict from fastapi.testclient import TestClient from docs_src.security.tutorial005_an import ( @@ -270,9 +271,36 @@ def test_openapi_schema(): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "email": {"title": "Email", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, - "disabled": {"title": "Disabled", "type": "boolean"}, + "email": IsDict( + { + "title": "Email", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Email", "type": "string"} + ), + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), + "disabled": IsDict( + { + "title": "Disabled", + "anyOf": [{"type": "boolean"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Disabled", "type": "boolean"} + ), }, }, "Token": { @@ -289,16 +317,46 @@ def test_openapi_schema(): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial005_an_py310.py b/tests/test_tutorial/test_security/test_tutorial005_an_py310.py index cca581980..82a5ba554 100644 --- a/tests/test_tutorial/test_security/test_tutorial005_an_py310.py +++ b/tests/test_tutorial/test_security/test_tutorial005_an_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -298,9 +299,36 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "email": {"title": "Email", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, - "disabled": {"title": "Disabled", "type": "boolean"}, + "email": IsDict( + { + "title": "Email", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Email", "type": "string"} + ), + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), + "disabled": IsDict( + { + "title": "Disabled", + "anyOf": [{"type": "boolean"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Disabled", "type": "boolean"} + ), }, }, "Token": { @@ -317,16 +345,46 @@ def test_openapi_schema(client: TestClient): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial005_an_py39.py b/tests/test_tutorial/test_security/test_tutorial005_an_py39.py index eae851457..702baa696 100644 --- a/tests/test_tutorial/test_security/test_tutorial005_an_py39.py +++ b/tests/test_tutorial/test_security/test_tutorial005_an_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -298,9 +299,36 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "email": {"title": "Email", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, - "disabled": {"title": "Disabled", "type": "boolean"}, + "email": IsDict( + { + "title": "Email", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Email", "type": "string"} + ), + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), + "disabled": IsDict( + { + "title": "Disabled", + "anyOf": [{"type": "boolean"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Disabled", "type": "boolean"} + ), }, }, "Token": { @@ -317,16 +345,46 @@ def test_openapi_schema(client: TestClient): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial005_py310.py b/tests/test_tutorial/test_security/test_tutorial005_py310.py index cdbd8f75e..45905ae8b 100644 --- a/tests/test_tutorial/test_security/test_tutorial005_py310.py +++ b/tests/test_tutorial/test_security/test_tutorial005_py310.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py310 @@ -298,9 +299,36 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "email": {"title": "Email", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, - "disabled": {"title": "Disabled", "type": "boolean"}, + "email": IsDict( + { + "title": "Email", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Email", "type": "string"} + ), + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), + "disabled": IsDict( + { + "title": "Disabled", + "anyOf": [{"type": "boolean"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Disabled", "type": "boolean"} + ), }, }, "Token": { @@ -317,16 +345,46 @@ def test_openapi_schema(client: TestClient): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_security/test_tutorial005_py39.py b/tests/test_tutorial/test_security/test_tutorial005_py39.py index 45b2a2341..d764d3022 100644 --- a/tests/test_tutorial/test_security/test_tutorial005_py39.py +++ b/tests/test_tutorial/test_security/test_tutorial005_py39.py @@ -1,4 +1,5 @@ import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient from ...utils import needs_py39 @@ -298,9 +299,36 @@ def test_openapi_schema(client: TestClient): "type": "object", "properties": { "username": {"title": "Username", "type": "string"}, - "email": {"title": "Email", "type": "string"}, - "full_name": {"title": "Full Name", "type": "string"}, - "disabled": {"title": "Disabled", "type": "boolean"}, + "email": IsDict( + { + "title": "Email", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Email", "type": "string"} + ), + "full_name": IsDict( + { + "title": "Full Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Full Name", "type": "string"} + ), + "disabled": IsDict( + { + "title": "Disabled", + "anyOf": [{"type": "boolean"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Disabled", "type": "boolean"} + ), }, }, "Token": { @@ -317,16 +345,46 @@ def test_openapi_schema(client: TestClient): "required": ["username", "password"], "type": "object", "properties": { - "grant_type": { - "title": "Grant Type", - "pattern": "password", - "type": "string", - }, + "grant_type": IsDict( + { + "title": "Grant Type", + "anyOf": [ + {"pattern": "password", "type": "string"}, + {"type": "null"}, + ], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "Grant Type", + "pattern": "password", + "type": "string", + } + ), "username": {"title": "Username", "type": "string"}, "password": {"title": "Password", "type": "string"}, "scope": {"title": "Scope", "type": "string", "default": ""}, - "client_id": {"title": "Client Id", "type": "string"}, - "client_secret": {"title": "Client Secret", "type": "string"}, + "client_id": IsDict( + { + "title": "Client Id", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Id", "type": "string"} + ), + "client_secret": IsDict( + { + "title": "Client Secret", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Client Secret", "type": "string"} + ), }, }, "ValidationError": { diff --git a/tests/test_tutorial/test_settings/test_app02.py b/tests/test_tutorial/test_settings/test_app02.py index fd32b8766..5027ef7cf 100644 --- a/tests/test_tutorial/test_settings/test_app02.py +++ b/tests/test_tutorial/test_settings/test_app02.py @@ -1,17 +1,22 @@ -from fastapi.testclient import TestClient from pytest import MonkeyPatch -from docs_src.settings.app02 import main, test_main - -client = TestClient(main.app) +from ...utils import needs_pydanticv1 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_settings(monkeypatch: MonkeyPatch): + from docs_src.settings.app02 import main + monkeypatch.setenv("ADMIN_EMAIL", "admin@example.com") settings = main.get_settings() assert settings.app_name == "Awesome API" assert settings.items_per_user == 50 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_override_settings(): + from docs_src.settings.app02 import test_main + test_main.test_app() diff --git a/tests/test_tutorial/test_sql_databases/test_sql_databases.py b/tests/test_tutorial/test_sql_databases/test_sql_databases.py index a2628f3c3..b4248f9f8 100644 --- a/tests/test_tutorial/test_sql_databases/test_sql_databases.py +++ b/tests/test_tutorial/test_sql_databases/test_sql_databases.py @@ -3,8 +3,11 @@ import os from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from ...utils import needs_pydanticv1 + @pytest.fixture(scope="module") def client(tmp_path_factory: pytest.TempPathFactory): @@ -26,6 +29,8 @@ def client(tmp_path_factory: pytest.TempPathFactory): os.chdir(cwd) +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_user(client): test_user = {"email": "johndoe@example.com", "password": "secret"} response = client.post("/users/", json=test_user) @@ -37,6 +42,8 @@ def test_create_user(client): assert response.status_code == 400, response.text +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_user(client): response = client.get("/users/1") assert response.status_code == 200, response.text @@ -45,11 +52,15 @@ def test_get_user(client): assert "id" in data +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_inexistent_user(client): response = client.get("/users/999") assert response.status_code == 404, response.text +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_users(client): response = client.get("/users/") assert response.status_code == 200, response.text @@ -58,6 +69,8 @@ def test_get_users(client): assert "id" in data[0] +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_create_item(client): item = {"title": "Foo", "description": "Something that fights"} response = client.post("/users/1/items/", json=item) @@ -75,6 +88,8 @@ def test_create_item(client): assert item_to_check["description"] == item["description"] +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_read_items(client): response = client.get("/items/") assert response.status_code == 200, response.text @@ -85,7 +100,9 @@ def test_read_items(client): assert "description" in first_item -def test_openapi_schema(client): +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -313,7 +330,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), }, }, "Item": { @@ -322,7 +348,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"}, + ), "id": {"title": "Id", "type": "integer"}, "owner_id": {"title": "Owner Id", "type": "integer"}, }, diff --git a/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware.py b/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware.py index 02501b1a2..45262ce71 100644 --- a/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware.py +++ b/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware.py @@ -2,8 +2,11 @@ import importlib from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient +from ...utils import needs_pydanticv1 + @pytest.fixture(scope="module") def client(): @@ -22,6 +25,8 @@ def client(): test_db.unlink() +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_user(client): test_user = {"email": "johndoe@example.com", "password": "secret"} response = client.post("/users/", json=test_user) @@ -33,6 +38,8 @@ def test_create_user(client): assert response.status_code == 400, response.text +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_user(client): response = client.get("/users/1") assert response.status_code == 200, response.text @@ -41,11 +48,15 @@ def test_get_user(client): assert "id" in data +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_inexistent_user(client): response = client.get("/users/999") assert response.status_code == 404, response.text +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_users(client): response = client.get("/users/") assert response.status_code == 200, response.text @@ -54,6 +65,8 @@ def test_get_users(client): assert "id" in data[0] +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_create_item(client): item = {"title": "Foo", "description": "Something that fights"} response = client.post("/users/1/items/", json=item) @@ -77,6 +90,8 @@ def test_create_item(client): assert item_to_check["description"] == item["description"] +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_read_items(client): response = client.get("/items/") assert response.status_code == 200, response.text @@ -87,7 +102,9 @@ def test_read_items(client): assert "description" in first_item -def test_openapi_schema(client): +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -315,7 +332,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), }, }, "Item": { @@ -324,7 +350,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"}, + ), "id": {"title": "Id", "type": "integer"}, "owner_id": {"title": "Owner Id", "type": "integer"}, }, diff --git a/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py310.py b/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py310.py index 67b92ea4a..679af00de 100644 --- a/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py310.py +++ b/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py310.py @@ -3,9 +3,10 @@ import os from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient -from ...utils import needs_py310 +from ...utils import needs_py310, needs_pydanticv1 @pytest.fixture(scope="module") @@ -30,6 +31,8 @@ def client(tmp_path_factory: pytest.TempPathFactory): @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_user(client): test_user = {"email": "johndoe@example.com", "password": "secret"} response = client.post("/users/", json=test_user) @@ -42,6 +45,8 @@ def test_create_user(client): @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_user(client): response = client.get("/users/1") assert response.status_code == 200, response.text @@ -51,12 +56,16 @@ def test_get_user(client): @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_inexistent_user(client): response = client.get("/users/999") assert response.status_code == 404, response.text @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_users(client): response = client.get("/users/") assert response.status_code == 200, response.text @@ -66,6 +75,8 @@ def test_get_users(client): @needs_py310 +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_create_item(client): item = {"title": "Foo", "description": "Something that fights"} response = client.post("/users/1/items/", json=item) @@ -90,6 +101,8 @@ def test_create_item(client): @needs_py310 +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_read_items(client): response = client.get("/items/") assert response.status_code == 200, response.text @@ -101,7 +114,9 @@ def test_read_items(client): @needs_py310 -def test_openapi_schema(client): +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -329,7 +344,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), }, }, "Item": { @@ -338,7 +362,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"}, + ), "id": {"title": "Id", "type": "integer"}, "owner_id": {"title": "Owner Id", "type": "integer"}, }, diff --git a/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py39.py b/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py39.py index a2af20ea2..2310ecc00 100644 --- a/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py39.py +++ b/tests/test_tutorial/test_sql_databases/test_sql_databases_middleware_py39.py @@ -3,9 +3,10 @@ import os from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient -from ...utils import needs_py39 +from ...utils import needs_py39, needs_pydanticv1 @pytest.fixture(scope="module") @@ -30,6 +31,8 @@ def client(tmp_path_factory: pytest.TempPathFactory): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_user(client): test_user = {"email": "johndoe@example.com", "password": "secret"} response = client.post("/users/", json=test_user) @@ -42,6 +45,8 @@ def test_create_user(client): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_user(client): response = client.get("/users/1") assert response.status_code == 200, response.text @@ -51,12 +56,16 @@ def test_get_user(client): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_inexistent_user(client): response = client.get("/users/999") assert response.status_code == 404, response.text @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_users(client): response = client.get("/users/") assert response.status_code == 200, response.text @@ -66,6 +75,8 @@ def test_get_users(client): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_item(client): item = {"title": "Foo", "description": "Something that fights"} response = client.post("/users/1/items/", json=item) @@ -90,6 +101,8 @@ def test_create_item(client): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_read_items(client): response = client.get("/items/") assert response.status_code == 200, response.text @@ -101,7 +114,9 @@ def test_read_items(client): @needs_py39 -def test_openapi_schema(client): +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -329,7 +344,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), }, }, "Item": { @@ -338,7 +362,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"}, + ), "id": {"title": "Id", "type": "integer"}, "owner_id": {"title": "Owner Id", "type": "integer"}, }, diff --git a/tests/test_tutorial/test_sql_databases/test_sql_databases_py310.py b/tests/test_tutorial/test_sql_databases/test_sql_databases_py310.py index 2f918cfd8..4127b8df1 100644 --- a/tests/test_tutorial/test_sql_databases/test_sql_databases_py310.py +++ b/tests/test_tutorial/test_sql_databases/test_sql_databases_py310.py @@ -3,9 +3,10 @@ import os from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient -from ...utils import needs_py310 +from ...utils import needs_py310, needs_pydanticv1 @pytest.fixture(scope="module", name="client") @@ -29,6 +30,8 @@ def get_client(tmp_path_factory: pytest.TempPathFactory): @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_user(client): test_user = {"email": "johndoe@example.com", "password": "secret"} response = client.post("/users/", json=test_user) @@ -41,6 +44,8 @@ def test_create_user(client): @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_user(client): response = client.get("/users/1") assert response.status_code == 200, response.text @@ -50,12 +55,16 @@ def test_get_user(client): @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_inexistent_user(client): response = client.get("/users/999") assert response.status_code == 404, response.text @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_users(client): response = client.get("/users/") assert response.status_code == 200, response.text @@ -65,6 +74,8 @@ def test_get_users(client): @needs_py310 +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_create_item(client): item = {"title": "Foo", "description": "Something that fights"} response = client.post("/users/1/items/", json=item) @@ -89,6 +100,8 @@ def test_create_item(client): @needs_py310 +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_read_items(client): response = client.get("/items/") assert response.status_code == 200, response.text @@ -100,7 +113,9 @@ def test_read_items(client): @needs_py310 -def test_openapi_schema(client): +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -328,7 +343,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), }, }, "Item": { @@ -337,7 +361,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"}, + ), "id": {"title": "Id", "type": "integer"}, "owner_id": {"title": "Owner Id", "type": "integer"}, }, diff --git a/tests/test_tutorial/test_sql_databases/test_sql_databases_py39.py b/tests/test_tutorial/test_sql_databases/test_sql_databases_py39.py index f2eefe41d..46478b81e 100644 --- a/tests/test_tutorial/test_sql_databases/test_sql_databases_py39.py +++ b/tests/test_tutorial/test_sql_databases/test_sql_databases_py39.py @@ -3,9 +3,10 @@ import os from pathlib import Path import pytest +from dirty_equals import IsDict from fastapi.testclient import TestClient -from ...utils import needs_py39 +from ...utils import needs_py39, needs_pydanticv1 @pytest.fixture(scope="module", name="client") @@ -29,6 +30,8 @@ def get_client(tmp_path_factory: pytest.TempPathFactory): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_create_user(client): test_user = {"email": "johndoe@example.com", "password": "secret"} response = client.post("/users/", json=test_user) @@ -41,6 +44,8 @@ def test_create_user(client): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_user(client): response = client.get("/users/1") assert response.status_code == 200, response.text @@ -50,12 +55,16 @@ def test_get_user(client): @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_inexistent_user(client): response = client.get("/users/999") assert response.status_code == 404, response.text @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_get_users(client): response = client.get("/users/") assert response.status_code == 200, response.text @@ -65,6 +74,8 @@ def test_get_users(client): @needs_py39 +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_create_item(client): item = {"title": "Foo", "description": "Something that fights"} response = client.post("/users/1/items/", json=item) @@ -89,6 +100,8 @@ def test_create_item(client): @needs_py39 +# TODO: pv2 add Pydantic v2 version +@needs_pydanticv1 def test_read_items(client): response = client.get("/items/") assert response.status_code == 200, response.text @@ -100,7 +113,9 @@ def test_read_items(client): @needs_py39 -def test_openapi_schema(client): +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -328,7 +343,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"} + ), }, }, "Item": { @@ -337,7 +361,16 @@ def test_openapi_schema(client): "type": "object", "properties": { "title": {"title": "Title", "type": "string"}, - "description": {"title": "Description", "type": "string"}, + "description": IsDict( + { + "title": "Description", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Description", "type": "string"}, + ), "id": {"title": "Id", "type": "integer"}, "owner_id": {"title": "Owner Id", "type": "integer"}, }, diff --git a/tests/test_tutorial/test_sql_databases/test_testing_databases.py b/tests/test_tutorial/test_sql_databases/test_testing_databases.py index 6f667dea0..ce6ce230c 100644 --- a/tests/test_tutorial/test_sql_databases/test_testing_databases.py +++ b/tests/test_tutorial/test_sql_databases/test_testing_databases.py @@ -4,7 +4,11 @@ from pathlib import Path import pytest +from ...utils import needs_pydanticv1 + +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_testing_dbs(tmp_path_factory: pytest.TempPathFactory): tmp_path = tmp_path_factory.mktemp("data") cwd = os.getcwd() diff --git a/tests/test_tutorial/test_sql_databases/test_testing_databases_py310.py b/tests/test_tutorial/test_sql_databases/test_testing_databases_py310.py index 9e6b3f3e2..545d63c2a 100644 --- a/tests/test_tutorial/test_sql_databases/test_testing_databases_py310.py +++ b/tests/test_tutorial/test_sql_databases/test_testing_databases_py310.py @@ -4,10 +4,12 @@ from pathlib import Path import pytest -from ...utils import needs_py310 +from ...utils import needs_py310, needs_pydanticv1 @needs_py310 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_testing_dbs_py39(tmp_path_factory: pytest.TempPathFactory): tmp_path = tmp_path_factory.mktemp("data") cwd = os.getcwd() diff --git a/tests/test_tutorial/test_sql_databases/test_testing_databases_py39.py b/tests/test_tutorial/test_sql_databases/test_testing_databases_py39.py index 0b27adf44..99bfd3fa8 100644 --- a/tests/test_tutorial/test_sql_databases/test_testing_databases_py39.py +++ b/tests/test_tutorial/test_sql_databases/test_testing_databases_py39.py @@ -4,10 +4,12 @@ from pathlib import Path import pytest -from ...utils import needs_py39 +from ...utils import needs_py39, needs_pydanticv1 @needs_py39 +# TODO: pv2 add version with Pydantic v2 +@needs_pydanticv1 def test_testing_dbs_py39(tmp_path_factory: pytest.TempPathFactory): tmp_path = tmp_path_factory.mktemp("data") cwd = os.getcwd() diff --git a/tests/test_tutorial/test_sql_databases_peewee/test_sql_databases_peewee.py b/tests/test_tutorial/test_sql_databases_peewee/test_sql_databases_peewee.py index d2470a2db..3e50475bc 100644 --- a/tests/test_tutorial/test_sql_databases_peewee/test_sql_databases_peewee.py +++ b/tests/test_tutorial/test_sql_databases_peewee/test_sql_databases_peewee.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient +from ...utils import needs_pydanticv1 + @pytest.fixture(scope="module") def client(): @@ -17,6 +19,7 @@ def client(): test_db.unlink() +@needs_pydanticv1 def test_create_user(client): test_user = {"email": "johndoe@example.com", "password": "secret"} response = client.post("/users/", json=test_user) @@ -28,6 +31,7 @@ def test_create_user(client): assert response.status_code == 400, response.text +@needs_pydanticv1 def test_get_user(client): response = client.get("/users/1") assert response.status_code == 200, response.text @@ -36,11 +40,13 @@ def test_get_user(client): assert "id" in data +@needs_pydanticv1 def test_inexistent_user(client): response = client.get("/users/999") assert response.status_code == 404, response.text +@needs_pydanticv1 def test_get_users(client): response = client.get("/users/") assert response.status_code == 200, response.text @@ -52,6 +58,7 @@ def test_get_users(client): time.sleep = MagicMock() +@needs_pydanticv1 def test_get_slowusers(client): response = client.get("/slowusers/") assert response.status_code == 200, response.text @@ -60,6 +67,7 @@ def test_get_slowusers(client): assert "id" in data[0] +@needs_pydanticv1 def test_create_item(client): item = {"title": "Foo", "description": "Something that fights"} response = client.post("/users/1/items/", json=item) @@ -83,6 +91,7 @@ def test_create_item(client): assert item_to_check["description"] == item["description"] +@needs_pydanticv1 def test_read_items(client): response = client.get("/items/") assert response.status_code == 200, response.text @@ -93,6 +102,7 @@ def test_read_items(client): assert "description" in first_item +@needs_pydanticv1 def test_openapi_schema(client): response = client.get("/openapi.json") assert response.status_code == 200, response.text diff --git a/tests/test_union_body.py b/tests/test_union_body.py index bc1e74432..a9a11ee8a 100644 --- a/tests/test_union_body.py +++ b/tests/test_union_body.py @@ -1,5 +1,6 @@ from typing import Optional, Union +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel @@ -90,7 +91,18 @@ def test_openapi_schema(): "Item": { "title": "Item", "type": "object", - "properties": {"name": {"title": "Name", "type": "string"}}, + "properties": IsDict( + { + "name": { + "title": "Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"name": {"title": "Name", "type": "string"}} + ), }, "ValidationError": { "title": "ValidationError", diff --git a/tests/test_union_inherited_body.py b/tests/test_union_inherited_body.py index 988b920aa..a02fc35f9 100644 --- a/tests/test_union_inherited_body.py +++ b/tests/test_union_inherited_body.py @@ -1,5 +1,6 @@ from typing import Optional, Union +from dirty_equals import IsDict from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel @@ -84,14 +85,34 @@ def test_openapi_schema(): "Item": { "title": "Item", "type": "object", - "properties": {"name": {"title": "Name", "type": "string"}}, + "properties": { + "name": IsDict( + { + "title": "Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Name", "type": "string"} + ) + }, }, "ExtendedItem": { "title": "ExtendedItem", "required": ["age"], "type": "object", "properties": { - "name": {"title": "Name", "type": "string"}, + "name": IsDict( + { + "title": "Name", + "anyOf": [{"type": "string"}, {"type": "null"}], + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "Name", "type": "string"} + ), "age": {"title": "Age", "type": "integer"}, }, }, diff --git a/tests/test_validate_response.py b/tests/test_validate_response.py index 62f51c960..cd97007a4 100644 --- a/tests/test_validate_response.py +++ b/tests/test_validate_response.py @@ -2,8 +2,9 @@ from typing import List, Optional, Union import pytest from fastapi import FastAPI +from fastapi.exceptions import ResponseValidationError from fastapi.testclient import TestClient -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel app = FastAPI() @@ -50,12 +51,12 @@ client = TestClient(app) def test_invalid(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/items/invalid") def test_invalid_none(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/items/invalidnone") @@ -74,10 +75,10 @@ def test_valid_none_none(): def test_double_invalid(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/items/innerinvalid") def test_invalid_list(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/items/invalidlist") diff --git a/tests/test_validate_response_dataclass.py b/tests/test_validate_response_dataclass.py index f2cfa7a11..0415988a0 100644 --- a/tests/test_validate_response_dataclass.py +++ b/tests/test_validate_response_dataclass.py @@ -2,8 +2,8 @@ from typing import List, Optional import pytest from fastapi import FastAPI +from fastapi.exceptions import ResponseValidationError from fastapi.testclient import TestClient -from pydantic import ValidationError from pydantic.dataclasses import dataclass app = FastAPI() @@ -39,15 +39,15 @@ client = TestClient(app) def test_invalid(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/items/invalid") def test_double_invalid(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/items/innerinvalid") def test_invalid_list(): - with pytest.raises(ValidationError): + with pytest.raises(ResponseValidationError): client.get("/items/invalidlist") diff --git a/tests/test_validate_response_recursive/__init__.py b/tests/test_validate_response_recursive/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_validate_response_recursive.py b/tests/test_validate_response_recursive/app_pv1.py similarity index 58% rename from tests/test_validate_response_recursive.py rename to tests/test_validate_response_recursive/app_pv1.py index 3a4b10e0c..4cfc4b3ee 100644 --- a/tests/test_validate_response_recursive.py +++ b/tests/test_validate_response_recursive/app_pv1.py @@ -1,7 +1,6 @@ from typing import List from fastapi import FastAPI -from fastapi.testclient import TestClient from pydantic import BaseModel app = FastAPI() @@ -49,32 +48,3 @@ def get_recursive_submodel(): } ], } - - -client = TestClient(app) - - -def test_recursive(): - response = client.get("/items/recursive") - assert response.status_code == 200, response.text - assert response.json() == { - "sub_items": [{"name": "subitem", "sub_items": []}], - "name": "item", - } - - response = client.get("/items/recursive-submodel") - assert response.status_code == 200, response.text - assert response.json() == { - "name": "item", - "sub_items1": [ - { - "name": "subitem", - "sub_items2": [ - { - "name": "subsubitem", - "sub_items1": [{"name": "subsubsubitem", "sub_items2": []}], - } - ], - } - ], - } diff --git a/tests/test_validate_response_recursive/app_pv2.py b/tests/test_validate_response_recursive/app_pv2.py new file mode 100644 index 000000000..8c93a8349 --- /dev/null +++ b/tests/test_validate_response_recursive/app_pv2.py @@ -0,0 +1,51 @@ +from typing import List + +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class RecursiveItem(BaseModel): + sub_items: List["RecursiveItem"] = [] + name: str + + +RecursiveItem.model_rebuild() + + +class RecursiveSubitemInSubmodel(BaseModel): + sub_items2: List["RecursiveItemViaSubmodel"] = [] + name: str + + +class RecursiveItemViaSubmodel(BaseModel): + sub_items1: List[RecursiveSubitemInSubmodel] = [] + name: str + + +RecursiveSubitemInSubmodel.model_rebuild() +RecursiveItemViaSubmodel.model_rebuild() + + +@app.get("/items/recursive", response_model=RecursiveItem) +def get_recursive(): + return {"name": "item", "sub_items": [{"name": "subitem", "sub_items": []}]} + + +@app.get("/items/recursive-submodel", response_model=RecursiveItemViaSubmodel) +def get_recursive_submodel(): + return { + "name": "item", + "sub_items1": [ + { + "name": "subitem", + "sub_items2": [ + { + "name": "subsubitem", + "sub_items1": [{"name": "subsubsubitem", "sub_items2": []}], + } + ], + } + ], + } diff --git a/tests/test_validate_response_recursive/test_validate_response_recursive_pv1.py b/tests/test_validate_response_recursive/test_validate_response_recursive_pv1.py new file mode 100644 index 000000000..de578ae03 --- /dev/null +++ b/tests/test_validate_response_recursive/test_validate_response_recursive_pv1.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient + +from ..utils import needs_pydanticv1 + + +@needs_pydanticv1 +def test_recursive(): + from .app_pv1 import app + + client = TestClient(app) + response = client.get("/items/recursive") + assert response.status_code == 200, response.text + assert response.json() == { + "sub_items": [{"name": "subitem", "sub_items": []}], + "name": "item", + } + + response = client.get("/items/recursive-submodel") + assert response.status_code == 200, response.text + assert response.json() == { + "name": "item", + "sub_items1": [ + { + "name": "subitem", + "sub_items2": [ + { + "name": "subsubitem", + "sub_items1": [{"name": "subsubsubitem", "sub_items2": []}], + } + ], + } + ], + } diff --git a/tests/test_validate_response_recursive/test_validate_response_recursive_pv2.py b/tests/test_validate_response_recursive/test_validate_response_recursive_pv2.py new file mode 100644 index 000000000..7d45e7fe4 --- /dev/null +++ b/tests/test_validate_response_recursive/test_validate_response_recursive_pv2.py @@ -0,0 +1,33 @@ +from fastapi.testclient import TestClient + +from ..utils import needs_pydanticv2 + + +@needs_pydanticv2 +def test_recursive(): + from .app_pv2 import app + + client = TestClient(app) + response = client.get("/items/recursive") + assert response.status_code == 200, response.text + assert response.json() == { + "sub_items": [{"name": "subitem", "sub_items": []}], + "name": "item", + } + + response = client.get("/items/recursive-submodel") + assert response.status_code == 200, response.text + assert response.json() == { + "name": "item", + "sub_items1": [ + { + "name": "subitem", + "sub_items2": [ + { + "name": "subsubitem", + "sub_items1": [{"name": "subsubsubitem", "sub_items2": []}], + } + ], + } + ], + } diff --git a/tests/utils.py b/tests/utils.py index 5305424c4..460c028f7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,8 +1,11 @@ import sys import pytest +from fastapi._compat import PYDANTIC_V2 needs_py39 = pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python3.9+") needs_py310 = pytest.mark.skipif( sys.version_info < (3, 10), reason="requires python3.10+" ) +needs_pydanticv2 = pytest.mark.skipif(not PYDANTIC_V2, reason="requires Pydantic v2") +needs_pydanticv1 = pytest.mark.skipif(PYDANTIC_V2, reason="requires Pydantic v1")