pgjones / quart-schema

Quart-Schema is a Quart extension that provides schema validation and auto-generated API documentation.
MIT License
76 stars 24 forks source link

Support of "lists" or "arrays" in the query parameters #39

Closed maxyz closed 1 year ago

maxyz commented 1 year ago

I would like to use a querystring with a parameter that's a list.

I've done:

@dataclass
class QueryArgs:
    key: list[str] = field(default_factory=list)

@blueprint.get("/query")
@validate_querystring(Query)
async def query(
    query_args: Query
) -> ResponseReturnValue:
    current_app.logger.debug(query_args)
    return "", 202

Which generates the documentation as expected, but when you try to use it with:

curl -X 'GET' 'http://localhost:8000/query?key=string1&key=string2&test'  -H 'accept: */*'

I get

{"errors":"[\n  {\n    \"loc\": [\n      \"key\"\n    ],\n    \"msg\": \"value is not a valid list\",\n    \"type\": \"type_error.list\"\n  }\n]"}

I could workaround this using:

from quart import Request
from werkzeug.datastructures import ImmutableMultiDict
from werkzeug.exceptions import BadRequestKeyError

class ParameterStorage(ImmutableMultiDict):
    def __getitem__(self, key):
        """Return the data values for this key; or the first item if there is only one item in the list
        raises KeyError if not found.

        :param key: The key to be looked up.
        :raise KeyError: if the key does not exist.
        """

        if key in self:
            lst = dict.__getitem__(self, key)
            if (lst_len := len(lst)) == 1:
                return lst[0]
            if lst_len > 1:
                return lst
        raise BadRequestKeyError(key)

Request.parameter_storage_class = ParameterStorage

Should that be part of quart-schema? Should it be fixed in the werkzeug directly?

maxyz commented 1 year ago

Still it won't fix a querystring ?key=string1 -> so it would also need to modify key to list[str] | str. Which is, not particularly nice. :/

lukaskiss222 commented 1 year ago

You can check type of the key. If the required type is the list.

maxyz commented 1 year ago

You can check type of the key. If the required type is the list.

Ok, I made a pr (#44) following this idea. I had a couple of minor problems, the main one was that pydantic_dataclass was marking the field as required, so I had to switch to use create_pydantic_model_from_dataclass, which will be no longer exported in pydantic 2.0, so a better solution will be needed (the weird thing is that if I don't add a value to the model then it's tagged as required: false, but if the value is present then it's tagged as required: true, I guess this is due to an optimization in pydantic).

I also added the deps needed to run the tests to the dev dependencies.

Anyway, it's a wip. Please let me know if that's what you had in mind.

Drachenfels commented 1 year ago

I fixed this issue by overriding validate_querystring as the current implementation is well let's use word 'lacking'.

I copied this code: https://github.com/pgjones/quart-schema/blob/main/src/quart_schema/validation.py#L56 Using knowledge from this answer to question: https://stackoverflow.com/questions/75165745/cannot-determine-if-type-of-field-in-a-pydantic-model-is-of-type-list#answer-75166583

Basically, before we pass query arguments to pydantic model, we check if they are lists and if so access it via getlist(key) vs request_args[key], which can be expanded to support tuples, sets, etc.

End result:

from functools import wraps
from typing import Any, Callable

from humps import decamelize
from pydantic import ValidationError
from pydantic.fields import SHAPE_LIST
from pydantic.schema import model_schema
from quart import current_app, request
from quart_schema.typing import Model
from quart_schema.validation import (
    QUART_SCHEMA_QUERYSTRING_ATTRIBUTE,
    QuerystringValidationError,
    SchemaInvalidError,
    _to_pydantic_model,
)

def validate_querystring(model_class: Model) -> Callable:
    """Validate the request querystring arguments.

    This ensures that the query string arguments can be converted to
    the *model_class*. If they cannot a `RequestSchemaValidationError`
    is raised which by default results in a 400 response.

    Arguments:
        model_class: The model to use, either a dataclass, pydantic
            dataclass or a class that inherits from pydantic's
            BaseModel. All the fields must be optional.
    """
    model_class = _to_pydantic_model(model_class)
    schema = model_schema(model_class)

    if len(schema.get("required", [])) != 0:
        raise SchemaInvalidError("Fields must be optional")

    def decorator(func: Callable) -> Callable:
        setattr(func, QUART_SCHEMA_QUERYSTRING_ATTRIBUTE, model_class)

        @wraps(func)
        async def wrapper(*args: Any, **kwargs: Any) -> Any:
            if current_app.config["QUART_SCHEMA_CONVERT_CASING"]:
                request_args = decamelize(request.args)
            else:
                request_args = request.args

            parsed_args = {}

            for key in request_args:
                if model_class.__fields__[key].shape == SHAPE_LIST:
                    parsed_args[key] = request_args.getlist(key)
                else:
                    parsed_args[key] = request_args[key]

            try:
                model = model_class(**parsed_args)
            except (TypeError, ValidationError) as error:
                raise QuerystringValidationError(error)
            else:
                return await current_app.ensure_async(func)(*args, query_args=model, **kwargs)

        return wrapper

    return decorator
pgjones commented 1 year ago

I've implemented a solution in c2d20688fc6470429f5f045c8426dee3944fc054.