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

Allow lists to be used as models for validate_request and validate_response #67

Closed bselman1 closed 7 months ago

bselman1 commented 10 months ago

Addresses Issue #16.

Change how validate_request and validate_response validate if the provided model is the same as the model provided in the decorator. For BaseModel and RootModel instances, we can use the validate_python method to do the validation. In all other cases, we'll instead use a pydantic TypeAdapter which will attempt to build a validator for the requested model.

These changes allow us to use the following methods for declaring a method that expects a list of items:

import asyncio
from pydantic import BaseModel, RootModel
from quart import Quart
from quart_schema import QuartSchema, validate_request
from typing import Optional

class Details(BaseModel):
    name: str
    age: Optional[int] = None

class Item(BaseModel):
    count: int
    details: Details

class ItemCollection(RootModel):
    root: list[Item]

def create_app():
    app = Quart(__name__)
    quart_schema = QuartSchema(
        app, 
        swagger_ui_path = '/api/docs',
        openapi_path = '/api/openapi.json',
        redoc_ui_path = None
    )

    @app.route("/items", methods = ["POST"])
    @validate_request(ItemCollection)
    async def handle_items_collection(data: ItemCollection):
        return f"{type(data)}"

    @app.route("/items-list", methods = ["POST"])
    @validate_request(list[Item])
    async def handle_items_list(data: list[Item]):
        return f"{type(data)}"

    @app.route("/items-root-model", methods = ["POST"])
    @validate_request(RootModel[list[Item]])
    async def handle_items_root_model(data: RootModel[list[Item]]):
        return f"{type(data)}"

    return app

async def do_tests(app: Quart):
    items = [
        { "count": 2, "details": { "name": "bob" } },
        { "count": 2, "details": { "name": "jane" } }
    ]
    test_client = app.test_client()
    response = await test_client.post("/items", json=items)
    assert response.status_code == 200
    assert "<class '__main__.ItemCollection'>" == await response.get_data(as_text = True)

    test_client = app.test_client()
    response = await test_client.post("/items-list", json=items)
    assert response.status_code == 200
    assert "<class 'list'>" == await response.get_data(as_text = True)

    test_client = app.test_client()
    response = await test_client.post("/items-root-model", json=items)
    assert response.status_code == 200
    assert "<class 'pydantic.root_model.RootModel[list[Item]]'>" == await response.get_data(as_text = True)

def main():
    app = create_app()
    asyncio.run(do_tests(app))
    # Uncomment if you want to actually start the web server to view the documented API
    ## app.run(debug = True)

if __name__ ==  '__main__':
    main()

Additional tests have been added to validate cases where lists or RootModels are passed to validate_request and validate_response.

Note that this PR doesn't make any changes to the header validation or query string validation. I wanted to get an idea if this altered way of validating makes sense before making too many changes.

pgjones commented 7 months ago

Thanks, I've gone for a very similar approach. I've not documented this yet, as it requires further testing, but please give it a go and let me know your thoughts.