diff --git a/docs/en/docs/img/tutorial/query-params/image01.png b/docs/en/docs/img/tutorial/query-params/image01.png
new file mode 100644
index 000000000..6222a9aef
Binary files /dev/null and b/docs/en/docs/img/tutorial/query-params/image01.png differ
diff --git a/docs/en/docs/tutorial/query-params.md b/docs/en/docs/tutorial/query-params.md
index bc3b11948..508ab8c9a 100644
--- a/docs/en/docs/tutorial/query-params.md
+++ b/docs/en/docs/tutorial/query-params.md
@@ -225,3 +225,45 @@ In this case, there are 3 query parameters:
!!! tip
You could also use `Enum`s the same way as with [Path Parameters](path-params.md#predefined-values){.internal-link target=_blank}.
+
+## Free Form Query Parameters
+
+Sometimes you want to receive some query parameters, but you don't know in advance what they are called. **FastAPI** provides support for this use case.
+
+=== "Python 3.10+"
+
+ ```Python hl_lines="8"
+ {!> ../../../docs_src/query_params/tutorial007_py310.py!}
+ ```
+
+And when you open your browser at http://127.0.0.1:8000/docs, you will that OpenAPI supports this format of query parameter:
+
+
+
+However, since the query parameters are declared in the request as
+
+```
+http://127.0.0.1:8000/query/mixed-type-params?query=1&foo=bar&foo=baz
+```
+
+**FastAPI** greedily adds all the query parameters to every `Query` argument for which it is valid. The above request will be parsed as:
+
+```Python
+{
+ "query": 1,
+ "string_mapping": {
+ "query": "1",
+ "foo": "baz"
+ },
+ "mapping_query_int": {
+ "query": 1
+ },
+ "sequence_mapping_queries": {
+ "query": [
+ "1"
+ ],
+ "foo": []
+ }
+}
+```
+As you can see the `query` parameter is added to every `Query` argument for which it is valid. This is because **FastAPI** does not know which `Query` argument you want to add the `query` parameter to, and `1` validates as both an `int` and a `str`. `List[str]`. `foo` is only added to the `string_mapping` and `sequence_mapping_queries` arguments because it is not a valid `int`.
diff --git a/docs_src/query_params/tutorial007_py310.py b/docs_src/query_params/tutorial007_py310.py
new file mode 100644
index 000000000..7d0ffca04
--- /dev/null
+++ b/docs_src/query_params/tutorial007_py310.py
@@ -0,0 +1,20 @@
+from typing import List, Mapping
+
+from fastapi import FastAPI, Query
+
+app = FastAPI()
+
+
+@app.get("/query/mixed-type-params")
+def get_mixed_mapping_mixed_type_query_params(
+ query: int = Query(),
+ mapping_query_str: Mapping[str, str] = Query({}),
+ mapping_query_int: Mapping[str, int] = Query({}),
+ sequence_mapping_queries: Mapping[str, List[int]] = Query({}),
+):
+ return {
+ "query": query,
+ "string_mapping": mapping_query_str,
+ "mapping_query_int": mapping_query_int,
+ "sequence_mapping_queries": sequence_mapping_queries,
+ }
diff --git a/fastapi/_compat.py b/fastapi/_compat.py
index 35d4a8723..ab98c3e6d 100644
--- a/fastapi/_compat.py
+++ b/fastapi/_compat.py
@@ -18,7 +18,7 @@ from typing import (
)
from fastapi.exceptions import RequestErrorModel
-from fastapi.types import IncEx, ModelNameMap, UnionType
+from fastapi.types import IncEx, ModelNameMap, UnionType, FFQuery
from pydantic import BaseModel, create_model
from pydantic.version import VERSION as PYDANTIC_VERSION
from starlette.datastructures import UploadFile
@@ -43,6 +43,13 @@ sequence_annotation_to_type = {
sequence_types = tuple(sequence_annotation_to_type.keys())
+mapping_annotation_to_type = {
+ FFQuery: list,
+}
+
+mapping_types = tuple(mapping_annotation_to_type.keys())
+
+
if PYDANTIC_V2:
from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError
from pydantic import TypeAdapter
@@ -242,6 +249,12 @@ if PYDANTIC_V2:
def is_scalar_sequence_field(field: ModelField) -> bool:
return field_annotation_is_scalar_sequence(field.field_info.annotation)
+ def is_scalar_sequence_mapping_field(field: ModelField) -> bool:
+ return field_annotation_is_scalar_sequence_mapping(field.field_info.annotation)
+
+ def is_scalar_mapping_field(field: ModelField) -> bool:
+ return field_annotation_is_scalar_mapping(field.field_info.annotation)
+
def is_bytes_field(field: ModelField) -> bool:
return is_bytes_or_nonable_bytes_annotation(field.type_)
@@ -294,6 +307,7 @@ else:
from pydantic.fields import ( # type: ignore[attr-defined]
SHAPE_FROZENSET,
SHAPE_LIST,
+ SHAPE_MAPPING,
SHAPE_SEQUENCE,
SHAPE_SET,
SHAPE_SINGLETON,
@@ -341,6 +355,7 @@ else:
SHAPE_SEQUENCE,
SHAPE_TUPLE_ELLIPSIS,
}
+
sequence_shape_to_type = {
SHAPE_LIST: list,
SHAPE_SET: set,
@@ -349,6 +364,11 @@ else:
SHAPE_TUPLE_ELLIPSIS: list,
}
+ mapping_shapes = {
+ SHAPE_MAPPING,
+ }
+ mapping_shapes_to_type = {SHAPE_MAPPING: FFQuery}
+
@dataclass
class GenerateJsonSchema: # type: ignore[no-redef]
ref_template: str
@@ -416,6 +436,28 @@ else:
return True
return False
+ def is_pv1_scalar_mapping_field(field: ModelField) -> bool:
+ if (field.shape in mapping_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_scalar_field(sub_field):
+ return False
+ return True
+ return False
+
+ def is_pv1_scalar_sequence_mapping_field(field: ModelField) -> bool:
+ if (field.shape in mapping_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_scalar_sequence_field(sub_field):
+ return False
+ return True
+ return False
+
def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]:
use_errors: List[Any] = []
for error in errors:
@@ -486,6 +528,12 @@ else:
def is_scalar_sequence_field(field: ModelField) -> bool:
return is_pv1_scalar_sequence_field(field)
+ def is_scalar_sequence_mapping_field(field: ModelField) -> bool:
+ return is_pv1_scalar_sequence_mapping_field(field)
+
+ def is_scalar_mapping_field(field: ModelField) -> bool:
+ return is_pv1_scalar_mapping_field(field)
+
def is_bytes_field(field: ModelField) -> bool:
return lenient_issubclass(field.type_, bytes)
@@ -535,14 +583,27 @@ def field_annotation_is_sequence(annotation: Union[Type[Any], None]) -> bool:
)
+def _annotation_is_mapping(annotation: Union[Type[Any], None]) -> bool:
+ if lenient_issubclass(annotation, (str, bytes)):
+ return False
+ return lenient_issubclass(annotation, mapping_types)
+
+
+def field_annotation_is_mapping(annotation: Union[Type[Any], None]) -> bool:
+ return _annotation_is_mapping(annotation) or _annotation_is_mapping(
+ 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: Union[Type[Any], None]) -> bool:
return (
- lenient_issubclass(annotation, (BaseModel, Mapping, UploadFile))
+ lenient_issubclass(annotation, (BaseModel, UploadFile))
or _annotation_is_sequence(annotation)
+ or _annotation_is_mapping(annotation)
or is_dataclass(annotation)
)
@@ -573,8 +634,6 @@ def field_annotation_is_scalar_sequence(annotation: Union[Type[Any], None]) -> b
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)
@@ -582,6 +641,22 @@ def field_annotation_is_scalar_sequence(annotation: Union[Type[Any], None]) -> b
)
+def field_annotation_is_scalar_mapping(annotation: Union[Type[Any], None]) -> bool:
+ return field_annotation_is_mapping(annotation) and all(
+ field_annotation_is_scalar(sub_annotation)
+ for sub_annotation in get_args(annotation)
+ )
+
+
+def field_annotation_is_scalar_sequence_mapping(
+ annotation: Union[Type[Any], None]
+) -> bool:
+ return field_annotation_is_mapping(annotation) and all(
+ field_annotation_is_scalar_sequence(sub_annotation)
+ for sub_annotation in get_args(annotation)[1:]
+ )
+
+
def is_bytes_or_nonable_bytes_annotation(annotation: Any) -> bool:
if lenient_issubclass(annotation, bytes):
return True
diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index b73473484..b5e1aa8d9 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -1,4 +1,5 @@
import inspect
+from collections import defaultdict
from contextlib import AsyncExitStack, contextmanager
from copy import deepcopy
from typing import (
@@ -35,7 +36,9 @@ from fastapi._compat import (
is_bytes_field,
is_bytes_sequence_field,
is_scalar_field,
+ is_scalar_mapping_field,
is_scalar_sequence_field,
+ is_scalar_sequence_mapping_field,
is_sequence_field,
is_uploadfile_or_nonable_uploadfile_annotation,
is_uploadfile_sequence_annotation,
@@ -463,6 +466,11 @@ def is_body_param(*, param_field: ModelField, is_path_param: bool) -> bool:
param_field.field_info, (params.Query, params.Header)
) and is_scalar_sequence_field(param_field):
return False
+ elif isinstance(param_field.field_info, params.Query) and (
+ is_scalar_sequence_mapping_field(param_field)
+ or is_scalar_mapping_field(param_field)
+ ):
+ return False
else:
assert isinstance(
param_field.field_info, params.Body
@@ -647,6 +655,10 @@ async def solve_dependencies(
return values, errors, background_tasks, response, dependency_cache
+class Marker:
+ pass
+
+
def request_params_to_args(
required_params: Sequence[ModelField],
received_params: Union[Mapping[str, Any], QueryParams, Headers],
@@ -658,6 +670,16 @@ def request_params_to_args(
received_params, (QueryParams, Headers)
):
value = received_params.getlist(field.alias) or field.default
+ elif is_scalar_mapping_field(field) and isinstance(
+ received_params, QueryParams
+ ):
+ value = dict(received_params.multi_items()) or field.default
+ elif is_scalar_sequence_mapping_field(field) and isinstance(
+ received_params, QueryParams
+ ):
+ value = defaultdict(list)
+ for k, v in received_params.multi_items():
+ value[k].append(v)
else:
value = received_params.get(field.alias)
field_info = field.field_info
@@ -674,6 +696,31 @@ def request_params_to_args(
v_, errors_ = field.validate(value, values, loc=loc)
if isinstance(errors_, ErrorWrapper):
errors.append(errors_)
+ elif (
+ isinstance(errors_, list)
+ and is_scalar_sequence_mapping_field(field)
+ and isinstance(received_params, QueryParams)
+ ):
+ new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
+ # remove all invalid parameters
+ marker = Marker()
+ for _, _, key, index in [error["loc"] for error in new_errors]:
+ value[key][index] = marker
+ for key in value:
+ value[key] = [x for x in value[key] if x != marker]
+ v_, _ = field.validate(value, values, loc=loc)
+ values[field.name] = v_
+ elif (
+ isinstance(errors_, list)
+ and is_scalar_mapping_field(field)
+ and isinstance(received_params, QueryParams)
+ ):
+ new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
+ # remove all invalid parameters
+ for _, _, key in [error["loc"] for error in new_errors]:
+ value.pop(key)
+ v_, _ = field.validate(value, values, loc=loc)
+ values[field.name] = v_
elif isinstance(errors_, list):
new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
errors.extend(new_errors)
diff --git a/fastapi/types.py b/fastapi/types.py
index 3205654c7..4b680bf3e 100644
--- a/fastapi/types.py
+++ b/fastapi/types.py
@@ -8,3 +8,4 @@ DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any])
UnionType = getattr(types, "UnionType", Union)
ModelNameMap = Dict[Union[Type[BaseModel], Type[Enum]], str]
IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]]
+FFQuery = Dict[str, Union[str, IncEx]]
\ No newline at end of file
diff --git a/tests/main.py b/tests/main.py
index 15760c039..52b70ed2a 100644
--- a/tests/main.py
+++ b/tests/main.py
@@ -1,7 +1,8 @@
import http
-from typing import FrozenSet, Optional
+from typing import FrozenSet, List, Mapping, Optional, Union
from fastapi import FastAPI, Path, Query
+from fastapi.types import FFQuery
app = FastAPI()
@@ -184,6 +185,38 @@ def get_query_param_required_type(query: int = Query()):
return f"foo bar {query}"
+@app.get("/query/mapping-params")
+def get_mapping_query_params(queries: FFQuery[str, str] = Query({})):
+ return f"foo bar {queries['foo']} {queries['bar']}"
+
+
+@app.get("/query/mapping-sequence-params")
+def get_sequence_mapping_query_params(queries: FFQuery[str, List[int]] = Query({})):
+ return f"foo bar {dict(queries)}"
+
+
+@app.get("/query/mixed-params")
+def get_mixed_mapping_query_params(
+ sequence_mapping_queries: FFQuery[str, List[Union[str, int]]] = Query({}),
+ mapping_query: FFQuery[str, str] = Query(),
+ query: str = Query(),
+):
+ return (
+ f"foo bar {sequence_mapping_queries['foo'][0]} {sequence_mapping_queries['foo'][1]} "
+ f"{mapping_query['foo']} {mapping_query['bar']} {query}"
+ )
+
+
+@app.get("/query/mixed-type-params")
+def get_mixed_mapping_mixed_type_query_params(
+ sequence_mapping_queries: FFQuery[str, List[int]] = Query({}),
+ mapping_query_str: FFQuery[str, str] = Query({}),
+ mapping_query_int: FFQuery[str, int] = Query({}),
+ query: int = Query(),
+):
+ return f"foo bar {query} {mapping_query_str} {mapping_query_int} {dict(sequence_mapping_queries)}"
+
+
@app.get("/enum-status-code", status_code=http.HTTPStatus.CREATED)
def get_enum_status_code():
return "foo bar"
diff --git a/tests/test_application.py b/tests/test_application.py
index ea7a80128..741d80182 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -1163,6 +1163,204 @@ def test_openapi_schema():
},
}
},
+ "/query/mapping-params": {
+ "get": {
+ "summary": "Get Mapping Query Params",
+ "operationId": "get_mapping_query_params_query_mapping_params_get",
+ "parameters": [
+ {
+ "required": False,
+ "schema": {
+ "additionalProperties": {"type": "string"},
+ "type": "object",
+ "title": "Queries",
+ "default": {},
+ },
+ "name": "queries",
+ "in": "query",
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ "/query/mapping-sequence-params": {
+ "get": {
+ "summary": "Get Sequence Mapping Query Params",
+ "operationId": "get_sequence_mapping_query_params_query_mapping_sequence_params_get",
+ "parameters": [
+ {
+ "required": False,
+ "schema": {
+ "additionalProperties": {
+ "items": {"type": "integer"},
+ "type": "array",
+ },
+ "type": "object",
+ "title": "Queries",
+ "default": {},
+ },
+ "name": "queries",
+ "in": "query",
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ "/query/mixed-params": {
+ "get": {
+ "summary": "Get Mixed Mapping Query Params",
+ "operationId": "get_mixed_mapping_query_params_query_mixed_params_get",
+ "parameters": [
+ {
+ "required": False,
+ "schema": {
+ "additionalProperties": {
+ "items": {
+ "anyOf": [
+ {"type": "string"},
+ {"type": "integer"},
+ ]
+ },
+ "type": "array",
+ },
+ "type": "object",
+ "title": "Sequence Mapping Queries",
+ "default": {},
+ },
+ "name": "sequence_mapping_queries",
+ "in": "query",
+ },
+ {
+ "required": True,
+ "schema": {
+ "additionalProperties": {"type": "string"},
+ "type": "object",
+ "title": "Mapping Query",
+ },
+ "name": "mapping_query",
+ "in": "query",
+ },
+ {
+ "required": True,
+ "schema": {"type": "string", "title": "Query"},
+ "name": "query",
+ "in": "query",
+ },
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
+ "/query/mixed-type-params": {
+ "get": {
+ "summary": "Get Mixed Mapping Mixed Type Query Params",
+ "operationId": "get_mixed_mapping_mixed_type_query_params_query_mixed_type_params_get",
+ "parameters": [
+ {
+ "required": False,
+ "schema": {
+ "additionalProperties": {
+ "items": {"type": "integer"},
+ "type": "array",
+ },
+ "type": "object",
+ "title": "Sequence Mapping Queries",
+ "default": {},
+ },
+ "name": "sequence_mapping_queries",
+ "in": "query",
+ },
+ {
+ "required": False,
+ "schema": {
+ "additionalProperties": {"type": "string"},
+ "type": "object",
+ "title": "Mapping Query Str",
+ "default": {},
+ },
+ "name": "mapping_query_str",
+ "in": "query",
+ },
+ {
+ "required": False,
+ "schema": {
+ "additionalProperties": {"type": "integer"},
+ "type": "object",
+ "title": "Mapping Query Int",
+ "default": {},
+ },
+ "name": "mapping_query_int",
+ "in": "query",
+ },
+ {
+ "required": True,
+ "schema": {"type": "integer", "title": "Query"},
+ "name": "query",
+ "in": "query",
+ },
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ },
},
"components": {
"schemas": {
diff --git a/tests/test_invalid_mapping_param.py b/tests/test_invalid_mapping_param.py
new file mode 100644
index 000000000..7a24953a9
--- /dev/null
+++ b/tests/test_invalid_mapping_param.py
@@ -0,0 +1,13 @@
+from typing import List, Mapping
+import pytest
+from fastapi import FastAPI, Query
+from fastapi.types import FFQuery
+
+
+def test_invalid_sequence():
+ with pytest.raises(AssertionError):
+ app = FastAPI()
+
+ @app.get("/items/")
+ def read_items(q: FFQuery[str, List[List[str]]] = Query(default=None)):
+ pass # pragma: no cover
diff --git a/tests/test_invalid_sequence_param.py b/tests/test_invalid_sequence_param.py
index 475786adb..cb61cfdce 100644
--- a/tests/test_invalid_sequence_param.py
+++ b/tests/test_invalid_sequence_param.py
@@ -1,4 +1,4 @@
-from typing import Dict, List, Optional, Tuple
+from typing import Dict, List, Tuple
import pytest
from fastapi import FastAPI, Query
@@ -39,15 +39,3 @@ def test_invalid_dict():
@app.get("/items/")
def read_items(q: Dict[str, Item] = Query(default=None)):
pass # pragma: no cover
-
-
-def test_invalid_simple_dict():
- with pytest.raises(AssertionError):
- app = FastAPI()
-
- class Item(BaseModel):
- title: str
-
- @app.get("/items/")
- def read_items(q: Optional[dict] = Query(default=None)):
- pass # pragma: no cover
diff --git a/tests/test_query.py b/tests/test_query.py
index 5bb9995d6..9281cafdb 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -408,3 +408,36 @@ 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"
+
+
+def test_mapping_query():
+ response = client.get("/query/mapping-params/?foo=fuzz&bar=buzz")
+ assert response.status_code == 200
+ assert response.json() == "foo bar fuzz buzz"
+
+
+def test_mapping_with_non_mapping_query():
+ response = client.get("/query/mixed-params/?foo=fuzz&foo=baz&bar=buzz&query=fizz")
+ assert response.status_code == 200
+ assert response.json() == "foo bar fuzz baz baz buzz fizz"
+
+
+def test_mapping_with_non_mapping_query_mixed_types():
+ response = client.get("/query/mixed-type-params/?foo=fuzz&foo=baz&bar=buzz&query=1")
+ assert response.status_code == 200
+ assert (
+ response.json()
+ == "foo bar 1 {'foo': 'baz', 'bar': 'buzz', 'query': '1'} {'query': 1} {'foo': [], 'bar': [], 'query': [1]}"
+ )
+
+
+def test_sequence_mapping_query():
+ response = client.get("/query/mapping-sequence-params/?foo=1&foo=2")
+ assert response.status_code == 200
+ assert response.json() == "foo bar {'foo': [1, 2]}"
+
+
+def test_sequence_mapping_query_drops_invalid():
+ response = client.get("/query/mapping-sequence-params/?foo=fuzz&foo=buzz")
+ assert response.status_code == 200
+ assert response.json() == "foo bar {'foo': []}"
diff --git a/tests/test_tutorial/test_query_params/test_tutorial007_py310.py b/tests/test_tutorial/test_query_params/test_tutorial007_py310.py
new file mode 100644
index 000000000..a6dfc140f
--- /dev/null
+++ b/tests/test_tutorial/test_query_params/test_tutorial007_py310.py
@@ -0,0 +1,21 @@
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from docs_src.query_params.tutorial007_py310 import app
+
+ c = TestClient(app)
+ return c
+
+
+def test_foo_needy_very(client: TestClient):
+ response = client.get("/query/mixed-type-params?query=1&query=2&foo=bar&foo=baz")
+ assert response.status_code == 200
+ assert response.json() == {
+ "query": 2,
+ "string_mapping": {"query": "2", "foo": "baz"},
+ "mapping_query_int": {"query": 2},
+ "sequence_mapping_queries": {"query": [1, 2], "foo": []},
+ }