|
34 | 34 | from litestar.di import NamedDependency, Provide |
35 | 35 | from litestar.enums import ParamType |
36 | 36 | from litestar.exceptions import ImproperlyConfiguredException |
| 37 | +from litestar.openapi import OpenAPIConfig |
37 | 38 | from litestar.openapi.spec import ExternalDocumentation, OpenAPIType, Reference |
38 | 39 | from litestar.openapi.spec.example import Example |
39 | 40 | from litestar.openapi.spec.parameter import Parameter as OpenAPIParameter |
@@ -837,6 +838,136 @@ def handler(query_param: Annotated[annotation, QueryParameter(description="foo") |
837 | 838 | assert param.description == "foo" |
838 | 839 |
|
839 | 840 |
|
| 841 | +@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12") |
| 842 | +def test_recursive_type_alias_type() -> None: |
| 843 | + # https://github.com/litestar-org/litestar/issues/4843 |
| 844 | + # A self-referential PEP 695 ``type`` alias must not blow the stack when generating the OpenAPI schema. |
| 845 | + ctx: dict[str, Any] = {"__name__": __name__} |
| 846 | + exec("type JSON = None | bool | str | float | int | list[JSON] | dict[str, JSON]", ctx, None) |
| 847 | + annotation = ctx["JSON"] |
| 848 | + |
| 849 | + @get("/") |
| 850 | + def handler() -> annotation: # type: ignore[valid-type] |
| 851 | + return {} |
| 852 | + |
| 853 | + app = Litestar([handler]) |
| 854 | + schema = app.openapi_schema.to_schema() |
| 855 | + |
| 856 | + # the alias is registered as a named component and the response references it |
| 857 | + assert "JSON" in schema["components"]["schemas"] |
| 858 | + response_schema = schema["paths"]["/"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] |
| 859 | + assert response_schema == {"$ref": "#/components/schemas/JSON"} |
| 860 | + |
| 861 | + # the recursive occurrences inside the component resolve to a ``$ref`` back to the alias, breaking the cycle |
| 862 | + component = schema["components"]["schemas"]["JSON"] |
| 863 | + one_of = component["oneOf"] |
| 864 | + array_schema = next(s for s in one_of if s.get("type") == "array") |
| 865 | + object_schema = next(s for s in one_of if s.get("type") == "object") |
| 866 | + assert array_schema["items"] == {"$ref": "#/components/schemas/JSON"} |
| 867 | + assert object_schema["additionalProperties"] == {"$ref": "#/components/schemas/JSON"} |
| 868 | + |
| 869 | + |
| 870 | +@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12") |
| 871 | +def test_mutually_recursive_type_alias_types() -> None: |
| 872 | + # Mutually recursive aliases must terminate as well, not just directly self-referential ones. |
| 873 | + ctx: dict[str, Any] = {"__name__": __name__} |
| 874 | + exec("type A = str | list[B]\ntype B = int | dict[str, A]", ctx, None) |
| 875 | + annotation = ctx["A"] |
| 876 | + |
| 877 | + @get("/") |
| 878 | + def handler() -> annotation: # type: ignore[valid-type] |
| 879 | + return "" |
| 880 | + |
| 881 | + app = Litestar([handler]) |
| 882 | + # smoke test: this must not raise ``RecursionError`` |
| 883 | + schema = app.openapi_schema.to_schema() |
| 884 | + response_schema = schema["paths"]["/"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] |
| 885 | + assert response_schema == {"$ref": "#/components/schemas/A"} |
| 886 | + |
| 887 | + |
| 888 | +@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12") |
| 889 | +def test_type_alias_type_to_recursive_model() -> None: |
| 890 | + # An alias whose value is a single component (here a dataclass) that recurses back into the alias: the alias's |
| 891 | + # value resolves to a ``Reference`` rather than a ``Schema``, so the component defers to it via ``allOf``. |
| 892 | + # ``exec`` into the module globals so the dataclass's forward reference to the alias can be resolved. |
| 893 | + exec( |
| 894 | + "@dataclass\nclass _AliasContainer:\n children: 'list[_AliasNode]'\ntype _AliasNode = _AliasContainer", |
| 895 | + globals(), |
| 896 | + ) |
| 897 | + annotation = globals()["_AliasNode"] |
| 898 | + |
| 899 | + @get("/") |
| 900 | + def handler() -> annotation: # type: ignore[valid-type] |
| 901 | + ... |
| 902 | + |
| 903 | + app = Litestar([handler]) |
| 904 | + schema = app.openapi_schema.to_schema() |
| 905 | + schemas = schema["components"]["schemas"] |
| 906 | + assert schema["paths"]["/"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] == { |
| 907 | + "$ref": "#/components/schemas/_AliasNode" |
| 908 | + } |
| 909 | + # the ``_AliasNode`` component defers to the ``_AliasContainer`` component |
| 910 | + assert schemas["_AliasNode"]["allOf"] == [{"$ref": "#/components/schemas/_AliasContainer"}] |
| 911 | + # and ``_AliasContainer`` references ``_AliasNode`` back, completing the cycle without recursing infinitely |
| 912 | + assert schemas["_AliasContainer"]["properties"]["children"]["items"] == {"$ref": "#/components/schemas/_AliasNode"} |
| 913 | + |
| 914 | + |
| 915 | +@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12") |
| 916 | +def test_recursive_type_alias_type_with_example_generation() -> None: |
| 917 | + # https://github.com/litestar-org/litestar/issues/4843 |
| 918 | + # The example factory does not guard against recursive ``type`` aliases, so example generation must be |
| 919 | + # skipped for them -- otherwise it recurses without bound (here ``dict[str, JSON]`` is the dangerous shape, |
| 920 | + # processed after the alias has finished expanding). |
| 921 | + ctx: dict[str, Any] = {"__name__": __name__} |
| 922 | + exec("type JSON = None | bool | str | float | int | list[JSON] | dict[str, JSON]", ctx, None) |
| 923 | + annotation = ctx["JSON"] |
| 924 | + |
| 925 | + @get("/") |
| 926 | + def handler() -> dict[str, annotation]: # type: ignore[valid-type] |
| 927 | + return {} |
| 928 | + |
| 929 | + app = Litestar([handler], openapi_config=OpenAPIConfig(title="t", version="1", create_examples=True)) |
| 930 | + # must terminate (no ``RecursionError`` / hang) -- a non-recursive annotation containing the alias |
| 931 | + schema = app.openapi_schema.to_schema() |
| 932 | + component = schema["components"]["schemas"]["JSON"] |
| 933 | + # example generation was skipped for the self-referential shapes, so no value was fabricated for them |
| 934 | + assert component.get("examples", []) == [] |
| 935 | + array_member = next(s for s in component["oneOf"] if s.get("type") == "array") |
| 936 | + assert array_member.get("examples", []) == [] |
| 937 | + |
| 938 | + |
| 939 | +@pytest.mark.skipif(sys.version_info < (3, 12), reason="type keyword not available before 3.12") |
| 940 | +def test_contains_recursive_type_alias_detection() -> None: |
| 941 | + from litestar._openapi.schema_generation.examples import _contains_recursive_type_alias |
| 942 | + |
| 943 | + # the composite annotations are built inside ``exec`` so they are not analysed as type expressions |
| 944 | + ctx: dict[str, Any] = {"__name__": __name__} |
| 945 | + exec( |
| 946 | + "type Name = str\n" |
| 947 | + "type JSON = None | str | list[JSON]\n" |
| 948 | + "type Wrapper = list[JSON]\n" |
| 949 | + "type A = str | list[B]\n" |
| 950 | + "type B = int | dict[str, A]\n" |
| 951 | + "dict_str_json = dict[str, JSON]\n" |
| 952 | + "tuple_name_name = tuple[Name, Name]\n" |
| 953 | + "dict_name_name = dict[Name, Name]", |
| 954 | + ctx, |
| 955 | + None, |
| 956 | + ) |
| 957 | + |
| 958 | + # directly recursive, mutually recursive, and aliases that *contain* a recursive one |
| 959 | + assert _contains_recursive_type_alias(ctx["JSON"]) is True |
| 960 | + assert _contains_recursive_type_alias(ctx["dict_str_json"]) is True |
| 961 | + assert _contains_recursive_type_alias(ctx["Wrapper"]) is True |
| 962 | + assert _contains_recursive_type_alias(ctx["A"]) is True |
| 963 | + assert _contains_recursive_type_alias(ctx["B"]) is True |
| 964 | + # a non-recursive alias is not flagged -- even when reused several times in one annotation |
| 965 | + assert _contains_recursive_type_alias(ctx["Name"]) is False |
| 966 | + assert _contains_recursive_type_alias(ctx["tuple_name_name"]) is False |
| 967 | + assert _contains_recursive_type_alias(ctx["dict_name_name"]) is False |
| 968 | + assert _contains_recursive_type_alias(int) is False |
| 969 | + |
| 970 | + |
840 | 971 | def test_decimal_schema_type() -> None: |
841 | 972 | from litestar._openapi.schema_generation.schema import create_schema_for_annotation |
842 | 973 |
|
|
0 commit comments